🙌

ゼロからのOS自作入門 in Rust /ブートローダまで

10 min read

https://www.amazon.co.jp/gp/product/B08Z3MNR9J/
を始めました。 #![no_std] 環境でのRustを試したかったため、Rustで実装しています。ブートローダからカーネルを起動するところまで進めましたが、色々と差異があるので書き残しておきたいと思います。

https://github.com/yubrot/ors

この記事が書かれた時点ではNightly Rustが必要です。

ブートローダをRustで

uefi-rs

本書では、2章よりUEFI アプリケーションの記述のためEDK IIのセットアップを行っています。ただ、UEFIはあくまで仕様であり、EDK IIはUEFI アプリケーションの開発に必須ではありません (1章のハローワールドがそうであったように)。

Rustではuefiクレートがよく使用されているようです。筆者も試したところ、

  • 本書で利用されているEDK IIのAPIとの大きな差異を感じず、適度に抽象化されている
  • グローバルアロケータやパニックハンドラの実装など、 [no_std] 環境下の配慮も整っている
  • Rustらしく、Rustの言語機能を活用したAPIデザインがされている
    • CとRustの書き心地の差がそのままAPIの使い心地の差になっている

と、試した範囲では良い感触だったためこのクレートを採用することにしました。

Cargo.toml
[dependencies]
uefi = {version = "0.11", features = ["alloc", "exts"]}
uefi-services = "0.8"

いくつかのフィーチャーと補助クレートについては後述します。

最初のUEFI アプリケーションをビルド

いくつか通常のRustプログラムでは見られない記述があります。

main.rs
#![no_std]
#![no_main]
#![feature(abi_efiapi)]

// uefi-servicesを明示的にリンク対象に含める
extern crate uefi_services;

use core::fmt::Write;
use uefi::prelude::*;

#[entry]
fn efi_main(_image: Handle, mut st: SystemTable<Boot>) -> Status {
    st.stdout().reset(false).unwrap_success();
    writeln!(st.stdout(), "Hello, World!").unwrap();

    loop {}
}
  • stdクレートを使用しないため、 [no_std] 属性が必要です。UEFI アプリケーション上でフルセットのstdは動作させられず、よりミニマムで依存フリーなcoreクレート (およびalloc, compiler_builtins等の標準クレート群) を用いることになります。システムへの依存がない Result<T, E>Option<T> などはcoreクレートで提供されており、 [no_std] 環境でも使用することができます。
  • UEFI アプリケーションはエントリポイントが特殊なため、 [no_main] を指定してuefiクレートの提供する [entry] 属性を付けた関数を定義しています。
  • EDK IIではブートサービスにグローバルな gBS からアクセスしていましたが、uefiクレートを用いたRustではエントリポイントに渡された引数からアクセスします (ex. st.boot_services())。
  • [no_std] で必要な [panic_handler] は、uefi-servicesクレートによって与えられています。

Rustはターゲットとして x86_64-unknown-uefi をサポートしており、 --target に指定してUEFI アプリケーションをビルドできます。

cargo build \
  -Zbuild-std=core,compiler_builtins \
  -Zbuild-std-features=compiler-builtins-mem \
  --target x86_64-unknown-uefi

そのままビルドすると、 x86_64-unknown-uefi ではサポートされないstdがインストールされていない旨エラーとなるため、 -Zbuild-std でコンパイル対象に標準のクレート群を明示して含めるようにします。また、 -Zbuild-std-features=compiler-builtins-mem によって、coreクレートが要求する memcpy 等の実装をcompiler_builtinsクレートに提供させます。compiler_builtinsもstdクレートおよびRustコンパイラの屋台骨となるクレートの一つのようです。

これらビルドオプションは、毎回指定する代わりに .cargo/config.toml に記述できます。

.cargo/config.toml
[build]
target = "x86_64-unknown-uefi"

[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]

あわせてrust-analyzerで全てのターゲットに対する検証を切っておきます。エディタ上で x86_64-unknown-linux-gnu をターゲットとした時のコンパイルエラーが通知されなくなります。

settings.json (VSCodeの場合)
    ...
    "rust-analyzer.checkOnSave.allTargets": false,
    ...

alloc

allocクレートはstdの裏にある標準のクレートの一つで、動的なアロケーションを要する BoxVec 等を提供しています。これらの型はcoreクレートと同じくstdクレートが再exportしているので、通常のRustプログラミングでは意識することはありません。今回は [no_std] な環境なので明示的に使用することになります (build-std のオプションにもまた含める必要があります)。

allocクレートは動的なメモリの確保・解放のため グローバルアロケータ を必要としますが、これをuefiクレートの alloc フィーチャーが提供しています。これ有効にし、 uefi-servicesクレートの補助関数 init を用いて初期化すると、動的なアロケーションを伴う機能を利用できるようになります。

#[entry]
fn efi_main(image: Handle, mut st: SystemTable<Boot>) -> Status {
    uefi_services::init(&mut st).unwrap_success();

    let v = vec![0; 32]; // 動的なアロケーション

    ...
}

ただし、uefi-servicesクレートによるグローバルアロケータの実装は ブートサービスの機能を利用しており、ブートサービス終了後は有効でない ため注意する必要があります。特に動的確保された値がブートサービス終了後にDropされる状況も不正です。

fn exit_boot_services(image: Handle, st: SystemTable<Boot>) -> SystemTable<Runtime> {
    let enough_mmap_size =
        st.boot_services().memory_map_size() + 8 * mem::size_of::<MemoryDescriptor>();
     // 動的確保したVecをleakしてDropを回避する
    let mmap_buf = vec![0; enough_mmap_size].leak();
    let (st, _) = st
        .exit_boot_services(image, mmap_buf)
        .expect_success("Failed to exit boot services");
    st
}

(2021/07/16修正) mem::forget ではなく Vec::leak を用いるように

ローダの実装

以降はuefiクレートを用いてひたすら実装していく形になります。

メモリマップの取得

BootServices::memory_mapから取得できます。返値がメモリディスクリプタのイテレータになっています。

ファイル操作

uefiクレートの exts フィーチャーを有効にすると、 BootServices::get_image_file_system から SimpleFileSystem が抽象化されたインターフェースを利用できます。
前述の alloc を有効にしていれば、ファイルを Vec<u8> に読み込んで返すなども記述できます。

最初のカーネルをRustで

カーネル側も同様に [no_std] でプログラムしていきます。ローダ(UEFI アプリケーション)側と異なりuefi-servicesクレートの手助けはないため、 [panic_handler] も自身が提供する必要があります。とりあえず無限ループさせておきます。

main.rs
#![no_std]
#![no_main]
#![feature(asm)]

#[no_mangle]
pub extern "sysv64" fn kernel_main() {
    loop {
        unsafe { asm!("hlt") }
    }
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {
        unsafe { asm!("hlt") }
    }
}

呼出規約 sysv64 は後述します。

本書では、これと同じようなCコードをいくつかのコンパイラオプションを与えてカーネルをビルドしています。Rustにおいても重要なものを抜粋すると、

  • --target=x86_64-elf - x86_64向けの機械語を生成する、出力ファイルの形式をELFとする
  • -ffreestanding - フリースタンディング環境向けにコンパイルする
  • -mno-red-zone - Red Zone機能を無効にする
  • --entry KernelMain - エントリポイントを KernelMain とする
  • -znorelro - リロケーション情報を読み込み専用にする機能を使わない
  • --image-base=0x100000 - 出力されたバイナリのベースアドレスを0x100000番地とする
  • --static - 静的リンクする

これらのオプションと同等なコンパイル・リンクを行う必要があります。調べると、 x86_64-unknown-uefi と異なりコンパイラ組み込みのターゲットに今回適切なターゲットが無かったため[1]カスタムターゲットを定義する必要がありました。Rustでオブジェクトファイルを生成し、リンクは手動でやる方法もありますが、カスタムターゲットに全て定義することで cargo build でリンクまで一括で済ませられます。

上記ドキュメントやRustによる実装の先駆者の定義、コンパイラ組み込みのターゲットやWriting an OS in Rustの解説等を参考にターゲットを書いてみました。

x86_64-unknown-none-ors.json
{
  "arch": "x86_64",
  "code-model": "kernel",
  "cpu": "x86-64",
  "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128",
  "disable-redzone": true,
  "executables": true,
  "features": "-mmx,-sse,-sse2,-sse3,-ssse3,-sse4.1,-sse4.2,-3dnow,-3dnowa,-avx,-avx2,+soft-float",
  "linker-flavor": "ld.lld",
  "llvm-target": "x86_64-unknown-none-elf",
  "max-atomic-width": 64,
  "os": "none",
  "panic-strategy": "abort",
  "relro-level": "off",
  "position-independent-executables": false,
  "post-link-args": {
    "ld.lld": [
      "--entry=kernel_main",
      "--image-base=0x100000",
      "--static"
    ]
  },
  "target-pointer-width": "64"
}

オプション数は多いですが、その多くは x86_64-unknown-*.json で共通している定義となっています。また各オプションは精査していないので最適なものではないかもしれません (どちらでも/未指定でも動くがこちらの方が好ましい、といったオプションがありそう)。

このようにターゲット定義を用意すれば、そのファイルを直接 --target に指定することができます。

.cargo/config.toml
[build]
target = "./x86_64-unknown-none-ors.json"

[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]

kernel_main 呼び出しのABIを合わせる

efi_main でカーネルのエントリポイントを取得して kernel_main を呼び出しますが、ここで呼出規約の差異に注意が必要でした。

efi_main
...
let entry_point: extern "sysv64" fn() =
    unsafe { mem::transmute(entry_point_addr) };
...
kernel_main
#[no_mangle]
pub extern "sysv64" fn kernel_main() {}

通常のRustプログラムからC APIを利用する場合、単に extern "C" とすればそのプラットフォームで適切なC言語互換の呼出規約でコンパイルされますが、今回はUEFI アプリケーション (Microsoft x64 calling convention)からカーネルの関数を呼び出すため、この extern "C" で選択される呼出規約に互換性がありません。今回は両方で sysv64 を指定し、System V AMD64 ABIでカーネルに制御を移すことを明示しました。なお、System V AMD64 ABIは非Windowsなx64ターゲットでデフォルトで選択される呼出規約なので、カーネル側は extern "C" でも同様となります。

ELFのロード

本書の3章.3にて、ELFファイルをメインメモリに 単に展開して kernel_main を呼び出す実装を行いますが、こちらはRust実装では動作しませんでした。Rustによるビルドでは、ELFファイルのバイナリデータ上の kernel_main のオフセットと、ロード後の kernel_mainimage-base=0x100000 からのオフセットが一致しないためです。
これはRustが問題なのかというとそうではなく、本書でのCコードのコンパイル結果は、たまたま余分なデータがないためこれらオフセットが一致しているのではないかと思われます (もしかしたら見逃している何らかのオプションがあるかもしれない)。Rustによるビルドも、4章.5の ELFのロード を実装すれば動作するため、こちらを先に着手すると良いです。

ELFの解釈にはgoblinクレート[no_std] 環境下でもいい感じに利用できます。

ここまでの比較と感想

  • 開発環境のセットアップはおそらくRustの方がずっと楽 (EDK IIがしんどい...)
  • [no_std] 環境下でも有用なクレートが使える
  • Rust特有の問題にハマった、ということは少なかった (ブートサービスの終了と動的アロケーションの件ぐらい)
    • 本書の構成ゆえに意識しなかったことをRustでは意識した、というのはある (efi_main から kernel_main への呼出規約やELFのロードなど)
  • Rustのエコシステムはよくできている...
  • Rustを学びながらやるのはオススメしない
    • 通常のRustとは話が変わってるところが多いため
脚注
  1. カーネルの特性上、存在し得ない、が正しい? ↩︎