简介:《一个UEFI引导程序的实现》是田宇撰写的UEFI固件开发主题PDF电子书,适合有操作系统基础、正从传统BIOS转向UEFI引导开发的读者,也适合希望深入理解固件启动流程的工程师。书中先对比UEFI与BIOS在结构组成、启动流程上的差异,由BIOS的局限与UEFI的优点引出取代原因,说明安全启动、PEI/DXE/BDS等阶段;再基于TianoCore EDK2开发环境讲解GPT分区、ESP文件系统、UEFI镜像、系统表、调用约定与协议;最后通过配置显示模式、获取物理内存、读取存储介质文件并引导64位内核,完整演示UEFI引导程序从零到可运行的实现过程。资源为单文件PDF压缩包,约2.36MB,目前已有2379人学习。配合书中源码与运行示例研读,可系统掌握编写UEFI引导程序的关键链路,为后续学习Linux内核源码和自主开发操作系统提供有力支撑。
1. UEFI引导程序实现的实战路径:从EDK2到内核引导
很多人觉得UEFI引导程序没必要自己写——能用就行,反正有grub这类现成方案。可真到了动手开发一个操作系统的时候,最先卡住的往往不是内核代码,而是固件接口。传统BIOS统治了二十多年,如今UEFI全面替换,启动流程从实模式下的16位代码变成保护模式下的PE/COFF镜像,老办法全得推倒重来。这份PDF用一个UEFI引导程序的完整实现过程,把EDK2环境搭建、UEFI Shell编译、GPT分区与ESP系统分区、以及最终引导内核的每个环节串起来。它适合正在写操作系统、或者准备从BIOS引导迁移到UEFI引导的开发者,照做一遍就能把UEFI的框架立起来。我是边读边敲命令跑通的,这篇就把踩过的坑和验证方法一起写出来。
2. 搭建EDK2开发环境:依赖安装、分支切换与首次编译
2.1 为什么选EDK2而不是自己写链接脚本
UEFI程序不是普通的Linux可执行文件。它必须打包成PE32+(PE/COFF)镜像,入口函数需要接收ImageHandle和SystemTable两个参数,链接时还要挂在UEFI引导服务、运行时服务这些符号上。用裸的gcc直接编译,光是处理重定位表和运行时服务地址就够折腾的。EDK2是目前功能最完整的UEFI开发环境,它自带一套构建系统,用dsc、inf、fdf三种描述文件管理平台、模块和固件布局,编译时自动链接UEFI库,生成可以直接放进ESP分区的.efi文件。
EDK2的层级要捋清楚:最顶层是平台描述文件dsc,它声明编译哪些模块、用哪套工具链;模块内部用inf描述源文件、依赖库和架构;固件布局由fdf控制。平时写一个UEFI应用,只需要关心inf和dsc就够了。PE/COFF头怎么填、重定位怎么处理,构建系统全干了。相比自己折腾交叉编译脚本,这条路省下大量时间,也更容易排查问题。
2.2 安装依赖并拉取源码:命令背后的作用
先做环境准备,推荐使用带图形界面的64位Linux发行版,和书中保持一致最省事。需要安装的包包括编译器、git、UUID开发库、ACPI编译器和汇编器,参考命令如下:
yum install build-essential git uuid-dev iasl nasm libuuid-devel git clone https://github.com/tianocore/edk2.git cd edk2 git checkout UDK2018这里有两个必须说清楚的地方。第一,yum仓库里其实没有叫build-essential的包,这个包名是Debian系的习惯,CentOS下应该用gcc gcc-c++ make替代,后面避坑章节会专门讲。第二,nasm是编译UEFI中32位启动代码时必需的汇编器,iasl用来处理ACPI表,缺了它们后面build会直接报错。
克隆完成后检查一下当前分支。EDK2仓库默认在master上,而这本书基于UDK2018分支开发,需要切过去。UDK2018是相对稳定的一版,工具链配置、模块路径都比较收敛,适合按图索骥。如果直接从master编译,部分模块的依赖关系已经变了,和书上的命令会对应不上。
2.3 编译BaseTools与初始化构建环境
源码就位后,下一步是编译EDK2自带的基础工具。这些工具负责解析dsc/inf、生成镜像头、处理固件卷,是整个构建链条的地基。执行:
make -C BaseTools source edksetup.shmake -C BaseTools会编译出位于BaseTools/目录下的一组二进制工具,包括生成固件卷的GenFv、生成FAT镜像的GenFfs等。source edksetup.sh则是把构建环境变量导入当前shell,它会设置EDK_TOOLS_PATH等路径变量,后续build命令依赖这些变量定位工具。如果你用的是较新的EDK2版本,还需要先执行git submodule update --init --recursive把子模块拉全,不然编译到某些包时会提示找不到头文件。
环境变量是否导入成功,可以执行echo $EDK_TOOLS_PATH确认,别急着build。很多第一次接触EDK2的人在这里会跳过source,导致后续命令报了找不到BaseTools的错误,其实只是当前终端没导入环境变量。
2.4 配置target.txt:用ShellPkg跑通第一个编译
EDK2的构建目标在Conf/target.txt里定义。第一次运行edksetup.sh会自动生成Conf目录,之后手动编辑target.txt。书里以OVMF为例展示了三个关键配置项,我把它们整理成了参数表:
| 配置项 | 取值示例 | 作用 |
|---|---|---|
| ACTIVE_PLATFORM | ShellPkg/ShellPkg.dsc | 要编译的模块组描述文件路径 |
| TOOL_CHAIN_TAG | GCC48 | 编译工具链配置,对应gcc 4.8 |
| TARGET_ARCH | X64 | 目标架构,X64对应64位x86 |
把ACTIVE_PLATFORM换成ShellPkg/ShellPkg.dsc,就能编译UEFI Shell,它是验证环境是否跑通的最短路径。配置好后直接执行build,编译产物在Build/Shell/DEBUG_GCC48/X64/Shell.efi。看到这个文件生成,说明环境链路基本通了。
这里额外说明一下OVMF。OVMF是一套开源UEFI固件,编译它可以在虚拟机里提供完整的UEFI运行环境,后面验证引导程序全靠它。如果只编译Shell不编译OVMF,虚拟机缺少UEFI固件,程序跑不起来。所以建议两个目标都编译一遍:先ShellPkg验证工具链,再OvmfPkg生成OVMF.fd,产物路径在Build/OvmfX64/DEBUG_GCC48/FV/OVMF.fd。
3. 制作UEFI启动U盘:GPT分区、ESP文件系统与虚拟机验证
3.1 GPT与ESP:UEFI引导的两个前提
UEFI引导跟BIOS引导最大的区别之一,就是对分区表格式和系统分区有硬性要求。BIOS时代从MBR里找引导扇区,逻辑块地址只有32位,最大只能寻址2^32×512字节,也就是2TB,磁盘稍微大点就束手无策。GPT把分区表挪到了LBA 2开始的扇区,用64位逻辑块地址,支持远超2TB的磁盘,并通过CRC校验保护分区表完整性。
ESP(EFI System Partition,EFI系统分区)是UEFI规范要求的一种特殊分区,通常用FAT32格式化,存放UEFI应用程序、驱动和操作系统加载器。UEFI固件启动后会按照固定路径去ESP里找引导文件,最常见的就是\EFI\BOOT\BOOTX64.EFI。也就是说,引导程序本身只是这个目录下的一个.efi文件,固件不关心磁盘上有没有引导扇区,只关心这个文件路径是否存在。
动手之前还要理解protective MBR的概念。GPT磁盘的第一个扇区保留了一个伪MBR,它的分区类型字段标为0xEE,叫protective MBR,目的是让老式分区工具不会误认磁盘为空盘。后面用fdisk查看U盘时看到的那个奇怪分区表项,就是它在作怪,不是分区没建好。
3.2 用磁盘管理工具把U盘转为GUID分区表
制作UEFI启动U盘,第一步是清空U盘的所有扇区数据,避免遗留的MBR分区表干扰后续操作。这一步我直接用磁盘管理工具完成,比如DiskGenius这类工具都支持分区表转换。选择U盘设备后,找到转换分区表类型的入口,把分区表格式改为GUID。软件会弹一个确认框,提醒转换后原磁盘分区表及分区将全部失效,确认即可。
这一步有个细节要注意:确认之后,U盘上原有的分区会全部消失,如果有数据一定提前备份。另外,U盘容量不要选得太小,ESP分区就要留20MB,还得再建一个FAT32数据分区,我建议至少准备32MB以上的空间。
转换完成后,磁盘管理工具里应该能看到U盘的磁盘布局变成了GPT风格。这时候还不需要急着拔U盘,直接在当前工具里继续创建分区。
3.3 创建ESP与FAT32双分区:参数怎么设
在GUID分区表上新建分区时,工具会提供两个关键选项:一个是"建立ESP分区",另一个是"对齐到此扇区数的整数倍"。ESP分区勾选后,工具会为它写入专用的分区类型GUID,UEFI固件靠这个GUID识别系统分区。容量我按书里的做法给了20MB,这个大小足够存放Shell.efi和后续的引导程序。对齐参数选择4096扇区,也就是2097152字节,目的是让读写性能对齐现代闪存芯片的擦除块。
接着创建第二个分区,文件系统选FAT32,容量10MB左右。到这里磁盘管理工具会显示两个独立分区:一个是ESP,一个是普通FAT32。需要留意,Windows资源管理器默认看不到ESP分区,这不是分区创建失败,而是系统出于保护目的隐藏了它。想确认ESP是否真的存在,去磁盘管理控制台看,能看到带"EFI系统分区"卷标的卷。
分区创建完毕后,先别手动格式化ESP——工具在建立ESP分区时通常已经按FAT32初始化了。有经验的开发者会在这里检查一下U盘的扇区对齐情况,如果发现分区起始LBA不是8的倍数,建议删除重建,避免后面固件读取时出现对齐警告。
3.4 在Linux下挂载U盘并部署BOOTx64.EFI
Windows访问不了ESP,但Linux没有这个限制。把U盘插到Linux机器上,假设设备节点是/dev/sdb,先用root权限确认磁盘布局:
fdisk -l /dev/sdb ls /dev/sdb*结果里有个奇怪现象:fdisk显示/dev/sdb1的起始LBA是1、结束LBA是4294967295,分区类型标为GPT,看起来像一个巨型分区占满整个磁盘。这其实是protective MBR记录表项,fdisk对GPT格式支持不完整,把这个伪MBR当成了真实分区列出来。ls /dev/sdb*的结果更可靠,实测中我看到/dev/sdb1对应ESP,/dev/sdb2对应FAT32数据分区。
确定设备节点后,把ESP挂载到/mnt,然后把之前编译好的Shell.efi拷贝进去,并重命名为UEFI规定的默认引导文件名:
mount /dev/sdb1 /mnt/ mkdir -p /mnt/EFI/BOOT cp /root/Desktop/edk2/Build/Shell/DEBUG_GCC48/X64/Shell.efi /mnt/EFI/BOOT/BOOTx64.EFI sync umount /mnt/mkdir -p /mnt/EFI/BOOT这步容易漏。UEFI可移动介质引导的标准路径是\EFI\BOOT\BOOTX64.EFI,固件只会去这个固定位置找文件,目录层级不能错,而且UEFI对大小写敏感。sync是强制把缓存写入磁盘,拔U盘前一定要执行,否则文件可能还在页缓存里没落盘。拷贝后建议核对一下文件大小和源文件一致,防止传输中断。
3.5 虚拟机固件切换到UEFI并验证引导
U盘准备好了,还要有一个支持UEFI引导的运行环境。常用的虚拟机软件默认都按BIOS引导处理,需要手动把固件类型改成UEFI。在创建虚拟机时选择64位操作系统环境,然后在虚拟机的硬件配置里找固件类型选项,改为UEFI。这一步不做,虚拟机还是会走传统BIOS路径,U盘插上去毫无反应。
接着给虚拟机添加USB控制器,并把物理U盘连接上去。USB控制器的兼容性按虚拟机默认值即可,连接状态选开机时连接。如果虚拟机里还挂着网络适配器,UEFI固件在引导阶段可能因为尝试PXE网络引导而卡很久,实践中最快的做法是先卸下网络适配器,等引导流程跑通再加回来。
启动虚拟机,如果一切正常,屏幕上会进入UEFI Shell界面,或者在固件引导菜单里看到从U盘启动的选项。进到Shell后用map命令确认文件系统映射,能看到U盘上的ESP分区被映射为fs0之类,敲fs0:再ls,就能看到EFI\BOOT\BOOTX64.EFI躺在那里。到这一步,环境链路和U盘启动链路都通了,后面写任何UEFI程序,只要替换这个efi文件即可。
4. 拆解UEFI程序结构:系统表、PE/COFF镜像与Hello World
4.1 UEFI系统表:程序入口能拿到什么
UEFI应用程序的入口函数不像普通C程序的main那样只有argc/argv,它拿到的是一个指向EFI_SYSTEM_TABLE(UEFI系统表)的指针。系统表是UEFI固件暴露给应用程序的窗口,里面包含控制台输出、引导服务、运行时服务和一组配置表。引导服务(Boot Services)在操作系统启动前可用,提供内存管理、协议查找、事件创建等功能;运行时服务(Runtime Services)在操作系统接管后仍然可用,负责时钟、复位、NVRAM变量这类底层资源。
写UEFI程序时最常见的操作是拿系统表里的ConOut做屏幕输出,一个标准入口长这样:
#include <Uefi.h> EFI_STATUS EFIAPI UefiMain ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { SystemTable->ConOut->OutputString (SystemTable->ConOut, L"Hello UEFI!\r\n"); return EFI_SUCCESS; }入口函数有两个参数,第一个是ImageHandle,标识当前加载的镜像句柄;第二个就是系统表指针。EFIAPI是UEFI规定的调用约定宏,在x64下展开为Windows ABI,参数传递用rcx/rdx/r8/r9而不是Linux默认的System V风格,写UEFI代码务必带上这个宏,否则在64位环境下函数调用约定不匹配,轻则参数错乱,重则栈损坏。OutputString是ConOut协议提供的方法,第一个参数是协议实例自身,第二个参数是宽字符串,UEFI内部统一用UTF-16编码,所以字符串字面量必须加L前缀。
返回值EFI_SUCCESS表示程序正常结束。需要注意的是,UEFI应用程序返回后,固件会把它从内存中卸载,如果想在屏幕上看到文字不被立刻清掉,可以加一个死循环等待,这也解释了为什么很多入门示例里会有个while(1),不过加之前要想清楚,加了死循环程序就退不回去了。
4.2 PE/COFF镜像与EFIAPI调用约定
UEFI可执行文件的格式是PE32+,也就是PE/COFF,扩展名为.efi。这意味着UEFI程序和Windows下的exe/dll在文件格式上是同一族,只是入口、子系统标记有些差异。UEFI规范要求镜像里设一个特定的子系统值,构建工具会把它设置为EFI application,固件加载时根据这个值知道该用引导服务去装载。
调用约定方面,UEFI规定了EFIAPI,在x64平台上展开为Microsoft x64调用约定,这和Linux下的默认约定不同,混用会出现参数被放到错误寄存器的问题。一个典型症状是:函数在32位模拟环境下跑得好好的,切到64位就随机崩溃,往往就是忘了在函数声明前加EFIAPI。EDK2提供的库函数全部按EFIAPI声明,自己写的自定义函数如果被协议或回调引用,也必须保持同一约定。
与镜像格式相关的还有基地址和重定位。UEFI加载镜像时会把PE/COFF按节复制到内存,然后处理重定位表。如果编译时关闭了重定位信息的生成,固件只能把镜像加载到预设基址,一旦地址被占用,加载就会失败。EDK2的构建系统默认保留.reloc节,手工用其他工具链编译时要确认这一点,不然做出来的efi在部分固件上会加载不了。
4.3 编译Hello World并放入ESP分区
以EDK2的方式组织Hello World,需要三个文件:源文件、模块描述文件inf、以及要把它挂进去的dsc。先看inf文件:
[Defines] INF_VERSION = 0x00010006 BASE_NAME = HelloWorld FILE_GUID = 57F4F6E0-9D28-4A1E-9B3E-2A1D4E8F7C21 MODULE_TYPE = UEFI_APPLICATION ENTRY_POINT = UefiMain [Sources] HelloWorld.c [Packages] MdePkg/MdePkg.dec [LibraryClasses] UefiApplicationEntryPoint UefiLibFILE_GUID是模块的唯一标识,每个模块固定一个GUID,后期定位就靠它;MODULE_TYPE声明这是一个UEFI应用程序,构建系统会按应用的方式链接;LibraryClasses列的UefiApplicationEntryPoint提供标准入口封装,UefiLib提供常用的协议包装函数。有了inf之后,把它加入某个应用的dsc中,比如建一个HelloPkg,然后重新build,产物就是HelloWorld.efi。
编译好之后,把这个efi文件替换第3章部署的BOOTx64.EFI,或者放到ESP分区的EFI\TOOLS目录下通过Shell手动执行。Shell里切到对应文件系统,执行HelloWorld.efi,屏幕上打印出Hello UEFI!,说明从编写、编译到部署的完整链路已经打通。后面写引导程序时,UEFI系统表里的BootServices会成为主要工作对象,GetMemoryMap、ExitBootServices、LocateHandleBuffer这些服务都会在引导流程里频频出场。
5. UEFI引导开发避坑指南:环境、分区、编译的排查实录
5.1 依赖包与工具链
现象:在CentOS 7上直接执行书中给出的yum install build-essential,系统提示没有这个软件包,命令直接失败。
原因:build-essential是Debian/Ubuntu系Linux发行版的概念,对应编译工具链全家桶,CentOS的yum源里没有这个包名。书里的命令直接搬过来会翻车。
解决:换用CentOS对应的包名,执行yum install gcc gcc-c++ make nasm iasl libuuid-devel,其中gcc提供C编译器、nasm提供汇编器、iasl提供ACPI编译器、libuuid-devel提供UUID开发头文件。装完后可以执行which nasm确认版本,nasm没装的话,后面编译OVMF会报找不到nasm的错误,这个报错信息很直接。
现象:执行source edksetup.sh后,build提示找不到BaseTools,或者提示EDK_TOOLS_PATH未定义。
原因:常见原因是编译BaseTools之前没有先执行make -C BaseTools,又或者source命令在子shell中执行,环境变量没有导出到当前shell。每次打开新终端,环境变量都会丢。
解决:先确认make -C BaseTools执行无报错,然后必须在同一个终端里source edksetup.sh。每打开新终端都要重新source一次,或者写进bashrc,避免每次手动导入。用echo $EDK_TOOLS_PATH验证变量是否生效,确认有输出再继续。
5.2 分区管理与识别
现象:U盘转换为GUID分区表后,在Windows资源管理器里看不到任何分区,以为转换失败,反复重新分区。
原因:Windows默认不给ESP分区分配盘符,资源管理器里自然看不到。这不是分区表坏了,而是系统有意隐藏,防止用户误删引导文件。
解决:打开磁盘管理控制台,能看到带“EFI系统分区”标签的隐藏卷就说明分区正常。Linux下直接执行ls /dev/sdb*更直观,看到sdb1、sdb2两个设备节点,说明ESP和FAT32分区都在,直接挂载干活就行。
现象:fdisk -l /dev/sdb显示/dev/sdb1的起始LBA为1、结束LBA为4294967295,类型为GPT,看起来像是一个巨型分区占满了整个磁盘。
原因:这是GPT磁盘的protective MBR在起作用。fdisk对GPT分区表的支持不完整,把这个伪MBR条目当成真实分区列了出来,而真实分区需要在内核层面通过/dev节点访问。
解决:不要用fdisk的输出判断分区是否建好,改用ls /dev/sdb*或parted /dev/sdb print查看真实分区布局。只要ls能看到sdb1、sdb2,分区就没有问题,可以直接挂载。一开始我盯着fdisk的输出纠结了半天,后来才发现是工具兼容性的问题。
5.3 编译与部署
现象:build命令报错,提示找不到GCC48工具链,或者提示GCC版本与TOOL_CHAIN_TAG不匹配。
原因:EDK2内置的工具链配置文件里,GCC48对应gcc 4.8时代编译参数集,如果系统gcc版本过新或过旧,工具链定义里某些选项会不兼容,直接导致工具链初始化失败。
解决:查看当前gcc版本,gcc --version,如果版本明显高于4.8,把target.txt里的TOOL_CHAIN_TAG改成GCC49或GCC5,具体看EDK2版本支持哪些配置。另外,首次build前还要记得执行git submodule update --init --recursive,否则编译到个别包会提示缺少头文件,这个问题和工具链版本无关,纯粹是依赖没拉全。
6. 引导程序的最后一步:ExitBootServices与内核交接
引导程序把内核文件读进内存之后,还有一件绕不开的事:调用ExitBootServices,把控制权从UEFI固件交还给内核。但这一步的前提是先拿到一张内存映射表,而GetMemoryMap这个服务至少要调用两次,直接原因是固件在两次调用之间可能创建新事件,MapKey会变化,内核拿到的映射表必须是最终版本,ExitBootServices的入参MapKey必须和最后一次GetMemoryMap返回的一致。正确顺序是这样:
EFI_STATUS EFIAPI GetMemoryMapAndExit ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { UINTN MemoryMapSize = 0; EFI_MEMORY_DESCRIPTOR *MemoryMap = NULL; UINTN MapKey = 0, DescriptorSize = 0; UINT32 DescriptorVersion = 0; // 第一次调用只为了拿缓冲区大小 SystemTable->BootServices->GetMemoryMap ( &MemoryMapSize, NULL, &MapKey, &DescriptorSize, &DescriptorVersion); // 按返回值分配内存,再调用一次拿真正的映射 MemoryMap = AllocateZeroPool (MemoryMapSize); SystemTable->BootServices->GetMemoryMap ( &MemoryMapSize, MemoryMap, &MapKey, &DescriptorSize, &DescriptorVersion); // 用同一个MapKey退出引导服务 SystemTable->BootServices->ExitBootServices (ImageHandle, MapKey); return EFI_SUCCESS; }第一次调用传入NULL缓冲区,让服务返回所需大小;分配好缓冲区后再调一次,拿到完整的EFI_MEMORY_DESCRIPTOR数组。这里有个很容易踩的坑:ExitBootServices必须传入和第二次GetMemoryMap相同的MapKey,如果固件在两次调用之间创建了新事件,MapKey会变,调用会返回EFI_INVALID_PARAMETER,这时不能硬退,需要重新GetMemoryMap再退一次。退出引导服务之后,内存管理、协议查找这些引导服务全部失效,内核只能靠自己了,所以内存映射数据要在退出前保存到内核能访问的位置。我自己的习惯是先把内核需要的参数写到某个固定地址,再用汇编跳转,把内核入口、内存映射基址、描述符大小通过结构体指针传过去。从那以后,我每次调ExitBootServices都强制走一遍先GetMemoryMap再比对MapKey的流程,这个顺序不能换,换了回头调试内核内存异常就得多花半天。希望帮到你。
本文还有配套的精品资源,点击获取