起因

自制工业控制系统:从UEFI到内核》定下了路线:零外部 Cargo 依赖,与固件打交道的这一层参考 r-efi 手写 FFI 绑定,代码全部 vendor 进自己的仓库。这篇把这个系列真正的第一步展开:从 UEFI 应用入口写起,拿齐所有"过了这村没这店"的资源,最后调用 ExitBootServices() 完成固件到系统的权力交接。这一篇的目标是:照着做就能跑。权力交接为什么长这个样子,有段来历:BIOS 是 1981 年 IBM PC 的遗产,16 位实模式加 1MB 地址空间的假设,靠 INT 10h/13h 这类软中断服务往后缝缝补补撑了近二十年;九十年代末 Intel 给 64 位安腾平台做启动方案,发现这套底子实在撑不住,1998 年立项 EFI 推倒重来,2005 年捐给 UEFI 论坛变成开放标准——今天的主板固件几乎清一色是它。我们手写 FFI 对接的不是某家厂商的私有接口,是这份二十多年没换过骨架的行业标准。

一、入口与调用约定:先避开 ABI 地雷

用官方 x86_64-unknown-uefi 目标编译(rustup target add x86_64-unknown-uefi),产物就是合法的 .efi 文件,放到 ESP 分区 EFI/BOOT/BOOTX64.EFI,QEMU 里 qemu-system-x86_64 -bios ovmf.fd -serial stdio 就能调。入口签名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#![no_std]
#![no_main]

#[no_mangle]
extern "efiapi" fn efi_main(
    image: EFI_HANDLE,                    // *mut c_void
    system_table: *mut EFI_SYSTEM_TABLE,
) -> u64 {                                // EFI_STATUS,0 = EFI_SUCCESS
    0
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }

要点:

  • extern "efiapi" 对应 Microsoft x64 调用约定(前四个参数走 RCX/RDX/R8/R9,调用方清栈),UEFI 规范钦定,跟 Linux 侧 SysV(RDI/RSI/RDX/RCX)不同。用官方 target 编译器替你处理对了;自建工具链混用两种 ABI 是裸机项目最常见的暴毙点
  • EFI_SYSTEM_TABLE 里这一层用到的关键字段:BootServices: *mut EFI_BOOT_SERVICES(主角,函数指针表)、ConfigurationTable(按 GUID 找 ACPI RSDP 的地方)、ConOut(调试期文字输出)

EFI_BOOT_SERVICES 是一张几十个函数指针的表,我们实际用到的只有这几个,绑定时按需声明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#[repr(C)]
pub struct EFI_BOOT_SERVICES {
    pub hdr: [u64; 6],                        // 表头,跳过
    pub raise_tpl: usize,
    pub restore_tpl: usize,
    pub allocate_pages: usize,
    pub free_pages: usize,
    pub get_memory_map: extern "efiapi" fn(
        memory_map_size: *mut usize,
        memory_map: *mut EFI_MEMORY_DESCRIPTOR,
        map_key: *mut usize,
        descriptor_size: *mut usize,
        descriptor_version: *mut u32,
    ) -> u64,
    pub allocate_pool: usize,
    pub free_pool: usize,
    // ... 后面按需补:create_event, locate_protocol, exit_boot_services ...
}

结构体字段顺序和偏移必须跟规范一致,多一个少一个都会错位——所以逐条对着 r-efi 的定义搬(MIT/Apache-2.0 宽松许可,注明出处后 vendor 进仓库,从此与上游脱钩)。

二、拿 GOP:帧缓冲五元组

1
2
3
4
5
6
7
static GOP_GUID: EFI_GUID = EFI_GUID {
    data1: 0x9042a9de, data2: 0x23dc, data3: 0x4a38,
    data4: [0x96, 0xfb, 0x7a, 0xde, 0xd0, 0x80, 0x51, 0x6a],
};

let mut gop: *mut c_void = core::ptr::null_mut();
(boot_services.locate_protocol)(&GOP_GUID, null_mut(), &mut gop);

locate_protocol 的第一个参数是协议 GUID——协议就是"带 GUID 的接口",这是 UEFI 一切服务的寻址方式。GOP 结构里真正要用的字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#[repr(C)]
pub struct EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE {
    pub max_mode: u32,
    pub mode: u32,
    pub info: *mut EFI_GRAPHICS_OUTPUT_MODE_INFORMATION,
    pub size_of_info: usize,
    pub frame_buffer_base: u64,   // 帧缓冲物理基址
    pub frame_buffer_size: usize,
}
#[repr(C)]
pub struct EFI_GRAPHICS_OUTPUT_MODE_INFORMATION {
    pub version: u32,
    pub horizontal_resolution: u32,
    pub vertical_resolution: u32,
    pub pixels_per_scan_line: u32,  // pitch(像素单位)
    pub pixel_format: u32,          // 1 = BGRA8888,Intel 核显最常见
    // ...
}

拿到这五个值——基址、宽、高、每行像素数、像素格式——显示的全部家当就到手了。显卡篇会回到这里接着写像素。

三、拿内存映射:物理内存管理的唯一依据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let mut map_size = 0usize;
let mut map_key = 0usize;
let mut desc_size = 0usize;
let mut desc_ver = 0u32;
// 第一次调用只为问大小
(boot_services.get_memory_map)(&mut map_size, null_mut(),
    &mut map_key, &mut desc_size, &mut desc_ver);
map_size += 2 * desc_size;                    // 留余量,防两次调用间变化
let buf = allocate_pool(map_size);
(boot_services.get_memory_map)(&mut map_size, buf,
    &mut map_key, &mut desc_size, &mut desc_ver);

坑都在细节里:

  • 返回 EFI_BUFFER_TOO_SMALL 是正常流程不是错误;描述符数组大小随设备状态变化,必须留余量(惯例是多留两个描述符)
  • 描述符是变长的(desc_size 通常 40 字节,别硬编码 size_of::<T>()),遍历方式是 ptr + i * desc_size 跳着走
  • 描述符里 type == 7(EFI_CONVENTIONAL_MEMORY)的区间是可用内存,其余类型要么已占用要么有特殊用途(如 MMIO);这个数组就是后面物理页分配器的输入

顺手在 ExitBootServices 之前用 GetTime 拿一次 RTC 时间做日志起点,再调 SetWatchdogTimer(0, 0, 0, null) 关看门狗——UEFI 默认开着 5 分钟看门狗,不关的话内核初始化到第 4 分 59 秒机器直接重启,不留任何错误信息。

四、ExitBootServices():一个重试循环

规范设了"对暗号"机制:GetMemoryMap 返回 MapKeyExitBootServices 校验它;两次调用之间只要有事件触发或内存分配,key 就失效返回 EFI_INVALID_PARAMETER(5)。标准写法:

1
2
3
4
5
6
7
loop {
    // 重新取内存映射,拿到新鲜 map_key(这一次的缓冲区要提前分配好)
    (boot_services.get_memory_map)(...);
    let status = (boot_services.exit_boot_services)(image, map_key);
    if status == 0 { break; }
    // 失败说明期间有事件/分配发生,回顶部重来;实践一两轮就过
}

把"最后一次 GetMemoryMap“和”ExitBootServices“之间的代码压到最少,别在中间碰任何可能分配内存的接口。返回 0 的那一刻起:Boot Services 作废、协议作废、ConOut 作废;CPU 仍在 64 位长模式、仍用固件建好的 identity 分页——不需要任何"跳转内核"的仪式,efi_main 接下来的代码就是内核本身。Runtime Services 理论上还活着,但用不到 UEFI 变量服务就当它不存在。

五、Handoff:出生证明

移交产物收敛成一个结构体,后面的内核拿它当出生证明:

1
2
3
4
5
6
7
8
9
#[repr(C)]
pub struct Handoff {
    pub fb: FrameBufferInfo,   // 五元组:base/宽/高/pitch/像素格式
    pub mmap: *mut u8,         // EFI_MEMORY_DESCRIPTOR 数组
    pub mmap_size: usize,
    pub mmap_desc_size: usize,
    pub rsdp: *const u8,       // ACPI 根指针
    pub boot_time: EfiTime,    // GetTime 拿的墙钟起点
}

它就放在 efi_main 栈上或紧跟内核镜像的 .bss 里——反正 efi_main 接下来的代码就是内核本身,不存在"跨模块传不过去"的问题。ACPI RSDP 的取法:遍历 system_table.configuration_table,匹配 ACPI 2.0 的 GUID(8868e871-e4f1-11d3-bc22-0080c73c8881),取其 vendor_table 字段——后续 MADT/MCFG/HPET 全从这张表往下找。

总结

这一篇的实战清单:官方 uefi target 起项目、薄 FFI 绑定层搬 r-efilocate_protocol 拿 GOP 五元组、两次 GetMemoryMap 问大小再取映射、关看门狗、重试循环闯 ExitBootServices、RSDP 从 Configuration Table 按 GUID 取出。跑通的标志:QEMU 里执行到 ExitBootServices 返回 0 后,串口每隔几秒打一行心跳横幅(直写 16550,此时 ConOut 已经死了),挂过 5 分钟不复位——顺手把"看门狗确实关掉了"这件事也验了。下一篇接管 GDT/IDT。

参考链接