☰
从BIOS到内核:bootloader启动流程与实模式保护模式切换详解
2026/10/14 1:20:52 网站建设 项目流程

1. 从按下电源键到内核接管:bootloader到底在忙什么

很多人第一次翻操作系统源码,都是从启动流程开始的。原因很简单——这是整个系统里唯一一段“从头到尾”能跑通的代码路径,没有之一。你不需要理解调度器、内存管理、文件系统这些庞然大物,只要跟着CPU上电后的第一条指令往下走,就能看到一个操作系统是怎么从一堆二进制里“长”出来的。

但bootloader这块内容,恰恰是劝退率最高的部分。我见过不少朋友,汇编能看懂,C语言也熟,可一打开boot目录就懵了:实模式、保护模式、GDT、A20地址线、链接脚本、重定位……这些词单独拎出来都认识,凑在一起就不知道谁先谁后、谁依赖谁。更麻烦的是,这部分代码和硬件耦合极深,换个平台、换个编译器版本,行为可能就不一样,网上搜到的解释又往往是碎片化的,看完还是串不起来。

这篇内容就是想把“bootloader启动”这件事,按照CPU实际执行的顺序,从头到尾捋一遍。我会以常见的x86平台教学用操作系统为背景(比如MIT 6.828的JOS、xv6这类经典教学系统,以及各类自制OS项目),讲清楚三件事:第一,bootloader在整个启动链条里处于什么位置,它要解决的核心问题是什么;第二,从BIOS交棒到bootloader,再到跳进内核,中间每一步具体在做什么,为什么必须这么做;第三,实际读代码、调代码时会踩哪些坑,怎么排查。

适合的读者是:已经学过操作系统理论课,想通过读源码把知识落地的人;正在跟MIT 6.828、xv6或者自己写玩具OS的人;以及做嵌入式底层开发、需要理解启动流程的工程师。如果你连实模式和保护模式的区别都还没搞清楚,建议先补一下x86基础,不然读起来会比较吃力。但只要你有基本的汇编和C语言功底,跟着这篇的节奏走,bootloader这块硬骨头是能啃下来的。

2. 启动链条全景:bootloader站在哪个位置

2.1 从上电到内核的四棒接力

要理解bootloader,先得把它放回整条启动链里看。一台x86机器从上电到操作系统跑起来,大致经过四个阶段,像接力赛一样一棒传一棒:

阶段执行者运行环境核心任务
第一棒BIOS/UEFI固件实模式硬件自检、初始化关键外设、找到可引导设备
第二棒bootloader第一阶段实模式把自己从磁盘加载到内存、准备进入保护模式
第三棒bootloader第二阶段保护模式加载内核镜像、建立基本运行环境、跳转内核
第四棒操作系统内核保护模式/长模式接管全部硬件、建立完整内存管理、启动进程

这个划分不是绝对的。有的系统把bootloader做成一个整体,有的拆成两段甚至三段。但不管怎么拆,核心逻辑是一样的:BIOS只认识磁盘的第一个扇区,而内核镜像通常远大于一个扇区,中间必须有个“搬运工”把内核搬到内存里,并且把CPU从实模式切换到保护模式。这个搬运工就是bootloader。

为什么BIOS不能直接加载内核?因为BIOS的设计年代太早了,它的职责被严格限定在“找到引导扇区并跳过去”。引导扇区只有512字节,还要以0x55AA结尾,实际能用的代码空间不到510字节。这点空间连一个像样的文件系统驱动都塞不下,更别说加载几MB的内核了。所以bootloader的第一阶段必须极其精简,它的唯一使命就是“把自己剩下的部分加载进来”。

2.2 为什么bootloader要分阶段

初学者常问:既然要加载内核,为什么不一次性写完,非要分阶段?这里有两个硬约束。

第一个约束是空间。前面说了,BIOS只加载512字节。这512字节里,你要放磁盘读取代码、放跳转逻辑、放一些基本的数据结构,根本不够。所以第一阶段只能做最紧急的事:把磁盘上后续的扇区读进内存,然后跳过去执行。真正的重活——比如解析ELF格式、设置页表、进入保护模式——都放在第二阶段。

第二个约束是运行环境。第一阶段跑在实模式下,只能寻址1MB内存,而且没有内存保护。第二阶段要加载的内核通常是32位甚至64位的,需要保护模式或长模式。从实模式切到保护模式,本身就需要一系列准备工作(设置GDT、打开A20、设置CR0),这些代码放在第二阶段做更从容。

我个人的经验是,读bootloader源码时,不要一上来就逐行读汇编。先找到链接脚本,看清楚各个段被安排到什么地址,再找到入口点,顺着跳转关系画一张流程图。这张图比任何注释都管用。很多人的挫败感来自于“不知道当前这行代码在整条链的哪个位置”,一旦有了全局图,细节就好理解了。

2.3 教学系统与真实系统的差异

需要说明的是,教学用操作系统的bootloader和真实生产系统的bootloader(比如GRUB、U-Boot)差别很大。教学系统追求的是“最小可运行”,代码量通常几百行,功能只覆盖“加载内核并跳转”。真实bootloader要处理文件系统、多操作系统菜单、网络启动、设备树、安全校验等一大堆事情,代码量是前者的几百倍。

但核心原理是相通的。你把教学系统的bootloader读透了,再去看GRUB的stage1/stage2设计,会发现思路完全一致,只是工程复杂度不同。所以我的建议是:先用教学系统建立心智模型,再去啃真实bootloader的源码,不要反过来。

3. 第一阶段:512字节里的极限操作

3.1 BIOS交棒时的机器状态

要写bootloader第一阶段,必须先知道BIOS把控制权交出来时,机器处于什么状态。这个状态是bootloader的“初始条件”,所有代码都建立在这个基础上。

BIOS完成自检和初始化后,会读取引导设备的第一个扇区(512字节)到内存物理地址0x7C00处,然后跳转到0x7C00执行。此时:

  • CPU处于实模式,寻址方式为“段基址×16+偏移”,最大寻址1MB。
  • 通用寄存器状态不确定,但CS:IP被设置为0x0000:0x7C00,DL寄存器保存着引导设备的驱动器号。
  • 中断向量表在内存低地址处已经由BIOS建立好,可以通过int 0x13等BIOS中断访问磁盘。
  • 内存0x7C00以上到0x9FC00左右是可用的,0xA0000以上是显存和BIOS ROM区域。

这里有个细节值得注意:为什么是0x7C00?这是历史遗留约定。早期IBM PC的BIOS设计者把引导扇区放在这里,是为了给操作系统留出低地址空间,同时避开显存区域。0x7C00这个值本身没有魔法,但所有x86 BIOS都遵守这个约定,所以bootloader必须假设自己被加载到这里。

注意:如果你自己写bootloader,链接脚本里必须把代码段的起始地址设为0x7C00,否则跳转指令的偏移会全部错位。这是新手最容易犯的错误之一。

3.2 第一阶段的核心任务拆解

第一阶段能做的事非常有限,通常只包含以下几个动作:

  1. 关中断。BIOS交棒时中断是开着的,但bootloader在设置好自己的栈和中断处理之前,不应该响应任何中断。一条cli指令搞定。
  2. 设置段寄存器。把DS、ES、SS等清零,让段基址从0开始,简化后续寻址。
  3. 建立栈。栈指针SP要指向一块安全的内存。通常做法是把栈顶设在0x7C00(因为代码从0x7C00开始,栈向下增长不会覆盖代码),或者设在0x7C00以下的一段空间。
  4. 保存驱动器号。DL里的值要存起来,后面读磁盘时还要用。
  5. 读取后续扇区。用int 0x13的AH=0x02功能,把磁盘上第二阶段的代码读到内存某个地址。
  6. 跳转到第二阶段。

这六步里,最复杂的是第五步。int 0x13读磁盘需要指定柱面号、磁头号、扇区号,这些参数的计算涉及磁盘几何结构。而且早期BIOS对读取的扇区数有限制,一次不能读太多。实际代码里往往要循环读取。

3.3 磁盘读取:int 0x13的坑与技巧

int 0x13是BIOS提供的磁盘服务,功能号放在AH里。读扇区用AH=0x02,参数如下:

  • AL:要读的扇区数(不能为0)
  • CH:柱面号低8位
  • CL:扇区号(bit 0-5)加柱面号高2位(bit 6-7)
  • DH:磁头号
  • DL:驱动器号
  • ES:BX:数据缓冲区地址

返回值方面,AH为0表示成功,CF标志位为0表示无错误。如果出错,AH里是错误码。

这里有几个坑我踩过:

第一个坑是扇区号从1开始。CHS寻址里,扇区号是1到63,不是0到62。如果你按0开始算,读出来的数据会整体偏移一个扇区。

第二个坑是跨磁道读取。如果你要读的扇区跨越了磁道边界,BIOS可能读失败。稳妥的做法是一次只读一个磁道内的扇区,或者干脆一个扇区一个扇区地读。

第三个坑是读取数量限制。有些BIOS实现里,一次读取超过一定数量(比如64个扇区)会失败。教学系统里通常一次读几个扇区,问题不大,但真实bootloader必须处理这个限制。

第四个坑是重试机制。磁盘读取可能因为各种原因失败,标准做法是失败后重置磁盘控制器(AH=0x00),然后重试几次。我见过不少自制OS的bootloader没有重试逻辑,在真机上偶尔启动失败,在虚拟机上却一直正常,排查起来很痛苦。

下面是一段典型的第一阶段读磁盘代码(AT&T语法,来自教学系统风格):

mov $0x02, %ah # 功能号:读扇区 mov $0x01, %al # 读1个扇区 mov $0x00, %ch # 柱面号0 mov $0x02, %cl # 从第2个扇区开始(第1个是引导扇区本身) mov $0x00, %dh # 磁头号0 mov $0x00, %dl # 驱动器号,实际应从保存的值取 mov $0x1000, %bx # 缓冲区偏移 mov $0x0000, %ax mov %ax, %es # ES:BX = 0x0000:0x1000 int $0x13 jc disk_error # 如果CF置位,跳转到错误处理

这段代码把第二个扇区读到物理地址0x1000处。为什么选0x1000?因为0x7C00是引导扇区,0x1000在它下面,不会冲突,而且留出了足够空间。

3.4 从实模式到保护模式的过渡准备

第一阶段通常不直接进入保护模式,但有些设计会把切换逻辑放在第一阶段末尾。不管放在哪里,进入保护模式前必须完成三件事:

第一,打开A20地址线。实模式下,地址线只有20根,超过1MB的地址会回绕。保护模式需要访问1MB以上内存,所以必须打开第21根地址线。方法是通过键盘控制器或快速A20门。教学系统里常用int 0x15的AX=0x2401功能,或者直接操作0x92端口。

第二,建立GDT。全局描述符表定义了保护模式下的内存段。至少要有代码段和数据段两个描述符,加上一个空描述符。GDT的基址和限长要加载到GDTR寄存器。

第三,设置CR0的PE位。把CR0的第0位置1,CPU就切换到保护模式。但这条指令之后,下一条指令的寻址方式就变了,所以通常要用一个远跳转来刷新指令流水线。

这三步的顺序不能乱。A20没打开就进保护模式,访问高地址会出错;GDT没建好就设置PE位,CPU找不到段描述符会三重故障重启。

实操心得:调试保护模式切换时,如果机器不断重启,八成是GDT有问题或者A20没打开。可以在切换前后往显存写不同字符,看卡在哪一步。显存地址0xB8000在实模式和保护模式下都能访问,是个很好的调试输出点。

4. 第二阶段:加载内核与跳转前的最后准备

4.1 第二阶段为什么用C语言写

第一阶段受限于512字节,只能用汇编。第二阶段没有这个限制,可以用C语言写,可读性和可维护性大幅提升。但这里有个矛盾:C语言编译出来的代码假设自己运行在保护模式下,有平坦的内存模型和正常的栈。而第二阶段刚被加载时,CPU还在实模式。

解决办法是:用汇编写一个小的入口,完成从实模式到保护模式的切换,然后跳转到C语言写的主函数。这个汇编入口通常叫bootmain或者entry,它负责设置GDT、打开A20、设置CR0,然后用一个远跳转进入32位代码段,最后调用C函数。

这个设计模式在教学系统里非常常见。比如JOS的bootloader,汇编部分只有几十行,剩下的逻辑全在C里。这样既满足了硬件约束,又保证了代码的可读性。

4.2 读取内核镜像:ELF格式解析

第二阶段的核心任务是加载内核。内核通常以ELF格式存储在磁盘上,bootloader需要解析ELF头,找到各个程序段,把它们加载到指定的内存地址。

ELF头里关键字段包括:

  • e_entry:程序入口地址,bootloader最后要跳到这里。
  • e_phoff:程序头表在文件中的偏移。
  • e_phnum:程序头表项数量。
  • e_phentsize:每个程序头表项的大小。

每个程序头描述一个段,关键字段有:

  • p_type:段类型,PT_LOAD表示需要加载。
  • p_offset:段在文件中的偏移。
  • p_va:段的虚拟地址(加载目标地址)。
  • p_memsz:段在内存中的大小。
  • p_filesz:段在文件中的大小。

加载逻辑就是遍历程序头表,对每个PT_LOAD段,从磁盘读取p_filesz字节到p_va处,如果p_memsz大于p_filesz,剩余部分清零(通常是BSS段)。

这里有个细节:磁盘读取的单位是扇区,而ELF段的大小不一定是扇区的整数倍。所以读取时要向上取整到扇区边界,但加载到内存时只复制实际需要的字节数。多读的字节要丢弃,否则会覆盖后面的数据。

4.3 链接脚本:决定代码住在哪里

链接脚本是bootloader和内核的“地址规划图”,它决定了各个段被放到什么地址。读源码时,链接脚本往往比代码本身更重要,因为它定义了整个内存布局。

一个典型的bootloader链接脚本长这样:

OUTPUT_FORMAT("elf32-i386") ENTRY(_start) SECTIONS { . = 0x7C00; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }

这段脚本的意思是:代码从0x7C00开始,.text段在最前面,然后是.data和.bss。ENTRY(_start)指定了入口符号。

为什么链接脚本这么重要?因为bootloader里的跳转指令、函数调用、全局变量访问,都依赖于符号的绝对地址。如果链接脚本写错了,代码被加载到错误的位置,所有跳转都会失败。我见过一个案例,有人把bootloader链接到0x0,结果BIOS把引导扇区加载到0x7C00后,代码里的跳转全部指向了错误地址,机器直接跑飞。

注意:链接脚本里的地址是虚拟地址(在bootloader阶段等同于物理地址)。如果你后续要开启分页,链接脚本里的地址和实际加载地址可能不同,需要额外处理重定位。

4.4 跳转内核前的最后检查

在跳转到内核入口之前,bootloader需要确保几件事:

第一,内核已经被完整加载到内存。可以通过校验ELF头里的魔数(0x7F 'E' 'L' 'F')来确认。

第二,必要的硬件状态已经设置好。比如保护模式已经开启,GDT已经加载,栈指针指向合法内存。

第三,传递必要的启动参数。有些系统会把内存大小、启动设备号等信息放在特定寄存器或内存地址,内核启动后会读取这些信息。

第四,关中断。在跳转前关掉中断,让内核自己决定什么时候开中断。

跳转本身通常是一个函数指针调用:

void (*entry)(void) = (void (*)(void))elf->e_entry; entry();

这行代码看起来简单,但背后是bootloader所有工作的终点。执行到这一行时,CPU已经处于保护模式,内核代码已经在内存里,控制权正式从bootloader移交给操作系统。

5. 实操中踩过的坑与排查方法

5.1 常见问题速查表

现象可能原因排查方法
机器不断重启三重故障,通常是GDT错误或A20未打开在关键步骤往显存写字符,定位卡在哪一步
跳转后跑飞链接地址与实际加载地址不一致检查链接脚本和加载地址是否匹配
读磁盘失败CHS参数错误或未重试打印错误码,检查扇区号是否从1开始
内核加载后无输出内核入口地址错误或段未正确加载用调试器查看e_entry处的内存内容
在虚拟机正常,真机失败时序问题或BIOS实现差异增加重试逻辑,避免依赖未定义行为
保护模式切换后立即崩溃GDT描述符格式错误检查GDT基址、限长、访问字节

5.2 调试bootloader的实用技巧

调试bootloader比调试普通程序难得多,因为它运行在裸机上,没有printf,没有调试器(除非你用QEMU的gdb stub)。我常用的几个技巧:

第一,显存输出。往0xB8000开始的内存写字符,可以直接在屏幕上看到。每个字符占两个字节:低字节是ASCII码,高字节是属性(颜色)。写一个简单的putchar函数,就能在屏幕上打印调试信息。

第二,QEMU的gdb stub。用qemu -s -S启动,然后用gdb连接,可以单步执行bootloader,查看寄存器和内存。这是最强大的调试手段,强烈建议掌握。

第三,串口输出。如果显存输出不够用,可以配置串口,把调试信息发到主机。QEMU支持-serial stdio,把串口重定向到终端。

第四,二分法定位。如果不知道问题出在哪,在代码中间插入一个无限循环或者显存输出,看能不能执行到那里。逐步缩小范围。

5.3 读源码的方法论

最后分享一点读bootloader源码的方法。很多人读源码的习惯是从第一行开始逐行读,这在bootloader里效率极低。我的做法是:

  1. 先看链接脚本,搞清楚内存布局。
  2. 找到入口点,通常是_start或start。
  3. 画流程图,把跳转关系标出来。
  4. 分阶段读,先读第一阶段,理解它怎么加载第二阶段;再读第二阶段,理解它怎么加载内核。
  5. 对照手册,遇到指令或寄存器不懂,查Intel手册,不要猜。
  6. 动手改,在关键位置加调试输出,看实际执行路径。

bootloader的代码量不大,但信息密度极高。每一行都有存在的理由,没有废话。读透一个bootloader,对理解计算机启动过程、硬件编程、底层调试都有巨大帮助。我自己读完JOS的bootloader后,再看其他系统的启动流程,明显感觉顺畅多了。

这个系列后续还会继续往下走,进入内核初始化阶段。bootloader只是开胃菜,真正的大餐在后面。但这一关必须过,因为后面所有的代码,都建立在bootloader搭好的舞台上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询