☰
UEFI引导程序实现:从BIOS到内核交权的完整解析
2026/10/23 5:08:22 网站建设 项目流程

简介:《一个UEFI引导程序的实现》是一份PDF格式电子书,由田宇撰写,作为《一个64位操作系统的设计与实现》的补充内容,面向操作系统开发初学者、软件工程师及业余爱好者。资源围绕UEFI引导程序从零到一的实现展开,先对比UEFI与BIOS的结构组成和启动流程,再讲解搭建TianoCore EDK2开发环境、UEFI Shell编译执行、GPT磁盘布局与ESP文件系统等基础内容,进而逐步实现显示模式配置、物理内存获取、存储介质文件读取、64位处理器平台适配以及系统内核引导启动。资源包共1个文件,为PDF格式,大小约2.36MB,便于读者在电脑、平板等设备上对照阅读。目前已有2379人学习下载。通过实例与迭代式说明,读者可理解UEFI镜像、系统表、调用约定与协议等关键概念,并为后续阅读Linux内核源码、动手构建支持UEFI引导的操作系统打下扎实基础。

1. UEFI引导程序:为什么操作系统启动这件事,越来越不像是一次“开机”

要说清楚“一个UEFI引导程序的实现”,得先接受一个反直觉的事实:引导程序跑起来的瞬间,内存还没被完整初始化,常规 C 运行时根本不可用。它要在处理器缓存里临时搭起一块可写空间撑过最初几十条指令,等固件把内存控制器配好,再把自己的运行环境一步步迁移过去。更反直觉的是,当它把所有硬件准备好,又必须主动销毁自己赖以生存的引导服务,把机器还原成“只有内核说了算”的状态。这套“先借后还”的机制,正是 UEFI 与 BIOS 最本质的区别,也是打开操作系统启动这个黑匣子的钥匙。

2. BIOS与UEFI的鸿沟:从实模式中断到保护模式服务,启动契约换了一套写法

很多人以为 UEFI 不过是“界面好看一点的 BIOS”,但实际上两者暴露给操作系统的根本不是同一种东西。BIOS 向软件提供的是“中断服务”——你想读磁盘,发一个 INT 13H 就行;而 UEFI 向软件提供的是“数据表和协议”——你想读文件,得先找到句柄,打开协议,再调用里面的函数指针。这个差别不是形式上的,它决定了引导程序怎么写、能写多复杂、能访问多大的资源。

2.1 实模式与保护模式:CPU状态本身就是最大的分水岭

系统上电那一刻,CPU 运行在实模式:数据位宽 16 位,最大寻址范围只有 0~1MB。物理地址 0x0C0000~0x0FFFFF 被保留给 BIOS 使用,CPU 的第一跳是物理地址 0x0FFFF0,那里通常是一条跳转指令,让它进入真正的 BIOS 入口。这意味着 BIOS 时代的所有引导代码,都活在 1MB 的寻址空间里,解析 32 位 LBA 地址都要自己动手拼接。

UEFI 固件启动完毕后,CPU 已经运行在保护模式或者 IA-32e(也就是 64 位长模式)。这对引导程序是一个巨大的红利:你不需要再写一套“从实模式切换到保护模式”的汇编代码,固件已经替你走完了这一步;你面对的是一个平坦的 64 位地址空间,指针就是 64 位,UINTN 和指针同宽。理解这一点之后,你就明白为什么很多 UEFI 引导程序代码里没有“切换模式”这个环节——模式切换的责任被固件提前承担了。

但也正因为模式变了,内存管理方式完全改变:保护模式下离不开 GDT 和页表,而固件建立的那套页表是“够用就好”,不一定覆盖全部物理内存。引导程序要做的,不是直接信任固件给出的地址,而是自己去查询内存映射,重新规划。这就是后面 GetMemoryMap 登场的根本原因。

2.2 BIOS的启动流程:一段只能靠中断完成接力赛

BIOS 的启动流程是线性的:上电后先做加电自检(POST),检测关键设备;然后初始化显示设备并显示显卡信息,检测 CPU 和内存;接着检测标准设备(硬盘、光驱、串口、并口);再检测即插即用设备并为它们分配中断号、I/O 端口和 DMA 通道;如果硬件配置变了,更新到 CMOS;最后根据启动顺序,通过 BIOS 中断把设备的引导程序读入内存,把控制权交给引导程序。

这段流程里最关键的设计是“中断”。BIOS 把硬件操作的差异封装成一组中断服务例程,引导程序只需要知道“INT 13H + 寄存器传参”就能读扇区。86 汇编的调用方式虽然简单,代价却很沉重:中断调用开销大,CPU 只能同步等待设备完成;BIOS 代码自身用汇编开发,与硬件耦合极深;代码采用静态链接,升级扩展 ROM 只能往 0x0C0000~0x0DFFFF 这个固定窗口里塞,几乎没有动态加载驱动的能力。

最致命的限制在寻址空间:BIOS 硬盘寻址采用 32 位地址,引导扇区的最大逻辑块地址是 2^32,换算成字节是 2^32 × 512B = 2TB。超过 2TB 的磁盘,BIOS 连看都看不到。这就是 2TB 引导天花板。你之前可能听过“MBR 只能引导 2TB”,根源就在这里——不是分区表格式的锅,是固件寻址能力的锅。UEFI 引入 GPT 后,分区表项数不再被 4 个主分区限制,寻址也从 32 位扩展到 64 位,这条天花板才算被拆掉。

2.3 UEFI的七阶段推进:谁在什么时间点把机器准备好、又把控制权交给谁

UEFI 启动流程被规范分成七个阶段:SEC、PEI、DXE、BDS、TSL、RT、AL。这不是为了仪式感,每个阶段都有明确的物理前提和可交付物。

SEC 阶段,内存尚未初始化,不可使用。固件只能在处理器缓存里建立临时内存(Cache as RAM),同时把 CPU 切换到保护模式。你之前做嵌入式开发时可能见过这种“内存不够拿缓存凑”的路子,UEFI 把它用在了固件第一公里上。PEI 阶段的核心是对内存、CPU、芯片组做初始化,这部分代码没有压缩,必须精简。DXE 是 UEFI 最重要的阶段,大部分驱动和固件服务在这里加载,整个 UEFI 的“服务表”也是在这个阶段搭建起来的。BDS 阶段读取环境变量里的启动项,初始化控制台设备,尝试从记录的启动设备中启动——操作系统加载程序就在这个阶段登场。TSL 是临时系统运行阶段,Shell 这类 UEFI 应用程序跑在这里,它和操作系统加载器处于同一个阶段。RT 阶段从操作系统调用EFI_BOOT_SERVICES.ExitBootServices开始,DXE 驱动和引导服务彻底销毁,只保留 EFI 运行时服务和系统表。AL 阶段则是系统复位或进入 ACPI 休眠后的事。

把这七个阶段串起来看,UEFI 其实是在用“事务”的方式管理启动过程:SEC 交付临时内存,PEI 交付真实内存,DXE 交付设备驱动,BDS 交付启动路径,TSL 交付加载器运行环境,RT 交付内核独占的平台。引导程序真正能用的,是 BDS 之后、RT 之前这段窗口期——它必须在这个窗口里把该拿的资源全部拿到手。这就是为什么“引导程序是租赁者,不是主人”这个概念贯穿全书。

对比维度BIOSUEFI
CPU 运行模式实模式(16 位)保护模式 / IA-32e
对外接口形态中断服务(INT 13H 等)系统表 + 引导/运行时服务
设备访问方式通过 BIOS 中断通过句柄和协议
引导代码形态16 位汇编为主PE/COFF 镜像,C/C++ 开发
磁盘寻址上限32 位 LBA,约 2TB64 位 LBA
驱动扩展方式静态链接,固定 ROM 窗口模块化动态加载,带版本号
安全机制无可执行代码校验Secure Boot 证书签名验证

3. UEFI程序的运行环境:EDK2、PE/COFF镜像与系统表,固件怎样“接待”引导程序

引导程序不是普通的可执行文件,它的“形态”和“接待环境”都有严格约定。UEFI 规范规定可执行镜像要用 PE/COFF 格式——对,就是 Windows 可执行文件用的那套格式。而开发者用来编译它的,通常是 EDK2 这套开源开发环境。你第一次看到Shell.efi这个产物时可能会愣一下:这不是 Windows 的.exe吗?是的,UEFI 程序本质上就是 PE/COFF 镜像,只不过它的入口不是main,而是EFIAPI修饰的入口函数。

3.1 EDK2编译链与PE/COFF格式:为什么引导程序长着一张Windows可执行文件的脸

UEFI 选择 PE/COFF 而非 ELF,一个重要原因是工具链成熟度:x86 体系下无论是微软的链接器还是 EDK2 集成的交叉编译器,都能稳定产出这种格式;同时 PE/COFF 规范自带签名字段,Secure Boot 可以直接验证镜像的证书后再决定是否执行。UEFI 应用程序和驱动采用 PE/COFF 格式,签名放在签名块中——这就是“安全启动”在镜像层面的落地方式。

实际开发时,流程是先编译 EDK2 的基础工具,再设置环境变量,然后修改模块组配置文件指定编译目标。以下三行配置是 EDK2 里最核心的设置:

# Conf/target.txt 的核心配置 ACTIVE_PLATFORM = OvmfPkg/OvmfPkgX64.dsc # 目标模块组的描述文件路径 TOOL_CHAIN_TAG = GCC48 # 使用的编译器标签 TARGET_ARCH = X64 # 目标处理器体系结构

代码逻辑很直白:ACTIVE_PLATFORM告诉编译系统“我要编译哪个平台模块组”,这里指向的是 OVMF 开放虚拟机固件;TOOL_CHAIN_TAG选择编译器,不同的 Linux 发行版默认编译器不同,配置错了编译会直接报错;TARGET_ARCH决定生成 32 位还是 64 位镜像。编译完成后,产物会出现在Build/<模块名>/DEBUG_<工具链>/X64/目录下,Shell.efi就在其中。你可以把Shell.efi改名为BOOTX64.EFI放到 ESP 分区的标准路径,UEFI 固件会在引导阶段自动去加载它。

3.2 EFI_SYSTEM_TABLE:整个固件世界的目录总纲

UEFI 程序跑起来后,第一件事是从入口参数里拿到一个极其重要的指针——EFI_SYSTEM_TABLE。这个结构体是固件向操作系统加载程序开放的全部家底:

typedef struct { EFI_TABLE_HEADER Hdr; // 表头,含签名和版本 CHAR16 *FirmwareVendor; // 固件厂商名 UINT32 FirmwareRevision; // 固件修订号 EFI_HANDLE ConsoleInHandle; // 输入句柄 EFI_SIMPLE_TEXT_INPUT_PROTOCOL *ConIn; // 简单文本输入协议 EFI_HANDLE ConsoleOutHandle; // 输出句柄 EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut; // 简单文本输出协议 EFI_HANDLE StandardErrorHandle; // 错误输出句柄 EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *StdErr; // 标准错误协议 EFI_RUNTIME_SERVICES *RuntimeServices; // 运行时服务表 EFI_BOOT_SERVICES *BootServices; // 引导服务表 UINTN NumberOfTableEntries; // 配置表项数 EFI_CONFIGURATION_TABLE *ConfigurationTable; // 配置表数组 } EFI_SYSTEM_TABLE;

这个结构的精妙之处在于“分层”:ConOut等控制台协议让引导程序可以直接输出字符;BootServices指向一张庞大的函数表,里面有内存分配、定时器、协议查找和ExitBootServices;RuntimeServices指向的是操作系统运行期仍然可用的服务,比如变量读写和复位系统。引导程序打印 Hello World,本质上就是通过SystemTable->ConOut->OutputString()把字符串写到屏幕。

字段阶段有效性说明
ConOut / ConIn引导阶段有效操作系统接管后不可再依赖
BootServices仅 ExitBootServices 之前退出后被固件销毁
RuntimeServices一直有效变量服务、复位服务等
ConfigurationTable一直有效(数据自行解析)存放 ACPI 表、SMBIOS 表入口

理解了这张表,你就能看懂后面几乎所有引导程序代码:一切功能都是“查表拿指针,再调用指针所指的函数”。而 ExitBootServices 之所以是个分水岭,是因为这个调用一执行,BootServices整张函数表就变成不可用的内存。很多引导程序在这之后一调用 Print 函数就死机,原因就是它还在惦记着已经被销毁的引导服务。

3.3 句柄、协议与调用约定:UEFI用“可查询接口”代替硬编码中断号

BIOS 时代,你想读磁盘就调 INT 13H,想设显示模式就调 INT 10H——中断号是硬编码的,硬件变了你得猜它支不支持新功能。UEFI 换了一套思路:设备或者驱动在固件中注册为一个句柄(Handle),句柄上挂着若干协议(Protocol)。协议是一组函数指针的结构体,你想用某个功能,先按 GUID 去查找协议,再调用里面的函数。

这套机制本质上是“可查询的接口”:设备能干什么、不能干什么,固件运行期一查便知,不必预先约定中断号。驱动模块化加载,每个表和协议都带版本号,硬件升级时旧版本协议仍然可用,系统升级平滑得多。调用约定上,64 位 UEFI 程序遵循 Microsoft x64 ABI:参数前四个走 RCX、RDX、R8、R9,栈保持 16 字节对齐,UINTN与指针同宽。这些细节写进规范不是学术洁癖——你从 C 代码调用固件服务时,如果栈没对齐,实机上会出现诡异的随机崩溃,这类问题在真机上一追就是一天。EDK2 基础环境已经帮你处理了大部分,但定制汇编与固件交互时,这些约束就是装甲板上的孔洞,漏一处就翻车。

4. 引导程序的实质动作:显示模式、内存映射与内核交权,一步步把机器交到内核手里

前面几章说的是“环境”,从这一章开始是“动作”。一个 UEFI 引导程序的核心流程可以拆成四件事:配置图形设备、获取可用物理内存、从存储介质读取内核文件、把系统交到内核手里。每一件事对应的都是一次“与固件服务打交道”的过程,而且每一步都有严格的时序约束,顺序错了系统就在启动早期黑屏或死机。

4.1 图形显示模式:为什么引导程序要亲自配置分辨率

UEFI 固件默认的输出是一个简单的文本控制台,分辨率低、显示粗糙。如果你想在内核加载前显示一个漂亮的引导界面,就得自己动手设置图形模式。操作路径是通过 GOP(Graphics Output Protocol)协议:先查找系统中支持图形输出的句柄,拿到协议后查询支持的模式列表,再选择合适的分辨率和像素格式。

// 通过GOP协议设置图形模式 EFI_GRAPHICS_OUTPUT_PROTOCOL *Gop; EFI_STATUS Status; // 1. 查找系统中提供图形输出能力的句柄 Status = gBS->LocateProtocol(&gEfiGraphicsOutputProtocolGuid, NULL, (VOID**)&Gop); // 2. 查询并选择模式:这里假设模式3是 1024x768 if (Gop->Mode->Mode >= Gop->Mode->MaxMode) { Status = Gop->QueryMode(Gop, 3, &SizeOfInfo, &Info); // 查询该模式参数 } Status = Gop->SetMode(Gop, 3); // 切换分辨率 // 3. 从返回的帧缓冲基址直接写像素 UINT32 *FrameBuffer = (UINT32 *)Gop->Mode->FrameBufferBase;

代码里最关键的是第三步:SetMode成功后,固件会返回一个帧缓冲基址,之后你把像素数据直接写入这个地址就能显示在屏幕上。引导程序的图形界面本质上是“对着显存画画”,不需要再经过文本控制台。注意QueryMode和SetMode之间要检查返回值——有些固件支持的分辨率列表很短,你选了一个它不支持的,SetMode返回错误,但固件可能已经切换到一半,这时候你的输出就花了。常见的问题是初学者直接写死一个分辨率就SetMode,结果在另一台机器上黑屏。正确的做法是先用QueryMode遍历一遍,确认模式存在再设置。

4.2 获取物理内存:GetMemoryMap返回的并不只是一张地址表

引导程序给内核递交接力棒之前,必须完成一件核心工作:把整个物理内存的布局摸清楚。内核建立页表、标记保留区域、统计可用内存,全部依赖这份数据。UEFI 提供的内存查询接口是GetMemoryMap,但它返回的不是一个简单的地址数组,而是一组描述符。

EFI_STATUS GetMemoryMap( IN OUT UINTN *MemoryMapSize, // 入参:缓冲区大小;出参:实际需要的字节数 OUT EFI_MEMORY_DESCRIPTOR *MemoryMap, // 出参:内存描述符数组 OUT UINTN *MapKey, // 出参:内存映射的“版本号” OUT UINTN *DescriptorSize, // 出参:单个描述符的实际大小 OUT UINT32 *DescriptorVersion // 出参:描述符结构版本 );

调用这个接口的标准姿势是“问两次”:第一次传NULL给它,固件告诉你需要多大的缓冲区;你分配好内存后第二次调用,把真正的描述符数组填进去。MapKey是内存映射的版本号,这个值必须原封不动地传给后面的ExitBootServices——如果在两次操作之间固件因为某种原因改变了内存状态(比如加载了新驱动),Key 就不再匹配,ExitBootServices会拒绝交权并返回错误。很多引导程序在这里出错:分配了缓冲区后没有重新查询就强行退出引导服务,结果在内核跳转后页表错乱,随机死机。每个内存描述符里的NumberOfPages是以 4KB 页为单位的数量,不是字节数——这个单位陷阱也是踩坑高发区。Attribute字段则告诉内核这段内存的缓存属性,以及它是否是运行时服务需要保留的区域。

4.3 从ESP分区读内核:文件系统协议帮你绕开了FAT的细节

引导程序的下一步,是从 ESP(EFI 系统分区)里把内核文件读出来。这是很多人第一次接触时最困惑的地方:为什么我不直接读磁盘扇区?原因是 UEFI 已经帮你把文件系统这层做掉了。ESP 分区使用 FAT 格式,固件内部有完整的 FAT 驱动,你只需要通过文件系统协议打开文件即可,完全不需要自己解析 FAT 表。

EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *FileSystem; EFI_FILE_PROTOCOL *RootDir, *KernelFile; UINTN RealReadSize; // 1. 打开卷,拿到根目录句柄 Status = gBS->LocateProtocol(&gEfiSimpleFileSystemProtocolGuid, NULL, (VOID**)&FileSystem); Status = FileSystem->OpenVolume(FileSystem, &RootDir); // 2. 从根目录打开内核文件,路径用反斜杠 Status = RootDir->Open(RootDir, &KernelFile, L"\\EFI\\BOOT\\KERNEL.EFI", EFI_FILE_MODE_READ, 0); // 3. 读取文件内容到已分配的缓冲区 RealReadSize = KernelFileSize; Status = KernelFile->Read(KernelFile, &RealReadSize, KernelBuffer);

这段代码里有几个值得注意的细节。第一,路径分隔符是反斜杠\,不是类 Unix 系统的正斜杠/,写错了固件直接返回EFI_NOT_FOUND。第二,Read函数的第二个参数是“输入输出双重角色”:调用前传入缓冲区大小,返回后它保存的是实际读取的字节数。文件如果比缓冲区大,固件不会报错,它只会读完缓冲区允许的字节数就返回——你需要循环调用Read直到文件读完。第三,文件名大小写不敏感,因为 FAT 文件系统本身不区分大小写,但如果你把文件放在\EFI\BOOT之外,固件默认的引导路径就找不到它。

引导程序从存储介质读文件的“能力边界”就此清晰了:文件系统协议覆盖了 FAT 层,ESP 分区的类型 GUID 则让固件知道“这块区域是我的地盘”。这两个约定缺一不可——分区类型不对,固件不会把设备当作启动介质;文件不在标准路径,固件找不到要加载的东西。把这个链路想明白,U 盘启动失败这种问题就很好排查了。

4.4 交权时刻:关闭中断、退出引导服务、接管整个平台

引导程序的最后一个动作是“交权”,但交权之前先要“清场”。UEFI 固件在引导阶段可能注册了各种回调事件,比如定时器中断、设备热插拔事件。这些回调依赖的正是引导服务。如果不清掉它们就跳转到内核,固件的回调会随时抢占 CPU,在你毫无防备的时候执行一段仍然依赖 BootServices 的代码——后果是内核刚起跑就崩。

标准的收尾序列是这样:

// 1. 关闭中断,阻止固件回调继续触发 __asm__ volatile ("cli"); // 2. 先重新查询内存映射,拿到最新的MapKey Status = gBS->GetMemoryMap(&MapSize, MemoryMap, &MapKey, &DescriptorSize, &DescriptorVersion); // 3. 调用ExitBootServices,交出平台所有权 Status = gBS->ExitBootServices(ImageHandle, MapKey);

ExitBootServices是一个不可逆的调用:它执行完之后,BootServices 表内的所有函数指针全部失效,固件不再监听任何中断事件,操作系统成为平台的唯一主人。调用它必须带上正确的MapKey,否则固件认为内存视图已过期,会拒绝交权。这一步通过后,你能继续使用的只剩下RuntimeServices和SystemTable的部分字段。接下来,引导程序要建立自己的中断描述符表、页表和 GDT,然后跳转到内核入口。

从 64 位平台的角度看,跳转前的状态检查和历史上实模式下完全不同:UEFI 启动完成时你已经在长模式里,不需要再写“从实模式切到保护模式再切长模式”的汇编模板。你需要做的是确认自己页表覆盖了内核所在的所有物理区域,并且中断已经关闭。有些开发者习惯性地沿用 BIOS 时代的引导时序,在处理器的模式切换上绕远路,反而踩进了固件遗留页表的坑。

5. 实践避坑:从编译到真机上电,UEFI引导开发最常见的五个翻车现场

引导程序开发的难点不在“能不能编译过”,而在“为什么实机上表现和虚拟机不一样”。以下五个问题是我在阅读和实践中反复见到的高频故障,每个都是“现象→原因→解决”的结构,值得你在动手前先看一遍。

5.1 编译通过、虚拟机黑屏:问题往往出在控制台句柄没就绪

现象:代码能编译过,BOOTX64.EFI也放到了 ESP 分区的正确路径,但虚拟机启动后直接黑屏,连一个字都不输出。

原因:引导程序入口里默认认为SystemTable->ConOut可用。但在某些固件配置下,控制台协议可能尚未初始化,或者你调用了SetMode设置了一个固件不支持的模式,输出路径已经损坏。代码没有检查返回值,黑屏就成了必然结果。

解决:入口处先判断SystemTable->ConOut是否为 NULL,如果是就直接返回;调用ConOut->OutputString()后检查返回值。设置显示模式前先遍历QueryMode查一遍分辨率。另外,切记输出一条信息后先Flush再进入下一步,很多黑屏其实是输出缓冲区没刷出来。

5.2 U盘插上却不进引导:ESP分区和文件路径的约定一个都不能错

现象:U 盘重新分区后,固件就是不认它,开机直接跳回硬盘启动或进入 UEFI Shell。

原因:U 盘分区表虽然是 GPT,但 ESP 分区的类型 GUID 不对——必须是规范指定的C12A7328-F81F-11D2-BA4B-00A0C93EC93B;或者文件路径不是\EFI\BOOT\BOOTX64.EFI。有些固件比较挑剔,还要求 ESP 分区对齐到 4KB 边界,分区位置不对也会被跳过。

解决:用支持 GPT 的磁盘工具重新创建 ESP 分区,确认类型 GUID;把编译好的Shell.efi改名BOOTX64.EFI,放到\EFI\BOOT\目录下;分区对齐设置成 4096 扇区。另外,真机上有些固件第一次插 U 盘需要手动进入启动菜单选择一次,这不代表你的分区有问题。

5.3 内存描述符错乱:DescriptorSize写死、MapKey过期都是经典坑

现象:内核收到的内存映射表解析出来的物理地址乱七八糟,有的区域被标记成可用但实际访问就触发硬件异常。

原因:代码里用sizeof(EFI_MEMORY_DESCRIPTOR)来遍历固件返回的描述符数组。UEFI 规范允许固件在描述符尾部追加自定义字段,导致每个描述符的实际大小比标准结构大。使用错误步长遍历,会把前一个描述符的中间位置当成下一个描述符的开头,整张表全错。另一个常见原因是退出引导服务时用了旧的MapKey。

解决:遍历描述符数组时一律使用GetMemoryMap返回的DescriptorSize作为步长,不要用编译期常量。调用ExitBootServices前重新执行一次GetMemoryMap,把最新的MapKey传进去。这两条是实机上最常见的死法,我一般会写成一个专门的函数来封装。

5.4 ExitBootServices之后一调用固件函数就崩:引导服务已经不存在了

现象:内核引导阶段想复用固件的 Print 函数打印日志,结果一调用就死机,连错误信息都看不到。

原因:ConOut指向的文本输出协议实现在 BAT 服务里,退出后 BootServices 已经被销毁,你手里的函数指针指向一片被释放的固件内部数据结构。调用一个“已经不存在的服务”,行为是未定义的——在虚拟机里可能只是报错,在真机上直接停机。

解决:引导阶段把所有关键信息打印完,退出后不再依赖任何 BootServices 提供的函数。如果必须在退出后输出信息,自行实现一个最简串口驱动或者直接写显存帧缓冲,GOP 返回的帧缓冲地址在退出后仍然可写。把“退出引导服务”视为一条绝对红线:之后只用 RuntimeServices 和硬件寄存器。

5.5 把GPT认成MBR:protective MBR和fdisk的误导

现象:在 Linux 下用fdisk -l查看 U 盘,显示磁盘标签类型是dos,/dev/sdb1的容量是 2TB 以上,以为分区没建好或建错了。

原因:GPT 磁盘的第一个扇区保留了一个“保护性 MBR”(protective MBR),它的作用是让老旧的 MBR 工具意识到“这块盘是 GPT 的,别乱动”。旧版fdisk不认识 GPT 分区表,把这个保护性 MBR 解析成了一个大分区(类型 0xEE),所以看起来像一块巨大的 FAT 分区。这不是问题,恰恰说明 GPT 保护机制在工作。

解决:用ls /dev/sdb*查看实际设备节点,你会看到/dev/sdb1、/dev/sdb2分别对应 ESP 分区和数据分区。Linux 内核早已支持 GPT,应用层工具的误读不影响内核对分区的识别。检查分区时优先用支持 GPT 的工具(例如parted或图形化磁盘工具),不要用旧版fdisk的输出判断布局。

6. 把引导设计成“租借环境”:UEFI的取舍、代价与我的固定流程

回看整个 UEFI 引导流程,你会发现一个贯穿始终的设计哲学:固件把引导阶段的一切资源都“租借”给操作系统加载程序,用完之后必须全数交还。ExitBootServices就是这场租赁合同的终止按钮——按下去,引导服务销毁,固件撤回大部分能力,只剩下运行时服务继续履行最小职责。这不是实现上的偷懒,而是刻意的安全边界:操作系统内核不信任固件,固件也不打算为操作系统的运行期负责。BIOS 时代那种“内核运行后还能调 INT 13H 读写磁盘”的暧昧状态,在 UEFI 中被彻底终结。

这个设计有明确的代价。引导程序的开发门槛被抬高了一个层级:你要理解哪些服务在哪个阶段有效,要管理MapKey的生命周期,要在退出引导服务前把所有数据备齐。相比 BIOS 时代“INT 13H 读扇区 + INT 10H 画字符”的朴素接口,UEFI 的协议、句柄、版本号机制一开始确实劝退不少人。但它换来的是模块化驱动、动态加载、签名校验和跨平台可移植性——这些能力让 UEFI 在移动设备到服务器的全谱系硬件上都能站住脚。

我验证自己是否真正理解这套机制,靠的是三个问题:退出引导服务后,SystemTable 本身还能读吗?答案是能读,但其中指向 BootServices 的指针已经失效。GOP 的帧缓冲地址还能写吗?能写,因为那是物理显存地址。RuntimeServices 里的变量服务为什么保留?因为操作系统需要一种标准方式在重启后记住启动项。把这三个问题在每一台目标机器上过一遍,基本能排除九成时序性故障。

从那以后,我每次写引导程序都强制走一遍“阶段服务清单”——把 SystemTable 里的每个指针按时间轴标出让它在哪一刻失效,再开始写代码。这份资源把从 SEC 到 RT 的每个阶段、每个关键结构都拆开讲透,配合完整示例代码,值得你按着上面的路径在虚拟机里亲手验证一遍。希望帮到你。

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

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

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

立即咨询