
说真的很多人学操作系统原理学了大半年却始终不知道电脑开机后执行的第一条用户代码长什么样。直到我自己动手写了那个 512 字节的 x86 引导程序才真正把实模式中断向量表0x7C000xAA55这些词从抽象概念变成看得见摸得着的东西。这篇文章不是让你背概念而是带你从零写一个能在模拟器里跑起来的引导程序先打印一行字符再读取磁盘上的内核最后切进 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 和一条干净的 Makefile2.1 为什么选 NASM 而不是 GNU Assembler写 x86 引导程序第一件事是选汇编器。NASM 是很多操作系统开发教程的首选原因有三支持-f bin直接生成裸二进制文件不像 GNU Assembler 那样默认输出 ELF 或 COFF 目标文件还要额外做链接和格式转换。Intel 语法读起来非常直观mov ax, bx一看就知道是数据传送和绝大多数教材、文档保持一致。对org指令支持好我们可以明确告诉汇编器这段代码将被加载到哪个地址所有标签的地址按这个基址计算。你可能想问GNU Assembler 的 ATT 语法不也能写吗能但没必要折磨自己。引导程序本来就容易出地址错误如果再把源操作数和目的操作数写反调试成本会翻倍。我建议新手直接选 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 ofboot.img bs512 count2880 dd ifboot.bin ofboot.img bs512 convnotrunc dd ifstage2.bin ofboot.img bs512 seek2 convnotrunc run: boot.img $(QEMU) -drive formatraw,fileboot.img,iffloppy clean: rm -f boot.bin stage2.bin boot.img这里几个关键点-f bin让 NASM 输出纯二进制不带任何文件头。bs512 count2880生成一张 1.44MB 软盘镜像共 2880 个扇区。convnotrunc是必须的。第一次dd已经把镜像填充好了第二次、第三次写入时如果不加notrunc文件会在写完一个扇区后被截断导致镜像只有 512 字节。seek2表示把 stage2.bin 写到第 3 个扇区。为什么要留出第 2 个扇区因为引导扇区在第 0 个扇区第 1 个扇区是留给后续扩展的从第 2 个扇区开始放第二段代码可以避免和引导扇区相邻导致误读。磁盘扇区编号从 1 开始所以第 2 个扇区在 CHS 参数里对应sector2而seek2正好对应第三个物理扇区。这个细节如果搞混后面int 0x13读盘就会失败。2.3 QEMU 比真机好在哪用 QEMU 而不是真机启动引导程序最直接的好处是方便调试。真机上如果引导代码写错轻则黑屏重则反复重启你根本不知道 CPU 执行到哪一步。QEMU 可以配合 GDB、monitor 查看寄存器、内存、反汇编还能随时重置虚拟机。等代码在 QEMU 上完全跑通了再考虑放到真机或虚拟机里做最终验证。QEMU 启动命令我固定用qemu-system-i386 -drive formatraw,fileboot.img,iffloppyiffloppy很重要它会告诉 QEMU 这是一张软盘镜像BIOS 引导时会把DL寄存器设为0x00。如果省略这个参数QEMU 可能把它当成硬盘DL会变成0x80。虽然我们的代码会保存并使用 BIOS 传入的DL但为了和大家的认知一致还是统一用软盘模式。3. 第一个引导扇区打印一行字符并进入死循环3.1 用 BIOS 中断输出字符的原理实模式下没有显卡驱动这种概念想往屏幕上输出字符最传统的方法是通过 BIOS 中断int 0x10。其中AH0x0E表示以电传机模式输出单个字符它会把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。由于DS0且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 个扇区也就是sector2, head0, cylinder0。4.2 int 13h 的 CHS 寻址到底怎么传参BIOS 的磁盘服务中断int 0x13有很多功能号读扇区用AH0x02。它的参数很让人头疼因为是老式 CHS 寻址AL要读取的扇区数。CH柱面号低 8 位。CL扇区号其中最高两位是柱面号的高 2 位。DH磁头号。DL驱动器号软盘是0x00硬盘是0x80。ES:BX数据加载到内存的缓冲区地址。举个例子从软盘的第 3 个扇区读 1 个扇区参数就是CH0, CL2, DH0, DL0x00, AL1。注意扇区号从 1 开始所以引导扇区是CL1第 3 个扇区是CL2第 4 个扇区是CL3。这个从 1 开始的习惯很容易让人栽跟头。如果把 stage2.bin 写到seek2却用CL3去读读到的就是第 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里的IP0一致。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是否为 2seek是否为 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 formatraw,fileboot.img,iffloppy -monitor stdio启动后输入info registers可以查看所有寄存器的值比如检查RIP是否停在0x7C00附近DL是否被 BIOS 正确设为0x00。输入xp /16bx 0x7c00可以查看物理内存0x7C00开始处的 16 个字节确认代码是否真的加载到了预期位置。GDB 调试更适合逐步跟踪指令。启动 QEMU 时加两个参数qemu-system-i386 -s -S -drive formatraw,fileboot.img,iffloppy-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 字节之后每一次底层跳转你都能看得明明白白。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。