news 2026/10/16 1:06:07

JOS bootloader详解:从实模式到保护模式的内核加载全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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。注意选择子的低位还包含请求特权级 RPL,JOS 这里用的是内核态特权级 0,所以最低两位都是 0。

3.2 改 CR0 寄存器,一条必须“先读后写”的指令

GDT 准备好之后,接下来就是切换保护模式。这一步的核心操作是修改控制寄存器 CR0 的第 0 位,也就是 PE 位。把它置 1,CPU 就从实模式切到保护模式。

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 附近,内核要加载到 0x100000,0x10000 这个位置在两者之间,既不会被 bootloader 代码占住,又不会和内核重叠。选择这样一个“不会被其他数据覆盖”的临时地址,是嵌入式开发里非常常见的做法。

4.2 LBA 模式下的磁盘 I/O

bootmain 调用的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 手册里对前缀的说明。这个过程虽然费纸,但能一次性根治“段选择子为什么是这个数”的困惑。我的经验是,花十分钟画完一个描述符的位图,比背十遍概念都管用。后续做内存管理实验时,你会发现这套位图的思维方式同样受用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:05:58

RAPID:从人类示范生成可执行机器人程序的新范式

1. 项目概述&#xff1a;当机器人开始“看懂”人类动作并自动生成可执行程序RAPID——全称 Robot Agentic Programming from Demonstrations&#xff0c;不是某个新出的OCR工具&#xff0c;也不是云端文档扫描服务&#xff0c;更和“rapid ocr onnx是云端还是本地”这种纯视觉识…

作者头像 李华
网站建设 2026/10/16 1:05:28

51单片机按键与数码管协同实战:消抖、动态扫描与时间片轮询

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:05:25

易语言封装CreateThread线程API模块:句柄管理与参数传递实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:05:01

Kibana 7.8.0 Dashboard实战:从环境准备到面板共享与排错

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:04:23

2026年LED数码管定制:解读行业三大核心趋势

引言&#xff1a;LED数码管定制行业正迎来关键转折点近年来&#xff0c;LED光电元器件市场规模持续扩张&#xff0c;据行业公开数据&#xff0c;2024年国内LED元器件市场规模已超千亿元&#xff0c;下游应用从传统领域逐步拓展至汽车电子、医疗电子、新能源等多个高端领域&…

作者头像 李华
网站建设 2026/10/16 1:03:56

Odoo二开必修:模块继承之模型与视图继承完整实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华