从零编写操作系统 04 - 进入保护模式:建立第一份运行环境
第 03 篇已经让第一阶段启动扇区加载第二阶段,也让第二阶段读取载荷并检查边界。它仍然停留在实模式。实模式下可以方便调用 BIOS,也能通过操作数前缀使用 32 位寄存器;后续内核需要的平坦地址空间和权限隔离仍要建立在保护模式上。
本篇完成第一次处理器模式切换:在进入保护模式前收集 E820 内存图,打开 A20,建立 GDT,设置 CR0.PE,再用远跳转刷新代码段。切换完成后,32 位代码直接写 VGA 文本显存,GDB 能观察到保护模式状态。BIOS 调用到这里为止,后面的内核不能再按实模式方式直接调用 int 10h 或 int 13h。
为什么先把 BIOS 调用做完
BIOS 服务是实模式时代的接口。第 03 篇依赖它读盘;本篇还要依赖它查询内存图。进入保护模式后,CPU 的段解释、地址宽度和中断入口都变了。没有虚拟 8086 模式、实模式回跳或专门 BIOS 调用桥时,内核不能继续把 int 15h 当作普通函数使用。
切换前后的准备顺序如下:
1 | |
E820 返回的内存图会在第 10 篇决定哪些物理页可以分配;启动盘编号记录固件选择的设备,后续驱动仍需建立自己的设备对应关系;显示信息会在第 06 篇和第 25 篇继续扩展。
E820 返回的是系统地址图
ACPI 规范中的 INT 15h, E820h 接口用于在 IA-PC 系统的实模式下查询系统地址图。调用者从 EBX=0 开始,多次调用,BIOS 每次返回一段物理地址范围和类型,直到续传值归零。若 E820 与老接口返回的信息不同,E820 结果优先。
基础条目是 20 字节,增加扩展属性后是 24 字节。调用时给出 24 字节容量,也必须接受返回 20 字节;只有返回长度达到 24 字节,才能读取属性字段。ACPI 6.5 §15.1 将扩展属性 bit 0 定义为保留且必须为 1,不把它描述成该版规范的有效位。
保存后的条目可以用以下 C 类型说明字段;本篇实际读写仍由汇编按字节偏移完成:
1 | |
type == 1 表示普通可用内存,其他类型先按保留处理。第 10 篇物理页分配器会在这个基础上排除内核、启动器、显存和设备区域。当前阶段不分配页,只把 BIOS 给出的事实复制到 BootInfo。
读取 E820 时返回签名必须是 SMAP,每条记录长度至少覆盖基础 20 字节,记录数量不能超过 BootInfo 指向的数组容量。容量不足时停止并报告错误,避免把不完整的地址图当成完整输入继续处理。最后一次成功返回的 EBX=0 表示本条是最后一条,仍需先保存本条。
用地址别名检查 A20
早期 x86 为了兼容 8086,有一个地址回绕历史包袱。A20 地址线关闭时,0x100000 会回绕到低地址区域。内核计划放在 1 MiB 附近,因此要先验证 A20。本篇保护模式入口仍位于低端内存,暂不搬运或执行载荷。
本篇实现两步:
1 | |
具体打开方式以源码为准:
1 | |
代码先比较 0000:0500 与 ffff:0510,对应物理地址 0x500 与 0x100500。临时写入不同字节,判断两处是否发生别名,随后恢复原值。若已经开启就继续;否则使用端口 92,再执行一次相同检测。
QEMU v7.2.0 的端口 92 实现 将 bit 1 连接到 A20,bit 0 的上升沿请求复位。因此这里置 bit 1、清 bit 0。端口设置成功与实际地址不再别名是两条证据,不能省掉后一次检查。
这段代码针对固定 QEMU i440fx PC 与 SeaBIOS 环境。若端口 92 操作后仍未通过检测,就报告失败;本版没有实现 8042 或 BIOS 兼容回退。
GDT 提供最小分段约定
保护模式下,段寄存器不再直接表示实模式段基址。它们保存段选择子,CPU 通过 GDT 或 LDT 查出段描述符。本篇采用最小 GDT:
1 | |
这叫 flat segmentation。代码段和数据段的基址为 0,因此段内偏移等于线性地址。分页尚未开启,线性地址继续等于物理地址。第 11 篇开启分页后,这个等式才会被页表打断。
GDT 每项占 8 字节,GDTR 中的表限长是总字节数减 1。段描述符的限长则是最后一个合法偏移:20 位限长设为 0xfffff、粒度位 G 为 1 时,实际限长为 0xffffffff,覆盖 4 GiB。这两个限长服务不同的边界。
进入保护模式前用 cli 屏蔽可屏蔽中断;它不屏蔽 NMI,也不阻止异常。切换后先重装数据段和栈段,在 IDT 就绪前保持可屏蔽中断关闭。关键顺序见 Intel SDM Vol.3A 253668-078US §10.9.1:
1 | |
远跳转很关键。设置 CR0.PE 之后,CPU 需要一次加载新代码段描述符的控制转移,才能按保护模式代码段继续取指。只改 CR0 再继续顺序执行,容易停在一个很难诊断的位置。
BootInfo 是跨环境交接协议
第二阶段引导器进入保护模式,不等于它的局部变量自动变成内核能长期相信的协议。后续内核需要知道启动盘、载荷范围、内存图和显示状态。把这些信息收进一个显式结构,能避免靠散落寄存器和魔法地址传递状态。
| 偏移 | 类型 | 本版内容 |
|---|---|---|
| 0 | u32 | magic 0x31495442,字节为 BTI1 |
| 4、6 | u16、u16 | version 1、size 40 |
| 8 | u8 | 启动盘;后 3 字节保留 |
| 12、16、18 | u32、u16、u16 | 内存图地址、条目数、步长 24 |
| 20、24 | u32、u32 | 载荷暂存地址、有效文件长度 |
| 28 | u32 | 文本显存地址 |
| 32、34、36 | u16、u16、u16 | 列数、行数、每行字节数 |
| 38、39 | u8、u8 | BIOS 显示模式、每字符单元字节数 |
这是 boot/bootinfo.inc 中的紧凑字节协议,尚未依赖 C 的结构体对齐。BootInfo 位于 [0x5000,0x5028),内存图另占 [0x5100,0x5400),容量 32 条;记录的是物理地址。载荷地址为 0x10000,长度 20000,显示模式为 3、80 列、25 行。
结构里的 size 和 version 很早就值得保留。后面加入图形模式、页分配边界或磁盘参数时,读取端能知道自己拿到的是哪一版协议。第 05 篇跳进 C 时会把 BootInfo 指针传给 kernel_main。
本篇先在 32 位汇编里消费它:检查魔数,取出少量字段,输出保护模式标识。C 端读取还在下一篇。
直接写 VGA 文本显存
此前已经直接写过 VGA 文本显存。本篇切换前明确调用 BIOS 设置 Mode 3,随后在保护模式下继续直接写显存。彩色文本模式常用显存地址是 0xb8000,每个字符占两个字节:低字节是字符码,高字节是颜色属性。
1 | |
这只是固定 QEMU PC 文本模式下的早期输出手段。后续开启分页后,要保证这段物理地址仍然按预期映射;第 25 篇进入图形模式后,显示路径会换成像素缓冲。串口沿用第 03 篇的固定字符串探针,第 06 篇再建立通用格式化输出和 panic。
正常实验
构建并启动本篇版本后,先看屏幕是否出现 32 位阶段标识。这个标识由 0x8e00 开始的 32 位汇编代码输出。
1 | |
1 | |
Mode 3 设置会清屏,因此前面的 D03 信息保留在串口日志,最终屏幕只显示 PM 标识。
手动调试时先退出正常运行的 QEMU,在同一容器执行 make debug &,再启动 gdb-multiarch -q。不要与自动检查同时占用 GDB 的 1234 端口。GDB 命令:
1 | |
实测摘要保留在本篇证据文件中:
1 | |
CR0 的 PE 位应为 1,CS 应指向代码段选择子,DS 等数据段寄存器应指向数据段选择子。这个状态说明 CPU 已经按保护模式规则执行当前代码;它不说明分页已经开启。
故意破坏 GDT 或 A20
make check-pm 为故障生成独立镜像。坏 GDT 变体将 GDTR 的 limit 改为 7,只允许访问空描述符,选择子 0x08 对应的代码段已在表外。实际异常日志为:
1 | |
远跳转触发 #GP(0008);没有有效保护模式 IDT,异常递送又失败,升级到 #DF 和三重故障。QEMU 带 -no-reboot 因此退出。这是诊断到处理器故障链,尚未实现异常捕获或恢复。
A20 有两个独立变体。mbr-a20off.img 在检测前主动关闭 A20,随后打印 D04 A20 ENABLE,经端口 92 恢复后进入保护模式。mbr-a20fail.img 强制地址检测结果持续失败,输出 D04 A20 FAIL 后停机,不出现 PM OK。前者验证真实关闭后的恢复,后者验证持续失败时的控制流。
mbr-e820full.img 把内存图容量限制为 1。下一次 BIOS 调用前发现容量不足,输出 D04 E820 FAIL;不会越过缓冲区边界继续写入。正常版本实测得到 6 条记录,条目已在 GDB 中转储并逐字段检查。
本次 SeaBIOS 返回基础 20 字节条目,软件补上 attributes=1,统一保存成 24 字节。24 字节响应、错误签名和超过调用上限的分支尚未注入验证,不把正常启动当成这些分支的运行证明。
本篇留下的约定
第 04 篇以后,早期 BIOS 调用阶段结束。磁盘读取、内存图收集和显示状态准备都必须发生在切换前;进入保护模式后的代码按 32 位 flat segment 运行,早期输出先写 0xb8000。
BootInfo 成为引导器和内核之间的协议。协议字段宁可少,也要显式。寄存器可以传入口参数,不能替代长期可演进的结构。
前置源码为 4bac9fd2d2d68e288f85ee7df7b30aefe277d960。下载本篇完整源码,沿用第 01 篇工具链镜像。本篇版本:
1 | |
参考资料
- ACPI 6.5 System Address Map Interfaces:用于核对
INT 15h, E820h的输入、输出和 E820 优先于旧接口的边界。 - Intel 64 and IA-32 Architectures Software Developer Manuals:用于核对保护模式、GDT、控制寄存器和远跳转语义。
- QEMU GDB 使用说明:用于核对
-S、-gdb与寄存器观察路径。
练习与导航
- 在 GDB 中比较进入保护模式前后的
CS、DS和CR0,写下可见寄存器变化。 - 修改 VGA 文本属性字节,观察前景色和背景色变化。恢复后执行
make check。 - 修改
BootInfo声明的大小,使它与读取端预期不同,观察保护模式入口是否拒绝输出成功标识。
上一篇:03 - 两阶段引导。下一篇:05 - 跳进 C。

