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

今回は,いよいよ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内部のデバッグにも使えるような気もするが,まあとりあえずはこれでいいだろう.それよりも問題や改善点がまだまだいっぱいあるので,まずはそっちを直さにゃならん.