☰
UEFI引导程序实战:从EDK2环境搭建到内核加载
2026/10/24 1:03:17 网站建设 项目流程

简介:《一个UEFI引导程序的实现》是田宇撰写的一本PDF电子书,作为《一个64位操作系统的设计与实现》的补充,系统讲解UEFI引导程序的开发方法。全书先对比BIOS与UEFI的结构组成和启动流程,解释UEFI取代BIOS的必然性;随后基于TianoCore EDK2环境,依次介绍GPT磁盘布局、ESP文件系统、Hello World应用、UEFI镜像与系统表、调用约定与协议等基础内容,并在后续章节深入展示显示模式配置、可用物理内存获取、存储介质文件读取、64位平台处理以及系统内核的引导启动实现。资源为单个PDF文件,大小仅2.36MB,内容紧凑且实例完整,适合操作系统初学者、软件工程师及Linux内核学习者参考,目前已有2379人学习,是一份理解UEFI引导流程与固件开发的实用资料。

1. UEFI引导程序项目解析:为什么你该自己写一个引导程序

折腾过操作系统的人大概率碰过这类事:拿老工具做的引导盘插到新机器上,BIOS模式还能走,UEFI模式直接黑屏,连个报错都不给。原因很简单——UEFI根本不读传统引导扇区,它只认EFI系统分区里的.efi文件。这本书讲的就是:如何用TianoCore EDK2从零写一个UEFI引导程序,完成显示模式配置、物理内存探测、从ESP分区读取内核并引导启动的完整链路。适合两类人:一类想弄明白UEFI启动里SEC、PEI、DXE这些阶段在干什么;另一类手头有内核但缺引导程序、没法在自己的机器上跑起来的人。

2. UEFI与BIOS:启动流程、架构差异与EDK2环境搭建

2.1 BIOS与UEFI的结构组成:实模式中断表与协议表

BIOS是一组固化在主板ROM里的代码,核心能力是把硬件操作封装成中断服务。上电后CPU跑在16位实模式,最大只能寻址1MB空间,其中0x0C0000到0x0FFFFF这段留给了BIOS自己。应用层程序要操作硬盘、键盘、显示器,全靠INT 10h、INT 13h这类中断入口。这种设计在PC兼容机时代是合理的,因为BIOS统一了硬件差异,程序只需要调中断,不需要关心硬件型号。

UEFI的接口形式完全不同。它不再靠中断,而是用一张张数据表来表达能力,最核心的是EFI系统表(EFI_SYSTEM_TABLE),里面挂着引导服务(Boot Services)和运行时服务(Runtime Services)。引导服务在操作系统加载阶段可用,包含内存分配、协议查找、定时器、ExitBootServices这类关键接口;运行时服务在操作系统接管后仍然可用,比如读写NVRAM变量、复位系统。设备访问则通过句柄(Handle)和协议(Protocol)抽象出来——每个设备对应一个句柄,设备能力挂在协议上,程序先按GUID找协议,再调用协议里的函数。这套抽象让驱动可以独立加载,也方便扩展。

从软件工程的角度看,BIOS更像一个把所有驱动静态链接在一起的固件,UEFI则像一套带服务注册表、支持动态发现的微内核框架。这个设计差异直接影响了后面所有事情的走向——包括为什么UEFI能动态加载Shell、驱动和应用,而BIOS做不到。

2.2 启动流程对比:POST八步与UEFI七个阶段

BIOS的启动流程是线性串行的:从POST加电自检开始,检测关键设备,初始化显示设备,检测CPU内存和标准设备(硬盘、光驱、串口、并口),为即插即用设备分配中断号、I/O端口和DMA通道,配置更新到CMOS,然后按启动顺序读引导扇区,把控制权交给引导程序。整个过程是“检测一遍,再引导一次”,大量时间花在遍历所有设备上。

UEFI把启动过程拆成了七个阶段,环环相扣:

  1. SEC(Security,验证阶段):CPU执行第一条指令,此时内存还没初始化,只能用缓存或物理内存搭一个临时环境,顺带做安全验证。
  2. PEI(Pre-EFI Initialization Environment,EFI环境预初始化阶段):初始化CPU、芯片组和内存,确定引导路径,为后续DXE阶段准备内存。
  3. DXE(Driver Execution Environment,驱动运行环境阶段):UEFI最重要的阶段,大量驱动在这里被加载和连接,产生各种协议。
  4. BDS(Boot Device Select,引导设备选择阶段):初始化控制台设备,读取环境变量里的启动项,尝试引导操作系统加载器。
  5. TSL(Transient System Load,临时系统运行阶段):进入UEFI Shell之类的临时环境。
  6. RT(RunTime,运行时阶段):操作系统调用ExitBootServices之后进入,此时引导服务销毁,只有运行时服务和系统表继续可用。
  7. AL(After Life,后世阶段):系统复位、掉电或触发SMI事件时进入。

初学者最容易忽略的一点是:UEFI不是“一个程序”,而是从SEC到AL的一整条流水线。写引导程序时,自己写的.efi文件是在BDS阶段被选中的,但此刻内存、驱动、协议都已经由固件准备好了,你的程序只是在搭好的台子上唱戏。

2.3 UEFI取代BIOS:性能、安全性与2TB硬盘墙

BIOS的瓶颈非常具体。性能上,外部设备操作依赖中断,CPU在等待时被浪费,而且没有异步机制;开发效率上,BIOS代码大量用汇编写,和硬件耦合极深;扩展性上,16位代码必须固定在0x0C0000到0x0DFFFF区间,新增硬件功能只能靠静态链接往里塞,没有动态加载驱动的方案。最直观的痛点是寻址:BIOS用32位LBA寻址磁盘,最大只能支持2^32×512B,也就是2TB。现在随便一块NVMe固态都轻松超过这个容量,BIOS连盘都认不全,更谈不上引导。

UEFI的对策是:用C语言代替汇编,用协议抽象代替中断,用事件驱动异步模型代替同步等待。安全启动(Secure Boot)打开后,固件在执行驱动和应用前会校验PE/COFF格式镜像里的数字签名,这对防早启动劫持类攻击意义不小。虽然安全启动也给双系统用户添了不少麻烦,但它确实是UEFI相对BIOS在安全模型上的核心突破。这些取舍逻辑理解了,后面在看EDK2的工程结构时就不会觉得云里雾里。

2.4 搭建TianoCore EDK2开发环境:从克隆到OVMF编译

EDK2是Intel开源的UEFI实现,它不光能编译UEFI应用程序,还能编译整个固件。以CentOS 7环境为例,完整链路如下:

# 安装依赖包,不同发行版包名有差异,按错误提示补包即可 yum install gcc gcc-c++ git iasl nasm # 从官方仓库克隆EDK2源码,切到UDK2018分支 git clone https://github.com/tianocore/edk2.git cd edk2/ git checkout UDK2018 # 编译基础工具 make -C BaseTools/ # 设置编译环境 . ./edksetup.sh # 编辑Conf/target.txt指定要编的模块组 vi Conf/target.txt build
# Conf/target.txt 核心三行配置 ACTIVE_PLATFORM = OvmfPkg/OvmfPkgX64.dsc TOOL_CHAIN_TAG = GCC48 TARGET_ARCH = X64

ACTIVE_PLATFORM指定了要编译的模块组描述文件,OvmfPkgX64.dsc是OVMF(开放虚拟机固件)在64位平台上的配置;TOOL_CHAIN_TAG告诉构建系统用哪套编译器,CentOS 7默认GCC 4.8所以填GCC48;TARGET_ARCH决定目标处理器体系,这里是X64。

编译OVMF之前要先更新子模块,否则会因为缺少依赖目录直接失败:

git submodule update --init --recursive

执行build命令后,固件会输出在Build/OvmfX64/DEBUG_GCC48/FV/OVMF.fd。这个.fd文件就是一份完整的UEFI固件镜像,后面可以把它直接配置到虚拟机的NVRAM里用于调试。整套搭建流程里最容易翻车的其实是子模块和工具链版本,换一个GCC大版本,TOOL_CHAIN_TAG就要跟着换,不然编译到一半就报一堆ABI不匹配的错误。

3. 核心模块拆解:从U盘GPT分区到内核加载的完整链路

3.1 制作可引导U盘:GPT分区表、ESP与FAT32

UEFI要求可引导块设备必须带一个ESP(EFI System Partition)分区。ESP分区是用来存放UEFI应用、驱动和操作系统加载器的,固件的引导管理器会按照固定路径去里面找.efi文件。另一个关键设计是UEFI不要求修改引导扇区,因此同一块盘既能支持UEFI引导,也能留着传统BIOS的引导方式。

用DiskGenius做U盘引导盘时,实际步骤是:先清空U盘扇区数据,把分区表类型改成GUID格式;然后新建ESP分区,分配20MB,勾选“对齐到此扇区数的整数倍”,按4096扇区(2097152字节)对齐;随后再建一个10MB的FAT32分区作为普通数据区。注意GPT磁盘没有扩展分区和逻辑分区,所有分区都是主分区。

为什么Windows下看不到ESP分区?因为Windows系统限制了对ESP磁盘分区的访问,资源管理器里能看见分区存在,但就是点不进去。这时候要把U盘插到Linux机器上挂载操作:

# 查看U盘设备节点,确认是哪个设备 fdisk -l /dev/sdb ls /dev/sdb* # 挂载ESP分区,把编译好的Shell.efi拷贝成默认引导文件 mount /dev/sdb1 /mnt/ mkdir /mnt/EFI/BOOT cp /root/Desktop/edk2/Build/Shell/DEBUG_GCC48/X64/Shell.efi /mnt/EFI/BOOT/BOOTx64.EFI sync umount /mnt/

固件的默认引导路径约定是\EFI\BOOT\BOOTx64.EFI(64位平台),所以拷贝时必须改名。BOOTx64.EFI这个名字是约定,不是随便起的。

这里还有个细节值得注意:执行fdisk -l /dev/sdb时,返回的/dev/sdb1其实是protective MBR扇区里的GPT伪分区记录,不代表真实的ESP分区。fdisk命令对GUID分区表兼容性一般,看到起始LBA和结束LBA异常地跨到4亿多就不要用来分析分区了,直接用ls /dev/sdb*查看内核识别出的实际分区节点。

3.2 在VMware虚拟机里跑UEFI固件

VMware默认用BIOS引导,需要手动改成UEFI。创建虚拟机时选64位操作系统环境,然后进入虚拟机设置-选项-高级-固件类型,把固件改成UEFI。如果虚拟机环境里没有USB控制器,要在硬件界面手动添加USB控制器,并设置连接状态。

这一步卡住的人不在少数:固件类型没改对,U盘插上去完全不引导;固件类型改对了,USB控制器没加,虚拟机认不到U盘。另一个实践技巧是,如果UEFI引导期间网络适配器初始化耗时太长,直接卸下网络适配器,因为UEFI固件会尝试网络引导流程。拔掉网卡后从U盘引导的速度肉眼可见地变快。

3.3 Hello World:一个最小UEFI应用程序的完整实现

EDK2里的UEFI应用入口不是main函数,而是UefiMain。它是个标准的EFI_APPLICATION入口点,拿到两个参数后就能访问固件提供的所有服务:

#include <Uefi.h> #include <Library/UefiBootServicesTableLib.h> #include <Library/UefiLib.h> EFI_STATUS EFIAPI UefiMain ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { SystemTable->ConOut->OutputString( SystemTable->ConOut, L"Hello from UEFI Boot Loader!\r\n" ); return EFI_SUCCESS; }

逻辑说明:UefiMain是EDK2应用的标准入口,ImageHandle是当前镜像自身的句柄,SystemTable是UEFI系统表入口。这里通过SystemTable->ConOut拿到简单文本输出协议,再调用OutputString往屏幕上输出一行Unicode字符串。注意UEFI控制台协议只认CRLF换行,所以字符串结尾要写\r\n,只写\n在真机上可能显示不出换行效果。

参数说明:ImageHandle在后面读取文件时会用到——查自己镜像所在的文件系统协议需要它作参数;SystemTable则更重要,引导服务和运行时服务的函数指针全在SystemTable里,后面拿内存映射、定位图形协议、调用ExitBootServices都要顺着这张表往下钻。

3.4 引导程序核心模块:显示模式、物理内存与文件读取

从这一节开始就是正经的引导程序逻辑了。先把图形设备的显示模式配好,让屏幕输出恢复正常分辨率;然后拿物理内存布局,为内核准备内存管理数据;最后从ESP分区里把内核文件读取到内存,准备跳转。

获取图形输出协议并选择最佳显示模式的常用做法是:

#include <Protocol/GraphicsOutput.h> EFI_GRAPHICS_OUTPUT_PROTOCOL *gGop; EFI_STATUS Status = gBS->LocateProtocol( &gEfiGraphicsOutputProtocolGuid, NULL, (VOID **)&gGop ); if (EFI_ERROR(Status)) { return Status; } UINT32 BestMode = 0; UINTN MaxPixels = 0; for (UINT32 Mode = 0; Mode < gGop->Mode->MaxMode; Mode++) { EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *Info; UINTN SizeOfInfo; Status = gGop->QueryMode(gGop, Mode, &SizeOfInfo, &Info); if (EFI_ERROR(Status)) { continue; } UINTN Pixels = Info->HorizontalResolution * Info->VerticalResolution; if (Pixels > MaxPixels) { MaxPixels = Pixels; BestMode = Mode; } } Status = gGop->SetMode(gGop, BestMode);

逻辑说明:先用LocateProtocol按GUID找图形输出协议,然后遍历gGop->Mode->MaxMode里的所有模式,用QueryMode逐个查询分辨率,挑像素总数最大的那个,最后SetMode切换过去。

参数说明:gEfiGraphicsOutputProtocolGuid是GOP协议的全局唯一标识符;QueryMode返回的Info里HorizontalResolution和VerticalResolution分别是宽高像素数;SetMode第二个参数是模式号,不是分辨率数值。

物理内存探测同样要走UEFI的标准两段式调用:

#include <Library/UefiBootServicesTableLib.h> #include <Library/MemoryAllocationLib.h> EFI_MEMORY_DESCRIPTOR *MemoryMap = NULL; UINTN MemoryMapSize = 0; UINTN MapKey = 0; UINTN DescriptorSize = 0; UINT32 DescriptorVersion = 0; EFI_STATUS Status = gBS->GetMemoryMap( &MemoryMapSize, MemoryMap, &MapKey, &DescriptorSize, &DescriptorVersion ); if (Status == EFI_BUFFER_TOO_SMALL) { MemoryMap = AllocatePool(MemoryMapSize); Status = gBS->GetMemoryMap( &MemoryMapSize, MemoryMap, &MapKey, &DescriptorSize, &DescriptorVersion ); EFI_MEMORY_DESCRIPTOR *Desc = MemoryMap; for (UINTN i = 0; i < MemoryMapSize / DescriptorSize; i++) { // Desc->Type, Desc->PhysicalStart, Desc->NumberOfPages Desc = (EFI_MEMORY_DESCRIPTOR *)((UINT8 *)Desc + DescriptorSize); } }

逻辑说明:GetMemoryMap第一次调用时传空的缓冲区,会返回EFI_BUFFER_TOO_SMALL并告诉你需要多大空间;分配好后第二次调用才能真正拿到完整映射。遍历内存描述符时变量名里那个小坑就是Buffer不是sizeof(EFI_MEMORY_DESCRIPTOR)。

参数说明:MapKey是当前内存映射的标识,后面调用ExitBootServices时还要原样传进去;DescriptorSize是每个描述符的实际长度,因为固件可能对描述符做对齐填充,遍历时指针要按它步进。

从存储介质读取文件,要先拿到自己镜像所在文件系统的协议:

#include <Protocol/SimpleFileSystem.h> EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *FileSystem; Status = gBS->HandleProtocol( gImageHandle, &gEfiSimpleFileSystemProtocolGuid, (VOID **)&FileSystem ); EFI_FILE_PROTOCOL *RootDir; Status = FileSystem->OpenVolume(FileSystem, &RootDir); EFI_FILE_PROTOCOL *KernelFile; Status = RootDir->Open( RootDir, &KernelFile, L"\\EFI\\BOOT\\kernel.elf", EFI_FILE_MODE_READ, 0 ); UINT8 *Buffer = AllocatePool(1024 * 1024); UINTN ReadSize = 1024 * 1024; Status = KernelFile->Read(KernelFile, &ReadSize, Buffer);

逻辑说明:通过gImageHandle找到当前应用所在的文件系统,OpenVolume拿到根目录,再按绝对路径打开内核文件。路径里的分隔符是反斜杠,而且字符串前要加L前缀,UEFI协议里的字符串全是UTF-16。

参数说明:Open的第五个参数0是文件属性,这里是只读打开所以不设属性;Read调用时ReadSize既是输入也是输出,调用前传入缓冲区大小,调用后就变成实际读到的字节数。

引导程序的收尾动作是调用ExitBootServices,把固件的引导服务关掉,把设备控制权完整交给内核。这一步一旦做了,就不能再回头调用GetMemoryMap、LocateProtocol这些引导服务了。所以内核需要的所有信息——内存映射、图形缓冲区地址、帧缓冲尺寸——必须在ExitBootServices之前全部拿到并且拷贝到自己的数据结构里。

4. 高频踩坑排查:UEFI引导开发中五个典型问题与解决

4.1 fdisk只显示一个分区,且容量大到离谱

现象:U盘已经转成GPT格式并创建了ESP分区和FAT32分区,但执行fdisk -l /dev/sdb时只看到一个/dev/sdb1,起始LBA是1,结束LBA是4294967295,容量飙到2TB以上。

原因:这个/dev/sdb1不是真实分区,而是GPT磁盘开头的protective MBR伪分区项。fdisk命令对GUID分区表的兼容性有限,它把GPT的MBR保护扇区解析成了一个“分区”,实际分区表信息它没有正确读取。

解决:不要用fdisk分析GPT盘,直接用ls /dev/sdb*看内核识别出的设备节点,真实分区会以sdb1、sdb2的形式独立存在。想确认具体类型就用parted或者gdisk。

4.2 Windows下ESP分区能看到但无法访问

现象:U盘插入Windows机器,磁盘管理里能看到ESP分区,但资源管理器里不显示盘符,也无法通过命令行挂载。

原因:Windows操作系统默认屏蔽了ESP分区的访问,原因是ESP分区不属于常规数据分区,Microsoft故意不向普通用户暴露它,防止误操作破坏引导文件。

解决:把U盘插到Linux机器上操作。Linux内核没有这个限制,mount /dev/sdb1 /mnt/之后就能正常读写EFI目录。

4.3 UEFI引导过程卡在网络适配器初始化

现象:虚拟机从U盘引导UEFI Shell时,启动过程在网卡初始化阶段卡了十几秒甚至更久,每次都像死机一样。

原因:UEFI固件在BDS阶段会按环境变量加载驱动,未连接的网络适配器会触发固件的网络引导流程。固件会尝试获取IP、探测启动镜像服务器,这套超时机制在虚拟网络环境下被放大了。

解决:直接把虚拟机的网络适配器卸掉,或断开虚拟网络连接。在设置里移除网卡后,引导速度会有肉眼可见的提升。

4.4 汇编代码大小写混用导致编译失败

现象:从Intel白皮书里抄了一段汇编,指令和寄存器都是大写的,放进项目里编译直接报错。

原因:相关编译器对汇编指令书写格式有要求,源码里的汇编代码必须使用小写字母。Intel官方文档是大写描述指令,和编译器要求不匹配,加上部分编译器还有前后缀修饰规则,直接抄白皮书代码就会翻车。

解决:统一把汇编代码写成小写,指令助记符、寄存器名全部小写。区分代码和文档的方法是:代码清单里的小写汇编是可执行代码,文档里的大写汇编是描述性写法。

4.5 虚拟机固件类型没改,U盘引导永远失败

现象:U盘做成GPT+ESP格式,Shell.efi也放到了\EFI\BOOT\BOOTx64.EFI,虚拟机开机却直接进入系统或黑屏,没有任何UEFI引导迹象。

原因:VMware虚拟机默认固件类型是传统BIOS,传统BIOS不识别GPT和ESP分区,也不会去扫描\EFI\BOOT路径下的.efi文件。

解决:虚拟机设置-选项-高级-固件类型改成UEFI,重启后重新引导。如果这里已经是UEFI但引导还是失败,检查USB控制器是否存在,U盘是否真正连接到了虚拟机。

5. 二次开发改造:把引导程序扩展成你自己的Loader

引导程序跑通之后,改造空间非常大,可以从三个方向入手。

第一个方向是改造显示子系统。现在的代码只会挑最大分辨率,实际项目里更应该先读EDID或预设配置。常见做法是解析UEFI的HII数据库,或者直接在代码里维护一张分辨率黑白名单,再通过SetMode逐个试验。这样能解决“最大分辨率不一定最适合内核FrameBuffer”的问题。

第二个方向是扩展启动菜单。UEFI的引导管理器本身支持多个启动项,但自己写的引导程序可以绕过固件菜单,直接读取ESP分区里的目录结构,动态扫描\EFI\BOOT下的所有.efi文件,用GOP画出图形菜单,通过显示协议处理按键选择。这比依赖固件启动项更可控,也更接近商业操作系统的引导体验。

第三个方向是内核加载参数的传递。拿到内存映射和内核文件缓冲区之后,还要把内核需要的启动参数组织成固定格式:内核入口地址、栈指针、设备树地址或ACPI根表指针。这些信息必须在ExitBootServices之前准备好。我的习惯是专门定义一个全局结构体,把所有引导收集的信息集中存放,ExitBootServices之后再只读这份结构体,不碰任何UEFI引导服务。

验证引导程序改动的标准流程是:先在OVMF虚拟机里跑一遍Hello World,确认编译链没问题;再做一次U盘引导,确认固件路径和文件系统没问题;最后才把显示和内存逻辑加进去,每动一个模块就回到Shell里打印关键数值确认状态。所有改动跑通后再回退一次,看看是不是真的全部通过,避免侥幸。

从那以后,我每次改UEFI引导程序都强制走一遍这个流程:先编译Shell确认环境,再最小化改功能,最后才碰显示和内存。整个过程里最值钱的经验是——UEFI开发大部分时间不是在写代码,而是在和规范和固件行为较劲。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询