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 按协议类型查找句柄。

我画了一张图,帮你理清这几个概念的关系:

UEFI核心概念关系图 EFI_SYSTEM_TABLE Boot Services Runtime Services Configuration Table Handle Database Protocols Device Handles 核心逻辑 系统表是入口,它指向启动服务、运行时服务和配置表。 启动服务管理句柄数据库,句柄数据库上挂载各种协议。 设备句柄是具体硬件对象的标识,协议是访问硬件的接口。

2.6 它们怎么协作?一个实际例子

假设你写了一个UEFI应用,想读取硬盘的第一个扇区。流程是这样的:

  1. 你的应用入口收到 EFI_SYSTEM_TABLE 指针。
  2. 通过系统表拿到 BootServices。
  3. 调用 LocateHandleBuffer,按 EFI_BLOCK_IO_PROTOCOL 的GUID查找所有支持块IO的句柄。
  4. 遍历句柄,对每个句柄调用 OpenProtocol,拿到 EFI_BLOCK_IO_PROTOCOL 接口。
  5. 调用 BlockIO->ReadBlocks,传入LBA地址和缓冲区。
  6. 数据读回来了。

你看,整个过程中,你只跟系统表、启动服务、句柄、协议打交道。没有直接操作硬件寄存器,没有关心磁盘是IDE还是AHCI。这就是UEFI抽象的魅力。

一句话总结: 系统表是入口,启动服务是工具箱,运行时服务是留守人员,协议是接口契约,句柄是对象的ID。五者合一,构成了UEFI的整个运行时骨架。

嗯,这一章的内容就到这里。这些概念你如果现在觉得有点抽象,没关系。后面几章我们会反复用到它们,到时候你自然就熟了。