(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.
とりあえず,独自OSといわれても,どんなものか実感がわかないよね...ホントは今回やることをきちんと説明してからソースコードの紹介,と順序を踏みたいのだけれど,まずはイメージがわかないと説明聞いてもよくわからんと思うので,まずはスレッド切替えだけの単純なモノ(メッセージ通信すら無い)を書いてみて,動作を確認してみよう.
以下がソースコードです.で,動作させるためにはOSだけではなくてサンプルプログラムが必要なのだけど,とりあえず以下の main1.c を使ってみる.まずはディレクトリを作成してこれらのファイルを置いて,main1.c を main.c にリネームして,make してみよう.ちなみにぼくの動作環境は FreeBSD-6.x です.実は内部で setjmp() 用のバッファを直接操作しているので,Linux だとちょっとどうなるか不明です.
これで実行形式 koz ができるので,実行してみます.
どうでしょう? 同じ結果になったでしょうか?
現段階では,KOZOSは簡単なスレッド切替えしか行ってくれません.しかしスレッド切替え(コンテキストスイッチ)を行うということは,そのスレッドのコンテキストをスレッド単位で管理しなければなりません.つまり,以下をスレッド単位に持たせる必要があります.
KOZOS の本体は thread.c にあります.また KOZOS 上で動作するアプリは main1.c になります.main1.c では,main()関数の先頭で kz_start() により KOZOS に処理を渡します.つまり,KOZOS で最初に呼ばれるのは kz_start() です.
kz_start() は thread.c にあります.以下,抜粋です.
kz_start() ではいきなり setjmp() を行い,割り込み(実体はシグナル)発生時のコンテキストとして intr_env を作成します.これは setjmp() がゼロとして返ってくるので,その後は thread_start() が呼ばれることになります.ではその下の thread_intrvec() はいつ呼ばれるのか?これは,割り込み(実体はシグナル)発生時に intr_env を使って longjmp() することで,上の setjmp() の位置にジャンプしてきます.この際にはsetjmp() の戻り値にはシグナル番号を渡されるので,if 文の下に抜けてthread_intrvec() が呼ばれることになります.thread_intrvec() は割り込みハンドラであり,割り込み(しつこいけど,実体はシグナル)の種類に応じて適切な処理を行います.
つまり intr_env は,「割り込みハンドラを実行するためのコンテキスト」ということになります.言いかたを変えると,kz_start() 起動時のコンテキスト(スタックと,レジスタの状態)を intr_env に保存しておき,割り込み発生時にはそのコンテキストを利用して割り込みを処理します.
と,ここまでざーーーーっと書いてしまったけど,うーん,わかりにくいよねえ...「コンテキスト」というのは,実行するための環境というか,資源のことね.平たくいうと,スタックとレジスタの状態だと思ってくれればいい.
まあ説明を先に進めてしまうと,初期化時には kz_start() から thread_start() が呼ばれます.
まずはスレッドの管理用配列(threads)と,レディーキュー(readyque)を初期化しています.threads はスレッド管理用の構造体(kz_thread)の配列です.レディーキューについては後述します.
signal()によって,thread_intr() を SIGSYS のハンドラとして登録しています.つまり,シグナル SIGSYS の発生時には thread_intr() が呼ばれるわけです.SIGSYS って何? どんなときに発生するの? と思いますよね...実は KOZOS では,システムコール呼び出しは SIGSYS の発行によって行われます.つまり SIGSYS を発行するのは,システムコールを要求するアプリ自身です.まあこのへんについてはあとで詳しく説明します.
その後に,thread_run() によりスレッドを作成します.この時点で func をメイン関数とするスレッドが作成,というか threads[] の配列に登録されます.
で,最後にキモとなる longjmp() の呼び出しがあります.
longjmp() が呼ばれて,どこに飛んでいくのか?longjmp() の引数は,current->context.env です.current はカレントスレッド(現在動作中のスレッド)です.カレントスレッドは thread_run() によるスレッド作成時に,今まさに作成されたスレッドになっています.
thread_run() によるスレッド作成では,そのスレッドを動作させるためのスタックの確保とレジスタの設定を,構造体 kz_thread の context.env というメンバに対して行っています.
で,longjmp() を呼ぶことで,そのスレッドのコンテキストに切り替わり,メイン関数に飛んでいくというしくみです.別のいいかたをすると,context.env を引数にして longjmp() が呼ばれたらメイン関数に飛んでくるように,thread_run() の内部で context.env を初期化している,ということです.
次に,スレッドの作成用の関数である thread_run() について説明します.ちょっと長めの関数なので,分割して説明します.
まずは配列 threads[] から空いてる領域をもらいます.スレッド管理をリンクリストにしなかったのはあまり意味はないのだけれど,まあそのほうがわかりやすいかな,というのと,スレッド構造体はひじょーに重要なので,デバッグのことなども考えて,配列で固定で持っておきたかったというのがあります.
malloc() によってスタックを確保します.当り前のことなのですが,自分で確保したスタックの上で動作することもできるもんなんですねぇ.
i386ではスタックは下方伸長なので,アドレスの大きなほうにスタックポインタを合わせておきます.STACK_SIZEは 0x8000 なので32kBとちと大きめなような気もするのですが,実は製作当初は4kBくらいだったのですが,後々スタック不足で誤動作したので,大きめにしておきました.最低でも16kB程度は必要なようです.
このへんからがスレッド初期化のキモとなる部分です.spはスタックポインタです.i386のスタック構造では,引数をお尻から順にスタックに格納し,一番上に戻り先アドレスを格納して関数呼び出しを行います.実際の値設定はスタックが下方伸長なので,上記のようになります.戻り先には thread_end() を指定しているので,スレッド終了時(スレッドのメイン関数から抜けたとき)には,thread_end() が呼ばれます.実は thread_end() は
のようになっていて,kz_exit() システムコールによってスレッドを消去します.このためスレッドのメイン関数が終了すれば,自動的にスレッドは消滅することになります.
引き続き,thread_run() の説明です.
ここで,コンテキストの初期化を行っています.実体は setjmp()/longjmp() 用のバッファである jmp_buf の初期化です.
FreeBSD では,setjmp()/longjmp() は /usr/src/lib/libc/i386/gen/setjmp.Sにあり,ここを見れば jmp_buf の構造というか利用方法がわかります.ここで重要なのは,実行再開番地とスタックポインタ(ESP)です.見たところ,_jb[0]に実行再開番地,_jb[2]にスタックポインタであるESPが格納されるようですのでそうします(このへんはOS依存(というか setjmp()/longjmp() の実装依存)だと思うので,Linux だとどうなるかわかりません.ていうか多分そのままでは Linux では動かないと思う.必要なら setjmp()/longjmp() のソースを見て判断してください).他レジスタの値はたいして重要ではないですが,アドレスベースとなるようなレジスタがある...と思う(実はi386のレジスタ構成をよく知らない)ので,最初に作成した intr_env の値をコピーしておきます.
ちなみに setjmp()/longjmp() ではシグナルマスク情報も引き継がれます.このへんの情報は,_jb[6] 以降に入っているような気がします(未確認ですが).なので _jb[6] 以降もコピーするかどうかはちょっと微妙です.
最後に,カレントスレッドの設定をします.起動時は current==NULL となっているので,上記の if 文は実行されません.上記 if 文はシステムコールとして thread_run() が呼ばれた際に,呼んだもとのスレッドをレディーキューに繋ぐための処理です.なので起動時は不要です.
作成したスレッドを current に代入することでカレントスレッドとし,putcurrent() によってレディーキューに繋ぎます.
putcurrent()では,レディーキューのリンクリストの配列にカレントスレッドを繋ぎます.すでにレディーキューに繋がれているならば,なにもせずに -1 を返します.レディーキューはスレッドの優先度ごとの配列になっています.スレッドのディスパッチ時には,優先度の数値の低い順にレディーキューを検索し,一番最初に見つかったスレッド(すなわち,最も優先度の高いスレッド)が次のカレントスレッドとなります.
ここまでで,スレッドが作成され,新しいスレッドのためのコンテキスト(スタックとレジスタ情報)によってスレッドが動作し始めます.実際には main1.c のメイン関数で最初に kz_start() を呼び出していましたが,そのときの引数であった mainfunc() 関数が,KOZOS上で動くアプリとして,ひとつのスレッドとして動作を開始します.
さて,これでスレッドが動作開始したのですが,次にOSが動作するタイミングは何でしょうか?それはアプリがOSに何かお願いをしたとき,つまり,システムコールを呼んだときですね.
KOZOSでは現段階では5つのシステムコールを用意しています.
kz_run(), kz_exit() はまあそのまんまの意味で,スレッドの起動と終了です.UNIXでいうと,fork() と exit() に相当する,というと,実際は全然違うけど,まあ,イメージが湧きやすいかもしれません.
ちょっとわかりにくいのは kz_wait() です.これはスレッドのディスパッチを行うだけで何もしません.たとえば同じ優先度で動作しているスレッドが複数あった場合に,片方が kz_wait() を行うと,もう片方のスレッドに処理が渡ります.同じ優先度で動作しているスレッドが存在しない場合には,kz_wait()によりディスパッチを行ったところで,再び自分がカレントスレッドになるだけなので,ほんとになにも起きないことになります.
kz_sleep() はスレッドの動作を停止します.他のスレッドが停止中のスレッドに対して kz_wakeup() を行うと,そのスレッドは動作を再開します.実際にはレディーキューから外す処理と,再び繋ぐ処理を行うだけです.
システムコールの実際を見てみましょう.システムコール用のサービス関数は syscall.c にまとめられてありますが,実際には kz_syscall() が呼ばれます.
引数を用意して,SIGSYS を発行します.で,ここで kz_start() 実行時の最初に行った,thread_intr() のハンドラ登録が効くのです.
SIGSYS 発行により,thread_intr() が(まるでソフトウエア割り込みのように)呼ばれます.ここでシステムコール処理用の関数をいきなり呼ぶ,という方法もあるのですが,動作中のスレッドのコンテキスト(というか,スタック)が利用されてしまうため(シグナルの設定によってはハンドラ用に別スタックを利用させることも可能.詳しくは後日.まあここではそんなものだと軽く思ってください)が使われてしまうため,なんかイマイチです.というのは疑似的な割り込みとしてシグナルを利用していますが,割り込みというのは,割り込み発生時のスレッドのコンテキスト(主にスタック)には影響を与えずに,スレッド側からすれば割り込みが発生したことなど知ることもなく,終了すべきものだからです.なので,割り込み発生時のスレッドのスタックを利用して割り込み処理を行うというのは,スレッド側からすればスタックの未使用領域が突然汚れることになり,今後実装する予定の外部割り込みなども考えると,ちょっとイマイチな実装です.
なので,起動時に作成しておいた割り込み処理用のコンテキストであるintr_env を利用して,割り込み処理に入ります.
まあ実はハンドラ内部から setjmp()/longjmp() を呼び出しているので,割り込み発生時のスレッドのスタックは多少なりとも汚れてしまいます.これに関しては,後日また改良します.
ちなみにシステムコール用に SIGSYS を選んだ理由ですが,とくに特別な理由はありません.SIGHUP でもいいし,SIGTERM とか SIGUSR1 でもいいと思います.まあ名前がシステムコールっぽい(シグナルの実際の意味は違うのだけど)のと,SIGHUPとかは他で使いたいこともあるだろうという理由で SIGSYS にしてしまいました.
setjmp()により,割り込み発生時のスレッドのコンテキストを保存します.で,longjmp() により割り込み処理用のコンテキストに切り替わります.longjmp() では intr_env を引数としていますが,これは実は kz_start() による KOZOS 起動時に最初に setjmp() によってコンテキスト作成しているので,kz_start() の setjmp() 部分にジャンプします.
シグナルハンドラ(thread_intr())からはシグナル番号を引数としてlongjmp() が行われるため,上記の if 文は実行されず,割り込みベクタであるthread_intrvec() が呼ばれます.
現在はシステムコールとして SIGSYS が発行されているため,システムコールの処理関数である syscall_proc() に処理が渡ります.
syscall_proc()では,システムコールの内容に応じて,処理用の関数が呼ばれます.システムコールの実行結果は,各関数の戻り値として,p->un.XXXX.ret に格納されます.実はシステムコールを行うとスレッドのディスパッチが行われるため,戻り値は(グローバル変数を使うなどして)そのままスレッドには返せません.なのでシステムコール呼び出し時のパラメータ領域(これはシステムコールのサービス関数によってスレッドのスタック上に確保されているので,スレッドごとに存在する)に戻り値を保存しておきます.
syscall_proc() の先頭で getcurrent() を行っていることに注目してください.これはカレントスレッドを,レディーキューから抜きます.つまりこの時点で,カレントスレッドはスリープ状態(レディーキューに繋がれていないため,ディスパッチを行っても引っかからないので動作できない状態)になっています.
getcurrent() では,レディーキューの先頭からカレントスレッドを抜き取ります.一見して,リンクリストの検索処理を行わなくていいのか?と疑問に思ってしまいそうですが,カレントスレッドは前回のディスパッチによりレディーキューの先頭にあるスレッドが選択されているはずなので,必ず先頭にあるため,これでいいことになります.
ここで,各システムコールの処理関数について説明しておきます.
まずはスレッド作成のシステムコールである kz_run() の処理用関数の thread_run()ですが,すでに説明済みのために割愛します.ただ1点,thread_run() の内部で
のようにしている部分がありました.この段階ではカレントスレッドは kz_run() を「呼び出した」スレッドになっています.なので,putcunrent() によりレディーキューに繋ぎます.
syscall_proc() の先頭で getcurrent() を行っていることを思い出してください.この時点では,カレントスレッドはレディーキューに繋がれていないため,ディスパッチを行っても見つからない状態(スリープ状態)です.よってこれを行わないと,kz_run()を呼び出すとスレッドが固まる(スリープしたまま),ということになります.
さらに新規に作成したスレッドをカレントスレッドとして putcurrent() を呼ぶことで,やはりレディーキューに繋ぎます.まあ実はカレントスレッドはこの後のディスパッチ処理で変更されるためcurrent に設定する意味は無いのですが,putcurrent() を current に対して処理を行うように書いてしまったので,このようになっています(引数取るようにすればよかったね...まあいいや).
次に,スレッドの終了処理です.
カレントスレッドを解放するだけですね.current はこの後のディスパッチによりやはり変更されるため,解放済みのスレッドを指し続けるようなことはありません.
次に,スレッドのディスパッチを行うだけで何もしないという,よくわからんシステムコールの kz_wait() の処理です.
getcurrent() によって抜かれたカレントスレッドを,putcurrent() によって繋ぎなおすだけです.ただし putcurrent() はレディーキューのリンクリストのお尻に繋ぐため,同一優先度に別スレッドが存在する場合には,今度はそちらがディスパッチされて動作が渡ることになります.
次に,スリープです.
何もしません.getcurrent()によりスレッドはレディーキューから抜かれているので,そのままにしておけばスリープすることになります.
次に,wakeup です.
引数として渡されたスレッドをカレントスレッドとし,putcurrent() を行うことでレディーキューに繋ぎ直します.これにより再び動作を再開することになります.putcurrent() を2回行っていますが,前者は kz_wakeup() を呼び出した側のスレッドをレディーキューに繋ぐためのものです.これを行わないと,kz_run() で説明したのと同様に,kz_wakeup()を呼び出すと,呼ばれた側のスレッドは動作再開するが,呼び出した側のスレッドはスリープしてしまう,ということになります.
ここまでで各システムコールの処理について説明しましたが,各処理を行った後にはスレッドのディスパッチが行われます.再び thread_intrvec() に戻ります.
syscall_proc() 呼び出し後には dispatch() が呼ばれます.これによりスレッドのディスパッチ(次にカレントスレッドとなるべきスレッドの検索.あれ,それはスケジュールと呼ぶべきか?うーんちょっと名前間違ったかも...)が行われます.
dispatch()では,レディーキューを優先度が高い順(=優先度の数値が低い順)に検索し,一番最初に見つかったスレッドをカレントスレッドとします.ただしレディーキューがまったく空(すべてのスレッドがスリープ状態になってしまっている)の際には,動く意味は無いので終了してしまいます.
thread_intrvec() では,dispatch() によるディスパッチ(スケジュール?)の後にはlongjmp() が呼ばれます.
ここで,カレントスレッドのコンテキストを,longjmp() にバッファとして渡しています.このため先程ディスパッチされたスレッドが,次に動き出します.
さて,次に動き出すのはどこからでしょうか?スレッドは割り込み発生した状態で,その位置のコンテキストを保存して停止しています.これは外部割り込み,もしくはシステムコールによるソフトウエア割り込みが入った位置です.よってディスパッチされて新しいカレントスレッドとなったスレッドが,前回に割り込まれた位置から動作再開します.
現状のKOZOSでは,割り込み発生時のハンドラである thread_intr() の内部で,setjmp() によりコンテキスト保存し,longjmp() により割り込みハンドラ用のコンテキストで割り込み処理を開始しています.もう一度,thread_intr() を見てみましょう.
dispatch() によるディスパッチ後の longjmp() で飛んでくるのは,上の setjmp() の部分です.setjmp() により,スレッドのコンテキストが保存されているからです.
つまり動作はカレントスレッドが割り込まれて(=シグナルを受けて)シグナルハンドラが起動され,setjmp() を呼び出した時点に戻ります.そしてそのスレッドのコンテキストでシグナルハンドラが終了し,再びそのスレッドが動作を開始することになります.
このように KOZOS では,setjmp() による状態保存用のバッファ(jmp_buf)をコンテキスト保存用に流用し,setjmp()/longjmp() によってコンテキストスイッチを行います(この構造には実はいろいろ問題があるので,後期バージョンで変更します).どうでしょう? シグナルと setjmp()/longjmp() を利用することで,OSもどきの動作ができています.
とりあえずOSの動作の流れをひととおり説明しましたが,うーん,ちょっと駆け足というかわかりにくいですね...どうだったでしょうか?
アプリの実行結果については次回説明します.
とりあえず,独自OSといわれても,どんなものか実感がわかないよね...ホントは今回やることをきちんと説明してからソースコードの紹介,と順序を踏みたいのだけれど,まずはイメージがわかないと説明聞いてもよくわからんと思うので,まずはスレッド切替えだけの単純なモノ(メッセージ通信すら無い)を書いてみて,動作を確認してみよう.
以下がソースコードです.で,動作させるためにはOSだけではなくてサンプルプログラムが必要なのだけど,とりあえず以下の main1.c を使ってみる.まずはディレクトリを作成してこれらのファイルを置いて,main1.c を main.c にリネームして,make してみよう.ちなみにぼくの動作環境は FreeBSD-6.x です.実は内部で setjmp() 用のバッファを直接操作しているので,Linux だとちょっとどうなるか不明です.
% make
cc -c thread.c -g -Wall -static
cc -c syscall.c -g -Wall -static
cc -c main.c -g -Wall -static
cc thread.o syscall.o main.o -o koz -g -Wall -static
%
これで実行形式 koz ができるので,実行してみます.
% koz
main start
thread 1 started
thread 2 started
mainfunc loop 0
mainfunc loop 1
mainfunc end
func1 start 1 ./koz
func1 loop 0
func2 start 1 ./koz
func2 loop 0
func1 loop 1
func2 loop 1
func1 end
func2 end
%
どうでしょう? 同じ結果になったでしょうか?
現段階では,KOZOSは簡単なスレッド切替えしか行ってくれません.しかしスレッド切替え(コンテキストスイッチ)を行うということは,そのスレッドのコンテキストをスレッド単位で管理しなければなりません.つまり,以下をスレッド単位に持たせる必要があります.
- スタック
- スレッドが停止している状態でのレジスタ情報
KOZOS の本体は thread.c にあります.また KOZOS 上で動作するアプリは main1.c になります.main1.c では,main()関数の先頭で kz_start() により KOZOS に処理を渡します.つまり,KOZOS で最初に呼ばれるのは kz_start() です.
kz_start() は thread.c にあります.以下,抜粋です.
void kz_start(kz_func func, char *name, int pri, int argc, char *argv[])
{
int signo;
/*
* setjmp()は最低位の関数から呼ぶ必要があるので,本体は thread_start() に
* 置いて setjmp() 呼び出し直後に本体を呼び出す.
* (setjmp()した関数から return 後に longjmp() を呼び出してはいけない)
*/
if ((signo = setjmp(intr_env)) == 0) {
thread_start(func, name, pri, argc, argv);
}
thread_intrvec(signo);
/* ここには返ってこない */
abort();
}
kz_start() ではいきなり setjmp() を行い,割り込み(実体はシグナル)発生時のコンテキストとして intr_env を作成します.これは setjmp() がゼロとして返ってくるので,その後は thread_start() が呼ばれることになります.ではその下の thread_intrvec() はいつ呼ばれるのか?これは,割り込み(実体はシグナル)発生時に intr_env を使って longjmp() することで,上の setjmp() の位置にジャンプしてきます.この際にはsetjmp() の戻り値にはシグナル番号を渡されるので,if 文の下に抜けてthread_intrvec() が呼ばれることになります.thread_intrvec() は割り込みハンドラであり,割り込み(しつこいけど,実体はシグナル)の種類に応じて適切な処理を行います.
つまり intr_env は,「割り込みハンドラを実行するためのコンテキスト」ということになります.言いかたを変えると,kz_start() 起動時のコンテキスト(スタックと,レジスタの状態)を intr_env に保存しておき,割り込み発生時にはそのコンテキストを利用して割り込みを処理します.
と,ここまでざーーーーっと書いてしまったけど,うーん,わかりにくいよねえ...「コンテキスト」というのは,実行するための環境というか,資源のことね.平たくいうと,スタックとレジスタの状態だと思ってくれればいい.
まあ説明を先に進めてしまうと,初期化時には kz_start() から thread_start() が呼ばれます.
static void thread_start(kz_func func, char *name, int pri, int argc, char *argv[])
{
memset(threads, 0, sizeof(threads));
memset(readyque, 0, sizeof(readyque));
signal(SIGSYS, thread_intr);
/*
* current 未定のためにシステムコール発行はできないので,
* 直接関数を呼び出してスレッド作成する.
*/
current = NULL;
current = (kz_thread *)thread_run(func, name, pri, argc, argv);
longjmp(current->context.env, 1);
}
まずはスレッドの管理用配列(threads)と,レディーキュー(readyque)を初期化しています.threads はスレッド管理用の構造体(kz_thread)の配列です.レディーキューについては後述します.
signal()によって,thread_intr() を SIGSYS のハンドラとして登録しています.つまり,シグナル SIGSYS の発生時には thread_intr() が呼ばれるわけです.SIGSYS って何? どんなときに発生するの? と思いますよね...実は KOZOS では,システムコール呼び出しは SIGSYS の発行によって行われます.つまり SIGSYS を発行するのは,システムコールを要求するアプリ自身です.まあこのへんについてはあとで詳しく説明します.
その後に,thread_run() によりスレッドを作成します.この時点で func をメイン関数とするスレッドが作成,というか threads[] の配列に登録されます.
で,最後にキモとなる longjmp() の呼び出しがあります.
longjmp() が呼ばれて,どこに飛んでいくのか?longjmp() の引数は,current->context.env です.current はカレントスレッド(現在動作中のスレッド)です.カレントスレッドは thread_run() によるスレッド作成時に,今まさに作成されたスレッドになっています.
thread_run() によるスレッド作成では,そのスレッドを動作させるためのスタックの確保とレジスタの設定を,構造体 kz_thread の context.env というメンバに対して行っています.
で,longjmp() を呼ぶことで,そのスレッドのコンテキストに切り替わり,メイン関数に飛んでいくというしくみです.別のいいかたをすると,context.env を引数にして longjmp() が呼ばれたらメイン関数に飛んでくるように,thread_run() の内部で context.env を初期化している,ということです.
次に,スレッドの作成用の関数である thread_run() について説明します.ちょっと長めの関数なので,分割して説明します.
static int thread_run(kz_func func, char *name, int pri,
int argc, char *argv[])
{
int i;
char *sp;
kz_thread *thp;
for (i = 0; i < THREAD_NUM; i++) {
thp = &threads[i];
if (!thp->id) break;
}
if (i == THREAD_NUM) return -1;
memset(thp, 0, sizeof(*thp));
thp->next = NULL;
strcpy(thp->name, name);
thp->id = thp;
thp->func = func;
thp->pri = pri;
まずは配列 threads[] から空いてる領域をもらいます.スレッド管理をリンクリストにしなかったのはあまり意味はないのだけれど,まあそのほうがわかりやすいかな,というのと,スレッド構造体はひじょーに重要なので,デバッグのことなども考えて,配列で固定で持っておきたかったというのがあります.
thp->stack = malloc(STACK_SIZE);
memset(thp->stack, 0, STACK_SIZE);
sp = thp->stack + STACK_SIZE - 32;
malloc() によってスタックを確保します.当り前のことなのですが,自分で確保したスタックの上で動作することもできるもんなんですねぇ.
i386ではスタックは下方伸長なので,アドレスの大きなほうにスタックポインタを合わせておきます.STACK_SIZEは 0x8000 なので32kBとちと大きめなような気もするのですが,実は製作当初は4kBくらいだったのですが,後々スタック不足で誤動作したので,大きめにしておきました.最低でも16kB程度は必要なようです.
/* メイン関数終了時の戻り先 */
((int *)sp)[1] = (int)thread_end;
/* スタック上に引数(argc,argv)を準備する */
((int *)sp)[2] = (int)thp;
((int *)sp)[3] = argc;
((int *)sp)[4] = (int)argv;
このへんからがスレッド初期化のキモとなる部分です.spはスタックポインタです.i386のスタック構造では,引数をお尻から順にスタックに格納し,一番上に戻り先アドレスを格納して関数呼び出しを行います.実際の値設定はスタックが下方伸長なので,上記のようになります.戻り先には thread_end() を指定しているので,スレッド終了時(スレッドのメイン関数から抜けたとき)には,thread_end() が呼ばれます.実は thread_end() は
static void thread_end()
{
kz_exit();
}
のようになっていて,kz_exit() システムコールによってスレッドを消去します.このためスレッドのメイン関数が終了すれば,自動的にスレッドは消滅することになります.
引き続き,thread_run() の説明です.
/*
* 以下の設定については setjmp()/longjmp()
* (/usr/src/lib/libc/i386/gen)を参照.
*/
#if 1
thp->context.env[0]._jb[0] = (int)thread_init; /* EIP */
thp->context.env[0]._jb[1] = intr_env[0]._jb[1]; /* EBX */
thp->context.env[0]._jb[2] = (int)sp; /* ESP */
thp->context.env[0]._jb[3] = intr_env[0]._jb[3]; /* EBP */
thp->context.env[0]._jb[4] = intr_env[0]._jb[4]; /* ESI */
thp->context.env[0]._jb[5] = intr_env[0]._jb[5]; /* EDI */
/* thp->context.env[0]._jb[6] = ??? */
#else
memcpy(thp->context.env, intr_env, sizeof(intr_env));
thp->context.env[0]._jb[0] = (int)thread_init; /* EIP */
thp->context.env[0]._jb[2] = (int)sp; /* ESP */
#endif
ここで,コンテキストの初期化を行っています.実体は setjmp()/longjmp() 用のバッファである jmp_buf の初期化です.
FreeBSD では,setjmp()/longjmp() は /usr/src/lib/libc/i386/gen/setjmp.Sにあり,ここを見れば jmp_buf の構造というか利用方法がわかります.ここで重要なのは,実行再開番地とスタックポインタ(ESP)です.見たところ,_jb[0]に実行再開番地,_jb[2]にスタックポインタであるESPが格納されるようですのでそうします(このへんはOS依存(というか setjmp()/longjmp() の実装依存)だと思うので,Linux だとどうなるかわかりません.ていうか多分そのままでは Linux では動かないと思う.必要なら setjmp()/longjmp() のソースを見て判断してください).他レジスタの値はたいして重要ではないですが,アドレスベースとなるようなレジスタがある...と思う(実はi386のレジスタ構成をよく知らない)ので,最初に作成した intr_env の値をコピーしておきます.
ちなみに setjmp()/longjmp() ではシグナルマスク情報も引き継がれます.このへんの情報は,_jb[6] 以降に入っているような気がします(未確認ですが).なので _jb[6] 以降もコピーするかどうかはちょっと微妙です.
/* 起動時の初回のスレッド作成では current 未定なのでNULLチェックする */
if (current) {
putcurrent();
}
current = thp;
putcurrent();
return (int)current;
}
最後に,カレントスレッドの設定をします.起動時は current==NULL となっているので,上記の if 文は実行されません.上記 if 文はシステムコールとして thread_run() が呼ばれた際に,呼んだもとのスレッドをレディーキューに繋ぐための処理です.なので起動時は不要です.
作成したスレッドを current に代入することでカレントスレッドとし,putcurrent() によってレディーキューに繋ぎます.
static int putcurrent()
{
kz_thread **thpp;
for (thpp = &readyque[current->pri]; *thpp; thpp = &((*thpp)->next)) {
/* すでに有る場合は無視 */
if (*thpp == current) return -1;
}
*thpp = current;
return 0;
}
putcurrent()では,レディーキューのリンクリストの配列にカレントスレッドを繋ぎます.すでにレディーキューに繋がれているならば,なにもせずに -1 を返します.レディーキューはスレッドの優先度ごとの配列になっています.スレッドのディスパッチ時には,優先度の数値の低い順にレディーキューを検索し,一番最初に見つかったスレッド(すなわち,最も優先度の高いスレッド)が次のカレントスレッドとなります.
ここまでで,スレッドが作成され,新しいスレッドのためのコンテキスト(スタックとレジスタ情報)によってスレッドが動作し始めます.実際には main1.c のメイン関数で最初に kz_start() を呼び出していましたが,そのときの引数であった mainfunc() 関数が,KOZOS上で動くアプリとして,ひとつのスレッドとして動作を開始します.
さて,これでスレッドが動作開始したのですが,次にOSが動作するタイミングは何でしょうか?それはアプリがOSに何かお願いをしたとき,つまり,システムコールを呼んだときですね.
KOZOSでは現段階では5つのシステムコールを用意しています.
- KZ_SYSCALL_TYPE_RUN ... 新しいスレッドの作成
- KZ_SYSCALL_TYPE_EXIT ... スレッドの終了
- KZ_SYSCALL_TYPE_WAIT ... ディスパッチを行い,処理を他のスレッドに渡す
- KZ_SYSCALL_TYPE_SLEEP ... スレッドの停止
- KZ_SYSCALL_TYPE_WAKEUP ... スレッドの再開
int kz_run(kz_func func, char *name, int pri, int argc, char *argv[]);
void kz_exit();
int kz_wait();
int kz_sleep();
int kz_wakeup(int id);
kz_run(), kz_exit() はまあそのまんまの意味で,スレッドの起動と終了です.UNIXでいうと,fork() と exit() に相当する,というと,実際は全然違うけど,まあ,イメージが湧きやすいかもしれません.
ちょっとわかりにくいのは kz_wait() です.これはスレッドのディスパッチを行うだけで何もしません.たとえば同じ優先度で動作しているスレッドが複数あった場合に,片方が kz_wait() を行うと,もう片方のスレッドに処理が渡ります.同じ優先度で動作しているスレッドが存在しない場合には,kz_wait()によりディスパッチを行ったところで,再び自分がカレントスレッドになるだけなので,ほんとになにも起きないことになります.
kz_sleep() はスレッドの動作を停止します.他のスレッドが停止中のスレッドに対して kz_wakeup() を行うと,そのスレッドは動作を再開します.実際にはレディーキューから外す処理と,再び繋ぐ処理を行うだけです.
システムコールの実際を見てみましょう.システムコール用のサービス関数は syscall.c にまとめられてありますが,実際には kz_syscall() が呼ばれます.
void kz_syscall(kz_syscall_type_t type, kz_syscall_param_t *param)
{
current->syscall.type = type;
current->syscall.param = param;
kill(getpid(), SIGSYS);
return;
}
引数を用意して,SIGSYS を発行します.で,ここで kz_start() 実行時の最初に行った,thread_intr() のハンドラ登録が効くのです.
SIGSYS 発行により,thread_intr() が(まるでソフトウエア割り込みのように)呼ばれます.ここでシステムコール処理用の関数をいきなり呼ぶ,という方法もあるのですが,動作中のスレッドのコンテキスト(というか,スタック)が利用されてしまうため(シグナルの設定によってはハンドラ用に別スタックを利用させることも可能.詳しくは後日.まあここではそんなものだと軽く思ってください)が使われてしまうため,なんかイマイチです.というのは疑似的な割り込みとしてシグナルを利用していますが,割り込みというのは,割り込み発生時のスレッドのコンテキスト(主にスタック)には影響を与えずに,スレッド側からすれば割り込みが発生したことなど知ることもなく,終了すべきものだからです.なので,割り込み発生時のスレッドのスタックを利用して割り込み処理を行うというのは,スレッド側からすればスタックの未使用領域が突然汚れることになり,今後実装する予定の外部割り込みなども考えると,ちょっとイマイチな実装です.
なので,起動時に作成しておいた割り込み処理用のコンテキストであるintr_env を利用して,割り込み処理に入ります.
static void thread_intr(int signo)
{
/*
* setjmp()/longjmp() はシグナルマスクを保存し復元するが,
* _setjmp()/_longjmp() はシグナルマスクを保存しない.
* (レジスタセットとスタックしか保存および復元しない)
*/
if (setjmp(current->context.env) == 0) {
longjmp(intr_env, signo);
}
}
まあ実はハンドラ内部から setjmp()/longjmp() を呼び出しているので,割り込み発生時のスレッドのスタックは多少なりとも汚れてしまいます.これに関しては,後日また改良します.
ちなみにシステムコール用に SIGSYS を選んだ理由ですが,とくに特別な理由はありません.SIGHUP でもいいし,SIGTERM とか SIGUSR1 でもいいと思います.まあ名前がシステムコールっぽい(シグナルの実際の意味は違うのだけど)のと,SIGHUPとかは他で使いたいこともあるだろうという理由で SIGSYS にしてしまいました.
setjmp()により,割り込み発生時のスレッドのコンテキストを保存します.で,longjmp() により割り込み処理用のコンテキストに切り替わります.longjmp() では intr_env を引数としていますが,これは実は kz_start() による KOZOS 起動時に最初に setjmp() によってコンテキスト作成しているので,kz_start() の setjmp() 部分にジャンプします.
void kz_start(kz_func func, char *name, int pri, int argc, char *argv[])
{
int signo;
/*
* setjmp()は最低位の関数から呼ぶ必要があるので,本体は thread_start() に
* 置いて setjmp() 呼び出し直後に本体を呼び出す.
* (setjmp()した関数から return 後に longjmp() を呼び出してはいけない)
*/
if ((signo = setjmp(intr_env)) == 0) {
thread_start(func, name, pri, argc, argv);
}
thread_intrvec(signo);
/* ここには返ってこない */
abort();
}
シグナルハンドラ(thread_intr())からはシグナル番号を引数としてlongjmp() が行われるため,上記の if 文は実行されず,割り込みベクタであるthread_intrvec() が呼ばれます.
static void thread_intrvec(int signo)
{
switch (signo) {
case SIGSYS: /* システムコール */
syscall_proc();
break;
case SIGBUS: /* ダウン要因発生 */
case SIGSEGV:
case SIGTRAP:
case SIGILL:
{
fprintf(stderr, "error %s\n", current->name);
/* ダウン要因発生により継続不可能なので,スリープ状態にする*/
getcurrent();
}
break;
default:
break;
}
dispatch();
longjmp(current->context.env, 1);
}
現在はシステムコールとして SIGSYS が発行されているため,システムコールの処理関数である syscall_proc() に処理が渡ります.
static void syscall_proc()
{
/* システムコールの実行中にcurrentが書き換わるのでポインタを保存しておく */
kz_syscall_param_t *p = current->syscall.param;
getcurrent();
switch (current->syscall.type) {
case KZ_SYSCALL_TYPE_RUN:
p->un.run.ret = thread_run(p->un.run.func, p->un.run.name, p->un.run.pri,
p->un.run.argc, p->un.run.argv);
break;
case KZ_SYSCALL_TYPE_EXIT:
/* スレッドが解放されるので戻り値などを書き込んではいけない */
thread_exit();
break;
case KZ_SYSCALL_TYPE_WAIT:
p->un.wait.ret = thread_wait();
break;
case KZ_SYSCALL_TYPE_SLEEP:
p->un.sleep.ret = thread_sleep();
break;
case KZ_SYSCALL_TYPE_WAKEUP:
p->un.wakeup.ret = thread_wakeup(p->un.wakeup.id);
break;
default:
break;
}
return;
}
syscall_proc()では,システムコールの内容に応じて,処理用の関数が呼ばれます.システムコールの実行結果は,各関数の戻り値として,p->un.XXXX.ret に格納されます.実はシステムコールを行うとスレッドのディスパッチが行われるため,戻り値は(グローバル変数を使うなどして)そのままスレッドには返せません.なのでシステムコール呼び出し時のパラメータ領域(これはシステムコールのサービス関数によってスレッドのスタック上に確保されているので,スレッドごとに存在する)に戻り値を保存しておきます.
syscall_proc() の先頭で getcurrent() を行っていることに注目してください.これはカレントスレッドを,レディーキューから抜きます.つまりこの時点で,カレントスレッドはスリープ状態(レディーキューに繋がれていないため,ディスパッチを行っても引っかからないので動作できない状態)になっています.
static void getcurrent()
{
readyque[current->pri] = current->next;
current->next = NULL;
}
getcurrent() では,レディーキューの先頭からカレントスレッドを抜き取ります.一見して,リンクリストの検索処理を行わなくていいのか?と疑問に思ってしまいそうですが,カレントスレッドは前回のディスパッチによりレディーキューの先頭にあるスレッドが選択されているはずなので,必ず先頭にあるため,これでいいことになります.
ここで,各システムコールの処理関数について説明しておきます.
まずはスレッド作成のシステムコールである kz_run() の処理用関数の thread_run()ですが,すでに説明済みのために割愛します.ただ1点,thread_run() の内部で
/* 起動時の初回のスレッド作成では current 未定なのでNULLチェックする */
if (current) {
putcurrent();
}
current = thp;
putcurrent();
のようにしている部分がありました.この段階ではカレントスレッドは kz_run() を「呼び出した」スレッドになっています.なので,putcunrent() によりレディーキューに繋ぎます.
syscall_proc() の先頭で getcurrent() を行っていることを思い出してください.この時点では,カレントスレッドはレディーキューに繋がれていないため,ディスパッチを行っても見つからない状態(スリープ状態)です.よってこれを行わないと,kz_run()を呼び出すとスレッドが固まる(スリープしたまま),ということになります.
さらに新規に作成したスレッドをカレントスレッドとして putcurrent() を呼ぶことで,やはりレディーキューに繋ぎます.まあ実はカレントスレッドはこの後のディスパッチ処理で変更されるためcurrent に設定する意味は無いのですが,putcurrent() を current に対して処理を行うように書いてしまったので,このようになっています(引数取るようにすればよかったね...まあいいや).
次に,スレッドの終了処理です.
/* スレッドの終了 */
static int thread_exit()
{
free(current->stack);
memset(current, 0, sizeof(*current));
return 0;
}
カレントスレッドを解放するだけですね.current はこの後のディスパッチによりやはり変更されるため,解放済みのスレッドを指し続けるようなことはありません.
次に,スレッドのディスパッチを行うだけで何もしないという,よくわからんシステムコールの kz_wait() の処理です.
/* スレッドの実行権放棄(同一priの別スレッドに実行権を渡す) */
static int thread_wait()
{
putcurrent();
return 0;
}
getcurrent() によって抜かれたカレントスレッドを,putcurrent() によって繋ぎなおすだけです.ただし putcurrent() はレディーキューのリンクリストのお尻に繋ぐため,同一優先度に別スレッドが存在する場合には,今度はそちらがディスパッチされて動作が渡ることになります.
次に,スリープです.
static int thread_sleep()
{
return 0;
}
何もしません.getcurrent()によりスレッドはレディーキューから抜かれているので,そのままにしておけばスリープすることになります.
次に,wakeup です.
static int thread_wakeup(int id)
{
putcurrent();
current = (kz_thread *)id;
putcurrent();
return 0;
}
引数として渡されたスレッドをカレントスレッドとし,putcurrent() を行うことでレディーキューに繋ぎ直します.これにより再び動作を再開することになります.putcurrent() を2回行っていますが,前者は kz_wakeup() を呼び出した側のスレッドをレディーキューに繋ぐためのものです.これを行わないと,kz_run() で説明したのと同様に,kz_wakeup()を呼び出すと,呼ばれた側のスレッドは動作再開するが,呼び出した側のスレッドはスリープしてしまう,ということになります.
ここまでで各システムコールの処理について説明しましたが,各処理を行った後にはスレッドのディスパッチが行われます.再び thread_intrvec() に戻ります.
static void thread_intrvec(int signo)
{
switch (signo) {
case SIGSYS: /* システムコール */
syscall_proc();
break;
case SIGBUS: /* ダウン要因発生 */
case SIGSEGV:
case SIGTRAP:
case SIGILL:
{
fprintf(stderr, "error %s\n", current->name);
/* ダウン要因発生により継続不可能なので,スリープ状態にする*/
getcurrent();
}
break;
default:
break;
}
dispatch();
longjmp(current->context.env, 1);
}
syscall_proc() 呼び出し後には dispatch() が呼ばれます.これによりスレッドのディスパッチ(次にカレントスレッドとなるべきスレッドの検索.あれ,それはスケジュールと呼ぶべきか?うーんちょっと名前間違ったかも...)が行われます.
static void dispatch()
{
int i;
for (i = 0; i < PRI_NUM; i++) {
if (readyque[i]) break;
}
if (i == PRI_NUM) {
/* 実行可能なスレッドが存在しないので,終了する */
exit(0);
}
current = readyque[i];
}
dispatch()では,レディーキューを優先度が高い順(=優先度の数値が低い順)に検索し,一番最初に見つかったスレッドをカレントスレッドとします.ただしレディーキューがまったく空(すべてのスレッドがスリープ状態になってしまっている)の際には,動く意味は無いので終了してしまいます.
thread_intrvec() では,dispatch() によるディスパッチ(スケジュール?)の後にはlongjmp() が呼ばれます.
dispatch();
longjmp(current->context.env, 1);
}
ここで,カレントスレッドのコンテキストを,longjmp() にバッファとして渡しています.このため先程ディスパッチされたスレッドが,次に動き出します.
さて,次に動き出すのはどこからでしょうか?スレッドは割り込み発生した状態で,その位置のコンテキストを保存して停止しています.これは外部割り込み,もしくはシステムコールによるソフトウエア割り込みが入った位置です.よってディスパッチされて新しいカレントスレッドとなったスレッドが,前回に割り込まれた位置から動作再開します.
現状のKOZOSでは,割り込み発生時のハンドラである thread_intr() の内部で,setjmp() によりコンテキスト保存し,longjmp() により割り込みハンドラ用のコンテキストで割り込み処理を開始しています.もう一度,thread_intr() を見てみましょう.
static void thread_intr(int signo)
{
/*
* setjmp()/longjmp() はシグナルマスクを保存し復元するが,
* _setjmp()/_longjmp() はシグナルマスクを保存しない.
* (レジスタセットとスタックしか保存および復元しない)
*/
if (setjmp(current->context.env) == 0) {
longjmp(intr_env, signo);
}
}
dispatch() によるディスパッチ後の longjmp() で飛んでくるのは,上の setjmp() の部分です.setjmp() により,スレッドのコンテキストが保存されているからです.
つまり動作はカレントスレッドが割り込まれて(=シグナルを受けて)シグナルハンドラが起動され,setjmp() を呼び出した時点に戻ります.そしてそのスレッドのコンテキストでシグナルハンドラが終了し,再びそのスレッドが動作を開始することになります.
このように KOZOS では,setjmp() による状態保存用のバッファ(jmp_buf)をコンテキスト保存用に流用し,setjmp()/longjmp() によってコンテキストスイッチを行います(この構造には実はいろいろ問題があるので,後期バージョンで変更します).どうでしょう? シグナルと setjmp()/longjmp() を利用することで,OSもどきの動作ができています.
とりあえずOSの動作の流れをひととおり説明しましたが,うーん,ちょっと駆け足というかわかりにくいですね...どうだったでしょうか?
アプリの実行結果については次回説明します.