操作系统学习笔记——第一章
第一章 操作系统的启动
计算机启动的大致概念(bootsec模块剖析)
核心关注
计算模型中,需重点关注指针IP/PC及其指向的内容
计算机开机时IP的初始值
计算机刚开机时IP的值,由硬件设计者决定(以下以x86 PC为例)
x86 PC开机启动(实模式)流程
注:实模式寻址方式为 CS左移4位 + IP = CS * 16 + IP(与保护模式寻址逻辑不同)
- 开机初始状态:CPU处于实模式
- 初始寄存器值:
CS=0xFFFF,IP=0x0000 - 寻址结果:通过实模式寻址,定位到内存地址
0xFFFF0(ROM BIOS映射区) - BIOS执行自检:检查RAM、键盘、显示器、软硬磁盘等硬件
- 加载引导扇区:将磁盘0磁道0扇区的内容,读入内存
0x7c00处 - 更新CS:IP:设置
CS=0x07c0、IP=0x0000,后续执行引导扇区代码
操作系统课程笔记:引导扇区代码(bootsect.s)
▶ 1. 伪操作符与段声明
.globl:声明全局符号(如begtext、begdata等),供链接器识别.text/.data/.bss:是伪操作符,分别标识文本段(代码)、数据段、未初始化数据段的开始- 注意:此处这3个段是重叠、不分段的
entry start:关键字entry告诉链接器“程序入口是start”,而start对应的代码就是之前引导扇区被读入的0x7c00处的语句
▶ 2. 常量定义
代码中用到的段地址常量:
BOOTSEG = 0x07c0:引导扇区初始加载的段地址INITSEG = 0x9000:引导扇区要迁移到的目标段地址SETUPSEG = 0x9020:后续setup程序的段地址
▶ 3. start代码功能(引导扇区核心操作)
start:
mov ax, #BOOTSEG mov ds, ax ; 把数据段寄存器ds设为引导扇区当前段地址(0x7c0)
mov ax, #INITSEG mov es, ax ; 把附加段寄存器es设为目标段地址(0x9000)
mov cx, #256 ; 设置拷贝字数:256字(对应512字节,即引导扇区大小)
sub si, si sub di, di ; 源/目的变址寄存器清零(源地址:ds:si=0x7c0:0000;目的地址:es:di=0x9000:0000)
rep movw ; 重复执行字拷贝:把引导扇区从0x7c00处迁移到0x90000处
jmpi go, INITSEG ; 跳转到INITSEG段下的go标签(即跳转到迁移后的代码位置继续执行)
▶ 代码功能解析
➤ 一、sub si, si / sub di, di 的本质含义
sub si, si
sub di, di
这两条指令的作用是:
si ← si - si = 0di ← di - di = 0
即:将 SI 和 DI 寄存器清零
等价写法为:
mov si, 0
mov di, 0
注意:
这里清零的是寄存器本身,而不是任何内存地址中的内容。
➤ 二、为什么不用 mov si, 0?
在 8086 及早期 x86 汇编中,清零寄存器有多种常见写法:
mov si, 0
xor si, si
sub si, si
其中:
-
sub si, si和xor si, si- 不需要立即数
- 指令长度较短
- 执行效率较高
因此在早期操作系统源码(如 Linux 0.11 的引导代码)中,这是一种非常典型、规范的寄存器清零写法。
➤ 三、结合上下文理解这两条指令
引导扇区中的相关代码如下:
mov ax, #BOOTSEG
mov ds, ax ; DS = 0x07c0
mov ax, #INITSEG
mov es, ax ; ES = 0x9000
mov cx, #256 ; 拷贝 256 个字(512 字节)
sub si, si ; SI = 0
sub di, di ; DI = 0
rep movw
此时:
DS保存源段地址SI保存源段内偏移ES保存目标段地址DI保存目标段内偏移
因而:
- 源地址寄存器对为
DS:SI - 目的地址寄存器对为
ES:DI
➤ 四、rep movw 的工作机制
指令 movw 的含义是:
将一个字(16 位)从
DS:SI指向的内存地址
复制到ES:DI指向的内存地址
执行一次 movw 后:
SI += 2DI += 2
指令 rep movw 表示:
在
CX ≠ 0(CX就是上面设置的拷贝字数)时,重复执行movw
其逻辑等价于:
while (CX != 0) {
*(ES:DI) = *(DS:SI);
SI += 2;
DI += 2;
CX--;
}
因此,真正发生内存读写操作的是 movw,
而不是前面的 sub si, si 或 sub di, di。
➤ 五、为什么必须把 SI 和 DI 清零?
执行清零后:
DS = 0x07c0,SI = 0x0000ES = 0x9000,DI = 0x0000
在 8086 实模式 下:
物理地址 = 段寄存器 × 16 + 偏移寄存器
因而常用记号表示为:
- 源地址:
DS:SI = 0x07c0(段寄存器):0000(偏移寄存器) - 目的地址:
ES:DI = 0x9000(段寄存器):0000(偏移寄存器)
该写法的含义是:
描述当前段寄存器与偏移寄存器组合后所形成的地址,
而不是对内存内容进行赋值或清零。
转换为物理地址(实模式):
-
源物理地址:
使用 DS:SI 时:
物理地址 = DS × 16 + SI0x07c0 × 16 + 0x0000 = 0x7c00 -
目的物理地址:
使用 ES:DI 时:
物理地址 = ES × 16 + DI
0x9000 × 16 + 0x0000 = 0x90000
因此,rep movw 的整体作用是:
将整个 512 字节的引导扇区,从 BIOS 加载位置
0x7c00
复制到新的内存位置0x90000
➤ 六、总结
sub si, si 与 sub di, di 是一种经典的寄存器清零写法,用于将 SI 和 DI 置为 0,从而使地址表示 DS:SI 与 ES:DI 分别指向源段和目标段的段首地址,使 rep movw 能够从段首开始完成整个引导扇区的内存复制。
bootsect.s 代码解析(引导扇区核心操作)
bootsect.s:从 jmpi 到 setup 执行前的完整逻辑
➤ 一、jmpi 指令:段间跳转的作用与时机
▪ 1. 指令形式
jmpi go, INITSEG
▪ 2. 指令性质
-
jmpi是 段间跳转(far jump / intersegment jump) -
一次性修改:
CS ← INITSEGIP ← go
▪ 3. 执行结果
假设:
INITSEG = 0x9000
执行后:
CS = 0x9000IP = go
CPU 从 0x9000:go 开始继续执行代码。
▪ 4. 为什么必须用 jmpi
bootsect 最初由 BIOS 加载在 0x7C00 附近:
- 段寄存器不统一
- 栈环境混乱
- 不适合继续复杂逻辑
因此第一件事就是:
把引导代码“搬家”并通过
jmpi正式切换到新的段环境
➤ 二、go 标签:统一段寄存器 + 初始化栈
▪ 1. 代码
go:
mov ax, cs ; 此时 cs = INITSEG = 0x9000
mov ds, ax
mov es, ax
mov ss, ax
mov sp, #0xff00
▪ 2. 每条指令的意义
| 指令 | 作用 |
|---|---|
mov ax, cs |
取当前代码段 |
mov ds, ax |
数据段 = 代码段 |
mov es, ax |
附加段 = 代码段 |
mov ss, ax |
栈段 = 代码段 |
mov sp, #0xff00 |
初始化栈指针 |
▪ 3. 整体效果
- 所有段寄存器统一为
0x9000 - 栈位于
0x9000:FF00向下增长 - 后续可以安全使用
call / ret / int
➤ 三、load_setup:加载 setup 模块(磁盘 → 内存)
▪ 1. 常量定义(上下文)
SETUPLEN = 4 ; setup 占 4 个扇区
INITSEG = 0x9000
SETUPSEG = 0x9020 ; setup 目标段地址
setup 被加载到物理地址:
\(0x9020 \times 16 = 0x90200\)
▪ 2. load_setup 完整代码(整理版)
load_setup:
; 1. 设置磁盘参数
mov dx, #0x0000 ; dh=0 磁头号, dl=0 驱动器号(A:)
mov cx, #0x0002 ; ch=0 柱面号, cl=2 起始扇区
; 2. 设置内存目标地址
mov bx, #0x0200 ; ES:BX = 0x9000:0200 → 0x90200
; 3. 设置功能号 + 扇区数
mov ax, #0x0200 + SETUPLEN
; ah=0x02 读扇区
; al=4 读 4 个扇区
; 4. 调用 BIOS 磁盘中断
int 0x13
; 5. 检查是否成功
jnc ok_load_setup
; 6. 失败:复位磁盘并重试
mov ax, #0x0000 ; ah=0x00 磁盘复位
int 0x13
j load_setup
ok_load_setup:
; setup 已成功加载到内存
▪ 3. int 0x13(磁盘中断)的核心意义
▫ 为什么必须用 BIOS 中断
- 实模式下 CPU 不能直接操作磁盘硬件
- 唯一可行方式:调用 BIOS 提供的中断服务
▫ 本次调用完成的任务
-
从启动盘:
- 第 2~5 扇区
-
读取:
- setup 模块(4 个扇区)
-
存放到:
- 内存 0x90200
▫ 容错机制
-
通过 CF(进位标志) 判断成败
-
失败则:
- 磁盘复位
- 重新读取
➤ 四、启动盘结构(逻辑视图)
┌───────────────┐
│ boot 扇区 │ 第 1 扇区
│ bootsect.s │
└───────────────┘
┌───────────────┐
│ setup 模块 │ 第 2~5 扇区
│ 共 4 个扇区 │
└───────────────┘
┌───────────────┐
│ system 模块 │ 第 6 扇区起
│ OS 核心代码 │
└───────────────┘
➤ 五、bootsect 后半段逻辑(setup 执行前)
▪ 1. 获取磁盘参数(int 0x13 / AH=08)
mov dl, #0x00
mov ax, #0x0800
int 0x13
mov sectors, cx
用途:
- 获取 每磁道扇区数
- 为后续读取 system 模块做准备
▪ 2. 显示启动提示(int 0x10)
▫ 读光标位置
mov ah, #0x03
xor bh, bh
int 0x10
▫ 显示字符串
mov cx, #24
mov bx, #0x0007
mov bp, #msg1
mov ax, #0x1301
int 0x10
显示效果:
Loading system...
▪ 3. 读取 system 模块
mov ax, #SYSSEG
mov es, ax
call read_it
▪ 4. 跳转到 setup 模块执行
jmpi 0, SETUPSEG
执行结果:
CS = SETUPSEGIP = 0- 开始执行 setup 模块
➤ 六、bootsect.s 数据区
sectors: .word 0
msg1:
.byte 13,10
.ascii "Loading system..."
➤ 七、boot 扇区整体职责总结
bootsect.s 只做引导,不做初始化内核:
- 建立干净的段 / 栈环境
- 读取 setup 模块
- 获取磁盘参数
- 显示启动提示
- 加载 system 到内存
- 跳转到 setup
boot → setup → system
这是 Linux 启动链条的最小闭环。
setup 模块剖析(启动阶段)
一、setup 模块的整体定位(主线先行)
setup 模块是引导过程中的过渡阶段代码,位于:
- bootsect 之后
- system(内核)之前
其核心任务不是“运行操作系统”,而是:
为内核正式启动准备运行环境
二、setup 模块的三项核心职责(一定要记)
setup 模块主要完成以下工作:
- 获取并保存 BIOS 提供的硬件参数
- 关闭中断并完成必要的 CPU 状态初始化
- 将 system 模块整体迁移到内存 0 地址
这三点就是 setup 的课程主线,后面的所有代码都围绕它们展开。
三、start 段:获取硬件参数并完成初始化
▶ 1. 初始化数据段寄存器
mov ax,#INITSEG
mov ds,ax
含义:
- 设置
DS = 0x9000- 后续所有
[偏移]内存访问,统一存放到 0x90000 起始的内存区域这块区域相当于:
内核启动前的“硬件参数缓冲区”
▶ 2. 获取光标位置(BIOS 中断)
mov ah,#0x03
xor bh,bh
int 0x10
mov [0],dx
说明:
int 0x10是 BIOS 显示服务- 功能号
0x03:获取当前光标位置- 返回结果保存在
DX存储位置:
DS:0→ 物理地址0x90000目的:
让内核在启动后知道屏幕当前状态
▶ 3. 获取扩展内存大小(BIOS 中断)
mov ah,#0x88
int 0x15
mov [2],ax
说明:
int 0x15是 BIOS 系统服务- 功能号
0x88:查询扩展内存大小- 返回值保存在
AX存储位置:
DS:2→ 物理地址0x90002目的:
为内核后续内存管理提供基础信息
▶ 4. 关闭中断并初始化 CPU 状态
cli
mov ax,#0x0000
cld
作用说明:
cli:禁止中断
- 启动阶段不允许被外部中断打断
cld:清除方向标志
- 确保串操作指令(如
rep movsw)按 地址递增方向 执行这一步属于:
为后续内存搬移操作做准备
四、do_move 段:迁移 system 模块到内存 0 地址
▶ 1. 为什么要迁移 system 模块?
system(内核)最初被加载在较高内存地址
而内核设计上:要求从内存 0 地址开始运行
因此 setup 的关键任务之一是:
把 system 模块整体搬到低地址内存
▶ 2. 迁移逻辑的总体思路(比代码重要)
system 模块的迁移采用:
- 按段(64KB)逐段搬移
- 每次使用串操作指令完成批量拷贝
▶ 3. 关键代码与含义(理解级即可)
do_move:
mov es,ax ; 设置目的段
add ax,#0x1000 ; 指向下一个 64KB 段
cmp ax,#0x9000
jz end_move
mov ds,ax ; 设置源段
sub di,di
sub si,si
mov cx,#0x8000
rep movsw
jmp do_move
核心含义总结:
ES:DI:指向目标内存区域(低地址)DS:SI:指向 system 模块当前所在段rep movsw:完成大块内存复制循环执行直到 system 模块全部搬移完成
▶ 4. 为什么每次拷贝 0x8000 个字?
0x8000字 = 32KB- 配合段切换,逐步完成整个 system 模块迁移
这是实现细节,理解“分块搬移”即可
五、硬件参数存储表(内核与 setup 的接口)
setup 模块将 BIOS 获取的硬件参数统一存放在:
物理地址
0x90000开始的一段内存区域内核启动后将直接从这里读取信息。
| 物理地址 | 长度 | 含义 |
|---|---|---|
| 0x90000 | 2 | 光标位置 |
| 0x90002 | 2 | 扩展内存大小 |
| 0x9000C | 2 | 显卡参数 |
| 0x901FC | 2 | 根设备号 |
进入保护模式(Protected Mode)
一、为什么要进入保护模式
实模式存在的主要限制:
- 地址空间仅 20 位(1MB)
- 无内存保护机制
- 无特权级划分
- 无法支持现代操作系统的内存管理与多任务
因此,操作系统在启动早期必须从 实模式切换到保护模式。
二、进入保护模式前必须完成的三项准备工作
进入保护模式不是一条指令就能完成的,需要依次完成以下步骤:
- 打开 A20 地址线
- 初始化中断控制器(8259A)
- 设置 CR0 并执行远跳转
三、步骤一:打开 A20 地址线
▶ 1. 为什么要打开 A20
在早期 8086 体系中,为了兼容性,地址线 A20 默认被关闭:
- 地址超过 1MB 会回绕到低地址(地址回卷)
保护模式需要线性地址空间,因此必须 显式打开 A20。
▶ 2. 控制 A20 的硬件方式
- 通过 8042 键盘控制器
- 其输出端口 P2 控制 A20 地址线
▶ 3. A20 控制代码逻辑(说明性)
call empty_8042 ; 等待 8042 空闲
mov al,#0xD1 ; 命令:写 P2 输出端口
out #0x64,al ; 发送命令到 8042
call empty_8042
mov al,#0xDF ; 设置 P2,使 A20 = 1
out #0x60,al
call empty_8042
说明:
- 端口
0x64:8042 命令端口- 端口
0x60:8042 数据端口0xDF:使 A20 地址线被选通
四、步骤二:初始化 8259A 中断控制器
初始化 8259A 的目的是:
- 重新设置中断向量号
- 避免硬件中断与 CPU 异常向量冲突
该部分代码通常较为固定、流程机械,因此课件未展开。
五、步骤三:切换到保护模式
▶ 1. 设置 CR0 寄存器
CR0 是控制处理器工作模式的重要控制寄存器:
第 0 位(PE,Protection Enable)
0:实模式1:保护模式
mov ax,#0x0001
mov cr0,ax
此时:
- CPU 已进入保护模式
- 但 CS 缓存仍保留实模式状态
▶ 2. 远跳转刷新 CS 和指令流水线
jmpi 0,8 ; IP = 0,CS = 8
该指令的作用:
- 刷新 CS 段寄存器
- 清空指令队列
- 使 CPU 真正开始按保护模式解释指令
其中:
CS = 8是一个 段选择子- 指向 GDT 中的代码段描述符
六、实模式与保护模式地址计算对比
实模式:
物理地址 = CS × 16 + IP
- 20 位地址
- 最大 1MB
保护模式:
- 使用段选择子 + 段描述符
- 支持 32 位线性地址
- 理论地址空间可达 4GB
七、empty_8042 子程序的作用
empty_8042:
.word 0x00eb,0x00eb
in al,#0x64
test al,#2
jnz empty_8042
ret
功能说明:
- 读取 8042 状态寄存器
- 检查输入缓冲区是否为空
- 确保向 8042 写命令前硬件已准备好
其中:
test al,#2:检测输入缓冲区满标志(位 1)
保护模式切换
总览:
setup 做的事情只有一件——
为 system 进入保护模式铺好“地址规则”和“中断规则”,然后一跳进去。
➤ 一、为什么进入保护模式前一定要有 GDT / IDT
▪ 1. 实模式 vs 保护模式,根本区别在哪?
▫ (1)实模式:段寄存器本身就是地址
CS:IP → CS << 4 + IP → 物理地址
- CS 里放的就是“段基址的高 16 位”
- CPU 不检查权限、不检查越界
- 能跑,但不安全、不现代
▫ (2)保护模式:段寄存器只是“索引”
CS:IP → 用 CS 查 GDT → 得到段基址 → 段基址 + IP → 物理地址
关键变化:
- CS 不再是地址
- CS 是一个 “选择子(selector)”
- 真正的地址规则写在 GDT 表项里
没有 GDT,保护模式根本无法工作
➤ 二、IDT:为什么“中断表可以先是空的”
你这里有个非常容易被卡住的点,我直接替你拆掉。
▪ 1. 实模式中断
int n → 中断向量表(固定在 0x0000)→ 处理函数
▪ 2. 保护模式中断
int n → IDT → 处理函数
▪ 3. 那为什么 setup 里 IDT 是“空表”?
idt_48:
.word 0
.word 0,0
原因只有一个:
刚进保护模式,先不允许中断发生
- system 还没准备好
- 中断一来,CPU 就会查 IDT
- 查不到 → 直接死机
所以:
- 先关中断
- IDT 先给一个“占位”
- 等 system 初始化完,再真正建立 IDT
这一步是防炸机操作,不是“没写完”。
➤ 三、把 setup 移到 0 地址的真正目的(非常关键)
PPT 里“移到 0 地址”,不是为了“方便”
真正目的:
让 GDT 里的代码段 / 数据段基址 = 0
▪ 1. 为什么段基址要是 0?
因为 system 是一个 32 位内核,它希望:
线性地址 = 逻辑地址
也就是:
段基址 = 0
这样:
- C 代码里用的指针
- 汇编里用的地址
- 都不用再管段偏移
这是现代操作系统的基本假设。
▪ 2. setup 在做什么?
lgdt gdt_48
lidt idt_48
它不是“进入保护模式”,而是:
- 提前把保护模式要用的表准备好
- system 一跳进去就能直接用
➤ GDT 表的最小可用结构
▫ 1. GDT 里现在只有三类东西
| 表项 | 用途 |
|---|---|
| 空表项 | 保护用,CS=0 会异常 |
| 代码段 | system 的代码 |
| 数据段 | system 的数据 / 栈 |
▫ 2. 代码段 / 数据段“最重要的三个信息”
| 字段 | 含义 |
|---|---|
| 段基址 | 0x00000000 |
| 段限长 | 覆盖 system 所需空间 |
| 属性 | 能不能执行 / 读写 |
你现在完全可以忽略描述符的位级结构,只记:
GDT = 段规则表,不是地址表
➤ jmpi 0,8:真正进入保护模式的跳转
这是整个 setup 的高潮。
▪ 1. 指令本身
jmpi 0,8
含义:
IP = 0CS = 8
▪ 2. CS=8 为什么是代码段?
8 = 0000 1000b
- 低 3 位:特权 / 表类型
- 高位:GDT 第 1 个有效表项
也就是定义的:
; 代码段描述符
.word 0x07FF, 0x0000, 0x9A00, 0x00C0
▪ 3. 跳转后的地址计算流程
jmpi 0,8
↓
CPU 用 CS=8 查 GDT
↓
得到:段基址 = 0
↓
物理地址 = 0 + IP(0)
↓
开始执行 system 的第一条指令
这不是“跳转”,而是“切换世界规则后第一次执行”
保护模式切换小结
-
知道 GDT 决定地址怎么算
-
知道 IDT 决定中断怎么进
-
知道
jmpi 0,8是保护模式的入口跳 -
能用一句话解释:
setup 做了什么?
“准备表 + 跳进 system”
跳转到 system 模块并开始内核运行
(承接:进入保护模式 & GDT / IDT 初始化)
承接「进入保护模式」,说明 CPU 如何从 setup 跳转到 system 模块,并确定 system 的执行入口。
4.1 system 模块的入口问题
➤ 问题 1:system 模块的第一段执行代码是什么?
system 模块开始执行的第一条指令来自
head.s
该结论不是约定,而是由 Makefile 链接顺序 + CPU 跳转方式共同决定的。
➤ 问题 2:system 由多个文件组成,为什么偏偏从 head.s 开始?
原因如下:
在链接生成
tools/system时,boot/head.o被放在第一个目标文件位置
而 CPU 跳转到 system 后,默认从 system 的起始地址(offset = 0)开始执行。
4.2 system 的生成过程(Makefile 视角)
➤ 4.2.1 启动镜像 Image 的组成
Image: boot/bootsect boot/setup tools/system tools/build
tools/build boot/bootsect boot/setup tools/system > Image
由此可知:
Image =
[ bootsect ] → 引导扇区
[ setup ] → 实模式 → 保护模式准备
[ system ] → Linux 内核本体
➤ 4.2.2 system 模块的链接规则
tools/system: boot/head.o init/main.o $(DRIVERS) ...
$(LD) boot/head.o init/main.o $(DRIVERS) ... -o tools/system
关键点:
-
boot/head.o位于链接命令的第一个 -
链接器规则:
第一个目标文件的起始地址 = 可执行文件的起始地址
因此:
system 的 offset = 0
→ head.s 的第一条指令
4.3 setup → system 的控制转移
➤ 4.3.1 setup 末尾的关键跳转指令
jmpi 0,8
含义为:
CS = 8 (代码段选择子,对应 GDT 中的代码段)
IP = 0 (段内偏移为 0)
即:
从 system 模块的 0 偏移处开始执行
4.3.2 执行流闭环
bootsect
↓
setup
↓ jmpi 0,8
system(offset = 0)
↓
head.s
至此,CPU 正式进入 system 模块执行。
4.4 head.s 的作用
可以将 head.s 理解为:
从“硬件 + 保护模式”过渡到“C 语言内核”的最后一段汇编代码
其主要职责包括:
-
运行于保护模式下(setup 已完成模式切换)
-
建立内核运行所需的最小环境
- 段寄存器
- 栈
- 页表(早期 Linux)
-
跳转到 C 语言入口函数
start_kernel(位于init/main.c)
执行层次为:
head.s (汇编:环境准备)
↓
main.c (C:内核逻辑开始)
4.5 本节小结
system 模块的入口是
head.s,原因在于链接顺序决定 system 的 0 地址对应head.o,而 setup 通过jmpi 0,8跳转到 system 的起始地址执行
4.6 与上一节的逻辑关系
进入保护模式
↓
GDT / IDT 初始化
↓
jmpi 0,8
↓
system 模块
↓
head.s
↓
C 语言内核
system 模块的第一段代码:head.s
(承接:setup 跳入 system)
5.1 head.s 的定位与分工
➤ 1. 本节标题含义校准
head.s:一段在保护模式下运行的 32 位汇编代码
注意三个关键词:
- 保护模式下
- 32 位
- 汇编(C 之前)
这三点决定了它“必须存在,且只能用汇编”。
➤ 2. setup 与 head 的职责边界(常考)
| 模块 | 职责 |
|---|---|
| setup | 进入保护模式(A20、CR0、GDT、跳转) |
| head | 保护模式之后的初始化(段、栈、IDT、分页准备) |
一句话记忆:
setup 负责“进门”,head 负责“站稳”
5.2 head.s 的执行入口:startup_32
➤ 1. 为什么入口叫 startup_32
- system 是 32 位内核
- CPU 此时已在保护模式
- 默认使用 32 位指令集
所以第一条执行代码就是:
startup_32:
5.3 段寄存器初始化(保护模式必做)
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
➤ 这一步在干什么?
0x10是 GDT 中的数据段选择子- 所有数据段寄存器统一指向 内核数据段
在保护模式下:
- 段寄存器 不是地址
- 必须显式初始化,否则访问内存直接异常
5.4 栈初始化(进入 C 世界的前提)
lss _stack_start,%esp
➤ 对应的 C 结构定义
struct {
long *a;
short b;
} stack_start = {
&user_stack[PAGE_SIZE>>2],
0x10
};
➤ 核心理解
-
设置 系统栈
-
0x10:栈段选择子(数据段) -
没有栈:
callpush- C 函数
全部不能用
这是进入 C 代码前的“生命线”
5.5 setup_idt / setup_gdt —— 重点
➤ 1. 调用顺序
call setup_idt
call setup_gdt
➤ 2. 为什么 system 里还要再初始化 IDT / GDT?
▪ 之前 setup 里做了什么?
-
setup 里:
- 给 CPU 一个 “最小可用的 GDT / IDT”
- 目的是:不死机,能跳进 system
▪ head.s 里要做什么?
-
system 是正式内核
-
需要:
- 完整 GDT
- 完整 IDT
- 为后续中断 / 异常 / 系统调用做准备
setup 的表是“临时的”,
head 的表才是“内核级的”。
5.6 IDT 初始化:全部指向 ignore_int
➤ 1. IDT 空间定义
_idt: .fill 256,8,0
含义:
- 256 个中断向量
- 每个 8 字节(中断门)
- 初始全 0
➤ 2. setup_idt 核心逻辑
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax
lea _idt,%edi
movl %eax,(%edi)
▪ 这在做什么?
- 把 所有中断入口 设置为
ignore_int - 即:
中断来了也什么都不做
➤ 3. 为什么“忽略中断”是正确做法?
此时 system 还没完成:
- 页表
- 内存管理
- 中断子系统
如果中断进来:
CPU → 查 IDT → 跳处理函数 → 系统未准备 → 崩
所以策略是:
先全部接住,但不处理
这是典型的 防炸机设计。
5.7 A20 检测逻辑(安全兜底)
movl %eax,0x0000000
cmp %eax,0x100000
je 1b
➤ 含义
-
检查:
0 地址 和 1MB 地址 是否指向同一物理内存 -
如果相同:
- A20 没打开
- 地址回卷
- 直接死循环
这是内核级的“硬件自检”
5.8 跳转到分页初始化阶段
jmp after_page_tables
说明:
-
head.s 的下一阶段任务是:
- 建立页表
- 打开分页
-
这一步之后,才真正接近现代 OS 的内存模型
5.9 本节总结
➤ 一句话
head.s 是 system 的第一段 32 位汇编代码,用于在保护模式下完成内核运行前的关键初始化(段、栈、IDT、硬件校验),为 C 语言内核铺路
➤ 执行流回顾
bootsect
↓
setup(进入保护模式)
↓ jmpi 0,8
head.s(startup_32)
↓
IDT / GDT / 栈 / A20 检测
↓
分页初始化
↓
main.c
after_page_tables:设置好页表之后的执行逻辑
➤ 再次明确分工
| 模块 | 作用 |
|---|---|
| setup | 进入保护模式 |
| head | 保护模式后的初始化 |
| after_page_tables | 分页完成后,安全跳入 C 内核 main() |
➤ 代码整体结构(先看轮廓)
after_page_tables:
pushl $0
pushl $0
pushl $0
pushl $L6
pushl $_main
jmp setup_paging
L6:
jmp L6
setup_paging:
; 设置页表
ret
这一段不是顺序执行代码
➤ 关键思想:用「栈 + ret」跳进 main
不是 call main,而是用 ret“骗”CPU 跳进 main
➤ 压栈顺序(一定要从“最后一次 push”往回看)
▪ 汇编里的压栈
pushl $0
pushl $0
pushl $0
pushl $L6
pushl $_main
▪ 栈的真实布局(栈顶在上)
ESP →
┌───────────────┐
│ _main │ ← ret 后跳转到这里
├───────────────┤
│ L6 │ ← main 返回地址
├───────────────┤
│ 0 │ ← 参数3
├───────────────┤
│ 0 │ ← 参数2
├───────────────┤
│ 0 │ ← 参数1
└───────────────┘
➤ 6.6 控制流一步一步走(容易乱)
▪ 第一步:跳入 setup_paging
jmp setup_paging
- 不使用
call - setup_paging 不会自动生成返回地址
▪ 第二步:setup_paging 执行完毕
ret
ret 的行为是:
弹出栈顶地址 → 跳转
栈顶是谁?
_main
所以:
ret 直接跳进 main()
▪ 第三步:main() 看到的世界
此时栈是:
ESP →
┌───────────────┤
│ L6 │ ← main 的返回地址
├───────────────┤
│ 0 │ ← 参数1
├───────────────┤
│ 0 │ ← 参数2
├───────────────┤
│ 0 │ ← 参数3
└───────────────┘
因此:
main(0, 0, 0)被正确调用- 完全符合 C 语言调用约定
▪ 第四步:main() 返回之后会发生什么?
- main 执行
ret - 返回地址是
L6
L6:
jmp L6
结果:
内核 main 永远不应该返回,一旦返回就原地死循环
这是一个明确的设计约束。
➤ 为什么不直接 call main?
原因:
分页开启前后,栈与返回路径必须完全可控
更具体地说:
-
setup_paging 会改变内存映射
-
普通
call/ret路径容易失控 -
用“提前布好栈 + ret 跳转”:
- 控制流可预测
- 不依赖当前 EIP
这是内核早期代码的典型写法。
本节总结
after_page_tables 通过“手动构造栈 + ret 跳转”的方式,在分页开启后安全进入 C 语言内核 main(),并确保 main 永不返回
➤ 执行流
bootsect
↓
setup(进入保护模式)
↓
head.s(startup_32)
↓
after_page_tables
↓
setup_paging(建页表)
↓ ret
main(0,0,0)
↓
(若返回)→ 死循环
main.c
➤ 职责变了:
| 阶段 | 本质 |
|---|---|
head.s |
汇编,解决 CPU / 内存 / 页表 / 执行权交接 |
main() |
C 语言,解决 内核子系统初始化 |
head.s 的最后一件事就是:
“把执行权安全地交给 main()”
讲的已经是 交接完成之后发生的事。
进入 main 函数(C 语言内核入口)
:从这里开始,不再是汇编,而是 C 语言内核代码
main 函数的位置与身份
- 所在文件:
init/main.c - 函数原型:
void main(void)
这是 Linux 内核中,第一个被执行的 C 语言函数
main 是如何被调用的?
-
head.s中:- 构造栈
ret跳转
-
并不是通过
call main -
而是通过 “伪函数调用 + ret” 进入
所以:
main 看起来像“普通函数”,
实际上是 内核执行权的正式起点
main 函数的核心工作(初始化总调度)
void main(void)
{
mem_init(); // 内存初始化
trap_init(); // 中断 / 异常初始化
blk_dev_init(); // 块设备
chr_dev_init(); // 字符设备
tty_init(); // 终端
time_init(); // 时钟
sched_init(); // 调度器
buffer_init(); // 缓冲区
hd_init(); // 硬盘
floppy_init(); // 软盘
sti(); // 打开中断
move_to_user_mode(); // ★ 切换到用户态(重点)
if (!fork()) { init(); }
}
main 的作用是“把内核世界搭起来”
关于 void main(void) 的说明
- 这不是用户程序的
main - 没有参数需求
- 它只是“C 语言的入口形式”
不用返回值、不用参数 argc/argv
操作系统永远不停
main 中两个“真正的关键点”
-
move_to_user_mode()- 第一次从内核态 → 用户态
-
fork() + init()- 第一个用户进程的诞生
从这里开始,Linux 不再是“一段代码”,而是“一个系统”
与栈结构的衔接说明
-
进入
main()时:- 栈中已有
0, 0, 0, L6
- 栈中已有
-
这三个
0:- 对应 main 的“伪参数”
-
main返回后:- 会跳回
L6 - 进入死循环(系统不会真正返回)
- 因为我们操作系统是不停的
这是内核设计
- 会跳回
内存初始化:mem_init
到这里为止,Linux 已经完成了
从上电 → 内核启动 → 基本资源可用 的全过程。
mem_init 的位置与调用关系
- 所在文件:
linux/mm/memory.c - 调用链路
bootsect → setup → head.s → main() → mem_init()
含义:
mem_init 是内核启动后,对“物理内存”的第一次正式接管
mem_init 的函数原型
void mem_init(long start_mem, long end_mem)
参数含义:
start_mem:可用内存的起始物理地址end_mem:可用内存的结束物理地址
说明:
这两个参数由前面的启动阶段(setup / head)计算好,
mem_init 只负责“按规则登记”。
内存管理的基本单位
- 内存以 页(Page) 为单位管理
- 每一页大小:4KB
- 换算关系:
1 页 = 4KB
右移 12 位 = 除以 4096
这是后面所有内存管理内容的基础假设。
mem_map:内存映射数组(核心概念)
mem_map[]:内核维护的物理页使用情况表- 每一个元素对应一页物理内存
- 状态含义(老师板书):
| 值 | 含义 |
|---|---|
USED(如 100) |
已被占用 |
0 |
空闲,可分配 |
一句话理解:
mem_map 是内核“记账用”的内存台账
mem_init 的整体执行逻辑
➤ 第一步:先“假设所有内存都不能用”
for(i=0; i<PAGING_PAGES; i++)
mem_map[i] = USED;
意义:
内核启动时采取“保守策略”
先全部标记为已占用,防止误用未知内存区域。
➤ 第二步:计算“真正可用内存”的页范围
i = MAP_NR(start_mem);
end_mem -= start_mem;
end_mem >>= 12;
含义拆解:
MAP_NR(start_mem)
→ 把起始物理地址换算成 页号end_mem -= start_mem
→ 得到可用内存总大小>> 12
→ 把字节数换算成 页数
➤ 第三步:把“可用内存页”标记为空闲
while(end_mem-- > 0)
mem_map[i++] = 0;
最终结果:
- 不可用区域:仍是
USED - 可用内存区域:被标记为
0
一句话总结这一整段代码:
mem_init 的本质工作:
在 mem_map 中标出“哪些物理页可以被内核使用”
从系统角度看 mem_init 在干什么?
你可以站在更高一层这样理解(非常重要):
-
在 mem_init 之前:
- 内核“知道有内存”
- 但还不能安全地分配
-
在 mem_init 之后:
-
内核拥有了:
- 可分配的物理页
- 后续
kmalloc、进程内存、页表管理的基础
-
这一步完成后,内核才算“真正活过来”
第一章启动流程总结
现在已经完整走完了一条主线:
上电
↓
bootsect(引导)
↓
setup(进入保护模式)
↓
head.s(32 位环境 / 页表 / 栈)
↓
main(内核初始化)
↓
mem_init(内存正式可用)
到这里:
“操作系统如何启动”这一章,逻辑是闭合的

浙公网安备 33010602011771号