「ゼロからのOS自作入門」を Rust でやる (第7章)
引き続き Rust で OS を作っていきます。 今回は、Rust 1.39 で安定化されたあの機能が登場します!!!
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第6章 その2) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第9章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第7章
割り込みハンドリングの章です。 謎の現象が起きてなかなか実装に苦労した章です。
割り込み契機でマウスカーソルを動かすようにする (day07a)
6章までの実装では無限ループでポーリングすることで xHC のイベント発生を検知していました。 本章では xHCI で定められた割り込みの発生方法である、 MSI (Message Signaled Interrupts) に対応し、 MSI 割り込みを受け取った契機でマウスカーソルを動かすようにします。
C++実装の通りに割り込みハンドラを定義し、 IDT (Interrupt Descriptor Table) を設定し、 PCI コンフィグレーション空間の読み書きで MSI を設定しました。
さっそくQEMUで動かしてみたところ、割り込み呼び出し箇所で Page Fault や Double Fault や Segment Not Present やらの例外が発生して正しく動作しません。 なにが起きているのかよく分からないのですが、各例外発生時の情報を見て想像するに、どうやらセグメントがおかしいのが原因のようです。
というわけで GDT (Global Descriptor Table) でセグメント設定するようにしてみました。 コードセグメントを設定するだけではだめだったので、スタックセグメントも設定したところ、うまく動作するようになりました。 理屈は全く理解できていませんが、ひとまず動くようになったのでヨシ!ということで先にすすめたいと思います。
ヒープへ対応
C++ 実装でヒープに対応するのは第9章ですが、この次に実装するものでヒープが必要だったので先にヒープを実装してしまいます。
C++ 実装では brk
を実装することで newlib の malloc
を利用できるようにしていたのですが、 Rust ではこれ以上 newlib
に依存するのは避けたかったので、 "Writing an OS in Rust" の "Allocator Designs" の記事 を参考に独自のメモリアロケータを作成しました (ほぼほぼコピペですが...)。
このメモリアロケータ実装ではヒープ領域とするフレームの数と同じ回数だけ FrameAllocator::allocate()
を呼び出します。
今回ヒープ領域は 128MiB としているので当該関数は 128MiB / 4KiB = 32,768 回呼び出されることになります。
FrameAllocator
(BitmapMemoryManager
) の実装そのままでは処理に時間が掛かりすぎ、OSの起動に長時間かかるようになってしまったため、 BitmapMemoryManager
の実装を高速化しています。
具体的には、割り当て可能なフレームを探索する範囲を意味する BitmapMemoryAllocator::range
の値を、フレームの割り当ての度に更新することで不要な探索を削減するようにしています。
(フレームの割り当て回数 に対し、従来は
回処理が発生していたところを
回の処理で済むように改善しました)
これで Box
や Rc
や Arc
が使えるようになりました。
BIOS で起動しなくなっていたのを修正する (修正できなかった)
ここまで開発してきた機能は主に UEFI のブートローダーで動作するか確認していたのですが、 BIOS のブートローダーで確認したところ、起動処理中に panic が発生するようになってしまっていましたので修正します。
一つ目の問題は、ブートローダーに渡されたメモリのマッピング情報から各フレームの使用状況を更新する処理にありました。 具体的には、BIOS のブートローダーから渡されるメモリのマッピング情報に含まれるアドレスがフレーム境界に揃っていなかったためエラーとなっていたのでした。 このアドレスをフレーム境界に合うように丸めることで問題に対処しました。 なお、他の用途で使われているフレームを使用可能として扱ってはいけないため、フレーム全体が使用可能なフレームのみ使用可能としています。
これで一つ目の問題は解決したのですが、依然として C++ 側処理のログ出力処理で Segment Not Present の例外が発生してしまいます。
どうも浮動小数関連のレジスタを操作している箇所が問題で、 va_start
などが関係していそうな気がするのですが、原因がよく分からないですし、 UEFI 側では問題なく動作しているようなので、ひとまず放っておくことにします。
async/await を使えるようにする
"Writing an OS in Rust" の "Async/Await" の章 に感銘を受けたので、 sabios にも実装します。 割り込み契機のイベント処理のために async/await が使えると非常に便利で綺麗に書けると思います。 これがやりたかったがために MikanOS の Rust 移植を始めたと言っても過言ではありません。
Executor
等の実装は "Writing an OS in Rust" のほぼコピペです。
これだけのコードで非同期ランタイムが作れてしまうのはすごいですよね。
タスクを意味する構造体の名前は、 CoTask
(Cooperative Task)としました。
Task
ではなく CoTask
としたのは、後の章で出てくるプリエンプティブなタスクと区別するためです。
async/await で割り込みを処理する (day07b)
割り込みハンドラ内でイベント処理を行っていたのを、割り込みハンドラ内ではイベントを通知しメインのタスクで通知を受け取りイベント処理するように変更する節です。 Rust 実装ではグローバルなイベントキューではなく async/await を使います!
今回は割り込みが発生したか否かという情報だけを割り込みハンドラから CoTask
へ通知すれば良いので、キューではなく AtomicBool
で割り込み発生有無を通知するようにしました。
Ordering
は Relaxed
で良い...はず... (あまり自信がないです)
ついでにマウスカーソル移動時のイベント処理についても CoTask
にしました。
従来処理では C++ から呼び出されるコールバック関数内で mouse_cursor
や framebuffer
のロックを取得していたため、ロック順序の整合性がとれているか不安だったのですが、今回の改造によりコールバック関数の中ではロックを取得しなくなったので、安心度が上がりました。
まとめ
割り込みに対応し、割り込み契機でマウスカーソルを動かせるようにしました。 また、 async/await で割り込みをエレガントに処理できるようにしました。 一方、BIOS のブートローダーではなぜか動作しなくなってしまいました。謎です。。。
さて、8章の内容は先日やってしまったので、次は9章です。 どんどん OS が高機能化していって Rust で実装する楽しみが増えそうですね! 次回もお楽しみに。
「ゼロからのOS自作入門」を Rust でやる (第6章 その2)
ちょっと間が空いてしまいましたが、第6章の続きです。 今回はついにマウスカーソルを動かせるようになります!
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第8章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第7章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第6章
第6章 その1 の記事では C++ の USB ドライバをコンパイル & カーネルにリンクした上で Rust 側コードから呼び出すようにしましたが、XHC との通信を行う MMIO 領域へアクセスするとページフォールトが発生してしまったのでした。
今回はページテーブル等の設定を行う事で USB マウスでマウスカーソルを動かせるようにします。
MMIO 領域に仮想アドレスを割り当てる
第8章 で用意した部品を使って、 xhc_mmio_base
から 64KiB 分のフレームを仮想アドレスにマッピングします。
マッピングするアドレスは物理アドレスと同じアドレスとします (アイデンティティマッピング)。
アイデンティティマッピングとすることでアイデンティティマッピングを前提としている USB ドライバ実装の変更が不要になります。
64KiB マッピングしているのは、 Intel のリファレンスのページ にそう書いてあるためです (This gives 64 KB of relocatable memory space aligned to 64 KB boundaries.)。
マッピングをしているのは以下処理です。
{ // Map [xhc_mmio_base..(xhc_mmio_base+64kib)] as identity map use x86_64::structures::paging::PageTableFlags as Flags; let base_page = Page::from_start_address(VirtAddr::new(xhc_mmio_base))?; let base_frame = PhysFrame::from_start_address(PhysAddr::new(xhc_mmio_base))?; let flags = Flags::PRESENT | Flags::WRITABLE; let mut allocator = memory::lock_memory_manager(); for i in 0..16 { let page = base_page + i; let frame = base_frame + i; unsafe { mapper.map_to(page, frame, flags, &mut *allocator) }?.flush(); } }
x86_64
クレート の x86_64::structures::paging::mapper::Mapper
トレイト を使い、 page
を frame
にマッピングしています。
ページテーブルを配置するための物理メモリを割り当てる必要があるため allocator
を引数に渡しています。
この対応により無事ページフォールトは発生しなくなりました! やったね!
続きの部分の実装
残りの処理も実装しました。
C++ 実装と大きな差異はありません。
一通り実装して動かしてみたのですが... 動きません。 なぜでしょうか。 ページフォールトなどの例外も発生していないようです。
デバッグ
どうやら C++ のコードがうまく動作していないようです。 それなりの規模のコードをデバッガなしでデバッグするのはつらいので、 gdb でデバッグを試してみましょう。
まず、 QEMU の起動オプションに以下を追加します。
$ qemu-system-x86_64 ... -gdb tcp::1234
これにより localhost のポート 1234 で gdb の接続を受け付けるようになります。 別の端末から以下のように gdb を起動するとアタッチできます。
$ gdb ./target/x86_64-sabios/debug/sabios -ex "target remote localhost:1234"
実行中のコード行やバックトレースも表示され、 ユーザーランドの普通のプログラムをデバッグしているかのようですね。
gdb でステップ実行などして調べてみると usb::xhci::ProcessEvent
関数 の振る舞いがおかしいようです。
当該関数ではどこからか通知されたイベントを処理しているようですが、イベントがひとつもキューイングされてきていないようです。
イベントをキューイングする処理が正しく動作していないのかもしれません。
次に、イベントをキューイングしているのは誰かを調べてみましょう。
PrimaryEventRing()
で返ってくるのは usb::xhc::EventRing
型の参照なので、 EventRing
の実装を調べるのが良さそうです。
ProcessEvent
関数で呼び出している EventRing::HasFront
メソッドでは ReadDequeuePointer
というメソッドを呼び出しています。
ReadDequeuePointer
の実装 では interrupter_->ERDP.Read().Pointer()
を呼び出しています。
interrupter_
は、EventRing::Initialize
呼出し時 に渡されるもので、 InterrupterRegisterSets()
で返される配列の先頭へのポインタです。
InterruptRegisterSets
の定義 より、これは xhc_mmio_base
に一定のオフセットを加算した領域のようです。また、当該関数の戻り値型である InterrupterRegisterSetArray
は メモリマップされたレジスタを意味する構造体 (の配列) のようです。
interrupter_->ERDP.Read().Pointer()
の意味するところは、 ERDP
というメモリマップドレジスタの値を読み込んでいるようですね。
まとめると、EventRing
ではメモリマップドレジスタの値を読み込み、値が特定の条件を満たしている場合にイベントが発生したと判断するようです。
イベントが発生していないということは、このメモリマップドレジスタの値の更新が (XHC により?) 行われていないというのではないでしょうか。
もしそうであれば、 XHC の初期化処理が正しく行われていないのではないかと想像されます。
この観点でもう少し調べて見ましょう。
手始めに EventRint::Initialize
の実装を見てみましょう。
ざっと読むと、以下のような処理を行っているようです。
AllocArray
を呼び出し、buf_
とerst_
の領域を確保ERSTSZ
レジスタにerstsz
(erst
のサイズ?) をセットERDP
レジスタにbuf_
のアドレスをセットERSTBA
レジスタにerst_
のアドレスをセット
ここで ERDP
と ERSTBA
にセットされるアドレスはどのようなものなのでしょうか。
AllocArray
の実装 を見てみると、グローバル変数として静的に獲得された領域のようです。
C++プログラム上で取り扱っているアドレスをそのままレジスタにセットしているため、設定される値は当然仮想アドレスです。
一方、XHC 側が必要とするアドレスは物理アドレスだと想像されます。
仮想アドレスが物理アドレスと一致しないために、 EventRing
は正しく動作しないのではないでしょうか。
仮想アドレスと物理アドレスが一致していないかどうか確認するため、 bootloader のソースを確認してみましょう。
bootloader のカーネルをロードする処理 では、セグメントをロードする物理アドレスはファイル中のセグメント位置に固定値 (self.kernel_offset
) を足したもの、仮想アドレスはセグメントで指定された仮想アドレスにロードしているようです。
self.kernel_offset
の値は、 カーネルと同じサイズ (?) の static 変数の先頭アドレス でした。
この実装を見る限り、仮想アドレスと物理アドレスが必ず一致するようなソースにはなっていないようですね。
対処
どうやら、XHC のレジスタに渡すアドレスが適切な物理アドレスになっていないところに問題がありそうです。 他にも問題はあるかもしれませんが、まずはこの問題に対処してみましょう。以下の方針が考えられそうです。
- ブートローダーのカーネルのロード処理を改造し、カーネルをロードした範囲では仮想アドレスと物理アドレスが一致するようにする
- USB ドライバーを改造し、メモリマップドIOに利用するバッファ領域を仮想アドレスと物理アドレスが一致している領域に配置する
- USB ドライバーを改造し、アドレスをレジスタにセットする処理で仮想アドレスを物理アドレスへ変換した上でセットするようにする
1, 3 は大変そうなので、簡単な2を採用しました。
static 変数で 32 * 4096 バイトの領域を確保していたところを、 FrameAllocator
32フレーム獲得した上でアイデンティティマッピングを設定し、その仮想アドレスをバッファのアドレスとしてセットするようにしました。
ドキドキしながら実行してみたところ、無事マウスカーソルがマウスの動きに連動して動作するようになりました。やったーーー!!!
まとめ
紆余曲折ありましたが、無事USBドライバを動作させマウスの動きをキャプチャできるようになりました。 C++とのFFIに苦戦するかと思っていたのですが、そこは思ったよりすんなりとできました。 苦労したのは bootloader のメモリの使い方の差異への対応でしたが、なんとか解決できて良かったです。 また、C++のUSBドライバ実装にも少しだけ詳しくなれた気がします。
一つ大きな山を越えられたので、この先もどんどん実装していきたいと思います。
「ゼロからのOS自作入門」を Rust でやる (第8章)
早い物でこのシリーズも4記事目です。 内容もだんだん難しくなってきて、Rust に移植するのも大変になってきましたが、頑張っていきましょう。
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第6章 その1) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第6章 その2) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第8章
メモリ管理を実装する章です。 前回 の記事 (第6章その1) では USB マウスへ対応させようとしていたのですが、 USB デバイスの制御に必要な MMIO 領域の仮想アドレスが割り当てられていないためにアクセスができず、USB ドライバーが動作しませんでした。 この問題に対処するため、先に8章を実装することにしたのでした。
UEFI メモリマップの表示 (day08a)
ブートローダーから渡されるメモリマップ情報を表示させます。
bootloader
クレートのブートローダーから渡されるメモリマップ情報 (memory_regions) では、連続した領域でもページ毎に(?)バラバラになっており、そのまま println
すると出力が大量となってしまいます。
この現象への対処として、 MemoryRegions
というイテレータを作成し、連続した領域は一つにまとめるようにしています。
ブートローダーから渡される情報メモリマップ情報では各領域の種別は以下 enum で表されます。
MemoryRegionKind in bootloader::boot_info - Rust より
#[non_exhaustive] #[repr(C)] pub enum MemoryRegionKind { Usable, Bootloader, UnknownUefi(u32), UnknownBios(u32), }
Usable
がカーネルで自由に利用可能な領域です。
UnknownUefi(u32)
についても u32
が特定の値の場合は利用可能だと思うのですが、どの数値が何に対応しているのかが分からなかったこと (UEFIのヘッダを見れば分かるのでしょうが)、また、全領域合計でたかだか2MB程度だったので当該領域の再利用はやめ、 Usable
の領域だけ利用することとしました。
これにより、UEFI データ領域に割り当てられている領域の移動は不要になりますので、スタック領域の移動、セグメンテーションの設定は省略しました。
ページングの設定
C++版ではページング設定として物理アドレスを同じ仮想アドレスにマッピング (アイデンティティマッピング) していました。
Rust では bootloader
クレートが物理メモリのマッピングに対応しているのでそれをそのまま使い楽をしました。
やったことは簡単で、 Cargo.toml
に以下を書き足しただけです。
[package.metadata.bootloader] map-physical-memory = true
bootloader
クレートのマッピング方式は物理アドレスに固定のオフセット値を足した仮想アドレスにマッピングする方式です。
今のところはページテーブルの設定等でこの差異を気にする必要があります。
ひとまず、 Writing an OS in Rust の Paging の章 を参照し、ページテーブルの取得とアドレス変換について実装してみました。
フレームアロケーターの作成 (day08c)
ページフレームを割り当てを管理するフレームアロケーターを作成しました。
実装は C++ と同じです。 一点引っかかった点として、 "Writing an OS in Rust" のようにフレームアロケーターをローカル変数としてスタック上に割り当てたところ、カーネルが起動しなくなったという点がありました。
これは、恐らくスタックオーバーフローが発生していたためです。
「ゼロからのOS自作入門」のフレームアロケーターは128GiB分のフレームの管理領域を保持するため32MiBの固定長配列を獲得します。
これは bootloader
クレートで設定されるスタックサイズ 80KiB より大きいです。
フレームアロケーターを static 変数とすることでスタックオーバーフローを回避しました。
まとめ
メモリ関連の処理を実装しました。 ページテーブルへのアクセスも可能になったため、当初の目的である6章を先に進めることも可能になったことでしょう。 というわけで次回は6章に戻ろうと思います。お楽しみに。
「ゼロからのOS自作入門」を Rust でやる (第6章 その1)
今日も今日とて「ゼロからのOS自作入門」をRustでやっていきます。
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第5章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第8章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
6章
MikanOS を USB マウスへ対応させる章です。
マウスカーソルとデスクトップを描画 (day06a)
マウスカーソルとデスクトップを描画する節です。 描画するといっても、ただ表示するだけで操作などはできないものなので簡単です。
デスクトップの描画とマウスカーソルの描画はそれぞれ desktop
, mouse
というモジュールへと分割しています。
デスクトップの描画処理ではスクリーンのサイズ情報が必要です。
FrameBufferInfo
構造体のメンバが使えるのですが、当該構造体メンバの型は usize
であるため、
描画処理で利用している型である i32
への変換が必要です。
スクリーンサイズを必要とする処理は他にも登場することが予想され、それぞれで型変換やそれに伴うエラーハンドリングを実装するのはめんどくさいため、 i32
型でスクリーンサイズ等の情報を格納する ScreenInfo
構造体を用意し、 framebuffer の初期化時に1度だけ初期化するようにしました。
PCI デバイスの探索 (day06b)
IO 命令により PCI コンフィグレーション空間にアクセスし、 USB のホストコントローラ (今回は xHC) に対応するデバイスを探索します。
処理は C++ 版と同じです。工夫した点をいくつか紹介します。
インラインアセンブリを使わない
IO ポートにアクセスするためには通常アセンブリを書く必要があります。
今回は x86_64
クレート の x86_64::instructions::port::Port
を利用することで、インラインアセンブリを手書きせずに済ませました。
アトミックな IO ポートアクセス
PCI コンフィグレーション空間にアクセスするためには
という2つの手順が必要です。
この 1 と 2 の間に別のタスクが CONFIG_ADDRESS
レジスタの操作を行ってしまうと意図せぬ結果となる可能性があるため、両レジスタへアクセスしている間は spin::Mutex
のロックを取得するようにしました。
bit_field::BitField
の利用
PCI コンフィグレーション空間からデバイスの情報を取得するためには、ビットフィールド操作が必要になります。
u32
のうち n ビット目から m ビット目までを特定の数値を設定する、といった操作です。
この操作を簡単化するため bit_field
クレート の bit_field::BitField
構造体を使いました。
ビットフィールドの読み書きが以下のように書けます。
// 値の設定 let mut value = 0u32; value.set_bits(0..8, u32::from(reg_addr)); value.set_bits(8..11, u32::from(function)); value.set_bits(11..16, u32::from(device)); value.set_bits(16..24, u32::from(bus)); value.set_bits(24..31, 0); value.set_bit(31, true); // 値の取得 let bus = values.get_bits(16..24) as u8;
range の形式でビット範囲が表せるのは非常に分かり易いですね。
USB マウスへの対応 (day06c)
さて、ここが問題のUSBマウス対応です。
実装方針
C++ 版実装では、「ゼロからのOS自作入門」著者の方が開発された USB ドライバ一式をインポートして使っています。 曰く、「USB関連のドライバは本書で説明するには高度で複雑すぎますので、筆者が開発したドライバを使う方法を説明するだけにします」とのこと。 OS自作が主題であるためこの対応はまったく正しいと思います。
では Rust 版を実装するにあたりどうするかですが、私も「ゼロからのOS自作入門」と同じアプローチを取りたいと思います。 つまり、C++のUSBドライバ実装を流用することとします。
USB ドライバを Rust で実装するのも興味深いですし、実際にそのアプローチで Rust 版 MikanOS を実装しようとされている方も、また、 USB ドライバの実装に成功された方もいらっしゃいます。 しかし、私が同じことをやろうとすると間違いなく時間がかかるでしょうし、その間このブログの更新は止まってしまうでしょうし、なにより作業のモチベーションを保つのが難しそうだと考えたので、 USB ドライバの Rust 移植はまずは諦めることとしました。
USB ドライバの移植は、一通り OS 実装が完了し満足した後で次の課題として取り組めたら取り組もうかなというスタンスで進めていきたいと思います。
というわけで C++ ソースと Rust ソースを結合して一つのバイナリ (カーネル) を作ることになるわけですが、以下のような作戦をとりました。
- MikanOS の USB ドライバ一式 (と、Rust から呼び出すためのグルーコード) を含むクレート
mikanos_usb
を作成 mikanos_usb
のbuild.rs
で C++ ソースをコンパイルし、静的ライブラリlibmikanos_usb.a
を作成し、クレートに含める- 1, 2 で作成したクレートをカーネルのクレート (sabios) から利用する
この中でもポイントとなる build.rs
の中身について説明します。
C++ ソースのコンパイル
build.rs
の中で C++ ソースをコンパイルするために、 cc
クレート を使いました。
コンパイラは gcc/g++ ではなく clang/clang++ を使わせたいため、少し行儀が悪いですが build.rs
内で環境変数 CC
と CXX
に clang
/ clang++
を設定しています。
なお、 C++ の標準ヘッダファイルも一式用意する必要があります。 当初はソースコード中にヘッダファイルを全て含めていたのですが、後に方針変更しています (後述)。
依存ライブラリの同梱
C++ ソースは newlib
の libc
および LLVM の libc++
, libc++abi
に依存しているため、 libmikanos_usb.a
をカーネルにリンクする場合は、 libc.a
および libc++.a
, libc++abi.a
もリンクする必要があります。
C++ 版では作者の方がコンパイルしたバイナリを使っています。
Rust 版では本来はこれらのライブラリについてもソースからビルドするのが良いのですが、コンパイル時間が長くなりそうだったので、作者の方がコンパイルしたバイナリを使うことにしました。
具体的には、 build.rs
の中で GitHub からライブラリを含んだアーカイブをダウンロードし OUT_DIR
配下に展開、
println!("cargo:rustc-link-lib=static={}", lib);
により cargo
にライブラリをリンクすることを指示するというやり方をしました。
buiild.rs
の中でウェブにアクセスするのは禁じ手ですが、あくまでの学習用のプロジェクトということでご容赦頂きたく...
副作用として、 GitHub からダウンロードしたアーカイブにはヘッダファイル一式も含まれていたため、C++ のビルド時にもこのヘッダファイルを利用することとしました。 リポジトリに大量のヘッダファイルを登録しなくても済むようになったところは嬉しいですね。
さて、後は Rust から C++ コードを呼び出せるように、また、 C++ から Rust を呼び出せるように、グルーコードを書きます。
適当に extern "C"
な関数を定義すれば OK です。
さて、 C++ で定義されたクラスのコンストラクタを呼び出すところまで実装できました。 まだすべては実装できていませんが、できたところから動作確認していくのが良いでしょう。
動作確認
C++ ライブラリをリンク & 呼び出すようにし、さっそく実行してみたところ、 OS が reset され、 QEMU 上で再起動を繰り返すようになってしました。 なんででしょうか。
デバッグのため QEMU の起動オプションに -d int --no-reboot --no-shutdown
を追加します。
これにより、割り込み発生時に割り込みの情報が標準出力に吐き出されます。
また、OS リセット時に仮想マシンが再起動せず止まった状態のままになります。
再実行したところ、どうやら例外 0xe
が発生しているようです。
OSDev Wiki によると、 0xe
は Page Fault とのこと。
どうやら不正なアドレスへのアクセスが起きているようですね。
QEMU の出力から、例外発生時の RIP も分かります。
QEMU のコンソールから x /5i 0x<RIPの値>
を実行することで RIP 周辺の実行命令が分かります。
レジスタの値など合わせて判断するに、 xhc_mmio_base
のアドレスにアクセスしようとして Page Fault が発生しているようです。
この値は今回呼び出しを追加した C++ の usb::xhc::Controller
クラスのコンストラクタに渡している値でもあり、つじつまが合いますね。
すべての物理メモリをマッピングする
今回利用しているブートローダーの bootloader
クレートはページテーブルを更新しているようです。
xhc_mmio_base
の値は物理アドレスのはずですが、そのアドレスに向けた仮想アドレスがマッピングされていないのではないかと考えました。
bootloader
の設定 を参照したところ、 map-physical-page
という設定があり、デフォルト値は false
でした。
これを true
にすることで物理メモリがマッピングされ、 xhc_mmio_base
のアドレスへもアクセスできるようになるのではないかと考え試してみました。
このオプションでは物理アドレス0以降のメモリを physical_memory_offset
だけずらした仮想メモリアドレスにマッピングするため、 xhc_mmio_base
のアドレスに physical_memory_offset
だけ足したものを usb::xhc::Controller
のコンストラクタに渡してみます。
だめでした。
bootloader
の map-physical-page 有効時の処理 では、アドレス 0 から物理メモリサイズ分の領域を仮想アドレスにマッピングしています。
今回 xhc_mmio_base
の値は 0x8_0000_0000
だったのですが、これは 0x0
から 32GiB 分だけ離れた領域になります。
QEMU 仮想マシンにはのメモリ容量はもっと小さいはずなので、 xhc_mmio_base
の領域が仮想アドレスにマッピングされないのは当然です。
どうやら自分で xhc_mmio_base
の物理アドレスを仮想アドレスにマッピングする必要がありそうですね。
しかし、6章時点ではまだページング関連処理は実装されていません。 6章を進めるのは一旦ここでやめ、先にページングを実装することとしました。
まとめ
USB ドライバを利用しようとしたところ、C++プログラムのコンパイルとリンクはうまくいきましたが、
bootloader
クレートの仕様により xhc_mmio_base
へのアクセスができませんでした。
対処のためにはページテーブルを更新して当該アドレスへアクセスできるようにする必要があります。
一旦6章は中断し、先に8章に進むことにしました。次回もお楽しみに。
「ゼロからのOS自作入門」を Rust でやる (第5章)
引き続き「ゼロからのOS自作入門」を Rust でやっていきます。
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第6章 その1) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
完走できるといいなぁ。
第5章に入る前に
前回作った部分でいくつか気になった箇所があったので修正しました。代表的なものを紹介します。
unwrap
/ expect
を減らす
main
以外の処理では unwrap
/ expect
を使わず Result
で呼び出し元にエラーを通知するようにしました。
unwrap
/ expect
の利用箇所を見つけるためには clippy の以下 lint が便利です。
#![warn(clippy::unwrap_used)] #![warn(clippy::expect_used)]
今回独自の Error
型を定義した訳ですが、 thiserror
などが使えないのはやはりめんどくさい。
thiserror
の no_std 対応は Pull Request はあるのですが、マージされないまま1年以上経っており状況がよくわからないです。。。
描画関連処理を trait へ括りだす
framebuffer::Drawer
の描画関連処理を graphics::Draw
トレイトへ括りだし、 framebuffer::Drawer
が当該トレイトを実装するようにました。
今後 &dyn Draw
により描画のバックエンドを切り替えられるようにすることが必要になってくるので、このような対応をしました。
Draw
が object safe であることを保証するため、 static_assertions::assert_obj_safe
を使っています。
最初からこのような形にしなかったのは、トレイトの関数の引数では型パラメータや impl Trait
を使いたかったためです。
例えば、以下のような関数を持つトレイトは object safe ではありません。
impl Draw { fn draw(&mut self, point: impl Into<Point<i32>>); }
上記のように引数を impl Into<T>
で受けられると便利なのですが、今回は諦めました。
もう一点、細かい話ですが画面上の座標や長さを表す型は Point<i32>
, Size<i32>
としています。
どちらも Vector<i32>
の別名で同じ型なのですが、コードを見やすくするために別名を与えています。
数値として i32
を利用しているのは、座標が負値になることがあったり、座標の引き算などを簡単にできるようにしたかったためです。
配列のインデックスの usize
型との変換がめんどくさい (そして as
や unwrap
が登場しがち) という問題はありますが、
細かな工夫をすることで乗り越えることにします (例えば、 iter.enumerate()
の代わりに (0..).zip(iter)
を使うなど)。
第5章
文字列の出力をする章です。
文字 'A' の出力 (day05a)
特に難しいことはやっていません。
スクリーン範囲外に描画しようとした場合に発生したエラーを無視するための手段として Result<(), DrawError>::ignore_out_of_range()
なんて関数を定義しているあたりが少し面白いくらいでしょうか (この後のコミットでスクリーン範囲外に描画した場合もエラーにしないよう変更しており、結局この関数は削除されていますが)。
分割コンパイル (day05b)
C++ のファイルを複数に分割する話です。 最初からモジュール分割してプログラムを作成していたため、スキップしました。
フォントを増やす & 文字列の出力 (day05c)
'A' 以外の文字を出力するために、テキストファイルに定義された字形 (フォント) をプログラム中から利用するという節でした。
C++ 実装ではテキストからオブジェクトファイルを生成していました。
Rust 実装では build.rs
でコード生成しモジュールとしてインポートするようにしました。 Rust ではよくあるパターンですね。
C++ で写経していたときは objcopy 実行ディレクトリと入力ファイルの (hankaku.bin
) のパスが変わると生成されるシンボル名が変わる現象に悩まされたりしましたが、 Rust のソースコード生成ではこのあたりの悩みとは無縁なので良いですね。
write
/ writeln
で文字列を出力できるようにする (day05d)
C++ では sprintf
を使えるようにしていましたが、 Rust では write!
と writeln!
を使えるようにします。
まだヒープアロケーションはないので format!
は使えません。
やったことは簡単で、 fmt::Write
を実装した font::StringDrawer
を用意しました。
write!
呼び出し1回につき複数回 fmt::Write::write_fmt
が呼び出されることがあるので、 font::StringDrawer
では次に描画する位置を覚えておき、1回出力することに更新する必要があります。
最初この処理を忘れてしまったため、文字が同じ場所に重ねて出力されて大変な事になっていました。 書いたプログラムが滅茶苦茶な動作をするのを見るのも自作の楽しさかなという感じです。
コンソールを追加する (day05e)
最終的に C++ 版をベタ移植したようなコードに落ち着きました。
Console::buffer
の持ち方をどうするか悩んだのですが、どうせ ASCII の範囲しか表示されないだろうということで割り切って u8
の配列としました。
ASCII の範囲で表せない文字を表示しようとした場合、 font::char_to_byte
により b'?'
に変換されます。
また、前の章で &str
を受け取る描画関数 font::draw_str
を用意したのですがこれは使わず、 u8
の配列を受け取る font::draw_byte_str
を使うようになっています。
このあたりの文字列処理は後々ちゃんとしたいですね。
printf!
/ println!
を追加する (day05f)
先ほど作った console::Console
をグローバル変数にし、カーネル内の各所から printf!
/ println!
により文字列を書き込めるようにしました。
グローバル変数の排他の取り方に苦労しています。
元々の Console
の実装ではフレームバッファへの可変参照 &mut framebuffer::FrameBuffer
を保持していましたが、これをそのままにして console::Console
をグローバル変数にするのは論外です。
console::Console
以外から画面への出力ができなくなってしまうためです。
ちょっと悩んだのですが、以下のようにしています。
console::Console
にはframebuffer::Framebuffer
を含めず初期化- コンソールへの出力処理 (
_print
) でフレームバッファとコンソールのロックを取得し、Console::writer()
によりfmt::Write
を実装したConsoleWriter
を取得する ConsoleWrite
に対してwrite!
する
framebuffer のロックは様々なタスクで奪い合いになりそうなのでなんだか不安な感じがしますが、ひとまずはこれでいこうかと。 そのうちちゃんとします。
おまけ
今回作成した print!
, println!
を使い、 panic
時にメッセージを出力するようにしました。
panic 時に行っていた処理で framebuffer やコンソールのロックを取得していた場合デッドロックしますが、まあ何もしないよりはマシかなと。
unsafe
なコードを書き始めたら、割り込み発生時の情報などの出力できるようにしたいですね。
まとめ
文字列の描画関連を用意しました。 ロック周りや文字/文字列の扱いで少し悩んだところはありましたが、まだまだ全然簡単ですね。
次章は Rust 実装最大の (?) 鬼門であるUSBデバイス対応です。 この難題にどう立ち向かうのか。お楽しみに。
「ゼロからのOS自作入門」を Rust でやる (第1章~第4章)
ゼロからのOS自作入門 を 一通り写経 したところ、 Rust に移植したくなったのでやっていきます。
github.com Rust で OS なので名前は "錆OS" です。安直です。 "sabios" でざっとググったところ、スペイン語で "賢い" って意味があるようです。 良いですね。 クレバーな実装を目指したいところです。
方針
「ゼロからのOS自作入門」の章立てに沿って1章から順番に実装していきます。 C++ で一通り写経は完了しているので完成形の OS と関係ない節はスキップしていきます。
せっかく Rust で実装するので安全性や抽象化という点で MikanOS との差異を出せたら良いなと思っています。
それでは早速やっていきましょう。
第1章~3章
UEFI でブートローダーを作成し自作カーネルを起動する章です。
uefi-rs
を利用してブートローダーを自作している方も多かったですが、
今回は楽をして Writing an OS in Rust の作者が提供している bootloader
を利用することにしました。
bootloader
は 2021/5/5 に公開された v0.10 より UEFI でのブートに対応しています。
bootloader
を使ったブートローダーの作成は特殊な手順を踏む必要があるのですが、 Writing an OS in Rust の 3rd edition のドラフト の post-02 で詳しく説明されているのでこれに従って実装します。当該記事では cargo の alias を利用してビルドコマンド実行を簡易化していますが、あまり好みではなかったので sabios では Makefile を採用しました。
記事に従って実装していくとあら不思議、3章完了時点の MikanOS とほとんど同じ画面出力が得られました。 これにて3章まで完了とします。 (2章のメモリマップファイルの作成は UEFI 側の処理なので省略しました。)
3章完了時点のソースは以下。
第4章
本格的なOS実装の始まりです。 最初の一歩はフレームバッファを利用した画面描画用モジュールの作成です。
最初の実装
まずは細かい事を気にせずに、 Rust のスタイルでベタに書いてみました。
フレームバッファへの描画を担当する framebuffer::Drawer
構造体を作成しました。
この構造体を main
関数内で初期化し、描画処理を呼び出しています。
特に難しいことはないですね。
おまけで MikanOS 本では対応していなかった PixelFormat::U8
なフレームバッファにも対応しています。
Rust では同一領域に対する &mut
の参照が複数同時に存在すると Undefined Behavior となるため、 ブートローダーから渡される FrameBuffer
からバッファのスライス &mut [u8]
を取り出して利用するのではなく、 FrameBuffer
構造体そのものを framebuffer::Drawer
のフィールドとして保持するようにしています。
フレームバッファをどこからでも使えるようにする
これで描画はできるようになったのですが、このままではフレームバッファに描画するために framebuffer::Drawer
への参照をあちこちに取り回す必要があります。
それではあまりにも不便なので framebuffer::Drawer
型のグローバル変数 (static
変数) を作成し、カーネル内のどこからでもアクセスできるようにします。
FrameBuffer
はカーネルのエントリポイント (kernel_main
) の引数として渡される情報であるため、static 変数の初期化のタイミングでは利用することができません。
static 変数の初期化のタイミングでは未初期化状態のままにしておき、 kernel_main
から framebuffer::init
を呼び出すタイミングで初期化します。
このような初期化動作を実現するために conquer_once::OnceCell
を利用しています。
また、 static 変数への mutable アクセスを実現するために spin::Mutex
を利用しています。
spin::Mutex
は no_std
でも利用できる Mutex です。名前の通りスピンロックが使われています。
どちらの crate も Writing an OS in Rust で知りました (post-03 と post-12)。本当に良い記事です。
フレームバッファの性能改善
ここまででフレームバッファを使うための基本的な機能は一通り揃いました。 続いては性能改善です。 MikanOS では C++ の仮想関数を使うことで override により条件分岐の回数を大幅に減らすことで性能改善していました。 sabios では dyn Trait を使った動的ディスパッチにより同様の性能改善を実現しました。 画面を見ながらストップウォッチで描画速度を計測したところ、だいたい2倍強高速になったようです。やったね!
framebuffer::Drawer
全体を dyn Trait 化するのはややこしくなりそうな気がしたので、分岐のある部分のみ dyn Trait として抽出しています。
性能改善の度合いを測定するため、カーネルを release ビルドできるように Makefile も改造しています (コミットメッセージが typo してて意味不明になってますが)。
まとめ
「ゼロからのOS自作入門」の第4章までを Rust で実装しました。 ここまでは大きな引っかかりもなくスムーズに実装できました。 次の記事では第5章以降を実装していきます。
PlantUML サーバーの Docker コンテナをオンデマンドで起動する
tl;dr
- VSCode の PlantUML 拡張は便利だがデフォルトでは遅い (JVM 起動や各種 class ファイルのロードに時間がかかる?)
- PlantUML サーバーをローカルで起動することで高速化可能
- PlantUML のようなたまにしか使わないサービスを常時起動させておくのはなんとなく嫌
- 本記事では特定のポートにアクセスがあった時点で PlantUML を起動するための設定方法を記す
使うもの
- systemd
- docker
手順1: PlantUML Docker サービスを systemd サービス化する
systemctl
コマンドで PlantUML サーバーを起動できるようにします。
以下では TCP 51000番ポートを PlantUML サーバーへ割り当てます。
# unit ファイルの作成 $ sudo -E systemctl edit --force --full docker-plantuml.service
エディタが開くので以下を入力。
[Unit] Description=PlantUML Server Container After=docker.service Requires=docker.service [Service] TimeoutStartSec=0 Restart=always ExecStartPre=-/usr/bin/docker stop %n ExecStartPre=-/usr/bin/docker rm %n ExecStartPre=/usr/bin/docker pull plantuml/plantuml-server ExecStart=/usr/bin/docker run --rm --name %n -p 51000:8080 plantuml/plantuml-server ExecStartPost=/usr/bin/wget --quiet --method HEAD --waitretry 0.1 --tries 600 --retry-connrefused http://localhost:51000 [Install] WantedBy=multi-user.target
参考にしたサイト では nc -z $host $port
でサービス起動を待ち合わせていましたが、 PlantUML サーバーの場合は TCPポートへ connect(2)
できるようになってから (コンテナが起動してから?) HTTP サーバーが応答を返せるようになるまで10秒弱程度のタイムラグがあるようなので、 wget(1)
でポーリングするようにしています。
手順2: ソケット接続時に docker-plantuml
サービスを起動する
systemd には socket activation という特定のポートへの接続があった時にサービスを起動するような仕組みがあります。 この仕組みを使うためにはは、起動されるサービス側が socket activateion に対応している必要があり、 残念ながら Docker は対応していません。
socket activation に対応したプロキシである systemd-socket-proxyd
を利用し、
特定のポートへの接続と Docker の listen(2)
しているポートへの接続を仲介することで、
特定の TCP ポートに接続があった時に Docker コンテナを起動させることができます。
以下では、 TCP 50000 番ポートへ connect(2)
された場合に、 PlantUML のサービス (TCP 510000番) へ転送するように設定しています。
$ sudo -E systemctl edit --force --full docker-plantuml-proxy.socket
エディタが開くので以下を入力。
[Socket] ListenStream=50000 [Install] WantedBy=sockets.target
$ sudo -E systemctl edit --force --full docker-plantuml-proxy.service
エディタが開くので以下を入力。
[Unit] Requires=docker-plantuml.service After=docker-plantuml.service [Service] ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:51000
最後に、 docker-plantuml-proxy.socket
を有効化 & 起動してください。
$ sudo systemctl enable docker-plantuml-proxy.socket $ sudo systemctl start docker-plantuml-proxy.socket
手順3: VSCode の設定
以下のように設定すればOK!
{ "plantuml.render": "PlantUMLServer", "plantuml.server": "http://localhost:50000/" }
手順4: 動作確認
VSCode で PlantUML ファイルを開きプレビューすると、 PlantUML サーバーが起動してレンダリングされるはずです。