ゼロからのOS自作入門をRustで実装した
概要
OSを座学・書籍で学ぶだけでなく実際に作ってみたくて、またRustでなにかを作りたくて、
ゼロからのOS自作入門(以下みかん本)で作るMikanOSをRustで実装しました[1]。
先駆者はいくつか見かけましたが、最後まで実装されている方は見つけられず、
もしRustで実装しようとしてハマっている人や途中で諦めてしまった方がいれば参考になるかと思い、記事を書きました。
実装者のレベル
一応、私のレベル感を書いておきます。
- OSの開発経験はなく、低レイヤーっぽい開発経験もひとつだけ
- Rustは以下のように多少の経験はあるが業務で利用したことはなし
- the book はほぼ読んで写経した
- Rustで始めるTCP自作入門 はやった
- Programming Rust, 2nd Edition は気になるところを読んだ
というような感じで、OS開発もRustも経験が浅いので、コードは参考程度に見てもらうのが良いと思います。
実装方針
実装するにあたって、いくつか実装方針を決めていたので共有します。
コードを読むときに参考になれば幸いです。
基本的な方針は、「公式のMikanOSの実装から大きく外れる実装はしない」です。
理由は以下の通りです。
- MikanOSの実装を通してOSの知識を深めることを最優先としたい
- 独自実装が多くなるとハマった時の原因究明に時間がかかりそう
なので、具体的に以下の方針で実装しています。
- 開発環境は公式の推奨環境で行う
- 公式の手順で書くC++のコードのみをRustで書いていく
- つまりブートローダはCのまま
- USBドライバのコードもRustで実装しない
- みかん本通りやったとしても、USB周りは自分で実装する範囲ではないため
- Rustらしいコードにこだわらない
- 実装を終わらせることを優先するため
- 排他制御は公式以上の考慮をしない
- 時間がかかりそうであり、実装を終わらせることを優先するため
- (一通り実装後に一部対応しました)
リポジトリ
以降では、ハマったところなど特筆したい内容を章ごとにメモとして残しておきます。
なお、基本的にはosbook_day30a
のようなタグごとにプルリクエストを作っているので、
それを見ればタグごとの差分を見ることができるはずです。
1章~2章
チュートリアルのような章なので特になし。
3章 画面表示の練習とブートローダ
osbook_03a
ここからRustを書いていく。
ブートローダからカーネルを呼び出すようなbuild環境の構築を書く必要がある。
みかん本74pと以下の記事を参考に.cargo/config.toml
, x86_64-unknown-none-elf.json
を用意した。
また同様に Writing an OSを参考にmain.rs
を実装した。
4章 ピクセル描画とmake入門
osbook_04b
公式における frame_buffer_config.hpp
と同じように
Cで書かれたブートローダとRustで書かれたカーネル側で使える struct
が必要だった。
ブートローダ、カーネルそれぞれで struct
を用意しても良かったが、cbindgenというライブラリを使って、
RustのコードをCに変換し、それをブートローダが読むこむようにした。
5章 文字表示とコンソールクラス
osbook_05c
ここではフォントを増やすためフォントファイルからオブジェクトファイルを作る。
kernelのビルド時にこのオブジェクトファイルも一緒に作りたいため、build script
という仕組みを使うことにした。
コマンドはみかん本に書いてある通りで、それをRustで記述していった。
6章 マウス入力とPCI
osbook_06c
USB関連のドライバの実装は範囲外とのことなので、
実装はせず、公式のC++のコードを使うことにした。
C++のコードをRustから呼び出せるようにしたコードは大体以下の通り
- C++側でRustから呼び出すインターフェイスを用意
- manglingしたら面倒なので
extern C
で囲む
- manglingしたら面倒なので
- Rust側から先程追加したC++を呼び出すコードを書く
- C++側のコードをcompileするmakefileを書き、
build.rs
でbuild時にcompile・linkさせる
RustからC++のコードを呼び出す部分については以下の記事を参考にした。
(今回採用した方法は方法1)
7章 割り込みとFIFO
osbook_07a
ここで実装しておきたい部分
osbook_day07a自体に関することではないが、
ここでinterrupt handlerを登録できるようになったため、先にosbook_day20dを実装すると今後の開発に役立つはず。
理由はみかん本の20.4(476P)に書いてあるとおり、現段階ではCPU例外が起きるとOSが再起動されてしまうので、原因究明に時間がかかり、特定も大変なため。
osbook_day20dの変更
9章 重ね合わせ処理
ユニットテストを実行できるようにする
9章の実装に関わる部分ではないが9章の時点で実装したので書いておく。
OSの起動にはコンパイル時間、OSの起動も含めると時間がかかるため、ユニットテストを書きたくなった。
以下の記事を参考にし、library crateに移動できるコードは移動し、その部分をテストできるようにした。
また、ユニットテストをGithub Actionsで実行できるようにした。
clippy
も走らせたかったが、環境構築が大変そうだったので諦めて、ローカルで実行するようにした。
11章 タイマとACPI
osbook_11a
ここではコードの整理をすると同時にglobal
というmoduleを用意して、global変数を使う部分はすべてそのmodule内に入れるようにした。
そうすることで、テストをグローバル変数に依存しないようにしてテストを書きやすくしたかったのが狙い。
ただ、この方式を取ると通常のコードはグローバル変数を依存しなくなる代わりに、グローバル変数の参照を保持する期間が多くなってしまうことになる。
つまり、排他制御を入れる場合結果、デッドロックを起こす危険性が増えてしまうことになってしまう。
このような理由から、global
モジュールは作らないほうが良かった。
13章 マルチタスク(1)
osbook_13a
ここでTaskBというタスクを新しく作るが、みかん本の「16.6省電力化」にも書いてある通りCPUがかなり使用されるタスクとなっている。
しばらくはTaskBを使う開発はあるが、
CPUファンが回ったりして開発しづらかった経験があるので、いつでもTaskBの起動を無効にするように作ったほうがいいかもしれない。
18章 アプリケーション
osbook_18c
ここで初めてRustが書かれた外部アプリを実行する。
外部アプリの実装
基本的には公式のC++コードの通り。
引数argvについてはMikanOSと同じようにcharのポインタとして宣言するが、これをRustで扱う必要がある。
そのためRustの標準ライブラリにあるCStr
を使いたかったが、no_std環境なので使うことができなかった。
仕方がないので標準ライブラリのコードを拝借した。
外部アプリのビルド
kernelのELFファイルのビルド方法を参考にビルド環境を作った。
ただし、jsonファイルの以下の部分は変更した
- みかん本にも記載がある通り、
image-base
を0に -
-o
をapps/rpn/rpn
に
カーネルから外部アプリの呼び出し
基本的には公式のC++コードの通りに実装。
ただ「外部アプリの実装」のときと同じように、argvをcharのポインタとして渡さないといけない。
ここでも標準ライブラリのCString
を使いたく、外部ライブラリ側と同じようにRust公式からコードを拝借した。
デバッグ方法について
今後このようなアプリを作成するようになるが、
- 自分が実装したカーネルから公式のアプリを呼び出す
- 公式のカーネルから自分が実装したアプリを呼び出す
という2種類のデバッグをすると、自分の実装のどこに問題があるかわかりやすかった。
19章 ページング
osbook_19a
largeアプリのビルド作るためにMikanOSで使われている-mcmodel=large
というオプションはrustcにはなさそうだった。
ただ特に指定せずにビルドしたが動いてくれたので、うまくビルドできているようだった。
アプリのビルドに必要になる.cargo/config.toml
やtarget.json
はほぼ一緒なので共通化したかったが、
良い方法が思いつかなかったのでアプリごとにファイルを用意することにした。
20章 システムコール
osbook_20a
すぐ消すことになるコードのようだったので実装はしなかった。
osbook_20e
公式を参考に実装をしたが、ログを出力するシステムコールをアプリ側が呼び出す時に、
通常のRustの文字列を同じように書いてしまうと、関係ない別の文字列まで一緒に表示されてしまうことが起こっていた。
原因はRustの文字列にはヌル終端文字が含まれないため。
そのため明示的にヌル終端文字を入れるようにした。
21章 アプリからウィンドウ
osbook_21b
テキストを出力するときに毎回SyscallLogString
を書くのも大変だったので、prinf
関数を実装した。
その時に使われるBufferが必要だったが、まだアプリ側で動的にメモリを確保する方法がなかったので、固定長のbufferであるByteBuffer
を用意した。
22章 グラフィックとイベント(1)
osbook_22g
みかん本に書いてある通り、アセンブリ言語から呼ばれる GetCurrentTaskOSStackPointer
に no_caller_saved_registers
というattributeをつける必要がある。
Rustでこれ相当の動きをする機能を探したが見つからなかったので、仕方なくC++側でRustを呼び出す関数を no_caller_saved_registers
attribute付きで用意し、
アセンブリ言語 -> C++ -> Rust
という流れでタスクのOS用スタックポインタを取得するようにした。
ただし、C++側のコードがうまく動かず、C++側に無意味なif文を追加することで対処した。
if文を追加した理由
C++側でRust側の関数だけをただ呼び出す場合、rdi
やrsi
などのレジスタが変わってしまい、kernel側のシステムコール関数に到達したときに、引数が意図しない値になっていた。
__attribute__((no_caller_saved_registers))
extern "C" uint64_t GetCurrentTaskOSStackPointer() {
auto p = GetCurrentTaskOSStackPointerInRust();
return p;
}
上記のコードを使ったkernel.elf
をobjdump
コマンドで見てみると、
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_registers
とoptnone
を同時に使うと、公式の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を使うことにした。
28章 日本語表示とリダイレクト
osbook_28b
結果としては、日本語フォントは動くようにはなっているが機能は無効化した。
フォントの読み込みには 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
に切り出すことにした。
30章 おまけアプリ
osbook_30e, osbook_30f
テキストビューアと画像ビューアを作成することになるが実装しなかった。
理由は、公式の実装を見る限りアプリ単独の開発のようで、OSの技術に関することは無さそうと判断したため。
排他制御
ここまででMikanOSの実装は一通り実装し終わったが、排他制御をしていないことがずっと気になっていた。
理由は、コンテキストスイッチが起きるとグローバルにある変数の mutable reference
が複数のタスクで参照されることになり、
RustのReferenceにおけるルールを破ってしまうため。
なのでMikanOS実装後に排他制御の対応を入れた。
実装はWriting an OS in RustのSpinlocksを参考にした。
またデッドロック部分の回避についてはsabiosのmutexを参考にした。
ただ既存の実装に対して入れるのは大変で、既存のコードを大きく修正してまでやるモチベーションはなく、完全にはやらなかった。
最後に
以上、MikanOSをRustでほぼ作ることができました。
座学・書籍で学んだことを実際に実装してみると解像度が上がってよかったです。
結構無理やりな実装はしてしまって良いコードではないのですが、
RustでMikanOSを作ってみたい人に少しでも参考になれば幸いです。
-
日本語フォントと最後のtview, gviewは未実装。理由は後述 ↩︎
Discussion