UEFI标准文档https://uefi.org/specifications
UEFI标准文档包含了很多C代码片段,它们具体定义了很多struct。对于UEFI标准程序的开发来说,实际上是一个输入输出如下的函数:

有关各个变量的具体定义,可以在"2.3.1 Data Types(P.23)"找到:

EFI_STATUS表示的是状态码,它的类型是UINTN。UINTN是一个与平台相关的类型,在32位CPU上,它表示32位无符号整数“unsigned int”,而在64位CPU上,它表示64位无符号整数“unsigned long long”。
UEFI标准中还定义了IN、OUT、OPTIONAL、EFIAPI等修饰符(Modifier),它们是对函数或参数的说明。在像EDK2或是gnu-efi这些既有的开发环境和工具链中,它们均被定义为空字符串。
main函数的两个主要输入
main函数由一个指代自己的EFI Handle和一个EFI System Table作为输入。
EFI Handle本身被认为是一个指针,一般来说,函数指针内部的结构是不可解析的,但具体到EDK2这样的环境中来说,使用了IHANDLE的某种具体的struct对EFI Handle进行解析。因此可以说EFI Handle本身是一个32位或64位的指针,其指针在kernel中的内部实例是如下一个struct的展开:
struct {
UINT Signature; //表明Handle的类别
LIST_ENTRY AllHandles; //所有IHANDLE组成的链表
LIST_ENTRY Protocols; //此Handle的Protocols链表
UINTN LocateRequest;
UINT64 Key;
} IHANDLE;"EFI_SYSTEM_TABLE"是函数中最重要的一个概念,它在"4.3 EFI System Table(P.78)"中被定义:

UEFI标准文档中有一个叫做“协议(Protocol)”的概念。在UEFI中,功能被分为一个个单元,这些单元被称为“协议”,并且它们被定义成以_PROTOCOL结尾的结构体。名如~_PROTOCOL这样的结构体包含一系列的函数指针,通过这些函数指针可以调用UEFI固件中的内置功能。
可以在"11.4 Simple Text Output Protocol(P.424)"找到PROTOCOL的描述。
每个Protocol本身的结构体是任意的,在UEFI中,通过系统表可以实现基于GUID的检索PROTOCOL,并由PROTOCOL出发,去检索当前的IHANDLE。
下面是PROTOCOL INTERFACE和PROTOCOL ENTRY的描述:
struct {
UNIT Signature;
LIST_ENTRY Link;
IHANDLE *Handle;
LIST_ENTRY ByProtocol;
VOID *Interface;
LIST_ENTRY OpenList;
UINTN OPenListCount;
} PROTOCOL_INTERFACE;
struct {
UINT Signature;
LIST_ENTRY AllEntries;
EFI_GUID ProtocolID;
LIST_ENTRY Protocols;
LIST_ENTRY Notify;
} PROTOCOL_ENTRY;可以看出,由UEFI的系统表中提供的OpenProtocol、HandleProtocol、LocateProtocol这些函数,可以具体访问到一个HANDLE或PROTOCOL的句柄(通过GUID的查询),然后在PROTOCOL中可以关联到HANDLE中去,抑或是由PROTOCOL ENTRY本身包含的entry地址直接跳转到对应的Protocol结构体中。
Protocol结构体本身不感知GUID,Protocol通过一个entry地址挂载在PROTOCOL ENTRY中后,被UEFI的系统表以GUID的方式纳管。

在"Protocol Interface Structure",可以看到它有一个叫做"OutputString"的成员,这就是被用于在屏幕上显示文字的函数。图2.4中的"Parameters"是对各个成员的描述,并且"OutputString"拥有一个指向其定义页面的链接(下划线的"OutputString()"处)。

用于清屏的函数是EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL中的ClearScreen函数(标准文档"11.4 Simple Text Output Protocol(P.437)")。

Grub2的struct
Grub2中有关系统调用表的描述在/include/grub/efi/api.h文件中描述:
struct grub_efi_system_table
{
grub_efi_table_header_t hdr;
grub_efi_char16_t *firmware_vendor;
grub_efi_uint32_t firmware_revision;
grub_efi_handle_t console_in_handler;
grub_efi_simple_input_interface_t *con_in;
grub_efi_handle_t console_out_handler;
grub_efi_simple_text_output_interface_t *con_out;
grub_efi_handle_t standard_error_handle;
grub_efi_simple_text_output_interface_t *std_err;
grub_efi_runtime_services_t *runtime_services;
grub_efi_boot_services_t *boot_services;
grub_efi_uintn_t num_table_entries;
grub_efi_configuration_table_t *configuration_table;
};
typedef struct grub_efi_system_table grub_efi_system_table_t;当然,如果你不想要这么详尽的解析系统表中的内容,有一些内容你也可以当作一个buff忽略调。
这里忍不住提及一些C语言的奥妙:
structA定义的数据结构在代码B中实例化后,其指针作为参数传递给函数C。在函数C中存在一个另类的structD,函数C可以根据B传递给它的指针,使用structD来解析指针原本对应的structA结构的数据。具体来说就是下面这样:
#include <stdio.h>
#include <stdint.h>
// 定义 UINT16 类型,确保跨平台兼容性
typedef uint16_t UINT16;
// 定义 structA: 由8个char组成
typedef struct {
char c[8];
} structA;
// 定义 structB: 由两个UINT16组成
typedef struct {
UINT16 u1;
UINT16 u2;
} structB;
// 处理函数:接收 structA 的指针,将其作为 structB 解析并打印
void parse_and_print(structA *pA) {
// 将 structA 的指针强制转换为 structB 的指针
// 注意:这种操作依赖于内存对齐和结构体 packing。
// 由于 char 和 uint16_t 在大多数编译器默认对齐下,
// structA (8 bytes) 和 structB (4 bytes, 但这里我们需要映射前4个字节或者整个8字节?)
// 题目说 structB 由两个 UINT16 组成,通常意味着 structB 大小为 4 字节。
// 但 structA 有 8 字节。
// 如果 structB 只有两个 UINT16,它只覆盖 structA 的前 4 个字节。
// 如果意图是解析全部 8 个字节,structB 应该包含 4 个 UINT16。
// 但题目明确说“structB由两个UINT16组成”,因此我们只解析前 4 个字节。
// 为了确保严格别名规则(Strict Aliasing Rule)的安全性和可移植性,
// 实际工程中推荐使用 memcpy,但为了符合题目“使用structB解析指针”的意图,
// 这里使用指针强制转换。在实际编译时可能需要关闭严格别名警告或使用 __attribute__((packed))。
structB *pB = (structB *)pA;
// 打印结果
// 注意:输出值取决于系统的字节序(Endianness)
printf("StructB element 1 (u1): 0x%04X (Decimal: %u)\n", pB->u1, pB->u1);
printf("StructB element 2 (u2): 0x%04X (Decimal: %u)\n", pB->u2, pB->u2);
}
int main() {
// 实例化 structA
structA myStructA;
// 分别赋值 8 个 char 为 0x1 到 0x8
myStructA.c = 0x1;
myStructA.c = 0x2;
myStructA.c = 0x3;
myStructA.c = 0x4;
myStructA.c = 0x5;
myStructA.c = 0x6;
myStructA.c = 0x7;
myStructA.c = 0x8;
// 调用函数,传递指针
parse_and_print(&myStructA);
return 0;
}
它跑出来是这个样子的:

这可以说明两个问题:1、输入的指针使用什么struct进行解析是任意的,你只需要注意一下大端序和小端序的问题,免得两个变量叠加起来有毛病就行了;2、如果输入的指针使用了短struct进行解析,那么没有被遍历到的部分就会被舍弃。
回归正题,我们的主要目的,是通过解析GRUB2中有关EFI系统描述的内容,获得一份正确的struct的描述,并根据此去编写一份独属于我们自己的裸机变成struct。
首先是EFI系统表的头,它也在/include/grub/efi/api.h文件中描述:
struct grub_efi_table_header
{
grub_efi_uint64_t signature;
grub_efi_uint32_t revision;
grub_efi_uint32_t header_size;
grub_efi_uint32_t crc32;
grub_efi_uint32_t reserved;
};
typedef struct grub_efi_table_header grub_efi_table_header_t;这一部分比较简单,由于是直接嵌套关系,因此在EFI中实实在在就有这么多位宽的数据存在表示头了。
接下来是几个可以从名字中看出来的结构:*firmware_vendor、firmware_revision,一个是指针类型的,直接与当前UEFI固件挂钩,一般是一个64位的地址。firmware_revision从描述中就能看出来,是一个uint32的32位宽的东西。
然后是比较关键的efi handle的结构,这个结构本身在api的文件中就是一个void *的指针类型定义,含义很明确,就是代表一个64位的指针地址。但是,在UEFI固件内核中,实际上还是有一个针对这个地址的struct展开(就是前文所属的IHANDLE的struct),只不过它不在grub中描述,你可以去https://github.com/tianocore/edk2下载EDK2的源代码,去UEFI固件内核中找一个参考的(由于UEFI固件都是各做各的,所以只是一个参考)。
后续的*con_in也是一个指针,指向的是一个具体的PROTOCOL,这里是一个HANDLE+一个PROTOCOL,就将一个完整的输入方向的设备+协议都告知过来了。
后续的output、错误debug方向也是一样,都是各自一个HANDLE+一个PROTOCOL就搞定了。
最后,这个struct再给出了一个运行时表的指针、一个boot启动服务表的指针,一个uint宽度的描述table entries数目的变量,一个配置表的指针。这样EFI传递过来的系统表本身就结束了。
算下来,这个系统表其实就是一个如下结构位宽的东西:
struct grub_efi_system_table
{
uint64_t signature;
uint32_t revision;
uint32_t header_size;
uint32_t crc32;
uint32_t reserved;
uint64_t firmware_vendor_point;
uint32_t firmware_revision;
uint64_t console_in_handler;
uint64_t con_in_point;
uint64_t console_out_handler;
uint64_t con_out_point;
uint64_t standard_error_handle;
uint64_t std_err_point;
uint64_t runtime_services_point;
uint64_t boot_services_point;
uint32_t num_table_entries;
uint64_t configuration_table_point;
};
typedef struct grub_efi_system_table grub_efi_system_table_t;这样一划拉,是不是就看起来舒服多了。EFI表是一个总共896位宽的数据体,其中第288到415之间是描述输入的两个64位宽的指针,可以表示UEFI提供的一种快速输入的描述;第416到543之间的64位宽的两个指针,是UEFI固件提供的快捷方式的输出描述。
裸机编程时,单文件可用的struct大合集
单纯的console作为主输入输出的情况,使用如下代码可以“最直白,最不绕弯子”的方式解决HelloWorld的问题。
struct EFI_SYSTEM_TABLE {
char _buf[60];
struct EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL {
unsigned long long _buf;
unsigned long long (*OutputString)(
struct EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *This,
unsigned short *String);
unsigned long long _buf2[4];
unsigned long long (*ClearScreen)(
struct EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *This);
} *ConOut;
};
void efi_main(void *ImageHandle __attribute__ ((unused)),
struct EFI_SYSTEM_TABLE *SystemTable)
{
SystemTable->ConOut->ClearScreen(SystemTable->ConOut);
SystemTable->ConOut->OutputString(SystemTable->ConOut, L"Hello UEFI!\n");
while (1);
}注意,你编译出来的文件需要搭配合理的lds内存段映射,并且以PE头的方式描述。
但是,对于笔记本电脑而言,未必UEFI固件提供的这个CONIN与CONOUT的HANDLE与PROTOCOL就是显示器(vedio)的东西。从Grub2的源代码来看,如果你想要以完整的显示器、键盘作为输入输出的方式的话,是需要调用系统表中的OpenProtocol函数,根据GUID去寻找显示器Vedio的Protocol实例,并将这种CONIN与CONOUT指向正确的设备地方,才能算HelloWorld打印成功。
最后这一部分内容将在下一篇文章中结合Grub2的具体源代码来展开讲。