2、UEFI核心概念:系统表、启动服务、运行时服务、协议与句柄数据库
好,咱们正式开始拆解UEFI的内核。上一章我们聊了UEFI是什么,它怎么从BIOS手里接过了接力棒。这一章,我们得钻进它的肚子里,看看它到底靠什么运转。
说白了,UEFI的整个运行时环境,就是围绕几个核心数据结构搭建起来的。你只要搞懂了这几个东西,看UEFI代码就不会再觉得是天书。我当年刚接触UEFI时,对着EDK2的代码看了三天,头都是大的。后来一位老前辈点了我一句:“别管那些花里胡哨的函数,先搞清楚系统表里挂了什么。” 嗯,从那以后,路就顺了。
2.1 UEFI系统表 — 一切入口
UEFI系统表,也就是 EFI_SYSTEM_TABLE,是整个固件世界的“上帝对象”。它不干活,但它知道谁在干活。
每个UEFI应用程序或驱动在启动时,都会收到一个指向系统表的指针。你可以把它想象成一个巨大的结构体,里面挂着所有你需要的东西。
核心结构(简化版):
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(输出)和 ConIn(输入)。所以你在写UEFI应用时,第一件事就是拿到这个表,然后才能 gST->ConOut->OutputString(gST->ConOut, L"Hello UEFI\n");。
我个人习惯把系统表比作“总机接线员”。你不直接找打印机,你找接线员,他告诉你打印机在哪。
2.2 启动服务 — 只能在Boot阶段用
启动服务(Boot Services)是UEFI在启动阶段提供的一系列核心功能。注意“启动阶段”这四个字,它是有生命周期的。
一旦系统调用 ExitBootServices(),这些服务就全部失效了。为什么?因为操作系统接管了硬件,UEFI不能再占着内存和资源不放。
启动服务表里包含了什么?我列几个你一定会用到的:
| 服务类别 | 典型函数 | 用途 |
|---|---|---|
| 内存管理 | AllocatePages, FreePages |
分配/释放物理内存页 |
| 协议管理 | InstallProtocolInterface, LocateProtocol |
安装/查找协议 |
| 句柄管理 | InstallMultipleProtocolInterfaces |
批量安装协议到句柄 |
| 镜像管理 | LoadImage, StartImage |
加载并启动UEFI应用或驱动 |
| 事件与定时器 | CreateEvent, SetTimer |
创建定时事件,实现轮询或回调 |
我曾经踩过的坑: 有一次我在写一个UEFI驱动,在 ExitBootServices 之后还试图调用 AllocatePages。结果系统直接挂死。后来查了规范才知道,启动服务在运行时阶段就是一堆空指针。切记,ExitBootServices 之后,只能用运行时服务。
2.3 运行时服务 — 陪你到关机
运行时服务(Runtime Services)是UEFI留给操作系统的“遗产”。操作系统启动后,UEFI的大部分代码都被回收了,但运行时服务会常驻内存,直到你关机。
这些服务主要处理一些操作系统不方便直接操作的事情:
- 时间管理:
GetTime,SetTime— 读写硬件RTC时钟。 - 变量服务:
GetVariable,SetVariable— 读写NVRAM中的UEFI变量。这是最常用的,比如你改启动顺序,就是改BootOrder这个变量。 - 复位服务:
ResetSystem— 冷重启、热重启或关机。 - 虚拟地址转换:
SetVirtualAddressMap— 操作系统切换页表时,通知UEFI更新指针。
你想想看,Windows或Linux为什么能通过 efibootmgr 修改启动项?就是通过调用运行时服务的 SetVariable 接口,往NVRAM里写数据。
2.4 协议 — UEFI世界的接口契约
协议(Protocol)是UEFI最灵活的设计。它本质上是一个GUID标识的接口结构体,里面挂了一堆函数指针。
举个例子,你想读写硬盘,你不能直接操作AHCI寄存器。你得先找到 EFI_BLOCK_IO_PROTOCOL,然后调用它的 ReadBlocks 和 WriteBlocks。
协议的设计哲学是:面向接口编程,而不是面向实现。你不需要关心底层是SATA还是NVMe,你只需要拿到协议,调用接口就行。
我的经验: 调试UEFI驱动时,90%的问题都是“协议没找到”。我建议你在代码里加一些调试打印,把 LocateProtocol 的返回值打出来。如果返回 EFI_NOT_FOUND,别慌,先检查GUID有没有写错,或者驱动有没有被正确加载。
2.5 句柄数据库 — 对象的身份证
句柄(Handle)是什么?它就是一个 void * 指针,但你不能直接解引用它。它更像是一个“身份证号”,用来标识一个UEFI对象。
每个硬件设备、每个加载的镜像、每个协议实例,都会有一个句柄。句柄本身不存数据,但它身上可以挂多个协议。
句柄数据库(Handle Database)就是管理所有这些句柄的中央仓库。你可以通过 LocateHandleBuffer 枚举所有句柄,也可以通过 LocateHandle 按协议类型查找句柄。
我画了一张图,帮你理清这几个概念的关系:
2.6 它们怎么协作?一个实际例子
假设你写了一个UEFI应用,想读取硬盘的第一个扇区。流程是这样的:
- 你的应用入口收到
EFI_SYSTEM_TABLE指针。 - 通过系统表拿到
BootServices。 - 调用
LocateHandleBuffer,按EFI_BLOCK_IO_PROTOCOL的GUID查找所有支持块IO的句柄。 - 遍历句柄,对每个句柄调用
OpenProtocol,拿到EFI_BLOCK_IO_PROTOCOL接口。 - 调用
BlockIO->ReadBlocks,传入LBA地址和缓冲区。 - 数据读回来了。
你看,整个过程中,你只跟系统表、启动服务、句柄、协议打交道。没有直接操作硬件寄存器,没有关心磁盘是IDE还是AHCI。这就是UEFI抽象的魅力。
一句话总结: 系统表是入口,启动服务是工具箱,运行时服务是留守人员,协议是接口契约,句柄是对象的ID。五者合一,构成了UEFI的整个运行时骨架。
嗯,这一章的内容就到这里。这些概念你如果现在觉得有点抽象,没关系。后面几章我们会反复用到它们,到时候你自然就熟了。