Open18

UEFI bootloader を Rust で作る

TakatomTakatom

rustのtargetに、 x86_64-unknown-uefi が追加されている。ただし、これはTier 3サポートで、macOSで試したところうまくコンパイルできなかった。(リンカーエラーが出てしまう)
Linux環境(Docker on macOS)で試したところ、ちゃんとリンクできた。

TakatomTakatom

macOSで x86_64-unknown-uefi をコンパイルできるようにするのも、Rustへのコミットとしていいかもしれない。

TakatomTakatom

x86_64-unknown-uefi へのコンパイル方法。
このターゲットは標準ライブラリを同梱していないので、標準ライブラリも一緒にビルドする必要がある。
少し前まではそのために cargo-xbuild を利用する必要があったが、最近のcargoでは新しく -Z build-std オプションが追加され、これを指定することで標準ライブラリをその場でビルドするようになった。

`cargo +nightly rustc --target x86_64-unknown-uefi -Z build-std`

ただし、そのためには

rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu

コマンドで rust-src コンポーネントをインストールする必要がある。

TakatomTakatom

qemuで実行できた!

qemu-system-x86_64 -nographic -bios ~/Desktop/OVMF-X64-r15214/OVMF.fd -drive format=raw,file=fat:rw:image

-nographic を指定することでコンソールで qemu を起動できる。終了するときは CTRL-a x.

TakatomTakatom

UEFIのbootloaderからkernelをロードするの、実際どうやればいいんだ?

なんか色々設定して、 EFI_BOOT_SERVICES.ExitBootServices 関数を呼び出して、kernelのエントリーポイントにジャンプすればいいのか?

TakatomTakatom

どうやってカーネルのエントリーポイント取得してるんだろ

TakatomTakatom

BootService の LocateProtocol() 関数を使って、まず Simple File System Protocol をロードする。

LocateProtocol() は、指定のプロトコルへのハンドラを取得するための関数。

Simple File System Protocol は Volume へのアクセスを提供するプロトコル。このプロトコルの OpenVolume() 関数を実行することで、ファイルへのアクセスを提供する File Protocol を取得できる。

つぎに、 File ProtocolOpen() 関数を使い、カーネルのファイルを読み込む。 Open() 関数は指定のパスのファイルへのアクセスを提供する File Protocol を返す。ちなみに最初に取得した File Protocol は root directory へのアクセスを提供していた。

その後、 File ProtocolRead() 関数や SetPosition() 関数などを使ってファイルを読み込み、BootService の AllocatePages() 関数などを使い、メモリ上に配置していく。(この辺はまだちゃんと調べられてない)

TakatomTakatom

UEFIの仕様書の ExitBootServices の部分をみてみる。

EFI_BOOT_SERVICES.ExitBootServices()

The ExitBootServices() function is called by the currently executing UEFI OS loader image to terminate all boot services. On success, the UEFI OSloader becomes responsible for the continued operation of the system. All events of type EVT_SIGNAL_EXIT_BOOT_SERVICES must be signaled before ExitBootServices() returns EFI_SUCCESS. The events are only signaled once even if ExitBootServices() is called multiple times.

A UEFI OS loader must ensure that it has the system’s current memory map at the time it calls ExitBootServices(). This is done by passing in the current memory map’s MapKey value as returned by EFI_BOOT_SERVICES.GetMemoryMap(). Care must be taken to ensure that the memory map does not change between these two calls. It is suggested that GetMemoryMap()be called immediately before calling ExitBootServices(). If MapKey value is incorrect, ExitBootServices() returns EFI_INVALID_PARAMETER and GetMemoryMap() with ExitBootServices() must be called again. Firmware implementation may choose to do a partial shutdown of the boot services during the first call to ExitBootServices(). A UEFI OS loader should not make calls to any boot service function other than GetMemoryMap() after the first call to ExitBootServices().

On success, the UEFI OS loader owns all available memory in the system. In addition, the UEFI OS loader can treat all memory in the map marked as EfiBootServicesCode and EfiBootServicesData as available free memory. No further calls to boot service functions or EFI device-handle-based protocols may be used, and the boot services watchdog timer is disabled. On success, several fields of the EFI System Table should be set to NULL. These include ConsoleInHandle, ConIn, ConsoleOutHandle, ConOut, StandardErrorHandle, StdErr, and BootServicesTable. In addition, since fields of the EFI System Table are being modified, the 32-bit CRC for the EFI System Table must be recomputed.

Firmware must ensure that timer event activity is stopped before any of the EXIT_BOOT_SERVICES handlers are called within UEFI drivers. UEFI Drivers must not rely on timer event functionality in order to accomplish ExitBootServices handling since timer events will be disabled.

これによると、

  • boot service を終了する時に ExitBootServices を呼び出す
  • この関数を呼び出す前に、OS loader (bootloader) はシステムのメモリマップを取得していなければならない
    • それを保証するため、 EFI_BOOT_SERVICES.GetMemoryMap() で取得できる MapKeyExitBootServices に渡す
    • 渡された MapKey が現在のメモリマップのものではない場合、 ExitBootServices は失敗する
  • 呼び出しに成功した後は、OS loader がシステムの実行を続ける責任を負う
    • OS loader は、システム上のメモリ全てを使用できる
    • さらに、boot service が使用していたメモリも使用できるようになる
    • EFI System Table のいくつかのフィールドは NULL にセットされる(利用不可になる)(入出力プロトコルなど)
TakatomTakatom

UEFIの呼出規約について

https://github.com/rust-lang/rust/pull/65809

とか見ると、UEFIの呼出規約は

  • x86, x86_64 プラットフォーム → win64 ( MS_ABI )
  • それ以外 → C

ってなってる(他のサイトでもそうなってる)んだけど、仕様書を見ると、 2.3.2 IA-32 Platforms2.3.4 x64 Platforms のとこにそれぞれ、

All functions are called with the C language calling convention.

って書いてある。
仕様書が間違ってる?

TakatomTakatom

windowsでの、Cの呼出規約、ということっぽい。

実際に内容を見てみると、UEFIの仕様には

The caller passes the first four integer arguments in registers. The integer values are passed from left to right in Rcx, Rdx, R8, and R9 registers.

とある。これは、windows における x64 の呼出規約 ( win64 ) に等しい。

他の環境での x64 の呼出規約 (System-V ABI) は、第一引数からそれぞれ rdi, rsi, rdx, rcx に設定する。また、第6引数までレジスタを利用する。

TakatomTakatom

boot loaderのエントリーポイントが呼び出された時のメモリレイアウトについて考えてみる。

パッと思いつく領域は、

  1. エントリーポイントが配置された領域
  2. boot service などの関数群が配置された領域

それぞれ調べてみる

TakatomTakatom

エントリーポイントのアドレスは↓のコードで取得できる。

writeln!(st.stdout(), "efi_main : {:p}", efi_main as *const ()).unwrap();

as *const () っていうやり方は知らなかったな。

TakatomTakatom

エントリーポイントの他に、ローカル変数、標準出力プロトコル、SimpleFileSystemプロトコルのアドレスをそれぞれ取得してみたら、想像とちょっと違う結果になった。
けどこれはファームウェアの実装次第(今回はOVMF)だと思うので、OVMFが特殊なだけかも。

エントリーポイント 0x673fa80
ローカル変数 0x7f94a34
標準出力プロトコル 0x7dd0b10
SimpleFileSystemプロトコル 0x6f33030

つまり、エントリーポイントの場所とローカル変数の場所の間に、各種プロトコルがロードされてる。
Rustのコードで表現するとこんな感じ?

fn efi_main(...) {
    fn simple_file_system(...) {
        ...
    }

    fn standard_output(...) {
        ...
    }

    let local_var = 42;
}