自作OSでのプロセス実装について (2) ~初めてのユーザプロセス~

この投稿は Aizu Advent Calendar 2014 の 7 日目の記事です。

前の人 @masaponto
次の人 @MiZuKi_Sonoko

また、自作OS Advent Calendar 2014 の 7日目の記事でもあります。


自作OSでのプロセス実装について (1) ~初めてのユーザプロセス~の続きです。

前回の内容
  • プロセスとは?
  • x86リングプロテクション(ユーザモードとカーネルモード)
  • マルチタスク
  • 割り込み
  • コンテキストスイッチ
を一気に説明しました。
正直、多少知識が無いとわからないよなあって感じになっているのでその点は反省しております。

今回は

  • コンテキストスイッチの実装
  • ユーザプロセス起動

を見て行きたいと思います。

前回はタイマ割り込みによってコンテキストスイッチが起きる、というところで終わったと思います。
そこの流れが大事ですので再掲します。

タイマ割り込み発生
実行中プロセスは割り込まれる
(中断される)
カーネルモード突入
今のカーネルモードスタックへ
今の実行コンテキストを保存
(CPUのレジスタとか)
次のプロセスを選ぶ
次のカーネルモードスタックから
次の実行コンテキスト復元
ユーザモードに復帰
はい、こんな感じです。
(※カーネルスタックではわかりづらいのでカーネルモードスタックに変更します。)

では、早速ですがもぷりんOSのコンテキストスイッチ実装を見て行きましょう。
この部分は主にLinuxの実装を真似ています。
と言っても、x86上で同じことをやろうとしたら大体こうなると思います。。。

リポジトリはこちら
mopp/Axel

上記の流れに沿ってコードを掲載していきます。
まずは、一番はじめに呼ばれる割り込みコードを見てみましょう。
以下のこれは、割り込みエントリ関数で、タイマー割り込み以外のキーボード割り込みや一般保護例外などでも呼ばれる部分です。

上記コードはsrc/interrupt_asm.asmにあります。
ただし、Gistに貼った方は見やすさのためにマクロなど全て展開済みです。

レジスタ操作があるのでC言語では書けません。ですのでアセンブラを使用しています。
asm_interrupt_timerという関数がカーネル初期化時にCPUのタイマ割り込みハンドラに登録されます。
ですので、タイマ割り込みが発生したらCPUが勝手にasm_interrupt_timerを呼び出してくれます。

ここで大切なのは、割り込みが発生した際、CPUが自動的にカーネルモードに切り替えるということです。
これは前回も書いたかもしれないですが、きちんと書いていないので、ちゃんと説明します。
詳細は、仕様書: IA-32 インテル ® アーキテクチャ ソフトウェア・デベロッパーズ・マニュアル 下巻:システム・プログラミング・ガイドの5.12.1を参照してください。
同じことを書いています。

割り込まれる状況というのは、カーネルモード時割り込みとユーザモード時割り込みが有ります。
前回、スタックも切り替わると書きましたが、これはユーザモード時割り込みの時だけです。
そもそもとして、カーネルモードだったら、カーネルモードスタックになっているに決まってるんだから切り替える必要はないですよね。

以下の図を見てください。
ユーザモード時割り込みのスタック切り替え
以前動作していたコンテキストの一部がカーネルモードスタックに保存されます。
ここで特に大事なのは、eip, cs, esp, ssですね。割り込みから戻るときにこれらを使って戻ります。csとssを説明する気力がないのでWikipediaに投げます…
ここは、混乱しがちですので注意してください。
割り込まれた時のスタックでは無く、カーネルモードになってからのスタック(カーネルモードスタック)です。
カーネルモード時割り込みの場合は、以前のespとssは変わらないから不要ですので、error codeからeflagsまでがカーネルモードスタックに保存されます。

さて、asm_interrupt_timerに戻りましょう。
まず、タイマ割り込みにはエラーコードが無いので擬似的に設定します。
これは後々出てくる構造体のためです。
次に、割り込み前の状態を維持し、正しく復帰するために汎用レジスタなどを保存します。
そして、タイマ割り込みハンドラ本体を呼び出します。
この時に、pushしているespを、"後々出てくる構造体のポインタ"として、関数に渡しています。

本体の処理が終わったら、レジスタ状態の復元と擬似エラーコードの削除をします。
一番最後のiret命令これが、割り込み復帰処理です。
正しく元々実行していたところへ帰ってくれます。
その際、以前のespやeipが必須になるわけです。

次にinterrupt_timer関数ですが、これはsrc/time.cにあります。
でも、実際、下記の関数を呼び出しているだけなので省略。

上記コードはsrc/proc.cにあります。

このコードでは、プロセスの切り替えを行っています。
スレッド実装したかったので、プロセス構造体無いのスレッド構造体に色々入っています。

currentが現在実行中のプロセスで、nextに実行可能なプロセスを持ってきます。

ここでキモなのは、インラインアセンブラの部分です。
30, 31行目で現在プロセスが復帰した時のeipとespを保存しています。
なので、次に実行されるときは、next_turnから実行されることになります。

次に、切り替え先プロセスの情報を復元します。ここが一番大事です。
34行目でespを切り替えています。
これはまあ普通ですね。

次はeipを切り替えます。
ですが、eipに対してmov命令を実行することは出来ません。
なので、スタックに入れて、やります。
そして、call命令ではなくjmp命令で関数に移動します。
すると、関数の終了時にはret命令が呼ばれるはずです。
この、ret命令はスタックから戻り先アドレスをeipに復元します。
これを利用しています。
なので、change_contextから戻った時点で、既にプロセスは切り替わったことになります。

なお、ここでchange_contextに渡す引数はレジスタ渡し、fastcall規約で渡しています。
(Linuxのを真似してみたんですが、現状fastcallである必要はあまりない…)

今の流れを延々と繰り返して、マルチタスクが実現されているわけですね。


やっとコンテキストスイッチの説明が終わりました。(長すぎた)
初めてのユーザプロセス起動を見て行きましょう。
上記コードはsrc/proc.cにあります。

この関数はプロセス初期化中に呼ばれます。
まず、プロセスを一つ確保します。
そして、textセグメントとstackセグメントを確保します。(dataセグメント忘れてた…)

この次の15行目、これがポイントです!
thread.ipにinterrupt_returnを設定しています。
先のswitch_contextではthread.ipを次の復帰先として、使っていました。
つまり、このプロセスは次にswitch_contextによって切り替えられた時に、interrupt_returnから実行が始まるのです。
そして、interrupt_returnでは汎用レジスタの復元、そして、iret命令を実行しています。
iret命令では上図(ユーザモード時割り込みのスタック切り替え)のようなスタックを読み取ってユーザプロセスを再開しています。

つまり、あたかも割り込みから戻ったようにカーネルモードスタックを調節してやればいいのです!

18〜32行目でそれを行っています。
ここで使っているInterrupt_frameが後に出てくる構造体です。
これを使って、割り込み時のスタックをいい感じに参照、変更することが出来ます。
eip, prev_espが上図の前の〜〜に対応するものです。
これをプログラムの実行開始アドレスにしてやればいいわけです。
もぷりんOSの現在のinitプログラムでは実行開始アドレスはDEFAULT_TEXT_ADDRです。
この辺は実行プログラムによります。

そして、48行目で、switch_context内のnext_procで拾えるように、実行可能状態にします。

あとは、普通にスケジューラが動き出すのを待つだけです。
初期ユーザプロセスの起動はこれで完了になります。


時間があれば、forkとかexecの話ももっとちゃんと書きたいなと思っています。
それでは。

コメント

このブログの人気の投稿

カーソルキーさん@つかわない インサートモード編

Android で MIME Type 判別

Erlang & Elixir Fest 2019 に参加してきた