简介:《一个UEFI引导程序的实现》是田宇撰写的一本UEFI引导程序开发专著,也是《一个64位操作系统的设计与实现》的补充内容。全书从UEFI与BIOS的结构和启动流程对比讲起,说明UEFI取代BIOS的原因,并涉及安全启动、PEI/DXE/BDS阶段等关键概念;随后基于TianoCore EDK2环境,依次讲解UEFI Shell编译、GPT磁盘布局、ESP文件系统、Hello World应用、UEFI镜像与系统表、调用约定等基础,再逐步实现显示模式配置、内存获取、文件读取和64位平台下的内核引导启动。书中配有大量实例和运行效果图,便于读者边学边验证。资源为1个PDF文件,压缩包总大小约2.36MB,适合操作系统的初学者、软件工程师及爱好者研读。目前已有2376人学习下载。通过本书,读者可以从零开始搭建开发环境、体验完整的UEFI引导程序开发流程,建立从固件接口到引导加载的完整认知,获得核心思路与可直接实践的示例,为后续学习Linux内核源代码和自研操作系统打下扎实基础。
1. 一个UEFI引导程序的实现.pdf:为什么值得把它跑起来
很多人第一次接触“UEFI引导程序”是从一份 PDF 开始的——打开一看,满屏的 EFI_STATUS、SystemTable、Boot Services,第一反应是这东西离自己太远。但换个角度想:操作系统内核写好了,总得有个东西把它从硬盘请进内存。GRUB 是通用的,但通用意味着黑匣子:你敲下回车之后它到底安排了什么,你很难说清楚。自己写一个最小 UEFI 引导程序,就是把启动链路上最关键的一段握在自己手里。本篇文章就围绕这份标题里的方案讲清楚——UEFI 引导程序到底是什么、最小实现长什么样、怎么编译验证、哪些坑几乎人人必踩,以及从“能跑”到“能交接内核”需要跨过的最后几步。适合正在写内核、想脱离 GRUB 或者对固件启动流程有好奇心的开发者。全程用 C 语言,加上一台虚拟机,半天就能跑起来。
2. 先弄清 UEFI 到底怎么启动:固件阶段、系统表与引导程序的入场点
动手写代码之前,得先把 UEFI 固件从上电到加载操作系统的路径理清楚。很多人写引导程序时习惯抄别人的模板,入口函数倒是写对了,却说不清它处在整个启动流程的哪一环。这直接导致后面排错的时候全靠猜。这一章先把“引导程序是被谁加载的、它手里拿着什么资源、能用哪些服务”三件事说透。
2.1 固件启动的四个阶段:SEC、PEI、DXE、BDS分别做了什么
现代 UEFI 固件的启动流程大致分成四个阶段,每个阶段的名字经常出现在文档和日志里,理解了它们的职责,才能定位自己的引导程序应该在哪一层介入。
| 阶段 | 全称 | 核心工作 | 特点 |
|---|---|---|---|
| SEC | Security | 上电后的第一条指令,做 CPU 初始化前的安全验证 | 无内存可用,靠汇编完成 |
| PEI | Pre-EFI Initialization | 初始化内存控制器,把系统内存摸清 | 运行环境仍在 SRAM/Cache 上 |
| DXE | Driver Execution Environment | 加载大量固件驱动,提供完整的系统服务 | 此时内存管理已经可用 |
| BDS | Boot Device Selection | 按启动顺序查找可引导设备,加载 UEFI 应用 | 我们写的引导程序在这里被加载 |
从这张表能看出,引导程序并不是和硬件第一秒打交道的角色。它出现的时候,DXE 已经完成大部分驱动初始化,内存分配、文件系统、协议注册这些基础设施都已就绪。这也是为什么可以用 C 语言、甚至可以不关心段页表细节来写引导程序——脏活累活固件已经干完了。
2.2 引导程序在UEFI里的真实身份:一个由BDS加载的EFI应用
UEFI 引导程序本质上不是一个“程序”这么简单,它是一个标准的 PE/COFF 格式可执行文件,加上一个特殊的入口约定。固件在 BDS 阶段扫描启动设备时,会在 FAT 格式分区里寻找固定路径下的引导文件(常见的是\EFI\BOOT\BOOTX64.EFI),找到后用内部的内存加载机制把它载入内存,然后跳转到它的入口函数。
这个入口函数不是传统的main,而是接收两个参数的efi_main。第一个参数是镜像句柄,用来标识这个引导程序自身的镜像;第二个参数是系统表指针,是一切服务的入口。它与普通操作系统下的应用程序有一个本质区别:它运行的时候,操作系统还没起来,所有“系统调用”实际都落在固件提供的服务表上。
所以,开发 UEFI 引导程序的一个关键转变是:你写的不再是“请求操作系统做什么”,而是“直接请求固件做什么”。内存要自己分配、显示要自己找协议、文件要自己读,固件不会帮你做任何事,但允许你调用它提供的一切能力。
2.3 SystemTable里藏着的引导资源:ConOut、BootServices、RuntimeServices与配置表
EFI_SYSTEM_TABLE是整个引导程序最重要的数据结构。拿到它,就等于拿到了一张完整的服务清单。通常关注的字段只有几个,但每个都对应一类能力。
| 字段 | 类型 | 用途 |
|---|---|---|
ConIn | EFI_SIMPLE_TEXT_INPUT_PROTOCOL* | 标准输入,读键盘 |
ConOut | EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL* | 标准输出,打文本 |
BootServices | EFI_BOOT_SERVICES* | 引导期服务:内存、协议、镜像加载 |
RuntimeServices | EFI_RUNTIME_SERVICES* | 运行期服务:变量、时间,退出引导后仍可用 |
NumberOfTableEntries/ConfigurationTable | 计数与数组 | ACPI、SMBIOS 等系统配置表 |
其中BootServices是引导程序使用最频繁的一组操作。内存分配用AllocatePages和FreePool,找硬件驱动用LocateProtocol,退出固件接管用ExitBootServices。还有一个概念容易混:引导程序同时也能通过HandleProtocol获取某个硬件句柄对应的协议,GOP 图形输出协议就是通过这种方式拿到的。
提示:
RuntimeServices里的变量服务在ExitBootServices之后依旧可用,但BootServices里的所有函数在退出引导后会变成不可调用状态,强行调用就是死机,这一点后面避坑章节还会重点展开。
第 2 章的最后再强调一个容易被忽略的细节:引导程序并不一定要“引导操作系统”。它可以是 UEFI Shell,可以是内存测试工具,也可以是硬件诊断程序。固件只看文件格式和入口约定,不看程序意图。搞清楚这一点,就会理解BOOTX64.EFI这个名字只是约定俗成的默认文件名,而不是 UEFI 的技术强制。
3. 用C写最小UEFI引导程序:入口函数、文本输出与图形模式
原理讲完,现在开始动手。目标非常明确:写一个能在 UEFI 环境下运行、往屏幕输出一行字、并且能拿到图形输出协议的引导程序。这个程序虽然不引导任何内核,但它是后续一切工作的骨架——一旦跑通,加载内核只是把文件读进内存然后跳转而已。
3.1 开发包怎么选:轻量的gnu-efi与完整的EDK2
写 UEFI 程序需要一套开发环境,常见的选择有两个,了解它们的差异能帮你节省大量时间。
| 方面 | gnu-efi | EDK2 |
|---|---|---|
| 体积 | 轻量,只有头文件和库 | 庞大,完整构建框架 |
| 构建方式 | gcc + ld + objcopy,直接 | 需要配置 Python/Build 环境 |
| 入门难度 | 低,适合理解原理 | 高,适合产品级固件开发 |
| 调试便利 | 配合 QEMU + gdb 很顺 | 有自己的调试体系,但对小白偏重 |
| 适用场景 | 自己写引导程序、做实验 | 做设备固件、定制平台 |
我一般会建议优先用 gnu-efi。理由很实际:EDK2 的整个工程框架比引导程序本身复杂一个量级,光是配置环境就要花掉不少时间。而 gnu-efi 提供的头文件直接把结构体定义、GUID、协议声明都准备好了,写一个最小引导程序只需要一个.c文件加一条编译命令。等真正需要定制固件时,再切 EDK2 也不迟。
3.2 最小工程结构:一个boot.c就够的目录布局
这个项目的目录结构可以收敛到最简单:
uefi-boot/ ├── boot.c # 引导程序源码 ├── Makefile # 编译脚本 └── disk/ └── EFI/ └── BOOT/ └── BOOTX64.EFI # 编译产物,运行时放在这里disk/目录会被当作虚拟启动盘的根目录。QEMU 会把整个目录模拟成一块 FAT 格式的磁盘,固件扫描时正好能找到\EFI\BOOT\BOOTX64.EFI。这样连磁盘镜像都不用创建,开发周期能压缩到分钟级。这个布局是 UEFI 开发里最省事的验证方式,值得固定下来当模板用。
3.3 写出入口函数:从文本输出到拿到图形输出协议
下面是完整的最小引导程序源码。它做的事有三件:往屏幕打印一行字、获取图形输出协议、把显示模式设为默认。每件事都对应后续开发的高频操作。
#include <efi.h> #include <efilib.h> EFI_STATUS efi_main(EFI_HANDLE image_handle, EFI_SYSTEM_TABLE *system_table) { EFI_STATUS status; EFI_GUID gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID; EFI_GRAPHICS_OUTPUT_PROTOCOL *gop = NULL; // 初始化 gnu-efi 的全局系统表指针,Print 等函数依赖它 InitializeLib(image_handle, system_table); // 使用标准输出协议打印一行文本 system_table->ConOut->OutputString(system_table->ConOut, L"Hello from UEFI Boot!\r\n"); // 通过 GUID 查找图形输出协议 status = system_table->BootServices->LocateProtocol(&gop_guid, NULL, (void **)&gop); if (EFI_ERROR(status)) { // 拿不到 GOP 时返回错误码,便于调试定位 return status; } // 把显示模式切回默认模式(通常对应文本模式下的默认分辨率) status = gop->SetMode(gop, 0); if (EFI_ERROR(status)) { return status; } return EFI_SUCCESS; }代码本身不长,但有几个点要说明。InitializeLib是 gnu-efi 提供的初始化函数,它会把你拿到的system_table保存到全局变量里,之后才能用Print这类简化封装。OutputString的参数是CHAR16*类型的宽字符常量,所以字符串字面量必须带L前缀。LocateProtocol是找协议的标准方式,三个参数分别是协议 GUID、可选的注册令牌、输出参数指针。注意gop的指针类型是EFI_GRAPHICS_OUTPUT_PROTOCOL*,但LocateProtocol的最后一个参数要求void**,所以必须强转。
注意:如果不调用
InitializeLib,代码里直接用
这段代码里没有任何“引导内核”的动作,它做的事情只是证明“一个 UEFI 引导程序能够运行、能够访问协议”。可别小看这一步,它把整个 UEFI 编程模型的关键路径全走了一遍:入口约定、系统表访问、协议定位。之后不管是读文件还是切页表,套路都是一样的,只是换了个 GUID 和数据结构。
4. 编译、运行、调试:把引导程序跑在QEMU里才算数
代码写出来只是开始,能编译通过、能在虚拟机里看到输出,才算是真正落地。这一章把从源码到运行的全过程拆成三步:编译参数、虚拟启动盘、gdb 跟调。每一步都有坑,我会把参数为什么这么设说清楚。
4.1 手写Makefile:gcc参数逐项说清
gnu-efi 的编译过程和普通 C 程序不太一样,最终产物不是 ELF 可执行文件,而是一个被objcopy转换过的 EFI 应用。先看 Makefile:
ARCH = x86_64 CC = gcc LD = ld OBJCOPY = objcopy CFLAGS = -I/usr/include/efi -I/usr/include/efi/$(ARCH) \ -fno-stack-protector -fpic -fshort-wchar -mno-red-zone \ -ffreestanding -O2 -Wall LDFLAGS = -nostdlib -shared -Bsymbolic -L/usr/lib \ /usr/lib/crt0-efi-$(ARCH).o all: boot.so $(OBJCOPY) -j .text -j .sdata -j .data -j .dynamic \ -j .dynsym -j .rel* \ --target=efi-app-$(ARCH) boot.so disk/EFI/BOOT/BOOTX64.EFI boot.so: boot.c $(CC) $(CFLAGS) -c boot.c -o boot.o $(LD) $(LDFLAGS) boot.o -lgnuefi -lefi -o boot.so clean: rm -f boot.o boot.so编译参数的解释值得逐条说清楚,因为它们不是随便写的:
| 参数 | 原因 |
|---|---|
-fshort-wchar | 让wchar_t变成 2 字节,匹配 UEFI 的CHAR16类型 |
-fno-stack-protector | 固件环境里没有栈保护所需的钩子函数 |
-mno-red-zone | x86_64 ABI 的红区在中断/异常处理时会被覆盖,固件不允许 |
-fpic | 生成位置无关代码,因为固件把引导程序加载到哪块内存不确定 |
-ffreestanding | 告诉编译器不要假设标准库存在 |
-nostdlib -shared -Bsymbolic | 链接成共享对象,下一步再由 objcopy 转换格式 |
其中-mno-red-zone和-fshort-wchar是最容易踩坑的两个参数。忘记-mno-red-zone,在 QEMU 里偶发死机;忘记-fshort-wchar,所有宽字符字符串都会变成乱码甚至直接崩溃。这两个参数建议直接焊死在 Makefile 里。
4.2 构建一个模拟UEFI启动盘:目录、文件与OVMF固件
QEMU 默认不带 UEFI 固件,它原本模拟的是传统 BIOS。要跑 UEFI 引导程序,需要一个开源 UEFI 固件镜像(通常叫 OVMF),这个文件在主流发行版软件源里都有。把固件路径和启动盘目录准备好之后,启动命令是这样的:
qemu-system-x86_64 \ -bios /usr/share/ovmf/OVMF.fd \ -drive file=fat:rw:./disk,format=raw \ -m 256 \ -net none \ -vga std-bios指定 UEFI 固件镜像;-drive file=fat:rw:./disk把本地目录当作 FAT 磁盘,rw表示可写;-vga std使用标准 VGA 显卡,避免部分图形模式在虚拟机上不兼容。启动后应该能看到 OVMF 的界面,然后自动找到磁盘里的BOOTX64.EFI,屏幕上出现白字蓝底的固件界面,最后打印出我们的Hello from UEFI Boot!。
如果没看到自动引导,说明固件没有把 disk 目录识别为可启动设备。常见的解决方式是先按 Esc 进 UEFI Shell,在fs0:下手动执行:
Shell> fs0: fs0:\> cd EFI\BOOT fs0:\EFI\BOOT\> BOOTX64.EFI这一步能区分两类问题:文件路径不对,还是引导程序本身崩溃。
4.3 用gdb跟调:QEMU的调试端口与断点验证
引导程序跑起来之后,怎么确认它内部执行到了哪一步?QEMU 自带 gdb 调试接口,配合编译时保留的符号信息,可以直接单步调试引导程序。
先在 QEMU 侧打开调试端口:
qemu-system-x86_64 \ -bios /usr/share/ovmf/OVMF.fd \ -drive file=fat:rw:./disk,format=raw \ -s -S-s让 QEMU 在端口 1234 监听 gdb 连接,-S让虚拟机在启动时暂停,等待调试器接入。然后再开一个终端,启动 gdb 并连接:
gdb boot.so (gdb) target remote :1234 (gdb) break efi_main (gdb) continue断点命中efi_main之后,可以继续下一步:
(gdb) print system_table->ConOut (gdb) print system_table->BootServices->AllocatePages这里有个细节:gdb 读取的是 boot.so 里的符号信息,而这些符号的加载地址和固件实际加载地址不一定一致。如果在真实机型上用 gdb 调试比较麻烦,但在 QEMU 里因为加载地址相对固定,调试体验足够用。实际操作时如果断点停不下来,先确认objcopy那一步有没有用-g保留调试信息,很多发行版的默认objcopy会丢掉符号表。
5. UEFI引导程序踩坑实录:5个「现象→原因→解决」的疑难
这一章全是我见过和亲自踩过的坑。UEFI 引导程序开发最大的特点就是:错误信息极少,死机往往无声无息。很多问题在虚拟机里不出现,一上真机就随机崩溃。下面五条按照“现象→原因→解决”的方式展开,基本覆盖了从编译到运行的高频故障。
5.1 固件找不到引导文件:黑屏进不去Boot Services
现象:QEMU 启动后直接进入 UEFI Shell 或固件设置界面,根本没有执行引导程序。在真机上表现为开机黑屏或固件报“找不到操作系统”。
原因:最常见的是目录层级不对。UEFI 固件默认只在固定路径找引导文件,通常是\EFI\BOOT\BOOTX64.EFI。有人把文件放到了\BOOTX64.EFI或者\EFI\BOOTX64.EFI,固件自然找不到。另一个原因是磁盘不是 FAT 格式,UEFI 固件只认 FAT 系列文件系统。
解决:确认目录为disk/EFI/BOOT/BOOTX64.EFI,且启动盘格式为 FAT。在 QEMU 里用fat:rw:目录模式时格式是自动处理的;如果是真实 U 盘,需要用mkfs.fat格式化分区。
5.2 编译报undefined reference:链接顺序与crt0
现象:链接阶段报undefined reference to 'efi_main'或undefined reference to 'uefi_call_wrapper'。
原因:链接顺序错了。crt0-efi-x86_64.o必须出现在所有目标文件之前,库文件-lgnuefi -lefi必须放在最后。GNU ld 是单遍扫描,顺序反了就会在符号表里找不到定义。
解决:把 Makefile 里 LDFLAGS 的写法固定成先crt0、再boot.o、最后-lgnuefi -lefi。只要顺序对了,这个报错立刻消失。
5.3 跑起来没输出:ConOut失效与GOP模式切换失败
现象:引导程序执行了,但屏幕上什么都没有,程序也没死。加一个死循环后确认程序确实进入了入口函数。
原因:分两种情况。第一种,ConOut这个文本输出协议在部分虚拟显卡配置下没有正确绑定到当前显示设备;第二种,代码里调用了gop->SetMode(gop, 0),但模式 0 在当前显卡上不可用,切换失败后固件把输出切到了一个不可见状态。
解决:先注释掉 GOP 相关代码,只留OutputString,确认文本输出正常。然后再打印gop->Mode->MaxMode看当前显卡支持几个显示模式,不要写死用模式 0,而是遍历模式找一块能用的。真机上根本没有模式 0 可用的例子很多,遍历是稳妥做法。
5.4 ExitBootServices后一调用就崩溃:引导程序的生死线
现象:引导程序在退出固件服务之前一切正常,一旦调用ExitBootServices,紧接着的任何一个内存操作或文本输出都会触发异常。
原因:ExitBootServices一旦调用成功,固件收回所有引导期服务,BootServices表里所有函数指针都变成无效地址。代码里还在用Print、AllocatePages,等于调用悬空指针。
解决:所有需要的数据和协议指针,必须在ExitBootServices之前全部准备好。退出之后想输出信息,有两个办法:直接往 GOP 的帧缓冲显存里写像素,或者用串口输出,后者在真机上更靠谱。我一般会在进入入口函数时把地址固定到某个空闲内存,退出后依然能读。这个设计习惯能救不少命。
5.5 x86_64真机随机死机,虚拟机正常:red zone与栈保护
现象:同一个引导程序,QEMU 里每次都能正常跑完,换到真实主板上就随机死机,有时十几秒,有时一进入口就卡死。
原因:这是编译参数问题。缺失-mno-red-zone时,编译器生成的代码会把数据放到栈顶下方的 128 字节红区里。UEFI 固件在特定时刻会触发中断,中断处理程序会覆盖这块区域,数据被破坏后程序就崩了。同理,-fno-stack-protector缺失时,函数入口和出口多了栈检查代码,固件环境里没有对应的钩子实现,也会崩。
解决:把第 4.1 节的 CFLAGS 完整复制,特别是-mno-red-zone -fno-stack-protector -fshort-wchar这三个。真机随机死机这类问题,排查方向先看编译参数,再看内存映射,顺序不要反。
6. 从打印Hello到交接内核:下一步就做这两件事
最小引导程序跑通之后,接下来就是真正的工作了:把内核镜像加载到内存,然后退出固件服务,交出一片干净的执行环境。这一步做不好,前面的功夫全白费。我给出一条保守可靠的路线。
6.1 先拿到内存图,再退出Boot Services
退出固件之前,必须完整读取内存描述符。这一步的顺序不能错:先GetMemoryMap拿内存图,记录下memory_map_key,再调用ExitBootServices。如果中间有任何内存分配操作导致 key 变化,退出就会失败——这是固件防止你边退边改内存图的机制。正确做法是:先分配好内核加载需要的所有内存,加载内核镜像到目标地址,最后才调用GetMemoryMap和ExitBootServices。
跳转到内核时,要把内核约定的启动信息结构体的地址放在某个寄存器里(不同内核约定不同,常见的是rsi寄存器),并关闭中断,设置好段寄存器。这一步开始,操作系统才真正接管了整台机器。
6.2 一个值得养成的调试习惯:串口日志代替Print
我个人吃过最大的亏是在退出 Boot Services 之后再也看不到Print输出,内核崩溃原因全靠猜。后来养成了一个习惯:打开串口输出,把所有关键节点用日志函数写进串口缓冲区,同一个函数在退出前后各写一份,退出后直接裸操作 MMIO 寄存器把缓冲区内容送到串口。这样即使内核交接失败,也能从串口日志里看到最后执行到哪一步。
这个习惯还有一个好处:串口不受文本模式切换的影响,GOP 切显卡也不会让日志消失,远比ConOut稳定。希望帮到你,也祝你写的引导程序第一次上真机就能一次点亮。
本文还有配套的精品资源,点击获取