一、从零开始:为什么我们要写引导程序?
说实话,很多C++程序员写了几年代码,都没见过计算机真正启动的那一刻发生了什么。我刚开始学系统编程时也一样——直到有一次我试着在虚拟机里写了个简单的引导程序,看到屏幕上跳出自己打印的字符,那种感觉真的很奇妙。
引导程序(Bootloader)说白了就是计算机上电后执行的第一个程序。它负责初始化硬件、加载操作系统内核,然后把控制权交出去。今天我们就手写一个极简的引导程序,让你真正理解计算机启动的底层逻辑。
二、环境准备:你需要什么?
工欲善其事,必先利其器。我个人习惯用以下工具链:
- NASM:汇编编译器,把我们的代码转成机器码
- QEMU:轻量级虚拟机,用来测试引导程序
- Hexdump 工具:比如 xxd,用来检查二进制文件是否正确
- 任意文本编辑器:VSCode、Vim 都行
安装命令(Ubuntu/Debian):
sudo apt-get install nasm qemu-system-x86 xxd
Windows 用户可以用 WSL 或者直接下载 NASM 和 QEMU 的 Windows 版本。嗯,这里要注意:路径中不要有中文和空格,否则 NASM 可能会报一些莫名其妙的错误。
三、引导程序的核心结构
一个标准的引导程序长什么样?我画了张图帮你理解:
你看,整个结构其实很简单。BIOS 上电自检后,会读取磁盘第一个扇区到内存 0x7C00 位置,然后检查最后两个字节是不是 0x55 和 0xAA。如果是,就跳过去执行。如果不是,就报错「Missing operating system」。
四、手写第一个引导程序
我们先写一个最简单的版本——在屏幕上打印一行字符,然后死循环。代码不多,但每一步都有讲究。
; boot.asm - 最简单的引导程序
[org 0x7C00] ; 告诉编译器,代码会被加载到 0x7C00
mov ax, 0x0000 ; 初始化段寄存器
mov ds, ax
mov es, ax
; 设置显示模式为文本模式(80x25)
mov ax, 0x0003
int 0x10
; 打印字符串
mov si, msg ; SI 指向要打印的字符串
call print_string
; 死循环
jmp $
; 子程序:打印字符串(以0结尾)
print_string:
lodsb ; 从SI指向的地址取一个字节到AL,SI自增
or al, al ; 检查是否为0
jz .done ; 如果是0,结束
mov ah, 0x0E ; BIOS 0x10 中断的 0x0E 功能:显示字符
int 0x10 ; 调用BIOS中断
jmp print_string
.done:
ret
msg db 'Hello from Bootloader!', 0
; 填充剩余空间到510字节
times 510 - ($ - $$) db 0
; 引导签名
dw 0xAA55
这段代码里,我用了 BIOS 中断 0x10 来显示字符。为什么不用 C++ 的 cout?因为这时候操作系统还没加载,没有任何库可用。你只能直接跟硬件打交道。
nasm -f bin boot.asm -o boot.bin 编译。参数 -f bin 表示输出纯二进制文件,不是 PE 或 ELF 格式。
五、编译与测试
编译命令很简单:
nasm -f bin boot.asm -o boot.bin
检查一下生成的二进制文件:
xxd boot.bin | tail -5
你应该看到最后两字节是 55 aa。如果不是,说明编译有问题。我曾经遇到过忘记加 dw 0xAA55 的情况,结果 QEMU 直接报错,折腾了半小时才发现。
用 QEMU 测试:
qemu-system-x86_64 -drive format=raw,file=boot.bin
如果一切正常,你会看到一个黑底白字的窗口,显示 "Hello from Bootloader!"。嗯,看到这个画面,说明你的引导程序已经成功运行了。
六、进阶:加载内核到内存
光打印一行字还不够。真正的引导程序要加载内核。我们扩展一下:让引导程序从磁盘读取第二个扇区的内容到内存,然后跳转过去执行。
; boot.asm - 加载内核版本
[org 0x7C00]
; 初始化段寄存器
xor ax, ax
mov ds, ax
mov es, ax
; 读取磁盘第二个扇区到内存 0x1000
mov ah, 0x02 ; BIOS 0x13 功能:读取扇区
mov al, 1 ; 读取1个扇区
mov ch, 0 ; 柱面号0
mov cl, 2 ; 扇区号2(第一个扇区是1)
mov dh, 0 ; 磁头号0
mov dl, 0x80 ; 驱动器号(0x80 = 第一个硬盘)
mov bx, 0x1000 ; 目标地址 ES:BX = 0x0000:0x1000
int 0x13
; 检查是否读取成功
jc disk_error ; 如果进位标志置位,说明出错
; 跳转到内核
jmp 0x0000:0x1000
disk_error:
mov si, err_msg
call print_string
jmp $
; 打印字符串子程序(同上)
print_string:
lodsb
or al, al
jz .done
mov ah, 0x0E
int 0x10
jmp print_string
.done:
ret
err_msg db 'Disk read error!', 0
times 510 - ($ - $$) db 0
dw 0xAA55
这里用到了 BIOS 0x13 中断来读取磁盘。你想想看,为什么目标地址是 0x1000?因为 0x7C00 到 0x7DFF 是引导程序自己占用的,我们不能覆盖。0x1000 是个相对安全的地址。
-d int 参数查看中断调用情况。
七、常见问题与避坑指南
写引导程序时,我踩过不少坑。分享几个典型的:
- 段寄存器没初始化:BIOS 跳转到 0x7C00 时,段寄存器的值是不确定的。一定要显式初始化,否则代码可能跑飞。
- 字符串忘记以0结尾:打印子程序依赖0作为结束标志。如果忘了,会一直打印内存中的垃圾数据,直到遇到0为止。
- 扇区编号从1开始:第一个扇区是1,不是0。我刚开始写的时候用了0,结果怎么都读不到数据。
- QEMU 和真机差异:QEMU 比较宽容,有些错误在真机上会直接崩溃。建议先在 QEMU 上调试,再用真实硬件测试。
八、总结
引导程序虽然只有512字节,但它涉及的知识点很密集:实模式、BIOS中断、磁盘I/O、内存布局……说白了,这是你离硬件最近的一次编程体验。
我个人觉得,写引导程序最大的收获不是技术本身,而是那种「我能控制计算机启动」的感觉。当你看到自己写的代码在裸机上运行时,那种成就感是写Web应用无法比拟的。
如果你对系统编程感兴趣,下一步可以尝试用C语言写内核,然后用汇编引导程序加载它。嗯,那就是另一个故事了。
公众号:蓝海资料掘金营,微信deep3321