从零编写操作系统 03 - 两阶段引导:加载比一个扇区更大的程序
第 02 篇的启动扇区已经能被 BIOS 执行,并在屏幕上显示启动盘编号。继续增加读盘、内存图和模式切换代码时,第一扇区的空间很快就会用完。本篇让第一阶段读取一个更大的第二阶段,再由第二阶段读取并校验载荷。
这一版读取 20000 字节的确定性测试数据,分两次放到低端内存,验证首尾标识与校验和后停机。载荷仍是数据,没有执行 C 内核,也没有进入保护模式。
两次加载分别解决什么
BIOS 先读取 LBA 0。第一阶段只有 512 字节,末尾保留字节 55 aa,偏移 446–509 仍留给未来分区表。第二阶段占用 8 个扇区,第一阶段将其装入物理地址 0x8000 后远跳转。
1 | |
第一阶段不需要理解载荷头,只需要知道第二阶段的固定位置。第二阶段有 4 KiB 空间,可以增加格式检查和分块循环。磁盘位置、装入地址、入口地址分别回答“从哪里读”“放到哪里”“从哪里执行”,不能因为第一阶段中它们较简单,就把三个概念合并。
本篇前置源码为 bed109258bcdcc94fa8483af742312dc76d126e7,完成源码为 4bac9fd2d2d68e288f85ee7df7b30aefe277d960。下载本篇完整源码,可在独立目录复现,不必切换已有工作目录的版本。
固定磁盘区间和暂存内存
布局写在 layout.mk,Makefile 据此生成 NASM 使用的 build/layout.inc。镜像写入和启动读取共享这些值。
| 区域 | 磁盘位置 | 本版内容 |
|---|---|---|
| MBR | LBA 0 | 第一阶段、零分区表区、签名 |
| 第二阶段 | LBA 1–8 | 4096 字节实模式程序 |
| 载荷头 | LBA 9 | 512 字节,记录格式和长度 |
| 载荷保留区 | LBA 10–137 | 128 个扇区,本次有效内容占 40 个扇区 |
| 后续文件系统 | 从 LBA 2048 开始预留 | 当前未建立 FAT16,也未填写分区表 |
完整镜像为 2 MiB。写入前检查第二阶段、头部、载荷区没有重叠,载荷区不能越过后续分区起点。暂存窗口固定为 [0x10000, 0x20000),最多 65536 字节。
1 | |
这些地址都低于 1 MiB,本篇不依赖 A20 开启。暂存地址按段:偏移表示为 1000:0000;它不是后续 C 内核的最终链接地址。第 04、05 篇会继续建立模式和装载约定。
先探测 EDD,再传入读盘请求
传统 CHS 接口使用柱面、磁头、扇区。这里采用 BIOS EDD 扩展接口,按线性 LBA 表示位置。第一阶段先执行探测:
1 | |
成功条件包括 CF 清零、返回签名正确,以及 CX bit 0 表示支持 packet 访问。不能只看 CF。输入仍使用 BIOS 传入并保存的 DL,依据 Phoenix EDD 1.1 §3.2.1 原厂文档镜像。
扩展读使用 AH=42h,DS:SI 指向 Disk Address Packet。第一阶段的请求形状是:
1 | |
前两字节是长度与保留字段,接着是块数、缓冲区偏移与段、64 位 LBA。EDD 1.1 将 offset 2 定义为最多 127 块的单字节计数,offset 3 为零;本例用 dw 写入不超过 127 的数,得到相同布局,见 §3.1.1。
SeaBIOS 1.16.2 的 int13ext_s 将计数字段声明为 u16。这个实现细节不等于可以忽略本篇采用的规范上限。
每次读取怎样推进地址
第二阶段每次最多读 32 个扇区,即 16384 字节。目的偏移始终为 0,每读一块,段值增加 扇区数 × 32,因为一个扇区 512 字节恰好等于 32 个段落单位。
1 | |
读取区间覆盖有效数据以及末尾扇区的填充字节。校验循环只处理头部声明的 20000 字节,不把填充算进载荷长度。
每次调用前重新设置 DAP 的计数、地址和 LBA。返回 CF 置位就停止,不把部分读取当作整块成功,也不尝试执行载荷。EDD 规范允许失败时回写已读块数,重试不能直接复用未经重置的计数。
本版的 32 扇区块长整除 64 KiB 窗口,暂存起点又位于窗口边界,因此每次读取不会跨物理 64 KiB 边界。构建检查拒绝破坏这个前提的块长。这是保守策略;SeaBIOS 的 process_op 检查的是总传输量是否超过 64 KiB,条件中没有缓冲区起始地址,两种限制不能混为一谈。
镜像里的长度不能直接相信
tools/mkpayload.c 生成测试载荷与头部。头部前 24 字节按小端保存以下字段,其余空间留作本实验的标识及保留字节。
| 偏移 | 字段 | 当前值或检查 |
|---|---|---|
| 0 | magic | 0x334f5342 |
| 4 | version | 1 |
| 8 | file_size | 20000;本版接受 64–65536 |
| 12 | memory_size | 不小于 file_size,且不超过 65536 |
| 16 | entry_offset | 小于 file_size;本版为 0,暂不执行 |
| 20 | checksum | 有效数据逐字节求和,取低 16 位 |
头部读取到 0x9000 后先检查上述约束,再决定读取多少扇区。memory_size 用于约束未来所需的内存范围,当前不会据此跳入内核或完成 BSS 初始化。
载荷读完还要比较首尾标识,并计算校验和。头合法、BIOS 报成功、内容匹配是三个不同条件。简单求和用于发现本实验中的损坏,不提供真实性保证,也无法检测所有多字节修改。
本版将坏头或内容不匹配统一输出为 D03 HEADER FAIL。这个文字表示载荷验证失败,不足以单独区分哪一个字段;进一步定位时应检查对应镜像和 validate_header、verify_payload 两段代码。
构建与正常启动
沿用第 01 篇已经构建的 localhost/build-an-os:day01 工具链镜像。下载附件后,在新的目录执行:
1 | |
进入容器后执行:
1 | |
实际屏幕如下。退出 curses 时,用 Alt+2,或依次按 Esc、2 切到 monitor,输入 quit 回车。
1 | |
第一行来自 MBR,其余来自第二阶段。载荷验证完成后使用 cli 与 hlt 循环停住;该版本不会返回 BIOS,也不会继续运行载荷。
检查脚本还通过 COM1 保存两个阶段二标识。这只是少量固定字符串的测试输出探针,当前没有通用控制台、格式化输出和 panic;第 06 篇再实现那些接口。make screenshot 可重新生成同一 QEMU 屏幕。
四类损坏怎样失败
make check 启动独立镜像验证以下情况。镜像之间不共享一次启动的状态。
| 镜像 | 修改 | 实际结果 |
|---|---|---|
mbr-truncated.img |
仅保留载荷开头 8 个扇区 | D03 READ FAIL |
mbr-badmagic.img |
将头部魔数改为 0 | D03 HEADER FAIL |
mbr-oversized-header.img |
声明 65537 字节 | D03 HEADER FAIL |
mbr-corrupt.img |
改变有效载荷偏移 100 的字节,保留头与首尾标识 | D03 HEADER FAIL |
四种镜像都先输出 D03 STAGE2 OK。这将失败位置限定到第二阶段,而不是 BIOS 签名检查或第一阶段跳转。坏签名镜像也继续保留,但它在 BIOS 层就会被拒绝。
截短镜像长 9216 字节。第二阶段读到合法头部,但第一次申请读取 32 个载荷扇区时已经超出磁盘末端,因此走读盘失败路径。
构建端也拒绝超大载荷声明。在容器中运行:
1 | |
实测返回非零,输出包含 payload size 65537 exceeds max 65536。运行时检查仍然需要存在,因为镜像可能在生成后被改坏,宿主工具的检查无法替 BIOS 入口保证输入完整。
本篇源码已通过 make -B check,完整记录在 docs/day03-evidence.md。检查程序使用超时结束主动停机的 QEMU,再核对日志标识;超时本身不代表实验成功。
练习与导航
- 手算 65536 字节载荷的四次读取,列出每次 LBA、段值和物理区间。
- 将块长改为 16,核对读取范围;再改为 31,观察构建检查为何拒绝它。
- 同时修改两个非标识字节,让总和保持不变,解释为什么简单校验和不提供真实性保证。
上一篇:02 - 第一个启动扇区。下一篇:04 - 进入保护模式。

