一、从零开始:为什么我们要写引导程序?

说实话,很多C++程序员写了几年代码,都没见过计算机真正启动的那一刻发生了什么。我刚开始学系统编程时也一样——直到有一次我试着在虚拟机里写了个简单的引导程序,看到屏幕上跳出自己打印的字符,那种感觉真的很奇妙。

引导程序(Bootloader)说白了就是计算机上电后执行的第一个程序。它负责初始化硬件、加载操作系统内核,然后把控制权交出去。今天我们就手写一个极简的引导程序,让你真正理解计算机启动的底层逻辑。

核心要点:引导程序必须放在磁盘的第一个扇区(512字节),并以魔数 0xAA55 结尾,否则 BIOS 不会认它。

二、环境准备:你需要什么?

工欲善其事,必先利其器。我个人习惯用以下工具链:

  • NASM:汇编编译器,把我们的代码转成机器码
  • QEMU:轻量级虚拟机,用来测试引导程序
  • Hexdump 工具:比如 xxd,用来检查二进制文件是否正确
  • 任意文本编辑器:VSCode、Vim 都行

安装命令(Ubuntu/Debian):

sudo apt-get install nasm qemu-system-x86 xxd

Windows 用户可以用 WSL 或者直接下载 NASM 和 QEMU 的 Windows 版本。嗯,这里要注意:路径中不要有中文和空格,否则 NASM 可能会报一些莫名其妙的错误。

三、引导程序的核心结构

一个标准的引导程序长什么样?我画了张图帮你理解:

引导程序结构图 磁盘第一个扇区(512字节) 引导代码区(446字节) 包含:实模式初始化、显示字符串、加载内核等 用汇编或C语言编写,编译成机器码 分区表(64字节) 4个分区表项,每个16字节 引导签名:0x55 0xAA(最后2字节) BIOS加载 到此扇区

你看,整个结构其实很简单。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?因为这时候操作系统还没加载,没有任何库可用。你只能直接跟硬件打交道。

小技巧:如果你在 Windows 下用 NASM,记得用 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 是个相对安全的地址。

注意:BIOS 0x13 中断的返回值在 AH 寄存器中。如果读取失败,AH 会包含错误码。常见的错误码有 0x01(无效命令)、0x02(地址标记未找到)、0x04(扇区未找到)等。调试时可以用 QEMU 的 -d int 参数查看中断调用情况。

七、常见问题与避坑指南

写引导程序时,我踩过不少坑。分享几个典型的:

  • 段寄存器没初始化:BIOS 跳转到 0x7C00 时,段寄存器的值是不确定的。一定要显式初始化,否则代码可能跑飞。
  • 字符串忘记以0结尾:打印子程序依赖0作为结束标志。如果忘了,会一直打印内存中的垃圾数据,直到遇到0为止。
  • 扇区编号从1开始:第一个扇区是1,不是0。我刚开始写的时候用了0,结果怎么都读不到数据。
  • QEMU 和真机差异:QEMU 比较宽容,有些错误在真机上会直接崩溃。建议先在 QEMU 上调试,再用真实硬件测试。

八、总结

引导程序虽然只有512字节,但它涉及的知识点很密集:实模式、BIOS中断、磁盘I/O、内存布局……说白了,这是你离硬件最近的一次编程体验。

我个人觉得,写引导程序最大的收获不是技术本身,而是那种「我能控制计算机启动」的感觉。当你看到自己写的代码在裸机上运行时,那种成就感是写Web应用无法比拟的。

如果你对系统编程感兴趣,下一步可以尝试用C语言写内核,然后用汇编引导程序加载它。嗯,那就是另一个故事了。


公众号:蓝海资料掘金营,微信deep3321