
开门见山问一个问题按下电源键之后CPU 执行的第一行代码是谁的不是 Windows、Linux 的启动管理器更不是操作系统的内核而是一段总共只有 512 字节、却决定了整台机器“把控制权交给谁”的程序——主引导记录也就是大家常说的 MBR。在《操作系统真象还原》第二章里作者带着读者手写一个 MBR 引导扇区标题叫做“编写 MBR 主引导记录让我们开始掌权”。这一章做完屏幕上会跳出你亲手写进去的字符虽然只是几个字母但它是你第一次让自己的代码跑在“操作系统之前”那种感觉和写普通应用完全不同。这一章非常适合三类人一类是学操作系统但总觉得隔着一层纱、想真刀真枪看启动过程的本科生一类是想搞懂 BIOS、实模式、引导扇区这些底层概念的后端或嵌入式开发者还有一类就是单纯喜欢折腾、想从零手搓系统的硬核爱好者。读完之后你会明白CPU 上电后发生的事并不是玄学而是一套严谨的硬件约定而 MBR 正是这套约定中最关键的第一环。下面我从环境搭建、实模式规则、代码实现、Bochs 调试再到 MBR 与 GPT 的现实对照把这一章的经验完整拆给你。1. 为什么是 MBR一段开机时必须“抢跑”的代码1.1 CPU 上电后根本不认识硬盘上的“操作系统”很多人潜意识里觉得按下电源键后电脑应该是“先启动操作系统”。实际上 CPU 刚上电时处于 16 位实模式只认识固定地址上的固件代码。主板上的 BIOS 或 UEFI 固件会先完成自检然后扫描启动设备U 盘、硬盘、光驱、网络按用户设置的启动顺序尝试从某个磁盘读取引导程序。这个“引导程序”不是 GRUB、不是 bootmgr而是磁盘第一个扇区里的 MBR。BIOS 读取磁盘第一个扇区的 512 字节到物理地址 0x7C00然后检查最后两个字节是不是 0x55、0xAA。如果是就跳转到 0x7C00 执行这段代码如果不是就认为这个磁盘不可引导继续尝试下一个设备。也就是说MBR 严格来说不是“操作系统的一部分”它是固件约定好的、操作系统启动之前的“过渡程序”。你写的代码真正跑在操作系统之前这就是“掌权”二字的来源。1.2 MBR 要满足“512 字节 55AA”这个铁律磁盘的第一个扇区是 512 字节这是硬盘硬件层面的约定BIOS 每次读扇区也是以 512 字节为单位。MBR 这个扇区的结构非常紧凑偏移长度用处0x000446 字节引导代码区CPU 从 0x7C00 开始执行的指令0x1BE64 字节分区表项区最多放 4 个分区项每项 16 字节0x1FE2 字节魔数 0x55、0xAA也就是说真正能写代码的区域只有 446 字节这还是在不覆盖分区表的前提下。很多第一次写 MBR 的人会觉得512 字节太小了能干什么啥也干不了所以真正的操作系统引导要分好几级MBR 只负责做最基本的初始化然后把控制权交给下一级引导程序那一级再加载内核。这一章我们不讨论复杂的分区表只写引导代码区所以后面用times 510-($-$$) db 0把扇区填满到 510 字节最后写上 0x55、0xAA凑成一个合法的 MBR。需要注意的是0x55AA 以小端序存进内存是0xAA 0x55但我们在源码里写db 0x55, 0xaa就行因为 db 就是按字节声明写入文件后字节序天然正确。2. 写 MBR 之前先把开发环境与“实模式规则”摸清2.1 一套能跑起来的环境NASM Bochs 虚拟硬盘我平时做 OS 开发习惯用 NASM 编译器原因很简单语法比 GNU as 的 ATT 格式直观太多尤其是处理section、vstart这类引导程序写地址的逻辑非常方便。Bochs 则是调试引导程序的利器它不像 QEMU 那样“跑起来就完事”Bochs 自带完整调试器可以随时暂停、单步、查看物理内存和寄存器这种能力在调试 512 字节引导程序时是刚需。这套环境按下面的步骤准备就行Linux 下直接apt install nasm bochs bochs-xWindows 下可以分别下 NASM 和 Bochs 安装包用 Bochs 自带的bximage工具创建一块虚拟硬盘。运行后选择hd、flat、大小填 60它会生成一个hd60M.img镜像文件同时打印出该镜像的柱面数、磁头数、扇区数这些参数稍后要填进配置文件。bochsrc 配置文件我习惯这样写megs: 32 romimage: file$BXSHARE/BIOS-bochs-latest vgaromimage: file$BXSHARE/VGABIOS-lgpl-latest ata0: enabled1, ioaddr10x1f0, ioaddr20x3f0, irq14 ata0-master: typedisk, pathhd60M.img, modeflat, cylinders121, heads16, spt63 boot: disk display_library: sdl2cylinders、heads、spt这三个参数不要自己乱编直接看 bximage 创建硬盘时的输出。路径也要对否则 Bochs 会傻乎乎地找不到盘。还有一个细节如果以后想用调试器建议直接在命令行跑bochs -qf bochsrc进入交互界面后按提示选择“begin simulation”Bochs 会进入调试模式下面所有调试命令都是在那个调试器里输入的。2.2 实模式的三个硬规则地址、中断、显存写 MBR 之前必须先理解实模式下几个“不讲道理”的硬件规则不然代码写完只能靠猜。第一个规则是地址计算。实模式只有 20 根地址线最大寻址 1MB采用“段寄存器左移 4 位 偏移地址”的方式形成 20 位物理地址公式是物理地址 段值 × 16 偏移。这就是为什么代码里写显存时mov ax, 0xb800; mov gs, ax随后访问[gs:0x00]实际访问的是物理地址0xB8000也就是文本模式显存的开头位置。如果你把gs设成 0xB8000反而会错位三个十六进制位跑出来全屏乱码。第二个规则是中断。实模式下可以用 BIOS 中断操作硬件常用的有int 0x10控制屏幕、int 0x13读写磁盘、int 0x16读取键盘。进入保护模式后这些中断就不能用了但在 MBR 阶段它们是唯一和硬件打交道的方式。这一章我们用int 0x10清屏后续读磁盘加载 loader 时会用到int 0x13。第三个规则是内存布局。1MB 里的安排是固定的0x00000-0x9FFFF是常规内存0xA0000-0xBFFFF是显存和硬件映射区0xC0000-0xFFFFF是 ROM 和 BIOS。MBR 被加载到0x7C00这个位置这个地址属于常规内存的高段区域BIOS 选在这里是为了给后续引导程序和内核腾出低端大量连续空间。所以代码一开始要把栈顶sp设为0x7C00因为栈向下增长正好从 MBR 所在位置往回压栈不会覆盖低地址的数据。3. 亲手写第一版 MBR控制显示器要靠“直接写显存”3.1 MBR 的程序框架和段寄存器初始化这一章的核心思路是先准备执行环境再做最简单的显存输出。刚上电时cs的值取决于 BIOS 如何跳转通常是从0x0000:0x7C00跳进来所以cs可能是 0但不同机器上不一定保证保险起见要把自己的段寄存器全部初始一遍。代码骨架如下段寄存器全部对齐到cs栈顶指向0x7C00同时把文本模式显存段地址 0xB800 装进gs。这里有一个新手容易犯的错误mov ds, 0这种写法在 8086 汇编里不合法因为段寄存器不能直接接收立即数必须先把立即数放到通用寄存器再通过通用寄存器赋给段寄存器。; 文件名: mbr.S ; 说明: 编译后写入虚拟硬盘第一个扇区 %include boot.inc SECTION MBR vstart0x7c00 mov ax, cs mov ds, ax mov es, ax mov ss, ax mov fs, ax mov sp, STACK_BASE mov ax, 0xb800 mov gs, axboot.inc里的内容也很简单把栈基址抽出来后续写 loader 时同样能复用; boot.inc STACK_BASE equ 0x7c00代码里SECTION MBR vstart0x7c00是 NASM 特有的写法vstart表示这个段内的代码和标签在链接时按 0x7C00 作为起始地址来计算相对地址。说白了它相当于告诉编译器这段代码将来会被加载到 0x7C00所有内部跳转和内存访问都按这个地址算。如果没有这一句代码里的jmp $以及后续跳转指令都可能生成错误的偏移。3.2 清屏与字符输出int 0x10 和显存操作MBR 第一次运行时屏幕是黑乎乎的还可能有 BIOS 留下的自检信息所以我习惯先清一下屏。清屏用的是int 0x10的 0x06 号滚动功能mov ax, 0x0600 mov bx, 0x0700 mov cx, 0 mov dx, 0x184f int 0x10这里 AH0x06 表示滚动窗口AL0 表示全窗口清除BH0x07 表示清屏后空白区域用黑底白字填充CX 和 DX 分别是窗口左上角和右下角的坐标(0, 0) 到 (24, 79) 正好覆盖 80×25 字符模式整个屏幕。清完屏后往显存里写字符。文本模式下一行 80 个字符每个字符占两个字节低字节是字符的 ASCII 码高字节是属性。属性字节的高 4 位决定背景色低 4 位决定前景色其中 bit7 如果是 1字符会闪烁。经典的 0xA4 拆开看就是 1010 0100背景是绿色前景是红色而且带闪烁效果看起来像在跳很适合用来给 MBR 刷存在感。写显存直接用mov byte [gs:0x00], 1这种形式偏移 0 是屏幕左上角第一行第一列第二行第一列偏移是 80×2160十六进制就是 0xA0; 第 0 行显示 1 MBR mov byte [gs:0x00], 1 mov byte [gs:0x01], 0xA4 mov byte [gs:0x02], mov byte [gs:0x03], 0xA4 mov byte [gs:0x04], M mov byte [gs:0x05], 0xA4 mov byte [gs:0x06], B mov byte [gs:0x07], 0xA4 mov byte [gs:0x08], R mov byte [gs:0x09], 0xA4 ; 第 1 行显示 2 MBR mov byte [gs:0xa0], 2 mov byte [gs:0xa1], 0xA4 mov byte [gs:0xa2], mov byte [gs:0xa3], 0xA4 mov byte [gs:0xa4], M mov byte [gs:0xa5], 0xA4 mov byte [gs:0xa6], B mov byte [gs:0xa7], 0xA4 mov byte [gs:0xa8], R mov byte [gs:0xa9], 0xA4 jmp $mov byte [gs:0x00], 1里的byte不能省因为 NASM 无法仅凭内存地址判断你要写一个字节还是两个字节一旦漏掉编译器可能报invalid combination of opcode and operands或者生成错误宽度。踩过一次这个坑后面写所有显存操作我都老老实实带byte。最后jmp $是无限循环$ 表示当前指令地址。执行完两个字符串的输写后MBR 的任务暂时完成原地打转等系统崩溃或重新启动都可以反正我们的目的只是验证引导程序能跑起来。3.3 MBR 结尾magic number 的正确姿势代码写完不是直接编译就完事还得让整个扇区凑满 512 字节并在最后两字节写上引导魔数。填充和魔数可以这样写times 510-($-$$) db 0 db 0x55, 0xaa$-$$表示当前代码偏移减去本 section 起始偏移得到已写代码的字节数510-($-$$)就是还需要填充多少字节才能让前面代码加上填充正好到第 510 字节的位置给最后的 0x55AA 留出两个字节。这里有个小细节容易踩坑如果编译出来的mbr.bin大小不是 512 字节先别急着 dd 写盘用xxd看一下。常见的问题是jmp $写成了jmp 0x7c00或者 section 段地址算错导致填充数量异常。我自己的检查命令是nasm -o mbr.bin mbr.S ls -l mbr.bin xxd mbr.bin | tail -2正常输出应该最后一行是类似00001f0: 0000 ... 55aambr.bin大小正好 512 字节。4. 让虚拟机会真正执行它写盘、运行、调试4.1 编译与写盘dd 命令与 convnotrunc编译完成后把mbr.bin写到虚拟硬盘hd60M.img的第一个扇区。Linux 下我用 dd 命令dd ifmbr.bin ofhd60M.img bs512 count1 convnotrunc这里的两个参数每个都有故事。count1表示只写 1 个扇区避免误把后续内容覆盖到磁盘其他位置convnotrunc表示不截断文件如果目标镜像比输入文件大写完之后保留镜像原有的剩余内容。对于 MBR 来说如果没有convnotrunc当mbr.bin大小小于 512 字节时dd 会直接截断镜像文件虚拟硬盘直接就废了。写完之后可以立刻验证xxd hd60M.img | head -1 xxd hd60M.img | tail -1第一扇区的前 16 字节应该是 MBR 开头的机器码最后一个扇区末尾应该能看到55 aa。Windows 环境下没有原生 dd我常用的办法是用 Git Bash 自带的 dd或者干脆把hd60M.img放到 WSL 里用 Linux 命令写入再拷回 Windows。也有专门的图形化镜像写入工具但命令行比图形工具更可控。4.2 Bochs 调试三板斧断点、看内存、看寄存器代码写进镜像后运行bochs -qf bochsrcBochs 会加载 BIOS、读取镜像、然后从 BIOS 开始模拟整个启动过程。如果一切正常屏幕应该出现绿色背景、红色闪烁的字符。如果没出现别急着改代码先用 Bochs 自带的调试器看一遍执行过程。我常用的调试流程是b 0x7c00 c第一个命令b 0x7c00在 MBR 入口设断点c继续执行。因为 BIOS 会做很多初始化如果不设断点代码瞬间就跑完jmp $进入死循环很难观察中间状态。断点命中后用info registers查看寄存器用xp /16bx 0x7c00查看物理内存 0x7C00 处的机器码用u 0x7c00 0x7c10反汇编当前指令确认编译器生成的目标码和源码预期一致。单步调试时按n它会逐条执行汇编指令。走到int 0x10之前观察ax、bx、cx、dx的值是否和清屏参数一致走到写显存指令之后执行命令xp /16bx 0xb8000如果看到0xB8000处的字节序列里有31字符 1 的 ASCII 码、A4、然后又是A4那就说明字符已经写进显存屏幕显示不出来多半是图形显示配置的问题。这种“先用调试器确认数据写对再怀疑显示后端”的思路能帮我省掉大量瞎猜时间。4.3 第一次运行的可能结果正常情况下你会看到屏幕左上角出现“1 MBR”第二行出现“2 MBR”字符是绿底红字而且因为属性字节 bit7 置 1显示上会有闪烁感。如果屏幕没有任何变化或者 Bochs 提示No bootable device先做三件事第一xxd检查镜像最后一个扇区末尾是不是55 aa第二确认 bochsrc 里的磁盘路径正确、几何参数和 bximage 一致第三确认编译出的mbr.bin大小正好是 512 字节。绝大多数“啥也没显示”的问题最后都归结到这三个环节而不是代码逻辑本身。如果屏幕出现乱码那就优先怀疑段地址。用xp /32bx 0xb8000看一眼显存内容再算一下gs4偏移的物理地址有没有超出文本显示范围。我最初犯过的错误是把mov ax, 0xb800写成了mov ax, 0xb8000结果访问物理地址直接跑到高位区全屏都是花点。5. 折腾完 MBR 之后固件、分区表与 GPT 的现实对照5.1 MBR 不只是一个引导程序它还是一个“分区表容器”写完 MBR 引导代码很多人会忽略同一扇区里的另一个重头戏分区表。其实 MBR 的 512 字节是“引导代码 分区表”二合一的容器。偏移 0x1BE 开始的 64 字节被划分为 4 个分区表项每个分区项占 16 字节其中包含分区类型、起始扇区、扇区数等信息。这个 4×16 字节的设计直接导致两个著名的限制第一MBR 磁盘最多只能有 4 个主分区第二因为分区表项里记录分区起始扇区用的是 32 位整数按每扇区 512 字节计算最大可表示约 2TB。如果再算上一块盘想装超过 4 个分区就得引入扩展分区和逻辑分区绕路麻烦得很。第二个限制是可靠性。MBR 里没有分区表备份机制分区表放在磁盘最开头这唯一一份。只要第 0 扇区被写坏、被病毒感染或者被异常覆盖整个磁盘的分区结构都跟着完蛋。这也是后来 GPT 要专门补课的地方。5.2 GPT它和 MBR 的区别在哪儿为什么我们还是要学 MBRGPT 是新一代分区方案它不再依赖 512 字节主引导扇区里的分区表。GPT 磁盘的 LBA0 仍然保留一个“保护性 MBR”里面放一条类型为 0xEE 的分区记录用来告诉老旧的磁盘工具“这块盘已被占用不要把它当空盘处理”。真正的 GPT 头部在 LBA1分区表项从 LBA2 开始可以放很多个分区项还带 CRC 校验并在磁盘末尾保留一份 GPT 头备份。用一张表能把 MBR 和 GPT 的核心差异讲清楚对比项MBRGPT分区数量上限4 个主分区默认 128 个以上磁盘容量上限约 2TB理论可达 ZB 级备份机制无头尾双备份 CRC 校验引导方式BIOS / LegacyUEFI操作系统支持老系统通用现代系统默认现在的 Windows 11 要求 UEFI GPT很多装机教程天天争论“装 Win11 用 GPT 还是 MBR”答案很清楚新机器建议 GPT老机器如果主板只支持 Legacy BIOS那就老老实实用 MBR。但不管选哪个引导链路的本质没变固件先去读一个引导程序引导程序再去加载操作系统。UUID 也好ESP 分区也好最终都是把控制权一级一级交出去。单纯从学习角度讲我仍然建议先手写一遍 MBR。因为 GPT 和保护性 MBR 的细节都是建立在“固件怎么找引导程序”这个逻辑上的。你亲手把一个扇区写到 0x7C00、验证 0x55AA 能被 BIOS 识别之后再看 UEFI 里那些ESP分区、bootx64.efi、Chainload的概念会顺畅得多。6. 常见问题与排查技巧实录这一章看着代码不多实际跑起来遇到的情况还挺五花八门。我把踩过和帮人排过的坑整理成一张速查表按现象排查比从头看教程要快得多现象常见原因排查与解决Bochs 提示 No bootable device0x55AA 没写对或 dd 没写进第 0 扇区xxd hd60M.img | tail -1看末尾重新执行 dd务必加 convnotrunc屏幕完全没输出代码没执行到写显存指令或 BIOS 没跳到 0x7C00用b 0x7c00断点确认单步执行观察指令流显示乱码段地址/偏移算错写进显存地址不对xp /32bx 0xb8000查看显存内容确认gs0xb800字符显示但颜色不对属性字节高 4 位背景、低 4 位前景理解反了属性 0xA4 检查 bit7 闪烁位、bit6-4 背景、bit3-0 前景NASM 编译报错内存操作忘记写byte/word或 vstart 位置不对检查mov byte [gs:...], 1确认vstart0x7c00写在 section 后重复编译写盘后还是旧代码dd 目标文件写错或镜像路径不对确认绝对路径写完立即用 xxd 查看第 0 扇区机器码和当前源码是否一致Bochs 启动后黑屏display_library 配置错误或图形库没装换用display_library: x或 sdl2确认安装对应图形包除了表格里的排查思路再分享两个个人习惯。第一我在做完一次代码修改后会固定执行“nasm → ls -l → xxd → dd → bochs”这一条流水线其中 xxd 这一步几乎从不跳过。因为它只要两秒钟却能直接确认编译结果、扇区大小、魔数位置三个关键信息。跳过这一步直接 dd遇到问题反而要回头多查好几遍。第二调试器里不要一心想着“跑完”要有意识地打断点观察。比如第一次运行到int 0x10清屏时可以在打断点后查看寄存器dx是不是0x184f如果不是说明你把行列参数搞颠倒了。这种观察习惯对后面调试 loader、开启 A20 地址线、进入保护模式都有帮助。最后再说点体会我在实际写这一章时最大的感受是“原来计算机有权移交的过程是靠这么几个数字约定好的”。0x7C00 告诉我代码该去哪儿0x55AA 告诉固件这段代码能不能信0xB8000 告诉我字符应该写到哪个门牌号。这三个数字背后没有魔法只是硬件工程师和软件工程师之间定下的一套古老契约。你亲手把代码填进这个契约里才算真正开始“掌权”。建议你拿到这套代码后不要光编译跑一遍就完事动手改点东西把1改成你自己的昵称首字母把偏移0xa0改成第 3 行对应的0x140把属性字节0xA4改成蓝底黄字再观察屏幕变化。改完这些你会更直观地理解“偏移量 行号 × 160 列号 × 2”这个公式的含义。下一步我们就可以考虑在 MBR 里调用int 0x13把 loader 从磁盘读进内存让引导程序从“显示字符”进化成“真正装载程序”。