【解説】12ステップで作る組込みOS自作入門 8thステップ ~システムコール~
8thステップ「スレッドを実装する」の解説記事、第3弾です。
今回はシステムコールからスレッドを作成し、ディスパッチするところまで解説します。
記事を書くために内容の整理を繰り返した結果、8thステップは
が肝だとわかりました。(ここさえ理解できれば理解すべきことは抑えられているハズ)
と第2弾の記事で書きました。
第2弾の記事は「初期スレッドの作成と起動」でしたが、実はOSによるメモリ管理の解説まで終わっています。
というのも、スタック領域をスレッドごとに確保することがOSによるメモリ管理であったためです。
つまり、後はシステムコールさえわかれば8thステップで学ぶべき内容は学ぶことができたと言えます。
目次
- 概要
- intr_syscall関数
- interrupt関数
- thread_intr関数
- syscall_intr関数
- syscall_proc関数
- call_functions関数
- thread_intr関数 (再開)
- あとがき
概要
本ページではシステムコールの流れを詳細に解説します。
割込み発生→intr_syscall関数→interrupt関数→thread_intr関数までは割込み要因に依存しない流れになっています。(重要)
今回も各関数の生存期間を表現したシーケンス図を載せておきます。
thread_intr関数はいくつかの処理を実行しているため、確認しやすいように色を付けました。
アクティビティ図 (本当はシーケンス図が良かったんだけどPlantUMLで描けない) も作成しました。*1
1つ目の条件分岐はinterrupt関数、2つ目の条件分岐はthread_intr関数です。
ざっくりした処理フローですが、伝わりやすいでしょうか?
intr_syscall関数
.global _intr_syscall .type _intr_syscall,@function _intr_syscall: mov.l er6,@-er7 mov.l er5,@-er7 mov.l er4,@-er7 mov.l er3,@-er7 mov.l er2,@-er7 mov.l er1,@-er7 mov.l er0,@-er7 mov.l er7,er1 mov.l #_intrstack,sp mov.l er1,@-er7 mov.w #SOFTVEC_TYPE_SYSCALL,r0 jsr @_interrupt mov.l @er7+,er1 mov.l er1,er7 mov.l @er7+,er0 mov.l @er7+,er1 mov.l @er7+,er2 mov.l @er7+,er3 mov.l @er7+,er4 mov.l @er7+,er5 mov.l @er7+,er6 rte
intr_syscall関数はシステムコールが発行されると (TRAP命令を発行すると) 呼ばれる関数です。
スレッドの処理を後で再開できるように汎用レジスタをスレッドのスタック領域に退避させ (4-10行) 、スレッドのスタックポインタを第2引数ER1としてinterstack関数に渡しています (11, 13行) 。
ダウン要因発生による割込みが発生すると別の関数が呼ばれますが、第1引数R0が異なるだけです。
いずれにしてもinterrupt関数が呼ばれることには違いがありません。
ちなみに後の処理でスレッドのディスパッチを行うと16行目以降は実行されません。
ディスパッチが行われないケース、例えば割込みに対する割込みハンドラが登録されていない場合には16行目以降を通ります。
ここで実行されている処理を箇条書きにしておきます。
- 汎用レジスタER0~ER6のスレッドのスタック領域に退避する (4-10行)
- スレッドのスタック領域を第2引数ER1に代入する (11行目)
- スタックポインタを割込みスタック領域に切り替える (12行目)
- 割込みスタック領域にシステムコールを発行したスレッドのスタックポインタを保存する (13行目)
- システムコールを表す値を第1引数ER0に代入する (14行目)
- interrupt関数を呼び出す (15行目)
- スタックポインタをスレッドのスタック領域に切り替える (16-17行)
- 汎用レジスタの復帰 (18-24行)
- スレッドの処理を再開する (25行目)
interrupt関数
/* * 共通割込みハンドラ * ソフトウェア割込みベクタを見て、各ハンドラに分岐する */ void interrupt(softvec_type_t type, UINT32 sp) { softvec_handler_t handler = SOFTVECS[type]; if (handler) { handler(type, sp); } }
OSから呼び出す割込みハンドラを登録している場合、handlerは必ずthread_intr関数になります。
その仕組みを別のソースコードを使って説明します。
OS側から呼ばれる割込みハンドラを登録すると、必ずSOFTVECSにthread_intr関数が登録されます。(16行目)
ちなみにOS側から呼び出す割込みハンドラはhandlersに割込み要因 (システムコール、ダウン要因発生) に対応させて登録します。(19行目)
/* ソフトウェア割込みベクタの設定 */ INT softvec_setintr(softvec_type_t type, softvec_handler_t handler) { SOFTVECS[type] = handler; return 0; } /* 割込みハンドラの登録 */ static INT setintr(softvec_type_t type, kz_handler_t handler) { static void thread_intr(softvec_type_t type, UINT32 sp); /* * 割込みハンドラを受けるためにソフトウェア割込みベクトルに * OSの割込み処理の入り口となる関数を登録する */ softvec_setintr(type, thread_intr); /* OS側から呼び出す割込みハンドラを登録 */ handlers[type] = handler; return 0; }
thread_intr関数
/* 割込み処理の入り口関数 */ static void thread_intr(softvec_type_t type, UINT32 sp) { /* カレントスレッドのコンテキストを保存する */ current->context.sp = sp; /* * 割込みごとの処理を実行する。 * SOFTVEC_TYPE_SYSCALL, SOFTVEC_TYPE_SOFTERR の場合は * syscall_intr(), softerr_intr() がハンドラに登録されているので * それらが実行される。 */ if (handlers[type]) { handlers[type](); } /* スレッドのスケジューリング */ schedule(); /* * スレッドのディスパッチ * (dispatch() 関数の本体はstartup.sにあり、アセンブラで記述されている) */ dispatch(¤t->context); /* ここには返ってこない */ }
thread_intr関数は以下の4つの処理をまとめた関数です。
- システムコールを発行したスレッドのスタックポインタを保存する (5行目)
- 割込み要因に応じた割込みハンドラを実行する (13-15行)
- スレッドのスケジューリング (18行目)
- スレッドのディスパッチ (24行目)
2つ目以外は共通処理となります。
システムコールを発行したスレッドのスタックポインタを保存する
スレッドの処理を再開するためにはスレッドのスタックポインタを保持しておく必要があります。
TCBにはスレッドのコンテキスト情報としてスタックポインタを保持できるようになっているので、ここに保存しておきます。(5行目)
割込み要因に応じた割込みハンドラを実行する
handlersには割込み要因に応じた割込みハンドラが登録されています。(interrupt関数の説明を参照)
そのため、コメントに記載の関数が実行されます。
※今回の割込み要因はシステムコールなのでsyscall_intr関数が実行されます。
スレッドのスケジューリング、スレッドのディスパッチ
処理順に説明した方が理解しやすいと思うので一旦飛ばします。
syscall_intr関数
static void syscall_intr(void) { syscall_proc(current->syscall.type, current->syscall.param); }
ただのラッパー関数だと思います。
※ラッパーは英語で書くとWrapperです。動詞、名詞はwrapで「包む」という意味です。サランラップとかでおなじみですね。ここでは関数名をわかりやすく変えたかったのだと思います。
つまり、この関数以下の処理がシステムコールの割込みハンドラだということを明示しています。
syscall_proc関数
/* システムコールの処理 */ static void syscall_proc(kz_syscall_type_t type, kz_syscall_param_t *p) { /* * システムコールを呼び出したスレッドをレディーキューから * 外した状態で処理関数を呼び出す。このためシステムコールを * 呼び出したスレッドをそのまま動作継続させたい場合には * 処理関数の内部で put_current() を行う必要がある。 */ get_current(); call_functions(type, p); }
OSの作り方はいくつかあると思いますが、KOZOSはシステムコールの処理をする前にレディーキューからカレントスレッドを抜き取ります。(10行目)
共通化するために抜いているのかもしれませんが、書籍内の機能を実装するだけだとほとんど恩恵を感じないレベルです。
その後、システムコール種別に応じた処理を行います。(11行目)
call_functions関数
static void call_functions(kz_syscall_type_t type, kz_syscall_param_t *p) { /* システムコールの実行中にcurrentが書き換わるので注意 */ switch (type) { case KZ_SYSCALL_TYPE_RUN: /* kz_run() */ p->un.run.ret = thread_run(p->un.run.func, p->un.run.name, p->un.run.stacksize, p->un.run.argc, p->un.run.argv); break; case KZ_SYSCALL_TYPE_EXIT: /* kz_exit() */ /* TCBが消去されるので戻り値を書き込んではいけない */ thread_exit(); break; default: break; } }
システムコール種別に応じて、その後の処理を切り替えています。
特に難しいことはないですね。
システムコール種別に応じた処理も書籍内で詳しく説明されているので割愛します。
thread_intr関数 (再開)
スキップしていたスレッドのスケジューリングとスレッドのディスパッチについて説明します。
スレッドのスケジューリング
/* スレッドのスケジューリング */ static void schedule(void) { /* 見つからなかった */ if (!readyque.head) { kz_sysdown(); } /* カレントスレッドに設定する (ラウンドロビン方式) */ current = readyque.head; }
スケジューリング方式がラウンドロビン方式 (先頭にあるものから順番に選択される) なので、処理は非常にシンプルです。
レディーキューの先頭にあるスレッドを次に実行するスレッド (カレントスレッド) に設定します。(10行目)
エラー処理としてスレッドが見つからなかったときにはシステムダウンするようにしているようです。(5-7行)
スレッドのディスパッチ
/* * スレッドのディスパッチ * (dispatch() 関数の本体はstartup.sにあり、アセンブラで記述されている) */ dispatch(¤t->context); /* ここには返ってこない */
カレントスレッドのコンテキスト (スタックポインタ) を渡しているのが重要です。
割込みハンドラは割込みスタックを使用していたので、各スレッドごとに用意されたスタックの再開ポイントから処理を再開する必要があります。
dispatch関数 (アセンブリ言語で記述) を載せておきますが、ここまで理解できている人には説明は要らないと思います。
.global _dispatch .type _dispatch,@function _dispatch: mov.l @er0,er7 mov.l @er7+,er0 mov.l @er7+,er1 mov.l @er7+,er2 mov.l @er7+,er3 mov.l @er7+,er4 mov.l @er7+,er5 mov.l @er7+,er6 rte
あとがき
なかなか複雑で長かったと思います。お疲れ様です。
そこそこC言語に慣れている人でも関数ポインタを利用した関数登録、実行は読みづらかったりします。
少しでも理解の助けになれていたら幸いです。
8thステップで理解すべき内容は大体書けているはずなので、よほど余力がない限りここで終わりになりそうです。(別の勉強も始めていきたいので)
要望があればstartスレッドからシステムコールを発行してcommandスレッド作成するところやスレッドの終了について書きますが…、難しかったですかね?(時間が立ちすぎていて当時の感触をかなり忘れています)
むしろ一段落したので、スライド型の説明資料を先に作り始めるかもしれません。
目に優しい理解用資料の方が読む方もうれしいですよね?