第03章 引导启动程序(1补充):硬件参数的捕获与根设备号——解码 bootsect.s 的“暗号”
引言一个看似简单的问题牵出半部计算机史朋友你是否有过这样的经历给电脑换了一块新硬盘或者插了一个 U 盘启动盘然后系统顺利启动一切正常。但你想过没有——操作系统是怎么知道它自己装在哪个盘、哪个分区上的它总不至于把电脑上每个存储设备都翻一遍吧当然不是。这个问题的答案就藏在引导程序bootsect.s中一个不起眼的数字里ROOT_DEV 0x306。这个数字在当时的 Linux 世界里就像一张回家的地图。它告诉内核“你的根文件系统在第二个硬盘的第一个分区上。” 但为什么是0x306它到底是怎么编码的这个约定从何而来又有哪些故事藏在它的背后在本篇文章中我们将从bootsect.s的这一行代码出发一路追溯到硬盘的分区表再追溯到 BIOS 的引导扇区魔数0xAA55。你会发现这些看似孤立的“冷知识”其实是一条完整的逻辑链是计算机启动过程从底层走向高层的“桥头堡”。1. 根设备号ROOT_DEV内核的“回家坐标”1.1 设备号的二进制编码主设备号与次设备号在 Linux 0.11 的时代设备号并不是我们今天熟悉的/dev/sda1这样的字符串而是一个16 位的整数。这个整数被分成了两段设备号 (主设备号 8) | 次设备号也就是说高 8 位是主设备号major number低 8 位是次设备号minor number。这个编码方式其实是沿用了 UNIX 早期传统的设备号管理思想——用一个整数来区分设备类型主设备号和同一类型中的具体单元次设备号。在 Linux 0.11 的内核中主设备号的分配是固定的。下面这张表记录了当时内核所支持的设备类别主设备号设备类型典型设备文件说明1内存设备/dev/ram内存虚拟盘RAM disk用于存放临时文件系统2磁盘设备/dev/fd0软盘驱动器早期 Linux 的主要安装介质3硬盘设备/dev/hd0硬盘驱动器这是最核心的块设备4终端设备/dev/ttyx串行终端如ttyS05终端设备/dev/tty控制台终端键盘显示器6并行设备/dev/lp并口打印机7命名管道内部使用用于进程间通信的管道文件所以ROOT_DEV 0x306拆开来就是高 8 位0x03→ 主设备号为 3即硬盘设备。低 8 位0x06→ 次设备号为 6即具体硬盘上的第6号“单元”。但问题来了“6 号单元”到底是什么意思是第 6 个分区吗不完全是。这就要说到 Linux 0.11 特有的分区编号规则了。1.2 次设备号的“分区映射规则”在今天的 Linux 中我们习惯用/dev/sda1、/dev/sda2来表示第一个硬盘的第一个、第二个分区。但在 Linux 0.11 中分区编号并不是这样直接连续的。它的规则更像是一张“映射表”。在include/linux/fs.h和kernel/blk_drv/hd.c中我们可以找到这样的隐含约定次设备号范围对应的硬盘/分区说明0x00-0x04第一个硬盘hd0及其分区0x00 整个硬盘0x01-0x04 第 1-4 分区0x05-0x09第二个硬盘hd1及其分区0x05 整个硬盘0x06-0x09 第 1-4 分区注意这里的“分区”编号只到 4因为 MBR 分区表最多只支持 4 个主分区。在 Linux 0.11 时代扩展分区逻辑分区的概念还不普及或者内核尚未完整支持。所以0x06就是第二个硬盘的第一个分区hd1的第 1 个分区。在当时的设备文件命名中它对应的节点是/dev/hd6在某些文档中也可能直接写作/dev/hd1的第一个分区但具体命名因发行版而异。小插曲Linus 为什么要把默认根设备设为第二个硬盘的第一个分区这很可能是因为 Linux 早期开发是“寄生”在 MINIX 系统上的。Linus 当时可能在一台装有 MINIX 的机器上工作Linux 安装在第二块硬盘上方便他来回切换测试。这种“自用”习惯直接固化在了内核的默认配置里。我们只能说那个年代的开发者真的是“自己用的东西就直接当标准发布了”。1.3 运行时动态探测当ROOT_DEV为 0 时当然Linus 也考虑到了通用性。如果ROOT_DEV在编译时被设为0那就意味着“请自动检测根设备”。这时bootsect.s就会进入一段“侦探模式”——它根据从 BIOS 获取的软盘参数来推测我们是从哪种软盘启动的。我们来看这段代码bootsect.s第 116–130 行seg cs mov ax,root_dev cmp ax,#0 jne root_defined seg cs mov bx,sectors mov ax,#0x0208 ! /dev/at0 - 1.2MB cmp bx,#15 je root_defined mov ax,#0x021c ! /dev/PS0 - 1.44MB cmp bx,#18 je root_defined undef_root: jmp undef_root root_defined: seg cs mov root_dev,ax它的逻辑很清晰如果root_dev非零直接使用它。如果root_dev为零则读取sectors每磁道扇区数若sectors 15则认为是1.2MB 软盘对应设备号0x0208主设备 2次设备 8。若sectors 18则认为是1.44MB 软盘对应设备号0x021c主设备 2次设备 28。如果都不是则陷入死循环死机。为什么 1.2MB 软盘是 15 扇区/磁道1.44MB 是 18 扇区/磁道这是由软盘的物理格式决定的。1.2MB 软盘5.25 英寸高密采用 80 磁道、2 磁头、15 扇区/磁道1.44MB 软盘3.5 英寸高密采用 80 磁道、2 磁头、18 扇区/磁道。BIOS 在初始化时会从软盘控制器读取这些参数放在内存的 BIOS 数据区。bootsect.s通过int 0x13的 AH0x08 功能号获取这些信息并保存在sectors变量中。这个动态探测的设计体现了早期内核的“自适应”思想同一个内核镜像可以同时适应 1.2MB 和 1.44MB 两种软盘启动提高了可移植性。现在我们用一张流程图来总结这个过程否是是否是否读取 root_dev 值root_dev 0?直接使用 root_dev从 sectors 获取每磁道扇区数sectors 15?设为 1.2MB 软盘设备号 0x0208sectors 18?设为 1.44MB 软盘设备号 0x021c死循环无法继续保存 root_dev 到固定内存位置继续执行 setup这个小小的逻辑让bootsect.s完成了一次“智能判断”为后续内核加载根文件系统铺平了道路。2. 硬盘分区表硬盘的“房产证”刚才我们聊了根设备号但这个设备号本身只是一个“门牌号”它所指代的物理位置到底是怎么定义的呢这就必须了解硬盘分区表Partition Table了。它是硬盘上真正的“地契”记录了每个分区的起始位置、大小和用途。2.1 MBR 的结构与分区表项我们说过硬盘的第一个扇区0 磁道 0 磁头 1 扇区有特殊的名字——主引导记录Master Boot RecordMBR。它的结构如下偏移量十六进制大小字节内容描述0x0000-0x01BD446引导代码Bootloader由操作系统安装程序写入0x01BE-0x01CD16第 1 个分区表项0x01CE-0x01DD16第 2 个分区表项0x01DE-0x01ED16第 3 个分区表项0x01EE-0x01FD16第 4 个分区表项0x01FE-0x01FF2引导扇区标志0xAA55小端序每个分区表项占 16 个字节其各个字段的定义如下字节偏移大小字段名说明01boot_ind引导标志0x80表示活动分区可引导0x00表示非活动11head起始磁头号CHS 寻址21sector起始扇区号低 6 位以及磁道号的高 2 位高 2 位31cyl起始磁道号的低 8 位41sys_ind分区类型如0x83 Linux0x0b FAT3251end_head结束磁头号61end_sector结束扇区号低 6 位及磁道号高 2 位71end_cyl结束磁道号低 8 位8-114start_sect分区起始扇区的 LBA 地址逻辑块地址从 0 计数12-154nr_sects分区的总扇区数为什么同时要有 CHS 和 LBA 两种寻址方式早期的 DOS 和 BIOS 使用 CHS柱面/磁头/扇区来寻址因为这种方式的物理意义很直观。但 CHS 有容量限制最大约 8GB。随着硬盘容量增大LBA逻辑块地址被引入它用线性地址表示扇区突破了 CHS 的限制。但为了兼容旧系统分区表中同时保留了 CHS 和 LBA 信息。2.2 Linux 0.11 如何解析分区表那么Linux 0.11 是在什么时候、用什么方式读取并解析这些分区表项的呢答案在setup.s和init/main.c之间。setup.s通过 BIOS 中断int 0x13获取了硬盘参数如柱面数、磁头数、每磁道扇区数并把它们保存在0x90000开始的内存区域。随后内核初始化时init/main.c会调用setup()系统调用实际对应sys_setup()位于kernel/blk_drv/hd.c该函数会再次读取硬盘的 MBR解析分区表并填充内核中的分区信息结构。在kernel/blk_drv/hd.c中有一个重要的数据结构hd_structstructhd_struct{longstart_sect;longnr_sects;}hd[5*MAX_HD];/* MAX_HD 2最多两个硬盘每个硬盘最多5个分区含整个盘 */hd_init()函数会调用hd_out()发送WIN_READ命令读取 MBR 扇区然后解析其中的 4 个分区表项并将每个分区的起始扇区号和扇区数填入hd数组。这样内核就建立起了“设备号 → 物理扇区范围”的映射关系。为什么hd数组的大小是5*MAX_HD因为每个硬盘除了 4 个主分区外第 0 项索引 0通常用来表示整个硬盘。在 Linux 0.11 中次设备号0x00和0x05正是对应整个硬盘所以索引 0 和索引 5 被保留给“整个硬盘”使用。这种设计兼顾了简单性和效率。2.3 案例实际硬盘分区表的“体检”为了让你对这个结构有更直观的感受我们来看一个真实的 MBR 数据的十六进制转储。假设我们有一个硬盘镜像文件hd.img我们可以用dd命令提取其 MBR 扇区ddifhd.imgofmbr.binbs512count1然后用hexdump查看hexdump-Cmbr.bin假设输出类似这样为了示范只截取分区表部分000001b0 00 00 00 00 00 00 00 00 80 01 01 00 83 00 00 00 |................| 000001c0 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| ...我们来解析第一个分区表项从偏移0x1BE开始偏移0x1BE80→ 引导标志 0x80表示该分区是活动分区可引导。偏移0x1BF01→ 起始磁头号 1。偏移0x1C001→ 起始扇区号低6位和磁道号高2位。01表示扇区号1磁道号高2位0。偏移0x1C100→ 起始磁道号低8位 0。偏移0x1C283→ 分区类型 0x83即 Linux 分区。偏移0x1C300→ 结束磁头号 0。偏移0x1C400→ 结束扇区号及磁道号高位。偏移0x1C500→ 结束磁道号低8位。偏移0x1C6-0x1C908 00 00 00→ 起始扇区 LBA 8小端序即0x00000008。偏移0x1CA-0x1CD00 00 00 00→ 总扇区数 0此处为示例实际应为非零。注意起始 LBA 为 8意味着这个分区不是从扇区 0 开始扇区 0 是 MBR 自身而是从扇区 8 开始。这是因为在早期系统中扇区 1 到扇区 7 可能被保留用于其他引导记录或空余空间。我们可以用fdisk -l hd.img来验证这些解析是否正确这正是一个“理论对照实践”的好例子。3.0xAA55引导扇区的“生死符”3.1 BIOS 如何识别可引导设备我们刚才看到MBR 的末尾两个字节是0xAA55。这个值在 BIOS 的引导流程中扮演着“判决者”的角色。当 BIOS 遍历启动设备软盘、硬盘、光驱等时它会对每个设备的第一个扇区做如下检查读取扇区到内存。检查该扇区末尾的两个字节是否等于0xAA55在内存中以小端序存储即55 AA。如果是则认定该扇区是“可引导的”并将控制权交给该扇区开头的代码。如果不是则跳过该设备继续检查下一个。这就是boot_flag那行代码存在的全部意义boot_flag: .word 0xAA55如果没有这个标志BIOS 根本不会执行bootsect.s引导过程会直接失败。3.2 为什么是0xAA55这个魔数的来源已经很难考证了但在 IBM PC 的早期设计中这个值被选中的原因可能有以下几点模式识别二进制10101010 01010101在数据总线上是一个很好识别的模式易于硬件检测。补码对称0xAA和0x55互为按位取反这种对称性在硬件设计中可能被视为一种“合理校验”。历史惯性IBM 的工程师当年选择了它后来的所有 PC 都继承了这一约定成为事实标准。有趣的事实在某些非 x86 体系架构如 PowerPC中引导标志可能不是0xAA55而是其他魔数这也说明了这个值是 Intel x86 世界特有的“文化符号”。3.3 如果丢失了0xAA55会发生什么我们可以做个实验用十六进制编辑器如hexedit或ddprintf修改bootsect.s编译后的二进制文件将末尾的55 AA改成00 00然后重新制作启动盘。当 BIOS 加载这个扇区时它检查末尾不是0xAA55就会直接放弃该设备转而寻找下一个启动设备。如果这是唯一的可启动设备系统就会显示类似“No bootable device”或“Operating System not found”的错误信息。这个简单的检查实际上是引导过程中最早的一道“安全检查”——它确保了引导扇区确实是有效的而不是随机的内存垃圾或损坏的数据。4. 综合思考与延伸4.1 从bootsect.s看早期内核设计的“简约主义”回顾bootsect.s的设计我们能看到那个时代开发者特有的“简约美学”512 字节的“囚笼”必须在 512 字节内完成所有工作迫使作者用最精练的代码完成任务。硬编码与动态探测的平衡既允许编译时指定根设备也允许运行时自动检测提高了通用性。明确的协议bootsect.s→setup.s→head.s的接力路线清晰每一个环节都只做自己必须做的事情然后干干净净地交出控制权。这种风格和当时 UNIX 的“做一件事并把它做好”的哲学一脉相承。4.2 现代引导程序的演进如今我们有了 GRUB、LILO、UEFI 等更强大的引导程序。它们支持更多的文件系统如 ext4、NTFS、图形界面、网络引导和复杂的配置菜单。但不管它们多复杂最基本的起点仍然是加载第一个扇区、检查魔数、跳转执行。Windows 的 BOOTMGR、Linux 的 GRUB 的 core.img都是在这个基础上建立的更复杂的“第二级引导器”。理解了bootsect.s你就理解了所有 x86 系统引导过程的“初心”。4.3 实践作业模拟修改bootsect.s的根设备号并观察效果我们鼓励你在 Bochs 或 QEMU 环境下完成如下实验获取 Linux 0.11 源代码。修改bootsect.s中的ROOT_DEV为一个无效值如0x0000或0x0300代表整个硬盘而非分区。重新编译内核生成新的Image文件。用该镜像启动 Bochs观察系统在挂载根文件系统时的错误提示如 “Unable to mount root fs” 或 “Kernel panic”。分析错误信息判断是哪一步出了问题是设备号解析失败还是分区表读取失败。这个实验能让你从“使用者”变成“调试者”更深入地理解设备号在内核中的解析路径。结语我们从一行ROOT_DEV 0x306出发一路解码了设备号的二进制结构揭示了硬盘分区表的神秘面纱又回头探明了引导扇区魔数0xAA55的生死大权。这些看似底层、琐碎的细节恰好勾勒出了操作系统启动的第一道关口。bootsect.s就像一位沉默的侦察兵它没有华丽的语言却用最朴实的方式为内核的万里长征铺下了第一块基石。理解它不仅是理解一段代码更是理解计算机系统设计中的“契约精神”——硬件与软件之间、BIOS 与操作系统之间、引导程序与内核之间通过一个个明确的约定设备号、分区表、魔数实现了无缝的协作。在下一篇文章中我们将跟随setup.s的脚步看它如何完成从实模式到保护模式的“华丽转身”并最终把舞台交给head.s。到那时我们会发现每一行汇编代码背后都藏着一段关于兼容性、性能和历史的精妙权衡。让我们继续前进吧。