从BIOS到内核:bootloader启动流程与实模式保护模式切换详解

发布时间:2026/10/14 1:20:53
从BIOS到内核:bootloader启动流程与实模式保护模式切换详解 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:0x7C00DL寄存器保存着引导设备的驱动器号。中断向量表在内存低地址处已经由BIOS建立好可以通过int 0x13等BIOS中断访问磁盘。内存0x7C00以上到0x9FC00左右是可用的0xA0000以上是显存和BIOS ROM区域。这里有个细节值得注意为什么是0x7C00这是历史遗留约定。早期IBM PC的BIOS设计者把引导扇区放在这里是为了给操作系统留出低地址空间同时避开显存区域。0x7C00这个值本身没有魔法但所有x86 BIOS都遵守这个约定所以bootloader必须假设自己被加载到这里。注意如果你自己写bootloader链接脚本里必须把代码段的起始地址设为0x7C00否则跳转指令的偏移会全部错位。这是新手最容易犯的错误之一。3.2 第一阶段的核心任务拆解第一阶段能做的事非常有限通常只包含以下几个动作关中断。BIOS交棒时中断是开着的但bootloader在设置好自己的栈和中断处理之前不应该响应任何中断。一条cli指令搞定。设置段寄存器。把DS、ES、SS等清零让段基址从0开始简化后续寻址。建立栈。栈指针SP要指向一块安全的内存。通常做法是把栈顶设在0x7C00因为代码从0x7C00开始栈向下增长不会覆盖代码或者设在0x7C00以下的一段空间。保存驱动器号。DL里的值要存起来后面读磁盘时还要用。读取后续扇区。用int 0x13的AH0x02功能把磁盘上第二阶段的代码读到内存某个地址。跳转到第二阶段。这六步里最复杂的是第五步。int 0x13读磁盘需要指定柱面号、磁头号、扇区号这些参数的计算涉及磁盘几何结构。而且早期BIOS对读取的扇区数有限制一次不能读太多。实际代码里往往要循环读取。3.3 磁盘读取int 0x13的坑与技巧int 0x13是BIOS提供的磁盘服务功能号放在AH里。读扇区用AH0x02参数如下AL要读的扇区数不能为0CH柱面号低8位CL扇区号bit 0-5加柱面号高2位bit 6-7DH磁头号DL驱动器号ES:BX数据缓冲区地址返回值方面AH为0表示成功CF标志位为0表示无错误。如果出错AH里是错误码。这里有几个坑我踩过第一个坑是扇区号从1开始。CHS寻址里扇区号是1到63不是0到62。如果你按0开始算读出来的数据会整体偏移一个扇区。第二个坑是跨磁道读取。如果你要读的扇区跨越了磁道边界BIOS可能读失败。稳妥的做法是一次只读一个磁道内的扇区或者干脆一个扇区一个扇区地读。第三个坑是读取数量限制。有些BIOS实现里一次读取超过一定数量比如64个扇区会失败。教学系统里通常一次读几个扇区问题不大但真实bootloader必须处理这个限制。第四个坑是重试机制。磁盘读取可能因为各种原因失败标准做法是失败后重置磁盘控制器AH0x00然后重试几次。我见过不少自制OS的bootloader没有重试逻辑在真机上偶尔启动失败在虚拟机上却一直正常排查起来很痛苦。下面是一段典型的第一阶段读磁盘代码ATT语法来自教学系统风格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的AX0x2401功能或者直接操作0x92端口。第二建立GDT。全局描述符表定义了保护模式下的内存段。至少要有代码段和数据段两个描述符加上一个空描述符。GDT的基址和限长要加载到GDTR寄存器。第三设置CR0的PE位。把CR0的第0位置1CPU就切换到保护模式。但这条指令之后下一条指令的寻址方式就变了所以通常要用一个远跳转来刷新指令流水线。这三步的顺序不能乱。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里效率极低。我的做法是先看链接脚本搞清楚内存布局。找到入口点通常是_start或start。画流程图把跳转关系标出来。分阶段读先读第一阶段理解它怎么加载第二阶段再读第二阶段理解它怎么加载内核。对照手册遇到指令或寄存器不懂查Intel手册不要猜。动手改在关键位置加调试输出看实际执行路径。bootloader的代码量不大但信息密度极高。每一行都有存在的理由没有废话。读透一个bootloader对理解计算机启动过程、硬件编程、底层调试都有巨大帮助。我自己读完JOS的bootloader后再看其他系统的启动流程明显感觉顺畅多了。这个系列后续还会继续往下走进入内核初始化阶段。bootloader只是开胃菜真正的大餐在后面。但这一关必须过因为后面所有的代码都建立在bootloader搭好的舞台上。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询