MikanOSをRustでやってますが、しんどいです。

みかん本やってます

x86のエミュレータを作ろう本を読んでます で話したとおり、 uchanのOS本が出たのでやってます。 表紙がみかんなので、みかん本というそうです。 x86本がとてもわかりやすかったので、今作を楽しみにしていました。

本の内容としては、UEFIを使って、 ELFフォーマットのカーネルをブートするところから始まって、 それからOSを作り上げていくというものです。

Rustでやるとブートでハマる

本の中の実装言語としては、C++が採用されていて、基本的に写経することで学ぶという ポリシーの本なので、そのとおりにやるだけなら手を動かすだけで終わると思いますが、 ここで実装言語としてRustを使おうとするとかなり難しいことになります。

組み込みRustに慣れた人であればそうでもないかも知れませんが、 ふつうにRustを使ってる人にとってはおそらく、ブートがもっとも鬼門です。 逆に、ここを突破してしまえば、あとはRustの安全性の中で 気持ちよく進めていけるはずですが、実際にTwitterを眺めていると、 Rustでやってる人はブートのところでハマってるケースが多いです。

現在、午前3時ですが、 とりあえずおれはカーネルに処理を移してフレームバッファを塗りつぶすところまでは出来たので、 ハマった点と解決案(仮)を共有します。

ブートまでにやること

まずはブートまでの流れを説明します。

まず、実行環境を作ります。 変な環境で実行して楽しむというのも楽しみ方の一つではありますが、 開発効率の点でいえば、Ubuntuなどの上にQemuをインストールするのがもっともわかりやすいです。 Qemuはモニタでレジスタやメモリの様子を簡単に見ることが出来て、 この機能は開発を進める上では必須なので、 Qemuをオススメします。 Hello, world!を表示するバイナリファイルを打ち込むのもだるいので、 サポートページからダウンロードしてきて使います。

ブートは、UEFIアプリケーションをまず立ち上げて、そこからELF形式のカーネルをブートします。 UEFIのアプリケーションにはuefi-rsを使うのがわかりやすいです。 これを使うということはすでに写経の道からは外れてしまってることと同義ですが、 uefi-rsはうまく設計されていて、使いこなすのは簡単でした。 uefi-rsを使ってHello, world!を出せば、セットアップは出来ていることになるでしょう。

ブートのためにはまず、ディスクにいれたカーネルファイルをメモリに読み込みます。 これにはSimpleFileSystemというのが使えます。 このカーネルファイルは、0x100000に配置されるようにビルドされているため、 UEFIによってアドレス指定方式でページをアロケートして、そこに書きます。

        const KERNEL_BASE_ADDR: usize = 0x100000;
        let n_pages = (kernel_file_size as usize + 0xfff) / 0x1000;
        let p = boot_services
            .allocate_pages(
                AllocateType::Address(KERNEL_BASE_ADDR),
                MemoryType::LOADER_DATA,
                n_pages,
            )
            .unwrap_success();

        // Read kernel file into the memory
        let buf = unsafe { core::slice::from_raw_parts_mut(p as *mut u8, kernel_file_size as usize) };
        f.read(buf).unwrap_success();
        f.close();

Entry関数のアドレスはELFの仕様で、ファイルの先頭から24バイトつまり0x100018に書かれています。 これを読み取ります。 Little Endianで書かれていますから、byteorderを使うと便利でしょう。 (あるいはポインタをとってキャストするでも出来るかも。怪しいと思ったら簡明に書くのが好きです)

        let buf = unsafe { core::slice::from_raw_parts((p + 24) as *mut u8, 8)};
        let kernel_main_addr = LittleEndian::read_u64(&buf);

これ以降はもう不要なのでUEFIのブートサービスを止めて、 カーネルに処理を移します。

        st.exit_boot_services(handle, &mut tmp_buf).unwrap_success();

        let kernel_main = unsafe {
            let f: extern "efiapi" fn(u64, u64) -> ! = core::mem::transmute(kernel_main_addr);
            f
        };
        kernel_main(fb_addr, fb_size as u64);

はまったポイント

おれがハマったのは2箇所です。 解決法と根拠も一応示しますが、そのどちらも必ずしも正しいとも限りません。

(1) カーネルファイルのビルド

本では、C++を使った例としてコンパイル方法が書かれていますが、Rustでやる場合はそのまんま使うことは出来ないので、 CargoやRustcのドキュメントを読んで同等のことをやる道を探る必要があります。

とりあえずおれのエンジニアとしての基本ポリシーは 「自分が理解出来ないことはしない」 「メンテナンス性が悪くなることはしない」 なので、自分が 理解可能な範囲で.cargo/config.tomlに以下のようにちょろっと書いて突破しました。

nmagicというのは、コードの番地とファイル上でのオフセットを一致させるために使いました。 これなしだと、コードの番地は0x101120なのに、ファイル上では0x120で、0x100000に単純に置くことが出来なくなります。 単純な実装ではこれら2つが一致していないと困るので、手っ取り早く先に進むためにこのフラグ1つでやり抜けることにしました。 これは一時的なもので、後に4章でELFヘッダを使ってロードを行うようにローダを改良する時に、nmagicは不要になります。 ちなみにELFの読み取りにはelf-rsというライブラリが使えました。

[target.x86_64-unknown-linux-gnu]
linker = "ld.lld"
rustflags = [
    # Build Options
    "-C", "no-redzone=yes",
    "-C", "relocation-model=static",

    # Linker Options
    "-C", "link-arg=--entry=kernel_main",
    "-C", "link-arg=--image-base=0x100000",
    "-C", "link-arg=-nmagic",
]

(2) カーネルのエントリ関数の呼び方

UEFIは、System V ABIの呼び出し規則とは関数の呼び出し規則が異なります。

この記事 によると、呼び出し規則の違いを吸収する方法は2通りあります。

  1. アプリケーション自体をUEFI規則でビルドしてしまう
  2. アプリケーション自体はSystem Vだが、UEFI規則の関数を呼び出す時にラッパーをかませる

みかん本では、カーネルのエントリ関数は System Vの呼び出し規則を使っています。 UEFIのアプリケーションからこれを呼び出せるのは、 上記2の方式を使ってるからです。

一方で、uefi-rsを使った場合、上記1の方式を使うことになります。 だから、カーネルのエントリ関数もUEFIの呼び出し規則でなければならないのです。

#[no_mangle]
extern "efiapi" fn kernel_main(fb_addr: u64, fb_size: u64) -> ! {

リポジトリはこちら:MikanOS.rs