写在前面:
本文是笔者UEFI学习过程中的笔记,放在博客上与大家分享。文章结构不一定完整;排版不一定好看;有些要点可能需要相关背景知识才能更好地理解。
笔者自己才疏学浅,许多知识点尚未形成体系。所以,欢迎讨论!
1. 需要编译的工程文件,应当被包含在虚拟机(OvmfPkg)的.dsc文件的[Components]区块中。
2. 编译完成后,将虚拟磁盘挂载到/mnt/disk/,而后将.efi文件复制到/mnt/disk/。之后卸载虚拟磁盘。然后,启动虚拟机,即可在fs0目录下显示该文件。
3. UEFI Shell对于文件名并不区分大小写。
4. UEFI Shell启动时,会试图寻找fs0:/EFI/BOOT/BOOTX64.EFI这一文件。如果找到该文件(不区分大小写),则会立即进入Shell界面;否则,它会扫描磁盘,找到该文件,用户需要等待一段时间(通常为30-60秒)。为了加快进入Shell界面,应当将Shell文件重命名为BOOTX64.EFI(不区分大小写)。
5. 如果一个UEFI程序(包括应用程序和驱动程序)需要引用一个全局GUID,则应当进行如下配置:
① 根据应用程序的使用场景,将该GUID写入一个被该应用程序所引用的模块的.dec文件中。例如,应用程序中引用的GUID,往往写在MdePkg/MdePkg.dec文件,或MdeModulePkg/MdeModulePkg.dec文件中。
② 在应用程序.inf文件的[Packages]区块中,引用①中所述的.dec文件。
③ 在应用程序.inf文件的[Guids]区块中,声明该GUID对应的全局变量名。
步骤①②③缺一不可。
6. QEMU、OVMF与UEFI应用程序的关系:QEMU是一个虚拟机Hypervisor,可以管理虚拟机,并在其中运行UEFI应用程序。但是,运行UEFI应用程序必须有UEFI固件(Firmware)的支持,而QEMU默认情况下并没有安装UEFI固件。
OVMF,全称为Open Virtual Machine Firmware,是一个为虚拟机提供必要固件的软件包。OVMF提供的固件中,就包含UEFI固件。因此,在QEMU中指定使用OVMF后,就可以在QEMU中执行UEFI应用程序了。
7. Linux EFI启动时的函数调用过程
efi_pe_entry ->
make_boot_params ->
Init systemTable and image_handle
Verify signature to check if booted from EFI firmware
setup_boot_service64 ->
Set SystemTable, BootService and RuntimeService
efi_call_early(handle_protocol, handle, &proto, (void *)&image) ->
…
efi_table_attr ->
((efi_boot_services_64_t*)(unsigned long) efi_config->boot_services)->handle_protocol
Invoke loadedImage->boot_services->handle_protocol
efi_low_alloc
Load bzImage's 1st sector (bzImage header)
efi_convert_cmdline /* convert unicode to ascii */
handle_cmdline_files /* Load ramdisk */
efi_main ->
Init systemTable and image_handle
Verify signature to check if booted from EFI firmware
setup_boot_service64
Generate cmdline_paddr
efi_get_secureboot
Clear memory
setup_graphics
setup_efi_pci
setup_quirks
efi_call_early
exit_boot /* Destroy UEFI boot_service */
8. UEFI应用程序、UEFI驱动
UEFI应用程序和UEFI驱动都是运行于UEFI环境中的二进制文件,用于实现具体的功能。区别在于,UEFI应用程序只在运行期间存在于内存中;而UEFI驱动一旦加载后就会常驻内存,只要没有离开UEFI环境,就可以被随时调用。驱动通常要有特定硬件设备的支持;但是,下一节提到的“服务型驱动”则不需要。
安装驱动的函数:InstallProtocolInterface。
调用驱动的函数:OpenProtocol/LocateProtocol。
9. Image Handle
UEFI应用程序被运行时,会被加载到内存,这块内存区域就称为image,而指向这块内存区域的指针,称为Image Handle。在大多数语境下,我们会用Image Handle指代当前正在运行的应用程序实例。
10. UEFI系统中驱动的分类
UEFI系统中的驱动,可分为UEFI驱动和DXE驱动。
① UEFI驱动
此类驱动遵循UEFI驱动模型,开发人员无需显式注册驱动到某个Handle上。
进入UEFI Shell后,UEFI系统就会自动检查每个Handle是否能够安装此类驱动(取决于该Handle是否实现了驱动的接口函数)。若是,则UEFI系统会自动将此驱动安装到该Handle上。
UEFI驱动需要和特定硬件相匹配。
② DXE驱动
此类驱动不遵循UEFI驱动模型,开发人员必须显式注册驱动到某个Handle上。最常见的做法是,将Handle注册到当前正在运行的Handle镜像(又称为Image Handle上)。
DXE驱动可以不与硬件相关联。
11. UEFI服务型驱动
11.1 概念
UEFI服务型驱动,顾名思义,就是把UEFI服务写成驱动形式,常驻内存,以便随时调用。UEFI中的“服务”是一个较为宽泛的概念,泛指提供一种完整功能的程序。
举一个很简单的例子:假如我们编写了这样一个程序,在UEFI环境中被调用时,会在控制台上输出“Hello World”,那么我们就可以称这个程序为“Hello World服务”。如果我们把“Hello World服务”写成UEFI驱动,那么它就是“Hello World服务型驱动”。我们需要首先加载这个服务型驱动,让它常驻内存;而后,我们可以随时调用它,从而在控制台输出“Hello World”。
开发UEFI服务型驱动有利于UEFI服务开发的模块化。
UEFI服务型驱动属于DXE驱动——因为它并不需要特定硬件的支持。但是,由于驱动必须安装在某个控制器或Handle上,所以我们通常会将驱动安装于模块本身——具体来说,是在模块入口函数中,将服务安装到入口函数的ImageHandle参数上。
另外,如果将服务编写成UEFI应用程序,由于它与一般意义上理解的应用程序概念完全相同,所以我们通常不会称之为“UEFI服务型应用程序”,而是直接说“UEFI应用程序”,如同第8节所述的那样。
11.2 代码结构
11.2.1 GUID声明、定义和引用
每个UEFI程序都需要有唯一的GUID作为标识符,UEFI服务型驱动也不例外。
#define EFI_FFDECODER_PROTOCOL_GUID { ...... } // (1)
#define EFI_FFDECODER_PROTOCOL_GUID // 为兼容旧版EFI标准而作的宏定义
extern EFI_GUID gEfiFFDecoderProtocolGUID; // (2)
注意:语句(2)仅仅作了extern声明,并未定义该变量的值。依照UEFI开发规范,该变量的值是定义在某个.dec文件当中的,并且:
① 其变量命名规则是“g开头 + (1)中宏定义的CamelCase形式”;
② 其值必须与(1)中的宏定义值相同。
之后,需要在项目的.inf文件中,引用该全局变量。这样,编译之后自动生成的源文件,就会包含这个全局变量的值。
11.2.2 PROTOCOL结构体、PRIVATE_DATA结构体及二者的关系
Protocol结构体包含了一系列指针,这些函数指针会被服务所提供的一系列接口函数所实例化。可以把Protocol结构体类比为面向对象编程中的成员函数。
PRIVATE_DATA结构体包含了一系列服务所需的属性,以及一个指向Protocol结构体的指针。可以把PRIVATE_DATA结构体类比为面向对象编程中的类,它包含了成员变量和成员函数(Protocol结构体)。
PRIVATE_DATA的第一个成员,必须是一个32位的Signature;第二个成员,必须是指向对应Protocol结构体的函数指针。这两者共同确保CR宏定义能够返回正确的结果,详见第12节。
11.2.3 PRIVATE_DATA全局实例及Protocol的注册流程
在完成PRIVATE_DATA的声明后,我们需要注册一个PRIVATE_DATA的全局实例。
之后,服务的PRIVATE_DATA初始化函数,将会引用这个全局的PRIVATE_DATA实例,并初始化其所包含的Protocol各个方法。而后,如果任何一个Handle需要将该Procotol注册到它身上,该Handle只需要在入口函数中,调用gBS->InstallProtocolInterface(或其他注册Protocol的函数),并提供全局PRIVATE_DATA对象的Protocol成员的首地址,即可完成注册。
12. UEFI PRIVATE_DATA结构详解
12.1 UEFI PRIVATE_DATA结构
根据UEFI开发规范,UEFI PRIVATE_DATA结构体的首个成员变量,必须是Signature,它是一个32位无符号整数。对于每种结构体,开发人员可以用宏定义的形式,自定义其合法Signature的值。由于32位是4字节,所以通常用4个字符来宏定义Signature。
此外,UEFI PRIVATE_DATA结构体的第二个成员变量,必须是指向Protocol结构体的指针。
以DISK_IO_PRIVATE_DATA结构体为例,其相关定义为:
#define FFDECODER_PRIVATE_DATA_SIGNATURE SIGNATURE_32 (‘V’, ‘I’, ‘D’, ‘O’) /* Signature宏定义 */
typedef struct {
UINT32 Signature; /* 结构体中实际的Signature */
EFI_FFDECODER_PROTOCOL FFDecoder; /* 成员指针变量FFDecoder,对应Protocol的实例 */
/* ……上下文数据…… */
} FFDECODER_PRIVATE_DATA;
#define FFDECODER_PRIVATE_DATA_FROM_THIS (a) \
CR (a, FFDECODER_PRIVATE_DATA, FFDecoder, FFDECODER_PRIVATE_DATA_SIGNATURE)
12.2 CR宏定义的作用
最后一行的CR宏定义,能够在给定DiskIo的前提下,返回包含该DiskIo的DISK_IO_PRIVATE_DATA结构体的起始地址。这个宏很重要——因为对于一个运行中的Protocol结构体,我们能够直接获取到的,只有其首地址(This指针);但是,Protocol当中只包含方法;而服务的上下文数据是保存在PRIVATA_DATA当中的。只有获取了包含Protocol的PRIVATE_DATA,才能修改服务的上下文数据。CR宏正是建立了二者之间的转换关系。
12.3 Signature在CR宏定义中的校验作用
以12.1中的代码为例,假设我们现在能够获取到一个运行中的Protocol结构体的地址FFDecoder,我们如何确保该FFDecoder确实是属于FFDECODER_PRIVATE_DATA的成员变量,而不是属于其他结构体,或只是一个单独的指针变量呢?
这就需要用到Signature——CR宏会检查FFDecoder变量自身的地址(注意不是其所指向的地址)之前4个字节(32位)的内存内容,与预先定义的PRIVATE_DATA_SIGNATURE(在本例中,即为’V’, ‘I’, ‘D’, ‘O’)是否一致。
如果二者一致,则认为FFDecoder确实是属于某个FFDECODER_PRIVATE_DATA的Protocol,而后就可以将FFDecoder的地址,减去Signature的长度(4字节),从而返回包含该FFDecoder的FFDECODER_PRIVATE_DATA首地址;否则,认为该FFDecoder并不属于FFDECODER_PRIVATE_DATA,从而返回空指针。
应用实例:
EFI_STATUS EFIAPI
OpenVideo(IN EFI_FFDECODER_PROTOCOL *This, IN CHAR16 *FileName)
{
FFDECODER_PRIVATE_DATA *Private = FFDECODER_PRIVATE_DATA_FROM_THIS(This);
/* …… */
}
13. initrd与ramdisk
① initrd是执行Linux内核镜像文件(即启动Linux内核)的必要参数之一。其中“rd”的全称是ramdisk。
② initrd自身是一个二进制文件,其内容可以理解为一个微型Linux文件系统,包含了Linux内核启动阶段的必要文件。Linux内核启动时,会通过特定驱动,将initrd加载到内存中,(本节下文将“加载到内存中的initrd文件内容”称为ramdisk),从而访问其中所包含的文件系统。即使进入到Linux操作系统,ramdisk仍然会存留。
③ 如果Linux根文件系统挂载失败,但ramdisk挂载成功,则Linux会进入initramfs界面。此时,用户仅能查看ramdisk文件系统中包含的文件,以及执行一些基本命令。
④ Linux内核中有着关于生成initrd的源代码。因此,每次重新编译Linux内核之后,initrd也会随之更新。当我们编译Linux内核之后,通常会使用最新的Linux内核映像文件和最新的initrd。
⑤ 如果在操作系统环境中直接修改ramdisk中所包含的文件,那么,这些改动仅会反映在RAM内存中,而不会同步到磁盘上的initrd文件。操作系统重启后,这些改动就会失效。
⑥ 要让对ramdisk的改动永久生效,就必须改动initrd文件。但是,initrd文件本身是二进制文件,无法直接手动修改。改动方式有两种:一是通过ramdisk驱动修改,二是修改Linux源代码中与生成initrd文件有关的代码,而后重新编译Linux内核,生成修改后的initrd文件。
14. gST、gBS、gRT、gImageHandle
前三者都是指向EFI全局服务的指针。
gST:系统表,指向EFI System Table的入口。
gBS:启动服务,指向EFI Boot Service Table的入口。
gRT:运行时服务,指向EFI Runtime Service Table的入口。
三者都只适用于UEFI应用程序和DXE驱动。
gImageHandle指向应用程序ImageHandle的入口。
这些指针都是在UEFI入口程序被调用之前,就已经在UefiBootServicesLib文件的构造函数中初始化完毕的。因此,只要在工程文件中引用UefiBootServicesLib,就可以在UEFI模块的任何函数当中使用它们。
14.1 System Table
System Table是UEFI内核中的一个全局结构体,可理解为UEFI内核入口。它包含6个部分:
- Table Header(表头)
- 固件信息
- ConIn(标准输入控制台)、ConOut(标准输出控制台)、StdErr(标准错误控制台)
- Boot Service Table(启动服务表)
- Runtime Service Table(运行时服务表)
- System Configuration Table(系统配置表)
注意:UEFI应用程序和UEFI内核占用的是同一个地址空间。这里所说的UEFI用户空间和内核空间,只是借用了操作系统当中的习惯称谓,分别代表UEFI应用程序和UEFI内核(即UEFI自身数据结构,如Boot Service、Runtime Service)所在的地址空间。事实上,UEFI环境中只有一种优先级,即Ring0,因此,用户空间可以自由访问内核空间的数据(可以类比x86实模式)。
14.2 Boot Service
UEFI的核心数据结构,提供了一系列重要的UEFI内核服务。包括:
- 事件服务(事件、优先级、定时器)
- 内存管理服务(内存的分配与释放、内存映射)
- Protocol管理服务(安装和卸载Protocol、注册Protocol通知函数)
- Protocol使用类服务(Protocol的打开与关闭)
- 驱动管理服务(安装和卸载驱动)
- Image管理服务(加载、卸载、启动、退出UEFI应用程序或驱动)
- ExitBootServices服务(结束Boot Service)
- 其他服务
Boot Service可以类比为操作系统的内核。
14.3 Runtime Service
UEFI内核向上层(操作系统、Bootloader、UEFI应用程序、UEFI驱动)提供的服务,包括:
- 时间服务
- 读写系统变量
- 虚拟内存服务
- 其他服务
Runtime Service可以类比为操作系统的系统调用。
15. UEFI标准应用程序加载流程
16. 磁盘的EFI分区及其挂载点
如果一台装载了Linux机器的硬件支持EFI启动,并且编译出来的内核启用了EFI启动,那么,要想顺利启动Linux内核,则内核二进制文件(bzImage.efi)必须被放在磁盘上任何一个文件系统为FAT32的磁盘分区,这个磁盘分区就称为EFI分区。EFI分区通常很小(常见大小为512MB)。
开机进入UEFI Shell后,显示器上只会显示EFI分区中的文件。假设bzImage.efi已经位于EFI分区中,则手动执行该文件,即可启动内核(注意bzImage.efi是一个UEFI标准应用程序,所以其执行过程参见第15节)。
进入Linux操作系统后,EFI分区会被默认挂载到/boot/efi/目录下。换言之,如果在Linux操作系统中,将文件移动到/boot/efi/目录,那么再次开机进入UEFI Shell后,我们就可以看到这些文件。
17. 签名与验签
签名:用于确认消息的发送者
验签:用于确认消息的完整性(消息内容没有被篡改)
签名与验签是UEFI Secure Boot的基础。