今回は,いよいよ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" という文字列がスタブにそのまま 送信されるわけではなくて,
で,スタブなのだけど,実は 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のマニュアル本にも乗っている.
基本的には,外部割り込みが入るとスタブに処理が渡り, 必要に応じてビジーループでコマンド待ちになり, コマンドを受け取ると処理を行って応答を返す (ここでスタブ内から 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文字入出力のみの貧弱なもので十分なので,専用のものを用意する必要がある.
まあ説明ばかりしていてもよくわからんだろうから,実装を説明しよう. 以下,スタブを組み込んだコード.
(2009/04/10 ライセンスに関する文書として,KL-01とLICENSEを追加. 詳しくは第43回を参照)
ソース公開がめんどいので,今回からひとまとめにディレクトリごと 公開することにした.上記ディレクトリに前回からの差分として diff.txt と, あと main.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内部のデバッグにも使えるような気もするが, まあとりあえずはこれでいいだろう.それよりも問題や改善点がまだまだいっぱい あるので,まずはそっちを直さにゃならん.