JOS bootloader详解:从实模式到保护模式的内核加载全流程
自学 6.828 这种操作系统经典课程最大的坎往往不是代码量而是“不知道自己写的代码到底在干什么”。尤其是 JOS 的 bootloader满打满算不到两百行却横跨实模式、保护模式、ELF 装载、IDE 磁盘协议好几个完全不同的领域。我第一次顺着源码读了三遍前两遍都是半途而废第三遍才真正看明白。后来我意识到问题不在于源码本身而在于我一直用“看应用代码”的方式去读它没有先建立 CPU 加电启动到内核执行之间的完整图景。这篇笔记会按我第三遍才理清的思路来写先讲清楚 bootloader 到底要解决什么问题再逐段拆解 boot.S 的汇编逻辑接着进入 main.c 的 C 语言装载流程最后补上我在实验里踩过的坑和调试心得。如果你能把这篇文章吃透后续再做进程初始化、虚拟内存映射这些实验时阻力会小很多。毕竟 JOS 所有实验都是建立在“内核已经被正确加载到内存里”这个前提上的而这个前提的建立者就是 bootloader。1. 先搞清楚 bootloader 的使命再看代码1.1 一个目标明确的“搬运工”JOS 的 bootloader 不是一个操作系统也不是一个完整软件它的身份更像是一段“临时工”代码把内核镜像从磁盘搬到内存然后把 CPU 的控制权交出去之后它就不再有存在感了。整个过程中最关键的两个动作一是“搬运”二是“跳转”。搬运没那么简单。磁盘上的数据是按扇区存的一个扇区 512 字节而内核是一个 ELF 格式的可执行文件有头部、有各个段还有一堆元信息。bootloader 必须先读懂 ELF 格式才能知道该把哪些数据放到内存的哪个位置。跳转就更讲究了跳去的目的地不能是随便一个地址必须是内核 ELF 头里记录的入口地址这个地址通常由链接脚本决定。如果你在实验里发现内核没跑起来大概率就是 elf 头和真实加载位置对不上。理解这个逻辑之后你会发现 bootloader 源码其实非常直白它的核心函数就是一个 bootmain先读 ELF 头再循环读所有程序段最后跳转。真正让人觉得难啃的是它运行的环境刚开机时 CPU 还在 16 位实模式没有现成的内存映射可用没有友好的调试工具一切都得按最底层的硬件规则来。这也是为什么 bootloader 必须用汇编写前半段的原因不是为了炫技而是因为 C 语言编译出的 32 位代码在实模式下根本跑不了。1.2 这段代码在工程里的位置如果你已经拿到了 JOS 的源码打开 boot 目录会看到两个核心文件boot.S 和 main.c。前者是汇编写的启动段后者是 C 写的内核装载逻辑。这两个文件不是独立编译的它们会被链接到一起最终生成的内核镜像文件里最开头就是 bootloader紧跟着才是内核的 ELF 数据。还有一个细节值得注意x86 规定 BIOS 只会从启动盘的第一个扇区加载 512 字节到内存 0x7C00 处所以 boot.S 编译后的总大小必须小于 510 字节。这也就解释了为什么 bootloader 的汇编部分写得那么紧凑每一条指令都精确设计过。我在做实验时特意检查过反汇编发现 boot.S 生成的机器码确实只占了一个扇区的大部分剩下的空间被用来存放 GDT 描述符和栈地址等静态数据。当时我犯过一个错误在 boot.S 里多加了一条调试用指令结果编译后镜像就超过了一个扇区QEMU 直接无法启动。从那以后我养成了习惯每次改完 bootloader 代码都会先看一眼生成的镜像文件大小。2. 开机那一刻实模式的准备动作2.1 从复位向量说起很多人以为开机后第一件事就是运行操作系统其实不是。x86 处理器上电后会从地址 0xFFFFFFF0 开始执行这个地址是主板 BIOS 的入口也就是常说的复位向量。BIOS 本身是一个固件程序它负责初始化硬件、检测内存、设置中断向量表最后一步才从存储设备上读取引导扇区。JOS 实验用的 QEMU 模拟了整个启动过程所以你能在调试器里完整看到从 0xFFFFFFF0 开始的执行轨迹。不过 BIOS 的内部逻辑对我们来说是个黑盒真正需要关注的是它把引导扇区读出来后跳转到 0x7C00 的那一刻。从这一刻起bootloader 才开始接管。0x7C00 这个地址是历史的产物在实模式下它属于 1MB 内存范围的末端区域。为什么 BIOS 偏偏选择这个位置简单说这是为了保证引导扇区不会被加载过早的内存中断向量表和数据区同时又能避开扩展内存区域的复杂性。作为一个约定俗成的标准所有兼容 x86 的机器都遵循它。2.2 实模式下的段寄存器为什么必须清零进入 bootloader 时CPU 还运行在 16 位实模式。此时的寻址方式是“段寄存器左移 4 位再加偏移”比如段寄存器 CS 的值是 0xF000偏移是 0xFFF0组合起来就是 0xFFFF0。这种模式下可用内存只有 1MB而且代码的寻址能力受限于 16 位偏移量非常憋屈。BIOS 跳转到 0x7C00 时它并不会保证段寄存器处于一个干净状态。所以 boot.S 的第一段代码就是清理现场把 DS、ES、SS 全部清零.globl start start: .code16 cli cld xorw %ax,%ax movw %ax,%ds movw %ax,%es movw %ax,%ss这里的cli是关中断防止后续操作被硬件中断打断。cld是清除方向标志位确保之后用到的字符串指令比如insl按地址递增方向执行。xorw %ax,%ax把 AX 寄存器清零再通过 AX 把它赋给数据段、附加段和栈段。我最初读到这里时有一个疑问为什么不初始化 CS后来才明白CS 的更新必须通过跳转指令间接完成你没法直接往 CS 里写值。后面切换到保护模式时一条远跳转会顺带着把 CS 改成新值。至于 SS它被设置成 0 其实有点冒险因为栈会从 0 开始向下生长很容易碰到中断向量表。不过前面已经关掉中断这个风险暂时不存在。JOS 这么做是为了简洁等到保护模式切换完成后才真正把栈指针设置到安全位置。2.3 打开 A20 门为什么要倒腾键盘控制器实模式下地址回绕是一个著名的历史遗留问题。8086 时代的地址线只有 20 根访问超过 1MB 的地址时最高位会被丢弃自动回绕到 0。比如地址 0x100000在 8086 上实际访问的是 0x000000。到了 80286 时代地址线扩展到了 24 根理论上可以访问 16MB 内存了但为了兼容旧软件硬件默认还是开启“回绕”行为。这个行为由 A20 地址线控制所以就有了“A20 门”的说法。JOS 的内核要被加载到 1MB 以上的物理地址所以 bootloader 必须先把 A20 门打开。JOS 用的是最传统的键盘控制器方案seta20.1: inb $0x64,%al testb $0x2,%al jnz seta20.1 movb $0xd1,%al outb %al,$0x64 seta20.2: inb $0x64,%al testb $0x2,%al jnz seta20.2 movb $0xdf,%al outb %al,$0x60第一段inb $0x64读取键盘控制器状态寄存器测试第 1 位这一位为 1 表示输入缓冲区里有数据没被处理完需要等待。等缓冲区空闲后向端口 0x64 发送命令 0xD1意思是“接下来我要往输出端口写数据”。第二段循环同样等待缓冲区空闲然后向端口 0x60 写入 0xDF。这个字节的第 1 位就是 A20 的开关置 1 表示启用。我当时看到这些端口和字节数值第一反应是“这什么鬼”。后来查阅资料才明白这套流程是当年 IBM PC 的键盘控制器接口规范里规定的JOS 选它是因为兼容性最好。实验里如果不执行这段代码内核加载到 0x100000 后数据会被回绕到 0 附近不但内核跑不起来还会把 bootloader 自己踩烂。所以别看这段代码短它一出问题后面的所有工作都会白费。3. 保护模式切换从汇编到 C 的分水岭3.1 GDT 表为什么必须自己搭实模式下的“段基址左移四位加偏移”虽然简单但限制很大。保护模式下段寄存器的含义完全变了它不再直接存储基地址而是存储一个索引指向某张描述符表中的一个表项。这个表项包含段基址、段限长、权限位、粒度位等信息。JOS 的 bootloader 需要自己定义一张 GDT全局描述符表因为此时操作系统还没有建立任何机制BIOS 设置的表不一定适合后续场景。boot.S 里的 GDT 只有三个表项gdt: SEG_NULL SEG(STA_X|STA_R, 0x0, 0xffffffff) SEG(STA_W, 0x0, 0xffffffff)第一个必须是空描述符这是 CPU 的硬性要求索引为 0 的选择子永远指向空描述符用来捕获无效段引用的错误。第二个是代码段描述符基地址 0限长 4GB可读可执行。第三个是数据段描述符基地址 0限长 4GB可写。这三个描述符定义了一个平坦内存模型段基址都是 0段内偏移就是线性地址这正是现代操作系统最常见的布局。提示保护模式定义的是“虚拟地址到线性地址”的映射而分页机制负责“线性地址到物理地址”的映射。bootloader 阶段还没开分页线性地址和物理地址在数值上暂时是一致的。选择子 0x8 和 0x10 是怎么来的GDT 每个表项占 8 字节。索引 1 的偏移是 8索引 2 的偏移是 16十六进制正好是 0x8 和 0x10。注意选择子的低位还包含请求特权级 RPLJOS 这里用的是内核态特权级 0所以最低两位都是 0。3.2 改 CR0 寄存器一条必须“先读后写”的指令GDT 准备好之后接下来就是切换保护模式。这一步的核心操作是修改控制寄存器 CR0 的第 0 位也就是 PE 位。把它置 1CPU 就从实模式切到保护模式。movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0为什么不能直接写movl $1, %cr0因为 CR0 还有其他控制位如果直接覆盖可能会破坏一些默认配置。正确做法是先把当前值读出来用或运算把 PE 位置 1其他位保持不变再写回去。movl %eax, %cr0执行完的那一瞬间CPU 已经在保护模式下工作了。但这里藏着一个很容易忽略的坑流水线里还残留着之前按 16 位模式解码的指令如果在同一条执行流里继续往下走CPU 可能把 32 位指令当成 16 位来解码。解决办法是紧跟一条远跳转指令ljmp $PROT_MODE_CSEG, $protcseg远跳转会刷新指令流水线同时更新 CS 寄存器。跳过去之后CPU 就开始以 32 位模式执行了。这一段逻辑我在调试时反复验证过ljmp后的第一条指令地址在反汇编里是明确的 32 位反汇编没有任何保留的 16 位痕迹。3.3 保护模式下的段寄存器刷新进入保护模式后数据段寄存器还停留在实模式状态必须重新装载否则访问任何内存都会出问题。JOS 的代码是这样做的.code32 protcseg: movw $PROT_MODE_DSEG, %ax movw %ax, %ds movw %ax, %es movw %ax, %fs movw %ax, %gs movw %ax, %ss这里把 DS、ES、FS、GS、SS 全部设置为数据段选择子 0x10。CS 已经在ljmp时被设置成 0x8 了所以不需要单独处理。设置完这些之后所有段寄存器都指向了平坦模型下的段程序的虚拟地址空间变得统一起来。最后设置栈指针movl $start, %esp call bootmain$start是链接脚本中定义的起始标号最终值为 0x7C00。把栈指针设置到这里栈会从 0x7C00 开始向下生长。这是一个非常经典的设计bootloader 自身占用了 0x7C00 到 0x7DFF 这一带区域栈往下长的话会远离这块区域也远离中断向量表相对安全。设置好栈后call bootmain把控制权正式交给 C 语言。4. bootmain 函数内核装载的真正主角4.1 三步走加载 ELF 到指定内存进入 main.c 之后代码风格瞬间从汇编的裸奔转为 C 的整洁。bootmain 函数是整个 bootloader 的核心它的逻辑只有三步void bootmain(void) { struct Proghdr *ph, *eph; readseg((uint32_t) ELFHDR, SECTSIZE * 8, 0); ph (struct Proghdr *) ((uint8_t *) ELFHDR ELFHDR-e_phoff); eph ph ELFHDR-e_phnum; for (; ph eph; ph) readseg(ph-p_va, ph-p_memsz, ph-p_offset); ((void (*)(void)) (ELFHDR-e_entry))(); }第一步把内核 ELF 头部读到内存的 0x10000 处。为什么要读 8 个扇区因为一个扇区只有 512 字节而 ELF 头加程序头表有可能超过 512 字节。安全起见一次读 4096 字节保证头部信息完整。第二步从 ELF 头中取出程序头表的位置和数量循环把每个段都加载到对应的虚拟地址。第三步从 ELF 头中取出入口地址直接跳转过去。这里有一个细节为什么 ELFHDR 是 0x10000因为 bootloader 自己住在 0x7C00 附近内核要加载到 0x1000000x10000 这个位置在两者之间既不会被 bootloader 代码占住又不会和内核重叠。选择这样一个“不会被其他数据覆盖”的临时地址是嵌入式开发里非常常见的做法。4.2 LBA 模式下的磁盘 I/Obootmain 调用的readseg和readsect负责从磁盘读取数据。磁盘控制器使用 IDE 接口标准通过一组 I/O 端口来操作。JOS 的 readsect 函数void readsect(void *dst, uint32_t offset) { waitdisk(); outb(0x1F2, 1); outb(0x1F3, offset); outb(0x1F4, offset 8); outb(0x1F5, offset 16); outb(0x1F6, (offset 24) | 0xE0); outb(0x1F7, 0x20); waitdisk(); insl(0x1F0, dst, SECTSIZE / 4); }这段代码的逻辑是按 LBA 模式读取一个扇区。端口 0x1F2 是扇区数量这里写 1 表示读一个扇区。端口 0x1F3 到 0x1F6 是 28 位的 LBA 地址按低四位、次四位、再次四位、高四位分四次写入。0x1F6 的高四位还要或上一个 0xE0用来设置主从盘和 LBA 模式标志。端口 0x1F7 写 0x20表示执行“读扇区”命令。读完命令之后需要等待磁盘操作完成。这里用的是轮询waitdisk函数它不断读取状态端口 0x1F7检测第 6 位和第 7 位。只有当这两位组合表示“驱动器就绪且无错误”时才继续往下走。最后的insl是一次性读取 128 个 32 位字正好是 512 字节。为什么要用 insl 而不是普通的内存拷贝因为它能把 I/O 端口数据连续读入内存效率高而且省去了在 C 里逐字节操作的繁琐。insl的执行依赖方向标志位这就是 boot.S 前面cld的作用。4.3 程序段加载时的地址细节readseg是 readsect 的封装负责把一个内存范围内的数据读完整。它的实现有一个很值得学习的细节static inline void readseg(uint32_t pa, uint32_t count, uint32_t offset) { uint32_t end_pa pa count; pa ~(SECTSIZE - 1); offset (offset / SECTSIZE) 1; for (; pa end_pa; pa SECTSIZE, offset) readsect((uint8_t *) pa, offset); }pa ~(SECTSIZE - 1)是把起始地址向下对齐到扇区边界。为什么要这么做因为磁盘操作的最小单位是扇区如果目标地址不是 512 的整数倍你没法只加载半个扇区。向下对齐之后即使多读了一点数据也无所谓反正后续段加载会覆盖掉多余区域。offset (offset / SECTSIZE) 1里的加 1 是 LBA 和 CHS 之间的“历史遗留偏移”。磁盘的第一个扇区是引导扇区被 BIOS 用过了。bootloader 从磁盘上加载内核时内核镜像是从第二个扇区开始存放的所以在 LBA 寻址里要从 1 开始计数。这个细节如果不注意读出来的数据就会整体错位一个扇区导致 ELF 解析失败。4.4 跳进内核入口之前的最后一瞬当所有程序段都加载完毕((void (*)(void)) (ELFHDR-e_entry))()这行代码会把 ELF 头里的入口地址转成函数指针并直接调用。我在第一次看到这行代码时觉得“这也太粗暴了”但仔细想想它背后有一套完整的硬件和软件约定入口地址是编译链接时就确定好的内核代码已经放在了该放的位置栈也已经就位CPU 处于 32 位保护模式所有条件都满足了。这行调用本质上就是一次普通的函数跳转只是它永远不返回而已。如果bootmain意外返回汇编里还有一个死循环等待spin: jmp spin这个死循环就是兜底逻辑。正常流程下永远到不了这里但如果 ELF 头损坏、入口地址错误、磁盘读取失败CPU 就会卡死在这个空循环里。调试时看到这个死循环说明 bootmain 某个环节出了问题。5. 调试经验和踩坑实录5.1 用 QEMU 和 GDB 单步观察启动学习 bootloader 最有效的方法不是看源码而是单步调试。QEMU 内建了 GDB 调试支持启动时加上-s -S参数就能让它在刚开始时暂停等待 GDB 连接。连接之后用x/i 0x7c00可以查看 0x7C00 处的反汇编和 boot.S 源码逐行对照。我建议的调试路径是先盯着刚进入 0x7C00 时的状态用si单步执行汇编同时观察每一条指令的效果。尤其在执行ljmp前后注意观察 CS 的变化。info registers可以看到所有寄存器的值变化这在理解保护模式切换时特别直观。等到call bootmain之后你可以在readseg函数的地址上打断点查看 ELF 头的内容看它加载到 0x10000 后是不是正确的 ELF 魔数。再在跳转入口的地址上打断点看看跳转前内存里对应位置的数据是否已经就位。整个过程走下来bootloader 的工作模式就完全清晰了。5.2 四个容易中招的坑我在做这个实验时踩过不少坑挑几个有代表性的第一个坑是忘记关中断。如果你在cli之前触发了某个中断CPU 会去查中断向量表但实模式下的中断处理程序在 BIOS 里不是为 bootloader 准备的。结果是程序跳到一个完全不可控的位置表现为 QEMU 窗口卡死或反复重启。解决办法是严格按照 JOS 源码顺序来第一条指令就关中断。第二个坑是 A20 门没有真正打开就急着访问高内存。这个问题不会立刻暴露因为 bootloader 本身在 0x7C00 运行低地址区域还能正常工作。但内核加载到 0x100000 之后数据被回绕到地址 0覆盖了中断向量表和部分 bootloader 栈区。此时表现出来的现象非常奇怪有时候能跑起来有时候跑起来后数据全是乱的。我当初调试了一整晚才定位到 A20 门这里。第三个坑是 GDT 表项设置错误。如果代码段和数据段描述符的可读可写位设置不对切到保护模式后任何一次内存访问都会触发保护异常。QEMU 的日志模式下会输出异常信息你可以用-d int参数看到异常编号。我当时把代码段描述符的写权限位设置错了结果 CPU 在第一条指令就触发了异常排查了很久。第四个坑是偏移地址搞错。offset (offset / SECTSIZE) 1里的加 1 很容易漏掉。漏掉之后内核段的数据会整体错位 512 字节ELF 解析通常会失败bootmain 直接跳到死循环。这个坑的隐蔽之处在于如果 ELF 头恰好读取了一部分正确的数据错误不会在第一时间暴露而是等到执行内核代码时出现各种奇怪的行为。5.3 我建议的学习顺序如果你正在自学这门课我强烈建议不要一上来就看源码。先做一个小实验用 QEMU 启动通过 GDB 观察 0xF000:0xFFF0 处的复位向量看 BIOS 是怎么跳到 0x7C00 的。有了这个直观印象之后再回到源码会发现 boot.S 的前半段很好理解。接下来把 boot.S 和 main.c 分别当成两个独立的问题来读。汇编部分重点回答“CPU 怎么从 16 位实模式切到 32 位保护模式”C 部分重点回答“ELF 文件怎么被分解到内存里”。每部分都带着问题去读效率会高很多。最后一定要动手改代码。比如把读扇区的扇区数从 1 改成 2观察会不会多读数据把 A20 的写入字节改成 0xDE观察回绕现象把 GDT 的某个权限位翻转观察保护异常。这些都是我在实际实验里做过的事每一个“故意搞坏”的尝试都让我对底层机制的理解深了一层。最后再分享一个小技巧在看 GDT 和段选择子的关系时你可以先把数值换算成二进制用笔在纸上画出每一位的含义再对照 CPU 手册里对前缀的说明。这个过程虽然费纸但能一次性根治“段选择子为什么是这个数”的困惑。我的经验是花十分钟画完一个描述符的位图比背十遍概念都管用。后续做内存管理实验时你会发现这套位图的思维方式同样受用。