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

前回はgdbと接続できたが,continueをしても動作継続できないという問題があった.その原因はなんだろうか?

前回の最後のほうに書いたけれど,continue を実行すると

[$M8048088,1:cc#1e](+)($T0b4:5ce3bfbf;5:78e3bfbf;8:ab8b0408;#66)[+]

というgdbコマンドが発行されている.これがヒントなのだけど,どうでしょう,わかるでしょうか?

もうちょっとまわりを詳しく見てみよう.continue した直後には,以下のようなgdbコマンドが発行されている.

[$m8048088,1#3e](+)($55#6a)[+]
[$X8048088,0:#62](+)($#00)[+]
[$M8048088,1:cc#1e](+)($T0b4:5ce3bfbf;5:78e3bfbf;8:ab8b0408;#66)[+]

上のコマンド群は,こーいうふうに読みます.
  • 1行目... mコマンドで,gdbが0x8048088のアドレスから1バイトのデータを読むように要求した.
    → スタブが応答し,0x55という値を返した.
  • 2行目... Xコマンドで,gdbが0x8048088のアドレスにゼロバイトのデータを書き込むように要求した(これはgdbが,Xコマンドが使えるかのテストを行っているようだ).
    →スタブでXコマンドが実装されていないので,できませんと返した.
  • 3行目... Mコマンドで,gdbが0x8048088のアドレスに0xccという1バイトのデータを書き込むように要求した.
    → ここでとつぜんスタブ側でシグナル発生し,Tコマンドによってシグナル番号0x0b(SIGSEGV)を返している.
Xコマンドが使えるかどうかテストしたあとに,ダメだとわかってMコマンドでの書き込みに切替えている.Xコマンドはデータをバイナリで送るので効率が良いのだが,ダメだったのでMコマンドでの動作に切替えているようだ.こーいうふうにgdbは,スタブ側で実装されているコマンドを調べて,実装されていないならばもっと基本的な(しかし効率はあまりよくない)コマンドでの指示に切替える,ということをよく行う.なので,コマンドはなんでもかんでもがんばって対応させる必要は無かったりする.

で,問題は 0x8048088 に何を書き込もうとしているのかなのだけど,これを調べるには,実行形式 koz のメモリマッピングを知る必要がある.これは readelf コマンドで知ることができる.

% readelf -a ./koz

で,ELF形式とか readelf とかについて説明しようかなーとも思ったのだけど,実はこのへんはこの記事ですっげー詳しく説明されていて,まあぜひそっちを読んでください.ちなみに以下が,readelf の出力結果.結論から言ってしまうと,0x8048088 はテキスト領域だ.うーんわかりやすくいうと,実行形式の機械語命令がマッピングされている領域だ.

で,gdbが機械語命令部分になにを書こうとしているかというと,0xccという命令を書きにいっているわけなのだが,i386のアセンブラがよくわからんのでこれがなんの命令なのかがちょっとわからない.まあ調べてもいいのだけど,ここでもっと気をつけなければならないのは,

「gdbの実機制御の都合上,命令書き換えなどをgdbが勝手に行うことがある」

という事実だ.実際にリモートデバッグをいろいろやってみるとわかるのだけど,gdbはまあいろいろなことを裏でやっていて,命令書き換えなどはあたりまえのようにやっている.

たとえばブレークポイントで停止して continue するような動作について説明しよう.ブレークポイントの設定からcontinueまでの一連の動作を説明すると,なんと以下のようなことをやることになる.
  • ブレークしたい位置の命令を,(Mコマンドなどにより)トラップ命令に置き換える.
  • トラップ命令が実行されると割り込みが上がり,スタブに処理が渡る.
  • gdbでcontinueを実行する.
  • gdbはトラップ命令に置き換えた部分を元に戻し(でないと実行開始直後にまたブレークしてしまうから),CPUのステップ実行フラグ(たいていのCPUが持っていて,1命令実行するごとに割り込みが入る)を立てて実行を再開する.
  • ステップ実行フラグが立っているので,1命令実行ごとに毎回gdbに処理が渡る.gdbは処理がどこまで進んだかを見ながら実行再開を繰り返し,じりじりと実行を進める.
  • 実行がC言語のソース上での次の行(アセンブラ上の次の命令,だったかもしれない)に渡ったら,ブレークポイントを再度トラップ命令に置き換える(でないと次回ブレークできなくなってしまうので).
  • ステップ実行フラグを落とし,処理を再開する.
どうでしょう? 単なる continue でも,これだけ複雑なことをやっているのだ.まあよく考えれば当り前なのだけど,これを初めて知ったときは,gdbって頭良いなーと感心したものだ.

というわけで何が言いたいかというと,上のようにgdbが制御の都合上,裏でいろんなことをやるので,命令書き換えなどができるようになっていないとダメだということだ.ところがFreeBSD上,ていうか普通の汎用OS上では,仮想メモリが動作して機械語コード(テキスト領域)には書き込み不可のガードがかかるのが普通だ.で,書き込もうとすると segmentation fault で落ちることになる.これをなんとかしなければならない.

これをどうするかなのだけど,ここでもこの記事が出てくる.この連載中に,ELF形式を解釈してテキスト領域を書き込み可にして命令書き換えを行うという,まんまそのままの記事があるのだな.まあ自分で書いた記事なのだが,ぜひ読んでください.

で,詳しい説明は上記記事に譲るとして,ELF形式のプログラムヘッダを読んでテキスト領域を書き込み可にするツールをちょちょっと作ってみた.使いかたは実行形式を指定するだけ.で,t2w を使うように Makefile を修正したのが以下のソースコード.前回からの差分は Makefile だけで,t2wを使うようにしただけなのでまあ見ておいてほしい.前回からの差分は,上記ソースコードの diff.txt 参照.

ちなみに以下が,t2wで書き込み可にしたあとの実行形式kozの readelf の出力結果.まああんましよくわからんかもしれないが,

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x6779a 0x6779a RWE 0x1000
LOAD 0x0677a0 0x080b07a0 0x080b07a0 0x0201c 0x1629c RW 0x1000

という部分で,LOADされるひとつめの領域のフラグがRWEとなっていて,書き込み可になっていることに注目してほしい.(さっきはREだった)

では,実行してみよう.まずはgdbで接続するまでは前回と同じ.

画像はこちら

前回と同じところ(stubdのaccept()の直後の kz_break())でブレークしている.

continue してみよう.

画像はこちら

Thu Nov 8 00:28:39 2007
Thu Nov 8 00:28:40 2007
Thu Nov 8 00:28:41 2007
Thu Nov 8 00:28:43 2007
Thu Nov 8 00:28:44 2007
Thu Nov 8 00:28:45 2007
Thu Nov 8 00:28:46 2007
Thu Nov 8 00:28:47 2007
Thu Nov 8 00:28:48 2007
Thu Nov 8 00:28:49 2007

koz 側で時刻表示が始まった.gdb側も,continue のあとに待ち状態になっている.無事に continue できたようだ.

telnet接続してみよう.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> date
Thu Nov 8 00:29:38 2007
OK
>

おー,ちゃんと接続できた.dateで時刻表示もできている.

次に,down コマンドで segmentation fault を発生させてみる.

> date
Thu Nov 8 00:29:38 2007
OK
> down



画像はこちら

おー,gdb側でコマンドプロンプトが出てきた.スタブからgdbにシグナルが渡り,gdbがダウン位置のソースコードを表示している.

...というのを期待していたのだが,よく見てほしい.シグナルハンドラ内部の setjmp() の位置で停止している.考えてみたら,stubd の最初のブレークでもそうだった.

むむっっ!これは大問題だ!考えてみれば当り前のことなのだけれど,スレッドのコンテキスト保存は setjmp() によって行っているので,スタブ内部でレジスタ情報を参照したときに見えるのは,setjmp() した瞬間のレジスタ情報だ!ということは,ブレークした位置を表示させることができないということだ!

これは実は大問題で,いろいろと回避策を考えたのだが,これ以上KOZOSのgdb対応を進めるのも限界かと一時は思った程だった.この解決策は次回!
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

今回は,いよいよgdb対応だ.まずは,組み込みOSのgdb対応の原理についてちょっと説明.

組み込みOSの場合には,いわゆる「リモートデバッグ」でデバッグするのが普通だ.PC上でgdbを動作させて,実機とはシリアルケーブル経由で通信することになる.で,PCのgdb上でブレークポイント設定とかステップ実行とかすると,実機がそーいうふうに動くわけだ.こういうのをリモートデバッグという.通信はたいていシリアルケーブルで,組み込み機器だと専用のデバッグ用ポートを持っていたりするのがふつうだ.

ちなみにPC上で通常アプリをgdbデバッグするときには,デバッグ対象となるプロセスを gdb が ptrace() システムコールで操作することになる(のだと思う).これに対して,リモートデバッグの場合にはシリアルケーブル経由でgdbがコマンドを送受信し,コマンドに応じて実機が応答したり反応したりする,ということになる.で,実機側にはこれらのコマンドを解釈して言われた動作を行うための簡単なプログラムが必要になる.これを「デバッグスタブ」という.「スタブ」というのは枝とかいった意味だが,実機の本来の動作に対して枝のように分かれて処理を行うためだそうな,たしか.

ちなみにコマンドの内容だけど,あるアドレスのメモリ内容を送れとか,どこそこのアドレスに指定した値を設定しろとか,レジスタの内容を送れとかいったような,非常に簡単なものばかりだ.実際のアドレス計算とかコード解析とかの難しいことは,すべてPC上のgdbが行うので,スタブは言われた通りに動くだけの,ほんとにシンプルなものになっている.たとえば gdb 上で,変数 samp_value の値が知りたいときには

(gdb) print samp_value

とかやると出てくるけど,この "print samp_value" という文字列がスタブにそのまま送信されるわけではなくて,
  1. 変数 samp_value のアドレス(C言語風にいうなら,&samp_valueのこと)を調べる.
  2. 当該のアドレスから4バイトの値を読み出すようなコマンドをgdbが送信.
  3. 上記コマンドをスタブが受信し,指定されたアドレスから4バイトの値を読んで返す.
  4. gdb側で応答を受け取り,変数 samp_value の値として解釈し表示する.
という動作を行う.PC上のgdbが行うのは1,2,4の動作,スタブが行うのは3の動作だけだ.このようにアドレス計算などはgdb側ですべて行って,スタブ側では言われたアドレスの値を読んで返すだけ,というような簡単な動作しか行わない.(もともと組み込み用のものなので,スタブ側は最低限の機能にして,難しいことはぜんぶPC上のgdbでやる,という作りになっている)

で,スタブなのだけど,実は gdb のソースコードにサンプルが付属している.gdb のソースを適当なところから持ってきて解凍し,中を見てみると,以下の5つのファイルがある.

% ls gdb-6.7/gdb/*stub*
gdb-6.7/gdb/i386-stub.c gdb-6.7/gdb/sh-stub.c
gdb-6.7/gdb/m32r-stub.c gdb-6.7/gdb/sparc-stub.c
gdb-6.7/gdb/m68k-stub.c
%

ラッキーなことに,i386用のサンプルがある.今回はこれを移植してみよう.

スタブの移植なのだけど,基本的には以下の作業が必要.このへんはgdbのマニュアル本にも乗っている.
  1. まず,exceptionHandler() という関数を用意する.これは後述するset_debug_traps() から呼ばれていて,割り込みハンドラをスタブ専用のものにする役割がある.なので,割り込みベクタを指定されたものに書き換えるような処理を行うように実装する.
  2. putDebugChar(), getDebugChar() を用意する.これは1文字入出力に利用される.スタブ内では,通信時にはこれらの関数を呼び出すようになっているので,たとえばシリアルからの1文字入力と1文字出力みたいなのを用意する.
  3. デバッグ開始時に set_debug_traps() を呼ぶ処理を追加する.これにより,割り込みハンドラがスタブ専用のものに置き換わる.
まあこんなところかな.

基本的には,外部割り込みが入るとスタブに処理が渡り,必要に応じてビジーループでコマンド待ちになり,コマンドを受け取ると処理を行って応答を返す(ここでスタブ内から putDebugChar(), getDebugChar() が利用される),という動作をする.また,segmentation fault などの発生時にはやはりスタブに処理が渡り,gdb側にコマンド(gdb的には「シグナル」と呼ばれている)を送信してやはりビジーループに入ってgdbからの指示待ち,という感じだ.このため,リモートデバッグ動作時には,割り込みハンドラをスタブ専用のものに書き換える必要がある.これを行うのが set_debug_traps() なのだけど,実は set_debug_traps() は内部で exceptionHandler() を呼び出しているだけで,実際に割り込みベクタを書き換える exceptionHandler() は自前で用意してやらなければならない.(割り込み処理の内容は,スタブにサンプルが入っている)

まあ,スタブ自体はたいして大きくはないプログラムなので,サンプルをじっくりと読むか,もしくはKOZOSの実装を参考にするのがいいだろう.

あとスタブ実装時の注意なのだけど,基本的にスタブは他の部分とは独立している必要がある.独立しているというのはどういうことかというと,ライブラリ関数やデバイスドライバを利用するのはまずいということだ.たとえばスタブ内で memcpy() などのライブラリ関数を呼んでしまうと,デバッグ中に memcpy() 内にブレークポイントを張ったときに(これは手動で張らなくても,gdbが何らかの処理のために自動で張るかもしれない),ブレークポイントで割り込みが入ってスタブに処理が渡っても,スタブ内から再び memcpy() が呼ばれてまた割り込みが入って,...というように割り込みの無限ループになって固まってしまう.

てなわけで,スタブ内では通常のライブラリ関数は原則として呼んではいけない.memcpy()とかstrcpy()とかは思わず使ってしまいがちなので,注意が必要だ.とはいっても,スタブの処理自体はまあたいしたことはないので,これらの関数が必要ならば自分でチャチャッと作ってしまえばよいだろう.

同じ理由で,デバイスドライバを利用することも原則としてできない.というか,不可能ではないがあまりよろしくない.たとえば putDebugChar(), getDebugChar() の処理のためにはおそらくコンソールドライバが必要だが,OSが持っているコンソールドライバを横から利用するようなことは望ましくない.1文字入出力のみの貧弱なもので十分なので,専用のものを用意する必要がある.

まあ説明ばかりしていてもよくわからんだろうから,実装を説明しよう.以下,スタブを組み込んだコード.ソース公開がめんどいので,今回からひとまとめにディレクトリごと公開することにした.上記ディレクトリに前回からの差分として diff.txt と,あと main.c もあるので自由に参照してほしい.

ちなみに今回は,以下のファイルが追加されている.
  • i386-stub.c
  • stublib.h
  • stublib.c
  • stubd.c
i386-stub.c は,gdb 付属のスタブにKOZOS向けにちょっと手を入れたもの.stublib.c は,スタブ側で利用される putDebugChar(), getDebugChar() と,あとKOZOS側で使うライブラリ関数がいくつか.stubd.c はスタブの設定を行うためのデーモンだ.

では,ひとつひとつ説明していこう.

まず i386-stub.c だけど,上記ディレクトリに i386-stub.c.orig というのがあって,こちらが gdb-6.7 についてきたオリジナルになる.オリジナルからの差分を以下に示して説明...しようかと思ったのだけど,なんか今見たらほとんど変更していないのね.なのでまあ各自読んでみてください.

ちなみに1点だけ,KOZOSでは割り込みベクタの変更を行っていない.このへんに関しては最後に説明するけど,まあとりあえずそういうふうに実装していると思って読んでちょうだい.なので割り込みベクタを書き換えるための exceptionHandler() の呼び出しを,コメントにしてしまっている.割り込みベクタに関しても,

#define asm(x)

のようにしてインラインアセンブラを無効にすることで,消してしまっている.まあどうせ使わないし,コンパイル通すために一番手っ取り早かったので.

次に stublib.c だけど,こっちはちょっと説明しよう.

void exceptionHandler(int vec, void (*f)(void))
{
return;
}

まず先にも書いたけど,割り込みベクタ書き換え用の exceptionHandler() はなにもしないダミー関数になっている.まあ実は i386-stub.c からは呼ばれないように #if 0 でくくってしまってあるので不要なのだけど,いちおう用意している.

次に,1文字入出力用の putDebugChar(),getDebugChar() と,あとバッファのクリア用に clearDebugChar() というのを用意してある.

void clearDebugChar()
{
#if 1
int ret;
fd_set fds;
struct timeval tm = {0, 0};
char buf[1];

while (1) {
FD_ZERO(&fds);
FD_SET(sockt, &fds);
ret = select(sockt + 1, &fds, NULL, NULL, &tm);
if ((ret > 0) && FD_ISSET(sockt, &fds)) {
read(sockt, buf, sizeof(buf));
fprintf(stderr, "{%c}", buf[0]);
} else {
break;
}
}
#if 0
buf[0] = '+';
write(sockt, buf, 1);
#endif
#endif
}

static int getting = -1;

void putDebugChar(int c)
{
char ch = c;
write(sockt, &ch, 1);

if (getting == 1) fprintf(stderr, "]");
if (getting != 0) fprintf(stderr, "(");
fprintf(stderr, "%c", ch);
getting = 0;
if (ch == '+') {
fprintf(stderr, ")");
getting = -1;
}
}

int getDebugChar()
{
char ch;
int s;
while ((s = read(sockt, &ch, 1)) != 1)
;

if (getting == 0) fprintf(stderr, ")");
if (getting != 1) fprintf(stderr, "[");
fprintf(stderr, "%c", ch);
getting = 1;
if (ch == '+') {
fprintf(stderr, "]\n");
getting = -1;
}

return (int)ch;
}

実はKOZOSのデバッグスタブは,シリアルではなくTCP/IP経由で通信を行う.なぜそうなっているかというと,そのほうが実装がラクだから.なので putDebugChar(),getDebugChar() は,ソケットに対しての1文字入出力を行うだけだ.まあ上のコードだとログを出しているためにちょっと複雑なコードになっているが,実はログ出力の部分を省くと

void putDebugChar(int c)
{
char ch = c;
write(sockt, &ch, 1);
}

int getDebugChar()
{
char ch;
int s;
while ((s = read(sockt, &ch, 1)) != 1)
;
return (int)ch;
}

という,もんのすごく簡単な関数になってしまう.

getDebugChar()で,read()でデータが到着するまで待っていることに注意.つまり,デバッグスタブ動作時には,PC上のgdbからコマンドが送られてくるまで,ずっと待ち合わせることになる.ということは,この間,OSは固まってしまっているわけだ.なのだけど,スタブ動作時にはOS(というか実機全体)は停止して,gdbの指示待ちになるというのが正しい動作なので,これはこれでいいのだ.

int stub_init(int s)
{
sockt = s;
set_debug_traps();
return 0;
}

stub_init() は KOZOS 側から呼ばれる,スタブの初期化用の関数.内容はソケット通信用のソケットを保持し(これは putDebugChar(),getDebugChar() に利用される),あとスタブの初期化のために set_debug_traps() を呼び出している.

void stub_store_regs(kz_thread *thp)
{
memset(registers, 0, sizeof(registers));
registers[PC] = thp->context.env[0]._jb[0]; /* EIP */
registers[EBX] = thp->context.env[0]._jb[1]; /* EBX */
registers[ESP] = thp->context.env[0]._jb[2]; /* ESP */
registers[EBP] = thp->context.env[0]._jb[3]; /* EBP */
registers[ESI] = thp->context.env[0]._jb[4]; /* ESI */
registers[EDI] = thp->context.env[0]._jb[5]; /* EDI */
}

void stub_restore_regs(kz_thread *thp)
{
thp->context.env[0]._jb[0] = registers[PC]; /* EIP */
thp->context.env[0]._jb[1] = registers[EBX]; /* EBX */
thp->context.env[0]._jb[2] = registers[ESP]; /* ESP */
thp->context.env[0]._jb[3] = registers[EBP]; /* EBP */
thp->context.env[0]._jb[4] = registers[ESI]; /* ESI */
thp->context.env[0]._jb[5] = registers[EDI]; /* EDI */
}

これらはレジスタ情報を退避・復元するための関数だ.スタブ(i386-stub.c)の内部では,CPUのレジスタ情報を読み書きしたい場合にはregisters[]という配列に対して読み書きするようになっている.なので,stub_store_regs() では停止しているスレッドのレジスタ情報をregisters[]に書き込み,stub_restore_regs()ではそれを復元するようになっている.スレッドのレジスタ情報は,スレッドのコンテキストとしてsetenv()用のバッファから取得できる.たとえばgdbでレジスタの値を書き換えてcontinue,とかした場合には,stub_store_regs() によってレジスタの値がregisters[]にコピーされ,gdbからのコマンドによりregisters[]の値が変更され,さらにstub_restore_regs() によって(変更された)レジスタ値がスレッドのコンテキストに戻される(そして次回のディスパッチ時にその値がレジスタに入り,動作を再開する)ということになる.

int stub_proc(kz_thread *thp, int signo)
{
gen_thread = thp;

stub_store_regs(gen_thread);

clearDebugChar();
handle_exception(signo);

stub_restore_regs(gen_thread);

return 0;
}

stub_proc()はKOZOS側からスタブの処理を呼び出すための入り口の関数だ.stub_store_regs()によってカレントスレッドのレジスタ値を退避,clearDebugChar() でソケットに溜っているデータをいったんフラッシュして,handle_exception()によってスタブの処理を呼び出している(handle_exception()はi386-stub.cにあり,スタブ処理の中核となっている).で,スタブ処理が終ったら stub_restore_regs() でレジスタ情報を復帰して戻る.

ちなみに handle_exception() を呼び出したあとなのだけど,gdb側からコマンドによる何らかの指示(continueしろ,とか)が無い限り,戻ってこない(getDebugChar() が read() でブロックする作りになっていたことを思い出してほしい).上でも説明したけど,スタブに処理が渡ったときは実機全体は停止してgdbの指示待ち,というのが正しい動作なので,これはこれでいい.(なぜそうしなければならないかというと,たとえばgdbが動作している最中に勝手に実機が処理を進めてメモリの値を書き換えてしまったりすると,gdb側と実機側で整合性がとれなくなるから.なので,gdbから「動いていいよ」と言われるまではじっと待つ必要があるのだ)

あとスタブを動作させるために,今回は stubd.c というデーモンを新規作成している.これは何をするかというと,スタブとgdbが通信するためのTCP/IPソケットをオープンし,そのソケットをKOZOSに(専用のシステムコールを使って)教える,というだけのことをする.これくらいのことはKOZOS内部で行ってもいいような気もするが,こーいうのもぜんぶデーモンにすることでKOZOSコアの外に出してしまうというのがKOZOSの特徴でもある.

stubd.c は以下のような感じだ.

int stubd_main(int argc, char *argv[])
{
...
sockt = socket(AF_INET, SOCK_STREAM, 0);
...
s = accept(sockt, (struct sockaddr *)&address, &len);

kz_debug(s);
...
kz_break();
...

ソケットをオープンして accept() したあとには,kz_debug()という今回新設したシステムコールでデバッグ用ソケットをKOZOSに教えてやり,kz_break()で強制ブレークを行う.

次に,KOZOS側の修正を見てみよう.以下は thread.c の修正だ.

+static int thread_debug(int sockt)
+{
+ debug_sockt = sockt;
+ stub_init(sockt);
+ putcurrent();
+ return 0;
+}
+
static void *thread_memalloc(int size)
{
putcurrent();
@@ -357,6 +367,9 @@
case KZ_SYSCALL_TYPE_SETSIG:
p->un.setsig.ret = thread_setsig(p->un.setsig.signo);
break;
+ case KZ_SYSCALL_TYPE_DEBUG:
+ p->un.debug.ret = thread_debug(p->un.debug.sockt);
+ break;
case KZ_SYSCALL_TYPE_MEMALLOC:
p->un.memalloc.ret = thread_memalloc(p->un.memalloc.size);
break;
@@ -397,7 +410,9 @@
case SIGSEGV:
case SIGTRAP:
case SIGILL:
- {
+ if (debug_sockt) {
+ stub_proc(current, signo);
+ } else {
fprintf(stderr, "error thread \"%s\"\n", current->name);
/* ダウン要因発生により継続不可能なので,スリープ状態にする*/
getcurrent();
@@ -468,4 +483,13 @@

/* ここには返ってこない */
abort();
+}
+
+void kz_break()
+{
+ /*
+ * スタブ付属の breakpoint() でうまくブレークできないので,
+ * トラップシグナルを上げてブレークする.
+ */
+ kill(getpid(), SIGTRAP);
}

kz_debug()を呼び出すと,引数として渡されたソケットを debug_sockt に保存する.さらに segmentation fault などが発生すると,stub_proc() に処理を渡す.これによりスタブに処理が渡り,あとはgdbから操作できるようになるわけだ.ちなみに kz_break() は強制ブレークするためのサービス関数で,SIGTRAP を発行することでやはり stub_proc() に処理を渡す.強制ブレークに関しては i386-stub.c で breakpoint() という関数が用意されているのだけど,なんかうまく動かないので kz_break() を利用している,...と今書いてて気がついたのだけど,i386-stub.c の breakpoint() はBREAKPOINT() を呼び出していて,これが実は

#define BREAKPOINT() asm(" int $3");

のようにインラインアセンブラになっているのだけど,アセンブラを無効化するために asm() を空に #define しているので,そりゃ効かないのがあたりまえだ.あちゃー.まあそのうちなんかいじってみよう.

以下は main.c の修正.

int mainfunc(int argc, char *argv[])
{
extintr_id = kz_run(extintr_main, "extintr", 1, 0, NULL);
- outlog_id = kz_run(outlog_main, "outlog", 2, 0, NULL);
+ stubd_id = kz_run(stubd_main, "stubd", 2, 0, NULL);
+ outlog_id = kz_run(outlog_main, "outlog", 3, 0, NULL);
idle_id = kz_run(idle_main, "idle", 31, 0, NULL);
clock_id = kz_run(clock_main, "clock", 7, 0, NULL);
telnetd_id = kz_run(telnetd_main, "telnetd", 8, 0, NULL);

stubd を起動するように修正している.stubd は最初のほうで高い優先順位で起動し,いきなりaccept()で待つので,まずはgdbで接続しないと他のスレッドは動作しない,という作りになっている.デバッグしたいときはまず最初にデバッガを繋げ,ということだ.

ついでに telnetd にもちょっと修正を入れている.

if (!strncmp(buffer, "echo", 4)) {
write(s, buffer + 4, strlen(buffer + 4));
- } else if (!strncmp(buffer, "break", 5)) {
+ } else if (!strncmp(buffer, "down", 4)) {
int *nullp = NULL;
*nullp = 1;
+ } else if (!strncmp(buffer, "break", 5)) {
+ kz_break();
} else if (!strncmp(buffer, "date", 4)) {
time_t t;
t = time(NULL);

従来の break コマンドは down に改名し,down 実行で segmentation fault 発生,break 実行で強制ブレークを行うようにしてある.

ふう,ようやくひととおり説明した.まあちょっと急ぎ足で説明を終らせてしまったが,サンプルコードもあることだし,あとはじっくりと読んでほしい.リモートデバッグに関しては,実装に関する資料もサンプルコードもあまり無いので,まずは手前味噌だけどKOZOSの実装をじっくりと読むのが良いと思う.

ではいよいよ,実行してみよう.

% ./koz
(この状態で停止)

実行しただけだと,gdbからの接続待ち(stubd.cのaccept()している部分)になっているので,なにも起きずに停止している.

この状態で,gdbで接続してみよう.で,ここでgdbを直接起動してもいいのだけど,gdbはemacsから利用するのが圧倒的に便利で使いやすい.これはほんとにおすすめ.なのでまずは

% mule -nw

で,emacs を起動する.

画像はこちら

Esc x gdb でgdbモードに入る.gdbはFreeBSD付属のものを使えばよい(i386用なので).gdbの引数には,実行形式の koz を指定する.

画像はこちら

gdbが起動したところ.

画像はこちら

ここで,リモートデバッグとして,ターゲットにTCP/IP接続を指定する.ポート番号は説明するのを忘れたが,stubd.c で 10001 にしてあるのでそれを指定する.

画像はこちら

おーすげえ,ソースコードが出てきた.

% ./koz
($T054:0c0b0e08;5:280b0e08;8:ab8b0408;#fd)[+]
[$Hc-1#09](+)($#00)[+]
[$qC#b4](+)($#00)[+]
[$qOffsets#4b](+)($#00)[+]
[$?#3f](+)($S05#b8)[+]
[$Hg0#df](+)($#00)[+]
[$g#67](+)($000000000000000000000000010000000c0b0e08280b0e0804e8bfbf00000000ab8b040800000000000000000000000000000000000000000000000000000000#d6)[+]
[$m80e0b30,4#8f](+)($05000000#85)[+]
[$qSymbol::#5b](+)($#00)[+]
(この状態で停止している)

これはgdbとスタブ間での通信の内容.通信内容は標準エラー出力にそのまま垂れ流している.これがgdbコマンドってやつだ.ちなみに[]でくくられているのがgdbからスタブに送信されたコマンドで,()でくくられているのがスタブからgdbに送信されたコマンドになる.

今は,stubd が起動時にデバッグ用ソケットの accept() に成功し,直後の kz_break() で停止している状態だ.なのでKOZOSの起動処理を先に進めるために continue してみよう.

画像はこちら

あれれれれ,また停止してしまった.繰り返しcontinueしてもダメ.

なんか,SIGSEGVで停止しているって言われているね.

hiroaki@teapot:~/kozos10>% ./koz
($T054:0c0b0e08;5:280b0e08;8:ab8b0408;#fd)[+]
[$Hc-1#09](+)($#00)[+]
[$qC#b4](+)($#00)[+]
[$qOffsets#4b](+)($#00)[+]
[$?#3f](+)($S05#b8)[+]
[$Hg0#df](+)($#00)[+]
[$g#67](+)($000000000000000000000000010000000c0b0e08280b0e0804e8bfbf00000000ab8b040800000000000000000000000000000000000000000000000000000000#d6)[+]
[$m80e0b30,4#8f](+)($05000000#85)[+]
[$qSymbol::#5b](+)($#00)[+]
(ここでcontinueを実行)
[$Z0,8048088,1#87](+)($#00)[+]
[$m8048088,1#3e](+)($55#6a)[+]
[$X8048088,0:#62](+)($#00)[+]
[$M8048088,1:cc#1e](+)($T0b4:5ce3bfbf;5:78e3bfbf;8:ab8b0408;#66)[+]
[$vCont?#49](+)($#00)[+]
[$Hc0#db](+)($#00)[+]
[$c#63](+)($T0b4:5ce3bfbf;5:78e3bfbf;8:ab8b0408;#66)[+]
[$M8048088,1:55#c2](+)($T0b4:5ce3bfbf;5:78e3bfbf;8:ab8b0408;#66)[+]
[$mbfbfe380,4#5d](+)($0a000000#b1)[+]
(この状態で停止している)



さて,continue しても先に進まない,この原因はなんだろうか?実は上の通信内容を解析するとこの答えはわかるのだけど,とは言ってもgdbコマンドなんてあんましよくわからんよね.ヒントは上の

[$M8048088,1:cc#1e](+)($T0b4:5ce3bfbf;5:78e3bfbf;8:ab8b0408;#66)[+]

という部分なのだが...

ということで,これについては次回に解説.

ところで,今回はスタブを動作させるためにKOZOSを通すような実装にした.具体的に言うと,SIGTRAPやSIGSEGV発生時には KOZOS の割り込みハンドラが呼ばれ,KOZOS側から stub_proc() が呼ばれることでスタブに処理が渡された.しかし本当は,スタブの実装は,OSも一切介さないのが正解だ.というのは,そうすればOSのデバッグにも使えるからだ.なので set_debug_traps() で割り込みベクタを書き換えて,OSを一切通さずに割り込みの延長で完全に独立して動作するというのが正しいのだけど,まあとりあえず安易にこんな感じで実装してみた.

KOZOSの場合は,割り込みベクタの書き換えは signal() による設定上書きに相当する.現状では割り込み(=シグナル)のハンドリングはKOZOSが行っており,KOZOS側からスタブを呼び出しているので,割り込みベクタをスタブ用に登録する必要は無く,このため exceptionHandler() は空関数になっている.しかし本来なら,exceptionHandler() で signal() によってシグナルハンドラを上書きし,スタブが直接呼ばれるようにするのが正解だろう.ここまでやればKOZOS内部のデバッグにも使えるような気もするが,まあとりあえずはこれでいいだろう.それよりも問題や改善点がまだまだいっぱいあるので,まずはそっちを直さにゃならん.
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

えーと本当はgdb対応をやろうかと思っていたのだけど,その前にスレッドのダウンについてちょっと説明.

現状,たとえばどれかのスレッドが不正アドレス参照とかでsegmentation fault とか bus error とかになった場合には,KOZOS全体が落ちてしまう.

しかしこれらのエラー時にはシグナルが発行されるので,それを割り込みとしてとらえて,当該のスレッドだけ落とす,という動作ができるはずだ.

で,そーいうふうに修正してみた.以下は前回からの差分.

今回の修正は少なめだ.まず thread.c だが,実は SIGSEGV とかを受け取ったときにはスレッドをスリープするような処理が既に入っている.これは thread_intrvec() の以下の部分だ.

case SIGBUS: /* ダウン要因発生 */
case SIGSEGV:
case SIGTRAP:
case SIGILL:
{
fprintf(stderr, "error %s\n", current->name);
/* ダウン要因発生により継続不可能なので,スリープ状態にする*/
getcurrent();
}

getcurrent()によりカレントスレッドをスリープ状態にして,あとはそのままなので,SIGSEGVなどを出したスレッドがスリープ状態になってあとは他のスレッドがディスパッチされて動き続けることになる.

ただ,スリープさせてもとくにすることはないし,UNIXでも segmentation fault が発生したらプロセスをダウンさせてしまうのが普通なので,スレッドを終了させてしまうように修正しよう.

case SIGBUS: /* ダウン要因発生 */
case SIGSEGV:
case SIGTRAP:
case SIGILL:
{
- fprintf(stderr, "error %s\n", current->name);
+ fprintf(stderr, "error thread \"%s\"\n", current->name);
/* ダウン要因発生により継続不可能なので,スリープ状態にする*/
getcurrent();
+#if 1 /* スレッド終了する */
+ thread_exit();
+#endif
}

あとKOZOSの起動時に,エラー関連のシグナルを受信できるように設定しておく.

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));
memset(sigcalls, 0, sizeof(sigcalls));

timers = NULL;

signal(SIGSYS, thread_intr);
signal(SIGHUP, thread_intr);
signal(SIGALRM, thread_intr);
+ signal(SIGBUS, thread_intr);
+ signal(SIGSEGV, thread_intr);
+ signal(SIGTRAP, thread_intr);
+ signal(SIGILL, thread_intr);

KOZOSに対する修正はこれだけだ.これだけで,不正アドレス参照などでのsegmentation fault 発生時に,当該のスレッドを終了させることができるようになる.

実験用に segmentation fault を手動で発生させることができるように,telnet に break コマンドというのを追加しよう.

diff -ruN kozos08/telnetd.c kozos09/telnetd.c
--- kozos08/telnetd.c Sun Nov 4 11:27:46 2007
+++ kozos09/telnetd.c Sun Nov 4 11:27:46 2007
@@ -13,6 +13,7 @@
#define PORT 20001

int telnetd_id;
+int telnetd_dummy;

static int command_main(int s, char *argv[])
{
@@ -41,6 +42,9 @@

if (!strncmp(buffer, "echo", 4)) {
write(s, buffer + 4, strlen(buffer + 4));
+ } else if (!strncmp(buffer, "break", 5)) {
+ int *nullp = NULL;
+ *nullp = 1;
} else if (!strncmp(buffer, "date", 4)) {
time_t t;
t = time(NULL);
@@ -51,7 +55,7 @@
int i;
for (i = 0; i < THREAD_NUM; i++) {
thp = &threads[i];
- if (!thp->id) break;
+ if (!thp->id) continue;
write(s, thp->name, strlen(thp->name));
write(s, "\n", 1);
}

telnet 接続して break コマンドを入力した場合には,NULLポインタアクセスでsegmentation fault が発生する.ついでに threads コマンドで,終了したスレッドがあるとそこで表示が終了してしまうバグを修正.あと telnetd_dummy はこのあとの gdb 対応のためのものなので,まあ気にしないで.

メインの関数は以下になる.実行してみよう.

% ./koz
Sun Nov 4 11:38:10 2007
Sun Nov 4 11:38:11 2007
Sun Nov 4 11:38:12 2007
Sun Nov 4 11:38:13 2007
Sun Nov 4 11:38:15 2007
...

時刻表示は正常に動作している.

telnetで接続してみよう.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> date
Sun Nov 4 11:38:34 2007
OK
>

もうひとつ,追加で telnet 接続する.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> date
Sun Nov 4 11:38:45 2007
OK
> threads
command
extintr
outlog
idle
clock
telnetd
httpd
command
OK
>

threads によるスレッド一覧表示で,command スレッドが2つ表示されていることに注目してほしい.2箇所から telnet 接続しているので,command スレッドが2つ起動しているわけだ.

ここで,片方の telnet 接続で break コマンドを実行してsegmentation fault を発生させてみる.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> date
Sun Nov 4 11:38:34 2007
OK
> break


Sun Nov 4 11:39:14 2007
Sun Nov 4 11:39:15 2007
Sun Nov 4 11:39:16 2007
Sun Nov 4 11:39:17 2007
error thread "command"
Sun Nov 4 11:39:18 2007
Sun Nov 4 11:39:19 2007
Sun Nov 4 11:39:20 2007
Sun Nov 4 11:39:21 2007

command スレッドでエラー発生したというログが出力され,時刻表示は継続している.segmentation fault が発生しても,別スレッドである時刻表示は動き続けているという点に注目.

もう1箇所の telnet 接続で,threads コマンドによってスレッド一覧を確認してみよう.

> threads
extintr
outlog
idle
clock
telnetd
httpd
command
OK
>

command スレッドがひとつだけになっている.つまり,segmentation fault を出したスレッドだけ終了して,他のスレッドはそのまま動き続けているわけだ.まあ本来ならばスレッド終了時にソケットのクローズとかを行うべき(なので,telnet している側が固まってしまう)なのだけど,まあ実験なのでとりあえずよしとする.

こんな感じで,segmentation fault や bus error の発生時には,当該のスレッドだけ落として処理を継続することができる.うーん,OSっぽい.他にもゼロ除算とかでも応用できるだろう.

ていうか,OS上で動くプロセスのレベルでも,ここまでできるんだなあ...我ながらちょっと関心.segmentation fault が起きたらcoreダンプして落ちてあとはgdbでデバッグ,というのが普通の手順だけど,こーいう対処もできるということだ.これってちょっと便利なんではなかろうか?なんか応用できないかなあ.他のスレッドライブラリとかって,このへんの動作はどうなっているんだろうか?pthreadとか.誰か知ってたら教えて.

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

さいきんいろいろ忙しくて前回からちょっと間が開いてしまったが,文化の日なのでちょっと文化的(工学的,か?)なことをしたい.ということで連載再開.

で,前回までの話なのだけど,前回にも書いたが現状の実装では以下の欠点がある.
  • スレッド内で無限ループに入ると,extintr の select() が呼ばれなくなってしまうので,OS全体が固まる.
  • 無限ループ中になんとか抜けるには現状でタイマ割り込みしか無いが,たとえタイマ割り込みがかかったとしても,extintr のスレッド優先度が31と最低(これは,select()待ちをするため)なので,他スレッドが無限ループしている限りは extintr が動けないので,外からの入力を処理できない.
ちなみに現在の extintr の役割は,外からの入力を受け付けて当該のスレッドにメッセージを投げることだ.で,そのためには select() で入力を確認する必要があり,ついでにふだんはその select() で待つことで,アイドル状態でのCPU負荷を下げる,という設計になっている.

で,この解決策なのだけど,まず現状の設計のように select() で外からの入力を待つ場合には,スレッド優先度を(extintrの31みたいに)下げざるを得ない(でないと他のスレッドが動けなくなってしまうので).しかしそれだと,今回のような問題が起きる.なので,
  • extintr のスレッド優先度を最優先にする.これにより,他スレッドが無限ループに入っても,なにかきっかけがあってOSに処理が渡れば,extintrが動くことができる.で,extintrが動ければ,extintrから各スレッドにメッセージが投げられるので,各スレッドも受信処理を行える.
  • extintr のスレッド優先度が最優先の場合,extintr 内で select() で待つとOS全体が固まってしまうのでダメ.よってCPU負荷を下げるためのスレッドを,idleスレッドとして別途作成する.
という設計が考えられる.つまり現状で extintr がやっている,外部入力の処理とCPU負荷を下げるための select() 待ちを,別々に分離するわけだ.

問題は,たとえ extintr のスレッド優先度を上げたところで,外部入力があったときに extintr に処理を渡すようななんらかのきっかけが無いと,やはり無限ループを救うことができない.で,どうするかなのだが,2つの実装が考えられる.
  • タイマ割り込みにより,定期的に extintr を動かす.
  • 外部からシグナルを発行する.(このシグナルが,extintr に処理を渡すきっかけになる)
まあどちらにしろ,シグナルを使うことになる(タイマ割り込みは要するにSIGALRMなので).1番目は,kz_timer() によって定期的に extintr にメッセージ発行するようなスレッドを別途作成し,extintr はメッセージ受けたら待ち時間無し(ゼロ秒でタイムアウトする)の select() で外部入力の有無を調べ,入力があれば処理する,という実装だ.

2番目はちょっとわかりにくいのだけど,外部入力の有無を他人に調べてもらい,外部入力があるのならばシグナルを発行してもらうというものだ.後述するけど,これだと非常に外部割り込みっぽくなってそれっぽくていいなーと思う.で,問題はその他人をどうするかなのだが,ちょっとどうしようか考えたのだけど,fork()で専用の子プロセスを作成し,select()で見張っていてもらうというのはどうだろうか.上述したように,idleスレッドを作成したとしても,外部入力があったときにextintr に処理を渡すようななんらかのきっかけが無いと,idleスレッド内のスリープや他スレッド内の無限ループから抜けられない.このためのきっかけとして,子プロセスからシグナルを発行してもらうわけだ.問題は子プロセス側で select() するために,親プロセス側のソケットを引き継ぐ必要があるのだが,ふつうに fork() してソケットって引き継げるものなのだろうか?と思ったのだけど,考えてみれば標準入出力とかパイプとかは引き継げるし,ためしてみたら引き継げるみたいだったので,まあとりあえずやってみてから考えればいいだろう.

1番目はデバイスドライバがポーリングするイメージ.2番目はCPU外部の割り込みコントローラから割り込みを上げてもらうようなイメージに近いだろうか.ちなみに1番目の実装だと,kz_timer()使って専用のスレッドをひとつ上げるだけなので,現状の作りから簡単に修正できる.ただポーリングになるので応答が鈍そうだし,ちょっとカッコ悪いな~~~,なんだかな~~~,って気がする.で,2番目の実装なのだけど,子プロセスが入力を監視してシグナルを上げてくるってのは,まるで割り込みコントローラがCPUとは独立して動いて割り込み線をアサートしてくるみたいでそれっぽい.応答も鈍くなることは無いし,まあもともとKOZOSは勉強用に実際の組み込みOSを模擬する,という目的があるので,今回は2番目の実装を採用.

で,書いてみた.今回はアイドル用のスレッドとして,idle.c が追加されている.以下は前回からの差分.今回の修正のキモは,extintr.cの改造だ.以下に説明していく.

まずメインループだが,

int extintr_main(int argc, char *argv[])
{
...

pid = getpid();
kz_setsig(SIGHUP);

先頭で,kz_setsig()というシステムコールを呼び出している.これは今回新設したシステムコールなのだけど,以下の動作をする.
  • 指定したシグナルが発行されたときに,自スレッドにメッセージを送らせる.
まあこれについては,あとでまた説明する.

で,extintrのメインループなのだが,

while (1) {
fd = kz_recv(&id, &p);
if (id == 0) { /* from kozos */
fds = readfds;
ret = select(maxfd + 1, &fds, NULL, NULL, &tm);
if (ret > 0) {
for (ibp = interrupts; ibp; ibp = ibp->next) {
if (FD_ISSET(ibp->fd, &fds)) {
switch (ibp->type) {
case INTR_TYPE_ACCEPT:
...
case INTR_TYPE_READ:
...
}
...
}
}
}

ここで,メッセージを受けたときに select() で外部入力状態を確認し,入力があるなら当該スレッドにメッセージを投げる,という動作をしている.まあこのへんの処理は実は前回とほぼ同じなのだが,select()のタイムアウト値がゼロになっているので,即時抜けしている点に注意してほしい.

extintrのこのへんの動作はSIGHUPを見なければならないので本来はKOZOSの内部で行うべきなのかもしれないが,シグナル発生をメッセージで通知する kz_setsig()というシステムコールを実装し,基本的にはメッセージドリブンで専用スレッド(extintr)にやらせる,というのが,KOZOSの特徴的なポリシーではある.このためKOZOSのコア部分である thread.c は,非常にシンプルなもの(スレッド管理とシステムコールだけ)になっている.まあ将来的にはタイマの管理も専用スレッドに切り出してしまいたい(kz_setsig()が新設されたので,これも可能なはずだ).

あとextintrが動作するのはメッセージを受けたときなのだけど,じゃあ誰がいつメッセージを投げるのかというと,先ほど先頭で kz_setsig() でSIGHUP が発生したときにメッセージが投げられるように設定しているので,SIGHUP 発生時にはOSからメッセージがとんでくることになる.つまり,子プロセスは以下のように動作すればいい.
  • select()で入力を監視し,入力があれば親プロセスにSIGHUPを投げる


で,extintrの続き.

} else if (fd) { /* from thread */
setintrbuf(fd, id, (struct sockaddr *)p);
/*
* ソケットを子プロセスに引き継ぐ必要があるので,子プロセスを
* 毎回作りなおす.
*/
if (chld_pid) {
write(cnt_fd, "exit", 5);
wait(NULL);
close(cnt_fd);
}
pipe(fildes);
if ((chld_pid = fork()) == 0) { /* 子プロセス */
#if 0
signal(SIGALRM, SIG_IGN);
#endif
close(fildes[1]);
intr_controller(fildes[0]);
}
close(fildes[0]);
cnt_fd = fildes[1];
}
}
}

ここが今回の改造のキモになる部分だ.従来の extintr は,他スレッド(telnetdとか)からメッセージを受けたら,そのソケットを見張るように select() 用の readfds に追加する,という動作をしていた.今回は,select() で見張るための子プロセスを作成する,という動作をしている.

注意として,子プロセスには現在のソケットをすべて引き継がせなければならない(子プロセス側では select() でソケットを見張る必要があるので).なので,毎回子プロセスを終了させては起動させなおす,ということをやっている.(子プロセスにパイプ経由で exit コマンドを送ることで子プロセスを終了させ,fork()で再度起動している)

また子プロセスとの同期のために,通信をするためのパイプを作成している.まあこのへんはネットとかで fork とか pipe とかプロセス間通信とかで検索すると山のように出てくるので,各自で調べてほしい.

実際の子プロセスは以下になる.

static int intr_controller(int cnt_fd)
{
int fd, ret, size;
fd_set fds;
char buf[32];
char *p;

/*
* 子プロセスなので,この中で kz_ システムコールを利用してはいけない.
*/

FD_SET(cnt_fd, &readfds);
if (maxfd < cnt_fd) maxfd = cnt_fd;

while (1) {
fds = readfds;
ret = select(maxfd + 1, &fds, NULL, NULL, NULL);
if (ret > 0) {
if (FD_ISSET(cnt_fd, &fds)) {
size = read(cnt_fd, buf, sizeof(buf));
if (size > 0) {
for (p = buf; p < buf + size; p += strlen(p) + 1) {
if (!strcmp(p, "exit"))
goto end;
fd = atoi(p);
FD_SET(fd, &readfds);
}
}
} else {
kill(pid, SIGHUP);
for (fd = 0; fd <= maxfd; fd++) {
if ((fd != cnt_fd) && FD_ISSET(fd, &fds)) {
FD_CLR(fd, &readfds);
}
}
}
#if 0
/*
* 繰り返しシグナルが発生することの防止.
* 本来なら割込み処理側からメッセージを送ってもらって,
* ソケットごとに割込みの有効化/無効化を行う必要があるだろう.
* (シグナル送信したら無効化し,割込み処理が行われたら
* メッセージを送ってもらって割込みを有効化する)
*/
usleep(1000);
#endif
}
}

end:
close(cnt_fd);
exit(0);
}

子プロセスは以下の動作をする.
  • select()で待ち,外部入力があればSIGHUPを発行する.
  • SIGHUP発行時には,入力があったソケットをいったん select() 対象から外す.で,親プロセスからソケット追加の指示があったときには再び select() 対象に入れる.
  • 親プロセスから exit コマンドが来たときには終了する.
上記2番目の動作は,親プロセスが入力の受信をもたついているときに子プロセスが毎回入力を感知して SIGHUP が上がりまくる(そして親プロセスは,SIGHUPの処理のためにさらにもたつく)という動作を防止するためだ.このため,入力があったらそのソケットを監視対象から除外し,親プロセス(extintr)側でソケットを読み取ったら再び監視対象に追加(このやりとりのためにパイプを利用している),という動作をしている.このへんの動作も,割り込み処理時に割り込みコントローラ側のレジスタをCPU側で刈り取っている(ちょっと動作は違うが,似てはいる)みたいで,ちょっとそれっぽいね.実はこの対策として,初期のころはusleep()でちょっと待つような暫定処理が入っていたのだけど(待っている間に親プロセスが動作してソケットをリードすることを期待している),この刈り取り処理が入ったので不要になったため,現在は #if 0 で無効になっている.

3番目のexitコマンドについてだが,上にも書いたがソケット情報を引き継ぐ必要があるので,監視対象のソケットが増えるたびに子プロセスは起動し直している.このため親側では子プロセスを終了させる必要があるのだが,まあこれは kill() してもいいのだけど,せっかくパイプを作成しているので,exit コマンドというので終了させるようにしてみた.

あと,アイドルスレッドについて.

int idle_main(int argc, char *argv[])
{
while (1) {
select(0, NULL, NULL, NULL, NULL);
}
}

アイドルスレッドは単に select() で無限待ちをするだけ.これが最低の優先度で起動するので,なにもないときにはプロセスはスリープしてCPU負荷を下げる,という動作をしている.で,外部入力があったときには子プロセスがSIGHUPを送ってくるし,タイマがかかったときにはSIGALRMが発行されて,KOZOSに処理が渡る,という動作になっている.SIGHUPが来ればextintrに対してメッセージが(これは,kz_setsig() で設定してたから)発行され,extintrが(idleよりも優先度高いから)動ける.で,extintrがソケットからデータをリードできれば,そのデータをメッセージで当該スレッドに送信するので,今度はそのスレッドが(これも,やはりidleよりも優先度高いから)動作できる.うーん,きれいな作りだ.

ついでに kz_setsig() についてちょっと説明しておこう.

static int thread_setsig(int signo)
{
sigcalls[signo] = current;
putcurrent();
return 0;
}

static void extintr_proc(int signo)
{
if (sigcalls[signo])
sendmsg(sigcalls[signo], 0, 0, NULL);
}

static void thread_intrvec(int signo)
{
switch (signo) {
...
case SIGHUP: /* 外部割込み */
break;
...
}
extintr_proc(signo);
dispatch();
longjmp(current->context.env, 1);
}

割り込みベクタにSIGHUPを追加し,さらに割り込み処理後に extintr_proc() を呼ぶように修正している.extintr_proc() では,シグナルの種類に応じてスレッドに空メッセージを投げている.

ちなみに telnetd.c,httpd.c に対してもちょっと修正が入っている.ソケットのクローズを close() でなく shutdown() にしないとなんかうまく telnet とかを終了できなくなっちゃった(子プロセス側でソケットを select() しているせい?前回は問題なかったのに...詳細不明)のでそうしたのと,あとついでに telnet での Ctrl-C,Ctrl-D に対応させてある.まあこのへんは実際のソースを見てほしい.

メインの関数は以下になる.実行してみよう.

% ./koz
Sat Nov 3 09:03:01 2007
Sat Nov 3 09:03:02 2007
Sat Nov 3 09:03:03 2007
Sat Nov 3 09:03:04 2007
Sat Nov 3 09:03:05 2007
Sat Nov 3 09:03:06 2007
Sat Nov 3 09:03:07 2007
Sat Nov 3 09:03:08 2007
Sat Nov 3 09:03:09 2007
Sat Nov 3 09:03:10 2007
Sat Nov 3 09:03:12 2007
Sat Nov 3 09:03:13 2007
Sat Nov 3 09:03:14 2007
Sat Nov 3 09:03:15 2007
...

おー,ちゃんと動いている.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> date
Sat Nov 3 09:03:12 2007
OK
> Connection closed by foreign host.
%

telnetも問題無しだ.

ちなみに今回は,外部入力を子プロセスで監視してSIGHUP割り込みを上げる,という実装にしてあるので,while(1)で無限ループするようなスレッドがいても,それより優先度の高いスレッドは正常に動作できる(もちろんそれより優先度の低いスレッドは動作できないが,優先度の高いビジーなスレッドが存在しているためなので,これはこれで正しい).このへんは組み込みOSの重要な要素なので,ぜひ,各自確認してみてほしい.

さて,今回でかなり組み込みOSっぽく動くようになった.無限ループのスレッドがあっても,きちんと動くようになった.次はKOZOS作成の目的のひとつである,gdb対応をぜひやってみたい.
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

そろそろOSとしてもそこそこ動くようになってきたので,単なる実験用のサンプルプログラムでなく,ちょっとした実用的なそれっぽいサービスを動かしてみたい.ということで,今回は telnetd と httpd と,あと時計アプリ(1秒おきに時刻を表示するだけだけど)を動かしてみよう.

で,実装なのだけど,ソケット開いて bind() して accept() して単に select()するだけ,ってだけでは芸が無い.サーバアプリを2つも動かすのだから,ソケットの select() 部分はうまく共通化したい.

とりあえず簡単に思い付くのは,kz_timer()使って一定時間ごとに select() でソケットの状態をポーリングして,受信データがあるならば,当該のスレッドに kz_send() でメッセージを送る,というものだ.しかしポーリングするというのはいまいちなあ...ちなみに前回まででとくに言及はしなかったのだが,main.c の内部ではwhile (1) で待ってしまっていたため,CPU負荷がやたら高くなってしまう,という問題があった.なにもやることが無いときには while (1) に入ってきて,無限ループをえんえんと繰り返しているということだ.

せっかく select() を使うならば,ソケット待ちでスリープすることでCPU負荷を一気に下げたいところだ.ということで,こーいう実装はどうだろう.
  • ソケット監視スレッド(extintr)を作成し,select()によってソケットを監視させる.
  • 各スレッドは,extintr に見張ってほしいソケットをメッセージによってextintr に通知する.extintr は accept() 待ちと read() 待ちを select() することができる.
  • extintr はタイムアウト無限で select() する.このため,受信データが無い場合にはプロセスはスリープしているのでCPU負荷は上がらない.
  • データが到着したら select() から抜け,read() もしくは accept() を行い,当該のスレッドに受信データをメッセージで投げる.で,各スレッドは受信データに対して適切な処理を行う.
  • extintr に無闇に処理が渡ってしまうと,select()無限待ちに入って他スレッドが固まってしまうことになる(ていうかKOZOS全体が固まる).これを防ぐために,extintr は優先度を最低にしておく.(extintr に処理が渡るときは,他のスレッドはすべてスリープ状態にある場合なので,これで問題無い)
  • タイマ割り込み発生時には,SIGALRMによって select() は抜ける.この場合,タイマを仕掛けているスレッドにKOZOSからメッセージが渡され,そのスレッドが動作を終えると,extintr は再び select()無限待ちに入ることになる.
ほんとは外部割り込みっぽく実装してみたいのだけど(このため extintr という名前になっている),とりあえずサーバ動かしてみたいし,いちどにあれもこれも説明するのもたいへんなので,とりあえず今回はこれでいいとしよう.

で,修正後のソースは以下になる.今回は extintr.c, clock.c, telnetd.c, httpd.c が追加されている.以下は前回からの差分.ちなみに今回,システムコールとして kz_pending() というのを追加してある.受信メッセージがあるかどうかを調べるためのシステムコールだ.こんなふうにして使う.

if (kz_pending()) { /* 受信メッセージがあるならば,受信して処理する */
size = kz_recv(&id, &p);
...

で,各ソースを説明しよう.まずは extintr.c だ.

まず extintr.c は intrbuf という構造体と,setintrbuf() という関数を持つ.これらは各スレッドからソケットを通知された際に,それらのソケットをリンクリスト管理しておくためのものだ.まああまり難しくはないと思うので,ここでは説明しない.

extintr スレッドのメインループは,以下のようになっている.

int extintr_main(int argc, char *argv[])
{
fd_set fds;
char *p, *buffer;
int fd, id, ret, size, s, len;
intrbuf *ibp;

FD_ZERO(&readfds);
maxfd = 0;

while (1) {
while (kz_pending()) {
fd = kz_recv(&id, &p);
if (fd) { /* from thread */
setintrbuf(fd, id, (struct sockaddr *)p);
}
}

fds = readfds;
ret = select(maxfd + 1, &fds, NULL, NULL, NULL);

if (ret > 0) {
for (ibp = interrupts; ibp; ibp = ibp->next) {
if (FD_ISSET(ibp->fd, &fds)) {
switch (ibp->type) {
case INTR_TYPE_ACCEPT:
len = ibp->addr.sa_len;
s = accept(ibp->fd, &ibp->addr, &len);
if (s > 0)
kz_send(ibp->id, s, NULL);
break;
case INTR_TYPE_READ:
buffer = kz_memalloc(BUFFER_SIZE);
size = read(ibp->fd, buffer, BUFFER_SIZE);
if (size > 0)
kz_send(ibp->id, size, buffer);
break;
}
}
}
}
}
}

まず最初に,

while (1) {
while (kz_pending()) {
fd = kz_recv(&id, &p);
if (fd) { /* from thread */
setintrbuf(fd, id, (struct sockaddr *)p);
}
}

という部分で,各スレッドからのメッセージを受け付ける.各スレッドからは,見張ってほしいソケットをメッセージで送ってくるので,それを受信して setintrbuf() を呼び出し,構造体 intrbuf のリンクリストに追加する.ちなみにソケット情報は,
  • accept()のためのソケット ... kz_send() のサイズの位置にソケット番号,データの位置に bind() したときのアドレス情報を入れてメッセージが送られる.
  • read()のためのソケット ... kz_send() のサイズの位置にソケット番号,データの位置に NULL を入れてメッセージが送られる.
というようにして送られてくる.なので setintrbuf() では,データとしてポインタが送られてきたかNULLが送られてきたかで,accept() 用のソケットなのかread() 用のソケットなのかを判断している.

kz_send() の戻り値がサイズではなくソケット番号になっていることに注意.前回だか前々回だかに説明したが,kz_send() ではパラメータ名は size となっているが,実際には指定されたパラメータ値をそのまま送るだけなので,このようにソケット番号を送ることに利用してもいいわけだ(本当は通信用の専用の構造体を作るべきだろうが,面倒なのでまあこれでいいとする).

さらに,select() によって無限待ちする.

fds = readfds;
ret = select(maxfd + 1, &fds, NULL, NULL, NULL);

select()から抜けるのは,ソケットがなんらかのデータを受信して読み込み可能になったか,もしくはシグナル(ここでは SIGALRM のみ)が発生したかだ.読み込むデータがある場合には select() の戻り値が正の数(読み込めるソケットの個数)になるので,受信処理を行う.

if (ret > 0) {
for (ibp = interrupts; ibp; ibp = ibp->next) {
if (FD_ISSET(ibp->fd, &fds)) {
switch (ibp->type) {
case INTR_TYPE_ACCEPT:
len = ibp->addr.sa_len;
s = accept(ibp->fd, &ibp->addr, &len);
if (s > 0)
kz_send(ibp->id, s, NULL);
break;
case INTR_TYPE_READ:
buffer = kz_memalloc(BUFFER_SIZE);
size = read(ibp->fd, buffer, BUFFER_SIZE);
if (size > 0)
kz_send(ibp->id, size, buffer);
break;
}
}
}
}

ソケットは accept() 用のものと,read() 用のものがある.これらの種類に応じて accept() もしくは read() を行い,実行結果を当該のスレッドにメッセージにして送っている.select()でチェックした後なので,accept(),read()は必ず成功するはずだ.にもかかわらず accept(),read() の戻り値チェックをしているのは,万が一これらと同時に SIGALRM が発生し,(そのようなことがあり得るのかどうかちょっと不明だけど)シグナル抜けしてしまったときの対処だ.

では次に,各種アプリについて説明しよう.まずは時計アプリである clock.c から.

int clock_main(int argc, char *argv[])
{
time_t t;
char *p;

while (1) {
kz_timer(1000);
kz_recv(NULL, NULL);

t = time(NULL);
p = kz_memalloc(128);
strcpy(p, ctime(&t));
kz_send(outlog_id, 0, p);
}
}

メインループでは,単にタイマを1秒おきでかけて時刻を表示しているだけ.表示は outlog スレッドにまかせている.

次に telnetd.c について.

int telnetd_main(int argc, char *argv[])
{
int sockt, s, ret;
char hostname[256];
struct hostent *host;
struct sockaddr_in address;
int backlog = 5;

#if 1
gethostname(hostname, sizeof(hostname));
#else
strcpy(hostname, "localhost");
#endif

host = gethostbyname(hostname);
if (host == NULL) {
fprintf(stderr, "gethostbyname() failed.\n");
exit (-1);
}

memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_len = sizeof(address);
address.sin_port = htons(PORT);
memcpy(&(address.sin_addr), host->h_addr, host->h_length);

sockt = socket(AF_INET, SOCK_STREAM, 0);
if (sockt < 0) {
fprintf(stderr, "socket() failed.\n");
exit (-1);
}

ret = bind(sockt, (struct sockaddr *)&address, sizeof(address));
if (ret < 0) {
fprintf(stderr, "bind() failed.\n");
exit (-1);
}

ret = listen(sockt, backlog);
if (ret < 0) {
fprintf(stderr, "listen() failed.\n");
exit (-1);
}

ここまでは通常のネットワークアプリなので,とくに説明はない.ソケットを開いて各種準備をするだけだ.

で,問題は次からだ.

kz_send(extintr_id, sockt, (char *)&address);

while (1) {
s = kz_recv(NULL, NULL);
kz_run(command_main, "command", 7, s, NULL);
}

close(sockt);

return 0;
}

listen()の直後に kz_send() によって extintr にメッセージを送る.これにより,accept() 用のソケット番号とアドレス情報を extintr に通知し,あとは extintr が select() でソケットを見張って,accept() できる状態(telnetに対する接続要求が来たとき)になったらメッセージを投げてくる.

kz_send() の後は kz_recv() でメッセージ待ちに入る.extintr からのメッセージを待つわけだ.で,接続要求が来たら extintr のほうで accept() してソケット番号を(kz_send()の size パラメータを使って)送ってくるので,そのソケット番号をパラメータにしてコマンド処理用スレッドを起動する.ちなみに telnetd はこの後再び kz_recv() による accept() 待ちに入り,接続のたびにコマンドスレッドを新規に起動するので,複数の telnet 接続を受け付けることができる.

コマンドスレッドのメインループは以下のようになっている.

static int command_main(int s, char *argv[])
{
char *p;
char buffer[128];
int len, size;

kz_send(extintr_id, s, NULL);

len = 0;
write(s, "> ", 2);

while (1) {
size = kz_recv(NULL, &p);
memcpy(buffer + len, p, size);
kz_memfree(p);
len += size;
buffer[len] = '\0';

p = strchr(buffer, '\n');
if (p == NULL) continue;

if (!strncmp(buffer, "echo", 4)) {
write(s, buffer + 4, strlen(buffer + 4));
} else if (!strncmp(buffer, "date", 4)) {
time_t t;
t = time(NULL);
strcpy(buffer, ctime(&t));
write(s, buffer, strlen(buffer));
} else if (!strncmp(buffer, "threads", 7)) {
kz_thread *thp;
int i;
for (i = 0; i < THREAD_NUM; i++) {
thp = &threads[i];
if (!thp->id) break;
write(s, thp->name, strlen(thp->name));
write(s, "\n", 1);
}
} else if (!strncmp(buffer, "exit", 4)) {
break;
}

len = 0;
write(s, "OK\n> ", 5);
}

kz_send(extintr_id, s, NULL);
close(s);

return 0;
}

順番に説明していこう.まず,先頭で kz_send() により extintr にメッセージを送っている.

kz_send(extintr_id, s, NULL);

これはソケットを extintr に登録しておいて,受信データがあったときにやはりメッセージで知らせてもらうためだ.で,kz_recv() により受信データ待ちになる.

while (1) {
size = kz_recv(NULL, &p);

先程の kz_send() で extintr に対してソケット番号を通知してあるので,あとは extintr が select() して,受信データがあれば kz_send() でメッセージにして送ってくる.

で,メッセージを受信したら,受信データの内容に応じてコマンドを実行する.ちなみに受信データは extintr 側で kz_memalloc() によって獲得したメモリに格納して送られてくるので,コマンド処理側で kz_memfree() によって解放している.

memcpy(buffer + len, p, size);
kz_memfree(p);
len += size;
buffer[len] = '\0';

で,以下がコマンド処理部分になる.

if (!strncmp(buffer, "echo", 4)) {
write(s, buffer + 4, strlen(buffer + 4));
} else if (!strncmp(buffer, "date", 4)) {
time_t t;
t = time(NULL);
strcpy(buffer, ctime(&t));
write(s, buffer, strlen(buffer));
} else if (!strncmp(buffer, "threads", 7)) {
kz_thread *thp;
int i;
for (i = 0; i < THREAD_NUM; i++) {
thp = &threads[i];
if (!thp->id) break;
write(s, thp->name, strlen(thp->name));
write(s, "\n", 1);
}
} else if (!strncmp(buffer, "exit", 4)) {
break;
}

len = 0;
write(s, "OK\n> ", 5);
}

現在実装してあるコマンドは以下の4つだけだ.
  • echo ... 指定した文字列をリピート表示する.
  • date ... 時刻を表示する.
  • threads ... 起動中のスレッド一覧を表示する.
  • exit ... telnet接続を終了する.
exitが打たれると,以下の終了処理を行う.

kz_send(extintr_id, s, NULL);
close(s);

kz_send()により extintr に対してもう一度ソケット番号を通知することで,そのソケットを監視対象から外す(extintr はメッセージを受けるとソケットの監視を開始し,もう一度メッセージを受けると監視から外す).で,ソケットをクローズしている.

さて次は httpd だ.まあ telnetd とあんまし変わらないのだが,異なる部分だけいちおう説明する.

httpdのメインループは,telnetdとほとんど同じだ.接続要求がくると処理用のスレッドとして以下を起動する.

static int http_proc(int s, char *argv[])
{
char *p;
char buffer[1024];
int len, size;

kz_send(extintr_id, s, NULL);

len = 0;

do {
size = kz_recv(NULL, &p);
if (len + size >= sizeof(buffer)) {
break;
}
memcpy(buffer + len, p, size);
kz_memfree(p);
len += size;
buffer[len] = '\0';
} while (strchr(buffer, '\n') == NULL);

write(s, mes, sizeof(mes));

kz_send(extintr_id, s, NULL);
close(s);

return 0;
}

HTTPのプロトコルでは,たしか要求の最後に改行が入っていたような...(あれ,空行だったかな?),なので,てきとうに要求を受けた後にHTMLのメッセージを返すようになっている.メッセージは以下の簡単なものだ.

static char mes[] =
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 1.0//EN\">\n"
"<html>\n"
"<head><title>hello</title></head>\n"
"<body>\n"
"<center><h1>KOZOS</h1></center>\n"
"<p><h2>This is HTTP server running on KOZO-OS.</h2>\n"
"</body></html>\n";

メインの関数は以下になる.ここで,各スレッドの起動部分について説明する.

int mainfunc(int argc, char *argv[])
{
extintr_id = kz_run(extintr_main, "extintr", 31, 0, NULL);
outlog_id = kz_run(outlog_main, "outlog", 1, 0, NULL);
clock_id = kz_run(clock_main, "clock", 7, 0, NULL);
telnetd_id = kz_run(telnetd_main, "telnetd", 8, 0, NULL);
httpd_id = kz_run(httpd_main, "httpd", 9, 0, NULL);

return 0;
}

extintr を一番最初に起動しているのだが,優先度は31と最低になっていることに注意してほしい.優先度が31になっているのはずっと上のほうで説明したように,内部で select() で待つので,他のスレッドが固まってしまうのを防止するためだ.まあ優先度が最低ということは,なにもやることがないときには extintr に処理が渡って select() でプロセス自体がスリープするということなので,これはけっこうまともな設計だと思う.(実際のOSでも,多くは idle スレッドみたいなものが最低の優先度で起動していて,なにもやることがない場合にはCPUを省電力モードにする,などの動作をする)

telnetd, httpd は extintr に対してメッセージを投げるので,telnetd, httpd が起動する前に extintr は存在していなければならない.なので extintr を一番最初に起動している(ただし優先度が最低なので,起動しただけで処理は何も行われておらず,存在しているだけの状態である).outlog を2番目に起動しているのも,まあ似たような理由だ.clock は outlog に対してメッセージを送信するので,clock よりも前にoutlog が起動している必要がある.ちなみに前回や前々回とかでは,メインとなるスレッドは,他のスレッドを全部起動した後に自身の優先度を最低に変更して while(1) で無限ループしていた.つまり,最後に idle スレッドになっていた(しかし無限ループなのでCPU負荷が異常に高くなっていた).しかし今回は extintr が select() で待つことでidleスレッドの役割をこなすので,メインスレッドはすべてのスレッドの起動後はそのまま終了してしまっている.(メインスレッドは優先度がゼロで一番高いので,メインスレッドの終了後に各スレッドが動作を開始することになる)

では,いよいよサンプルプログラムによって動作確認してみよう.で,動作確認の前に1点注意.今回は telnetd と httpd が gethostbyname() によってホスト情報を取得しているが,ここでネットワーク接続されていない端末(もしくは接続されていても)でホスト名がてきとうだったりすると,DNSから情報が得られなくてエラーになってしまう.対策としては,/etc/hosts にホスト名とIPアドレスの対応を書いて名前解決できるようにしておくか,もしくはtelnetd_main(), httpd_main()でgethostname()しているところを

-#if 1
+#if 0
gethostname(hostname, sizeof(hostname));
#else
strcpy(hostname, "localhost");
#endif

のように書き換えて localhost を使うようにするか,対処してほしい.

では起動してみよう.コンパイルしたら実行形式を起動する.

% ./koz
Tue Oct 23 22:32:08 2007
Tue Oct 23 22:32:09 2007
Tue Oct 23 22:32:10 2007
Tue Oct 23 22:32:11 2007
Tue Oct 23 22:32:12 2007
Tue Oct 23 22:32:13 2007
Tue Oct 23 22:32:14 2007
Tue Oct 23 22:32:15 2007
Tue Oct 23 22:32:16 2007
Tue Oct 23 22:32:17 2007
Tue Oct 23 22:32:18 2007
Tue Oct 23 22:32:19 2007
...

時刻が1秒おきに表示されている.

この状態で,telnet で接続してみよう.説明を忘れていたが,telnetd.c ではポート番号 20001 で待っているので,20001 番ポートに対して接続する.前述したようにDNSでうまく名前解決できなければ,localhost を使うかIPアドレス直書きでもいいだろう.ここではIPアドレス直書きで接続する.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
>

おお,無事に接続できた.

echoコマンドを実行してみよう.

> echo test
test
OK

問題ないようだ.

> date
Tue Oct 23 22:32:50 2007
OK
> threads
command
extintr
outlog
clock
telnetd
httpd
OK
>

date, threads コマンドも問題無し.

前述したように,telnetd は接続ごとに処理用スレッドを起動するので,この状態(すでに接続されている状態)で,抜けないままでさらに別の接続をすることができる.試してみよう.

別の kterm 上から,telnet を起動する.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
>

問題なく接続できた.

コマンドを実行してみよう.

> date
Tue Oct 23 22:33:11 2007
OK
> threads
command
extintr
outlog
clock
telnetd
httpd
command
OK
>

threads によるスレッド一覧表示で,command スレッドが2つ表示されていることに注目(さっきはひとつだけだった).2箇所から同時接続して,コマンド処理スレッドが2つ起動しているので,このようなことになっている.

次に,ブラウザでHTTP接続してみよう.こちらはポート番号は 30001 (httpd.c参照)なので,

http://192.168.0.3:30001/

として接続する.

画像はこちら

おー,問題無く表示された.ちょっと感動.この間も時刻は1秒ごとに表示されているし,telnetによるコマンド実行ももちろんできる.時刻表示やtelnetによるコマンド処理も行いながら,webサーバも同時にできているということに注目してほしい.つまり,スレッドのディスパッチがきちんと行われているということだ.

telnet接続は,exitで抜けることができる.

> exit
Connection closed by foreign host.
%

さて,ここまでで,各種サーバ機能と時刻表示を動かすことができたのだが,たとえばこれをスレッド構成にしないで,普通の作りにしたらどんな感じになるか考えてみてほしい.

まあ,いちばん最初に思い付くのは

signal(SIGALRM, alarm_handler);
...
while (1) {
...
r = select(...);
if (r < 0) {
/* シグナルによってselect()が終了した(※1) */
...
} else if (r == 0) {
/* select()がタイムアウトした */
...
} else {
/* ソケットに受信データが到着した */
for (i = 0; i <= maxfd; i++) {
if (FD_ISSET(i, &fds)) {
/* ソケットの受信処理(※2) */
...
}

みたいにして,select()で待ってその戻り値に応じて各種動作を行うというものだ.これでもたしかに,やりたいことはできるだろう.

しかしこの書き方だと,時刻表示とtelnetサーバとhttpサーバの処理を,完全に独立させて書くことはまあできない.それらはすべて select() から派生して処理が行われるような構造になるからだ.つまり,上記の(※1)や(※2)の位置から関数を呼び出すことで各種処理を行わなければならない.このため,関数の構造などは select() から呼び出せるようにしなければならず,それなりに制約を受ける.たとえば今回の telnetd.c のように,accept() したらコマンド処理してまたaccept() の無限ループ,のようなことはできない.無限ループしてしまっては,select() に処理が戻らずに,他の動作が固まってしまうからだ.

これに対して,今回の clock.c, telnetd.c, httpd.c の実装を見てほしい.まるでひとつひとつが,単なるアプリとなっていて,select() から派生して動くとかいったことは考える必要が無い.extintr にメッセージを投げておけば,あとは適切にメッセージが返ってくるので,それに対して処理を行うだけだ.telnetd.c の中では無限ループにより accept() を行っているが,無限ループのために他のサービスが固まってしまうだとかいったことを心配する必要は無い.OSがてきとうにスレッドディスパッチをして,処理を振り分けてくれるからだ.

ここで,たとえばスレッドを用いずに select() のみで処理するような実装で,telnet 接続したときに,あるコマンドを叩いたら複雑な時間のかかる処理を行うような場合を考えてみよう.たとえばソートとかだ.

ソートに10秒かかるとして,それをコマンドスレッドが何も考えずに実行してしまったら,10秒間 select() には戻ってこない.つまり,他の全てのサービスが固まってしまう.これを防ぐためには,ソート処理の途中でいったん抜けてselect() に戻り,しばらくしたらまたソート処理を呼んでもらってまた途中まで処理して,というような複雑なことを行わなければならない.このためにはソートの現在状態のセーブ/ロード処理を実装しなければならなくなる.これは面倒だし,なによりも本来のソート処理よりも,このような本筋ではない対応のほうに手間がかかってしまうことになる.場合によってはプログラムの構造自体を変更しなければならなくなる.

しかし今回のように,スレッド化してあれば簡単だ.簡単,というより,何も考えずにソート処理を行えばよい.とはいってもまあ実は現状のKOZOSの実装だと,コマンドで重い処理に入るとselect() が呼ばれずに同じようなことになってしまう.とりあえず重い処理をしたいならば,とりあえずはソート処理の要所に

kz_timer(10);
kz_recv(NULL, NULL);

とか入れておく必要がある.まあこれでもプログラム全体の構成を変える必要は無いわけで,とっても嬉しいともいえる.

この問題には,外部割り込み実装という解決策がある.まあこれはまた後で紹介しよう.