资讯动态

x86启动流程详解:bootloader如何从实模式切换保护模式并加载内核

发布时间:2026/10/16 4:15:58 来源:尧图企业网站定制
1. 写在前面为什么要把bootloader啃明白如果你开始自学操作系统多半会在某个阶段撞上经典课程 6.828 以及它的配套实验 JOS。很多人的第一站就是 Lab1而 Lab1 里最劝退的部分之一就是那个完全看不懂的 bootloader。我也一样第一次看 boot.S 的时候满脑子问号这堆cli、cld、lgdt是干嘛的为什么地址全是0x7c00、0x10000C 语言明明写了readseg怎么还得从汇编开始跑这和我平时写业务代码完全是两个世界。但说实话操作系统课这么多内容里恰恰是 bootloader 这一段最值得花时间。它是整个机器从硬件上电到操作系统接管之间的那段“无人区”没有任何现成软件可以依赖一切都是手工初始化。把这个过程啃明白你会真正理解 CPU 的分段模型、实模式和保护模式切换、ELF 文件的加载方式、设备 I/O 端口操作这些知识在后面 Lab 里全都会反复用到。这篇笔记不会把 6.828 的答案直接贴出来而是按照我自己当时的学习路线把 bootloader 的启动全流程拆成几个阶段讲清楚从 BIOS 把引导扇区加载到0x7c00到 boot.S 完成实模式到保护模式的切换再到 main.c 把内核 ELF 从磁盘读到内存最后跳进内核入口。我会尽量把所有“为什么这么做”也讲出来而不只是罗列代码。适合正在做 Lab1 的同学参考也适合对 x86 启动过程感兴趣、想跟着走一遍的人。2. 上电之后BIOS、实模式与那段历史遗留的 0x7c002.1 一开机CPU 在干什么很多教材会直接说“bootloader 被加载到 0x7c00”但很少有人解释为什么是这里。其实这背后是历史原因不是谁拍脑袋定的。过去的 BIOS 设计规范约定BIOS 在自检和初始化之后会把启动盘的第一个扇区512 字节读入内存的 0x7c00 处然后跳转执行。0x7c00 这个地址被 IBM PC 兼容机沿用了好几十年今天我们在 QEMU 里做实验同样是这个地址。刚上电时 CPU 运行在实模式。实模式最大的特点就是地址空间只有 1MB寻址方式是“段基址左移 4 位 偏移”也就是物理地址 段值 * 16 偏移。这导致所有地址都和段寄存器绑定操作数写起来非常别扭。但早期 x86 就是这样工作的而且这是我们必须先面对的现实。BIOS 本身也是代码。上电后 CPU 从复位向量执行然后 BIOS 完成硬件自检、建立中断向量表、初始化基本设备最后按照启动顺序寻找可引导设备。如果找到以0x55AA结尾的引导扇区它就把这个扇区复制到0x7c00并用一条跳转指令进入我们的 bootloader。注意这整个过程里我们没有任何操作系统也没有文件系统抽象BIOS 只是按扇区读数据。2.2 为什么要自己写这 512 字节你有没有想过一个问题一个简单的 “hello world” 就算写在引导扇区里然后让 BIOS 去跑它能干的事情也非常有限。因为 BIOS 在实模式里能访问的只有 1MB 内存而且访问磁盘全靠软中断每次读一个扇区速度感人。更麻烦的是我们最终要把 JOS 内核加载到内存的0x1000001MB 以上位置这已经超出实模式 1MB 的寻址上限了。所以 bootloader 的任务并不是“把内核整个读进来”那么简单它还必须先把 CPU 从一个只能寻址 1MB、用段寄存器拼地址的模式切换到能够访问大内存、逻辑更清晰的保护模式。这个切换不能在 C 里随便做必须直接用汇编操作控制寄存器所以 boot.S 的前半段才是那些看起来神神叨叨的指令。我把这个阶段总结成了一张自己的流程图BIOS 自检 - 读引导扇区到 0x7c00 - 跳转执行 boot.S - 关中断、设段寄存器 - 开 A20 - 加载 GDT - 进入保护模式 - 跳转到 C 代码 bootmain。后面几个步骤我会逐个拆开讲。3. boot.S 逐行实战从实模式一脚迈进保护模式3.1 刚开始的几行为什么要这样写JOS 的 boot.S 文件开头是这样的.globl start start: .code16 # 告诉汇编器下面按 16 位代码生成 cli # 关掉中断 cld # 方向标志清零后面字符串操作方向是递增 xorw %ax, %ax movw %ax, %ds movw %ax, %es movw %ax, %sscli关中断很好理解——bootloader 启动过程中不希望在跳转、改寄存器、打开 A20 这些中间态被任何硬件中断打扰。cld是给后面的rep指令准备的确保字符串复制方向是从低地址到高地址。接下来把ds、es、ss都清零。为什么因为我们刚从 BIOS 跳过来这些段寄存器里可能还是 BIOS 留下的值在实模式下段寄存器影响所有内存访问。假设ds残留了一个随机值后面我们访问绝对地址数据时就会错位。传统上还要设sp栈指针JOS 里这段协商由一个足够长的栈空间来保证。这里要特别提醒在实模式下不要乱设sp因为可能连 1MB 都访问不了栈出问题的话调试会非常痛苦。3.2 打开 A20一次被“物理地址回绕”逼出来的折腾接下来你会看到 boot.S 里有一段读写端口 0x92 的代码目的就是开启 A20 地址线。为什么要专门管一根地址线这又是个历史包袱。最早期的 8086 CPU 只有 20 根地址线寻址上限是 1MB。假如你计算出超过 1MB 的地址CPU 会“回绕”也就是把多出来的部分丢弃重新映射到低地址。当时有些程序故意利用回绕把自己放到高位地址当跳板。后来 386 有了 32 位地址线如果还保留回绕现代程序访问0x100000以上地址时就可能出错但保持对历史软件的兼容默认状态下 A20 是关闭的。所以我们必须主动打开它。JOS 用的是 I/O 端口 0x92 的位 1inb $0x92, %al orb $0x02, %al outb %al, $0x92这里先读端口 0x92 当前值把第二位置 1再写回去。我第一次看这段时很疑惑为什么改个内存地址位还能扯到硬件端口其实 A20 由主板上的键盘控制器或芯片组控制在早期 PC 上直接通过键盘控制器端口操作后来统一抽象成 0x92 端口。虽然标准实现里也可以用 BIOS 中断但 bootloader 此刻已经是实模式“裸奔”状态直接操作端口最可靠。还有个细节如果 A20 没开成功CPU 访问高地址时被回绕内核加载到0x100000之后数据可能会被写到低地址甚至覆盖掉 bootloader 自己。这是启动阶段最难排查的隐形故障之一所以代码里一般都无条件尝试开启 A20而不是“尽量开”。3.3 GDT 与保护模式的切换那几行你看不懂的伪代码从实模式切换到保护模式核心动作是修改控制寄存器cr0的保护模式位但光改位不够。保护模式下内存访问模型变成“分段 分页”而段描述符不再像实模式那样靠段值左移而是指向一个全局描述符表GDT。所以我们必须先在内存里建好一张 GDT再把 GDT 的地址和界限加载到gdtr寄存器最后才打开cr0的保护位。JOS 的 boot.S 里建立了一个非常简单的 GDT只有三个描述符空描述符、代码段描述符、数据段描述符。代码段基址是 0数据段基址也是 0界限覆盖整个 4GB 空间。这就是所谓的“平坦模型”。JOS 故意把地址空间铺平成一个大块内核里再靠分页和虚拟地址管理来做隔离。lgdt gdtdesc movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0lgdt之前需要先把 gdtdesc 里的界限和地址准备好它指向一个.word和.long组成的数据结构。这里埋了一个大坑lgdt执行之后CPU 还没有立即进入保护模式必须等到下一个把cr0的 PE 位置 1 的指令执行完成后才是真正的保护模式。而真正切割点是紧随其后的一条远跳转ljmp。为什么必须来一次ljmp因为进入保护模式后CPU 对段寄存器的解释方式变了。当前cs里存的还是实模式段值如果不重新加载下一条指令的取指就会出现不可预期的地址。远跳转会同时修改cs和eip让 CPU 使用 GDT 中新的描述符。这一步是“切换模式”的仪式性动作少一个都不行。我当年第一次调试时没认真看ljmp那条指令的操作数在后来的 C 代码入口处设置了断点结果怎么都断不上最后才发现是jmp目标地址写错导致直接飞了。遇到这种问题用 GDB 单步走一遍就能看到每条指令执行到哪、寄存器值变没变。3.4 在 32 位世界里设置栈与数据段进入保护模式后boot.S 后半段就是典型的 32 位初始化.code32 movl $start, %esp movw $0x10, %ax movw %ax, %ds movw %ax, %es movw %ax, %ssstart符号在链接脚本里对应加载地址直接用start作为栈顶是安全的因为此时我们还在低地址区栈向下增长也不会撞上系统区。这一段把数据段选择子设为 GDT 里的第二个描述符0x10栈段也同步换成保护模式描述符。注意cs已经在ljmp时换过了这里不需要再动。到这里 boot.S 的任务就完成了。它把 CPU 从“16 位 1MB 空间 段偏移寻址”的状态切换到“32 位 4GB 平坦空间 GDT 描述符寻址”的状态然后调用bootmain进入 C 代码。这一步切换是整个操作系统启动阶段最重要的分水岭后面所有的内存管理、进程隔离全靠这一下打底。4. bootmain 的内心戏ELF 内核是怎么被“请”进内存的4.1 为什么 C 代码还要操作磁盘进入 C 代码后bootloader 的目的非常明确把内核二进制从磁盘读进内存然后跳转执行。但这里有个关键约束此刻我们还没有文件系统、没有驱动、没有任何抽象唯一能用的就是 BIOS 提供的磁盘读扇区功能或者自己直接写 I/O 端口去操作磁盘控制器。JOS 的 main.c 选择的是直接通过 I/O 端口读写磁盘这在教学上特别有好处。你能清楚看到“读磁盘”这件事在底层就是和几个端口打交道。readsect函数负责读单个扇区readseg负责读取一段数据到指定内存地址。static 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, 4); }这里 0x1F0 到 0x1F7 是传统的 ATA 控制器端口。0x1F2 是扇区数0x1F3 到 0x1F6 是 28 位 LBA 地址0x1F6 高 4 位还要带上主盘标志 0xE00x1F7 写入 0x20 表示发起读扇区命令。waitdisk则是不断读取状态端口等方法位就绪。第一次看到这段时我最疑惑的是为什么要insl(0x1F0, dst, 4)一次读 4 个字节而不是一个字节一个字节地读答案是 IDE 控制器支持 32 位数据传输一次读 4 字节更高效。而且这里要求dst必须对齐到 4 字节边界否则会触发异常。我在实验里因为把目标地址算错了 1 字节结果读到一半卡死最后检查才发现是readseg里起始地址没做对齐处理。4.2 ELF 文件的三个关键信息磁盘上的内核文件是 ELF 格式。ELF 头位于文件最开始里面有我们关心的三个东西魔数、程序头表偏移、入口点地址。bootloader 不是通用的 ELF 加载器它不需要理解动态链接、符号表这些复杂结构只需要把可加载段PT_LOAD搬到对应内存地址。第一步从磁盘第 1 个扇区读入 ELF 头检查魔数是否匹配0x7f E L F。如果不对说明引导扇区之后跟的不是一个合法的内核文件。第二步从elf-e_phoff位置读取程序头表程序头表是一组struct Proghdr其中每个段描述了该段在文件中的偏移、加载到内存的虚拟地址、文件大小和内存大小。struct Proghdr { uint32_t p_type; uint32_t p_offset; uint32_t p_va; uint32_t p_pa; uint32_t p_filesz; uint32_t p_memsz; uint32_t p_flags; uint32_t p_align; };注意这里有p_va和p_pa两个地址。对 JOS 来说内核被链接在虚拟地址0xf0100000附近但物理内存加载地址是0x100000。bootloader 加载时看的是p_pa也就是物理地址。这一点如果没想清楚后面 Lab 里开启分页时会被“为什么内核里到处都是高地址、却又驻留在低物理内存”搞得一头雾水。第三步对每个程序头如果类型是PT_LOAD就从文件偏移处读p_filesz字节到目标地址再把p_memsz - p_filesz的区域清零。这个清零动作是为了.bss段——未初始化的全局变量不占磁盘空间加载时必须把它们对应的内存区域归零。4.3 页对齐与内核入口的最后一步JOS 的readseg里有一个看起来有点啰嗦的计算offset (offset / SECTSIZE) 1;为什么要加 1因为磁盘的第 0 个扇区是引导扇区已经被 BIOS 用来放 bootloader 了内核文件是写在后续扇区里的。所以读内核文件时逻辑上的第 0 个扇区对应物理上的第 1 个扇区。还要处理分段偏移和页对齐。ELF 段的起始地址不一定正好是扇区边界所以加载时要先按扇区大小向下取整把便宜段读进来再把多余部分去掉或者覆盖。JOS 的readseg实现里end_va向上取整到页边界这样保证一次覆盖整个段以及可能的.bss扩展区。全部加载完成后bootloader 调用 ELF 头里的e_entry((void (*)(void)) (elf-e_entry))();对 JOS 来说这个入口就是内核里的_start它会初始化栈、设置页表然后进入i386_init。到这一步bootloader 的使命正式完成。你已经从实模式一路走到了 32 位保护模式从磁盘读出了 ELF并且即将进入一个有分页、有调度、有进程概念的操作系统内核。有一个地方我踩过坑e_entry是虚拟地址还是物理地址在 JOS Lab1 里入口是虚拟地址0xf010000c之类的如果你直接跳过去CPU 还在实模式尾部、保护模式初期没有开启分页线性地址就等于物理地址此时如果跳到一个0xf0100000以上的地址内存里根本找不到代码。JOS 的做法巧妙在于CPU 在进入内核入口时的分页还没建立但入口处的前几条指令会立刻设置好页表并重新映射地址。所以 bootloader 这里直接用 ELF 头的入口地址而内核入口那段代码被要求保证“起始处不与虚拟地址直接挂钩”。这也是为什么 JOS 给内核加了-Ttext参数并在入口处做了一套“先物理后虚拟”的处理。理解了这一点你后面看entry.S时会顺畅很多。5. 调试实录用 QEMU GDB 把 bootloader 看得清清楚楚5.1 建立远程调试链路6.828 的实验环境自带 QEMU 的调试接口bootloader 阶段最推荐的调试方式就是让 QEMU 等待 GDB 连接。具体做法是先在.gdbinit里加上set architecture i8086 target remote :1234然后启动 QEMU 时带-s参数。这样 QEMU 会监听 1234 端口GDB 连上之后就能单步指令。很多初学者第一次连上后看到的是 BIOS 的初始状态CS:IP 0xf000:0xfff0也就是实模式复位向量。这时候你会意识到原来我们离 bootloader 还有十万八千里。想直接到0x7c00两条路要么break *0x7c00注意这里要用物理地址对应要么在 boot.S 的start符号上打断点。我强烈建议用符号打断点而不是硬编地址。因为链接脚本会把 bootloader 放在0x7c00但如果你改过链接脚本里的地址硬地址就失效了。使用b start更稳。连上后还要记得加layout asm和layout regs这样能看到每一行的汇编和寄存器变化实模式下的地址计算一目了然。5.2 查看内存和寄存器的小技巧保护模式切换那一刻用info registers看cr0的 PE 位变化再单步看ljmp前后cs的值这个画面比看多少教材都直观。切换到bootmain之后单步进 C 函数时注意用nexti和stepi的区别。nexti遇到call会跳过整个调用stepi会进入所以跟踪磁盘读取流程时先用nexti走到readsect的调用处再用stepi进函数。想看磁盘读进来的 ELF 头可以在readseg执行完insl后用x/16wx 0x10000看那段内存ELF 头的魔数0x464c457f也就是 .ELF 的倒序字节会出现在第一个双字里。如果看不到检查磁盘偏移算没算对。5.3 我遇到过的三个典型问题第一个是 A20 开启失败。表现是 bootloader 能跑但内核加载到0x100000后数据莫名错乱尤其是地址越靠近 1MB 越明显。排查方式是在 readsect 之后马上用 GDB 检查目标内存内容如果发现写错了位置重点回头查 A20 端口操作有没有被编译器优化掉。这里顺便提醒boot.S 里的端口操作最好保持不变不要自作聪明“优化”。第二个是readseg里少读了扇区。内核文件往往超过一个扇区而 ELF 段的文件大小可能不是扇区整数倍。我第一次实现时只按p_filesz的扇区数读取结果段末尾缺了一截跳转进内核后立刻 page fault。后来看标准实现才反应过来它读的是end_va向上取整后的页数不是简单按文件大小取整。第三个是栈溢出。我一开始把esp设到一个比较小的地址结果bootmain里调用readseg时栈向下增长直接覆盖了 bootloader 的代码区。这种情况最恶心因为它不是立即崩而是跑到某个奇怪的地址上GDB 里看 EIP 完全失控。解决办法是重新检查栈顶地址与代码、数据段的排布确保栈空间预留足够。6. 几个容易忽略的细节链接脚本、.code16 与编译选项6.1 链接脚本在 bootloader 里扮演的角色JOS 的 boot 目录里有一个boot.ld链接脚本它把 bootloader 的加载地址固定为0x7c00并且把.text、.rodata、.data、.bss都按顺序放在这个基址之后。链接脚本是 bootloader 能工作的地基因为引导扇区只有 512 字节放完代码和数据后最后一个字节必须刚好是0x55AA引导标志。很多实验里这个0x55AA不是代码写的而是链接脚本通过. . 1或者定义符号来保证填充。如果链接布局乱了比如数据段跑到了代码段前面整个 bootloader 可能连第一条指令都执行不对。所以我建议第一次做实验时先objdump -h obj/boot/boot.out看各段偏移再对照链接脚本确认布局。6.2 .code16 与汇编器的暗坑boot.S 开头写.code16但汇编器处理这段代码时用的还是 32 位指令编码加 16 位地址前缀不对.code16会直接告诉汇编器生成 16 位指令。问题是你现在用的是 32 位工具链如果代码里出现立即数过大、32 位寄存器操作汇编器可能生成超出 16 位指令编码范围的东西而 CPU 在实模式下只按 16 位解释导致机器码和解码不一致。我遇到过最经典的问题是有人在实模式代码里写了movl %eax, %cr0汇编器生成了带 66h 前缀的机器码在 16 位模式下 CPU 识别含义完全不同。所以 6.828 里 boot.S 的前半段只能老老实实写 16 位能表达的操作寄存器尽量用 16 位形式。如果你对编码不熟最简单的做法就是严格照着官方 boot.S 的写法来改别自作聪明加复杂指令。6.3 编译、链接时那些 -m32 -fno-builtin 选项JOS 的 Makefile 在编译 boot 目录时用了-m32 -fno-builtin -fno-stack-protector。-m32保证生成 32 位代码-fno-builtin防止编译器把普通函数调用识别成内建函数优化掉-fno-stack-protector则避免插入栈保护代码。栈保护机制会在函数入口处写入一个 canary 值这个值需要引用全局变量或特殊指令在 bootloader 里可能访问不存在的段导致异常。还有一个选项是-Os优化尺寸。为什么因为引导扇区就 512 字节代码稍微膨胀一点就放不下了。有些同学说“我编译过了但启动时黑屏”很可能就是.text段超出了引导扇区覆盖了0x55AA标志位。用objdump看下 boot.out 的段长度如果加起来超过 510 字节就得想办法精简代码。7. 反复踩过的几个大坑一次完整的启动排障这一节我想分享一次我印象特别深的排障经历。当时我在改一个自定义 bootloader想要打印一行字符再进内核。用的方式是把字符写到显存地址0xB8000也就是文本模式下的显示缓冲区。逻辑很简单但在我的机器上就是不出字。后来用 GDB 单步发现我写到0xB8000的数据确实在内存里但屏幕没反应。折腾了很久才发现我进入保护模式后没有重新设置显存段的选择子属性GDT 里的数据段描述符虽然覆盖了 4GB 空间但对应缓存或属性位设置不对导致写入被无效化。其实本质是我对 GDT 描述符的字节布局理解不深只看懂了“基址为 0界限为 0xfffff”没注意颗粒度位和 P 位对段有效性至关重要。还有一次是内核入口反复崩溃。我用objdump -d kernel找到入口地址设置 GDB 断点但每次continue都直接跑到异常处理里。最后发现是我把程序头的p_pa和p_va搞混了加载时用了虚拟地址0xf0100000导致实际物理内存0x100000附近根本没有代码CPU 从0xf0100000取指到空洞上直接抛异常。这一段经历让我彻底记住了 ELF 加载必须区分链接地址和加载地址。所以我特别建议做 bootloader 阶段遇到任何诡异问题第一反应不要猜而是开 GDB 实际看一下 PC 停在哪儿、内存是什么。操作系统启动过程没有玄学只有你还没看到的硬件细节和地址错位。8. 一些个人体会与后续建议把 bootloader 啃完并不代表你就能立刻看懂后面的 Lab。但有了这段经历你再看后面 Lab2 的分页、Lab3 的进程切换会有完全不同的感觉。尤其是你会在内存管理的代码里反复看到0xf0100000、0x100000、0x7c00这些地址那时候你会发现原来它们就像一把尺子上的刻度每个刻度都有它存在的理由。如果你正在做 6.828 的 Lab1我的建议是不要急着把答案抄完就交。花一晚上单独调试 bootloader试试自己去改 GDT、自己去修 A20、自己加一个打印函数。遇到问题了用 GDB 一步一步看。这个过程虽然费时间但带来的收益远超那一个实验的分数。我也是在这样反复折腾之后才真正觉得自己对 x86 启动过程“心里有底”了。最后再分享一个小技巧调试 bootloader 时把obj/boot/boot.asm留在手边这个文件是 GNU 工具链生成的“汇编 机器码”对照定位问题时比单看源码高效得多。后面做内核调试也一样kernel.asm是你最忠实的朋友。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑