第 02 篇的启动扇区已经能被 BIOS 执行,并在屏幕上显示启动盘编号。继续增加读盘、内存图和模式切换代码时,第一扇区的空间很快就会用完。本篇让第一阶段读取一个更大的第二阶段,再由第二阶段读取并校验载荷。

这一版读取 20000 字节的确定性测试数据,分两次放到低端内存,验证首尾标识与校验和后停机。载荷仍是数据,没有执行 C 内核,也没有进入保护模式。

两次加载分别解决什么

BIOS 先读取 LBA 0。第一阶段只有 512 字节,末尾保留字节 55 aa,偏移 446–509 仍留给未来分区表。第二阶段占用 8 个扇区,第一阶段将其装入物理地址 0x8000 后远跳转。

1
2
3
4
5
6
7
8
9
10
BIOS -> MBR at 0000:7c00
|
| INT 13h AH=42h: LBA 1..8
v
stage2 at 0000:8000
|
| read header at LBA 9, validate sizes
| read payload at LBA 10, verify contents
v
payload at 1000:0000, then HALT

第一阶段不需要理解载荷头,只需要知道第二阶段的固定位置。第二阶段有 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
2
3
4
5
early stack   grows down from 0x7c00
MBR [0x7c00, 0x7e00)
stage2 [0x8000, 0x9000)
header [0x9000, 0x9200)
payload [0x10000, 0x20000)

这些地址都低于 1 MiB,本篇不依赖 A20 开启。暂存地址按段:偏移表示为 1000:0000;它不是后续 C 内核的最终链接地址。第 04、05 篇会继续建立模式和装载约定。

先探测 EDD,再传入读盘请求

传统 CHS 接口使用柱面、磁头、扇区。这里采用 BIOS EDD 扩展接口,按线性 LBA 表示位置。第一阶段先执行探测:

1
2
3
4
5
6
7
8
9
mov ah, 0x41
mov bx, 0x55aa
mov dl, [boot_drive]
int 0x13
jc error
cmp bx, 0xaa55
jne error
test cx, 1
jz error

成功条件包括 CF 清零、返回签名正确,以及 CX bit 0 表示支持 packet 访问。不能只看 CF。输入仍使用 BIOS 传入并保存的 DL,依据 Phoenix EDD 1.1 §3.2.1 原厂文档镜像

扩展读使用 AH=42hDS:SI 指向 Disk Address Packet。第一阶段的请求形状是:

1
2
3
4
5
6
7
dap:
db 0x10
db 0
dw STAGE2_SECTORS
dw STAGE2_LOAD
dw 0
dq STAGE2_LBA

前两字节是长度与保留字段,接着是块数、缓冲区偏移与段、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
2
3
4
5
file bytes = 20000
sectors = ceil(20000 / 512) = 40

read 1: LBA 10, 32 sectors -> 1000:0000 -> 0x10000
read 2: LBA 42, 8 sectors -> 1400:0000 -> 0x14000

读取区间覆盖有效数据以及末尾扇区的填充字节。校验循环只处理头部声明的 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_headerverify_payload 两段代码。

构建与正常启动

沿用第 01 篇已经构建的 localhost/build-an-os:day01 工具链镜像。下载附件后,在新的目录执行:

1
2
3
unzip os-day-03-source.zip
cd os-day-03/examples/build-an-os
podman run --rm -it -v "$PWD:/work" -w /work localhost/build-an-os:day01 sh

进入容器后执行:

1
2
3
make build
make check
make run

实际屏幕如下。退出 curses 时,用 Alt+2,或依次按 Esc2 切到 monitor,输入 quit 回车。

第03篇实际启动屏幕:两阶段读取和校验完成

1
2
3
4
OSDAY03 DL=80 S1
D03 STAGE2 OK
D03 PAYLOAD OK
D03 HALT

第一行来自 MBR,其余来自第二阶段。载荷验证完成后使用 clihlt 循环停住;该版本不会返回 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
make check-layout PAYLOAD_BYTES=65537

实测返回非零,输出包含 payload size 65537 exceeds max 65536。运行时检查仍然需要存在,因为镜像可能在生成后被改坏,宿主工具的检查无法替 BIOS 入口保证输入完整。

本篇源码已通过 make -B check,完整记录在 docs/day03-evidence.md。检查程序使用超时结束主动停机的 QEMU,再核对日志标识;超时本身不代表实验成功。

练习与导航

  1. 手算 65536 字节载荷的四次读取,列出每次 LBA、段值和物理区间。
  2. 将块长改为 16,核对读取范围;再改为 31,观察构建检查为何拒绝它。
  3. 同时修改两个非标识字节,让总和保持不变,解释为什么简单校验和不提供真实性保证。

上一篇:02 - 第一个启动扇区。下一篇:04 - 进入保护模式