☰
从零编写x86引导程序:实模式、BIOS中断与保护模式切换实战
2026/10/17 14:59:57 网站建设 项目流程

说真的,很多人学操作系统原理学了大半年,却始终不知道电脑开机后执行的第一条用户代码长什么样。直到我自己动手写了那个 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 make

Windows 用户可以用 WSL,也可以直接下载 NASM 的 Windows 版本,再用 QEMU 的 Windows 安装包。为了专注核心内容,下面统一用 Linux + QEMU 演示。

2.2 最小工程结构与 Makefile 模板

我习惯把引导程序做成一个小工程,目录结构如下:

. ├── boot.asm # 第一阶段的引导扇区 ├── stage2.asm # 第二阶段的加载器或内核入口 └── Makefile

Makefile 的作用不是显得专业,而是保证"构建、运行、清理"这三个步骤可以重复执行。手敲命令容易漏参数,尤其像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=floppy

if=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: ret

lodsb是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 continue

set architecture i8086是实模式调试的关键,不设置的话 GDB 会按 64 位模式解析寄存器,看起来全是乱的。设置完成后,si单步执行,info registers查看寄存器变化,x/10i $eip查看当前位置反汇编。这样能非常清楚地看到代码每一条指令的效果。

6.3 建议按顺序做的自测清单

如果你是第一次接触引导程序,我建议不要一上来就冲保护模式,而是按下面的顺序逐个实验:

  1. 只写一个空循环,让引导扇区安静地停在0x7C00,验证签名和加载没问题。
  2. 在空循环基础上加打印字符,确认 BIOS 中断调用方式正确。
  3. 加段寄存器初始化,验证即使CS:IP被 BIOS 用非标准方式跳转,代码也能正常执行。
  4. 写完整int 0x13读盘,先读一个扇区,确认加载地址和跳转地址匹配。
  5. 最后再切换到保护模式,把 GDT 和 CR0 的操作加进去。

把每个阶段都做成一个独立的 commit,出了问题可以快速回退。引导程序调试最大的特点是反馈非常直接:错了就是黑屏或重启,没有日志可看。所以"小步快跑"比"一次写很多"要靠谱得多。

结尾的几句实在话

从零写引导程序这件事,技术上并不难,难的是第一次直面底层时那种"没人帮我,我只能自己查手册"的状态。我印象最深的一次经历是花了一个小时排查磁盘读取失败,最后发现只是CL里该写 2 却写了 3。扇区从 1 开始计数这个约定,看起来简单,却在真实调试中反复给我挖坑。这也让我养成了一个习惯:凡是跟硬件约定有关的数字,都要先在纸上演算一遍,而不是想当然地填进去。

如果你按这篇文章的步骤跑通了打印字符和读盘,已经超过了很多人。下一步可以试着让第二阶段的加载器读取一个简单内核的 ELF 文件,或者加入 A20 打开、中断描述符表初始化,继续向真正的操作系统内核靠拢。引导程序是整条链路里最短的一环,但它值得你花时间亲手做一次,因为从这 512 字节之后,每一次底层跳转你都能看得明明白白。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询