说真的,很多人学操作系统原理学了大半年,却始终不知道电脑开机后执行的第一条用户代码长什么样。直到我自己动手写了那个 512 字节的 x86 引导程序,才真正把"实模式""中断向量表""0x7C00""0xAA55"这些词从抽象概念变成看得见摸得着的东西。这篇文章不是让你背概念,而是带你从零写一个能在模拟器里跑起来的引导程序:先打印一行字符,再读取磁盘上的内核,最后切进 32 位保护模式。全程只需要一台普通电脑、一个开源的汇编器和模拟器,完全可以在半小时内看到结果。
1. 启动流程拆解:BIOS 把控制权交给我们之前发生了什么
1.1 实模式下的地址空间与 0x7C00 的来历
x86 处理器上电之后并不是直接进入现代操作系统,而是先运行一段固化在主板芯片里的固件程序,通常叫 BIOS 或者 UEFI 固件。BIOS 完成内存自检、设备枚举等初始化工作之后,会扫描可启动设备,然后把设备第一个扇区(512 字节)加载到物理内存地址 0x7C00,检查这个扇区最后两个字节是否为 0x55 和 0xAA,如果是,就把执行权跳转过去。
这里有两个很自然的疑问:为什么是 0x7C00?为什么是 512 字节?
先说 512 字节。早期软盘、硬盘的物理扇区大小就是 512 字节,这是几十年前就定下来的存储规格。BIOS 在引导阶段只会读取一个扇区,因为对固件来说,它不需要理解文件系统,只需要一个约定:第一个扇区是引导代码。512 字节看起来很小,但对于引导程序的第一阶段来说,足够放一段简短的磁盘读取代码了。
再说 0x7C00。这个地址是历史遗留的选择,IBM PC 时代设计者们为了保证引导程序能放在内存中一个相对安全、不会和 BIOS 数据区冲突的位置,选了 0x7C00。在实模式下,CPU 通过"段地址 × 16 + 偏移地址"来计算物理地址,比如0x0000:0x7C00就是物理地址0x7C00,而0x07C0:0x0000计算结果同样是0x7C00。这意味着 BIOS 跳转时,CS:IP 可能不是唯一的组合,这也是后面代码里必须手动初始化段寄存器的重要原因。
1.2 引导程序的第一性原理:只解决"接力"问题
我更喜欢把引导程序理解为一场接力赛的第一棒。BIOS 是发令员,它把控制权交给你的引导扇区,但交到你手上的并不是一个友好的开发环境,而是一个"实模式"下的裸机世界:
- CPU 运行在 16 位实模式,一次处理 16 位数据,默认只能访问 1MB 内存。
- 没有操作系统,没有文件系统驱动,没有显示驱动。
- 唯一可依赖的,是 BIOS 通过软中断提供的一组基础服务,比如
int 0x10操作屏幕、int 0x13读写磁盘。
所以引导程序的核心任务是:在 512 字节内,把内核从磁盘读进内存,然后跳转过去。如果内核太大,就得先加载一个二级引导器,再由二级引导器加载完整内核。换句话说,引导程序的本质不是实现操作系统功能,而是完成一次"接力"。
这个思路决定了后续的所有代码结构:入口处初始化环境,调用 BIOS 中断,跳到下一棒。明白了这一点,后面写代码时就不会迷失在汇编指令细节里。
2. 环境准备:NASM、QEMU 和一条干净的 Makefile
2.1 为什么选 NASM 而不是 GNU Assembler
写 x86 引导程序,第一件事是选汇编器。NASM 是很多操作系统开发教程的首选,原因有三:
- 支持
-f bin直接生成裸二进制文件,不像 GNU Assembler 那样默认输出 ELF 或 COFF 目标文件,还要额外做链接和格式转换。 - Intel 语法读起来非常直观,
mov ax, bx一看就知道是数据传送,和绝大多数教材、文档保持一致。 - 对
org指令支持好,我们可以明确告诉汇编器:这段代码将被加载到哪个地址,所有标签的地址按这个基址计算。
你可能想问,GNU Assembler 的 AT&T 语法不也能写吗?能,但没必要折磨自己。引导程序本来就容易出地址错误,如果再把源操作数和目的操作数写反,调试成本会翻倍。我建议新手直接选 NASM,省下来的时间拿去调试真正的逻辑。
在 Linux 环境下,安装工具通常只需要一行命令:
sudo apt install nasm qemu-system-x86 makeWindows 用户可以用 WSL,也可以直接下载 NASM 的 Windows 版本,再用 QEMU 的 Windows 安装包。为了专注核心内容,下面统一用 Linux + QEMU 演示。
2.2 最小工程结构与 Makefile 模板
我习惯把引导程序做成一个小工程,目录结构如下:
. ├── boot.asm # 第一阶段的引导扇区 ├── stage2.asm # 第二阶段的加载器或内核入口 └── MakefileMakefile 的作用不是显得专业,而是保证"构建、运行、清理"这三个步骤可以重复执行。手敲命令容易漏参数,尤其像dd这种工具,参数一错就会把镜像写坏。下面是一个我实测可用的模板:
AS = nasm QEMU = qemu-system-i386 all: boot.img boot.bin: boot.asm $(AS) -f bin -o boot.bin boot.asm stage2.bin: stage2.asm $(AS) -f bin -o stage2.bin stage2.asm boot.img: boot.bin stage2.bin dd if=/dev/zero of=boot.img bs=512 count=2880 dd if=boot.bin of=boot.img bs=512 conv=notrunc dd if=stage2.bin of=boot.img bs=512 seek=2 conv=notrunc run: boot.img $(QEMU) -drive format=raw,file=boot.img,if=floppy clean: rm -f boot.bin stage2.bin boot.img这里几个关键点:
-f bin让 NASM 输出纯二进制,不带任何文件头。bs=512 count=2880生成一张 1.44MB 软盘镜像,共 2880 个扇区。conv=notrunc是必须的。第一次dd已经把镜像填充好了,第二次、第三次写入时如果不加notrunc,文件会在写完一个扇区后被截断,导致镜像只有 512 字节。seek=2表示把 stage2.bin 写到第 3 个扇区。
为什么要留出第 2 个扇区?因为引导扇区在第 0 个扇区,第 1 个扇区是留给后续扩展的,从第 2 个扇区开始放第二段代码,可以避免和引导扇区相邻导致误读。磁盘扇区编号从 1 开始,所以"第 2 个扇区"在 CHS 参数里对应sector=2,而seek=2正好对应第三个物理扇区。这个细节如果搞混,后面int 0x13读盘就会失败。
2.3 QEMU 比真机好在哪
用 QEMU 而不是真机启动引导程序,最直接的好处是方便调试。真机上如果引导代码写错,轻则黑屏,重则反复重启,你根本不知道 CPU 执行到哪一步。QEMU 可以配合 GDB、monitor 查看寄存器、内存、反汇编,还能随时重置虚拟机。等代码在 QEMU 上完全跑通了,再考虑放到真机或虚拟机里做最终验证。
QEMU 启动命令我固定用:
qemu-system-i386 -drive format=raw,file=boot.img,if=floppyif=floppy很重要,它会告诉 QEMU 这是一张软盘镜像,BIOS 引导时会把DL寄存器设为0x00。如果省略这个参数,QEMU 可能把它当成硬盘,DL会变成0x80。虽然我们的代码会保存并使用 BIOS 传入的DL,但为了和大家的认知一致,还是统一用软盘模式。
3. 第一个引导扇区:打印一行字符并进入死循环
3.1 用 BIOS 中断输出字符的原理
实模式下没有"显卡驱动"这种概念,想往屏幕上输出字符,最传统的方法是通过 BIOS 中断int 0x10。其中AH=0x0E表示"以电传机模式输出单个字符",它会把AL里的字符显示到当前光标位置,然后自动把光标后移一个位置。
BIOS 中断本质上是一段固化在固件里的服务程序。我们执行int 0x10时,CPU 会查找中断向量表,找到0x10中断对应的处理函数地址,跳进去执行。这个过程很像用户程序调用系统调用,只是这里没有操作系统,提供服务的换成了 BIOS。
一个完整的"打印字符串"子程序可以这样写:
print_string: lodsb or al, al jz done mov ah, 0x0e mov bx, 0x0007 int 0x10 jmp print_string done: retlodsb是load string byte的缩写,它会从DS:SI指向的地址读取一个字节到AL,然后把SI加一。or al, al等价于比较AL是否为零,这里的零是 C 字符串的结束符。如果读到零,就跳到done返回;否则调用 BIOS 中断把字符显示出来,继续循环。
3.2 第一版引导代码逐句解读
下面是完整的boot.asm,功能是清屏后打印"Hello from x86 boot",然后死循环:
org 0x7c00 bits 16 cli xor ax, ax mov ds, ax mov es, ax mov si, msg call print_string hang: hlt jmp hang print_string: lodsb or al, al jz done mov ah, 0x0e mov bx, 0x0007 int 0x10 jmp print_string done: ret msg db "Hello from x86 boot", 0 times 510 - ($ - $$) db 0 dw 0xaa55逐句说明:
org 0x7c00:告诉 NASM,代码会被加载到物理地址0x7C00,后面所有标签(比如msg)的计算地址都基于这个基址。bits 16:让 NASM 生成 16 位机器码。cli:关闭中断。虽然 BIOS 引导时通常没有开启中断,但养成习惯不会有坏处。xor ax, ax加mov ds, ax:把数据段寄存器清零。BIOS 跳转时CS:IP不一定是我们期望的0x0000:0x7C00,手动设置段寄存器可以保证访问msg时地址正确。mov si, msg:把消息字符串的偏移地址放入SI。由于DS=0且org 0x7c00,实际访问地址就是0x7C00 + msg 在文件内的偏移,正好对应字符串在内存中的位置。hlt:让 CPU 暂停,直到下一个外部中断。配合jmp hang,让程序稳定地停在这里。如果没有这一步,CPU 会继续执行后续内存里的未知数据,表现为反复重启或花屏。
文件最后两行是每个引导扇区都必须有的签名:
times 510 - ($ - $$) db 0 dw 0xaa55$表示当前位置,$$表示当前 section 的起始位置,所以510 - ($ - $$)计算出已经占用的字节数,用零填充到 510 字节。最后用dw 0xaa55写入两个字节,正好补满 512 字节。BIOS 靠这两个字节判断这个扇区是否合法:如果最后两位不是0xAA55,它会认为设备不可引导。
3.3 编译、运行和第一次"虚线"
在工程目录下执行:
make make run你应该能看到 QEMU 窗口里出现"Hello from x86 boot"。如果出现的是乱码,多半是DS段寄存器没有初始化好,或者org写错了。如果 QEMU 直接提示Boot failed: not a bootable disk,那就是最后两个字节的签名不对,检查一下dw 0xaa55有没有写反成dw 0x55aa。字节序问题很隐蔽,但 NASM 在 x86 上会把0xaa55正确输出为55 AA,一般不用手动调整。
打印字符只是个开始,它证明我们的引导扇区已经被 BIOS 接受并执行。接下来要解决的问题更现实:512 字节装不下真正的内核。
4. 二级引导:用 int 13h 把内核从磁盘读进内存
4.1 为什么必须做两段式引导
512 字节里,除了签名、入口代码、打印子程序,留给实际功能的空间非常有限。如果你试着塞进去一个简单的内核,很快就会发现代码和数据的边界会溢出到 510 字节之外。解决办法是经典的"两段式引导":
- 第一阶段:引导扇区(boot.bin)只做一件事,把第二段代码从磁盘读到内存,然后跳过去。
- 第二阶段:真正的加载器(stage2.bin)可以自己决定加载内核、进入保护模式、初始化设备等。
第二段代码不再受 512 字节限制,可以写到几 KB 甚至更大。在本文的例子里,stage2 会被放到软盘镜像的第 3 个扇区,也就是sector=2, head=0, cylinder=0。
4.2 int 13h 的 CHS 寻址到底怎么传参
BIOS 的磁盘服务中断int 0x13有很多功能号,读扇区用AH=0x02。它的参数很让人头疼,因为是老式 CHS 寻址:
AL:要读取的扇区数。CH:柱面号低 8 位。CL:扇区号,其中最高两位是柱面号的高 2 位。DH:磁头号。DL:驱动器号,软盘是0x00,硬盘是0x80。ES:BX:数据加载到内存的缓冲区地址。
举个例子,从软盘的第 3 个扇区读 1 个扇区,参数就是CH=0, CL=2, DH=0, DL=0x00, AL=1。注意扇区号从 1 开始,所以引导扇区是CL=1,第 3 个扇区是CL=2,第 4 个扇区是CL=3。这个"从 1 开始"的习惯很容易让人栽跟头。
如果把 stage2.bin 写到seek=2,却用CL=3去读,读到的就是第 4 个扇区,跳转后大概率黑屏。
4.3 引导扇区读取 stage2 的完整代码
基于这个理解,boot.asm可以升级为:
org 0x7c00 bits 16 cli xor ax, ax mov ds, ax mov es, ax mov [drive_number], dl mov ax, 0x1000 mov es, ax xor bx, bx mov ah, 0x02 mov al, 0x01 mov ch, 0x00 mov cl, 0x02 mov dh, 0x00 mov dl, [drive_number] int 0x13 jc read_error jmp 0x1000:0x0000 read_error: mov si, msg_error call print_string hang: hlt jmp hang print_string: lodsb or al, al jz done mov ah, 0x0e mov bx, 0x0007 int 0x10 jmp print_string done: ret drive_number db 0x00 msg_error db "disk read error", 0 times 510 - ($ - $$) db 0 dw 0xaa55代码里的mov [drive_number], dl把 BIOS 传入的驱动器号保存起来。后面int 0x13用mov dl, [drive_number]重新加载,避免自己硬编码0x00或0x80。这样同一个镜像无论是从软盘还是硬盘启动都能工作。
读取目标是ES:BX = 0x1000:0x0000,也就是物理地址0x10000。跳转目标jmp 0x1000:0x0000和这个加载地址严格对应,所以 stage2.bin 必须用org 0x0000编译,这样标签的偏移量从 0 开始,和CS:IP里的IP=0一致。
4.4 stage2 的最简实现
stage2.asm 的内容可以先很简单:
org 0x0000 bits 16 mov si, msg call print_string hang: hlt jmp hang print_string: lodsb or al, al jz done mov ah, 0x0e mov bx, 0x0007 int 0x10 jmp print_string done: ret msg db "Stage2 loaded!", 0 times 512 - ($ - $$) db 0这里要注意一个关键点:stage2 被加载到物理地址0x10000,但org 0x0000意味着代码里的所有标签都是从 0 开始计数的。执行mov si, msg时,变量msg的机器码立即数是它在文件里的偏移,比如0x30。CPU 用DS:SI访问内存,而DS在进入这段代码前是什么?引导扇区代码只设置了ES,没有再次设置DS。因此,在 stage2 开头,最好也主动设置一下段寄存器:
org 0x0000 bits 16 cli mov ax, 0x1000 mov ds, ax mov es, ax因为 stage2 在物理地址0x10000运行,要把DS设为0x1000,让DS:msg_offset指向物理地址0x10000 + msg_offset。这个"段选择"是实模式下最容易出错的地方,也是为什么我前面反复强调必须让org、加载地址、段寄存器三者对齐。
5. 进阶挑战:从实模式切换进 32 位保护模式
5.1 为什么要保护模式,它到底保护什么
实模式能访问的最大内存是 1MB,而且程序可以随便访问任何内存地址,没有任何权限隔离。对现代操作系统来说,这显然不够用。保护模式提供了至少三样实模式没有的东西:
- 32 位寻址能力,最大支持 4GB 内存。
- 段级权限保护,代码段、数据段可以设置特权级。
- 更好的内存隔离,不同程序不能互相踩踏内存。
切换保护模式并不是操作系统内核才做的事,引导程序在加载内核之前通常就会完成切换到保护模式的准备工作。这一步虽然是进阶内容,但非常值得亲手做一次,因为它能让你理解GDT、CR0寄存器这些词到底长什么样。
5.2 最小的 GDT:两个描述符就够用
保护模式里的段不再由段寄存器 × 16 + 偏移决定,而是通过段寄存器里的"选择子"去查一张表,这张表叫全局描述符表 GDT。每个 GDT 条目有 8 字节,描述一个内存段的基地址、长度、权限等属性。
我们的 stage2 只需要两个段:
- 代码段:基地址
0x00010000,长度 4GB,因为在实模式下代码就运行在物理地址0x10000。 - 数据段:基地址
0x00000000,长度 4GB,方便直接访问显存0xB8000等地址。
GDT 的定义如下:
align 16 gdt_start: dq 0x0000000000000000 gdt_code: dw 0xffff dw 0x0000 db 0x01 db 0x9a db 0xcf db 0x00 gdt_data: dw 0xffff dw 0x0000 db 0x00 db 0x92 db 0xcf db 0x00 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 dd gdt_start这里gdt_code的基地址是0x00010000,展开来看就是低 16 位00 00,第 16 到 23 位是01,高 8 位是00。gdt_data的基地址是0x00000000。
描述符里比较难懂的是中间两个字节:0x9a表示代码段存在、可读、可执行,非一致性;0x92表示数据段存在、可写。0xcf是粒度标志,表示段长度按 4KB 页为单位计算,配合前两个字节的0xffff,就能得到 4GB 的长度上限。
5.3 切换保护模式的完整步骤
在 stage2.asm 中,切换到保护模式的核心代码是:
org 0x0000 bits 16 cli mov ax, 0x1000 mov ds, ax mov es, ax mov ss, ax mov sp, 0x9000 lgdt [gdt_descriptor] mov eax, cr0 or eax, 1 mov cr0, eax jmp 0x08:protected_mode bits 32 protected_mode: mov ax, 0x10 mov ds, ax mov es, ax mov ss, ax mov esp, 0x90000 mov edi, 0xb8000 mov byte [edi], 'P' mov byte [edi + 1], 0x0f mov byte [edi + 2], 'M' mov byte [edi + 3], 0x0f hang: hlt jmp hang步骤拆开看:
cli关中断,避免切换过程中被外部中断打断。lgdt [gdt_descriptor]把 GDT 的基地址和长度加载到 GDTR 寄存器。mov eax, cr0; or eax, 1; mov cr0, eax把 CR0 寄存器的最低位置 1,开启保护模式。jmp 0x08:protected_mode是关键一跳。0x08是 GDT 中代码段的选择子,对应前面 GDT 的第二项。这一步会让 CPU 用新的代码段描述符重新加载 CS,同时清空流水线,避免之前实模式下的指令预取造成异常。
protected_mode标签后面是 32 位代码。这里直接往显存地址0xB8000写字符,因为 VGA 文本模式下的显存就映射在这个位置。字符以两个字节为单位:低字节是 ASCII 码,高字节是颜色属性。0x0f表示白字黑底。
注意,进入保护模式后,BIOS 中断就不能用了。因为保护模式下的中断处理机制完全不同,此时再调用int 0x10不会有任何效果。这也是为什么在实模式阶段就把要打印的信息打印完,或者直接操作显存。
5.4 关于 A20 地址线的提醒
在真实硬件上,进入保护模式之前通常还需要打开 A20 地址线,否则第 20 个地址位会被强制置 0,导致访问0x100000以上的内存时回绕到低地址。QEMU 等模拟器默认已经打开 A20,所以本文代码在模拟器里跑没问题。但在真机上验证时,要先通过键盘控制器或者 BIOS 中断打开 A20,否则内核一旦放到 1MB 以上的地址就会神秘崩溃。这一点可以等后续真正移植到真机时再处理,不影响现在的核心逻辑。
6. 实操避坑:我从零写引导程序踩过的调试记录
6.1 高频错误速查表
我自己写这段引导程序时,基本把常见的坑都踩了一遍。下面这张表是根据真实调试经验整理的,遇到问题时可以直接对着查。
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| QEMU 提示 not a bootable disk | 引导扇区缺少0xAA55签名 | 看二进制最后两个字节是否是55 AA |
| 屏幕显示乱码 | 段寄存器没初始化,org地址不对 | 检查mov ds, ax和org 0x7c00 |
| 引导扇区跑完就重启 | 代码溢出 512 字节,或没有死循环 | 检查times填充是否正常,是否有hlt |
| 跳转 stage2 后黑屏 | 加载地址和跳转地址不一致 | 确认ES:BX与jmp 段:偏移、org三者一致 |
| 磁盘读取失败,打印错误信息 | CHS 参数算错,扇区号应从 1 开始 | 检查CL是否为 2,seek是否为 2 |
| 进入保护模式后三重故障 | 缺乏远跳转,GDT 基址错,中断没关 | 检查cli、jmp 0x08:xxx、GDT 描述符 |
| 保护模式下屏幕无输出 | 用了 BIOS 中断,或显存地址被段基址偏移 | 确认数据段基址为 0,直接写0xB8000 |
最隐蔽的一个错误是:先定义了代码段基地址0x10000,但数据段基地址也设成了0x10000,结果想写0xB8000显存时,实际写到了0xC8000。看起来代码逻辑没问题,屏幕就是不出字。解决方法是把代码段和数据段分开设计,代码段跟着物理加载地址走,数据段保持平坦模型,这样才能用固定地址访问外设映射区。
6.2 用 QEMU monitor 和 GDB 看现场
如果屏幕上没有输出任何信息,第一步不是改代码,而是看 CPU 到底停在哪里。QEMU 的 monitor 模式是最快的现场勘察工具:
qemu-system-i386 -drive format=raw,file=boot.img,if=floppy -monitor stdio启动后输入info registers可以查看所有寄存器的值,比如检查RIP是否停在0x7C00附近,DL是否被 BIOS 正确设为0x00。输入xp /16bx 0x7c00可以查看物理内存0x7C00开始处的 16 个字节,确认代码是否真的加载到了预期位置。
GDB 调试更适合逐步跟踪指令。启动 QEMU 时加两个参数:
qemu-system-i386 -s -S -drive format=raw,file=boot.img,if=floppy-s让 QEMU 在 TCP 1234 端口开放 GDB 调试接口,-S表示启动后先暂停。然后另开一个终端:
gdb target remote :1234 set architecture i8086 break *0x7c00 continueset architecture i8086是实模式调试的关键,不设置的话 GDB 会按 64 位模式解析寄存器,看起来全是乱的。设置完成后,si单步执行,info registers查看寄存器变化,x/10i $eip查看当前位置反汇编。这样能非常清楚地看到代码每一条指令的效果。
6.3 建议按顺序做的自测清单
如果你是第一次接触引导程序,我建议不要一上来就冲保护模式,而是按下面的顺序逐个实验:
- 只写一个空循环,让引导扇区安静地停在
0x7C00,验证签名和加载没问题。 - 在空循环基础上加打印字符,确认 BIOS 中断调用方式正确。
- 加段寄存器初始化,验证即使
CS:IP被 BIOS 用非标准方式跳转,代码也能正常执行。 - 写完整
int 0x13读盘,先读一个扇区,确认加载地址和跳转地址匹配。 - 最后再切换到保护模式,把 GDT 和 CR0 的操作加进去。
把每个阶段都做成一个独立的 commit,出了问题可以快速回退。引导程序调试最大的特点是反馈非常直接:错了就是黑屏或重启,没有日志可看。所以"小步快跑"比"一次写很多"要靠谱得多。
结尾的几句实在话
从零写引导程序这件事,技术上并不难,难的是第一次直面底层时那种"没人帮我,我只能自己查手册"的状态。我印象最深的一次经历是花了一个小时排查磁盘读取失败,最后发现只是CL里该写 2 却写了 3。扇区从 1 开始计数这个约定,看起来简单,却在真实调试中反复给我挖坑。这也让我养成了一个习惯:凡是跟硬件约定有关的数字,都要先在纸上演算一遍,而不是想当然地填进去。
如果你按这篇文章的步骤跑通了打印字符和读盘,已经超过了很多人。下一步可以试着让第二阶段的加载器读取一个简单内核的 ELF 文件,或者加入 A20 打开、中断描述符表初始化,继续向真正的操作系统内核靠拢。引导程序是整条链路里最短的一环,但它值得你花时间亲手做一次,因为从这 512 字节之后,每一次底层跳转你都能看得明明白白。