UEFI 启动基础:规范、流程与 BIOS 的差异
好,我们正式开始第二讲。这一章是地基,把 UEFI 搞明白,后面用 Yocto 构建启动分区时你才知道自己在拼什么。
很多工程师做了好几年嵌入式,对 UEFI 的理解还停留在「按 F2 进设置」这个层面。说实话,这远远不够。你想想看,UEFI 本质上是一个微型操作系统,它负责把硬件从冷启动状态带到能加载操作系统内核的状态。这个过程中,它要初始化 CPU、内存、外设,还要找到启动设备、加载引导程序。
我个人习惯把 UEFI 比作「硬件与操作系统之间的翻译官」。它说一种叫 EFI 字节码的语言,不依赖具体的 CPU 架构。这一点跟传统 BIOS 完全不同,后面我会细说。
UEFI 规范简介
UEFI 的全称是 Unified Extensible Firmware Interface,统一可扩展固件接口。它由 UEFI 论坛维护,目前最新的正式版本是 2.10。规范本身非常厚,大概有 2500 多页,但我们做嵌入式开发不需要全读,抓住几个核心概念就行。
UEFI 规范的核心要素:
- EFI 系统分区 (ESP):一个 FAT32 格式的分区,存放引导加载程序、驱动和应用。这是 Yocto 构建时我们要重点处理的对象。
- 固件文件系统 (FFS):固件内部存储文件的格式,跟 ESP 不是一回事。
- 协议 (Protocols):UEFI 驱动和应用程序之间的接口规范。比如你写一个网卡驱动,就要实现 Simple Network Protocol。
- 启动管理器 (Boot Manager):UEFI 固件内置的启动项管理工具,决定从哪个设备、哪个文件启动。
- 运行时服务 (Runtime Services):操作系统启动后仍然可以调用的固件服务,比如获取系统时间、设置启动项。
我记得刚接触 UEFI 时,最让我困惑的是「为什么 ESP 必须是 FAT32?」后来才明白,FAT32 是 UEFI 规范强制要求的,因为它的实现简单,而且几乎所有操作系统都能读写。但这里有个坑——FAT32 单文件最大只能 4GB,如果你的内核镜像超过这个大小,就得用其他方式加载了。
UEFI 启动流程
UEFI 的启动流程比 BIOS 清晰得多。它分七个阶段,每个阶段都有明确的任务。我画了一张流程图,你一看就明白。
这七个阶段里,我们做嵌入式开发最常打交道的是 BDS 和 TSL 阶段。BDS 阶段决定从哪个设备启动,TSL 阶段加载引导程序。在 Yocto 构建中,我们生成的 bootx64.efi 或 grubx64.efi 就是在 TSL 阶段被加载的。
一个小技巧: 调试 UEFI 启动问题时,可以在固件设置中开启「串口重定向」或「UEFI Shell」。UEFI Shell 是一个命令行环境,你可以手动执行 fs0:\EFI\BOOT\bootx64.efi 来测试引导程序是否正常。我曾经靠这个排查过一个 grub 配置文件路径写错的 bug,省了至少半天时间。
UEFI 与 BIOS 的区别
很多人觉得 UEFI 就是 BIOS 的升级版,其实不对。它们的架构差异非常大。我整理了一张对比表,你一看就清楚。
| 对比维度 | 传统 BIOS | UEFI |
|---|---|---|
| 接口模式 | 16 位实模式,只能访问 1MB 空间 | 32/64 位保护模式,可访问全部内存 |
| 启动速度 | 慢,需要自检所有硬件 | 快,可以并行初始化设备 |
| 分区表 | MBR,最大支持 2TB 磁盘 | GPT,支持 9.4ZB 磁盘 |
| 引导加载方式 | 读取 MBR 中的引导代码 | 直接加载 EFI 应用程序 (.efi) |
| 驱动模型 | Option ROM,16 位代码 | EFI 驱动,跨架构 |
| 安全启动 | 不支持 | 支持 Secure Boot,验证签名 |
| 用户界面 | 文本菜单,键盘操作 | 图形界面,支持鼠标 |
这里我想重点说一下 安全启动 (Secure Boot)。这是 UEFI 引入的一个关键特性,也是我们在 Yocto 构建中必须处理的问题。Secure Boot 要求所有在 TSL 阶段加载的 EFI 应用程序都必须有合法的数字签名。如果你自己编译了一个 grub.efi,没有签名就直接放到 ESP 分区,固件会拒绝加载它。
避坑指南: 我曾经在一个项目中,用 Yocto 构建了一个自定义的 UEFI 引导程序,烧到板子上死活启动不了。查了两天才发现是 Secure Boot 在作怪。解决方案有两个:要么在固件设置中关闭 Secure Boot,要么给引导程序签名。对于开发板,我建议先关掉 Secure Boot 调试,等稳定了再考虑签名流程。
UEFI 的启动项管理
UEFI 固件内部维护了一个启动项列表,存储在 NVRAM 中。每个启动项包含一个名称、一个设备路径和一个文件路径。比如:
Boot0000: Ubuntu PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x0,0x0)/HD(1,GPT)/\EFI\ubuntu\shimx64.efi
Boot0001: UEFI Shell PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x0,0x0)/HD(1,GPT)/\EFI\tools\Shell.efi
启动顺序由 BootOrder 变量控制。固件会按照这个顺序依次尝试每个启动项,直到成功为止。在 Yocto 构建中,我们通常会把引导程序放在 \EFI\BOOT\bootx64.efi 这个默认路径,这样即使 NVRAM 被清空,固件也能找到它。
嗯,这里要注意:不同架构的默认路径不一样。x86_64 是 bootx64.efi,ARM64 是 bootaa64.efi,IA32 是 bootia32.efi。在 Yocto 的机器配置文件中,你需要正确设置这些路径。
UEFI 与嵌入式 Linux 的关联
你可能觉得 UEFI 是 PC 领域的东西,跟嵌入式关系不大。其实不然。现在很多 ARM 开发板(比如 Raspberry Pi 4、Jetson 系列)都开始支持 UEFI。原因很简单:UEFI 提供了一套标准化的启动接口,让 Linux 内核可以不用关心底层硬件的差异。
在嵌入式场景下,UEFI 带来的好处很明显:
- 设备树传递:UEFI 可以通过
EFI_DT_FIXUP_PROTOCOL修改设备树,把硬件信息传给内核。 - ACPI 支持:对于服务器级别的嵌入式系统,UEFI 可以生成 ACPI 表,让操作系统发现硬件资源。
- 统一启动流程:不管你是 x86 还是 ARM,UEFI 的启动流程都一样,减少了平台适配的工作量。
我个人觉得,UEFI 在嵌入式领域的普及是必然趋势。你想想看,以前做嵌入式 Linux 启动,每个板子都要写一套 bootloader 脚本,从 U-Boot 的环境变量到内核加载地址,全是板级相关的。有了 UEFI,这些都可以标准化。Yocto 项目也提供了 meta-efi 层来支持 UEFI 启动,我们后面会详细讲。
一个实用的调试命令: 在 UEFI Shell 中,你可以用 bcfg 命令管理启动项。比如 bcfg boot dump 列出所有启动项,bcfg boot add 0 fs0:\EFI\BOOT\bootx64.efi "My Bootloader" 添加一个新启动项。这个命令在调试时非常有用,我几乎每周都会用到。