1. 这一章在做什么:从“能打印字符”到“迎接内核”
1.1 为什么标题叫“保护模式进阶”
《操作系统真象还原》的第五章标题是“保护模式进阶,向内核迈进”。我第一次看到这个标题时挺困惑:前面的章节明明已经切换到保护模式、也打印过字符了,怎么这里还要再来一次“进阶”?等真正把这一章走通才反应过来——前几章的保护模式只是“能用”,离“能承载内核”还差着十万八千里。
第五章要解决的,是 Boot Loader 和操作系统内核之间那段最容易被忽略、也最容易翻车的路。你在实模式下完成了最基本的初始化,哄着 CPU 进了保护模式,但保护模式下的内存、分页、特权级、内核文件格式这些事,前面根本没来得及碰。这一章把这些东西全部补上,所以叫“进阶”;最后真的跳到内核代码里执行,所以叫“向内核迈进”。
我建议学这一章之前,先把前几章的代码过一遍,尤其是 GDT 初始化、从实模式跳保护模式的jmp、屏幕打印字符这几个基础动作。操作系统开发是强依赖顺序的,任何一环没吃透,后面的问题都会成倍放大。
1.2 这一章的三个任务和路线图
这一章的核心任务可以拆成三块:
- 探测物理内存,搞清楚机器里到底有多少可用内存、哪些区域能用、哪些是硬件保留区。
- 构造页目录和页表,开启分页,让 CPU 从“段式翻译”进入“段式加页式翻译”的新阶段。
- 把 ELF 格式的内核文件从磁盘读到内存,解析出代码段和数据段,然后跳进内核入口。
这三件事是层层递进的关系。没有内存地图,你就不知道页表该建在哪、内核该加载到哪;没有分页机制,内核的内存管理模块后面根本没法展开;不解析 ELF,你可能连内核入口在哪都找不到。所以“向内核迈进”不是一句口号,它是有明确交付物的:一个可用的内存布局、一组能工作的页表、一个已经加载到内存并准备执行的 C 语言内核。
我看到很多初学者会犯一个毛病,急着去看后续的进程、文件系统,却把这块基本功跳过去。结果就是回到第五章,一头扎进三重故障里出不来。这一章值得慢慢磨。
2. 保护模式的分段细节:容易被忽略的几道坎
2.1 段描述符布局:写错一个位都可能直接崩
第五章虽然不从头讲段描述符,但后面所有操作都建立在“你能正确构造段描述符”的基础上。保护模式下,一个内存段不是像实模式那样简单地用段寄存器加偏移拼地址,而是由一个 8 字节的段描述符定义。描述符里写清楚了段的基地址、段界限、粒度、类型、特权级以及是否存在。
标准 32 位段描述符的布局可以这样记:
| 字节位置 | 位范围 | 含义 |
|---|---|---|
| 0 | 0-15 | 段界限低 16 位,对应 limit[15:0] |
| 1 | 16-31 | 段基地址低 16 位,对应 base[15:0] |
| 2 | 0-7 | 段基地址中 8 位,对应 base[23:16] |
| 2 | 8-11 | 段类型,比如代码段还是数据段、可读可写等 |
| 2 | 12 | S 标志,系统段还是普通代码/数据段 |
| 2 | 13-14 | DPL,描述符特权级 |
| 2 | 15 | P,段是否存在 |
| 3 | 0-3 | 段界限高 4 位,对应 limit[19:16] |
| 3 | 4 | AVL,软件可用位 |
| 3 | 5 | L,64 位代码段标志,32 位环境下置 0 |
| 3 | 6 | D/B,操作数大小,32 位代码段置 1 |
| 3 | 7 | G,粒度,0 表示字节粒度,1 表示 4KB 粒度 |
| 3 | 8-15 | 段基地址高 8 位,对应 base[31:24] |
很多人第一次写描述符,会直接把一个 64 位整数按十进制或者按内存顺序填错。比如想要一个基址为 0、界限为 4GB 的代码段,在 32 位保护模式下常见做法是:G 位为 1,段界限 20 位全用0xFFFFF。因为 G=1 后,实际段界限等于“20 位界限值 + 1 后再乘以 4KB”,也就是(0xFFFFF + 1) * 0x1000 = 0x100000000,正好覆盖整个 4GB 地址空间。
如果只把低 16 位界限填了0xFFFF,高 4 位没填,G 位又没设对,CPU 计算出来的段范围就会非常小,访问稍微高一点的地址就触发异常。这类错误不会在你刚进入保护模式时立刻暴露,等你开始往高地址加载内核、访问页表的时候,它就会变成非常难查的三重故障。
2.2 GDT、选择子与特权级
段描述符不是散落在内存里让 CPU 自己找的,而是组织成一张表,叫全局描述符表 GDT。CPU 通过 GDTR 寄存器保存 GDT 的基地址和界限,用lgdt指令加载。
进入保护模式后,段寄存器里存的并不是基地址,而是一个“选择子”。选择子的结构是:低 2 位是请求特权级 RPL,第 2 位是 TI 位,表示查 GDT 还是 LDT,高 13 位是描述符索引。CPU 拿到选择子后,去 GDT 中索引对应项,再把描述符里的基地址、界限等信息加载到内部的隐藏缓存里。
这里有个极易忽略的点:段描述符里的 DPL 不是摆设。普通数据段的访问规则可以简单记忆成:描述符的 DPL 必须不低于 CPL 和 RPL 中较高的那个。换句话说,DPL >= max(CPL, RPL)。如果内核代码用特权级 0 访问一个 DPL 也是 0 的数据段,没问题;但如果用户态代码用 CPL=3 去访问 DPL=0 的内核数据段,就会被拒绝。
在第五章这个阶段,内核还没有用户态,全局都跑在特权级 0。所以构造内核代码段和数据段时,DPL 一律填 0,选择子的 RPL 也填 0。不要自作聪明地去填 3,后续一旦接上用户态环境,你会发现自己陷入各种特权级检查失败里。
2.3 重新加载段寄存器与栈切换
进入保护模式那一步,虽然已经通过mov cr0, ...把 PE 位置 1,但 CS 这个寄存器的隐藏描述符缓存还停留在实模式状态。这时候必须用一条远跳转,比如jmp 0x08:flush_32,强制重新加载 CS。然后还要挨个把 DS、ES、SS 等数据段寄存器加载成新的选择子。
我见过有人跳进保护模式后忘了重新加载 DS,结果后续的mov访问内存还是按照实模式段寄存器语义去算,整个程序行为完全错乱。排查这种问题最直接的线索是:你明明已经开了保护模式,但地址表现还像实模式,而且越界访问也不报错。
另一个经常被忽略的是栈。进入保护模式前,你可能还在用 BIOS 提供的栈或者某个临时栈。到了第五章,加载内核、调用 C 代码之前,必须把栈切到一个明确的位置。建议单独划一块至少 4KB 的连续内存作为内核栈,不要继续依赖原来的临时栈。内核里的 C 函数调用、局部变量、后续的中断现场保存,全都要压栈。栈太小,函数一调深,就会往 loader 代码或者页表区域里写,结果会是系统性崩溃,而不是报一个明确错误。
3. 物理内存探测:给内核画出可用的地图
3.1 为什么必须在进入保护模式前探测
很多初学者不理解:内存容量有什么好探测的?直接假设一个值不就行了吗?不行。同一份内核可能跑在只有 32MB 内存的老环境,也可能跑在 512MB 的模拟器里。内存池大小、页表要建多少项、内核能不能加载成功,全都依赖真实的物理内存布局。
更要命的是,物理内存不是一整块连续的可用空间。低地址里有 BIOS 数据区、中断向量表、显存映射区,中高地址里可能有设备映射、ACPI 表、系统保留区。如果不做内存探测,单纯把所有地址都当作可用内存去建页表、写内核,很可能会覆盖硬件需要的数据。
BIOS 的int 0x15, eax=0xE820中断,是实模式下最权威的内存探测方式。它返回一段一段的地址范围描述符,每段都有起始地址、长度和类型。这个中断依赖实模式 BIOS 服务,所以在进入保护模式前必须在 loader 早期调用。一旦进了保护模式,你没法轻易再回去调 BIOS 中断。
3.2 调用约定与地址范围描述符结构
int 0x15, eax=0xE820的调用方式不算复杂,但寄存器不能填错:
| 寄存器 | 输入要求 | 输出结果 |
|---|---|---|
| EAX | 0x0000E820 | 成功时为签名0x534D4150,失败时不清空 |
| EBX | 初始为 0,后续传上次返回值 | 下一次要传回的新值;等于 0 表示探测结束 |
| ES:DI | 指向缓冲区 | 缓冲区被填充一段描述符 |
| ECX | 缓冲区大小,至少 20 | 实际写入的字节数 |
| EDX | 0x534D4150,即SMAP | 保持不变 |
缓冲区里每一段的结构是 20 字节:物理起始地址 8 字节,长度 8 字节,类型 4 字节。类型字段是核心中的核心:
| 类型值 | 含义 |
|---|---|
| 1 | 系统可用内存,可以正常分配和使用 |
| 2 | 保留区,不可用 |
| 3 | ACPI 可回收内存,操作系统在用完后可以回收 |
| 4 | ACPI NVS,系统休眠相关,不要碰 |
| 5 及以上 | 错误类型,直接忽略 |
写代码时,我习惯把所有返回结果保存到一个数组里,并记录总段数。这个数组需要放在一个“安全且不会被后续步骤覆盖”的地方。一般 loader 自身占不了多少空间,但你接下来还要读磁盘、加载内核、建页表,如果缓冲区选在那些区域中间,探测结果转头就被覆盖,那前面做的一切都白费。
3.3 基本的探测循环
用汇编来写,核心循环类似这样:
mov ebx, 0 ; ebx 初始为 0 mov edx, 0x534d4150 ; 'SMAP' mov di, mem_map_buf ; 指向内存地图缓冲区 xor ecx, ecx ; 先清空计数 .next_entry: mov eax, 0xe820 mov ecx, 20 ; 缓冲区大小 int 0x15 jc .done ; CF=1,说明出错或探测终止 cmp eax, 0x534d4150 jne .done ; 签名不对,不是有效结果 ; 这里对 es:di 指向的 20 字节做处理 add di, 20 inc ecx ; 用 ecx 记录段数 test ebx, ebx jz .done ; ebx=0,全部完成 jmp .next_entry .done: ; ecx 中就是总段数这一段里最容易写错的是忘记每次循环把ecx重新设为 20。有些资料里的示例把ecx先设一次,后面循环里一直没改,结果 BIOS 返回的字节数和你实际能处理的缓冲区长度不一致,后面解析就是乱套。
另外注意,调用时es:di指向的缓冲区大小如果只给 20 字节,int 0x15返回的数据长度就是 20。当 BIOS 支持 ACPI 3.0 的扩展属性时,它可能会写回 24 字节,所以缓冲区最好多预留一点,避免把循环后面的数据覆盖掉。
3.4 实测时的内存布局形态
如果你在虚拟机上跑,典型结果会是这样:
0x0000000000000000到0x000000000009FC00,类型 1,可用;0x000000000009FC00到0x00000000000A0000,类型 2,保留;0x00000000000F0000到0x0000000000100000,通常是保留;0x0000000000100000到若干 MB,类型 1,可用。
也就是说,从 0 开始的 640KB 区域并不全是能用的,中间有高地址硬件区和大小的 Hole。你如果不做探测,直接按“从 0 开始全部可用”来建页表,后面很容易出问题。得到地图后,第五章后面的内存池初始化、页表位置选择,就可以用这些数据来决策了。
4. 分页机制:地址要开始“翻译”了
4.1 分页到底解决了什么问题
在只有分段机制时,线性地址经过段部件转换成物理地址,全程没有页的粒度。操作系统如果直接暴露物理地址,各个任务之间没有任何隔离,一个进程里写一个野指针,可能直接改掉内核代码。分页机制引入后,线性地址和物理地址之间多了一层映射关系,每个 4KB 页面都能单独控制是否可读、是否可写、是否允许用户态访问。
对“向内核迈进”这个目标来说,分页更现实的用途是:你可以先规划好内核放在哪,再决定哪些线性地址映射过去。后续做虚拟内存、进程地址空间隔离时,页表会成为绝对核心。第五章的任务不是实现完整的虚拟内存,而是先把分页打开,让 CPU 习惯用页表翻译地址,并且保证开启分页的那一刻,当前执行的代码不被打崩。
4.2 32 位分页的地址翻译流程
32 位地址被分成三段:
- 高 10 位:页目录索引;
- 中间 10 位:页表索引;
- 低 12 位:页内偏移,正好对应 4KB 页面。
CPU 先读 CR3 拿到页目录的物理地址,然后用高 10 位索引去查页目录项 PDE。PDE 里如果存在位 P 是 1,就继续拿中间 10 位去查对应的页表项 PTE。PTE 指向真实的物理页框,最后把页内偏移加上去,就得到了物理地址。
PDE 和 PTE 都是 32 位。高 20 位是物理地址的高位,低 12 位是控制位。其中最低的 P 位表示页面是否存在,第 1 位 RW 表示读写权限,第 2 位 US 表示用户态是否可访问。章节练习阶段,我给内核页表项填的都是0x03,也就是 P=1、RW=1,US=0。这样内核自己可以读写,用户态将来不能访问内核页。
有个太容易被忽略的细节:页目录必须物理地址按 4KB 对齐,页表也必须 4KB 对齐。你如果随手把页目录定义在一个只按 1 字节对齐的缓冲区里,CR3 会把低位非零的地址当作无效值,分页开启后行为完全不可预测。
4.3 初始页表怎么建:恒等映射先跑通
开分页最怕的就是:分页位一打开,下一条取指的线性地址没法映射到物理内存,CPU 立刻缺页,缺页没处跑,变成双重故障,再演变成三重故障,机器直接复位。
所以第一步要做的,是建立“恒等映射”。最简单有效的初级方案是:把线性地址 0 到 4MB 这一片,映射到物理地址 0 到 4MB。因为当前 loader 的代码、栈、数据基本都位于低 1MB 内,只要这一段能映射,开启分页后当前位置就不会跑飞。
建立过程分两步:先准备一个页目录,再准备一个页表。页目录的第 0 项指向这个页表的物理地址,并设置 P/RW。页表中的 1024 项,每一项把物理地址设为i * 0x1000,同时加0x03,也就是直接把 0 到 4MB 映射到自身。页目录的其他项先全部置 0,保证没有被映射的地址访问时会产生页错误,方便排查。
如果用汇编来初始化,主体是这样的:
; 清空页目录 mov ecx, 1024 xor eax, eax mov edi, page_dir_base rep stosd ; 让页目录第 0 项指向 page_table_base,P|RW mov eax, page_table_base or eax, 0x3 mov [page_dir_base], eax ; 填充页表:1024 个 PTE,每个映射 4KB mov eax, 0x3 ; 物理地址从 0 开始,加上 P|RW mov edi, page_table_base mov ecx, 1024 .fill_pte: mov [edi], eax add eax, 0x1000 add edi, 4 loop .fill_pte代码里我用了0x3而不是0x1。如果只填0x1,页面存在但不可写,后面一旦执行写内存的指令,比如往栈里压数据,就会触发写保护异常。初学时最容易犯的错就是少写 RW 位。
页目录和页表本身占用的物理内存,也要避开之前加载内核的缓冲区。我一般会在内存探测结果里找一块低地址连续的可用区域,专门留给页表,并且把它固定下来,后面不要再用作其他用途。
4.4 开启分页的指令顺序
页表准备好之后,开启分页只需要三步:
mov eax, page_dir_base mov cr3, eax ; 加载页目录基地址 mov eax, cr0 or eax, 0x80000000 ; 设置 PG 位 mov cr0, eax注意顺序:一定要先把 CR3 设置好,再改 CR0 的 PG 位。如果你先开了分页,CPU 会立刻拿着一个还没有初始化的 CR3 去翻译地址,大概率当场崩溃。
开启分页后,旧的旁路转换缓冲 TLB 里可能缓存了之前线性地址到物理地址的映射,通常不受影响,但为了保险,可以用一条短跳转刷新一下流水线。这一步不是必须,却是我每次做都顺手加上的习惯。
分页开启并不意味着以后就不用管低地址了。内核入口、中断入口、设备映射,这些都需要页表项来覆盖。第五章只要把 0 到 4MB 的恒等映射做好,就能满足“先跑通再优化”的目标。
5. 加载内核并跳转:向“C 世界”迈出第一步
5.1 为什么内核用 ELF 而不是裸二进制
前几章写的汇编代码,编译出来可以是一段扁平的二进制,直接扔到内存某个位置就能跑。但真正的内核是用 C 语言写的,代码里包含多个节区:.text、.data、.bss,还有符号表。如果直接把裸二进制拷贝过去,入口地址、数据段位置、未初始化数据清零这些事情,全都要你手工维护,极其痛苦。
ELF 格式把这些元数据都放进了文件头部。loader 只要解析 ELF Header 和 Program Header,就能知道:
- 内核的入口地址在哪里;
- 哪些代码需要从文件里拷贝到内存;
- 哪些未初始化数据需要在加载后清零。
所以在“向内核迈进”这一步,手写一个最简 ELF 加载器,比凑一个裸二进制要合理得多。这个名字起得很形象:把 ELF 文件“还原”成内存中的可执行映像,正是操作系统加载程序的原始动作。
5.2 手写一个“够用就好”的 ELF 加载器
ELF Header 长 52 字节。对加载内核来说,关键字段是这些:
| 偏移 | 大小 | 含义 |
|---|---|---|
| 0x00 | 4 | ELF 魔数:0x7F 45 4C 46 |
| 0x18 | 4 | 入口地址e_entry |
| 0x1C | 4 | Program Header 表的偏移e_phoff |
| 0x2A | 2 | 每个 Program Header 大小e_phentsize |
| 0x2C | 2 | Program Header 数量e_phnum |
Program Header 才是真正描述“哪段文件数据该放到哪个内存地址”的结构。对 32 位 ELF,关键字段是:
| 偏移 | 大小 | 含义 |
|---|---|---|
| 0x00 | 4 | p_type,1 表示可加载段 PT_LOAD |
| 0x04 | 4 | p_offset,段在文件中的偏移 |
| 0x08 | 4 | p_vaddr,段要加载到的线性地址 |
| 0x10 | 4 | p_filesz,文件中占用的字节数 |
| 0x14 | 4 | p_memsz,内存中占用的字节数 |
加载逻辑非常简单:遍历所有 Program Header,遇到p_type = 1的段,就从内核文件缓冲区偏移p_offset处拷贝p_filesz字节到地址p_vaddr。然后如果p_memsz大于p_filesz,说明还有未初始化数据需要清零,就用memset把多出来的部分写成 0。
伪代码如下:
uint32_t entry = *(uint32_t *)(buf + 0x18); uint32_t phoff = *(uint32_t *)(buf + 0x1C); uint16_t phentsize = *(uint16_t *)(buf + 0x2A); uint16_t phnum = *(uint16_t *)(buf + 0x2C); for (int i = 0; i < phnum; i++) { Elf32_Phdr *ph = (Elf32_Phdr *)(buf + phoff + i * phentsize); if (ph->p_type != 1) // 不是 PT_LOAD continue; memcpy((void *)ph->p_vaddr, buf + ph->p_offset, ph->p_filesz); uint32_t bss_size = ph->p_memsz - ph->p_filesz; if (bss_size > 0) memset((void *)(ph->p_vaddr + ph->p_filesz), 0, bss_size); }为啥要区分p_filesz和p_memsz?因为在 ELF 里,未初始化的全局变量不占文件空间,只占内存空间。如果你不把这段内存清零,C 里未初始化的全局变量可能残留上一任数据,传给内核的全局状态就会莫名奇妙。
“内核加载到哪个地址”取决于链接脚本。第五章阶段,最简单的是让内核的虚拟地址和它实际加载的物理地址保持一致,或者落在你已经恒等映射的低 4MB 范围内。这样 loader 拷贝完,跳转过去就能直接跑。等后面实现了完整内核页表,再改成高地址映射,那是后话。
5.3 跳转前的最后三件事
加载 ELF 完成不代表万事大吉,真正跳转之前还必须处理三件事。
第一件事是关中断。如果还没建立 IDT,任何外部中断都会触发“中断描述符不存在”的异常,没有处理程序就会变成双重故障、三重故障。所以跳转内核前执行cli是常规操作。
第二件事是把栈设置好。C 语言的函数调用强依赖栈,你需要mov esp, kernel_stack_top,确保内核一进去就有合法的栈可用。如果这一步不做,内核入口第一条call指令就会把返回地址压到一个未知区域。
第三件事是把你刚刚探测到的内存地图传给内核。你可以把内存地图数组的地址放在寄存器里,然后在 C 入口处接收。最简单的做法是在跳转前把地址push到栈上,内核主函数按照 C 调用约定拿参数。
最后,用远跳转进入内核入口:
cli mov esp, kernel_stack_top push mem_map_buf ; 传内存地图地址 jmp dword 0x08:entry ; 选择子 0x08 对应内核代码段远跳转的好处是会强制重新加载 CS,并把 CPU 预取队列清空,避免旧指令缓存干扰新代码段的执行。如果你用mov eax, entry; jmp eax,也不是不行,但不够严谨。从这一跳开始,loader 的汇编代码就“退居二线”了,后面的事交给 C 语言内核去处理。
6. 常见问题与排查实录
6.1 内存探测结果被覆盖或地址算错
我最早做内存探测时,把结果数组放在了一个临时栈附近,结果后面加载内核时内存缓冲区用了同一个区域。内核加载完,发现内存地图的段数变成了一些不明意义的数字,内存池初始化时直接用了错误数据。
后来我统一了做法:在 loader 最前面划一块固定的内存区域作为boot_info,内存探测结果、内核入口地址、页表相关的物理地址,全都在初始化阶段写进去。这个区域只做记录,不参与后面的磁盘和内核缓冲。虽然牺牲了一点内存,但换来的是极大的确定性。
另外,解析int 0x15, eax=0xE820返回结果时,每个地址范围描述符是 20 字节,但很多人会在循环里按 16 字节去处理,导致下一段解析错位。遇到这种情况,内存地图里会出现极其不合理的地址,比如 64GB 的机器里冒出一段位于几百太字节的范围。建议每次收到结果后,先打印起始地址和长度,确认解析正确再往下写。
6.2 开启分页后三次复位
开启分页后如果模拟器直接复位,不要慌,这大概率是三重故障。三重故障的前因一般是:开启分页后,当前 EIP 指向的线性地址没有对应的 PTE,CPU 缺页;缺页异常又因为没有 IDT 导致双重故障;双重故障处理也没找到,最终 CPU 干脆复位。
最常见的几个原因:
- 页目录第 0 项没填 P 位,或者填成了 0;
- 页表项清空时把整个页表都用
rep stosd写 0,但之后忘了填充 PTE; - 页目录或页表的物理地址没有 4KB 对齐;
- 开启 PG 位时,CR3 还没加载;
- 在开启分页后,代码访问的地址超过了映射范围,比如 0x80000000 处的东西根本没映射。
排查建议是:先关掉分页,所有逻辑都正常;一开分页就复位,问题一定出在映射关系上。最笨也最有效的做法是只映射 0 到 4MB,然后把 loader 和页表全部放到 1MB 以内,开分页后原地执行几行代码验证。
6.3 调试小技巧:把阶段标记打满屏幕
操作系统启动早期,没有串口打印、没有文件系统日志,唯一无敌的输出手段就是往显存地址0xB8000写字符。我在 loader 的关键节点会连续打出“A、B、C、D、E”几个字母。比如:
mov byte [0xb8000 + 160 * 10], 'A'开机后,如果屏幕上只到“B”,就说明“B”和“C”之间的代码出了问题。这样排查三重故障比去看反汇编日志快得多。尤其是分页开启这种“当场死给你看”的操作,在开启前打一个“C”,开启后打一个“D”,就很容易定位到是分页开关写错了,还是分页后的第一条指令访问了非法地址。
我个人的经验是,这段调试期不用急着加太多功能。先把“不开分页能加载内核、能跳转”跑通,再开分页。如果你分成两步验证,心理压力会小很多。很多看着玄妙的崩溃,拆到最小步骤后,原因往往只是一个or指令把 CR0 的 PG 位和高位保留位同时写乱了,或者一个页表项漏了 RW 标志。把这些标记字符打得比代码还勤,你会少走很多弯路。