算起来这已经是我第二次完整刷 6.828现在叫 6.S081/6.1810的 JOS 实验了。第一次卡在 bootloader 上差不多小半个月主要是不明白这些汇编指令到底在做些什么、为什么要做以及最要命的——明明照着网上的博客抄了一遍跑起来却和预期不一样。后来把 CPU 的启动过程从头到尾梳理了一遍再回头看 boot/boot.S 和 boot/main.c几乎所有问题都豁然开朗。这篇心得只聊第一站从机器加电到 bootloader 把内核镜像读进内存、最终跳进内核入口的全过程。我会把 JOS 里涉及的实模式、A20 门、GDT、磁盘 I/O、ELF 加载这些知识点按启动时间线串起来讲而不是一个个名词孤立地解释。就算你之前从没写过汇编只要愿意跟着代码一行一行推也能完全吃透这一段。1. 为什么 JOS 的整个课程要从 bootloader 讲起1.1 一条完整的启动链路现代计算机从按下电源键到操作系统接管中间大概要经过这样几棒固件BIOS/UEFI→ 启动加载器bootloader→ 内核映像 → 内核初始化 → 用户进程。JOS 课程自己实现的 bootloader 长度只有几个文件但它是整条链路里夹缝求生的环节恰恰是最能暴露底层机制的地方。具体到 JOS 实验环境QEMU 模拟的是一台 PC。它加电后首先执行固件代码固件完成自检和硬件枚举后会根据启动顺序从磁盘读取第一个扇区512 字节也就是引导扇区检查这个扇区最后两个字节是否为0x55、0xAA确认无误后把它拷贝到物理内存地址0x7C00然后跳转过去执行。这条链路里bootloader 是第一个完全由我们自己的代码接管 CPU 的环节。1.2 bootloader 要干的四件事读 JOS 的 boot 目录前可以先给自己心里列个任务清单后面每一步代码都能对上号在实模式下初始化环境关中断、清方向标志、设置段寄存器和栈。打开 A20 地址线让 CPU 能完整访问 1MB 以上物理内存。加载全局描述符表GDT把 CR0 寄存器的保护模式位打开然后执行一次长跳转完成到 32 位保护模式的切换。通过磁盘 I/O 把内核 ELF 文件读取到内存解析 ELF 头按段加载最后跳到内核入口。反面教材是我第一遍刷实验时的状态直接去读 boot/main.c看到 readseg、readsect 一头雾水甚至不理解为什么加载 ELF 的地址还要做个减法。后来才意识到不理解 boot.S 直接看 main.c 是不行的因为 main.c 运行时的 CPU 状态、内存布局、寄存器规则全部由前面那几十行汇编决定。所以下面我会完全按照启动时间线来讲而不是按文件顺序来。另外说一句实验环境的事情JOS 的 makefile 默认会用 QEMU 模拟器运行。你不需要真机也不需要真实的软盘/硬盘QEMU 里一切都有。我用的环境是 Linux QEMU GDB这套组合调试 bootloader 非常方便后面专门有一节说调试手段。2. 物理世界里那个 512 字节的第一棒启动扇区是怎么跑到内存里的2.1 为什么固定加载到 0x7C00很多人第一次看到 bootloader 第一行汇编就蒙了因为它里面的变量地址完全从 0x7C00 开始。比如lgdt gdtdesc这条指令里的gdtdesc在反汇编出来的 boot.asm 里它的实际地址就是 0x7C00 附近偏移出来的某个值。这不是随便定的而是 IBM 时代的固件约定引导扇区被加载到物理地址0x7C00处。为什么是 0x7C00 而不是 0x0 或 0x1000历史原因是0x7C00 到 0x7DFF 这 512 字节区域既避开了中断向量表0x00000-0x003FF、BIOS 数据区0x00400-0x004FF也避开了显示内存映射区0xB8000 附近的显存以及内存顶部被 ROM/系统固件占用的区域。现代固件为了兼容老系统仍然沿用这个约定所以 JOS 的链接脚本也把 boot 程序链接到了 0x7C00。看到 boot.asm 里所有地址都聚集在 0x7C00 附近说明链接地址没问题如果看到地址跑到 0x100000 之类的位置那多半是链接脚本没生效跳转后会直接跑飞。在这 512 字节里固件检查的是最后两个字节是否为 0x55AA这个签名。所以写入引导扇区的镜像必须包含这个签名。JOS 编译时会在生成 boot 镜像后自动处理这部分不需要你手动补但理解这一点很重要——很多人在自己写 bootloader 时遇到0x55AA 不对导致无法启动的问题就是没注意字节序和签名位置。2.2 boot.S 开头的那些指令到底在干什么打开 boot/boot.S第一段代码大概是这样的start: .code16 # 告诉汇编器生成 16 位代码 cli # 关闭中断 cld # 清方向标志字符串操作从低地址往高地址走 # 初始化段寄存器 xorw %ax, %ax movw %ax, %ds movw %ax, %es movw %ax, %ss # 设置栈指针 movw $start, %esp这四件事每一件都有讲究。cli关中断是因为 bootloader 运行期间的栈和中断处理环境还没准备好如果 CPU 在这个节骨眼上响应一个外设中断可能跳到一个未初始化的中断向量上行为就没法预料了。cld清方向标志位DF保证后面字符串操作比如lodsb、stosb地址自动递增。你自己写代码时不注意在循环处理内存时很容易踩脏数据。段寄存器初始化成 0 是因为在实模式下物理地址是通过段基址 4 偏移算出来的。固件跳到 0x7C00 时CPU 的段寄存器其实是不确定的只有 CS 会在跳转时被固件设置成某种状态。我们自己重新把 DS、ES、SS 清零统一从物理地址 0x0 开始算偏移这样[0x7C00]这种引用方式就成立了也就是说 boot.S 里的地址直接就是物理地址不用做任何偏移修正。栈指针movw $start, %esp这里有个很精妙的地方把栈顶指向 bootloader 自己的起始地址 0x7C00。因为栈是向下生长的所以从 0x7C00 开始往下压栈不会覆盖掉 bootloader 代码和后面要读取的数据区域。实模式下访问 0x7C00 以下地址是合法的因为这段物理内存是常规内存读写没问题。注意这里用的是$start的绝对地址这是链接脚本保证的——start符号被链接到 0x7C00。我在第一次看这段代码时最大的误解是以为movw $start, %esp是把栈顶放到 0x7C00 之后的内存里其实它是把栈顶放在 0x7C00 这个位置向下生长。所以 bootloader 自己的代码段在 0x7C00 到 0x7DFF后面还有什么 I/O 缓冲区之类的话也不会和栈冲突栈从 0x7C00 往下延伸到低地址这对只有 512 字节的 bootloader 来说是够用的。3. A20 地址线一个老硬件的兼容性历史问题3.1 地址线回绕问题实模式下段地址和偏移都是 16 位组合出来的有效地址最多 20 位0xFFFF0 0xFFFF 0x10FFEF超过这个范围的部分按 20 位截断回绕——也就是说理论上访问 0x1000001MB 处会把地址线第 20 位拉到 0实际落脚到 0x0。这在 8086 时代没问题因为那会儿处理器只有 20 根地址线。问题出在 80286 之后的 CPU 地址线扩展到 24 根甚至更多。为了保护老程序在路上跑得好好的这个兼容需求厂商在引入新地址线的同时保留了第 21 根地址线A20可以被人为禁止的能力。如果 A20 被禁止访问 1MB 以上地址时第 20 位就会强制为 0产生回绕效果。操作系统尤其是保护模式下访问全部物理内存必须先把 A20 打开。为什么 JOS 的 bootloader 这么费劲地开 A20因为后面要把内核加载到物理地址 0x1000001MB以上也就是 JOS 内核链接时约定的物理加载点。如果不开 A20内核写入这些地址后实际内容会回绕到低地址几次循环下来内存内容就乱了而且这类问题极难看出现象——看起来代码没错、寄存器也正常但就是跑飞或有奇怪的覆盖。3.2 键盘控制器那套别扭的打开方式传统做法是操作键盘控制器8042来开启 A20JOS 代码里也保留了这个实现。虽然现在有很多更简单的办法如 BIOS 中断但 QEMU 也仿真了 8042所以这套代码在实验环境里完全可运行。具体流程是这样的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端口 0x64 是 8042 的状态寄存器它的 bit1 表示输入缓冲区是否已满。写入命令前必须等待输入缓冲区为空bit1 为 0否则命令可能丢失。第一条命令0xD1表示把下一个写入 0x60 的数据写到输出端口输出端口的具体位控制 A20 是第 1 位所以第二条数据0xDF的 bit1 为 1就把 A20 拉高。这套流程里最常见的错误是只写了movb $0xdf, %al到 0x60却忘记先写 0xD1 到 0x64或者两边都没等待缓冲区空。QEMU 对这种错误很宽容经常在环境里依旧凑巧能用换个严格仿真的环境或真机就会翻车。记住凡是外设 I/O时序比数值更重要。顺带说一句为什么不开 A20 也能跑一小会儿因为实模式本来只用 20 位地址前 64KB 不涉及回绕区域所以前几条指令不受影响。等到 bootloader 开始读磁盘、往 0x100000 写数据时问题才暴露。所以别指望跳过了也没事。4. 从实模式到保护模式GDT 和 ljmp4.1 进入保护模式前要准备一张表实模式访问内存是靠段偏移。保护模式的寻址则复杂得多段寄存器不再直接参与地址计算而是存放一个选择子selector相当于一个索引CPU 拿着它去查询全局描述符表GDT中对应的段描述符得到段的基地址、大小限制和访问权限。boot.S 里定义了一个最简单的 GDTgdt: SEG_NULL # 空描述符 SEG(STA_X | STA_R, 0x0, 0xffffffff) # 可执行段代码段 SEG(STA_W, 0x0, 0xffffffff) # 可读写段数据段这里的宏展开后会生成 64 位的段描述符。JOS 里把代码段基地址设为 0段界限设为 4GB0xFFFFFFFF 配合粒度位 G1实际是 4KB 粒度乘以 0xFFFFF 个页 4GB数据段也类似。因为 bootloader 阶段还不需要很精细的内存保护所以直接全部展平属于够用就行。关于 GDT 要注意两点第一个描述符必须全为 0空描述符。这是 CPU 的硬性规定避免未初始化选择子被意外使用。每个描述符的 64 位布局是有讲究的包括段界限、基地址、类型标志位、粒度位、默认操作数大小位等。直接看宏SEG()展开后的位域更容易理解但初学不一定要背下来能读懂 JOS 注释即可。4.2 为什么切个模式非要先来一次ljmp开启保护模式本身其实只有三条指令movl %cr0, %eax orl $CR0_PE, %eax movl %eax, %cr0把 CR0 寄存器的 bit0PEProtected Enable置 1CPU 就进入保护模式了。但问题在于CR0 切换的一瞬间CPU 已经在用新的寻址规则取指执行了而当前 CS 里的旧值实模式的段值在保护模式下含义已经变了直接用它去取指令轻则取到错误代码重则直接触发异常。解决办法是立刻执行一条长跳转ljmp把 CS 重新加载为 GDT 中代码段的选择子。在 boot.S 里是这样ljmp $PROT_MODE_CSEG, $protcseg跳转的同时CPU 会加载新的代码段描述符。因为代码段基地址为 0所以protcseg这个标号的实际线性地址就等于它在链接时的地址bootloader 在 0x7C00 附近不会跳错位置。4.3 切到保护模式后为什么又设置了一遍段寄存器和栈进入 32 位代码段后boot.S 把所有数据段寄存器都重新赋值成PROT_MODE_DSEG这是很自然的——因为旧的段寄存器内容在保护模式下已经没意义了不重新赋段选择子后面数据段的访问都会出问题。接下来的一行是movl $start, %esp。注意这里已经是 32 位操作数汇编器认识这条指令是movl把 esp 指向 0x7C00 附近。这时候的栈和实模式下的栈共用一个区域向下生长完全够用。很多人在这一步出错是把栈指针写成了movw $0x7c00, %sp或者忘记清空旧栈的内容认为进保护模式后栈不能用了其实栈只是算术规则变了物理内存还是一块普通 RAM关键是确保 esp 指向不被代码覆盖的合法区域。到这里bootloader 的环境准备就完成了。接下来调用 C 函数bootmain()就完全是 32 位保护模式在跑普通 C 代码。链接脚本会保证 16 位代码到 32 位入口之间的转换不会出问题因为 JOS 的 boot 目录专门做了.code16和.code32的区段划分。这一部分的意义往往被低估boot.S 只花了几十条指令却把 CPU 从一个16 位实模式、只能寻址 1MB、没保护机制的状态搬到了一个32 位保护模式、4GB 平坦寻址的状态。后面所有 C 代码都建筑在这个基础上。5. boot main真正把内核读进内存的 C 代码5.1 磁盘控制器与 LBA 读取进入 bootmain 之后第一个正事就是读磁盘。JOS 的 bootloader 使用传统 IDE 控制器也叫 ATA 接口的 PIO 模式操作硬盘。QEMU 默认把内核镜像obj/kern/kernel作为启动盘内容bootloader 通过 I/O 端口访问它。readsect 函数是核心static void readsect(void *dst, uint32_t offset) { waitdisk(); outb(0x1F2, 1); // 扇区数量 1 outb(0x1F3, offset); // LBA 低 8 位 outb(0x1F4, offset 8); // LBA 中 8 位 outb(0x1F5, offset 16); // LBA 高 8 位 outb(0x1F6, (offset 24) | 0xE0); // LBA 最高 4 位 主盘标志 outb(0x1F7, 0x20); // 发送读扇区命令 waitdisk(); insl(0x1F0, dst, SECTSIZE / 4); }端口 0x1F0 是数据端口0x1F0-0x1F7 一组端口对应主 IDE 通道。insl每次读 4 字节读满 512 字节算一个扇区。waitdisk等待状态寄存器的 bit6DRQ置位同时确认 bit7BSY为 0也就是数据已就绪、控制器不忙。这里有个鲜为人知的细节JOS 中 LBA 是从 0 开始还是从 1 开始看代码readseg里计算扇区号时写了(offset / SECTSIZE) 1加了 1。原因是磁盘的第一个扇区已经给了 bootloader引导扇区内核被链接进镜像时是从第二个扇区放置的所以在readsect里传入 offset 的语义是扇区在磁盘上的物理编号而 readseg 传进来的是内核文件内部的字节偏移。不开窍的时候我对着这两个 offset 绕了半天后来画了一条磁盘扇区编号 文件内部偏移对应扇区 1的线才彻底明白。5.2 ELF 头解析和程序段加载磁盘上放的是 ELF 格式的内核镜像。bootmain 前几步ELFHDR (struct Elf *) 0x10000; readseg((uint32_t) ELFHDR, SECTSIZE * 8, 0); if (ELFHDR-e_magic ! ELF_MAGIC) goto bad;先读 4KB8 个扇区到 0x10000检查 ELF magic。标准 ELF 文件的 magic 是0x7F E L FJOS 用整数比较所以代码里定义了ELF_MAGIC 0x464C457F这是小端序读入 int 后的值。注意是0x464C457F不是0x7F454C46我见过有人写成后者比较一直失败。然后遍历程序段表program header tableph (struct Proghdr *) ((uint8_t *) ELFHDR ELFHDR-e_phoff); eph ph ELFHDR-e_phnum; for (; ph eph; ph) { if (ph-p_type ELF_PROG_LOAD) { readseg(ph-p_pa, ph-p_memsz, ph-p_offset); } }关键点是ph-p_pa。ELF 头里既有虚拟地址p_va也有程序在内存中实际的物理地址p_pa。JOS 的内核在编译时通过链接脚本把虚拟地址定在高地址0xF0100000 起但此时 CPU 还没开启分页bootloader 必须把镜像加载到物理地址 0x100000。p_pa就是链接脚本算好的物理地址。所以你看 JOS 的 ELF 头p_va是 0xF0100000 左右p_pa是 0x100000 左右两者相差一个KERNBASE0xF0000000。bootmain 用的p_pa而不是p_va这是初学者最容易忽略的一环。5.3 最后的跳转加载完所有需要加载的段之后bootmain 会执行((void (*)(void)) (ELFHDR-e_entry))();e_entry是 ELF 头里保存的内核入口地址对于 JOS 内核来说就是_start符号对应的虚拟地址实际物理地址也在 0x100000 附近。这一步就是从前面的代码直接跳进内核的入口后面就归内核阶段的代码接管了。但注意bootloader 并不会关闭分页、也不会设置好内核真正需要的栈和页表这些是内核自己的 entry 汇编去做的。bootloader 的任务到这里就完成了。很多第一次做 lab 的同学以为 bootmain 最后跳进内核马上就会看到Welcome to JOS之类的输出其实还会经过内核自己的初始化流程中间可能还会再次设置 GDT、加载页表、开启分页等。这一块如果在调试时看到奇怪的行为不要着急先确认 bootloader 阶段是否正常结束。6. 我这轮做下来总结的坑与调试方法6.1 QEMU GDB 的断点打法调试 bootloader 最顺手的组合是 QEMU 远程 GDB 调试。在 makefile 里直接make qemu-gdb或者在另一个终端手动启动qemu-system-i386 -drive fileobj/kern/kernel,index0,mediadisk,formatraw -S -s参数-S让 QEMU 启动后暂停在第一条指令-s是在 1234 端口开 GDB stub。然后开 GDBgdb obj/kern/kernel target remote :1234 b *0x7c00 c断点在 0x7C00 是最常用的手法这正是固件跳转到 bootloader 的位置。如果你想知道 BIOS 阶段的指令可以在 0x7C00 之前在*0x7c00之前的任何地址观察但通常没什么必要。注意obj/kern/kernel是 ELF 文件包含符号表GDB 连上以后可以info registers看寄存器x/10i $eip看当前位置的汇编si单步执行。boot.S 的符号也能直接打断点比如b bootmain。6.2 反汇编文件怎么对照JOS 在编译后会生成obj/boot/boot.asm和obj/kern/kernel.asm这俩是带源码行号的反汇编文件。调试时对照看非常直观你断在 0x7C00boot.asm里 0x7C00 地址对应的源码是cli还是xorw一眼就能对上。这个文件我在第一次刷 lab 时几乎不离手。另一个有用的文件是obj/kern/kernel.sym里面是符号表列出了所有全局符号的虚拟地址。你可以grep _start obj/kern/kernel.sym来看内核入口具体地址用来判断 bootmain 最终的跳转目标是否正确。如果发现反汇编文件里的地址和实际断点对不上最常见的原因就是你还没重新编译——改了 boot.S 或 main.c 之后忘记makeQEMU 加载的还是旧镜像。6.3 最容易翻车的几个点第一A20 没成功开启的现象。表现形式很奇葩bootloader 加载完内核跳进内核入口后代码似乎跑起来了但某些全局变量值不对或者函数访问内存时分成两段高地址回绕到低地址。这是 A20 回绕造成的也是最难复现的 bug 之一。我自己的习惯是每轮都对照 boot.asm 检查 A20 代码有没有被优化掉或者在调试时打开 QEMU monitor 查看 A20 状态。第二链接地址和加载地址的混淆。bootloader 本身链接在 0x7C00内核链接在 0xF0100000。你在中间某个阶段访问一个全局变量如果还开着实模式自然访问不到高地址。此时要么确认是否已开保护模式要么确认访问方式是否用p_pa。这是 bootloader 里最容易让人精神分裂的地方。第三LBA 扇区编号 1 的问题。一开始我用十六进制数 LBA 地址读扇区发现读出来的内容完全不对。后来回来看 readseg 才知道它的第一个参数是目标内存物理地址第二个是读多少字节第三个是处在这个 ELF 文件里的偏移而扇区编号是算出来的还加了 1。顺序和语义如果搞错连报错都没得报就直接读到错误数据了。第四不要在主循环里修改正在执行的代码地址。有一次我试图把bootmain往 0x9000 搬运再跳过去搬完发现入口地址算错了CPU 直接在错误地址取指令。调试时看到eip变成 0x0 或者一堆int3第一反应应该去看是哪个跳转指令把它带过去的。在我看来把 bootloader 这一关踏踏实实过掉后面几轮 lab 都会顺手非常多。因为 page table、中断、用户环境那些实验本质上都是在不同层面处理地址怎么写才合法这个问题而 bootloader 第一次把虚拟地址、物理地址、链接地址、加载地址这四个概念放在了你面前。它们之间的关系理不清后面处处都是坑理清了你就掌握了一把打开操作系统的钥匙。最后分享一个实操心得把你的调试思路固化下来。每次跑起来之后第一件事是确认当前在哪个阶段——用 GDB 看 eip 是在 0x7C00 附近bootloader、0x100000 附近内核入口、还是 0xF0100000 附近内核虚拟地址。看到这个位置分布你就能迅速判断是加载错了、跳转错了、还是自身代码逻辑问题。这个问题定位能力是刷完 bootloader 之后最值钱的收获。
阅读完成 · 觉得有帮助?