第 01 篇得到了一块全零磁盘。QEMU 能运行固件,GDB 能控制 CPU,但 BIOS 最终只显示 No bootable device.。磁盘里没有可执行的引导入口,工具链生成的 ELF 文件也没有被加载。

本篇把一段 16 位机器码写进磁盘第一个扇区。BIOS 读取它,程序准备自己的段寄存器和栈,输出固定标识,再停在可检查的循环中。改坏末尾签名后,同一份机器码将不再得到执行机会。

从第一个扇区交出执行权

在本系列固定的 QEMU PC 和 SeaBIOS 环境里,固件尝试从 IDE 硬盘启动时,会读取第一个逻辑扇区,把内容放到物理地址 0x7c00。通过有效性检查后,执行流转移到这块内存。

磁盘上的扇区编号、文件偏移和内存地址是三个不同的量:

1
2
3
4
5
6
7
8
9
镜像文件
偏移 0 … 511:第一个 512 字节扇区
│ BIOS 读取

客体内存
0x7c00 … 0x7dff:刚读入的同一组字节
│ CPU 取指

设置段与栈 → 输出标识 → 停止

0x7c00 不是 NASM 决定的磁盘位置。固件先决定加载位置,汇编代码再按照这个约定计算地址。本篇使用二进制输出格式,不再把带有 ELF 文件头的产物当成引导扇区。

BIOS 的加载不等于创建了 C 运行环境。它没有为后续函数建立本系列约定的调用栈,也没有保证数据段寄存器等于代码期待的值。启动入口必须把这些前提写成指令。

512 字节怎样分配

硬盘采用 MBR 布局时,第一个扇区还需要给分区表保留空间。本篇没有文件系统和有效分区,但从开始就把这段空间空出来,后面增加 FAT16 时不必挤掉已经增长的启动程序。

文件偏移 字节数 本篇用途
0–445 446 启动代码、字符串和填充
446–509 64 四个 16 字节分区表项,当前全零
510–511 2 引导签名,按字节存放 55 aa

NASM 的 dw 0xaa55 写出一个 16 位整数。x86 采用小端字节顺序,因此文件中的低地址字节是 0x55,下一个字节是 0xaa。读十六进制转储时,要区分“整数值”与“两个字节的排列”。

签名只表示这份扇区通过了固件的某项格式检查。它不证明前 446 字节是正确代码,也不保证启动代码不会覆盖自己。带有签名的全零程序仍然可能跑飞;签名正确与入口正确需要分别验证。

先固定段寄存器,再使用标签

实模式常用的地址计算是 段值 × 16 + 偏移0000:7c0007c0:0000 都能表示物理地址 0x7c00。不同表示法指向同一处内存,不代表程序可以随意混用它们。

如果汇编器把字符串标签编码成接近 0x7c00 的偏移,而运行时 DS 又带着非零段值,DS:SI 访问的地址就可能偏离字符串。看见屏幕乱码时,应先检查地址计算,而不是先怀疑字库。

org 0x7c00 告诉 NASM,二进制文件装载后的起始地址按 0x7c00 计算。它改变标签对应的地址,不会在输出文件前面自动插入 0x7c00 字节,也不会把 CPU 的 CSDS 改成某个值。bits 16 同样只控制默认指令编码方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bits 16
org 0x7c00

start:
cli
cld
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00
sti

mov [boot_drive], dl

本篇入口通过相对跳转在代码内部移动,不用未规范化的 CS 参与数据寻址。数据段与栈显式初始化;把 DSSS 设为零,让对应的低端偏移直接落到预期物理地址。写显存之前,ES 会另设为 VGA 文本缓冲区所在段。

修改栈之前先执行 cli,防止可屏蔽中断在新旧栈状态交替时进入。SS:SP 准备好后,程序内部的 call 和可能发生的 BIOS 中断处理才有了可以保存返回现场的位置。cli 不会屏蔽 NMI,它也不能修复任意损坏的执行环境;本篇只在固定模拟机的早期入口使用这个顺序。

栈向低地址增长。把栈顶放在启动扇区下方,是为了避免第一次压栈就覆盖从 0x7c00 开始的指令。这个选择仍然有容量边界,后续加载器、内存图缓冲区和内核栈需要统一安排,不能让各篇各自随意挑一块地址。

cld 把方向标志清零。字符串指令 lodsb 随后从 DS:SI 读取一个字节,再增加 SI。如果省略这一步,循环是否向后扫描就依赖进入时的 DF 状态。

启动盘编号也属于交接信息

SeaBIOS 把启动盘编号放在 DL 中。本篇虽然不读取第二个扇区,仍在入口保存这个值。后面的 BIOS 读盘服务需要它,不能因为本机恰好从第一块硬盘启动,就把所有读盘请求都硬编码为 0x80

保存应发生在可能改动 DX 的代码之前。打印函数、调试端口输出或随手使用的临时寄存器,都可能覆盖它。跨阶段传递的信息需要一个明确存放位置,而不是寄希望于若干函数之后寄存器仍未变化。

输出与停止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    mov ax, 0xb800
mov es, ax
xor di, di
mov ax, 0x0720
mov cx, 80 * 25
rep stosw
xor di, di
mov si, message

.write:
lodsb
test al, al
jz .done
mov ah, 0x0f
stosw
jmp .write

.done:
mov al, [boot_drive]
call write_hex8

hang:
hlt
jmp hang

本篇直接写 VGA 文本缓冲区。固定模拟机的文本模式把字符格映射在物理地址 0xb8000;实模式中可以用 ES=0xb800、从零开始的 DI 表达这个地址。每个字符格占两个字节,低字节是字符编码,高字节是颜色属性。

代码把 AH 设为 0x0f,与 AL 中的字符组成一个字,再用 stosw 写入 ES:DI。DF 为零时,DI 随后增加 2。程序先用空格清除 80×25 个字符格,再从屏幕左上角写标识,不提供滚动、光标跟随或通用格式化;第 06 篇才会把这些最小输出路径组织成内核诊断接口。

输出循环必须知道字符串在哪里结束。本篇用零字节作为终止符,读取到零后离开循环;缺失终止符会导致代码继续扫描后面的指令或填充区。GDB 中检查 DSSI 和标签附近字节,可以把“输出服务有问题”和“传入地址有问题”分开。

停止位置采用 hlt 循环。本篇在准备栈后执行了 sti,因此 BIOS 时钟中断仍可唤醒处理器;中断返回后,短跳转让处理器再次执行 hlt。这里停止的是自制程序继续向后的执行,不是冻结整台模拟机。宿主上的 QEMU 进程仍然存在,可以接受调试器连接。循环还避免恢复执行后落入紧邻的字符串或填充字节。

构建这个版本

本篇从 e1a73a0ea7e650814f40dfe18205d28866401d58 的第 01 篇源码继续,完成版本为 bed109258bcdcc94fa8483af742312dc76d126e7。完整源码可从本篇附件取得。

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

镜像沿用第 01 篇已经构建的工具链。还没有该镜像时,先按照第 01 篇的 Dockerfile 构建命令建立它。以下命令都在容器内、配套项目根目录执行。

1
2
3
4
5
make build
wc -c build/mbr.bin
od -An -tx1 -j510 -N2 build/mbr.bin
make check
make run

make run 使用 curses 把 VGA 文本模式显示到终端。终端至少留出 80 列、25 行;退出时按 Alt+2,或先按 Esc 紧接着按 2,切到 QEMU monitor,输入 quit 并回车。这次实测通过发送 Esc2quit 返回 Shell。它与第 01 篇 -nographicCtrl-a x 退出方式不同。

不使用交互终端时,可以执行 make screenshot,从 QEMU 直接导出 build/day02-screen.pngmake run 设置 UTF-8 locale,避免 curses 转换 VGA 字符集时输出编码警告。

二进制编译完成后,先检查大小和最后两个字节,再启动 QEMU。这样,NASM 输出错误、镜像拼装错误和 CPU 运行错误不会被混成一个黑屏现象。

正常启动与坏签名实验

2026-09-05,沿用第 01 篇固定环境,GDB 在 0x7c36hlt 处停下,显存字符解码为 OSDAY02 DL=80。其中 80 是启动盘编号的十六进制表示。图像直接取自本篇 QEMU 的屏幕转储:

第02篇实际运行屏幕:OSDAY02 DL=80

1
2
3
4
MBR_EIP=0x7c36
0xb8000: 0x4f 0x0f 0x53 0x0f 0x44 0x0f 0x41 0x0f
0xb8008: 0x59 0x0f 0x30 0x0f 0x32 0x0f 0x20 0x0f
0xb8010: 0x44 0x0f 0x4c 0x0f 0x3d 0x0f 0x38 0x0f

Makefile 同时生成 build/mbr-badsig.img。启动这份镜像时,实测固件输出:

1
2
Booting from Hard Disk...
Boot failed: not a bootable disk

make check 自动执行有效镜像和坏签名镜像的检查,并继续保留第 01 篇的 ELF、漏符号与空盘复位实验。完整记录见附件 docs/day02-evidence.md

故障实验只破坏字节 510–511,保留前面的机器码。若固件拒绝该磁盘,而有效签名版本进入了固定停止点,就能把执行差异归因到启动入口的有效性检查。

单凭“没有看见字符串”还不够。程序可能已经执行,但输出函数或段地址出了错。因此检查同时观察固件输出和程序停止位置;需要时在 0x7c00 或汇编清单给出的标签地址设断点,读取寄存器和显存。

这两种故障的定位位置不同。未通过签名检查时先检查镜像头尾;已进入扇区但显示异常时,检查 CS:IPDS:SIES:DI、栈和显存属性字节。只有一个扇区的程序,也已经存在文件格式与执行现场两层证据。

一个扇区以后

446 字节的启动区足够完成入口整理和最小输出,但无法持续容纳内存探测、内核加载和错误诊断。直接删掉分区表预留区只能暂时增加几十字节,不能解决程序增长问题。

第 03 篇会把第一阶段保留为短入口,用 BIOS 读盘服务加载第二阶段。那时磁盘位置、读入长度和内存缓冲区将成为新的交接约定。

练习与资料

  1. 改变输出字符串,在编译清单中找到它的位置,解释为什么字符串变长可能导致填充表达式变成负数。
  2. 保留签名,只把数据段初始化去掉,用 GDB 对比字符串地址。记录实际初值,不把一次恰好可用当成可依赖的约定。
  3. dw 0xaa55 改成两个等价的 db 声明,比较生成文件是否逐字节相同。

SeaBIOS 1.16.2 的启动实现给出了扇区加载、签名判断与 DL 传递;NASM 的 bin 与 ORG 文档说明二进制输出和地址起点。curses 文本显示与 monitor 分别见 QEMU 启动参数monitor 命令。滚动手册用于核查选项含义,实测版本仍是 NASM 2.16.01 与 QEMU 7.2.22。

上一篇:01 - 准备工具链。下一篇:03 - 两阶段引导