您是否曾经想过,按下计算机电源按钮的那一刻会发生什么?在屏幕亮起之前,在那短暂的停顿背后,发生了一系列复杂的过程。本文将深入迷人的固件世界,探索不同组件在启动过程中如何交互。
通过了解这些联系,您将更清楚地了解使您的系统充满活力的基础元素。我们的主要重点是英特尔 x86 架构,但许多原则也适用于其他架构。
如果您错过了本系列的第一部分, 请点击此处查看。现在,让我们揭开固件背后的秘密。
为了理解固件组件如何交互,我们将探索整个架构及其所有连接部分。下图所示的执行流程从复位向量开始,它是第一阶段引导加载程序的一部分。从那里开始,它经历了各种固件阶段:
固件或 BIOS 通常可以分为两个主要部分,它们之间通常具有最小的接口:
平台固件的设计可以是单片的,结合硬件初始化和启动功能,也可以遵循模块化和分阶段的启动流程。设计选择取决于系统要求,可能对某些设备更有利。
下图说明了不同的固件组件如何交互以及如何一起使用来支持启动过程(箭头表示执行顺序):
如果这些图表现在看起来很复杂,请不要担心。阅读本文后再回顾一下,它们会更清晰。
此固件旨在初始化计算机和嵌入式系统,重点是最少的硬件初始化:只执行绝对需要的操作,然后将控制权交给第二阶段引导加载程序以启动操作系统。 FSBL不会从闪存芯片以外的存储介质加载操作系统。 由于它只初始化底层硬件,而不处理硬盘、SSD 或 USB 闪存驱动器等启动介质,因此需要另一个软件来实际启动操作系统。
FSBL 的主要职责:
在计算机发展的早期,开源软件并不流行,大多数 BIOS 实现都是专有的。只有少数可用的开放解决方案提供 BIOS POST 源代码,例如Super PC/Turbo XT BIOS和GLaBIOS 。这些项目旨在在 IBM 5150/5155/5160 系统和大多数 XT 克隆系统上运行。
然而,更知名的开源 BIOS 实现(如OpenBIOS和SeaBIOS )不执行硬件初始化,因为它们不打算在裸机上运行。但它们被广泛用作第二阶段引导加载程序,并在 QEMU 和 Bochs 等虚拟环境中本地运行。
无论如何,您几乎不需要直接使用这些早期的 BIOS 或深入研究它们的细节。但如果您有兴趣探索,上述存储库是一个很好的起点。
就目前的发展趋势而言,似乎没有专有 BIOS 解决方案的持续开发,而且面对现代替代方案,此类项目已经过时。
启动过程遵循分阶段的流程,在下图中从左侧开始,然后移至右侧。平台启动过程的时间线分为以下几个阶段,如黄色框所示:
ExitBootServices()
调用结束。
这个过程及其执行阶段在UEFI 平台初始化 (PI) 规范中有所介绍。不过,还有UEFI 接口(图中用粗蓝线表示),它不属于前一个文档,而是在UEFI 规范中有所描述。虽然UEFI的名称和频繁使用可能会造成混淆,但这两个文档的重点有所不同:
本质上,这两个规范都是关于接口的,只是层次不同。有关详细信息,您可以在UEFI 论坛网站上访问这两个规范。
UEFI PI最初设计为统一的固件解决方案,没有考虑第一阶段和第二阶段引导程序之间的区别。但是,当我们将UEFI称为第一阶段引导程序时,它包括SEC 、 PEI和早期 DXE阶段。我们将 DXE 分为早期和晚期阶段的原因是由于它们在初始化过程中扮演的角色不同。
在早期 DXE阶段,驱动程序通常执行必要的 CPU/PCH/主板初始化,并生成DXE 架构协议 (AP) ,这有助于将 DXE 阶段与平台特定的硬件隔离开来。AP 封装了特定于平台的细节,使后期 DXE阶段能够独立于硬件细节运行。
关于 Coreboot 工作原理的详细文章即将发布。关注我的社交媒体 - 它们很快就会发布!
初始硬件设置完成后,进入第二阶段。其主要作用是在操作系统和平台固件之间设置软件接口,确保操作系统可以管理系统资源并与硬件组件交互。
SSBL旨在尽可能隐藏硬件差异,通过处理大部分硬件级接口来简化操作系统和应用程序开发。这种抽象使开发人员可以专注于更高级别的功能,而不必担心底层硬件差异。
SSBL 的主要职责:
平台信息检索:从第一阶段引导加载程序获取平台特定信息,包括内存映射、SMBIOS、ACPI 表、SPI 闪存等。
运行平台独立驱动程序:包括 SMM、SPI、PCI、SCSI/ATA/IDE/DISK、USB、ACPI、网络接口等驱动程序。
服务实现(又名接口) :提供一组促进操作系统和硬件组件之间通信的服务。
设置菜单:提供系统配置的设置菜单,允许用户调整与启动顺序、硬件首选项和其他系统参数相关的设置。
启动逻辑:从可用的启动媒体定位并加载有效负载(可能是操作系统)的机制。
BIOS 中的接口称为BIOS 服务/功能/中断调用。这些功能提供了一组用于硬件访问的例程,但它们在系统特定硬件上执行的具体细节对用户是隐藏的。
在 16 位实模式中,可以通过INT x86 汇编语言指令调用软件中断轻松访问它们。在 32 位保护模式中,由于段值的处理方式不同,几乎所有 BIOS 服务都不可用。
我们以磁盘服务( INT 13h
) 为例,它使用柱面-磁头-扇区 (CHS)寻址提供基于扇区的硬盘和软盘读写服务,作为如何使用此接口的示例。假设我们要读取 2 个扇区 (1024 字节) 并将它们加载到内存地址0x9020 ,则可以执行以下代码:
mov $0x02, %ah # Set BIOS read sector routine mov $0x00, %ch # Select cylinder 0 mov $0x00, %dh # Select head 0 [has a base of 0] mov $0x02, %cl # Select sector 2 (next after the # boot sector) [has a base of 1] mov $0x02, %al # Read 2 sectors mov $0x00, %bx # Set BX general register to 0 mov %bx, %es # Set ES segment register to 0 mov $0x9020, %bx # Load sectors to ES:BX (0:0x9020) int $0x13 # Start reading from drive jmp $0x9020 # Jump to loaded code
如果您对 SeaBios 中这项服务的编写方式感兴趣,请查看src/disk.c 。
通过读取设备的第一个 512 字节扇区(扇区零)开始搜索可启动设备(可以是硬盘、CD-ROM、软盘等)。
BIOS 中的引导序列将它找到的第一个有效的主引导记录 (MBR)加载到计算机物理内存的物理地址0x7C00处(提示: 0x0000:0x7c00和0x7c0:0x0000指的是同一个物理地址)。
BIOS 将控制权移交给有效负载的前 512 个字节。这个已加载的扇区太小,无法容纳整个有效负载代码,其目的是从可启动设备加载剩余的有效负载。此时,有效负载可以使用 BIOS 公开的接口。
值得注意的是,早期并不存在 BIOS 规范。BIOS 是事实上的标准- 它的工作方式与 20 世纪 80 年代实际的 IBM PC 上的工作方式相同。其余制造商只是进行逆向工程并制作与 IBM 兼容的 BIOS。因此,没有法规阻止 BIOS 制造商发明新的 BIOS 功能或具有重叠的功能。
如前所述,UEFI 本身只是一个规范,有很多实现。最广泛使用的就是TianoCore EDK II ,它是 UEFI 和 PI 规范的开源参考实现。虽然仅靠 EDKII 不足以创建功能齐全的启动固件,但它为大多数商业解决方案提供了坚实的基础。
为了支持不同的第一阶段引导加载程序并提供 UEFI 接口,我们使用了UEFI Payload项目。它依赖于完成的初始设置和引导固件提供的平台信息来为 UEFI 环境准备系统。
UEFI Payload 使用DXE和BDS阶段,这两个阶段的设计与平台无关。它提供可适应不同平台的通用负载。在大多数情况下,它不需要任何自定义或针对特定平台的调整,并且可以通过使用第一阶段引导加载程序中的平台信息按原样使用。
UEFI Payload 的变体:
旧版 UEFI 有效负载:需要解析库来提取必要的特定于实现的平台信息。如果引导加载程序更新其 API,则有效负载也必须更新。
通用 UEFI 负载:遵循通用可扩展固件 (USF) 规范,使用可执行和可链接格式 (ELF)或平面映像树 (FIT)作为通用映像格式。它不需要自己解析它们,而是希望在负载入口处接收切换块 (HOB) 。
虽然Legacy UEFI Payload运行良好,但 EDK2 社区正试图将行业转向Universal UEFI Payload 。有效载荷之间的选择取决于您的固件组件。例如,如果没有我的补丁,就无法在Slim Bootloader上运行支持 SMM 的 Legacy Payload 。另一方面,将Universal Payload与 coreboot 一起使用需要一个垫片层来将coreboot 表转换为HOB ,这是 StarLabs EDK2 fork中才有的功能。
每个符合 UEFI 标准的系统都提供了一个系统表,该表会传递给在 UEFI 环境中运行的每个代码(驱动程序、应用程序、操作系统加载器)。此数据结构允许 UEFI 可执行文件访问系统配置表,例如ACPI 、 SMBIOS和一组UEFI 服务。
表结构在MdePkg/Include/Uefi/UefiSpec.h中描述:
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; // // A pointer to the EFI Runtime Services Table. // EFI_RUNTIME_SERVICES *RuntimeServices; // // A pointer to the EFI Boot Services Table. // EFI_BOOT_SERVICES *BootServices; UINTN NumberOfTableEntries; EFI_CONFIGURATION_TABLE *ConfigurationTable; } EFI_SYSTEM_TABLE;
服务包括以下类型:引导服务、运行时服务和通过协议提供的服务。
UEFI 通过设置UEFI 协议来抽象对设备的访问。这些协议是包含函数指针的数据结构,由全局唯一标识符 (GUID)标识,允许其他模块找到并使用它们。它们可以通过引导服务发现。
UEFI 驱动程序生成这些协议,实际函数(不是指针!)包含在驱动程序本身中。此机制允许 UEFI 环境中的不同组件相互通信,并确保操作系统在加载其自己的驱动程序之前可以与设备交互。
虽然某些协议是在 UEFI 规范中预定义和描述的,但固件供应商也可以创建自己的自定义协议来扩展平台的功能。
引导服务
提供只能在启动时使用的函数。这些服务保持可用,直到调用EFI_BOOT_SERVICES.ExitBootServices()
函数( MdeModulePkg/Core/Dxe/DxeMain/DxeMain.c )。
指向所有启动服务的指针都存储在启动服务表( MdePkg/Include/Uefi/UefiSpec.h ) 中:
typedef struct { EFI_TABLE_HEADER Hdr; ... EFI_GET_MEMORY_MAP GetMemoryMap; EFI_ALLOCATE_POOL AllocatePool; EFI_FREE_POOL FreePool; ... EFI_HANDLE_PROTOCOL HandleProtocol; ... EFI_EXIT_BOOT_SERVICES ExitBootServices; ... } EFI_BOOT_SERVICES;
运行时服务
操作系统运行时,仍有一组最少的服务可供访问。与引导服务不同,在任何有效负载(例如,操作系统引导加载程序)通过调用EFI_BOOT_SERVICES.ExitBootServices()
控制平台后,这些服务仍然有效。
指向所有运行时服务的指针都存储在运行时服务表( MdePkg/Include/Uefi/UefiSpec.h ) 中:
typedef struct { EFI_TABLE_HEADER Hdr; ... EFI_GET_TIME GetTime; EFI_SET_TIME SetTime; ... EFI_GET_VARIABLE GetVariable; EFI_GET_NEXT_VARIABLE_NAME GetNextVariableName; EFI_SET_VARIABLE SetVariable; ... EFI_GET_NEXT_HIGH_MONO_COUNT GetNextHighMonotonicCount; EFI_RESET_SYSTEM ResetSystem; ... } EFI_RUNTIME_SERVICES;
下图显示了启动和运行时服务的时间线,因此您可以准确地看到每个服务何时处于活动状态。
UEFI 规范定义了一个称为UEFI 启动管理器的启动策略引擎。它将尝试按特定顺序加载UEFI 应用程序。可以通过修改全局NVRAM(非易失性随机存取存储器)变量来配置此顺序和其他设置。让我们讨论其中最重要的一些:
Boot####
( ####
由一个唯一的十六进制值替换)—— 启动/加载选项。BootCurrent
— 用于启动当前运行系统的启动选项。BootNext
— 仅用于下次启动的启动选项。它仅用于一次BootOrder
,并在首次使用后被启动管理器删除。这允许您更改下次启动行为而不更改BootOrder
。BootOrder
— 有序的启动选项加载列表。启动管理器尝试启动此列表中的第一个活动选项。如果不成功,则尝试下一个选项,依此类推。BootOptionSupport
— 启动管理器支持的启动选项类型。Timeout
— 固件的启动管理器在自动从BootNext
或BootOrder
中选择启动值之前的超时时间(以秒为单位)。
使用efibootmgr(8)可以轻松从 Linux 获取这些变量:
[root@localhost ~]# efibootmgr BootCurrent: 0000 Timeout: 5 seconds BootOrder: 0000,0001,2001,2002,2003 Boot0000* ARCHLINUX HD(5,GPT,d03ca3cf-1511-d94e-8400-c7a125866442,0x40164000,0x100000)/File(\EFI\ARCHLINUX\grubx64.efi) Boot0001* Windows Boot Manager HD(1,GPT,6f185443-09fc-4f15-afdf-01c523565e52,0x800,0x32000)/File(\EFI\Microsoft\Boot\bootmgfw.efi)57a94e544f5753000100000088900100780000004200430044039f0a42004a004500430054003d007b00390064006500610038003600320063002d1139006300640064002d0034006500370030102d0061006300630031002d006600330032006200330034003400640034003700390035007d00000033000300000710000000040000007fff0400 Boot0002* ARCHLINUX HD(5,GPT,d03ca3cf-1511-d94e-8400-c7a125866442,0x40164000,0x100000) Boot2001* EFI USB Device RC Boot2002* EFI DVD/CDROM RC Boot2003* EFI Network RC
让我们根据上面的代码片段来看一下启动过程。UEFI 将开始迭代BootOrder
列表。对于列表中的每个条目,它会查找相应的Boot####
变量 — Boot0000
表示 0000, Boot2003
表示 2003,依此类推。如果变量不存在,它将继续查找下一个条目。如果变量存在,它会读取变量的内容。每个启动选项变量都包含一个EFI_LOAD_OPTION
描述符,它是可变长度字段的字节打包缓冲区(它只是数据结构)。
数据结构描述在[MdePkg/Include/Uefi/UefiSpec.h][ https://github.com/tianocore/edk2/blob/edk2-stable202405/MdePkg/Include/Uefi/UefiSpec.h#L2122 )
typedef struct _EFI_LOAD_OPTION { /// The attributes for this load option entry. UINT32 Attributes; /// Length in bytes of the FilePathList. UINT16 FilePathListLength; /// The user readable description for the load option. /// Example: 'ARCHLINUX' / 'Windows Boot Manager' / `EFI USB Device` // CHAR16 Description[]; /// A packed array of UEFI device paths. /// Example: 'HD(5,GPT,d03ca3cf-1511-d94e-8400-c7a125866442,0x40164000,0x100000)/File(\EFI\ARCHLINUX\grubx64.efi)' // EFI_DEVICE_PATH_PROTOCOL FilePathList[]; /// The remaining bytes in the load option descriptor are a binary data buffer that is passed to the loaded image. /// Example: '57a9...0400' in Boot0001 variable // UINT8 OptionalData[]; } EFI_LOAD_OPTION;
此时,固件将检查设备路径( EFI_DEVICE_PATH_PROTOCOL )。在大多数情况下,我们的计算机是从存储设备(硬盘/SSD/NVMe/等)启动的。因此,设备路径将包含HD(Partition Number, Type, Signature, Start sector, Size in sectors)
节点。
注意:如果您对如何转换其他路径感兴趣,请阅读UEFI 规范 v2.10,10.6.1.6 文本设备节点参考。
UEFI 将检查磁盘,看它是否有与节点匹配的分区。如果存在,则应使用特定的全局唯一标识符 (GUID) 对其进行标记,将其标记为EFI 系统分区 (ESP) 。该分区使用文件系统格式化,其规范基于特定版本的FAT 文件系统,名为EFI 文件系统;实际上,它只是一个普通的FAT12/16/32 。
File(\Path\To\The\File.efi)
的明确路径,则 UEFI 将查找该特定文件。例如, Boot0000
选项包含File(\EFI\ARCHLINUX\grubx64.efi)
。\EFI\BOOT\BOOT{arch}.EFI
(对于amd64为BOOTx64.EFI
或对于i386 / IA32为BOOTia32.EFI
)。此机制允许可启动的可移动媒体(例如 USB 驱动器)在 UEFI 中工作;它们只是使用后备启动路径。例如, Boot0002
选项将使用此机制。
注意:上面提到的所有Boot####
选项均指efibootmgr示例输出中显示的启动选项。
在这两种情况下, UEFI 启动管理器都会将UEFI 应用程序(可能是OS 引导程序、UEFI Shell、实用程序软件、系统设置等)加载到内存中。此时,控制权将转移到UEFI 应用程序的入口点。与BIOS不同, UEFI 应用程序可以将控制权返回给固件(除了应用程序接管系统控制的情况)。如果发生这种情况或出现任何问题,启动管理器将转到下一个Boot####
条目,并遵循完全相同的过程。
规范中提到,启动管理器可以自动维护数据库变量。这包括删除未引用或无法解析的加载选项变量。此外,它可以重写任何有序列表以删除任何没有对应加载选项变量的加载选项。
以上内容介绍了UEFI 启动。此外,UEFI 固件还可以在模拟 BIOS 的兼容支持模块 (CSM)模式下运行。
由固件(通常是第二阶段引导程序)启动并使用其接口加载操作系统内核的软件。它可以像操作系统一样复杂,提供以下功能:
这些程序的共同设计超出了本文的讨论范围。有关流行操作系统引导加载程序的详细比较,您可以参考ArchLinux wiki和Wikipedia 文章。
Windows 系统使用其专有的操作系统引导加载程序,称为Windows 启动管理器 (BOOTMGR) 。
固件不再是一小段复杂的代码。它已经变成了大量复杂的代码,而当前的趋势只会助长这一点。我们可以在固件上运行Doom 、 Twitter和许多其他有趣的应用程序。
了解整体架构有助于在脑海中组织这些组件。通过检查现有固件的设计,您可以深入了解每次启动计算机时展开的迷人过程。这种自上而下的视角不仅阐明了每个部分的作用,还突出了现代固件系统的复杂和不断发展的性质。