UEFI 规范基础:从起源到核心服务

聊 UEFI 之前,我得先说说 BIOS。你想想看,BIOS 这玩意儿从 1981 年 IBM PC 诞生就跟着了,三十多年没怎么大变。它用 16 位实模式,只能寻址 1MB 空间,启动流程死板得很——先 POST,再读主引导记录,然后加载操作系统。放到今天,这就像用算盘做大数据分析,不是不行,是太憋屈了。

UEFI 的出现,说白了就是要把固件拉进现代世界。它用 32 位或 64 位模式,能直接访问整个内存空间,有文件系统驱动,还能跑网络协议。我 2015 年第一次在 ARM 服务器上碰 UEFI,当时就觉得:这才是固件该有的样子。

UEFI 的起源:Intel 的野心与产业联盟

UEFI 的前身是 Intel 的 EFI(Extensible Firmware Interface),2000 年左右开始搞。Intel 当时推安腾处理器,发现传统 BIOS 根本撑不住 64 位系统的需求。于是他们写了一套全新的固件接口规范,叫 EFI 1.0。

2005 年,Intel 把 EFI 交给 UEFI 论坛管理,规范改名为 UEFI。从 2.0 版本开始,它加入了安全启动、网络支持、多架构兼容等特性。我印象最深的是 UEFI 2.3.1 版本,那会儿安全启动刚普及,很多主板因为签名问题翻车,我们团队没少加班处理。

关键节点:

  • 2000 年:Intel 发布 EFI 1.0 规范
  • 2005 年:UEFI 论坛成立,规范更名为 UEFI 2.0
  • 2010 年:UEFI 2.3 加入安全启动(Secure Boot)
  • 2012 年:Windows 8 强制要求 UEFI 安全启动
  • 2020 年:UEFI 2.9 规范发布,支持 LoongArch 架构

GUID 分区表:告别 MBR 的 2TB 天花板

传统 MBR 分区表用 32 位逻辑块地址,最大只能管理 2TB 磁盘。你想想,现在随便一块 SSD 都 4TB 了,MBR 根本不够用。GUID 分区表(GPT)用 64 位 LBA,理论上支持 8ZB 的磁盘——这个数字大到什么程度?把地球上所有数据存进去还有富余。

GPT 的结构很清晰:

  • 保护 MBR:位于 LBA 0,防止老工具误认磁盘为未分区
  • 主 GPT 头:LBA 1,记录分区表位置、大小、CRC32 校验
  • 分区表项:LBA 2 开始,每个表项 128 字节,支持 128 个分区
  • 备份 GPT:磁盘末尾,防止主 GPT 损坏

我在项目中遇到过最坑的事:某次固件升级后,GPT 头里的 CRC32 校验值没更新,系统死活不认磁盘。查了两天才发现是固件 bug——备份 GPT 是对的,但主 GPT 头校验失败,UEFI 直接拒绝加载。从那以后,我每次做分区操作都会手动校验 CRC。

个人习惯:做嵌入式系统时,我会在 GPT 分区表里预留一个 16MB 的“固件备份分区”。万一主固件刷坏了,还能从备份分区启动恢复。这个习惯救过我两次。

启动管理器:UEFI 的“大脑”

UEFI 启动管理器是整个启动流程的调度中心。它不直接加载操作系统,而是管理启动项(Boot Option)。每个启动项包含:

  • 启动描述(比如 "Windows Boot Manager")
  • 设备路径(指向 EFI 系统分区)
  • 文件路径(指向 .efi 文件)
  • 启动参数(可选)

启动管理器按优先级遍历启动项,找到第一个能启动的就执行。如果所有启动项都失败,它会进入 UEFI Shell 或者显示错误界面。

我记得有一次调试,系统总是跳过 UEFI 网络启动直接进硬盘。查了半天发现是启动项顺序里 PXE 的优先级被设成了最低。用 bcfg 命令调了一下顺序就好了——这种问题看着简单,但没经验的人能折腾一整天。

// 查看当前启动项
Shell> bcfg boot dump

// 设置 PXE 为第一启动项
Shell> bcfg boot -mv 3 0

// 添加自定义启动项
Shell> bcfg boot -a 0 "MyApp" FS0:\EFI\MyApp.efi

注意:UEFI 启动项存储在 NVRAM 里。NVRAM 的写入次数有限,频繁修改启动项可能损坏 NVRAM。我见过有人写脚本每秒改一次启动顺序,结果三天后主板变砖。

运行时服务与启动时服务

UEFI 把服务分成两类:启动时服务和运行时服务。这个区分很关键,直接决定了固件和操作系统怎么交互。

特性 启动时服务 运行时服务
可用阶段 固件启动期间 启动期间 + 操作系统运行后
内存占用 可被 OS 回收 必须保留
典型功能 内存管理、协议接口、设备操作 时间、变量、重置
调用方式 通过 Boot Services 表 通过 Runtime Services 表
退出后 调用 ExitBootServices() 后不可用 始终可用

启动时服务包括:

  • 内存管理:分配/释放内存、获取内存映射
  • 协议管理:安装、查找、注册协议
  • 设备操作:读写 I/O、中断管理
  • 图像加载:加载并启动 EFI 应用程序

运行时服务包括:

  • 变量服务:读写 UEFI 变量(存储在 NVRAM)
  • 时间服务:获取/设置系统时间、闹钟
  • 重置服务:冷重启、热重启、关机
  • 虚拟地址映射:OS 切换页表时通知固件

这里有个容易踩的坑:操作系统调用 ExitBootServices() 之后,启动时服务就没了。如果你在 OS 内核里还想用 UEFI 的协议接口,那肯定崩溃。我见过有人把 UEFI 图形协议当成帧缓冲来用,结果 ExitBootServices 一调用,屏幕直接黑了。

实战经验:在 ATF 和 UEFI 协同启动时,ATF 的 BL31 运行在 EL3 异常级别,它需要 UEFI 的运行时服务来读写 NVRAM 变量。但 UEFI 的运行时服务是跑在 EL1 的,所以中间要经过 SMC 调用和异常级别切换。这个机制我后面会详细讲。

UEFI 启动流程全景图

下面这张图展示了 UEFI 从上电到加载 OS 的完整流程。我特意把启动时服务和运行时服务的分界点标出来了。

UEFI 启动流程与服务分界 上电 → SEC 阶段 PEI 阶段(内存初始化) DXE 阶段(驱动执行) BDS 阶段(启动管理器) TSL 阶段(OS 加载) ExitBootServices() RT 阶段(OS 运行) 启动时服务 内存管理 协议管理 设备操作 图像加载 ExitBootServices() 后不可用 运行时服务 变量服务 时间服务 重置服务 虚拟地址映射 OS 运行期间 始终可用

从图上你能看到,UEFI 的启动时服务覆盖了 SEC 到 TSL 阶段,而运行时服务从 RT 阶段开始一直持续到系统关机。ExitBootServices() 就是那个分水岭——过了这道门,启动时服务就跟你没关系了。

嗯,这里要注意:UEFI 的运行时服务虽然名字叫“运行时”,但它其实是一组回调函数,由操作系统在切换页表时调用。比如操作系统要休眠了,它会调用 UEFI 的 SetVirtualAddressMap() 告诉固件新的虚拟地址映射。如果固件没处理好这个转换,系统唤醒后就会崩溃。

我在调试某款 ARM 服务器时遇到过:操作系统调用 SetVirtualAddressMap() 后,UEFI 的 GetTime() 服务返回的时间全是乱的。查到最后发现是固件里有一个全局指针没做虚拟地址转换,还在用物理地址访问。这种问题在 x86 上不常见,但在 ARM 上因为页表切换更频繁,很容易翻车。

避坑指南:写 UEFI 运行时服务驱动时,所有全局指针都要用 EfiConvertPointer() 做虚拟地址转换。我习惯在驱动入口就注册一个虚拟地址转换回调,省得后面忘了。

好了,UEFI 的基础框架就聊到这儿。GPT 分区表、启动管理器、两类服务的划分——这些概念搞清楚了,后面讲 UEFI 和 ATF 怎么握手、怎么传数据,你就能跟上节奏了。