(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

とりあえず,独自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は簡単なスレッド切替えしか行ってくれません.しかしスレッド切替え(コンテキストスイッチ)を行うということは,そのスレッドのコンテキストをスレッド単位で管理しなければなりません.つまり,以下をスレッド単位に持たせる必要があります.
  • スタック
  • スレッドが停止している状態でのレジスタ情報
問題は,スタックとレジスタ情報の切替えをどうするかです.これが通常の組み込みOSならば,割り込み契機でレジスタの退避を行い,割り込み完了命令(たいていのCPUが持っています)を実行すれば,レジスタの復帰を一気に行ってくれる,ということになります.同じようなことをやろうとすると,レジスタの操作が必要になるため,アセンブラで書かなければなりません.うーん面倒だなどうしようかなーと考えたのですが,実は同じようなことを行う setjmp()/longjmp() というものがあり,これを使えばコンテキストスイッチができるのでは...というのが,実は 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 ... スレッドの再開
これらは syscall.h で定義されていますが,サービス関数として表現したほうがわかりやすいかもしれません.

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の動作の流れをひととおり説明しましたが,うーん,ちょっと駆け足というかわかりにくいですね...どうだったでしょうか?

アプリの実行結果については次回説明します.
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

KOZOS(Kernel Over Zone Operating System)は教育用というか,勉強用,独学用,いじって遊ぶ用のOSです.

OSというと動かすのにインストールしなければならないというか,いじるにしても環境作りがまずちょっと面倒であったり,まずはターゲット機を購入する(もしくは探すところから)必要があったり,いろいろ試すにしてもフラッシュへの焼き込が必要とかで気軽に試せなかったり,デバッグが大変であったりというようなイメージがあります.まあ最近のOSはそうでもなかったりするのだけど,PC上で1アプリとして気軽に(ここ重要!)ビルドできて,組み込みOSっぽく動くようなものがあったらおもしろいかなーくらいの感覚で,自分の勉強として作ってみました.というわけで分類的にはOSというよりもスレッドライブラリのような気もするのだけれど,スレッド管理や資源管理(CPU時間も資源のひとつです)など,やってることはOSとだいたい同じなのでここではOSと呼んでいます.そもそもスレッドライブラリを作ることが目的なのではなく,勉強用のOSとして,構造は組み込みOSなのだけどPC上で動作する,という路線で作っています.設計も,スレッドライブラリではなく組み込みOSに似せること(組み込みOSならば,こーいうふうに動作するべきなんだろうなーという視点)を主眼に置いています.つまりスレッドライブラリとしての実用性とかではなく,KOZOSの動作がわかれば組み込みOSの動作もわかるようになる(んではなかろうか)という,そーいうつくりを目指しています.そーいう意味では,疑似組み込みOSというのが正しい言いかたかもしれません.

実は Toppers ではそのようなことができて,Linux とかのOS上で動くスレッドライブラリとしても使えたりするのだけど,まあKOZOSは PC-UNIX 上での動作のみに限定して,とっつきやすさ,わかりやすさ,読みやすさ,いじりやすさをテーマにしてみようかな,と.そういう意味で,組み込みOSやスレッドライブラリは既にいろんなものがあるけど,こんなような勉強用の疑似OSって今まで無かったような気もする.(あったら筆者の無知です.ゴメン)

とくに,気軽にビルドして試せるということは非常に重要.社会人になると学生とは違い,まとまった時間でどっしりと構えてじっくり勉強できることってそうそう無くて,皆無ではないし,そういう時間を自分で作ろうとすることも大切だけど,やっぱしあいた時間とかでちょこちょこマメに勉強する,とか,通勤電車の中や家に帰ってからヒマを見つけて,あした(明日,ではなく,未来という意味ね)のためにちょっと勉強しておく,という技術が必要になってくる.なので,まずはターゲットマシンの確保とビルド環境の整備に3日,実機で動作するようになるまで苦戦してさらに3日,ソースコードの読破に1週間なーんてのはそれが本業ならばいいのだけれど,自分の勉強としてはなかなか難しい(モチベーションを保つのも難しい)ので,こーいう単純なサンプルコードで暇な時間(通勤電車の中とかね)にパッと勉強できる,コード量が少ないので紙に印刷して持ち歩いて暇な空き時間に読める,規模が小さいので簡単に手が入れられて,いろいろ試しながら楽しく勉強できるというのは,とっかかりとしてとても大切だと思う.とっかかりさえできれば,あとはそれなりに巨大なソースコードを見てもなんとなく何やってるかくらいはわかるようになるものです.

組み込みOSの勉強をしていると,「再入」「プリエンプティブ」「リアルタイム性」「排他」「同期」なーーんて言葉というか概念がいっぱい出てくる.そして,それらを説明している資料もいっぱいあるのだけど,やはり理論的な説明だけではわかりにくい.しかしこれらのことを実際に試してみるとすれば,きちんとした組み込みOSを用意して,ターゲットボードにインストールして,お試しアプリを作成して動かしてみる,ということになる.もちろんコンパイル環境作りから必要だし,どんなふうに動いているのかを確認することすら,場合によってはたいへんだ(printf()が使えなかったりね!).しかしこれでは初心者はとってもとっつきにくい.なので,気が向いたときにPC上でパッと試せる,実際に何が起きているのか,動作させてみて見てみることができるというのは,とてもとっつきやすくていいと思う.PC上での動作ならば,printf()とかでログを出させるのも自由自在で融通が効く.そーいうためのサンプルOSとしたい,という考えもある.

あとはGDBによるリモートデバッグに対応させたいというのもある.リモートデバッグに関する雑誌記事とか国産の資料とかってとっても少ない(ていうかほとんど無い)のだけれど,無いからにはちゃんと書かないとなーという思いもある.組み込みやるならば必須の機能だと思うし,ということで実はKOZOSはリモートデバッグのGDBスタブ実装のためのサンプルOSとして作ったというのもあるので,とくにリモートデバッグのスレッド対応に関して,きちんと実装させてみたい.KOZOSでリモートデバッグが実装できれば,実際の組み込みOSでも原理的には同じように実装できるはず.なので,このようなサンプルコードはどこかで必要とされているはずだ!

ちなみにOSの作成,i386のリモートデバッグ化,リモートデバッグのスレッド対応とも,ぼくはKOZOSが初体験となります.どんなものになることやら!
  • わかりやすさ,いじりやすさを優先する.(実用性はあんまり考えない)
  • すっきりした,見通しのよいシンプルな実装にする.
  • 余計な機能は積まない.わかりやすく,単純にするためにエラー処理なども省略.良い意味での手抜きコードにする.
  • GDBによるリモートデバッグに対応する.(スレッド対応もする)
  • へーこれっておもしろーい,と思えるような連載にしたい...なあ...
対象は FreeBSD-6.x 以降です.FreeBSD-4.x では getcontext() が使えないため動きません.Linux は...試してないのでちょっとわからないですが,シグナルハンドラ入った後のディスパッチ処理がどうなるか不明.まあ動くかもしれないし,動かないかもしれないというくらいです.スレッドのディスパッチにsetjmp()使っている初期のコードでは,なにかしらの対応は必要でしょう.まあOS依存がある部分は,そのように説明します.なぜ Linux でなく FreeBSD なのか! 単にぼくがメインOSとして FreeBSD を使っているからですな!

組み込みOSでもスレッド化でもそうなのだけど,実際にアプリ作成をやってみると,非常に新しいパラダイムというか,あーこんなプログラミング手法もあるんだなーと思う.たとえば telnet によるコマンド受付と http サーバと定期的な時計表示とデモ画面のリアルタイム動画をひとつのアプリでぜんぶいっぺんにやろうとしたらどうするか?いちばん単純なのは,signal()でSIGALRMのハンドラ登録してalarm()でタイマかけてselect()で待つ,という実装でしょう.でもたとえば telnet でのコマンド実行処理で数秒に渡ってCPUを占有してしまうような重い計算処理とか,httpサーバで処理途中に応答待ち合わせとかしたい場合とかあったらどうするのか?alarm()でタイマかけて定期的に呼んでもらうか?うまくやらないと時計は固まるしリアルタイム動画はカクカクになるし,あーめんどくせー.

でもこーいうのはもう時代遅れ!スレッド化の手法を勉強すると,プログラミングとデバッグに新しい道が見えてきます.その道が常に正しい,というわけではないけれど,ひとつの手法として,勉強する価値はおおいにありますよ!
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

えーと,いちおう書いておきますが,ここで説明していることはすべて無保証です.サンプルのソースコードやその他プログラム,各種情報などは,個人の責任の上で利用してください.

ちょっと思いついて自作OS(とは呼べないような単純なシロモノではあるが,とりあえずここではそう言っておく)というものを作成してみました.まあすでにあらかた作っちゃったので,作ったものをそのまま公開してもいいのだけれど,勉強用のOSなので,自作していく過程と言うか,発展していく過程と言うか,どんなふうに作っていったかを書いてみるのもいいかなーと思い,連載という形で思うままに書いてみます.優先度とかプリエンプティブとか再入とか排他とか,組み込みOSの基本的な動作について,KOZOSを使って試してみる(そーいうためのサンプルOSにする)という目的もあります.そんなようなことについても書いてみたい.

KOZOS(Kernel Over Zone Operating System)は教育用というか,勉強用のOSです.日本語では小僧's OS と書きます(なんだそりゃ).実体はOSというよりもスレッドライブラリのような気もするのですが,やってることはOSとだいたい同じなのでここではOSと呼んでいます.ほんとは疑似組込みOSというのが正しいでしょうか.ちなみにPC上のプロセスとして動くためリアルタイム性は無いですが,頑張れば,(構造的には)リアルタイムっぽい作りにすることもできるかも...

ご意見,要望などあれば,ぜひメールください!