第 03 篇已经让第一阶段启动扇区加载第二阶段,也让第二阶段读取载荷并检查边界。它仍然停留在实模式。实模式下可以方便调用 BIOS,也能通过操作数前缀使用 32 位寄存器;后续内核需要的平坦地址空间和权限隔离仍要建立在保护模式上。

本篇完成第一次处理器模式切换:在进入保护模式前收集 E820 内存图,打开 A20,建立 GDT,设置 CR0.PE,再用远跳转刷新代码段。切换完成后,32 位代码直接写 VGA 文本显存,GDB 能观察到保护模式状态。BIOS 调用到这里为止,后面的内核不能再按实模式方式直接调用 int 10hint 13h

为什么先把 BIOS 调用做完

BIOS 服务是实模式时代的接口。第 03 篇依赖它读盘;本篇还要依赖它查询内存图。进入保护模式后,CPU 的段解释、地址宽度和中断入口都变了。没有虚拟 8086 模式、实模式回跳或专门 BIOS 调用桥时,内核不能继续把 int 15h 当作普通函数使用。

切换前后的准备顺序如下:

1
2
3
4
5
6
7
8
9
10
11
real mode
├─ read disk through BIOS EDD
├─ prepare BootInfo
├─ query memory map through INT 15h E820
├─ enable A20
└─ enter protected mode

protected mode
├─ use flat 32-bit code/data segments
├─ write VGA text memory directly
└─ pass BootInfo pointer to later kernel entry

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
2
3
4
5
6
struct boot_mmap_entry {
uint64_t base;
uint64_t length;
uint32_t type;
uint32_t acpi_attrs;
};

type == 1 表示普通可用内存,其他类型先按保留处理。第 10 篇物理页分配器会在这个基础上排除内核、启动器、显存和设备区域。当前阶段不分配页,只把 BIOS 给出的事实复制到 BootInfo

读取 E820 时返回签名必须是 SMAP,每条记录长度至少覆盖基础 20 字节,记录数量不能超过 BootInfo 指向的数组容量。容量不足时停止并报告错误,避免把不完整的地址图当成完整输入继续处理。最后一次成功返回的 EBX=0 表示本条是最后一条,仍需先保存本条。

用地址别名检查 A20

早期 x86 为了兼容 8086,有一个地址回绕历史包袱。A20 地址线关闭时,0x100000 会回绕到低地址区域。内核计划放在 1 MiB 附近,因此要先验证 A20。本篇保护模式入口仍位于低端内存,暂不搬运或执行载荷。

本篇实现两步:

1
2
3
4
5
6
check A20 by comparing wrapped addresses
if already enabled:
continue
else:
request enable through a simple platform path
check again

具体打开方式以源码为准:

1
2
3
4
in al, 0x92
or al, 2
and al, 0xfe
out 0x92, al

代码先比较 0000:0500ffff:0510,对应物理地址 0x5000x100500。临时写入不同字节,判断两处是否发生别名,随后恢复原值。若已经开启就继续;否则使用端口 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
2
3
index 0: null descriptor
index 1: 32-bit code, base 0, effective limit 0xffffffff
index 2: 32-bit data, base 0, effective limit 0xffffffff

这叫 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
2
3
4
5
lgdt [gdtr]
mov eax, cr0
or eax, 1
mov cr0, eax
jmp CODE_SEL:pm_entry

远跳转很关键。设置 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 行。

结构里的 sizeversion 很早就值得保留。后面加入图形模式、页分配边界或磁盘参数时,读取端能知道自己拿到的是哪一版协议。第 05 篇跳进 C 时会把 BootInfo 指针传给 kernel_main

本篇先在 32 位汇编里消费它:检查魔数,取出少量字段,输出保护模式标识。C 端读取还在下一篇。

直接写 VGA 文本显存

此前已经直接写过 VGA 文本显存。本篇切换前明确调用 BIOS 设置 Mode 3,随后在保护模式下继续直接写显存。彩色文本模式常用显存地址是 0xb8000,每个字符占两个字节:低字节是字符码,高字节是颜色属性。

1
2
3
address = 0xb8000 + (row * 80 + col) * 2
byte 0 = ASCII character
byte 1 = attribute, for example 0x0f

这只是固定 QEMU PC 文本模式下的早期输出手段。后续开启分页后,要保证这段物理地址仍然按预期映射;第 25 篇进入图形模式后,显示路径会换成像素缓冲。串口沿用第 03 篇的固定字符串探针,第 06 篇再建立通用格式化输出和 panic。

正常实验

构建并启动本篇版本后,先看屏幕是否出现 32 位阶段标识。这个标识由 0x8e00 开始的 32 位汇编代码输出。

1
2
3
4
5
6
unzip os-day-04-source.zip
cd os-day-04/examples/build-an-os
podman run --rm -it -v "$PWD:/work" -w /work localhost/build-an-os:day01 sh
make build
make check-pm
make run
1
D04 PM OK - BootInfo v1

第04篇实际运行屏幕:保护模式输出

Mode 3 设置会清屏,因此前面的 D03 信息保留在串口日志,最终屏幕只显示 PM 标识。

手动调试时先退出正常运行的 QEMU,在同一容器执行 make debug &,再启动 gdb-multiarch -q。不要与自动检查同时占用 GDB 的 1234 端口。GDB 命令:

1
2
3
4
5
6
7
8
9
set architecture i386
target remote 127.0.0.1:1234
hbreak *0x8e00
continue
info registers cr0 cs eip
si 10
info registers cr0 cs ds ss esp eip
monitor quit
quit

实测摘要保留在本篇证据文件中:

1
2
PM_ENTRY=0x8e00 CR0=0x11 CS=0x8
PM_READY=0x8e1f CR0=0x11 CS=0x8 DS=0x10 SS=0x10 ESP=0x7c00

CR0 的 PE 位应为 1,CS 应指向代码段选择子,DS 等数据段寄存器应指向数据段选择子。这个状态说明 CPU 已经按保护模式规则执行当前代码;它不说明分页已经开启。

故意破坏 GDT 或 A20

make check-pm 为故障生成独立镜像。坏 GDT 变体将 GDTR 的 limit 改为 7,只允许访问空描述符,选择子 0x08 对应的代码段已在表外。实际异常日志为:

1
2
3
4
5
0: v=0d e=0008 i=0 cpl=0 IP=0000:00008068 pc=00008068
check_exception old: 0xd new 0xd
1: v=08 e=0000 i=0 cpl=0 IP=0000:00008068 pc=00008068
check_exception old: 0x8 new 0xd
Triple fault

远跳转触发 #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
2
3
4
版本名:os-day-04
源码提交:a742727f01fb108032a76321c0a8995324ee98b9
构建目录:examples/build-an-os
实验记录:docs/day04-evidence.md

参考资料

练习与导航

  1. 在 GDB 中比较进入保护模式前后的 CSDSCR0,写下可见寄存器变化。
  2. 修改 VGA 文本属性字节,观察前景色和背景色变化。恢复后执行 make check
  3. 修改 BootInfo 声明的大小,使它与读取端预期不同,观察保护模式入口是否拒绝输出成功标识。

上一篇:03 - 两阶段引导。下一篇:05 - 跳进 C