很多学系统编程的人第一次翻开 GRUB 的源码都会有同一个感受几百行汇编看起来像个旧时代的化石但它偏偏是整个启动链路里最不好动的部分。我自己当年第一次读 stage1.S 时也被它那套把自己搬个位置、再跳过去继续跑的手法绕得头晕后来在虚拟机里单步跟踪了几百条指令才算真正把这段代码吃透。这篇把 GRUBGRUB Legacy 0.97 这一代的 stage1.S 从入口到跳转完整拆开来讲适合已经写过一点汇编、想搞懂引导扇区背后机制的人。如果你暂时没写过汇编也没关系只要知道实模式、中断、寄存器这些概念跟着下文过一遍也能建立起整体图景。1. 前置知识开机后 BIOS 如何把控制权交给 stage11.1 实模式下的物理真相0x7C00 与中断向量表不谈 ARM、不谈 UEFI先把时间拨回 BIOS 引导的年代。复位之后CPU 处于 16 位实模式段寄存器基本不可信CS:IP 被强制指向 0xFFFF0也就是 64KB 顶端的 BIOS ROM 区。BIOS 完成自检后会查找第一个可引导设备并把该设备的 0 号扇区LBA 0读入内存地址 0x7C00然后跳过去执行。这里有一个很关键的事实BIOS 只负责读入 512 字节并跳转它不会给你额外的运行环境。操作系统还不存在中断向量表虽然还挂着 BIOS 的中断服务例程但寄存器、栈、数据段统统是默认值一切都要靠自己初始化。0x7C00 这个地址有历史原因——早期 DOS 就是这么约定的后来 IBM PC 的引导规范沿用了它。地址的绝对性是引导代码的第一课你在代码里写的绝对跳转地址必须和链接脚本约定好的加载地址一致否则第一条跳转就飞了。GRUB 的 stage1.S 在入口处立刻做相对寻址的短跳转jmp real_start就是为了让自己在未知地址下也能先站稳脚跟然后再处理重定位问题。1.2 MBR 布局446 字节代码区与 0x55AA 魔数512 字节的引导扇区不是随便 512 字节就能用的。在 MBR主引导记录场景里扇区内容的布局有硬性约定偏移 0x000 到 0x1BD 是引导代码区最多放 446 字节代码偏移 0x1BE 开始是 4 个 16 字节的分区表项共 64 字节偏移 0x1FE 处必须是 0x55AA 魔数也就是按字节序写入的55 AA。BIOS 检查到这个魔数才认为扇区内容有效。这里容易踩坑分区表区域对引导代码来说是禁区。stage1 的代码里专门保证不碰分区表只用代码区那 446 字节凡是需要安装器回填的信息比如 stage2 所在扇区号、加载段地址全部放在 446 字节之内而且刻意集中在代码头部字段不占用执行路径上的空间。提示在十六进制编辑器里看一个有效的 MBR最后两个字节必然显示55 AA。如果结尾是别的值引导流程根本不会走到你写的代码——BIOS 会直接报无效引导扇区然后尝试下一个设备。1.3 为什么只能是汇编512 字节极限的工程妥协新手总会问既然只有 512 字节能干什么其实 stage1 并不需要能干多少活它只需要回答三个问题启动盘是哪个下一步的代码在哪怎么把它读进来。GRUB 把真正繁重的工作留给 stage2 和 stage1_5stage1 只是一个最小加载器。这种分层设计背后是工程妥协BIOS 只保证读入一个扇区而且实模式下内存布局混乱任何把大量逻辑塞进 512 字节的行为都会导致代码可读性崩坏、错误处理无从下手。所以 GRUB 选择了三棒接力stage1 在 MBR 里负责读取固定位置的一小段代码如果读的是 stage1_5这段代码认识文件系统能从 /boot/grub 下找到 stage2如果是直接加载 stage2stage1 就用安装时写好的绝对扇区号把 stage2 读进内存。这种512 字节只做跳板的思路后来被大量嵌入式 bootloader 沿用。2. stage1.S 的整体设计512 字节到底装什么2.1 职责边界stage1 只干四件事在 GRUB Legacy 的体系里stage1 有且只有四个职责保存启动盘号、初始化最基础的环境关中断、设置栈、清零段寄存器、读取 stage1_5/stage2 的起始扇区、跳转到后续代码。从代码体积看完成这些只需要两百多字节指令剩余空间用来放关键字段和运行期数据。第二个职责听起来简单实际很讲究。BIOS 跳进 0x7C00 时你唯一能信赖的通用寄存器是 DL里面存着 BIOS 引导设备的编号0x80 起是硬盘0x00 起是软盘或可移动设备。SS:SP 指向哪里有些 BIOS 会设置成可用区域有些则不会还有极少数 BIOS 会在跳转前临时占用当前栈。所以在读盘之前不自己初始化栈后面任何一次int 0x13的中断返回都可能因为栈不可靠而崩溃。2.2 头部字段设计可补丁区域为何这样排打开 stage1.S最先看到的是 GPL 版权注释接下来才是代码本体。代码开头布局非常讲究start: jmp real_start .byte 0 drive: .byte 0xff stage2_sector: .long 0 stage2_segment: .word 0, 0start是入口第一条指令是短跳转跳过接下来的一串数据字段。为什么跳转后要垫一个.byte 0因为短跳转本身是两字节而drive字段要从start 3偏移开始放置。这样安排不是为了对齐好看而是为了让安装器能用固定偏移去修改这些字段——GRUB 约定它们的绝对位置比用标号引用更稳妥因为安装脚本不是 GRUB 自身代码它是在宿主机上直接改写扇区二进制内容的另一个程序。stage2_sector是 stage2 的起始 LBA 扇区号stage2_segment是 stage2 要被加载到的内存段。这两个字段在编译期只是占位符真正有意义的值由grub-install在安装阶段计算好并回填。这种编译期留空、安装期回填的做法是引导程序的通用技巧因为引导代码不知道未来的文件系统布局和分区位置。2.3 位置无关与 ABS 宏一套代码编译出两种引导stage1.S 里有一个容易被忽略的宏#ifdef STAGE1_5 #define ABSOLUTE_ADDR(x) (x - 0x2000) #else #define ABSOLUTE_ADDR(x) (x) #endif #define ABS(x) ((x) - real_start ABSOLUTE_ADDR(x))它的作用是把一个逻辑地址转换成当前加载地址下的实际地址。同一份源码既要编译成 MBR 版本加载地址 0x7C00又要编译成 stage1_5 版本加载地址不同代码里所有带绝对地址的引用都必须经过ABS()包装确保代码在自拷贝之后跳转目标仍然是拷贝后的位置。我分析这一类代码的经验是先搞清楚这个文件会被加载到几个不同地址再回头看宏一下子就能理解为什么代码里到处是ABS(something)。GRUB 的链接脚本和 Makefile 里定义了 STAGE1_5 宏同一份 .S 文件被编译两次产出两个行为略有差异的二进制这是位置无关代码PIC思想在汇编层面的最朴素的实现。3. 逐段解析从 start 到跳转 stage2 的关键代码3.1 cli 与栈初始化为中断调用铺路real_start的第一件事是cli。为什么要关中断实模式下栈还没设置好中断一进来就会把返回地址压到未知内存系统直接崩溃而且读盘过程本身很短没必要响应时钟中断。接下来是一串段寄存器清零然后把 SP 设为 0x2000movw $0x2000, %sp xorw %ax, %ax movw %ax, %ss movw %ax, %dsSS:SP 等于 0:0x2000意味着栈顶在物理地址 0x2000 处向下增长。0x2000 这个值不是随便选的它要避开头 1KB 的中断向量表0x0000-0x03FF也要避开后续 stage2 的加载区域。把栈放在低地址段是因为实模式下只能用 64KB 段而 stage2 往往要加载到 0x8000 或更高的内存区域栈如果也放那边数据一读入就把栈冲掉了。注意movw %ax, %ss和设置 SP 之间x86 允许在修改 SS 后紧跟一条指令再设置 SP 而不触发中断但中间插了别的指令就可能出问题。老汇编代码里cli加上这种紧挨着的 SP/SS 设置是非常典型的实模式初始化手法初学者不要擅自调整顺序。3.2 保存启动盘DL 是唯一信使BIOS 进入引导代码时DL 寄存器是唯一被正确设置的通用寄存器它表示我是从哪个设备启动的。stage1 把它存到drive字段movb %dl, drive这个字段为什么要单独存因为后面读盘、跳转都要用到设备号而某些 BIOS 在调用 INT 13H 之后会破坏 DL所以读盘前要用保存的值重新加载 DL不能假设它一直不变。此外stage2 也需要知道设备号但 stage1 跳转时会重新把设备号放进 DL这是引导代码之间约定俗成的寄存器传参。有个很隐蔽的细节GRUB 在进入real_start后把所有段寄存器都归零唯独 DL 被特意保留下来并写入drive字段。可见段寄存器清零是初始化而 DL 的处理是保存现场两步目的完全不同不能混为一谈。3.3 LBA 探测三道关卡AH41H 的完整检验stage1 读盘优先走 LBA 扩展而不是老式 CHS。首先探测 BIOS 是否支持扩展movw $0x55aa, %bx movb $0x41, %ah int $0x13 jc chs_mode cmpw $0xaa55, %bx jne chs_mode andw $0x1, %cx jz chs_mode逻辑拆开看调 AH41H 后如果 CF 置位说明 BIOS 不支持扩展直接走 CHS返回时 BX 必须是 0xAA55CX 的位 0 必须是 1表示支持扩展读盘接口。这三道判定一个都不能少少了哪一步都可能把一个半支持的 BIOS 误判成完全支持后面读盘就会出莫名其妙的问题。支持 LBA 后用 INT 13H AH42H 读取参数是一个 16 字节的 Disk Address PacketDAP偏移字段含义0size包长度必须为 0x101reserved保留恒为 02~3count要读取的扇区数4~5offset缓冲区偏移6~7segment缓冲区段地址8~15lba64 位 LBA 起始扇区号GRUB 在运行时构建这个包然后调int 0x13。DAP 本身要放在内存里——这就回到栈初始化的意义SP 设在 0x2000DAP 可以安稳地放在已知的栈顶附近不会因为干扰其他数据而报错。为什么优先 LBA因为 LBA 是线性的扇区号不涉及柱面/磁头/扇区换算对大容量磁盘支持很好CHS 模式受 BIOS 几何参数限制访问大容量盘非常困难。但 LBA 扩展不是所有 BIOS 都有所以代码必须保留 CHS 兜底路径。3.4 CHS 兜底读盘与参数换算一旦不支持 LBA就只能用 INT 13H AH02H 按 CHS 读盘。CHS 读盘要把 24 位地址塞进几个寄存器CL 的低 6 位是扇区号CL 的高 2 位加 CH 的全部 8 位组成 10 位柱面号DH 是磁头号DL 是驱动器号。代码需要做 LBA 到 CHS 的换算公式是扇区号 (LBA % 每磁道扇区数) 1磁头号 (LBA / 每磁道扇区数) % 磁头数柱面号 LBA / (每磁道扇区数 × 磁头数)stage1 没有自己猜测几何参数而是读取分区表项里的起始 CHS 值来推算。这也解释了为什么 stage1 只读紧跟 MBR 的扇区或安装器写死的扇区号——它不需要理解文件系统只需要把 BIOS 能懂的 CHS 地址硬算出来。CHS 模式下每磁道扇区数通常按 63 处理磁头数按 255 处理这是 PC 上的通用约定。3.5 读盘失败处理与屏幕上的GRUB字样读盘失败怎么处理stage1 的read_error分支会反复尝试若干次然后打印几个字符提示用户最后进入死循环或尝试调用 BIOS 的其他入口。实际效果就是失败的引导扇区通常表现为屏幕上出现GRUB几个字符然后机器卡住不动。为什么失败后要重试因为 BIOS 读盘有时是瞬时抖动尤其老机械硬盘上磁头归位瞬间会出现偶发错误。重试几次成本极低、收益很高这个细节后来被大量引导代码借鉴。另外GRUB字样本身就是一个很朴素的调试手段看到它说明 stage1 已经成功执行到了读盘阶段问题出在后续环节而不是 BIOS 压根没引导到这个扇区。4. 构建与安装源码怎么变成可引导的 MBR4.1 编译成裸二进制与体积检查stage1.S 不会单独链接成 ELF 再拿去写盘而是由构建系统把它编译成裸二进制。关键点是链接脚本必须保证.text段从约定的加载地址开始且所有符号都落在 446 字节范围内。如果你改了代码导致体积超限链接器通常不会直接报错而是等安装器检查扇区大小时才发现——这也是很多人改完引导代码后写盘成功但引导失败的根源。GRUB 的构建流程里有一个stage1_size之类的检查逻辑确保最终二进制不超过 512 字节还要预留 64 字节分区表和 2 字节魔数多余空间用 0 填充。实际编译出来的 stage1 大约只有三百多字节代码比上限宽松不少所以后续加功能时还有余地。但要注意改代码容易控制体积难每加一条指令都要想想有没有更省的写法。4.2 安装器如何回填关键字段grub-install干的事情远比把二进制写到 MBR复杂。它要做三件事根据磁盘布局计算 stage2 的起始扇区号算出 stage2 实际加载的目标内存地址把这两个值按约定好的固定偏移写进 stage1 的二进制连同分区表一起写入 MBR 扇区。回填的原理很简单因为stage2_sector和stage2_segment固定在start 4和start 8偏移处安装器直接打开二进制文件移动文件指针到对应偏移覆写 4 字节和 2 字节即可。这就是前面为什么在start后安排那么多占位字段——不是浪费是为了让安装逻辑保持简单稳定也让后续维护者一眼就明白布局。实战提示如果你手动改过 MBR发现 GRUB 提示找不到 stage2先用十六进制编辑器看偏移 0x04~0x07 是否为合理扇区号、偏移 0x08~0x0B 是否为合理内存段。这两个字段被弄乱是最常见的人为故障。4.3 三级接力stage1 到 stage1_5 再到 stage2GRUB 完整引导路径不一定只有两级。当/boot所在分区从 MBR 算起的扇区数超过 1024 柱面或者文件系统不支持直接扇区定位时stage1 不直接读 stage2而是先读 stage1_5——一个针对特定文件系统的独立小模块放在 MBR 和第一个分区之间的空隙里。stage1_5 认识文件系统能从 /boot/grub/stage2 把真正的 stage2 读出来。这三级设计很妙stage1 只有固定扇区寻址能力它读取的 stage1_5 的位置是安装时写死的不依赖文件系统stage1_5 的体积在几十 KB 以内能塞进 MBR 后面通常保留的 62 个扇区。如果把这些功能全部塞进 stage1代码量会爆炸如果让 stage1 直接支持常见文件系统446 字节连文件系统超级块结构都放不下。所以每级只解决一个问题是引导程序的黄金法则。5. 常见问题与调试实录5.1 屏幕停在GRUB字样不动很多人以为这是 GRUB 菜单卡住其实大多数情况是 stage1 读后续扇区失败。GRUB 的 stage1 会在成功读取后、跳转前把GRUB这几个字打到屏幕上作为执行标记停在这一行说明读盘失败代码已经进入错误分支。排查顺序先确认stage2_sector字段是不是指向真实存在的扇区再用镜像工具检查 MBR 后面几个扇区是否被其他工具覆盖最后确认 BIOS 设置里启动设备的 LBA 模式没有被强制成奇怪值。如果是虚拟机换一个磁盘控制器类型IDE 换 SATA 或反之往往能排除 BIOS 磁盘接口差异。5.2 分区表正常却无法引导这类问题八成出在字段错位。因为 stage1 代码和分区表共享同一个 512 字节任何修复引导扇区的工具若把数据写到了错误偏移就会覆盖 stage1 代码。检查手段用十六进制工具打开 MBR看 0x1BE 处是不是四个有效分区表项、0x1FE 处是不是 55AA再看 0x00~0x1BD 区域能否看到明显汇编指令特征比如EB xx开头的短跳转、连续填充的00。如果 MBR 首字节被写成了 0x90NOP 空操作或其他非跳转指令不要慌乱可以直接用备份分区表重建或者从同一版本 GRUB 的安装脚本重新执行安装。我自己遇到过几次手滑用dd写错偏移的情况教训只有一个写 MBR 之前先备份原始扇区格式要精确到块设备。5.3 用 QEMU 和 Bochs 单步调试引导代码我调试引导代码最常用的组合是 QEMU 加 GDB 脚本直接对磁盘镜像下软件断点。QEMU 可以模拟 BIOS 中断入口对int 0x13这类调用能直接观察寄存器返回。步骤很简单先启动 QEMU 并在第一个指令处暂停再用 GDB 连上去target remote :1234之后对 stage1 的任意地址下断点比如b *0x7c00、b *0x7c20单步观察寄存器如何被填充。Bochs 内置调试器则更贴近实模式可以直接在 0x7C00 下断、查看内存中的 DAP 结构。两种工具各有优势Bochs 对实模式细节模拟更准QEMU 更轻量、调试脚本更顺手。我在分析 LBA 探测逻辑时靠 Bochs 把int 0x13的返回值断下来看——因为仅看源码里的jc分支很难直观判断 BIOS 到底支持什么。5.4 缓冲区跨 64KB 边界的 BIOS 怪癖老 BIOS 的扩展读盘接口有时对缓冲区地址处理有 bug特别是缓冲区跨越 64KB 边界时某些实现会读错数据。GRUB 的做法是把 stage2 加载地址刻意安排在不会跨边界的段上。如果你自己写引导代码我的建议是缓冲区偏移尽量从 0 开始或者把目标地址对齐到 16 字节边界宁可用不完内存也别让 BIOS 的怪癖坑到你。这套核对了半天硬件经验才发现是 BIOS 实现闹鬼的排查流程几乎每个写引导代码的人都会经历几轮。遇到这种问题不要急着怀疑自己的换算公式先用最小复现实验确认是哪个环节出错再回头查代码。我个人做系统底层相关开发这些年反复读 stage1.S 的体会是这段代码虽然短但每一行都在和资源极度受限做斗争——512 字节、实模式、不能假设任何环境。它像是整个操作系统世界里最原始但最严谨的接力棒交接。如果你正在学操作系统或者想搞明白自己的电脑从按下电源到出现菜单之间发生了什么把 stage1.S 啃下来收获的不只是一段汇编更是一整套在极端约束下做设计的思维方式。下一步可以继续读 stage2 的入口代码或者拿 QEMU 试试给 MBR 打补丁都是很好的扩展方向。