飽きっぽい人のブログ@qwerty2501

プログラマとしてもテスターとしても中途半端な人のブログ

RustでOS作成したという論文を訳してみた その2

ここの翻訳になります
原文は2015年に書かれたものなので現在のRustの仕様と異なることが書いてある場合があります
本文章はGithubで管理されています
この翻訳は7割方間違っているので信用しないでください

前:その1 次:その3

2 Reenix

ReenixはRustで可能な部分のWeenix OSの大部分の再実装するという私のプロジェクトの名前だ。このプロジェクトでは仕事をWeenixプロジェクトでほとんど行ったように分けた。Weenixの5つのセクションで(小節1.1を見てほしい)、十分に1番目と2番目、プロセスとドライバを実装できた。また、VFSの非自明な部分のプロジェクトとS5FSとVMを完成させるために必要なコード作成を終えることができた。これを行う上でまた、Weenixの大部分をRustのコードをサポートするために変換と書き直しをする必要があった。プロジェクトの結果を含んだ全てのコードとオリジナルのWeenixへのパッチはGithubで見つけることができる。

2.1 機構

Reenix立ち上げにおいて、Rust言語のcratesのコンセプトを使った。Rustのcrateは関連するライブラリにコンパイルされることができるコードのパッケージ、または(いくつかのケースにおいて)バイナリだ。それらは一般的に'lib.rs'ファイルがあるフォルダを見つけることにより識別され、crateルートのための標準の(必須ではない)名前となる。Reenixのためプロジェクトを完全に新規の9つのプロジェクトに分けた。それらはまた、このプロジェクトで使っていた三つのコンパイルプラグインで、内二つは筆者が作成した。最後に、いくつかの標準ライブラリの枠組を利用し、Reenixのためだけに使う標準ライブラリのバージョンを作った。
相対的にReenixは19のcrateと完全にカスタムした12のものを利用する。
Reenixのcrateの大部分がWeenixプロジェクトの機構をミラーしている。メモリマネージメントアーキテクチャはmm crateを含んでおり、プロセス関連コードはprocs crateなどがある。しかしながらそれらはいくつかのcrateはWeenixの本当の類似物ではなく、Reenixに存在する。最初は、それらのうち最も基本的な基礎crateだ。このcrateは大きくそしてやや異なった型とトレイトのコレクションを持っている。これはcrate作成を可能にするためさらに下の依存ツリーの他のcrateのいくつかを部分的に知ることだ。このcrateにトレイト宣言があることで全てのcrateでトレイトが実装されている場合の依存で使われることができた。このcrateの別の機能はReenixでerrorsとして使われている基礎データ型の大部分を持っている。また'dbg'マクロを難なく使われることができるようにこのcrateに配置した。他の新しいcrateは(幾分悪い名前ではあるが)startup crateという。このcrateは最もCコード関数実装へ関連づけるためのACPI,PCIそしてGDTへのstubを最も保持しており、ブートされている間最も使われるためそう名づけられた。これらは、実際のハードウェアと複雑なbit-twiddling実行管理を要求する能力とメモリ操作、Rustでできる最善なことのいくつかなどがとても緊密に関連付けられている。また、データ関数のスレッド仕様の実装に含める。これらは主にここに置かれ、Reenixスレッドが作ったRustのスタックオーバーフロー検知実装をとても簡単にするという事実により全て作成した。最後の総合的に新しいcrateはumem crateだ。このcrateは完全に終わっておらず、そして現在いくつかのユーザスペース仮想メモリとページフレームキャッシュ実装を実装するためのメカニズムの必要性を持っている。Weenixではこれらはいくつかのmm階層になっているが、Rustでそれをするには、保持するcrateとRustの標準ライブラリの利用だけではより難しい。

2.2 ブート

一つ目の問題として、Reenixを手に入れるためにシステムブートを書いていた。Weenixは(私が開始した時点では)カスタム16bitアセンブリコードのブートローダを利用していた。
このブートローダは残念なことに4MBより大きなカーネルイメージを読み込むことができなかった。これは判明してすぐに問題になった。簡潔な出力を作成することをするのにrustcはgccよりも熟達していない。事実、この問題に直面しはじめたころからrustコードでの作業をやめる前は、かろうじて"Hello World"を作ることができた。
この問題の修正は多くの早期のすべてx86アセンブリなブートコードの修正を要求した。また、Linuxブートローダで有名なGRUBを使っていたブートディスク作成のためのビルドシステムの一部書き直しと、マルチブートをサポートするためのブートシーケンス仕様の変更を要求した。それ自体がひどく難しいというわけではないが、これはいくつかのシンプルなハックでCだと可能でRustだとできず、このプロジェクトを公開する試みのソートするために、絶対に必要なことだった。CはWeenixのような4MB以下の適度に複雑なカーネルを維持するのに完全に実現可能で、CS169の歴史でこの制限で実行するものはほぼ誰もいなかったのは事実だ。しかしながらRustにおいてこの制限はほぼすぐに知らしめられた。これはもっとRustコンパイラ最適化(またはその欠如)が言語自身で行っていることよりも多くのことを行っているようで、何人かのソフトウェア開発者の間で最適化の問題があることは事実だ。より確立された言語と比較した場合のRustの相対的な不足を考察する必要がある。

2.3 メモリと初期化

他の初期の問題として、メモリアロケート作業に直面していた。たくさんの理由により、気持ちとしてはむしろこのプロジェクトの範囲外でいたので、Rustでメモリアロケータ実装をせずに、代わりにWeenixに存在するアロケータを使うことにした。これはSlabアロケータというWeenixのアロケータから小規模な問題を引き継いでおり、連続したFixedサイズなデータ構造体のSlabメモリだ。これらのアロケータの型は実際非常に使いやすく、カーネルタスクと使用されたFreeBSDLinuxのような現実のカーネルに使われた。それらはまた、一般的にオブジェクトキャッシュスキームを含んでいるが、Weenixはそのようなスキームを使用していなかった。それらは一般的に多くのOSにて素早くアロケートされるために、サイズが知られている少数の構造体からなる。
問題となっているのはRustがmallocスタイルなメモリアロケータのアイディアでビルドされていることだ。この手のアロケータはどんなサイズでもバッファをアロケートできなければならないmallocからなるslabアロケータを使用して実装するのは逆に大変だ。一つの要求として、いくつかのシステムで、malloc関数はたくさんの違うサイズのslabアロケータから適切なアロケータを探し出す必要がある。slabアロケータを簡単に使用するのを認めるためカスタムアロケータサポートをRustに追加に関していくつかの議論があるが、これはRust1.0では盛り込まれず、延期された。さらに、言語とコンパイラはまた、いくつものセンスによるアロケーションのアイディアから成り立っているのは間違いない。OSカーネルにおいて、これは平常を保つことができない。これはRustコンパイラがこれらの課程を組み込むことは残念なことにかなり深い問題だ。この問題の詳細を小節3.3にて解説する。これをサポートするためにWeenixアロケータがそうであるように、かなり複雑なshim周りをサポートするRustアロケータモデルを最終的に書く必要があった。結局全ての基地のアロケータは常にメモリが確保された状態かつ、有効なメモリを選択するコードを書くことになった。しかしこれはアロケータがカスタムタイプ向けに完全にサイズ計算されたものありつづけるためと、利用するのに良いスペースを確保するという二つの利便性の点で問題を作った。しかしこれを行うには一般的に使われる全てのアロケータのリストを取得する必要があった。これは幾分奇妙なブートのためのマルチステージ初期化案を作ることを要求した。アロケートがまだセットアップされていない場合の初期化を最初のステージとした。このフェーズの間、各部はアロケータに予約するかほかのセットアップに関連付けられたアロケートを要求しないタスクが含むCのプロジェクトの初期化パーツが行うのを要求する。これはWeenixからのtapdance初期化の他の二つのフェーズに追加される。一度これは我々が全ての他のリアルスレッドコンテキストに実行可能な初期化をされ、アイドルプロセスに入った時に最後の初期化を行う。

2.4 プロセス

この小節ではどのようにプロセスが作られ、管理と停止をカバーするプロセスシステムのもっとも基礎的な関数2つについて解説する。次に、内部スレッドの同期が実行と維持されることについて解説する。最後に、Reenixスケジューラの調査とどのように作成と振舞いを行うかについて説明する。最初のReenixの主要な部分である包括的なプロセスコントロール、スケジューリングと同期はまた、CS169 WeenixプロジェクトのPROCSで実装される。Weenix基礎設計に従って、プロセス構造を選択した。実行中のスレッドの各プロセスのヒエラルキーがある場合に、プロセスとスレッドは分割される。スレッドとプロセスは分けてかたどられ、プロセスは少なくとも最低一つのスレッドを持つ。標準的なUnixでは、プロセス内に親子関係の追跡とプロセス初期化時にはぐれたプロセスを退避させる。プロセスは子プロセス情報とメモリマップを保持する。

pub struct KProc {
  /// プロセスID
  pid : ProcId ,
  /// プロセス名
  command : String ,
  /// スレッド
  threads : HashMap  > ,
  /// 子プロセス
  children : HashMap < ProcId ,
    Rc < ProcRefCell < KProc > > > ,
  /// 有効かどうかのステータス
  status : ProcStatus ,
  /// 実行中/スリープ中/他
  state : ProcState ,
  /// 親
  parent : Option < Weak < ProcRefCell < KProc > > > ,
  /// ページディレクトリ
  pagedir : PageDir ,

  /// wait - waitpidのキュー
  wait : WQueue ,
 }

図3: Reenixでのプロセス構造体はWeenixのそれととてもよく似ているが、子プロセスやスレッドを保持する目的でWeenixの拘束リストの代わりにハッシュマップを利用している。

図3で見て取れるように、プロセス構造体の定義に少し注釈をいれた。もしそれを実装から離れたところにしたのなら、プロセスはまた、開かれたファイルとすべてのスレッドで共有されるカレントディレクトリ情報を保持している。スレッドはプロセスが現在何をしているかの情報を保持し、スケジューラの作業とともに、スタックの保有とブロックを行う。

2.4.1 コントロールと作成

Reenixは非常にシンプルなプロセスコントロールモデルを保有する。KProc::new関数によってプロセスをいつでも作成することができ、その第一スレッドによって呼び出される。この関数は新しいプロセスのユニークID番号か、なにか良くないことが起こった場合、エラー識別値を返す。一度この関数が呼ばれると、作成されたプロセスはスレッドが終了するか明示的に止められるまで実行し続ける。現在Reenixではマルチスレッドプロセスをサポートされていない。スレッドはプロセスの作成によって作られるためだ。この制限は現在における利便性のためにある。プロセスのデザインとスレッドは切り替えられるよう許可することは、マルチスレッドプロセスよりも簡単だ。Reenixにkill(2)に類似した機能はなく、スレッドを強制的に終了することはできず、停止している最中か停止に抵抗している場合に、どのスレッドが起動してもスレッドかプロセスをキャンセルできるようにして、スレッドは自らの意思で停止することができる。最後にwaitpid(2)のような関数をを通してどのプロセスも子プロセスが終了するのを待つことができる。プロセスはどの子プロセスでも、もしくは指定した一つのスレッドが終了するのを待つことができる。プロセスはスリープ中以外の既に終了したプロセスを終了するのを待つ。
このプロセス構造の所有権のシンプルな問題の実装における全てにおいて有名な試みである。もっとも明確な答えはプロセスはその子供の全てのプロセス構造を所有するべきであるということだ。これは我々が作成ししているwaitpidで作成しているプロセスツリーに反映され、よりシンプルな実装となっている。これを行う場合、事実にもとづいてプロセスは親のトラックを保持する必要性と、waitpidでスリープ中の親を通知する許可を対処する必要がある。さらに、現在のスレッド変数が常に通じていることが必要としないことは利便性において重要であり、したがって任意のプロセスIDをキャンセルか問い合わせ可能なプロセス構造の中に戻すことのいくつかの方法を必要とする。これらが我々が保持するプロセス構造体が最も参照カウントポインタである Rc<KProc> を全ての参照を所持しない Weak<KProc> という弱参照を通して使用することを全て許可する。これは我々にまだまだ提供されていてかつ完全な参照の取得のチェックなしで Weak<T> によるアクセスではないRustによる安全なアクセスをさせている間親としてのプロセス構造の所有を離させる。
Rustを使用することの利点はこのスコープベースのデストラクタが幾分コードを単純にすることを許可された点にある。これらのデストラクタがいつでもスコープ外のオブジェクトコードをきれいにし、エラーを単純化してきれいにすることを認めた。例えば、一般的に新しいプロセスが何らかの理由で失敗した場合、全ての新しいプロセス構造を含めた一時的な変数が破棄され、エラーコードが変えされることは知ることができる。これは繰り返しアクションを複数の場所でクリーンアップする必要がなく、'goto error'ベースのクリーンアップシステムが必要ないことを意味する。

2.4.2 同期

Reenixは厳格なシングルコアOSなのでよりシンプルな同期に関することの計画をできた。Reenixにおける全ての同期は待機キューを基にしている。待機キュー状態変数と同様にシンプルな動機プリミティブである。どれでも待機キューでの待機を選ぶことができ、それらはほかのスレッドでキューへの通知があるまでスリープする。これらの関数はすでに使用されている場合に割り込みをマスキングの面倒を見、スレッドがスリープしてる間か起動したときに割り込みが発生したとき抵抗する。待機キューはKQueueと呼ばれる構造体で実装される。実装においてエントリーキューで一度だけ起動だけできるので、スレッド軌道のために呼ばれているようなスリープするのを選択することができる。これを使用するのは、全てのミューテックスや状態変数のように同期コンストラクタ要求することはかなり容易なことだ。これらを作るために構造体をさらにジェネリックな構造体はカプセル化されたこの振る舞いのトレイトのペア作らせた。
上記のプロセスコントロールと同様に、同期の形を所有権のトリッキーな質問につなげるようにこれを実装していく。これは最終的には待機キューがシンプルな現在停止中スレッドリストであるためだ。待機キューは明確に、感覚的にもスレッドの所有者であるべきではなく、単純に一時参照を保持する。残念ながらRustのライフタイムシステムは我々の全てのスレッドにまたがって一貫性のある参照を与えたやり方ではきれいはライフタイムではない。これはRustのライフタイムが常に現在実行しているコールスタックに関連付けられているためだ。Rustは前提として、どの与えられたスレッドすべてが(a)永続的にありつづけるまたは(b)なにかに(特に現在のコールスタックで呼ぶ関数で)作られてかつフレームを抜けるときに破棄されるかのどちらかである。これは感覚的に、非常にトリッキーに完全に分けられたスタック上で生きる参照とともに働く。各スレッドにの総合的なライフタイム分割でRustで参照を安全に使用できることを証明する方法は他になく、したがってこの方法でキューを実装するのは認められない。解決可能な方法として前節でプロセスに行ったように弱参照を使うものがあり、もしこのプロジェクトでもした場合、おそらくそのようにしただろう。代わりに、Rustの能力の素晴らしい機能のRustの安全チェックを明示的に無効かするという悪用を選んだ。したがってスレッドをシンプルなポインタとしてキューにして持つようにし、そのポインタがなくなった場合にスレッドを元に戻すようにした。この解決方法は上記で開設した素朴な方法と同等に機能し、安全である。それはまた、弱参照による解決方法の極端に重い性質を便利に回避し、参照カウントポインタを守る必要性を回避するので、安心して作業することができる。

2.4.3 スケジュール

Reenix OSは単純なFirst-in-First-outスケジューラを用いる。それは単純な実行スレッドリストを保持する。各スレッドは特別なそのポインタ命令と他のレジスタに関する値を保持する構造体に状態を保持する。これは待機キューが所有権を戻すときに多くの点で自然に実行され、いくつかの方法でそれらを解決する。他の幾分奇妙な点として、私は多くの実行してすぐにスレッドがない場合にスケジューラ実行ループから離すコンパイラ最適化をしていた。それは他になにか変更できる方法がないリストのミュータブル参照のみを持っていることは確信できる。運が悪いことに、このリストはしばしば割り込みコンテキストによって変更され、随時確認されなければならない。そのようなエラーはCで発生する(そして最適化されて戻される)がRustで修正するのははるかに難しい。Cでは、使われている揮発性の変数は単純にマークされる必要があり、コンパイラはそのアクセスを最適化するのをやめる。同様のことをRustで行うには、変数の読み込みをマーカー関数を通して確実に行う必要がある。

2.5 ドライバ

次にReenixの有名な部分の基礎デバイスドライバの追加について記す。Weenixプロジェクトに従って、(エミュレートされた)ATAハードディスクドライバと基本的なTTY21ドライバスタックを付けることを選んだ。これは(少しばかりの)OS間相互作用をもつことを認め、テストと分析を簡単にし、そして将来的に持続性のがあるストレージを持つことを認める。さらに、これら二つのドライバはよりシンプルで理解しやすくてそしてドキュメント化しやすく、それらをゼロから作るプロセスを簡単にする。他のカーネルパーツでより簡単にドライバを利用するために、カプセル化する基本関数のために基本的なトレイトを作った。それらは図4でみることができる。それらはカーネル内ドライバのインプットとアウトプットをほど良く抽象化する。したがって、それらは何度も違う方引数による様々なデバイストレイトを再実装することを可能にするRustの良い機能を説明する。これは/dev/zeroのようなメモリドライバのような事例で非常に使いやすく、文字列から全メモリブロックを読み取るまたは文字列自体を読み取るのうちのどちらかをするようにするのが望ましい。
この節ではReenixで作った各ドライバの実装を掘り下げて説明する。また、私が製作中に直面した問題について確認できる。

2.5.1 TTYスタック

ReenixのTTYサブシステムは5つの分割されたパーツに分かれている。もっとも低レベルなのはキーボードドライバで、外界からの入力受信か外界に文字出力をさせるのを認める。ディスプレイドライバはシンプルなVGAモードで3テキストビデオインターフェースになっており、あらゆる全てを描画することを求められる実際のビデオドライバ作成のトラブルを回避する。キーボードドライバは標準PS/2キーボードで実装されており、標準キーの全てとメタキーのいくつかをサポートする。これら両方は完全にRustで実装されるが、WeenixではよりシンプルなCバージョンのパーツになる。
スクリーンドライバの利用において、シンプルな仮想ターミナルドライバを作った。仮想ターミナルは一般的なUNIX仮想スクリーンとキーボードを表す抽象的なものだ。これらの仮想ターミナルのスクロールと書くために次の位置を追跡するカーソルを実装する。これは他のCのより直接的なパーツをRustにしたものだ。最後に、上の仮想ターミナルとキーボードドライバについて、TTYドライバとライン制約を作った。これらは実際にTTYで実行されているドライバを使った。TTYドライバはキーボード割り込みを受信し、ライン制約過程を通して記録した後、スクリーンに出力する。TTYが表示可能な文字列に変換するためにライン制約を使用して書かれている時とそして仮想ターミナルに通す。TTYは全行を読まれている時に返すか、全行打たれるまで現在のスレッドはスリープ状態になる。
このパートのReenix設計に関することを与えられることを求めた試みはなかった。私ができなかった異なる仮想ターミナル間の切り替え方法を明確に見つけるという試みは難しかった。私はポインタ更新のためにどのTTYが入力送信をしたかわかるサブシステム割り込みを付けるのをやめた。しかしこれはまたCバージョンでも問題で、これは私が思いつく限りでこの問題を解決するための最善の方法だったことは確かで、以来Rustの評価が非常に下がった。実際のところ割り込みハンドラの切り替えは他の方法で解決できるが、特に異なる関数がほぼ同一であるという理由でやるのは信じられないほど大変だ。その上、割り込みを持っている間の割り込みハンドラの変更は苦痛で、そして、知っている限りで、これまでのところシステムは割り込みハンドラ変更操作をしないのだ。

/// ‘T‘単位で読み込み可能なデバイス。
pub trait RDevice  {
  /// buf.len ()のオフセットから始まるデバイスオブジェクトを読む。ストリームから読み込んだ
  /// オブジェクトの数か、失敗した場合はエラー番号を返す。
  fn read_from (& self , offset : usize , buf : & mut [ T ]) -> KResult ;
}

/// ‘T ‘単位で書き込み可能なデバイス。
pub trait WDevice  {
  /// デバイスのバッファを返し、デバイスの開始点を与えられたオフセットから開始する。
  /// 書き込んだバイト数を返すか、エラーが発生したらエラー番号を返す。
  fn write_to (& self , offset : usize , buf : &[ T ]) -> KResult ;
}

/// 読み込みと書き込みが可能なデバイス。
pub trait Device  : WDevice  + RDevice  + ’ static {}

/// バイト単位で読み込みと書き込み可能なデバイス。
pub type ByteDevice = Device ;

/// ブロック単位で読み込みと書き込み可能なデバイス。
pub trait BlockDevice : Device <[ u8; page :: SIZE ] > + MMObj {}

図4: 読み込みと確定サイズデータ書き込み可能なハードウェアデバイスカプセル化するデバイストレイト

2.5.2 ATAディスク

ReenixのATAディスクドライバはとても単純なデバイスドライバだ。ブロック単位ディスク読み取りと書き込みについて有用で、複数の連続した読み取りと書き込みを一度に行う。これを行うにはダイレクトメモリアクセス(DMA)を使用する。DMA使用中にドライバは指定したメモリのメモリバスがディスクに指示を送信するために使用する場所に書き込む。そしてディスクは指定したオペレーションを実行し、結果を(ユーザが指定した)メモリロケーションに結果を読み込む。さらにCPUに割り込ませる。これの全体プロセスはかなり正確でCS169のCバージョンと私のRustバージョンではわずかな違いしかない。主な違いは、RustはDMAデバイスコントロール用の秘伝マクロのいくつかを取り去れていることだ。

その他の思いつくものとしては、Cコードではそれを複製するために行っていたが、Rustでは相対的なアライメントの欠如が主な問題となった。DMAを使用するために、一つPhysical Region Descriptor Table(PRD)と呼ばれるデータ構造体を与える必要性がある。このテーブルはDMAが使われる際にPCIバスに伝えられる。なぜならこのテーブルはかなり密かにハードウェアにリンクされており、32バイト境界アライメントでなくてはならないという非常に厳しいアライメント要求がされる。Cでは大きな問題ではなく、単純なアライメント属性のテーブルを静的アロケートできるか、実行時にバッファをアライメントごとに区切られたアロケータかアライメント取得を保障できる十分なスペースで動的アロケートする。Rustではいくつかの理由によりはるかに挑戦的な問題となった。第一に強制的にデータアライメントする方法がRustにはなく、現在の勧告ではそれをエミュレートするために、SIMDタイプのゼロ配列長の配列を配置する。第二にReenixのアロケータは有効なアライメントを保障することができないため、とにかく良いアライメントを取得すことができなかったことが問題となった。これらを静的にアロケートできたが、Rustでこの問題を回避しようと試みたので、簡単にできなかった。さらに、CPUによりSIMDタイプによるラックサポートのためにコンパイルしているが、いい結果になったかどうか自身がない。最後にいつでもPRDテーブルを使えるようオーバーアロケートとにマニュアルデータアライメントを選択したが、むしろ面倒でエラーのもとになり、複雑になった。

2.6 KShell

最初に開始したもののうちのTTYドライバを実装し終えると、私はKShellと呼ぶ単純なコマンドシェルを実装を開始した。これは以前は不可能だったOS相互作用を認めるもので、キーボードを使って動的にタスクを選択できる。KShellのおかげで、テストとOSの試験をより簡単にすることができた。それがどれほど有用か過大にテストするのは難しかったが、俄然再コンパイルなしでコマンドシーケンスの差分を検証することを簡単にすることができた。さらに、KShellなしでTTYドライバのテストを行うにはより困難になった。最後に、ユーザスペースの形づくるか実装していなかったことから、カーネルスペースシェルは対話式コンソールシステムでできたただ一つの方法だった。多くのコマンドシェルのように、KShellは簡単なread-evaluate-printループを基にしている。KShellが使用可能であるコマンドの番号があり、平行に自身のスレッドで別のコマンドを実行するためのコマンドを含む。各コマンドはコマンド実行と成功したかどうかの値を返すRust関数によって書かれている。
最も興味深いことの一つは、KShellがRustで書かれたジェネリックREPLシェルとどのように似ているかということだ。様々な点において、通常のRustと同等の方法で実装されているのは確かだ。型が同じ、ループが同じ、などなど。これはReenixの変更なしでどれだけ多くのRust標準ライブラリが使えるか証明したということであり、興味深い。より高レベルな言語では、カーネルモードで実行中の実際のランタイムのラックは一度書いた場所からのコードのソートにひどく制約を受ける。さらに、KShellを書いている間、それがCで同じことをやるよりもRustでしたほうがどれだけ簡単に書けて私を驚かせた。Rustで書くことによって、私は簡単に使えるリストと型マップ及びRustの高レベルなstringルーチンを使うことができるという機能を活用することができた。これらはCで提供されているコマンドシェルを動作させるのを作りこむ上でもっとやっかいな側面のいくつかを取りされた。

2.7 仮想ファイルシステム

ドライバの実装を終えると、私は次にReenixの仮想ファイルシステムの製作にとりかかった。仮想ファイルシステム(VFS)はUnixライクなOSで最初に作られた有名な抽象化で、1980年代中ごろにSunによって広められた。VFSは様々なディスクとネットワークファイルシステムの差分を吸収するために設計された。VFSは有名な主要なファイルシステム全ての操作インターフェースと、ディレクトリ検索とファイル作成・削除・オープン・読み取り・書き込みなどのルーチン対話形式を定義する。また、一般的にファイルシステム配下によるブロックデバイス読み取りのキャッシュを認めるためにデータブロックキャッシュを定義する。WeenixとすべてのUnixライクOSのどちらも今日においてVFSライクなインターフェースを、ファイルシステムの仕様を容易にするために利用する。不幸なことに、時間的な制約の為ReenixではVFSの実装には手が届かなかった。コンポーネントのインクリメンタルテストを許可するため、私はWeenixの先例に従い、よりシンプルに RamFS とよばれるインメモリファイルシステムを実装することをS5ファイルシステムに完全に実装を移す前にVFSの機能のいくつかをテストするために決定した。しかしながら、ブロックデバイスとS5ファイルシステムのディスクアクセスレイヤーキャッシングの一部を実装できた。そして合理的にRamFSの製作を完了した。
この節は少なくとも完了時に開始することができた各VFSのパーツを調べる。我々はまず VNode トレイトの設計とそれに入った決定について焦点を当てる。次にRamFSテストファイルシステムのどれがもっとも完成することができたか調べる。最後に、ページフレーム、メモリオブジェクト、そしてそれらに付随する唯一細々と開始することができたキャッシングシステムについて調べる。

2.7.1 VNode

仮想ファイルシステムにおける主要なデータ型はVNodeだ。VnodeはVFSにおけるファイルまたはファイルシステムオブジェクトの表現になることができる。VNodeによる主要なファイルシステム操作に関する多くの関数が主に使われる。最も多く定義されていたのがその関数の実装であることにより、Reenixにおいて私はVNodeをトレイトで作った。VNodeは一般的に同時にファイルデータを利用する所有者に複数保有される。Weenixでは最も違う点としてVNodeの参照カウントをマニュアルで行う形で実装されており、その部分はエラーが発生しやすかった。
Reenixの一部であるVNodeトレイトは図5で見ることができる。これで記載しておいたこととしてはVNodeを直接与えずに代わりにハンドル参照カウントを与えるという事実を表現するためにRustの型システムを利用しているということだ。関数の結果の型はいくつかのVNodeの型のように使いやすく作られなければならないと主張している。これは各VNodeを実装するファイルシステムオブジェクトの分割型を持つこととこれらのファイルシステムオブジェクトのなにか標準的なenumを戻すことを許可する。この結果の型がコピー可能(クローントレイト)であることにより、VNode間で共有しているいくつかのオブジェクトを参照しているバッキングファイルを隠し、場面の後ろで参照カウントを認める。単純に参照されなくなった時にVNodeを解放する参照カウンタによってラップされた型を返すことができる。例えばRamFSにおける結果の型は Rc<RVNode> だ。これはWeenixにおけるVFSのトリッキーなパーツの一つであるエラークラス全体を防ぎ、マニュアル操作による参照カウントの必要性を取り去る。

pub trait VNode : fmt :: Debug {
  /// これは型システムが機能するようにここにしかない。HKTなしのb/cを必要とした。
  type Real : VNode ;
  /// Vnodeの型操作で取得/作成されるもの。これは複製でなくてはならない。
  /// 参照カウントを与えるラッパを持つことができるので、
  /// 借用(Borrow)と呼ぶのが望ましい。
  type Res : Borrow < Self :: Real > + Clone ;
  fn get_fs (& self ) -> & FileSystem < Real = Self :: Real , Node = Self :: Res >;
  fn get_mode (& self ) -> Mode ;
  fn get_number (& self ) -> InodeNum ;
  fn stat (& self ) -> KResult < Stat > { Err ( self . get_mode (). stat_err ()) }
  fn len (& self ) -> KResult  { Err ( self . get_mode (). len_err ()) }
  fn read (& self , _off : usize , _buf : & mut [u8 ]) -> KResult  {
  Err ( self . get_mode (). read_err ())
  }
  // ...
  fn create (& self , _name : &str) -> KResult < Self :: Res > { Err ( self . get_mode (). create_err ()) }
  fn lookup (& self , _name : &str) -> KResult < Self :: Res > { Err ( self . get_mode (). lookup_err ()) }
  fn link (& self , _from : & Self :: Res , _to : & str ) -> KResult <() > { Err ( self . get_mode (). link_err ()) }
  // ...
 }

図5: VNodeトレイト

2.7.2 RamFS

RamFSはテスト向けのWeenixとReenixでのVFS実装をテストするために使われたインメモリファイルシステムである。この"ファイルシステム"は実際のディスクなしでVFSに使われることを必要とするすべての呼び出しを実装する。WeenixでこのシステムはS5ファイルシステム製作との並行作業なしでVFSテストの有効なサポート実装の一つとして実装されている。実際その最終的なデザインができる前にRamFSを実装するいくつかを試みた。初期デザインはCからRustへの直接的な置き換えだった。私はVNode表現のような他の多くのWeenixサポートコードを信頼していたので、スタブバージョンを単純に使うことができなかった。これは想像していたものよりもより難しくさせた。
CのRamFSは最も一般的な実装となったが、ファイルNodeの割り当てられたトラック保持のためのシステムの容易性に欠き、ブロックメモリ確保のためのシステムがなかった(全てのファイルとディレクトリは正確に一つのブロックを取得する)。これは例えば、ディレクトリはディレクトリエントリの配列にキャストされているバイト配列で実装されていることを意味する。この方法でが非常に実ファイルシステムに酷似している間、Rustが生で、型付けされなく、Cのようにシンプルなストレージで働かないことが問題となった。Cでは配列の単純なキャストと(初期化なしの)ディレクトリエントリの配列の働きが、Rustで行うよりも面倒だった。この問題はS5FSを伴って(バイナリシリアライズライブラリの使用を通して)イベントハンドルする必要性があって、時間の関係で私はよりシンプルな道を選ぶことを決めた。
RamFSの第二のデザインはできる限りシンプルなファイルシステムのモックを作ることだった。実装において実ファイルシステムと同様に作るというあらゆる概念を棄てた。ファイルシステムオブジェクトの各型は違う型にし、各ディレクトリは単純なファイル名からなるNodeのマップとし、参照カウントはただちに標準ライブラリの RC<T> を使用した。これは遥かに簡単に実装され、大部分を短い時間で手早く終えることができた。残念ながらこれを終えるときにはVFSに加えて何か残りの作業をする時間がほとんどなかった。この間私はVNodeについて同作業するか決める最後のデザインをした。

2.7.3 PFrameとMMObj

私がVFSの作業を開始した時には、当初RamFSファイルシステムを作ることは計画していなかったので、S5FSによって必要とされたサポートコードの多くを作成することで開始した。このサポートコードはRamFSで使うファイルシステムで最も必要とされたブロックキャッシュ実装を必要とする構造体を含んでいる。これらのシステムはしばしばオペレーティングシステムカーネルプロジェクトにより仮想ファイルサブシステムの一部を考察されていたが、WeenixではそれらはS5FSプロジェクトまでの間実装されていなかった。このシステムは(キャッシュ可能な)データページを提供可能なデータソースの抽象化をするMMObj、キャッシュと更新可能なMMObjからなるデータページのPFrameという二つの有名なコンポーネントで作られている。PFrameは概念的に元となるMMObjによって所有され、感覚的にはシンプルなMMObjのCurrent Viewとなる。実際には、PFrameはMMObjから分割してキャッシュされて、このMMObjは単純にPFrameを満たすことに最も責務があり、PFrameが破壊される時にデータを書き戻す。Reenixではこれらの部品は完成していないが、公開するインターフェースは完全に書かれている。
この基本となるMMObjトレイトは図6で見ることができる。一つ注目すべき興味深いことがあり、この設計はPFrameを差し置いてファイルシステムコードによって直接的に呼ばれることがある関数がない。ファイルシステムがMMObjによるメモリページを必要とするときに、この基本的な構造体呼び出しは与えられたMMObj上のページ番号のPFrameを要求するだろう。これはすでに存在するか確かめるために最初にグローバルPFrameキャッシュを検索し、もし存在するのであればそれを返す。そうでないのなら新しいPFrameを作成し、MMObjで使用するデータを入れる。呼び出し元はその後PFrameを利用するが、スコープから抜けるときに解放されるのを防ぐため、それが参照カウントされるのを求められる。PFrameがスコープ外に抜けるときにその参照としてか、そのMMObjからのどちらかがどこからでもアクセス可能か確認する。もしそれができない(またはメモリがほとんど残っていない)場合、変更されているかどうかとMMObjが書き戻すかを確認する。
最も重要なのは構造体呼び出しが望んだ通りに実現できるかという問題がこれを自身で納得して作っている間に直面したということだ。初回呼び出しの多くの場合においては、実装を隠したトレイトにしており、判断するのを難しくさせることが発生される。他の私が抱えている問題としてはこのシステムが独立したテストを行うことが非常に難しいということだ。

pub trait MMObj : fmt :: Debug {
    /// このプロジェクトのMMObjIdを返す。
    fn get_id (& self ) -> MMObjId ;

    /// 入るべきデータを与えられたページフレームに入力する。
    fn fill_page (& self , pf : & mut pframe :: PFrame ) -> KResult <() >;

    /// フック。リクエストが非ダーティページに書き込む時に呼ばれる。
    /// 与えられたページへの書き込みを可能にしなけらばならない
    /// いずれかの必要なアクションを実行する。 これはブロックされる可能性がある。
    fn dirty_page (& self , pf : & pframe :: PFrame ) -> KResult <() >;

    /// pfアドレスで始まるページフレームの内容
    /// を書き戻す。pfで識別されるページにページ数をつける。
    /// これはブロックされる可能性がある。
    fn clean_page (& self , pf : & pframe :: PFrame ) -> KResult <() >;
}

図6: MMObjトレイト

2.8 他の問題

私がReenixの作業する上で、多くの問題とプロジェクトの任意の部品の一部を作ることを決めなければいけないことがあった。これらの試みはRustのスタックオーバーフロー調査の実装、スレッドローカルストレージ実装とシンプルなプロジェクト全ビルドの取得に含まれていた。これらと必要とされている全てを行う上で困難だったのは、RustとCの間の差異が主な理由だ。

2.8.1 スタックオーバーフロー検出

Cのような、ほかの(有名な)コンパイル言語より多くの良い機能を持っているRustはスタックオーバーフロー検出組み込みがサポートされている。これはメモリ保護可能ではないハードウェアの場合にスタックオーバーフロー検出ができることを意味する。RustはLLVM24が提供する、全てのサブルーチンがスタック終了地点を保持するスレッドローカル変数のチェックを保持しており、これを実行するメソッドを使用する。
各サブルーチン呼び出しにおいて、関数は保有するローカル変数スタックするための十分なスペースが存在するかどうか確かめるためにこれを使用する。特にx86システムで %gs セグメント内のオフセット0x30の値を確認する。この機能は(暗黙的に)スレッドローカル変数を利用するため、ランタイムサポートの指定とセットアップ作業を要求する。

c0048f10 < kproc :: KProc :: waitpid >:
# 使用済みスタックとスタックの終わりを比較する
c0048f10 : lea -0 x10c (% esp ) ,% ecx
c0048f17 : cmp %gs :0 x30 ,% ecx
# 十分なスペースがある場合持続する
c0048f1e : ja c0048f30
< kproc :: KProc :: waitpid +0 x20 >
# スタックのメタデータを保存する
c0048f20 : push $0xc
c0048f25 : push $0x10c
# __morestackは現在のプロセスを中断する。
# 名前はRustがセグメントスタックをサポートした時からずっと
# こうなっている。
c0048f2a : call c000975c < __morestack >
c0048f2f : ret
# 標準的なx86関数の前処理
c0048f30 : push %ebp
c0048f31 : mov %esp ,%ebp
c0048f33 : push %ebx
c0048f34 : push %edi
c0048f35 : push %esi
c0048f36 : sub $0xfc ,% esp

図7: KProc::waitpid から分解された関数前処理

Rustの標準x86関数前処理のコピーは図7で見れる。3行目で、関数は最初に使うスタックから最も遠い地点を計算するのを見ることができる。その後、関数は %gs セグメントからスタックの終わりまでの読み込みと、それと要求されたスタックスペースの比較を4行目で行う。最後に、6行目で実際の関数か、__morestack関数を呼び出してスレッドを中断処理のどちらかにジャンプする。
不運にもWeenixはこのスタックチェック形式をサポートしていなかったので、そのスタックチェック形式を望んで使う場合は自身の手で実装する必要があった。最初は、Weenixはこの機能なしで何一つ問題なく動いていたので、無効にすることができたと考えていた。だがさらに不運なことに、私がこのプロジェクトを開始した時には、全てにおいて関数からこのスタックチェックコードを取り除く方法がなかったのだ。これは自分で書いたコードを実行するために、LLVMを使ってスタックチェックメソッドのサポートをしなければならないことを意味していた。これを行うことは実際には恐れていたものよりもやや難しくなかった。Weenixによって提供された、グローバル記述子テーブル(GDT・Global Descriptor Table)操作ルーチンの使用で、保存するべき適切なオフセットのスタックエンドポイントのデータ構造を書くことができた。このデータの構造体は図8で見れる。

#[ cfg ( target_arch ="x86")]
#[ repr (C , packed )]
pub struct TSDInfo {
  vlow : [u8; 0 x30 ] ,
  stack_high : u32 , // オフセット0x30である
  /// 他のスレッド特有のデータ。
  data : VecMap < Box < Any > > ,
}

図8: TLSデータ構造体

これらのことから、私にはまだ少量のやらなければならないことがあった。私が次に必要としたのは、 実際幾分トリッキーな%gs GDTセグメントの準備を成功するまでの間、スタックチェック有効な呼び出しがないことを確認することだった。次に、プロセス切り替え中に我々が%gs 記述子の値の正しい変更を確実にすることと、先述と同様になるが、プロセス変更に成功するまでの間、スタックチェック有効な呼び出しなしにすることを必要とした。一度、これはドキュメント欠如の関係で最も作るのが難しいエラー報告関数に接続するために残された全てを達成された。皮肉なことに、これの全ての作業スタッフを揃えてから一週間もたたないうちに、Rustの開発者は完全にスタックチェックを無効にさせるスイッチを追加した。それはまた、コード中でいくつかの無限再帰呼び出しを見つけるという形で正当に残りのプロジェクトの作業の助けになった。

2.8.2 スレッドローカルストレージ

一度Rustでのスタックオーバーフロー検知作業を終えると、スレッドローカルシステムのほとんどを実現した。ほんの少しだけやることが残っていたので、私はその残りを実装することに決めた。標準的なWeenixでのシステムに匹敵はしないが。これを行うのに私は単純にスタックデータを含む構造体の最後にある VecMap を追加した。Weenixでスタティック変数に含まれたいくつかの現在のプロセスとスレッドを特定する情報を含んだこのスレッドローカルストレージを使用することを選択した。この方法でこのデータを保存することにより、それはまたマルチプロセッサマシン上で使用可能なReenix作成の障害を取り除くが、これを行うには沢山の問題がある。このスレッドローカルデータストレージ構造体は図8で見ることができる。それがスレッドローカルデータを VecMap<Box<Any>> 内に保持していることに注目してほしい。この Box<Any> 型は動的キャストを確認するための制限付き実行時型情報を使用する特殊な型だ。これは先述したデータのユーザがまだそれらが受けるデータが正しい型か確認に確認を重ねている間、正確にはなにか知る必要なく、このマップで任意のスレッドローカルデータを保存させる。例として、現在実行中のプロセスがこの構造体で使用ことを保存される。これが発生させることができるにもかかわらず、実際にはTLD構造体はプロセス構造体という概念を持たない。現在のプロセスが回収される時に、返された値、具体的にはプロセスの、どんなトレイトでも使用してユーザはマニュアルで確認しなければならない。

2.8.3 ビルドシステム

最後の一つは、Reenixを書いている間は全てビルドされるようになるという予期していなかった問題についてだ。標準的な方法は、Cargoのようなカスタムビルドツールを使用することでビルドする。このツールは各インクルード面で、外部ライブラリリンクや他のビルドツール呼び出しといった、素晴らしいサポートにより、多くの一般的なRustプロジェクトには完全に十分であった。不幸なことに、このツールは複雑なリンクやビルド操作を実行するが必要な場合に使用するのには非常に困難であるのだ。それにより、Rustでも動作できるmakefileの作成方法を探さなければならなかった。これはrustcが標準ライブラリと、他の自分でビルドしたライブラリのバージョンを使用するだけであること確認する必要があったので、幾分困難であることを明らかにした。加えてcratesにおける依存はCの依存に比べてはるかに複雑で、他のcratesで作成されたライブラリに依存する。さらにReenixで使用しているWeenixの命名規則とRustの標準的なライブラリの名前規則の二つはやや矛盾している。名前比較のためのrustc診断関数を使用することにより、私が作らなければならなかったより複雑で大きなルールを動的に作るmakefileマクロセット全てを終わらせた。

2.9 将来的な作業

Reenixが完成したと呼べる地点に至るまでには、いまだ多くのやるべき作業がある。最も明確に手を付けなければならないのは残された3つのVFS、S5FSそしてVMというWeenixの部品だ。これを行うにはいくつかのReenix設計の疑問点の解決をする必要がある。いくつかの主要な公開している疑問点としてはどのようにReenixはファイルオープンのトラックを維持するべきか、どのようにS5ファイルシステムと直列化されたバイナリデータ構造体を関係づけるか、そしてどのようにメモリマッピングのトラックを維持するべきか、などがある。さらには、Rustで書き直すべきWeenixがサポートするコードの大きな部品がまだある。ファイルシステムテストスイート、実際のシステムコールハンドラルーチン、ELFファイルローダとタイムスライスシステムなどが主要な部品である。これらOSカーネルの部品はそれぞれみんな重要ではあるが、最終的にはよりシンプルになり、カーネル全体の特殊な構造となって、これらの部品のWeenixバージョンを簡単に使用するのを防ぐ。
最後に、更なるソリューションの永続化のためには、Rustのメモリアロケーション失敗の問題を敬意をもって発見されるべきである。この問題は小節3.3で深く掘り下げて議論される。これなくしてシステムが本当にステーラブルになって使えるようになるのはうまくいったとしても困難だし最悪の場合不可能だ。これらは達成されているそれが追加可能なネットワーク、SMPまたは64bitサポートのようなさらに興味深い方向のReenixの実験を継続するために可能にするべきだ。

<<前 次>>