BIOS和UEFI是两种不同的固件标准,在x86/x86_64架构下,机器上电后需要先进入BIOS或UEFI,才能引导进操作系统。

本文对UEFI标准做个简单概述,并给出Rust下开发UEFI应用的说明。本文对应的具体代码见timetomb。

UEFI

本文当前版本参考的UEFI 标准 2.10版本。 之前或之后版本大概率对本文内容没有影响。

本节对我们要用到的UEFI 标准内容和API做个简述。

概述

UEFI标准的目的是为不同架构/平台与OS之间提供一层抽象。让OS loader等避免深入到各种不同的硬件细节中。

UEFI的Boot Manger读取NVRAM变量来自动加载和执行UEFI image。通过写入NVRAM变量,可以控制系统启动条目,启动文件等。

UEFI Firmware之上运行的程序为UEFI image。其二进制格式为PE32+,但是header signature有修改。 UEFI image可以分为不同类型,包括UEFI application,UEFI boot service driver和UEFI runtime driver。主要是加载的内存类型以及运行退出后的处理等有区别。 UEFI OS loader是一种UEFI application,只是它实现上一般从UEFI firmware中获取控制权后不在返回。UEFI firmware对此并没有特殊处理。

UEFI firmware为UEFI image 提供了两类service:boot service和runtime service。UEFI image通过调用这些API可以标准化的使用firmware提供的功能。 顾名思义,runtime service在OS 运行期间还能调用,而boot service只能在boot期间调用(在调用ExitBootService之前)。

我们知道在普通操作系统的用户空间开发的应用,操作系统会给进程提供统一的运行环境,例如提供虚拟内存,处于ring 3特权级,开发者不需要关系这些底层细节。 在UEFI环境下,类似的也需要有个确定的执行环境配置。以下是x86_64下运行环境的一些说明,具体请参考spec。

  • CPU处于uniprocessor模式。(UEFI spec引用的是2009版的Intel sdm第三卷8.4章的内容,笔者没找到这个版本的sdm。应该类似2016版本中的BSP。例如处于ring 0特权级)
  • CPU 处于long mode;开启分页,但是虚拟地址和物理地址相同;开启中断

UEFI 规范也定义了在不同CPU架构下的calling convention。我们本文中不需要处理calling convention的细节,因为rust在编译时会帮我们处理。

Entry Point

typedef
EFI_STATUS
(EFIAPI *EFI_IMAGE_ENTRY_POINT) (
  IN EFI_HANDLE                  ImageHandle,
  IN EFI_SYSTEM_TABLE            *SystemTable
  );

UEFI image的入口函数如上,Firmware加载UEFI image的时候会把两个参数传给该入口函数。

ImageHandle: firmware给当前UEFI image分配的handle
SystemTable: 指向EFI system table的指针。通过EFI system table可以访问UEFI firmware提供的各种服务(API)。

细节请参考spec。

内存相关函数

BootService中内存管理的API主要有以下几个:

AllocatePages: Allocates pages of a particular type.
FreePages: Frees allocated pages.
AllocatePool: Allocates a pool of a particular type
FreePool: Frees allocated pool
GetMemoryMap: Returns the current boot services memory map and memory map key.

应当注意,在UEFI image里显式分配的内存应该显式释放掉,UEFI image退出并不会像操作系统的进程退出一样自动回收内存。

UEFI会把内存分成不同的类型来管理,如EfiBootServicesCodeEfiBootServicesDataEfiConventionalMemory表示未分配的内存。

具体参数,用法等请参考spec的’Services - Boot Service’ 章节。

Note:

GetMemoryMap获取当前的memory map。类似BIOS下的e820。例如:

Type: EfiConventionalMemory PhysicalStart: 0x0 VirtualStart: 0x0 Pages: 135 Attribute: 15 Address: 0x6742018
Type: EfiBootServicesData PhysicalStart: 0x87000 VirtualStart: 0x0 Pages: 1 Attribute: 15 Address: 0x6742048
Type: EfiConventionalMemory PhysicalStart: 0x88000 VirtualStart: 0x0 Pages: 24 Attribute: 15 Address: 0x6742078
Type: EfiConventionalMemory PhysicalStart: 0x100000 VirtualStart: 0x0 Pages: 1792 Attribute: 15 Address: 0x67420a8
Type: EfiACPIMemoryNVS PhysicalStart: 0x800000 VirtualStart: 0x0 Pages: 8 Attribute: 15 Address: 0x67420d8
Type: EfiConventionalMemory PhysicalStart: 0x808000 VirtualStart: 0x0 Pages: 3 Attribute: 15 Address: 0x6742108
Type: EfiACPIMemoryNVS PhysicalStart: 0x80b000 VirtualStart: 0x0 Pages: 1 Attribute: 15 Address: 0x6742138
Type: EfiConventionalMemory PhysicalStart: 0x80c000 VirtualStart: 0x0 Pages: 4 Attribute: 15 Address: 0x6742168
...

应当注意,通过AllocatePool等分配或释放内存有可能造成memory map的变化。

函数调用,局部变量分配等栈上的内存使用应该不会造成memory map 变化。因为栈使用的内存仍被标记为EfiConventionalMemory(至少在qemu上用OVMF固件测试如此),UEFI spec要求栈最小128k。 x86_64架构下上栈向下生长,但局部变量的顺序可能被编译器调整

AllocatePool最后一个参数是二级指针void **buffer,指向分配的内存地址。原因是需要在*buffer中存储分配的内存地址,需要传指向*buffer的指针。对使用方来说,可以直接当作指针使用,避免类型转换。

AllocatePages 最后一个参数EFI_PHYSICAL_ADDRESS *Memory,没有用二级指针。EFI_PHYSICAL_ADDRESSunit64

个人认为原因:1. memory不仅作为输出参数,也作为输入参数,语义上更接近于地址数值而不是指针;2. 结果 *memory一般不会直接作为指针使用,通过allocate page多半是要自己处理分配到的page,直接使用应该直接用allocate_pool了。

Rust 实现说明

UEFI环境和上一篇中的纯baremetal编程是有区别的。UEFI本身已经提供了一个运行环境和API。 Rust当前已经支持uefi platform,对应x86_64的target triple为x86_64-unknown-uefi

FFI

在Rust里调用UEFI firmware提供的服务或者firmware 调用image 的entry point,都需要使用Rst的FFI。

要调用firmware提供的服务,需要根据uefi spec的定义,把用到的数据类型和服务用rust struct表示出来。为了简单,我们对uefi spec做尽量少的封装,而且只封装用得到的部分。uefi-rs提供了比较完善的rust对uefi的封装。

以BootService为例,spec里定义的struct类型如下

#define EFI_BOOT_SERVICES_SIGNATURE 0x56524553544f4f42
#define EFI_BOOT_SERVICES_REVISION EFI_SPECIFICATION_VERSION

typedef struct {
  EFI_TABLE_HEADER     Hdr;

  //
  // Task Priority Services
  //
  EFI_RAISE_TPL        RaiseTPL;       // EFI 1.0+
  EFI_RESTORE_TPL      RestoreTPL;     // EFI 1.0+

    //
    // Memory Services
    //
    EFI_ALLOCATE_PAGES   AllocatePages;  // EFI 1.0+
    EFI_FREE_PAGES       FreePages;      // EFI 1.0+
    EFI_GET_MEMORY_MAP   GetMemoryMap;   // EFI 1.0+
...

typedef
EFI_STATUS
(EFIAPI \*EFI_GET_MEMORY_MAP) (
   IN OUT UINTN                  *MemoryMapSize,
   OUT EFI_MEMORY_DESCRIPTOR     *MemoryMap,
   OUT UINTN                     *MapKey,
   OUT UINTN                     *DescriptorSize,
   OUT UINT32                    *DescriptorVersion
  );

改写为rust struct主要遵循了下面几个规则:

  • 结构体用#[repr(C)]修饰,以使用C内存布局。
  • 为简化使用,所有字段都是public的。
  • 不需要的字段不能直接忽略,但需要占位,以保证后续的字段有正确的偏移。
  • 函数指针都用unsafe extern "efiapi"修饰。
  • 函数参数:IN对应immutable的数据类型, OUT对应mutable的数据类型; 根据实际情况选择对应的rust类型。对于指针,根据使用便利选择引用或者裸指针都可以。

例如,上面BootService对应的部分rust代码如下:

#[repr(C)]
pub struct BootServices {
    pub header: Header,

    // Task Priority services
    pub raise_tpl: Ignore,
    pub restore_tpl: Ignore,

    // Memory allocation functions
    pub allocate_pages: unsafe extern "efiapi" fn(
        alloc_ty: u32,
        mem_ty: MemoryType,
        count: usize,
        addr: &mut u64,
    ) -> Status,
    pub free_pages: unsafe extern "efiapi" fn(addr: u64, pages: usize) -> Status,
    pub get_memory_map: unsafe extern "efiapi" fn(
        size: &mut usize,
        map: *mut MemoryDescriptor,
        key: &mut MemoryMapKey,
        desc_size: &mut usize,
        desc_version: &mut u32,
    ) -> Status,
...

Log to stdout

EFI system table中提供了向Console输出的能力。通过实现fmt::Writelog::Log trait达到通过日志api方便地输出内容到Console的效果。一个简单的实现请见logger.rs

结语

本文的内容只是一个最简单的UEFI Application,获取UEFI下的memory map并输出到控制台。 有了这个基础,我们就可以考虑如何从UEFI转到操作系统Kernel了。

Reference