1. UEFI变量:固件与操作系统间的“共享记事本”
如果你玩过UEFI BIOS设置,改过启动顺序或者开启过虚拟化,那你其实已经和UEFI变量打过交道了。简单来说,UEFI变量就是一小块存储在非易失性存储器(比如主板上的SPI Flash芯片)里的数据,它充当了固件(UEFI)和操作系统(比如Windows、Linux)之间一个持久化的、结构化的通信渠道。你可以把它想象成一个挂在UEFI环境里的“共享记事本”,固件和操作系统都能在上面读写信息,而且这些信息在断电重启后依然存在。
这个机制解决了传统BIOS时代的一个大问题:配置信息分散且不统一。以前,硬件设置、启动路径、系统密钥等信息可能用各种“土办法”存储,互操作性很差。UEFI变量通过标准化的服务(GetVariable/SetVariable)和命名规范,为所有这类需要持久保存的配置数据提供了一个“官方接口”。它的核心价值在于标准化和持久化,让固件开发、操作系统引导、安全启动、硬件配置管理等一系列动作有了可靠的数据交换基础。
对于开发者,尤其是做固件(BIOS)、操作系统引导器(如GRUB2、systemd-boot)、或需要与底层硬件深度交互的驱动开发者来说,理解UEFI变量是必备技能。它能帮你实现从保存一个简单的启动标志,到管理整个平台的安全密钥链等各种功能。接下来,我们就深入这个“共享记事本”的内部,看看它到底是怎么工作的,以及在实际项目中如何正确地使用它。
2. UEFI变量的核心架构与工作原理
要玩转UEFI变量,不能只停留在“读读写写”的层面,必须理解其背后的设计逻辑和存储机制。这就像使用数据库,了解其索引和存储引擎,才能写出高效可靠的代码。
2.1 变量服务的“三层架构”
UEFI变量并非直接操作Flash芯片,它通过一套清晰的分层服务来实现,主要分为三层:
运行时服务层 (Runtime Services):这是操作系统在运行时(Runtime)可以调用的接口。主要是
GetVariable()和SetVariable()这两个核心函数。操作系统或应用程序通过它们来查询或设置变量。这一层负责权限检查、缓存管理和接口暴露。变量驱动层 (Variable Driver):这是UEFI固件内部的一个模块,是变量管理的“大脑”。它负责:
- 变量存储:管理变量在非易失性存储介质(如SPI Flash)上的物理布局。
- 磨损均衡:由于Flash有擦写次数限制,变量驱动需要实现算法,将写操作均匀分布到不同物理区块,延长Flash寿命。
- 掉电保护:确保在设置变量的过程中如果发生断电,数据不会损坏,通常通过“写前日志”或“原子操作”来实现。
- 缓存:在内存中缓存频繁访问的变量,提升读取性能。
非易失性存储层 (Non-Volatile Storage):通常是主板上的SPI Flash芯片的一个特定区域,被划分为“变量存储区”。UEFI规范定义了
EFI_FIRMWARE_VOLUME_BLOCK_PROTOCOL等协议来抽象对这个区域的操作,使得变量驱动可以不关心具体Flash芯片的型号。
当操作系统调用GetVariable(L”BootOrder”, &gEfiGlobalVariableGuid, …)时,这个请求会依次穿过运行时服务层、变量驱动层,最终由变量驱动从Flash存储区或缓存中取出数据返回。SetVariable的过程则更复杂,涉及写入缓存、日志记录、最终刷写到Flash等步骤。
2.2 变量的“身份证”:名称与GUID
每个UEFI变量都由两个关键部分唯一标识:
- VariableName (变量名):一个以NULL结尾的Unicode字符串。例如,
L”BootOrder”,L”Lang”,L”PlatformLang”。注意,名称是大小写敏感的。 - VendorGuid (厂商GUID):一个128位的全局唯一标识符。GUID的作用是划分“命名空间”,防止不同厂商或模块定义的变量发生名称冲突。
例如,BootOrder这个所有系统都关心的变量,其GUID是gEfiGlobalVariableGuid(8BE4DF61-93CA-11D2-AA0D-00E098032B8C)。而一个硬件厂商自定义的特定配置变量,可能会使用自己生成的GUID。正确使用GUID是避免问题的关键。在代码中,永远不要硬编码GUID值,而应该引用头文件中定义好的宏。
2.3 数据的“包装盒”:属性与内容
除了名字,每个变量还有两个重要属性:
- Attributes (属性):一个32位的位掩码,定义了变量的特性。最重要的几个属性包括:
EFI_VARIABLE_NON_VOLATILE:非易失性。这是变量的默认且最重要的属性,表示变量需要被持久化到Flash中。如果没有这个属性,变量只存在于内存中,重启后丢失。EFI_VARIABLE_BOOTSERVICE_ACCESS:允许在UEFI引导服务和操作系统加载器的阶段访问。EFI_VARIABLE_RUNTIME_ACCESS:允许操作系统在运行时(即ExitBootServices之后)访问。像BootOrder这类需要被OS运行时管理的变量,就必须同时具有BOOTSERVICE_ACCESS和RUNTIME_ACCESS属性。EFI_VARIABLE_TIME_BASED_AUTHENTICATED_WRITE或EFI_VARIABLE_AUTHENTICATED_WRITE:与安全启动相关,表示写入此变量需要附带数字签名。
- Data (数据):变量的实际内容,是一个字节数组(
VOID*)。其格式和含义完全由变量的定义者决定。可以是简单的整型、字符串,也可以是复杂的结构体。
注意:
SetVariable时提供的DataSize必须精确匹配你准备写入的数据缓冲区大小。如果你有一个UINT32的值要写,DataSize就应该是sizeof(UINT32)即4字节。DataSize=0且Data=NULL是一种特殊操作,用于删除一个已存在的变量。
3. 关键变量解析与实战操作指南
了解了基本原理,我们来看看那些最关键、最常用的变量,并手把手演示如何操作它们。
3.1 引导管理变量组
这是UEFI变量中最核心的一组,负责管理操作系统的启动流程。它们通常使用gEfiGlobalVariableGuid。
BootOrder (类型: UINT16数组):一个按优先级排列的启动项索引号列表。系统固件会按照这个列表的顺序,依次尝试从每个
BootXXXX变量描述的设备启动。- 实操:如何读取和修改启动顺序?
避坑点:// 示例:读取当前BootOrder #include <Uefi.h> #include <Library/UefiRuntimeServicesTableLib.h> EFI_STATUS Status; UINTN DataSize = 0; UINT16 *BootOrderList = NULL; // 第一次调用,获取数据大小 Status = gRT->GetVariable ( L”BootOrder”, &gEfiGlobalVariableGuid, NULL, &DataSize, NULL ); if (Status == EFI_BUFFER_TOO_SMALL) { BootOrderList = AllocatePool(DataSize); if (BootOrderList != NULL) { // 第二次调用,获取实际数据 Status = gRT->GetVariable ( L”BootOrder”, &gEfiGlobalVariableGuid, NULL, &DataSize, BootOrderList ); if (!EFI_ERROR(Status)) { UINTN EntryCount = DataSize / sizeof(UINT16); for (UINTN i = 0; i < EntryCount; i++) { Print(L”Boot%04X\n”, BootOrderList[i]); } } FreePool(BootOrderList); } }GetVariable的标准用法是两段式。第一次传入DataSize=0和Data=NULL,函数会返回EFI_BUFFER_TOO_SMALL并告知你所需的缓冲区大小。你根据这个大小分配内存后,再进行第二次调用获取数据。直接猜测大小分配内存极易出错。
- 实操:如何读取和修改启动顺序?
BootXXXX (类型: EFI_LOAD_OPTION):每个启动项的具体描述。
XXXX是一个四位十六进制数,对应BootOrder中的索引。其数据结构EFI_LOAD_OPTION包含:Attributes: 启动项属性(如是否激活)。FilePath: 一个EFI_DEVICE_PATH_PROTOCOL列表,描述要启动的镜像文件所在位置(如\EFI\ubuntu\grubx64.efi)。Description: 一个用户可见的描述字符串(如“Ubuntu 22.04 LTS”)。OptionalData: 可选的、传递给启动镜像的额外数据。
BootCurrent (类型: UINT16):当前本次启动所选择的
BootXXXX索引号。BootNext (类型: UINT16):指定下一次启动(且仅下一次)使用的
BootXXXX索引号。重启后该变量会被自动删除。这是实现“单次引导到特定系统”功能的关键。例如,在双系统电脑上,从Windows内设置下次重启进入Linux,就是通过写BootNext实现的。
3.2 平台与硬件配置变量
这类变量通常由固件或硬件驱动创建,用于保存平台特定设置。
- Lang / PlatformLang (类型: 字符串):指定固件界面的显示语言。
Lang是平台支持的语言列表,PlatformLang是当前选中的语言。格式遵循RFC 4646,如”zh-Hans”,”en-US”。 - ConIn / ConOut / ErrOut (类型: EFI_DEVICE_PATH_PROTOCOL):分别指定标准输入、标准输出、标准错误输出的设备路径。通常指向串口或图形控制台。在无显示器的服务器上,通过修改
ConOut到串口路径,可以实现串口重定向输出,对于远程调试至关重要。 - 变量名以
Pcd…或gEfi…开头的变量:这些通常是UEFI固件内部用于传递动态配置的变量,其GUID和数据结构定义在对应的EDK II包中。普通开发者应避免直接操作这些变量,除非有明确的文档说明。
3.3 安全启动相关变量
在启用了UEFI安全启动的系统中,以下变量用于管理信任链,它们通常具有AUTHENTICATED_WRITE属性,写入需要签名。
- PK (Platform Key):平台密钥,是信任链的根。
- KEK (Key Exchange Key):密钥交换密钥,用于更新
db和dbx。 - db (Authorized Signature Database):授权签名数据库,存储被允许的镜像签名密钥或证书。
- dbx (Forbidden Signature Database):禁止签名数据库,存储被吊销或禁止的签名。
操作这些变量极其危险,需要对应的私钥进行签名。普通应用开发几乎不会涉及,主要是操作系统厂商(如微软、红帽)和OEM厂商在进行系统部署或更新时需要处理。
4. 在操作系统中操作UEFI变量的实战
操作系统提供了用户态接口来访问UEFI变量,这比在UEFI Shell或固件内操作更为常见。
4.1 Linux下的操作(通过sysfs和efivar)
Linux内核将UEFI变量抽象为sysfs文件系统中的文件,路径通常是/sys/firmware/efi/efivars/。每个文件对应一个变量,文件名格式为VarName-VendorGuid。
直接文件操作(不推荐):
# 查看所有变量 ls -l /sys/firmware/efi/efivars/ # 读取BootOrder变量(注意:文件前4字节是属性,后面才是数据) # 需要使用二进制工具,如hexdump hexdump -C /sys/firmware/efi/efivars/BootOrder-8be4df61-93ca-11d2-aa0d-00e098032b8c重要警告:直接
cat文本文件可能会损坏二进制数据。更不要直接使用echo或文件重定向来写入,这极易导致变量损坏或系统无法启动。使用专用工具
efivar或efibootmgr(推荐):efibootmgr是管理启动项的首选工具,它封装了对BootOrder、BootXXXX等变量的复杂操作。# 列出所有启动项 sudo efibootmgr -v # 创建一个新的启动项,指向ESP分区第一个分区(sda1)上的grub文件 sudo efibootmgr -c -d /dev/sda -p 1 -L “My Linux” -l \\EFI\\ubuntu\\grubx64.efi # 将Boot0003项设为下一次启动项 sudo efibootmgr -n 0003 # 调整启动顺序,让0003成为第一项,0000成为第二项 sudo efibootmgr -o 0003,0000 # 删除Boot0002项 sudo efibootmgr -b 0002 -B对于非启动变量,可以使用
efivar命令行工具(需要安装efivar包):# 列出所有变量 sudo efivar -l # 读取一个特定变量 sudo efivar -p -n 8be4df61-93ca-11d2-aa0d-00e098032b8c-BootOrder编程接口:通过
libefivar或/dev/efi_vars(已废弃): 在C程序中,可以使用libefivar库来安全地读写变量。这是最规范的方式。#include <efivar/efivar.h> int main() { efi_guid_t global_guid = EFI_GLOBAL_VARIABLE_GUID; uint32_t attributes; uint8_t *data = NULL; size_t data_size = 0; // 读取变量 int ret = efi_get_variable(global_guid, “BootOrder”, &data, &data_size, &attributes); if (ret < 0) { perror(“efi_get_variable”); } else { // 处理data... free(data); } // 写入变量(需要root权限) uint8_t new_data[] = {0x00, 0x00, 0x01, 0x00}; // 示例数据 ret = efi_set_variable(global_guid, “MyTestVar”, new_data, sizeof(new_data), attributes, 0644); if (ret < 0) { perror(“efi_set_variable”); } return 0; }编译时需要链接
-lefivar。
4.2 Windows下的操作(通过Win32 API)
Windows提供了GetFirmwareEnvironmentVariable和SetFirmwareEnvironmentVariable这两个API。
#include <Windows.h> #include <stdio.h> int main() { DWORD bufferSize = 0; UINT16 bootOrder[10]; // 假设最多10个条目 bufferSize = sizeof(bootOrder); // 读取BootOrder变量 DWORD result = GetFirmwareEnvironmentVariableW( L”BootOrder”, L”{8BE4DF61-93CA-11D2-AA0D-00E098032B8C}”, // GUID字符串 bootOrder, bufferSize ); if (result == 0) { DWORD err = GetLastError(); if (err == ERROR_INVALID_FUNCTION) { printf(“系统可能处于传统BIOS模式,或权限不足。\n”); } } else { // 成功读取,处理bootOrder数据 int count = result / sizeof(UINT16); for (int i = 0; i < count; i++) { printf(“Boot%04X\n”, bootOrder[i]); } } // 注意:在Windows下写入UEFI变量通常需要极高的权限(如SYSTEM权限), // 并且可能被安全策略阻止,操作需格外谨慎。 return 0; }重要限制:在Windows中,从Windows 8开始,出于安全考虑,对UEFI变量的写操作受到了极其严格的限制。普通应用程序甚至管理员权限的程序,通常都无法修改BootOrder等关键变量。修改通常需要通过特殊的平台管理工具(如bcdedit的部分功能)或由固件更新程序在特定上下文中完成。
5. 开发中的常见陷阱与调试技巧
在实际开发和调试中,操作UEFI变量会遇到各种“坑”。以下是我总结的一些常见问题和解决方法。
5.1 权限与访问失败问题
- 问题:调用
GetVariable或SetVariable返回EFI_SECURITY_VIOLATION,EFI_WRITE_PROTECTED或EFI_INVALID_PARAMETER。 - 排查:
- 检查属性:你是否在正确的时机访问变量?一个标记为
RUNTIME_ACCESS的变量,在UEFI引导服务阶段(ExitBootServices之前)是无法通过运行时服务访问的。反过来,某些只用于固件初始化的变量,可能没有RUNTIME_ACCESS属性,操作系统启动后自然读不到。 - 检查GUID和名称:这是最常见错误。确认GUID的值和字符串格式完全正确,变量名的大小写、尾随空格都要仔细核对。建议始终使用头文件中定义好的GUID常量,而不是自己手写字符串。
- 检查安全启动状态:如果要写入带有
AUTHENTICATED_WRITE属性的变量(如PK,KEK,db),而系统开启了安全启动,你必须提供正确的签名数据。否则操作会被拒绝。 - 检查存储空间:UEFI变量存储区有固定大小(通常由固件决定)。如果存储区已满,
SetVariable会失败。可以尝试删除一些不必要的、非标准的变量来释放空间。
- 检查属性:你是否在正确的时机访问变量?一个标记为
5.2 数据损坏与一致性难题
- 问题:读取到的变量数据乱码,或系统启动行为异常,怀疑变量被损坏。
- 预防与解决:
- 原子操作意识:
SetVariable本身设计为原子操作。但对于复合操作(如先读BootOrder,修改后再写回),你需要自己保证原子性。在UEFI环境下,这可能意味着需要临时提升中断级别或使用锁。在OS环境下,则需要防止多个进程同时修改。 - 备份关键变量:在对
BootOrder、BootNext等关键变量进行修改前,务必先读取并备份其原始值。这样在修改导致系统无法启动时,可以通过UEFI Shell或其他救援环境(如Linux Live CD)将其恢复。# Linux下备份BootOrder sudo efibootmgr > ~/efiboot_backup.txt # 或者直接备份二进制文件 sudo cp /sys/firmware/efi/efivars/BootOrder-8be4df61-93ca-11d2-aa0d-00e098032b8c ~/ - 理解变量格式:不要假设变量的数据格式。
BootXXXX是复杂的EFI_LOAD_OPTION结构,包含设备路径。错误地解析或构造这些数据会导致固件无法识别启动项。使用EDK II提供的库函数(如DevicePathFromText/DevicePathToText)来处理设备路径,而不是自己拼接字节。
- 原子操作意识:
5.3 调试与信息获取技巧
使用UEFI Shell进行底层调试: 如果你的设备支持进入UEFI Shell,这是最强大的调试环境。
Shell> dmpstore -b # 以二进制格式列出所有变量 Shell> dmpstore -guid 8BE4DF61-93CA-11D2-AA0D-00E098032B8C # 列出指定GUID的变量 Shell> dmpstore -s BootOrder # 搜索包含”BootOrder”的变量dmpstore命令比操作系统下的工具能看到更原始的状态,有助于判断是变量本身损坏,还是操作系统驱动读取有问题。查看内核日志: 在Linux中,使用
dmesg | grep -i efi可以查看内核初始化时关于EFI和变量的信息,有时能发现变量读取错误或空间不足的警告。利用固件设置界面: 很多主板的UEFI设置界面有“保存自定义设置”和“加载默认设置”选项。这本质上就是操作一组预定义的变量。当你怀疑变量混乱时,尝试“加载优化默认值”(Load Optimized Defaults)可以重置大部分非用户变量到出厂状态,但注意这会清除你所有的BIOS设置。
5.4 一个真实案例:修复因BootOrder损坏导致无法启动
我曾遇到一台服务器,在异常断电后无法启动,固件报错“No bootable device”。进入UEFI Shell后,用dmpstore查看,发现BootOrder变量存在,但数据长度异常(不是UINT16的整数倍)。推测是断电时写操作被中断,导致变量数据不完整。
修复步骤:
- 在UEFI Shell下,用
dmpstore -guid 8BE4DF61-93CA-11D2-AA0D-00E098032B8C列出所有全局变量。 - 发现
Boot0000,Boot0001等启动项变量都完好,里面有正确的硬盘设备路径。 - 由于
BootOrder损坏,我决定删除并重建它。
(Shell> setvar BootOrder -guid 8BE4DF61-93CA-11D2-AA0D-00E098032B8C -bs -rt -nv -d-d参数表示删除) - 创建一个新的
BootOrder变量,只包含一个有效的启动项索引(例如Boot0000)。
(注意:Shell> setvar BootOrder -guid 8BE4DF61-93CA-11D2-AA0D-00E098032B8C -bs -rt -nv -v 0000-v参数后的0000是十六进制值,实际写入的是两个字节0x00, 0x00) - 重启后,系统成功从
Boot0000对应的硬盘启动。
这个案例的关键教训是:关键变量(如BootOrder)的损坏虽然棘手,但只要你能访问UEFI Shell,并且知道其他启动项变量(BootXXXX)是完好的,就可以通过手动重建依赖关系来恢复。平时备份这些变量的内容(至少是efibootmgr -v的输出)非常重要。