3. UEFI 系统表与协议:理解固件的“神经中枢”
好,我们进入第三章。前两章我们聊了 UEFI 的启动流程和分区表,算是把骨架搭起来了。这一章,我们来填血肉——也就是 UEFI 系统表、启动服务、运行时服务,还有那个无处不在的协议机制。
说实话,我刚接触 UEFI 时,最头疼的就是这堆表啊、协议啊。感觉像一团乱麻。但后来我明白了,这东西说白了就是固件和操作系统之间的“API 手册”和“电话本”。你只要搞懂它怎么查、怎么用,一切就豁然开朗了。
3.1 系统表(System Table):一切入口的入口
UEFI 系统表,EFI_SYSTEM_TABLE,是整个 UEFI 世界的“总司令部”。任何 UEFI 应用程序或驱动,在启动时都会收到一个指向这个表的指针。它里面装了什么?
- 固件供应商信息:谁写的固件,版本号多少。
- 控制台句柄:标准输入(键盘)、标准输出(屏幕)、标准错误输出。
- 启动服务表指针:指向
EFI_BOOT_SERVICES。 - 运行时服务表指针:指向
EFI_RUNTIME_SERVICES。 - 配置表:一个数组,里面装着各种系统配置信息,比如 ACPI 表、SMBIOS 表。
- UEFI 规范版本:告诉你当前固件遵循哪个版本的规范。
我个人习惯,在写任何 UEFI 驱动时,第一件事就是把系统表指针存成全局变量。因为后面所有操作,几乎都要通过它来“导航”。
核心要点:系统表是 UEFI 应用的“根”。找不到它,你就什么都干不了。
3.2 启动服务(Boot Services)与运行时服务(Runtime Services)
系统表里有两个最重要的指针,分别指向启动服务和运行时服务。它们俩的生命周期完全不同,这是很多新手容易踩坑的地方。
3.2.1 启动服务(Boot Services)
启动服务只在操作系统启动前可用。一旦操作系统调用 ExitBootServices(),这些服务就全部失效了。它们主要负责:
- 内存管理:分配、释放内存,获取内存映射。
- 事件与定时器:创建事件、设置定时器、等待事件。
- 协议管理:安装、卸载、查找协议。这是 UEFI 的核心机制,我们后面细说。
- 镜像加载:加载并启动其他 UEFI 镜像。
- 控制台 I/O:通过文本或图形方式输出信息。
举个例子,我在调试一个早期启动阶段的驱动时,就靠 gBS->Stall() 这个启动服务来延时,好让我看清屏幕上的打印信息。没有它,调试信息一闪而过,根本抓不住。
小技巧:在 ExitBootServices() 之后,千万不要再调用任何启动服务函数。否则系统会直接挂掉。我曾经因为这个 bug 排查了整整两天。
3.2.2 运行时服务(Runtime Services)
运行时服务在操作系统启动后依然可用。它们提供了一些操作系统必须依赖固件的功能:
- 获取时间与设置时间:读写硬件 RTC。
- 获取变量与设置变量:读写 UEFI 变量(NVRAM 中的键值对)。
- 重置系统:冷重启、热重启、关机。
- 获取 Wakeup Time:读取系统唤醒时间。
这里有个关键点:运行时服务必须用虚拟地址映射来调用。操作系统在启动后会切换页表,固件必须支持虚拟地址转换。否则,操作系统调用 GetVariable() 时就会崩溃。
| 特性 | 启动服务 (Boot Services) | 运行时服务 (Runtime Services) |
|---|---|---|
| 生命周期 | 从固件启动到 ExitBootServices() |
从固件启动到系统关机 |
| 可用性 | 仅启动阶段 | 启动阶段 + 操作系统运行时 |
| 主要功能 | 内存、协议、事件、镜像加载 | 时间、变量、重置 |
| 地址模式 | 物理地址 | 虚拟地址(需转换) |
| 典型用途 | 驱动初始化、加载 OS Loader | 保存启动配置、系统重置 |
3.3 UEFI 协议机制:面向对象的“接口”
UEFI 协议,说白了就是一组函数指针和数据结构的集合。它很像面向对象编程里的“接口”。每个协议都有一个 GUID(全局唯一标识符)作为它的“身份证号”。
你想用某个硬件功能?比如读取硬盘数据?那就先找到“硬盘 I/O 协议”的 GUID,然后通过启动服务的 LocateProtocol() 或 OpenProtocol() 函数,拿到这个协议的实例。之后,你就可以调用协议里定义的函数了。
为什么会这样设计?因为 UEFI 固件不知道你会接什么硬件。它只负责提供“标准接口”。硬件厂商按照这个接口写驱动,你的代码就能通用。这就是 UEFI 的可扩展性所在。
3.3.1 协议的生命周期
一个协议从“安装”到“卸载”,大致经历这几个阶段:
- 安装:驱动或固件通过
InstallProtocolInterface()将协议绑定到一个设备句柄上。 - 查找:其他模块通过 GUID 查找这个协议。
- 使用:调用协议中的函数。
- 卸载:驱动卸载时,通过
UninstallProtocolInterface()移除协议。
注意:协议查找失败是 UEFI 开发中最常见的错误之一。原因通常是:
- GUID 写错了(大小写、字节序)。
- 对应的驱动还没有被加载。
- 协议被安装在了错误的设备句柄上。
3.3.2 一个简单的协议使用示例
我们来看一段伪代码,演示如何获取并调用一个简单的“计算器协议”:
// 假设我们有一个计算器协议,GUID 是 ...
// #define CALCULATOR_PROTOCOL_GUID {0x...}
// 1. 声明协议结构
typedef struct _CALCULATOR_PROTOCOL {
UINT64 (*Add)(UINT64 a, UINT64 b);
UINT64 (*Subtract)(UINT64 a, UINT64 b);
} CALCULATOR_PROTOCOL;
// 2. 在代码中查找并使用
EFI_STATUS Status;
CALCULATOR_PROTOCOL *CalcProtocol;
Status = gBS->LocateProtocol(
&gCalculatorProtocolGuid, // GUID
NULL, // 不指定注册通知
(VOID **)&CalcProtocol // 输出协议实例
);
if (EFI_ERROR(Status)) {
// 处理错误,比如打印 "Calculator protocol not found!"
return Status;
}
// 3. 使用协议
UINT64 Result = CalcProtocol->Add(10, 20);
// Result 现在应该是 30
你看,流程很清晰:查 GUID → 拿指针 → 调函数。UEFI 里几乎所有硬件操作,比如读写磁盘、操作网络、控制显卡,都是通过类似的协议机制完成的。
3.4 核心知识体系:一张图看懂
下面这张 SVG 图,把系统表、启动服务、运行时服务和协议的关系画了出来。你盯着看一分钟,就能明白整个架构。
3.5 避坑指南:我踩过的几个坑
最后,分享几个我实际项目中遇到的教训,希望能帮你少走弯路。
- GUID 搞错:我曾经把一个协议的 GUID 复制错了,结果
LocateProtocol永远返回EFI_NOT_FOUND。排查了三个小时,最后发现是 GUID 里一个字节写反了。所以,GUID 一定要从官方头文件里复制,别手敲。 - 忘记检查返回值:UEFI 函数几乎都会返回
EFI_STATUS。我早期写代码时,经常忘了检查。结果程序跑着跑着就挂了,还不知道原因。现在我的习惯是:每个 UEFI 函数调用后,必须用EFI_ERROR()宏检查一下。 - 在运行时服务里调用启动服务:这个错误很致命。操作系统调用
ExitBootServices()后,所有启动服务的内存都会被回收。你再调用gBS->AllocatePool(),系统直接崩溃。记住:运行时服务里只能用运行时服务。
嗯,这一章的内容就到这里。系统表、启动服务、运行时服务、协议机制,这四个概念是 UEFI 开发的基石。你理解了它们,就等于拿到了打开 UEFI 大门的钥匙。