☰
BootLoader实战:从实模式到保护模式,加载ucore内核全解析
2026/10/15 16:18:19 网站建设 项目流程

操作系统课讲到第四讲,终于轮到第一个真正“动手”的实验:用BootLoader把ucore内核从磁盘加载进内存,让它跑起来。这门课的操作系统叫thuOS,实验框架用的是开源的ucore教学项目。整个实验说难不算难,但信息量非常大——它把计算机从加电开始那几百条神秘指令摊开给你看,我第一次跑通时,感觉像是看到了电脑“灵魂”启动的瞬间。

这篇笔记面向正在做这个实验、或者准备做这个实验的同学,也适合对启动流程感兴趣的读者。我会把启动链路、BootLoader的核心机制、实际操作命令和调试方法全部拆开讲,顺便把我自己踩过的坑也写进去。如果你能跟着动手走一遍,收获会比单纯读书大得多。

1. 实验一在做什么:先看懂启动的全貌

1.1 从加电到main函数:一条完整的链路

开机以后发生的事情,大多数人也只是停留在“屏幕亮了”这个层面。但操作系统的视角里,这是一条严格有序的链路:电源上电、CPU复位、执行固件代码、固件加载引导扇区、引导代码加载内核、内核接管硬件。

以x86平台为例,CPU复位后会从物理地址0xFFFF0开始执行,那里存放的是BIOS(或者UEFI固件)的入口代码。BIOS做完自检、初始化基本硬件之后,会扫描可引导设备,找到引导设备后把它的第一个扇区(512字节)读到物理内存0x7C00处,然后跳转过去。从这一刻起,控制权交给我们自己的代码。

实验一的核心任务就是编写和运行这段“自己的代码”——BootLoader。Ucore的BootLoader分成两部分:bootasm.S负责CPU从实模式切换到保护模式,bootmain.c负责从磁盘读取ucore内核的ELF镜像并跳转执行。整个链路可以用一句话概括:BIOS加载BootLoader,BootLoader加载内核,内核调用lab1的初始化代码,最终在串口和显示器上输出字符。

1.2 BootLoader为什么必须存在,不能跳过

有人可能会问:为什么不能由BIOS直接把操作系统加载进内存?这就要说到BIOS的能力边界。BIOS只负责最基础的硬件初始化和设备访问,它不认识文件系统,不理解ELF格式,也不了解操作系统希望内存如何布局。它的职责是把一个固定大小的引导块加载进来,剩下的所有专业问题交给这个引导块自己解决。

BootLoader就是操作系统和固件之间的“翻译官”和“搬运工”。它在实模式下运行,借助BIOS中断读取磁盘扇区,然后自己完成解析内核镜像、准备运行环境、跳转执行等工作。为什么不能用人更熟悉的高级接口?因为在启动阶段,几乎没有可用的软件环境,连内存管理都还没有建立,你能用的就是最原始的端口读写、内存读写和CPU提供的几条基本指令。

还有一个实际原因是:引导扇区只有512字节,而且最后两个字节必须是0x55 0xAA,否则BIOS根本不会认为这是一个可引导设备。这么小的空间里塞不下复杂逻辑,所以BootLoader通常被设计成两段结构:第一段是精简的汇编入口,第二段是C语言实现的加载逻辑,通过把C代码链接到同一个512字节的引导扇区中,或者采用多阶段加载(stage1加stage2)。

Ucore用的是另一套设计:bootasm.S和bootmain.c一起链接,代码和只读数据都塞进这512字节的引导扇区。这是完全可行的,因为bootmain.c的代码量被严格控制了。实验里如果你修改了BootLoader的代码导致编译产物超过512字节,链接脚本会直接报错,我在第4节会详细展开这个经典问题。

1.3 实验环境与工具链准备

做这个实验前,我建议先把环境理顺。它不像很多现代项目那样开箱即用,因为要交叉编译出适合裸机运行的、无操作系统的镜像。你需要准备的东西包括:

  • 一个x86(或x86_64)环境的Linux虚拟机或真实机器,Windows下也可以通过WSL完成,但直接用Linux发行版最省心。
  • GNU工具链,重点是gcc、ld、objcopy、objdump、dd、make。
  • QEMU模拟器,用于运行生成的镜像。
  • GDB,用于调试(配合QEMU的调试接口使用)。
  • 课程仓库里的实验源码,通常lab1目录下已经包含了完整的BootLoader和内核源码。

我自己用的工具链版本大致是:gcc 11.x,GNU ld 2.38,QEMU 6.2,gdb 12.x,均通过发行版软件源直接安装。版本太新可能会遇到一些编译告警,比如-fno-builtin和-nostdinc这些选项需要确认是否出现在Makefile里,这些细节直接影响编译产物是否“干净”。

提示:如果你发现make后生成的bootblock比512字节大,先不要着急改代码,优先检查编译选项里有没有-fno-pic和-fno-builtin。没有禁掉位置无关代码生成,链接时很可能塞进多余的重定位数据。

2. 核心机制拆解:BootLoader启动的关键环节

2.1 实模式与那512字节的引导扇区

进入保护模式之前,CPU运行在实模式下。实模式最大的特点是:内存地址由“段寄存器左移4位 + 偏移”组成,也就是实际物理地址 = 段基址×16 + 偏移。这种方式看似简单粗暴,但带来的限制非常明显——所有程序共享同一个地址空间,没有隔离,没有保护,寻址范围也被限制在1MB以内。

BIOS把引导扇区加载到物理地址0x7C00,这是IBM PC时代定下的约定。为什么不放在内存起点?因为0x00000到0x7C00这一大片区域,要留给BIOS的数据区、中断向量表,以及一些运行时数据。0x7C00以上的位置相对安全,一直到0x9FFFF都是可用的常规内存。BootLoader代码从0x7C00开始运行,栈则通常被设置在该地址之上,由bootasm.S里的movl $0x7c00, %esp完成初始化。

引导扇区末尾的0x55AA是引导标记。BIOS在读取该扇区后会检查这两个字节,如果不匹配,就认为该设备不可引导。这也是为什么实验里的bootblock如果填不满512字节,最后也要用0x55AA补齐——这些细节在Makefile或sign工具中都已经处理好了。

2.2 分段寻址、A20与进入保护模式

BootLoader在实模式下做的第一件大事,是把CPU切换到保护模式。为什么要切换?因为ucore内核在编译时就默认运行在32位保护模式下,它要用到平坦的地址空间、页式内存管理、特权级保护这些机制。如果BootLoader不先把模式切换好,内核跳转后第一条指令就会出错。

切换过程分成几大步:

  1. 建立GDT(全局描述符表),并把GDT的基址和界限加载到GDTR寄存器中。
  2. 开启A20地址线,让CPU能够访问1MB以上的内存。这个在历史上是通过键盘控制器或快速A20门控实现,现代QEMU下通过端口0x92写入即可。
  3. 设置CR0寄存器的PE位为1,开启保护模式。
  4. 紧接着执行一条长跳转(ljmp),刷新CS和EIP,让CPU完全进入32位保护模式段。

保护模式下段寄存器不再是“左移4位加偏移”的方式,而是作为段选择子,指向GDT中的某个表项。Ucore在bootasm.S里定义了一个非常简单的GDT,只有一个空描述符和两个段描述符:一个代码段(可执行、可读),一个数据段(可读写)。这两个段的基址都是0,界限覆盖4GB,相当于把整个4GB地址空间映射为平坦模式,所以段寄存器基本可以“忽略”,完全靠之后的页表来管理内存。

这里有个细节我一开始没想明白:为什么需要A20地址线?因为在8086时代,地址总线只有20位,最多访问1MB。当段基址加偏移超过0xFFFFF时,地址会回绕到0x00000,而不是扩展出去。后来的80286地址总线扩展到24位,但为了兼容旧程序,默认依然保持回绕行为。必须通过开启A20,让地址线真正能够寻址到1MB以上的内存,否则切到保护模式后访问高地址会出错。

2.3 从磁盘读扇区:I/O指令的原始玩法

BootLoader进入保护模式后,按理说可以直接访问更大的地址空间了,但还有一个问题没解决:内核镜像还在磁盘里,没有文件系统可以调用。好在实验环境用QEMU模拟了一块IDE硬盘(或者软盘镜像),BootLoader可以直接通过端口读写IDE控制器来读取磁盘扇区。

在bootmain.c的read_sect函数里,核心流程是:向I/O端口发送读命令、等待磁盘状态就绪、用insl指令成批读取数据到内存。磁盘访问的最小单位是扇区,一个扇区512字节。BootLoader一次读取一个扇区,然后通过一个循环把数据搬运到目标内存地址。

使用IDE控制器的PIO方式读取扇区,核心步骤是:

  • 选择目标扇区的LBA地址(线性块地址)。
  • 设置读命令。
  • 轮询状态端口,等待DRQ位就绪。
  • 连续读取数据端口,一个扇区512字节。

Ucore的bootmain.c使用的是软盘接口(inb/outb到软盘控制器端口),而实际运行时的镜像可能是软盘镜像(ucore.img)。两者的原理相似,无非是端口号和命令字不同。看懂这部分的目的是理解:在没有设备驱动的条件下,如何用最低层的方式把外设数据搬到内存。

注意:在QEMU里调试时,如果发现BootLoader反复读取某个扇区却读不到数据,先确认一下磁盘镜像的格式和布局。Makefile里生成ucore.img时,data目录下的内核文件应该被dd到镜像的偏移1扇区之后,才能被BootLoader按预定扇区号读到。

2.4 解析ELF:内核镜像到底怎么被“搬运”进内存

bootmain从磁盘读入的数据不是裸的二进制指令,而是ELF格式的内核镜像。ELF是Linux等系统常用的可执行文件格式,它由ELF头部、程序头表(Program Header Table)和各段(Segment)组成。内核镜像和普通用户程序不同,它不需要动态链接器来加载,BootLoader就是它的加载器。

elf.h头文件里定义了ELF头部结构,核心字段有:e_magic(魔数)、e_phoff(程序头表偏移)、e_phnum(程序头表项数)、e_entry(入口点地址)。每次加载一个程序段之前,BootLoader需要先把该段对应的程序头(Elf32_Proghdr)读入内存,解析出p_offset(段在文件中的偏移)、p_va(段加载后的虚拟地址)和p_memsz(段在内存中的大小)。

Ucore的bootmain核心逻辑如下:

  1. 读取ELF头部,检查魔数是否匹配(0x7F 0x45 0x4C 0x46)。
  2. 根据程序头表偏移和项数,逐个读取程序段。
  3. 对每个段,把文件内容从p_offset位置读取到p_va指定的内存位置。
  4. 根据p_memsz和p_filesz的差值,把多出的部分清零(BSS段处理)。
  5. 最后跳转到ELF头部记录的e_entry入口地址。

这里有个需要你真正理解的概念:VMA(虚拟地址)和LMA(加载地址)。VMA是内核运行时代码和数据所在的虚拟地址,LMA是它们实际被加载到物理内存的位置。对于ucore lab1来说,内核链接脚本把代码链接到了0x00100000附近,BootLoader也把段加载到同样的地址,所以VMA和LMA一致。但你在分析系统启动时,必须清楚这两个概念可能不同,后续实验如果引入页表,虚拟地址和物理地址的关系就会复杂起来。

3. 实操过程全记录:从make到QEMU运行

3.1 目录结构与Makefile里的门道

实验lab1的代码结构,核心目录大致如下:

lab1/ ├── boot/ │ ├── bootasm.S # 实模式到保护模式的汇编入口 │ ├── bootmain.c # 加载ELF内核的C代码 │ └── sign.c # 生成引导扇区标记(补足512字节/0x55AA的小工具) ├── kern/ │ ├── init/init.c # 内核入口main函数 │ ├── lib/readline.c # 简单终端交互(实验里打印字符串) │ ├── mm/pmm.c # 物理内存管理(lab1里只有很基础的) │ └── trap/、driver/ # 中断与串口驱动 ├── libs/ # 公共头文件,比如elf.h、x86.h ├── tools/ │ ├── kernel.ld # 内核链接脚本 │ └── sign.c 的依赖 └── Makefile

Makefile是整个实验的调度中心。它的关键目标包括:

  • make:编译BootLoader和内核,生成内核镜像obj/kern、引导扇区bootblock,最后用dd拼接成ucore.img。
  • make qemu:启动QEMU,加载ucore.img。
  • make debug:以调试模式启动QEMU,等待GDB接入。
  • make gdb:启动GDB并连接到QEMU的调试端口。

在编译选项上,你会看到一堆对初学者不太友好的参数,比如-fno-builtin、-ffreestanding、-nostdinc、-fno-pic、-march=i686。这些选项的目的是告诉编译器:不要依赖宿主机的标准库,不要生成位置无关代码,不要链接任何系统启动文件。也就是说,生产的是“裸机代码”,编译产物全靠链接脚本和BootLoader自己组织。

3.2 编译、链接、制作镜像的完整步骤

如果代码仓库已经准备好,编译其实只需要输入make。但我想把制作镜像的底层过程讲清楚,因为这部分帮助你理解BootLoader是如何和内核“见面”的。

编译的大致流程是:

  1. 先用gcc把bootasm.S和bootmain.c编译成目标文件,再链接成bootblock.out。
  2. 用objcopy把bootblock.out中的代码和数据提取成纯二进制bootblock,加上0x55AA标记后检查大小是否为512字节。
  3. 用gcc编译内核各模块,按照tools/kernel.ld链接脚本生成kernel(ELF格式)。
  4. 用dd命令先把bootblock写到ucore.img的0号扇区,再把kernel写到偏移1扇区开始的位置。

这个顺序很重要。如果搞反了,内核文件覆盖了引导扇区,BIOS会找不到可引导标记,QEMU启动后会直接提示无引导设备。

实际执行时你可能会遇到make报错提示“bootblock too big”。这说明bootblock超过了1024字节的一半,也就是520字节左右(实际上由于512字节限制,链接器脚本通常会直接检查输出大小)。我从第一次做实验的教训就是:不要在bootasm.S里随意添加大段调试信息代码,真的塞不下。

3.3 用QEMU跑起来:验证第一行内核输出

编译完成后,运行make qemu。如果一切正常,你会看到QEMU窗口开启,终端输出类似这样的内容:

(THUOS) THUOS is starting...

在串口0(COM1)输出了内核启动的第一个字符串。这个输出是通过内核init.c里的cprintf实现的,而cprintf最终调用了串口驱动的serial_putc,往端口0x3F8写入字符。

这个过程看似简单,背后却体现了一条完整的启动链条:BIOS -> bootasm.S -> bootmain.c -> ELF加载 -> 内核main函数 -> 串口初始化 -> 字符输出。可以说,屏幕上出现这行字,比你日后写出再复杂的应用程序都有意义,因为这意味着从硬件复位那一刻起,所有的控制权都牢牢握在了你写的代码手里。

我建议在跑通之后,试试make debug和make gdb组合,这样你能看到CPU从0x7C00开始执行的第一批指令。

3.4 GDB + QEMU调试:亲眼看着CPU进入保护模式

实验手册里最酷的部分,就是用GDB和QEMU联合调试启动过程。QEMU在特定模式下会开启一个GDB服务器,你可以用GDB连接上去,像调试普通程序一样调试汇编指令。

启动调试的流程是:

make debug

QEMU会挂在后台等待GDB连接,默认端口是1234。然后另开一个终端:

gdb bin/kernel

进入GDB后连接远程目标:

target remote :1234

此时CPU已经停在复位状态附近,但我习惯直接下断点:

b *0x7c00 c

当执行到引导扇区入口时,可以用以下命令逐步观察:

  • si:单步执行一条指令。
  • info registers:查看寄存器的当前值。
  • x/10i $pc:反汇编当前地址附近的10条指令。
  • x/20wx 0x7c00:查看内存中的引导代码数据。

调试时我特别推荐在BSS清空、跳转内核前下个断点。你可以通过b *0x100000(或根据你内核的实际加载地址调整)验证ELF的入口点是否正确。如果跳转后执行的第一条指令不是预期内容,问题多半出在ELF加载阶段,或者链接脚本指定了错误的入口地址。

4. 实验中最容易踩的坑与排查技巧

4.1 引导扇区超512字节的经典报错

这个坑几乎人人都会遇到,除非你完全没改过BootLoader代码。只要你在bootasm.S或bootmain.c里加了任何一点点代码,比如加入一段调试用的字符串,链接后bootblock就可能超过512字节。

链接脚本tools/boot.ld通常会这样限制输出大小:

. = 0x7C00; . : { *(.text) *(.rodata) } . = 0x7DFF;

如果代码量超了,链接器会给出类似“section .text will not fit in region”的错误。我的建议是:先从结构上想一想哪些东西可以挪到内核阶段再打印。BootLoader阶段只保留最必要的信息,比如可以通过QEMU的调试端口输出一个十六进制数字来标记执行进度,而不是打印长字符串。

如果只是想确认bootblock大小,可以执行ls -l bootblock,或者用objdump -h bootblock.o看各段大小。超标的段通常是.rodata,因为字符串字面量都被塞进了只读数据段。避免在BootLoader里定义很长的格式化字符串,是最直接的办法。

4.2 ELF加载偏移与链接地址不一致

第二个常见问题是:kernel链接到某个虚拟地址,但BootLoader却把内核加载到了另一个物理地址。实验lab1的默认链接地址大约是0x00100000,BootLoader也按这个地址加载。但如果之前你做过自定义实验,修改了tools/kernel.ld或者bootmain.c里的链接地址,二者一旦不匹配,跳转到e_entry后第一条指令就会执行到全零或错误的数据,导致“死机”或QEMU直接退出。

排查方法是:

  1. 用readelf -l kernel查看内核各段的p_vaddr。
  2. 在bootmain.c里通过串口或调试器打印当前加载到的目标地址。
  3. 确保这两个数值一致。

另外一个容易踩的是PIE(位置无关可执行文件)问题。如果编译时没有加-fno-pic,链接产物会包含GOT(全局偏移表)等重定位信息,BootLoader直接把它搬到内存后,指令里引用的地址可能是相对于GOT的,执行时找不到正确地址。症状通常是:在QEMU里能打印启动字符串,但一进入内核初始化就异常。

4.3 调试小技巧与问题速查表

实验过程中,我总结了一张问题速查表,遇到报错先对照它排查:

现象可能原因检查方法
qemu启动后黑屏无输出bootblock超出512字节或0x55AA标记缺失ls -l bootblock、hexdump -C bootblock 查看最后两字节
输出乱码串口初始化失败或波特率不对检查kern/driver/console.c的初始化逻辑
启动后反复重启BootLoader读取扇区失败检查ucore.img的扇区布局,确认kernel是否在正确偏移
跳转内核后CPU异常或死循环ELF加载地址与链接地址不一致readelf -l kernel,对照bootmain.c里的加载地址
GDB连接不上1234端口QEMU没有以调试模式启动使用make debug而非make qemu
运行到0x100000处但指令为0x00000000BSS段没有正确清零检查bootmain.c中对p_memsz和p_filesz差的处理

实战里还有一个特别好用的技巧:在bootmain.c里临时加一个循环输出,比如把当前读到的扇区号输出到串口,通过“二分法”定位卡在哪个扇区读取阶段。虽然这样会暂时污染代码,但比起纯靠猜来调试,效率高很多。

提示:如果你在QEMU里开启调试模式,可以在GDB中用set $esp = $esp - 4后手动修改栈数据来模拟某个变量值,但这个操作会破坏现场,只适合临时验证猜想。

5. 写在最后:启动实验带给我的体会与后续展望

第一次跑通BootLoader的时候,我盯着QEMU窗口里那行字愣了几秒。因为我第一次真切感受到,从按下电源键到程序出现,中间经历了BIOS扫描、模式切换、磁盘读取、ELF解析、内存搬运,每一步都是写死的约定,任何一环出错都得不到这行输出。这种感觉,比看到“Hello World”要震撼得多。

做完实验一,后面几个lab的脉络也会清晰起来:既然BootLoader已经把内核装载进内存,接下来就要管理这块内存(lab2)、建立页表(lab3)、实现进程调度和中断(lab4-lab5)。可以说,启动这一步是整个操作系统课程的地基,虽然它短小、古老,但理解了它,再看后面的话题都会顺畅不少。

如果你正卡在某个报错上,我建议先别急着上看别人的答案,自己尝试用GDB走一遍启动过程。把每一个寄存器的值变化记录下来,把每一步加载地址算一遍,你会发现,那些看起来神乎其神的概念,其实不过是一行行可以亲眼验证的机器指令。祝实验顺利。

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

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

立即咨询