uefi-rsを使わずにRustでMikanOSをやっていく話

この記事は、 NTT Communications Advent Calendar 2022 6日目の記事です。

はじめに

こんにちは、SDPF クラウド・仮想サーバチームの松下です。
普段は OpenStack の開発・運用をしているエンジニアで、今年から新入社員としてJOINしました。

今回は、細々と取り組んでいるOSを自作する個人的な活動についてお話ししつつ、ちょっと普通とは違う開発にチャレンジする同志を増やしたいなと思い執筆しております。

人の子であれば、一度は何か古くからある難しそうなソフトウェアの自作に取り組みたくなるものです(主語デカ発言)
私も例に漏れずその一人で、現在RustでOSを自作しようとしているところです。
自作するOSは、「ゼロからのOS自作入門」1(通称「MikanOS本」)のお題であるMikanOSです。
このMikanOS本は、言語としてC++を利用しOSを自作していく本で、サポートページやGitHubが今もなお更新されるほど 注目されている名著・プロジェクトとなっています。

私の活動の特徴

MikanOSをRustで書くこと自体はやはり時代の潮流もあり、すでに行っている方々がいらっしゃいます。
その方々の実装を見ますと、基本的には"uefi-rs"2と呼ばれるCrateを用いて実装しているようです。
uefi-rsは、RustでUEFIアプリケーションを作成するためのCrateであり、MikanOSはUEFIを前提としているためこちらを用いて開発することが王道の戦略だと思います。

一方で私のRikan3は、このuefi-rsを使わずに開発を進めています。
uefi-rsを使わずに実装していくことによって、MikanOS本で開発するもの全てを理解できるのでは?と考えた末の戦略です。
この戦略の開発はMikanOS本ではあまり触れられないところを理解する必要があったり、 OS起動以前に大量の実装をする必要があるなど純粋な「自作OS」の範疇を少し超えるようなことをする必要があります。
しかし、それによってあまり知られていなさそうなことを知れたり「UEFI Specificationが愛読書」みたいなことが言えたりしちゃうのでとても楽しいです。

この記事では、皆さんにそれを始める最初のステップとして、UEFIで"Hello World"をするコードを解説しようと思います。

RustでUEFIアプリケーションを作る

今回利用するコードは私のRikanプロジェクトの最初のコミット4のものになります。
このプログラムは2つのファイルがメインとなりますので、その2つについて解説しようと思います。

まずは、全体の動きを説明するため、main.rsの中身を以下に載せます。

#![no_std]
#![no_main]
#![feature(abi_efiapi)]

use core::panic::PanicInfo;
use utf16_literal::utf16;

mod uefi;

#[no_mangle]
pub extern "C" fn efi_main(ImageHandle: uefi::EFI_HANDLE, SystemTable: &uefi::SystemTable) -> uefi::EFI_STATUS {
    let _conout = SystemTable.ConOut();
    _conout.Reset(false);
    _conout.OutputString(utf16!("Hello World\r\n").as_ptr());

    loop{}

    uefi::EFI_STATUS::Success
}

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

Hello Worldするだけですので複雑なコードではありませんが、普通のRustプログラムではあまり見かけないものが書かれていると思います。
UEFIの環境ではOS起動以前の環境ですので標準ライブラリにあたるものは利用できません。
従って、1行目にあるように #![no_std] とすることでRustの標準ライブラリを用いない core と呼ばれる最低限のライブラリを使う環境で動かすことになります。
これによって、普段あまり気にすることのないpanic時にどうするかも自分で実装する必要があります。
これが最後の方の #[panic_handler] 以降の部分にあたり、今回はループし続けるだけの実装にしています。

メインの処理ですが、これは efi_main関数の中身となります。
UEFIの仕様書ではUEFIが規定するデータ型とC言語の呼び出し規約5を利用するものとして定義されていますので、RustのABIではなくUEFI仕様書に沿ったものを利用するよう指定していく必要があります。
これは後述する構造体や列挙体にも当てはまります。
UEFI アプリケーションのエントリーポイントは、extern "C" とすることでUEFI仕様に則った形とし、
マングリングされないように #[no_mangle] も追加します。

UEFIアプリケーションは、エントリーポイントで2つの値を受け取ることになります。
このうち、SystemTableの方が重要で、UEFIの各種機能を呼び出すためのアドレス群を格納する構造体へのポインタとなっています。
画面にテキストを出力する「Simple Text Output Protocol」と呼ばれる機能や、OS起動以前に利用できる機能が詰まっているBoot Serviceを利用する場合には、 この構造体を経由してそれらを呼び出します。
Hello Worldをする際には、前者の機能を利用するため efi_main 関数の最初でこの機能の呼び出しの準備をしています。

SystemTableの説明をするために、次にuefi.rsの抜粋を以下に示します。 (構造体のメンバも削っているので、実際のコードとはかなり違います。)

#[repr(C)]
pub enum EFI_STATUS {
    Success = 0
}

type CHAR16 = u16;

pub struct EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL {
    Reset: extern "efiapi" fn(This: &EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL, ExtendedVerification: bool) -> EFI_STATUS,
    OutputString: extern "efiapi" fn(This: &EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL, String: *const CHAR16) -> EFI_STATUS,
}

impl EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL {
    pub fn Reset(&self, ExtendedVerification: bool) -> EFI_STATUS {
        unsafe {(self.Reset)(self, ExtendedVerification)}
    }

    pub fn OutputString(&self, String: *const CHAR16) -> EFI_STATUS {
        unsafe{(self.OutputString)(self, String)}
    }
}

#[repr(C)]
pub struct SystemTable {
    ConOut:                 *mut EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL,
    BootServices:           *mut EFI_BOOT_SERVICES,
}

impl SystemTable {
    pub fn ConOut(&self) -> &mut EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL {
        unsafe {&mut *self.ConOut}
    }
}

先ほども述べたようにSystemTableという構造体は、各種機能を呼び出すためのアドレスがメンバとなっています。
下方にあるSystemTableの構造体でそれらを定義しています。
エントリーポイントの時にも述べましたが、UEFIを利用するためにはC言語のABIに沿う必要があるため、 構造体の上に#[repr(C)]をつけることによってRustコンパイラにそれを示しています。6

C言語では、アドレスさえわかればそれをもとに関数ポインタを作って呼び出せば良いのですが、
Rustぽく実装するためにアドレスを格納している構造体に対してimplしてラッパーを作り呼び出しています。
UEFIの機能を呼び出すと返り値にEFI_STATUSが返却されますが、RustとしてはやはりResult型で表現したいためこのようにしています。 (この例ではそこまでやっていませんが...)

文字列を表示するためには、EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL構造体のメンバ変数であるOutputStringに格納されている関数にUTF16文字列のポインタを渡してあげれば良いため、 main.rsでは以下のようにすることでHello Worldを画面に表示できます。

_conout.OutputString(utf16!("Hello World\r\n").as_ptr());

uefi-rsを使わないMikanOSの実装の進め方

基本的には、Hello Worldで示したようなコードをひたすら書いていくだけになります。 つまり、以下のサイクルを回していく作業になります。

  1. MikanOSの実装を読む
  2. 実装に必要な構造体や列挙体、関数に必要な引数などをUEFI Specificationで探す
  3. Rustでそれらを書く
  4. Rustぽく動かせるようにする
  5. デバッグする

もし実装につまれば、uefi-rsを読んだりコードを入れ替えたりしたりすることで何かヒントは得られるのかなと思います。

Rikanを開発する上で感じたこと

Rikanを開発する上で思ったことは以下の5点です。

  • 構造体をひたすら定義し続ける苦行が辛い
  • Rustぽく動くようにimplし続ける苦行が辛い
  • グローバルアロケータを実装するまでまともなprintfデバッグすらできなくて辛い
  • PanicInfoを利用するとpanicが起きたときにどこの行が原因で起きているのかが出るのでありがたい
  • uefi-rsは偉大

まだday3までしか進んでいませんが、一番の山場はグローバルアロケータを実装するところかなと思います。
これを実装するまでは、変数の中身を表示することが難しいのでデバッグの難易度がかなり高い状態での開発になります。
また、UEFIをちょっと使うためにも多くのUEFIで定義される構造体を実装する必要があるため、uefi-rsは縁の下の力持ちだととても感じています。

まとめ

この記事は、MikanOSをRustで実装することで、UEFIという普段意識しないものに対する理解を深める最初の1歩になればと思い執筆しました。
ソフトウェア開発において、開発対象とする領域を下支えする裏側を意識する機会は少ないかも知れません。
ブラックボックスにすることによって集中すべきところにしっかり集中するというのが大事だからです。
しかし、一度トラブルなどが起きたときは、その裏側を知っておくとその解決が速くなったりすることがあります。
そういった意味で、エンジニアとしては裏側を知っておくということは重要になると私は考えています。

NTT Comでは現在、現場受け入れ型インターンシップを募集しており、以下のリンクから応募できます。
いろいろなサービスで利用されているクラウド技術の裏側を知ることのできるインターンシップになっていますので、 この記事を読んで「クラウドサービスの裏側を知りたい!」となったあなたからの応募をお待ちしております。

information.nttdocomo-fresh.jp

それでは、明日の投稿もお楽しみに。


  1. http://zero.osdev.jp
  2. https://github.com/rust-osdev/uefi-rs
  3. https://github.com/bean1310/Rikan
  4. https://github.com/bean1310/Rikan/tree/2e5f7a4456531e53a41508a1d498f3beea53ee1a
  5. 仕様書中に記述は見つかりませんでしたが、Microsoftのx64 calling conventionを採用しているように見えます。https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention
  6. ここのサイトがこの違いを解説しています。https://ryochack.hatenablog.com/entry/2018/03/23/184943
© NTT Communications Corporation All Rights Reserved.