第一次在 QEMU 里跑通自己的 boot 引导程序时,屏幕上只有一个简单的字符串,但那一瞬间我确实盯着看了挺久。从按下电源键到操作系统接管硬件,中间那段“没人提过的环节”,其实只有 512 字节的汇编代码在起作用。这篇文章会把整个制作过程完整拆开:为什么引导程序恰好是 512 字节、BIOS 为什么要把它加载到 0x7C00、如何用汇编写一个能运行的引导扇区、怎么把它塞进磁盘镜像并在 QEMU 里验证,以及怎样从这 512 字节继续扩展出去,加载属于自己的第二阶段代码。
如果你刚接触操作系统底层开发,或者想做引导程序实验又没有真机条件,这篇内容基本可以当一份手把手的实践笔记来用。我已经把常见误区、恶心的字节序问题、以及调试手段都踩过一遍,下面的内容就是我基于实际测试整理出来的完整链路。
1. 上电后的第一秒钟:BIOS 如何找到并加载那 512 字节
1.1 从按下电源键到控制权移交
很多人写第一个引导程序时,心里最大的疑问是:我的代码到底是怎么被执行起来的?要回答这个问题,得先理清计算机上电后的执行顺序。
按下电源键之后,CPU 会从复位向量(Reset Vector)开始取指执行。在传统 x86 平台上,这一步意味着 CPU 会直接跳到主板固件(BIOS/UEFI)的入口,由固件完成自检、初始化内存、枚举外设等工作。等你按 F12 选了启动项,或者固件按顺序扫描启动设备时,真正的引导环节才刚开始。
固件扫描启动设备的方式非常“原始”:它把存储设备的第一个扇区(512 字节)读到内存中一个约定好的位置,然后检查这个扇区末尾两个字节是否为固定标志。如果是,就把控制权交给这段代码;如果不是,就继续找下一个启动设备。这就是引导扇区(Boot Sector)的由来。所有操作系统,包括你日常用的桌面系统,启动最初的入口逻辑都在这 512 字节里。
这段流程在真实硬件上运行极快,快到你看不到任何反馈。但在 QEMU 里,我们可以通过虚拟化环境把每一个环节都放慢、放大,甚至用调试器单步观察,这正是我用 QEMU 做实验的核心原因:真实硬件上出了问题你只能猜,QEMU 里出了问题可以直接看。
1.2 为什么是 0x7C00、512 字节和 0x55AA
这里有一个很经典的问题:BIOS 到底把引导扇区加载到哪个内存地址?答案是 0x7C00。为什么偏偏是这个地址?因为它属于历史遗留设计:在最早的 IBM PC 时代,内存布局是这样的:0x00000 到 0x003FF 是中断向量表,0x00400 到 0x004FF 是 BIOS 数据区,0x00500 到 0x7BFF 留给了操作系统和应用程序,0x7C00 开始则是引导程序的地盘。
当时的工程师计算过,要保证引导程序加载后不会覆盖关键数据区,同时还要给后续加载的内核或操作系统留出足够空间,0x7C00 正好是一个平衡点。这个值一旦定下来,就一直沿用至今。所以你写引导程序时,汇编代码开头必须是org 0x7C00,意思就是告诉汇编器:我的代码运行时位于内存的 0x7C00 处,所有标签、变量引用的地址都要按这个基址来计算。
引导扇区为什么是 512 字节?因为传统硬盘和软盘的物理扇区大小就是 512 字节。固件以“扇区”为最小读取单位,一次读一个扇区,读出来刚好就是 512 字节。引导标志 0x55AA 就放在第 510 和 511 字节(即扇区最后两个字节)。注意这里有个很隐蔽的字节序坑:x86 是小端存储,所以在汇编里写dw 0xAA55时,磁盘上实际呈现的字节顺序是 55 AA。BIOS 检查扇区末尾时,看到的是偏移 0x1FE 处为 0x55、0x1FF 处为 0xAA。初学者在这里最容易搞反:写了dw 0x55AA,结果 BIOS 不认。
1.3 QEMU 模拟环境为什么适合做这种实验
真机做引导实验有几个问题:一是每次修改都要重新写盘,麻烦且伤硬件;二是真机引导失败后的反馈非常有限,屏幕上要么黑屏要么报错,你根本不知道代码停在哪一步;三是有些主板的启动逻辑有自己的脾气,跟标准的 BIOS 行为有出入。
QEMU 把这些麻烦全消掉了。它内置了一个完整的 BIOS 实现,而且可以通过软盘镜像(-fda)、硬盘镜像(-hda)、光驱(-cdrom)等各种方式模拟启动设备。你写完代码编译成二进制,做一个镜像文件,QEMU 启动后就会按照真正的硬件流程去加载引导扇区。
更关键的是,QEMU 支持 GDB 调试和日志输出。我可以随时切到 QEMU monitor 查看寄存器状态和内存内容,也可以让小虚拟机每执行一条指令都记录日志。这种调试能力在真机上是完全不可想象的。所以我强烈建议所有做引导程序实验的人都先熟悉 QEMU 这套环境,它不是一个“将就能用”的模拟器,而是这块领域最顺手的实验平台。
2. 实验环境的最小闭环:QEMU、NASM 与磁盘镜像的准备
2.1 安装工具链:三条命令的事
做引导程序实验,工具链其实非常精简,绝大多数人电脑上都已经装了大部分组件。我用的是 Linux 环境,下面这三样是最核心的:
- QEMU:虚拟机,用来模拟 CPU、内存、BIOS 和磁盘设备。建议安装 qemu-system-x86,如果日常用的是图形界面环境,qemu-system-i386 通常也已经包含在内。
- NASM:汇编器。引导程序要用平面二进制格式(flat binary)输出,NASM 的
-f bin选项正好满足需求,这也是引导开发最常用的汇编器。 - dd:镜像制作工具。负责生成空白磁盘镜像、把编译好的二进制文件写入指定扇区。
在 Debian/Ubuntu 系系统上,一句命令就能装齐:
sudo apt install qemu-system-x86 nasmdd 是 GNU coreutils 自带的,正常系统都有。如果你用的是 macOS,dd 也存在,NASM 可以用 Homebrew 安装;Windows 上则建议装 WSL,然后在 WSL 里操作,这样 dd 的用法和 Linux 完全一致,省去很多移植麻烦。
2.2 最小工作目录:一切从零开始也够用
我建议你在一个干净目录下做实验,目录里只需要准备两类文件:汇编源代码和构建脚本。刚开始甚至不需要构建脚本,命令一行一行敲也完全没问题。
目录结构大致是这样:
bootlab/ ├── boot.asm # 引导扇区源码 ├── loader.asm # 第二阶段源码(后面会用到) ├── build.sh # 构建脚本(可选) └── disk.img # 生成的镜像文件构建脚本不是必须的,但它能避免你反复敲那一串 dd 和 nasm 命令。我习惯把构建脚本写好之后再改代码,因为引导程序开发是一个“改一行、编译、写盘、启动、看结果”的死循环,脚本能帮你把循环缩短到几秒钟。
2.3 软盘镜像还是硬盘镜像:实验阶段选软盘
制作启动镜像有两种常见选择:软盘镜像(Floppy Disk Image)和硬盘镜像(Hard Disk Image)。实验阶段我强烈推荐先用软盘镜像,原因有三点:
第一,软盘镜像结构简单,就是一个裸的扇区序列,没有 MBR 分区表、没有 GPT 头,引导扇区就是镜像的第一个扇区,BIOS 直接读它就行。硬盘镜像则往往带分区表,传统 BIOS 启动时会读取 MBR(0 号扇区),解析分区表,再找活动分区,链路更长,更容易出错。
第二,软盘镜像的尺寸非常规整。1.44MB 的软盘恰好是 2880 个扇区,用dd if=/dev/zero of=disk.img bs=512 count=2880一条命令就能生成。你不需要关心分区对齐这类事。
第三,QEMU 对软盘仿真非常成熟,支持度很高,默认就能通过-fda disk.img直接把它当作软盘 A 启动。
等你把软盘引导这条链路彻底跑通,再迁移到硬盘镜像(其实就是把引导扇区写成 MBR,并构造合法的分区表)就会发现简单很多。先软盘、后硬盘,这个顺序能帮你把“引导”和“分区”两件事分开理解。
3. 第一段引导代码:org、实模式、BIOS 中断和 0xAA55
3.1 最小引导扇区:代码骨架拆解
一切后来的复杂逻辑,都建立在下面这个最小骨架之上。这段代码的作用非常朴素:在屏幕上打印一行字符串,然后停机。
org 0x7C00 bits 16 start: ; 复位段寄存器:BIOS 跳转前并未保证 DS/ES 有确定值 xor ax, ax mov ds, ax mov es, ax ; 设置栈:引导阶段栈要放在不会覆盖代码的位置 mov ss, ax mov sp, 0x7C00 ; 切换为 80x25 彩色文本模式,顺便完成清屏 mov ax, 0x0003 int 0x10 ; 打印字符串 mov si, msg print_loop: lodsb ; 从 DS:SI 加载一个字节到 AL or al, al ; 判断是否为字符串结束符 0 jz done mov ah, 0x0E ; 调用 int 10h 的“电传输出”功能 int 0x10 jmp print_loop done: hlt ; 停机,等待外部中断 jmp done ; 防止万一被唤醒,继续停机 msg db "Hello from QEMU boot sector!", 0 ; 填充到第 510 字节 times 510-($-$$) db 0 ; 引导标志(小端存储,磁盘上表现为 55 AA) dw 0xAA55先解释org 0x7C00和bits 16。org指定了代码的“预期运行地址”,因为 BIOS 把引导扇区加载到 0x7C00,所以所有内存引用都要以 0x7C00 为基址。bits 16是告诉汇编器生成 16 位机器码,因为引导阶段 CPU 还处于实模式,指令默认按 16 位解析。
3.2 实模式下能做什么:BIOS 中断是唯一的“系统调用”
引导程序运行在 CPU 的实模式下。实模式的特点很多,但对我们现在最关键的一个特点是:内存访问方式是“段地址 × 16 + 偏移地址”,没有任何保护机制,程序可以任意访问内存、端口,甚至关机指令都能直接执行。
在这个阶段,你没有任何操作系统可以用,也不能调用 C 库,唯一能依赖的“标准库”是 BIOS 提供的中断服务。比如视频输出对应int 0x10,磁盘读写对应int 0x13,定时器和键盘读取也有对应的中断号。这些中断服务是固化在 BIOS 里的,只要实模式寄存器状态正确,就能像调用系统 API 一样使用它们。
上面代码里的打印循环,核心就两行:
mov ah, 0x0E int 0x10int 0x10是视频服务中断,AH=0x0E表示“以电传方式输出一个字符”,AL 寄存器里就是要输出的字符编码。为什么叫“电传输出”?因为这个模式会把字符一个接一个地打到屏幕上,光标自动前进,遇到换行也会处理。虽然简单粗暴,却是引导阶段最实用的输出方式。
这里有个很重要的细节:BIOS 中断服务本身可能要使用栈,所以在调用中断前必须保证栈是配置好的,否则程序会神秘地死机或乱跳。上面的mov ss, ax / mov sp, 0x7C00就是把栈设置在引导扇区代码下方,避免覆盖正在执行的代码区域。
3.3 字符串和填充:lodsb、$与$$的配合
打印字符串时,我用lodsb指令从DS:SI指向的内存中取一个字节到 AL,同时 SI 自动加一。字符串以 0 结尾,所以循环里用or al, al判断是否读到了字符串的结束符。这个思路跟 C 语言的printf内部实现其实是一回事,只不过 C 库帮你封装好了,这里得自己写循环。
代码末尾的两行填充也很有讲究:
times 510-($-$$) db 0 dw 0xAA55NASM 中$表示当前位置的偏移,$$表示当前段(在平面二进制模式下就是文件)的起始位置。$-$$就是已经生成代码的字节数。times指令表示重复执行后面的部分多次,所以这两行的意思是:把从文件开头到当前为止的所有字节补齐到 510 字节,然后再写上两个字节的引导标志 0xAA55。这样整个引导扇区正好 512 字节。
这个填充是必须的。如果代码本身不足 510 字节,文件末尾就达不到偏移 0x1FE 的位置,BIOS 检查不到魔数,自然不会认为是引导扇区。如果代码本身超过 510 字节,NASM 会报错,因为最后 2 字节已经没有位置放了,这也从侧面提醒你:引导扇区只有 512 字节空间,代码要尽量精炼。
3.4 为什么入坑第一个引导程序最容易卡在“屏幕上什么都没有”
我见过不少同学第一次跑引导程序,QEMU 启动了,但屏幕黑屏或直接显示“Boot failed”,这时候第一反应往往是“我代码写错了”。其实九成情况下问题出在编译或写盘环节,而不是逻辑本身。
最容易犯的三个错误,我可以提前预警:
- 用
nasm -f elf32或-f elf64编译:这样生成的不是纯二进制,而是带 ELF 文件头的目标文件,BIOS 读到的前 512 字节里只有一堆文件头,根本执行不了。 - 镜像写入位置错了:引导程序必须写在磁盘镜像的第一物理扇区(偏移 0 开始),而不是文件开头预留了什么头。
- 魔数写反了:写成
dw 0x55AA而不是dw 0xAA55,BIOS 不认。
后面我会在调试章节再列一个更完整的错误对照表,但提前知道这三个,已经能让你少走很多弯路。
4. 完整的构建链路:编译、写入镜像、启动验证
4.1 编译:NASM 的平面二进制模式
代码写好后,编译命令非常简单:
nasm -f bin boot.asm -o boot.bin-f bin表示输出平面二进制文件,也就是没有任何文件头或重定位信息的裸机器码。boot.bin的大小应该恰好是 512 字节。你可以确认一下:
ls -l boot.bin如果看到的是 512,说明代码长度刚好;如果比 512 小,说明填充逻辑有误,或者你可能漏了times那两行;如果大于 512,说明代码超长了,NASM 会报错。经验之谈:第一次实验时,println 的字符串别写太长,否则光是那几十个字符就可能把 510 字节极限挤爆。
4.2 制作镜像:dd 的精确写入姿势
接下来要把 boot.bin 写到磁盘镜像的第一个扇区。先准备一个标准的 1.44MB 软盘镜像:
dd if=/dev/zero of=disk.img bs=512 count=2880这条命令会生成一个 1.44MB、全部由 0 填充的空白镜像。然后写入引导扇区:
dd if=boot.bin of=disk.img conv=notrunc这里conv=notrunc很关键,意思是“写入后不要截断目标文件”。如果不加这个参数,dd 在写入完成后可能把文件截断成 512 字节,整个镜像就废了。
为了验证引导扇区有没有写对,可以用十六进制查看工具:
xxd -a disk.img | head -1 xxd -a disk.img | tail -1重点关注最后两个字节,应该是55 aa。如果看到aa 55或者别的值,就要回头检查引导标志的写法了。
4.3 启动 QEMU:第一次看到自己打的字
镜像准备好之后,启动命令就一句话:
qemu-system-i386 -fda disk.img-QEMU 会启动一个带图形窗口的虚拟 PC,然后按照 BIOS 流程从软驱启动。如果你前面的步骤都正确,你会看到窗口中央出现一行字符:Hello from QEMU boot sector!。
如果不想每次都打开图形窗口,也可以用无图形模式,适合快速测试:
qemu-system-i386 -fda disk.img -nographic不过要注意,-nographic模式下 BIOS 输出和我们的字符输出都会走串口或直接映射到终端,行为与图形模式略有差异,第一次实验还是建议先用带窗口的方式,能直观地看到启动结果。
4.4 第一次驱动启动的“正反馈”标准
很多人第一次跑通后其实还有点懵:屏幕上出现字符,然后呢?这时我通常会建议再做一个小实验:故意把魔数改错,重新编译,再启动一次,看 QEMU 是否报错。比如把dw 0xAA55改成dw 0x55AA,启动后 QEMU 的 BIOS 会提示类似“No bootable device”或直接停在那里,进不了你写的代码。
这个试验的价值在于建立“正反馈系统”:你知道什么情况下代码会被执行,什么情况下代码根本不会被加载。一旦理解了“魔数没通过,代码连执行的机会都没有”这个逻辑,后续排查问题就会有条理得多,不会在代码逻辑里瞎猜。
5. 512 字节不够用:用引导扇区加载第二阶段代码
5.1 为什么一个扇区做不了正经事
引导扇区只有 512 字节,扣掉最后的 2 字节魔数,实际可用空间只有 510 字节。510 字节能干什么?勉强写一个打印字符串的程序,但绝对装不下文件系统解析代码、内存探测代码、保护模式切换代码。所以所有操作系统的引导流程都是分阶段进行的:第一阶段是极小的引导扇区,它的唯一使命就是把第二阶段加载器读进内存,然后跳转过去。
第二阶段代码可以用 C 写,可以编译成更大的二进制,可以做更多正经事。关键在于,引导扇区里必须有能力通过 BIOS 中断去读取磁盘上后续的扇区。这个能力来自int 0x13。如果说int 0x10是打印机,那int 0x13就是在实模式下读写磁盘的万能钥匙。
int 0x13功能号 AH=0x02 表示“读取扇区”,它的参数相当多,我整理成表格:
| 寄存器 | 作用 |
|---|---|
| AH | 功能号,0x02 表示读扇区 |
| AL | 要读取的扇区数(不能为 0) |
| CH | 柱面号的低 8 位 |
| CL | 低 6 位是起始扇区号,高 2 位是柱面号的高 2 位 |
| DH | 磁头号 |
| DL | 驱动器号(0x00 表示软驱 A,0x80 表示第一个硬盘) |
| ES:BX | 数据缓冲区的段地址和偏移地址 |
这些参数看着麻烦,但如果只读软盘镜像上第二个扇区(柱面 0、磁头 0、扇区 2),其实非常简单。
5.2 实战:让引导扇区读取并跳转到 loader
下面这段代码是完整可运行的。它在引导扇区里完成三件事:设置 CPU 环境、利用int 0x13把第二个扇区读进内存 0x9000 处、执行远跳转进入第二阶段。
org 0x7C00 bits 16 LOADER_ADDR equ 0x9000 ; 第二阶段加载到物理地址 0x9000 LOADER_SECTOR equ 2 ; 第二阶段所在扇区(第二个扇区) LOADER_COUNT equ 1 ; 要读取的扇区数 start: xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7C00 ; 读取第二个扇区到 0x0000:0x9000(物理地址 0x9000) mov ax, 0 mov es, ax mov bx, LOADER_ADDR mov ah, 0x02 mov al, LOADER_COUNT mov ch, 0 ; 柱面 0 mov cl, LOADER_SECTOR ; 扇区 2(扇区编号从 1 开始) mov dh, 0 ; 磁头 0 mov dl, 0x00 ; 软驱 A int 0x13 jc read_error ; 进位标志为 1 表示读取失败 ; 远跳转到第二阶段:CS=0x0000, IP=0x9000 jmp 0x0000:LOADER_ADDR read_error: mov si, err_msg err_loop: lodsb or al, al jz die mov ah, 0x0E int 0x10 jmp err_loop die: hlt jmp die err_msg db "Disk read failed!", 0 times 510-($-$$) db 0 dw 0xAA55这里有三个容易犯的坑,我在代码里都用注释标出来了。
第一,int 0x13读取成功后,判断结果要看进位标志 CF。如果 CF=1,说明读取失败,错误码存在 AH 里。很多初学者以为al返回读取的扇区数就够了,完全无视 CF,结果后续跳转到一个全是零的内存区域,程序行为变得非常诡异。
第二,扇区号是从 1 开始的,不是从 0 开始。第一个扇区是引导扇区,编号是 1;所以第二阶段如果放在第二个扇区,CL应该写 2。有人顺手写 0,结果读到的还是引导扇区自己。
第三,跳转语句jmp 0x0000:LOADER_ADDR是远跳转。它的含义是把 CS 设置为 0x0000,IP 设置为 0x9000。由于物理地址 = CS×16 + IP,这里计算为 0×16 + 0x9000 = 0x9000,正好是刚才加载第二阶段的缓冲区地址,所以执行流能准确进入 loader 代码。
5.3 第二阶段代码:验证 org 与 IP 的匹配关系
对应的 loader 代码如下:
org 0x9000 bits 16 start: ; 远跳转进入后:CS=0x0000, IP=0x9000,物理地址 0x9000 ; 重建 DS 与栈 xor ax, ax mov ds, ax mov ss, ax mov sp, 0x7C00 mov si, msg print_loop: lodsb or al, al jz done mov ah, 0x0E int 0x10 jmp print_loop done: hlt jmp done msg db "This is stage-2 loader.", 0 times 512-($-$$) db 0这段代码的org是许多人的拦路虎。为什么 loader 要用org 0x9000?因为跳转指令jmp 0x0000:LOADER_ADDR执行后,CS=0x0000、IP=0x9000,而 loader 的机器码就位于物理地址 0x9000。1739所以 loader 内所有标签的相对位置都必须按 IP=0x9000 来计算。org 0x9000正是告诉汇编器这一点。如果你把 loader 的 org 改成 0,编译出来的代码在跳转后就会指向错误的内存偏移,程序必然崩溃。
这个“远跳转后的 IP 必须与加载地址匹配”的规则,在引导程序开发中会反复出现。每当你把一段代码加载到一个新地址,都必须仔细搞清楚:跳转时 CS 是多少?IP 是多少?代码里引用的数据偏移是按哪个 org 编译的?三者的组合必须形成正确的物理地址。
5.4 构建第二步:完整镜像的制作
编译 loader:
nasm -f bin loader.asm -o loader.bin制作镜像的命令多了一条:
dd if=/dev/zero of=disk.img bs=512 count=2880 dd if=boot.bin of=disk.img conv=notrunc dd if=loader.bin of=disk.img bs=512 seek=1 conv=notrunc这里的seek=1是关键,它表示从目标文件的第 1 个扇区偏移处开始写入。注意:扇区计数从 0 开始,所以seek=1对应的是镜像的第二个扇区,也就是磁盘上的第二个物理扇区,逻辑扇区号为 2,与 boot.asm 里读扇区时CL=2正好对应。
然后启动:
qemu-system-i386 -fda disk.img这次你应该看到两行输出:第一行来自引导扇区本身的打印逻辑,第二行来自第二阶段 loader。如果你只看到第一行,说明磁盘读取失败或跳转失败;如果什么都看不到,先回退到只写引导扇区的版本,逐级排查。
6. 调试手段与高频坑位:我在 QEMU 里踩过的那些问题
6.1 症状、原因与排查对照表
引导程序开发有个特点:出错后的现场往往只有一个黑屏或一串乱码,连报错信息都没有。所以我习惯把常见问题整理成一张“症状到原因”的对照表,排查时从症状倒推,效率会高很多。
| 症状 | 最常见原因 | 排查方向 |
|---|---|---|
| QEMU 直接黑屏,无任何输出 | 引导扇区魔数不对,或根本没进入引导 | 检查最后两字节是否为 55 AA,检查镜像文件大小 |
| 屏幕出现乱码或字符串残缺 | 段寄存器未初始化,DS 与 org 基址不匹配 | 代码开头显式复位 DS/ES;确认 org 与加载地址一致 |
| 打印几行后突然卡死 | 栈未设置,调用中断时破坏了代码或数据 | 显式初始化 SS:SP;避免栈与代码区重叠 |
| 能进入引导程序,但读盘失败 | int 0x13 参数错误,比如扇区号从 0 开始 | 检查 CL 值、DL 驱动器号、ES:BX 缓冲区地址 |
| 引导程序正常,loader 无输出 | 远跳转地址与 loader 的 org 不一致 | 检查 jmp 的 CS:IP 与 loader 内存地址的关系 |
| 编译后文件大于 512 字节 | 代码超长,或用了 ELF 目标格式 | 精简代码,确认 -f bin |
这张表里每一行都是我在实际操作中遇到过的,尤其是“段寄存器未初始化”这一条,它在没有保护模式的引导阶段特别隐蔽。BIOS 跳转到引导扇区时,只保证了 CS:IP 指向 0x7C00,但 DS 和 ES 的值完全取决于 BIOS 的实现,可能指向任意区域。如果你直接使用mov si, msg,而 DS 还是 BIOS 遗留的值,那么 CPU 取字符串的地址就是错的,打印出来全是乱码。
6.2 QEMU monitor:不用加一行日志就能看现场
QEMU 自带一个监视器(monitor),在里面可以直接查看虚拟机内部的完整状态,这个工具调试引导程序时极其好用。
启动 QEMU 时加上-monitor stdio,QEMU 就会把监视器输出到当前终端。不过要注意,加了-monitor stdio后,如果还用了-nographic,终端输入会被 QEMU 和 BIOS 输出争抢,体验比较差。建议用图形模式启动,然后在 QEMU 窗口里按Ctrl + Alt + 2切换到监视器,按Ctrl + Alt + 1切回虚拟机屏幕。
在监视器里,有几个命令非常常用:
info registers:查看全部寄存器的当前值,包括 CS、IP、DS、SS 等。如果你怀疑“远跳转后 IP 不对”,一条命令就能立刻验证。xp /16bx 0x9000:查看物理地址 0x9000 处的 16 个字节。用来确认磁盘数据是否真正读到了目标内存,这一步能快速区分“读盘失败”和“跳转失败”。info mice、info usb之类的不太用得上,不是重点。
一个典型的排查过程是这样的:启动镜像后切换到 monitor,xp /16bx 0x7C00查看引导扇区是否在内存中,info registers看 CS:IP 是否停在0x0000:0x7C00,然后在监视器里慢慢翻页,对比代码和原文件。这个过程几乎是物理级别的“开箱验货”,比盲猜高效太多。
6.3 中断日志和 GDB:进阶调试的两把刀
QEMU 还支持记录 CPU 执行过程中的所有中断与异常,命令是:
qemu-system-i386 -fda disk.img -d int -D qemu.log-d int会让 QEMU 把所有中断/异常的触发记录到qemu.log。打开这个日志文件,你能看到 BIOS 的初始化流程、int 0x13的调用时机、甚至异常触发的位置。注意日志会非常庞大,所以只建议在精确定位时使用。
更进阶的调试工具是 GDB。QEMU 支持 GDB 远程调试协议:
qemu-system-i386 -fda disk.img -s -S-s让 QEMU 在 TCP 端口 1234 上等待 GDB 连接,-S则让 CPU 在上电后立刻暂停,等待调试器接管。然后另开一个终端:
gdb (gdb) target remote localhost:1234 (gdb) layout asm这样你就可以像调试普通程序一样,对引导代码单步执行、下断点、查看寄存器。比如说你想知道自己的代码是否在某一行之后才出错,在对应内存地址下个断点就能验证,不用反复烧写镜像。
不过要提醒一点:GDB 远程调试引导代码时,因为代码是 16 位实模式的,而且没有调试符号表(我们生成的是裸二进制的 bin 文件),反汇编出来的注释可能不准确,阅读时需要手动结合源码分析。建议先熟练使用 monitor 和中断日志,再考虑 GDB。
6.4 写引导程序时,我慢慢养成的几个习惯
最后分享几个来自实际操作的习惯,它们帮我在这个项目里省了大量时间。
第一个习惯是在开发早期就把输出函数独立出来。引导阶段的打印逻辑虽然只有几行,但后面的 loader、内存检查、错误报告全都要用。我不会为每个代码重复写打印循环,而是做一个print_string子程序,用call调用,统一管理。
第二个习惯是尽量保留错误提示。很多人写引导程序只实现“成功路径”,读盘失败就死循环,完全不告诉用户发生了什么。我吃过几次亏后,每个关键步骤都加了错误提示字符串和跳转。不要小看这几行代码,它们能在你一个月后重新调试时救你一命。
第三个习惯是始终保持小步迭代。每次只改一个变量、一段逻辑,编译、写盘、启动、确认,而不是一次性写完所有功能再测试。引导程序的环境极其原始,一次改太多,出了问题根本不知道该怀疑哪里。这个习惯几乎适用于所有底层开发工作。
第四个习惯是在汇编源文件开头写清楚加载地址和扇区布局。比如:
; boot.asm ; 加载位置: 0x7C00 ; 引导扇区: 磁盘扇区 1 (柱面0, 磁头0, 扇区1) ; loader 位置: 磁盘扇区 2, 加载到物理地址 0x9000这些注释看着冗余,但在你一个月后重新打开代码时,它们能让你三秒钟进入状态,而不是重新推导半天。自己写给自己看的注释,往往是最值得写的注释。
整个项目从零到把引导扇区和第二阶段跑通,本质上就是不断重复“修改、编译、写盘、启动、看结果”这个小循环。QEMU 的虚拟化环境让这个循环变得极其高效,你甚至可以在几秒内完成一次完整的迭代。待到某天你在屏幕上看到自己写的字符一行一行刷出来,那种满足感,大概就是底层开发者最上头的瞬间之一。