🍊

ゼロからのOS自作入門をRustで実装した

2022/07/29に公開

概要

OSを座学・書籍で学ぶだけでなく実際に作ってみたくて、またRustでなにかを作りたくて、
ゼロからのOS自作入門(以下みかん本)で作るMikanOSをRustで実装しました[1]

先駆者はいくつか見かけましたが、最後まで実装されている方は見つけられず、
もしRustで実装しようとしてハマっている人や途中で諦めてしまった方がいれば参考になるかと思い、記事を書きました。

実装者のレベル

一応、私のレベル感を書いておきます。

というような感じで、OS開発もRustも経験が浅いので、コードは参考程度に見てもらうのが良いと思います。

実装方針

実装するにあたって、いくつか実装方針を決めていたので共有します。
コードを読むときに参考になれば幸いです。

基本的な方針は、「公式のMikanOSの実装から大きく外れる実装はしない」です。
理由は以下の通りです。

  • MikanOSの実装を通してOSの知識を深めることを最優先としたい
  • 独自実装が多くなるとハマった時の原因究明に時間がかかりそう

なので、具体的に以下の方針で実装しています。

  • 開発環境は公式の推奨環境で行う
  • 公式の手順で書くC++のコードのみをRustで書いていく
    • つまりブートローダはCのまま
  • USBドライバのコードもRustで実装しない
    • みかん本通りやったとしても、USB周りは自分で実装する範囲ではないため
  • Rustらしいコードにこだわらない
    • 実装を終わらせることを優先するため
  • 排他制御は公式以上の考慮をしない
    • 時間がかかりそうであり、実装を終わらせることを優先するため
    • (一通り実装後に一部対応しました)

リポジトリ

https://github.com/toyamah/rusty-mikanos

以降では、ハマったところなど特筆したい内容を章ごとにメモとして残しておきます。
なお、基本的にはosbook_day30aのようなタグごとにプルリクエストを作っているので、
それを見ればタグごとの差分を見ることができるはずです。

1章~2章

チュートリアルのような章なので特になし。

3章 画面表示の練習とブートローダ

osbook_03a

ここからRustを書いていく。
ブートローダからカーネルを呼び出すようなbuild環境の構築を書く必要がある。
みかん本74pと以下の記事を参考に.cargo/config.toml, x86_64-unknown-none-elf.json を用意した。

また同様に Writing an OSを参考にmain.rsを実装した。

https://github.com/toyamah/rusty-mikanos/pull/2

4章 ピクセル描画とmake入門

osbook_04b

公式における frame_buffer_config.hpp と同じように
Cで書かれたブートローダとRustで書かれたカーネル側で使える struct が必要だった。
ブートローダ、カーネルそれぞれで structを用意しても良かったが、cbindgenというライブラリを使って、
RustのコードをCに変換し、それをブートローダが読むこむようにした。

https://github.com/toyamah/rusty-mikanos/pull/7

5章 文字表示とコンソールクラス

osbook_05c

ここではフォントを増やすためフォントファイルからオブジェクトファイルを作る。
kernelのビルド時にこのオブジェクトファイルも一緒に作りたいため、build script という仕組みを使うことにした。
コマンドはみかん本に書いてある通りで、それをRustで記述していった。

https://doc.rust-lang.org/cargo/reference/build-scripts.html

https://github.com/toyamah/rusty-mikanos/pull/12

6章 マウス入力とPCI

osbook_06c

USB関連のドライバの実装は範囲外とのことなので、
実装はせず、公式のC++のコードを使うことにした。

C++のコードをRustから呼び出せるようにしたコードは大体以下の通り

  • C++側でRustから呼び出すインターフェイスを用意
    • manglingしたら面倒なので extern C で囲む
  • Rust側から先程追加したC++を呼び出すコードを書く
  • C++側のコードをcompileするmakefileを書き、build.rs でbuild時にcompile・linkさせる

RustからC++のコードを呼び出す部分については以下の記事を参考にした。
(今回採用した方法は方法1)
https://qiita.com/moriai/items/e8e8b9c6a12f5a529d85

https://github.com/toyamah/rusty-mikanos/pull/21

7章 割り込みとFIFO

osbook_07a

ここで実装しておきたい部分

osbook_day07a自体に関することではないが、
ここでinterrupt handlerを登録できるようになったため、先にosbook_day20dを実装すると今後の開発に役立つはず。
理由はみかん本の20.4(476P)に書いてあるとおり、現段階ではCPU例外が起きるとOSが再起動されてしまうので、原因究明に時間がかかり、特定も大変なため。

osbook_day20dの変更
https://github.com/toyamah/rusty-mikanos/pull/80

9章 重ね合わせ処理

ユニットテストを実行できるようにする

9章の実装に関わる部分ではないが9章の時点で実装したので書いておく。
OSの起動にはコンパイル時間、OSの起動も含めると時間がかかるため、ユニットテストを書きたくなった。
以下の記事を参考にし、library crateに移動できるコードは移動し、その部分をテストできるようにした。
https://tomoyuki-nakabayashi.github.io/embedded-rust-techniques/05-library/testing.html

https://github.com/toyamah/rusty-mikanos/pull/32

また、ユニットテストをGithub Actionsで実行できるようにした。
clippyも走らせたかったが、環境構築が大変そうだったので諦めて、ローカルで実行するようにした。
https://github.com/toyamah/rusty-mikanos/pull/35

11章 タイマとACPI

osbook_11a

ここではコードの整理をすると同時にglobalというmoduleを用意して、global変数を使う部分はすべてそのmodule内に入れるようにした。
そうすることで、テストをグローバル変数に依存しないようにしてテストを書きやすくしたかったのが狙い。

ただ、この方式を取ると通常のコードはグローバル変数を依存しなくなる代わりに、グローバル変数の参照を保持する期間が多くなってしまうことになる。
つまり、排他制御を入れる場合結果、デッドロックを起こす危険性が増えてしまうことになってしまう。
このような理由から、globalモジュールは作らないほうが良かった。

https://github.com/toyamah/rusty-mikanos/pull/40

13章 マルチタスク(1)

osbook_13a

ここでTaskBというタスクを新しく作るが、みかん本の「16.6省電力化」にも書いてある通りCPUがかなり使用されるタスクとなっている。
しばらくはTaskBを使う開発はあるが、
CPUファンが回ったりして開発しづらかった経験があるので、いつでもTaskBの起動を無効にするように作ったほうがいいかもしれない。

https://github.com/toyamah/rusty-mikanos/pull/49

18章 アプリケーション

osbook_18c

ここで初めてRustが書かれた外部アプリを実行する。

https://github.com/toyamah/rusty-mikanos/pull/75

外部アプリの実装

基本的には公式のC++コードの通り。
引数argvについてはMikanOSと同じようにcharのポインタとして宣言するが、これをRustで扱う必要がある。
そのためRustの標準ライブラリにあるCStrを使いたかったが、no_std環境なので使うことができなかった。
仕方がないので標準ライブラリのコードを拝借した。

外部アプリのビルド

kernelのELFファイルのビルド方法を参考にビルド環境を作った。

ただし、jsonファイルの以下の部分は変更した

  • みかん本にも記載がある通り、image-baseを0に
  • -oapps/rpn/rpn

カーネルから外部アプリの呼び出し

基本的には公式のC++コードの通りに実装。
ただ「外部アプリの実装」のときと同じように、argvをcharのポインタとして渡さないといけない。
ここでも標準ライブラリのCStringを使いたく、外部ライブラリ側と同じようにRust公式からコードを拝借した。

デバッグ方法について

今後このようなアプリを作成するようになるが、

  • 自分が実装したカーネルから公式のアプリを呼び出す
  • 公式のカーネルから自分が実装したアプリを呼び出す

という2種類のデバッグをすると、自分の実装のどこに問題があるかわかりやすかった。

19章 ページング

osbook_19a

largeアプリのビルド作るためにMikanOSで使われている-mcmodel=largeというオプションはrustcにはなさそうだった。
ただ特に指定せずにビルドしたが動いてくれたので、うまくビルドできているようだった。

アプリのビルドに必要になる.cargo/config.tomltarget.jsonはほぼ一緒なので共通化したかったが、
良い方法が思いつかなかったのでアプリごとにファイルを用意することにした。

https://github.com/toyamah/rusty-mikanos/pull/77

20章 システムコール

osbook_20a

すぐ消すことになるコードのようだったので実装はしなかった。

osbook_20e

公式を参考に実装をしたが、ログを出力するシステムコールをアプリ側が呼び出す時に、
通常のRustの文字列を同じように書いてしまうと、関係ない別の文字列まで一緒に表示されてしまうことが起こっていた。
原因はRustの文字列にはヌル終端文字が含まれないため。
そのため明示的にヌル終端文字を入れるようにした。

https://github.com/toyamah/rusty-mikanos/pull/81

21章 アプリからウィンドウ

osbook_21b

テキストを出力するときに毎回SyscallLogStringを書くのも大変だったので、prinf 関数を実装した。
その時に使われるBufferが必要だったが、まだアプリ側で動的にメモリを確保する方法がなかったので、固定長のbufferであるByteBufferを用意した。

https://github.com/toyamah/rusty-mikanos/pull/83

22章 グラフィックとイベント(1)

osbook_22g

みかん本に書いてある通り、アセンブリ言語から呼ばれる GetCurrentTaskOSStackPointerno_caller_saved_registers というattributeをつける必要がある。
Rustでこれ相当の動きをする機能を探したが見つからなかったので、仕方なくC++側でRustを呼び出す関数を no_caller_saved_registersattribute付きで用意し、
アセンブリ言語 -> C++ -> Rust
という流れでタスクのOS用スタックポインタを取得するようにした。

ただし、C++側のコードがうまく動かず、C++側に無意味なif文を追加することで対処した。
https://github.com/toyamah/rusty-mikanos/pull/96

if文を追加した理由

C++側でRust側の関数だけをただ呼び出す場合、rdirsiなどのレジスタが変わってしまい、kernel側のシステムコール関数に到達したときに、引数が意図しない値になっていた。

__attribute__((no_caller_saved_registers))
extern "C" uint64_t GetCurrentTaskOSStackPointer() {
    auto p = GetCurrentTaskOSStackPointerInRust();
    return p;
}

上記のコードを使ったkernel.elfobjdumpコマンドで見てみると、
C++側の GetCurrentTaskOSStackPointer の部分がレジスタの復帰をした後に、callではなくjmp命令でRust側の関数呼び出しを行っていた。
つまりコンパイラにより末尾最適化され、caller-saved対象のレジスタがRustの関数内で上書きされてしまい、レジスタの値が意図しない値となってしまっていた。

参考: https://web.stanford.edu/class/archive/cs/cs107/cs107.1174/guide_x86-64.html

__attribute__((no_caller_saved_registers))
extern "C" uint64_t GetCurrentTaskOSStackPointer() {
# レジスタの保存
  10cf90:	55                   	push   rbp
  10cf91:	48 89 e5             	mov    rbp,rsp
  10cf94:	50                   	push   rax
  10cf95:	41 53                	push   r11
  10cf97:	41 52                	push   r10
  10cf99:	41 51                	push   r9
  10cf9b:	41 50                	push   r8
  10cf9d:	57                   	push   rdi
  10cf9e:	56                   	push   rsi
  10cf9f:	52                   	push   rdx
  10cfa0:	51                   	push   rcx
  10cfa1:	48 81 ec 08 01 00 00 	sub    rsp,0x108
...
  10d010:	0f 29 85 b0 fe ff ff 	movaps XMMWORD PTR [rbp-0x150],xmm0
    auto p = GetCurrentTaskOSStackPointerInRust();
# レジスタの復帰
  10d017:	0f 28 85 b0 fe ff ff 	movaps xmm0,XMMWORD PTR [rbp-0x150]
...
  10d086:	48 81 c4 08 01 00 00 	add    rsp,0x108
  10d08d:	59                   	pop    rcx
  10d08e:	5a                   	pop    rdx
  10d08f:	5e                   	pop    rsi
  10d090:	5f                   	pop    rdi
  10d091:	41 58                	pop    r8
  10d093:	41 59                	pop    r9
  10d095:	41 5a                	pop    r10
  10d097:	41 5b                	pop    r11
  10d099:	58                   	pop    rax
  10d09a:	5d                   	pop    rbp
# callではなくjmp
  10d09b:	e9 c0 2c 03 00       	jmp    13fd60 <GetCurrentTaskOSStackPointerInRust>
​
000000000010d0a0 <__cxa_pure_virtual>:
    return p;
}

対処として、末尾最適化を防ぐためにoptnone というattributeを検討した。
しかし、no_caller_saved_registersoptnoneを同時に使うと、公式のMikanOSでは行われていないraxレジスタもGetCurrentTaskOSStackPointer内で保存・復帰が行われてしまい、結果としてうまく動かなかった。

__attribute__((no_caller_saved_registers, optnone))
extern "C" uint64_t GetCurrentTaskOSStackPointer() {
  10cf90:	55                   	push   rbp
  10cf91:	48 89 e5             	mov    rbp,rsp
# raxの保存
  10cf94:	50                   	push   rax
  10cf95:	41 53                	push   r11
  10cf97:	41 52                	push   r10
  10cf99:	41 51                	push   r9
  10cf9b:	41 50                	push   r8
  10cf9d:	57                   	push   rdi
  10cf9e:	56                   	push   rsi
  10cf9f:	52                   	push   rdx
  10cfa0:	51                   	push   rcx
  10cfa1:	48 81 ec 18 01 00 00 	sub    rsp,0x118
...
  10d010:	0f 29 85 b0 fe ff ff 	movaps XMMWORD PTR [rbp-0x150],xmm0
    auto p = GetCurrentTaskOSStackPointerInRust();
  10d017:	e8 54 2d 03 00       	call   13fd70 <GetCurrentTaskOSStackPointerInRust>
  10d01c:	48 89 85 a8 fe ff ff 	mov    QWORD PTR [rbp-0x158],rax
    return p;
  10d023:	48 8b 85 a8 fe ff ff 	mov    rax,QWORD PTR [rbp-0x158]
  10d02a:	0f 28 85 b0 fe ff ff 	movaps xmm0,XMMWORD PTR [rbp-0x150]
...
  10d099:	48 81 c4 18 01 00 00 	add    rsp,0x118
  10d0a0:	59                   	pop    rcx
  10d0a1:	5a                   	pop    rdx
  10d0a2:	5e                   	pop    rsi
  10d0a3:	5f                   	pop    rdi
  10d0a4:	41 58                	pop    r8
  10d0a6:	41 59                	pop    r9
  10d0a8:	41 5a                	pop    r10
  10d0aa:	41 5b                	pop    r11
# raxの復帰
  10d0ac:	58                   	pop    rax
  10d0ad:	5d                   	pop    rbp
  10d0ae:	c3                   	ret    
  10d0af:	90                   	nop
​
000000000010d0b0 <__cxa_pure_virtual>:
}

結局いい方法が見つからず、
無駄なif文を追加することで、最適化を保ったままcall命令を強制するように変更した。

25章 アプリでファイル読み込み

osbook_25c

正規表現の実装について
公式が出している regex はドキュメントやCargo.tomlを見る限りno_stdは実質対応していないようだった (v1.6.0で確認)
なので、no_std対応の正規表現をcrateを探した結果 safe-regex-rs というcrateを使うことにした。
しかし、このcrateは使う正規表現をコンパイル時に宣言しないといけないようだった。
結局、正規表現自体はOS開発におけるコアな部分ではなく、みかん本に載っている正規表現を限られているので、このcrateを使うことにした。

https://github.com/toyamah/rusty-mikanos/pull/117

28章 日本語表示とリダイレクト

osbook_28b

https://github.com/toyamah/rusty-mikanos/pull/130

結果としては、日本語フォントは動くようにはなっているが機能は無効化した。

フォントの読み込みには no_std でも動く font_due というcrateを使うようにした。
しかし、font_due を使いサイズが大きい日本語フォントを読み込むと、初期化に1分ほどかかってしまっていた。
どこがボトルネックになっているか調べるとメモリのalloc/deallocがかなり呼ばれるようで、どうもそれが原因のようだった。
実際にみかん本の31章721Pに書かれているメモリ管理に改良の余地の話と一致していたので、恐らく合っているはず。

この対処方法の1つとして、 C++側で公式と同じやり方でfontデータを読み込んでRustに渡すことを考えた。
しかしフォントを読み込んで日本語を表示するという仮実装はできてはいるので、特に対処はしなかった。
また、このままだとカーネルの初期化に時間がかかるので、日本語表示機能は無効化にした。

osbook_28c

一通り実装した後に、ターミナルでcat memmap を実行するとファイル出力中にカーソルが写ってしまっていた。

原因は、TerminalDescriptor側でRustのReferenceにおけるルールを無視して無理やりTerminalを取得しているからだった。
これによりTerminalDescriptor側でTerminal.cursorを変更したことをコンパイラは検知せず、更新されていないTerminal.cursorの値でカーソルを表示するようになってしまっていた。
(実際opt-levelを変更したら期待された動作になった)

一旦はこのまま実装を進めたが、最終的な改善方法としてはTerminalの表示を行う部分の別のstructに切り出すことにした。
https://github.com/toyamah/rusty-mikanos/pull/147/commits/fe666b2b70baec56f21fec3928887707a0886a7e

30章 おまけアプリ

osbook_30e, osbook_30f

テキストビューアと画像ビューアを作成することになるが実装しなかった。
理由は、公式の実装を見る限りアプリ単独の開発のようで、OSの技術に関することは無さそうと判断したため。

排他制御

ここまででMikanOSの実装は一通り実装し終わったが、排他制御をしていないことがずっと気になっていた。
理由は、コンテキストスイッチが起きるとグローバルにある変数の mutable reference が複数のタスクで参照されることになり、
RustのReferenceにおけるルールを破ってしまうため。

なのでMikanOS実装後に排他制御の対応を入れた。
実装はWriting an OS in RustのSpinlocksを参考にした。
またデッドロック部分の回避についてはsabiosのmutexを参考にした。

ただ既存の実装に対して入れるのは大変で、既存のコードを大きく修正してまでやるモチベーションはなく、完全にはやらなかった。
https://github.com/toyamah/rusty-mikanos/pull/146

最後に

以上、MikanOSをRustでほぼ作ることができました。
座学・書籍で学んだことを実際に実装してみると解像度が上がってよかったです。

結構無理やりな実装はしてしまって良いコードではないのですが、
RustでMikanOSを作ってみたい人に少しでも参考になれば幸いです。

脚注
  1. 日本語フォントと最後のtview, gviewは未実装。理由は後述 ↩︎

Discussion