(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.
ここまで来て,ブレークポイントを試していないことに気がついた.デバッガを利用していてブレークポイントは非常に心強い機能で,ブレークポイントとステップ実行さえできればいいという場合も多いのだが,今回はブレークポイントの実現方法について説明しよう.
そもそもブレークポイントがどのように実現されるかというと,2通りある.まずひとつ目だが,CPUによっては,特定のアドレスの命令を実行しようとすると割り込みがかかるように設定できるものがある.まあそーいう専用のレジスタを持っているわけだが,これを利用してブレークポイントを実現するものをハードウエア・ブレークポイントと呼ぶ.
もうひとつは,ブレークさせたい場所の命令をトラップ命令などに書き換えておき,実行がその場所にさしかかったらトラップ命令が実行されて割り込みが上がるようにする,というものだ.これはソフトウエア・ブレークポイントと呼ぶ.
ハードウエア・ブレークポイントは,テキスト領域の書き換えを行う必要が無いので,メモリ保護で書き込み不可になっている場合でも利用できるという利点がある.ただしレジスタの数は有限なので,設定数に限りがある(そしてその上限はわりと少ない).
これに対してソフトウエア・ブレークポイントは,テキスト領域の書き換えを行うため,メモリ保護で書き込み不可になっていると利用できない.これは場合によってはわりと重要な問題で,KOZOSではこの問題を解消するためにt2w というツールを使ってテキスト領域を書き込み可としてマッピングしている(第11回参照).また,制御がけっこうめんどい.制御については後で説明するが,実際に見てもらうと,うわーデバッガってこんなめんどくせーことやってるんだーと思うことだろう.まあとは言ってもそーいう制御はgdbのお仕事なので,あまり気にする必要は無い.
ただしソフトウエア・ブレークポイントだと,ブレークポイントの個数に制限が無いという利点はある.ブレークポイントをかけまくる機会は結構多く,これは実のところ,けっこうなアドバンテージだ.
で,gdbではハードウエアなのかソフトウエアなのかどちらなのかというと,通常の break コマンドによるブレークポイントには,ソフトウエア・ブレークポイントが利用される.ハードウエア・ブレークポイントの設定には,break ではないなんか別のコマンド(忘れた)が利用されるようだ.
さて,ブレークポイントの実現方法についてなのだけど,こーいうのを調べたい場合には,リモートデバッグの場合には,GDBとスタブの間の通信を覗き見るのが一番手っ取り早い.ということで実際に見てみよう.
今回はソースコードの修正はちょろっとだけ.
修正は telnetd.c に対するものだけだ.
ブレークポイントを張るテスト用に dummy_func() を追加し,call コマンドを実行すると dummy_func() が呼ばれるようにした.
まずは実行形式 koz を起動して,前回同様 gdb で接続し continue して動作開始し,telnet で接続するところまでやってみよう.
上は,動作開始したところ.
telnet による接続もOK.
ここでgdbでCtrl-C(emacsからの利用の場合には,Ctrl+Cを2回押す)でブレークする.
画像はこちら
このとき,gdbとスタブの間では以下の通信が行われている.
時刻表示が行われていたが停止し,gdbコマンドが送受信されている.
まず[]でくくられているのはgdbからスタブへの通信,()でくくられているのはスタブからgdbへの通信だ.で,gdbコマンドの読み方だが,
という形式になっている.相手からのコマンドを正常に受信した場合にはACKとして'+'を,異常受信の場合には'-'を返すというプロトコルだ.で,上の通信の内容だが,
まず Ctrl-C により stubd から kz_break() が呼ばれることでブレークし,スタブがTコマンドを送信している.Tコマンドはスタブ側での割り込み発生時にスタブからgdbに発行されるコマンドで,まあパラメータが連続していてわかりにくいのだが,上の通信の内容をパラメータごとに分解すると以下のようになる.
さらに割り込み発生時には,一部の代表的なレジスタの内容を送信する.これはgdbから改めてレジスタの値を取りにいくと通信量が増えるのと,割り込み発生しているのでどうせレジスタ情報が必要になるために,あらかじめ送ってしまうということのようだ.多分.
で,レジスタ情報の送りかたなのだが,「4:58e60e08;」のように,レジスタ番号とその値というフォーマットで送信する.レジスタ番号は,i386-stub.c で定義されている
の番号になる.つまり「4:58e60e08;」というのは,上記 regnames の4番目(enumはゼロから数えることに注意)であるESP(スタックポインタ)の値が0x080ee658 であることを表わす(リトルエンディアンになっていることに注意).同様に,「5:58e60e08;」はEBPが0x080ee658,「8:c5a80408;」はPC(プログラムカウンタ.i386ではEIPともいう)が0x0804a8c5であることを表わす.だいたいどのアーキテクチャでも,プログラムカウンタとスタックポインタくらいの値は送信するようだ.
最後に「#c0」はチェックサムの値となる.正確にいうと「#」がコマンドの終りを表わし,その後に 0xc0 という値のチェックサムが付加されている.さらにgdbから「+」が返信され,コマンドが無事に受信されたことが通知されている.
スタブ側からTコマンドによるシグナル発生を受信したgdbは,次に
というコマンドを送信している.(で,スタブ側が「+」を返している)Mコマンドは特定アドレスへの書き込みを意味する.上のコマンドは,
「0x08048088 に1バイトの値として0x55を書き込め」
というgdbからの指示だ.
ここでまず注意したいのは,アドレス値が今度はビッグエンディアンになっているということだ.このようにエンディアンは値によって(そして,ターゲットのプラットホームによって)変わってくるので,注意する必要がある.
さらに,0x08048088 というアドレスだ.これは readelf で実行形式 koz を解析すると
となっており,テキスト領域の先頭であることがわかる.テキスト領域のオフセットは 0x000088 となっているが,実際の実行形式の16進ダンプを見てみると
となっている.テキスト領域の先頭はオフセット0x000088の位置であり,その位置の値は0x55になっている.つまり「$M8048088,1:55#c2」は,0x08048088 にすでに 0x55 という値があるにもかかわらず,0x55を上書きするということなので,意味の無い操作に見える.
で,gdbがいったいこれで何がしたいのかというと,おそらくテキスト領域への書き込みが行えるかどうかのチェックをしているのだと思う.t2wでテキスト領域の書き込みを可にしてあるので書き込みは成功し,スタブは「OK」という文字列を返している.これによりgdbはテキスト領域に書き込み可能であることを知ることができるわけだ.
で,gdb から break コマンドで,dummy_func() にブレークポイントを張ってみよう.
画像はこちら
mコマンドは特定アドレスの読み込みを意味する.このへんのコマンドの意味は,移植元である i386-stub.c を読めばわかるので,必要ならばよく読んでほしい.フォーマットはMコマンドとほぼ同じで,たとえば「$m804aa78,1」ならば,「0x0804aa78というアドレスから1バイトの値を読め」という意味になる.で,応答としてスタブが「$55」という値を返している.つまり0x0804aa78の値は0x55,ということだ.
で,0x0804aa78~0x0804aa7eまでの値を読み出しているのだが,これはいったい何をやっているのだろうか?
まず0x0804aa78なのだが,readelfの結果では
となっており,関数dummy_func()があることがわかる.つまりブレークポイントの設定時には,dummy_func()の先頭位置の実行コードを数バイト,gdbが読み出しているようだ.
で,これが何をやっているのかなのだけど,実は関数にブレークポイントを設定した場合には,その関数のほんとうに一番トップの位置にトラップ命令を仕掛けるわけではなくて,スタックの確保とかレジスタのスタック退避を行った後の位置にトラップ命令を仕掛けるようだ.でないと,関数の先頭でのブレーク時にはスタックが未設定となってしまい,ローカル変数の値などが壊れて見えてしまうからだろう.おそらく.
なので,関数の先頭部分の命令を読み出し,スタック確保などが行われた後のブレークポイント設定に適切な位置を調べているわけだ.
ソフトウエアブレークポイントとしてのトラップ命令の設定が行われていないが,実は break コマンドを実行しただけではトラップ命令は設定されない.continue を実行すると,そこで初めてトラップ命令が設定されることになる.
で,continue で動作再開する.
画像はこちら
時刻表示が動作再開していることに注目.
continue 実行時には,以下のことを行っているようだ.
dummy_func() の先頭付近にトラップ命令を仕掛けるのはわかるのだが,気になるのはテキスト領域の先頭(readelf の結果を見ると Entry point address もやはり 0x8048088 となっているので,実行が開始される位置)にもトラップ命令が仕掛けられているということだ.うーんなぜだろう,不明.
まあこれでトラップ命令が仕掛けられ,実行形式 koz の実行が続行(continue)されることになる.ちなみにトラップ命令設定の前に,mコマンドにより設定場所の値を読み出しているのは,あとでトラップ命令を削除する際に元の値に戻す必要があるから.
telnetから call コマンドを実行して,dummy_func()を呼び出してみよう.
画像はこちら
おー,ブレークした.
このときスタブは以下のような通信を行っている.
まず「$T054:ec461208;5:f4461208;8:7faa0408;」というコマンドがスタブからgdbに送信されているが,これは先に説明した通り,シグナルの発生をgdbに通知している.「T05」なので,やはり SIGTRAP が発生している.レジスタ情報としてはこれも先と同様に ESP,EBP,PC の値を送信している.これから,トラップ命令によりブレークした場所が特定できる.
PCの値は「8:7faa0408;」となっており,0x0804aa7f であることがわかる.ブレークポイントの設定時には,0x0804aa7e にトラップ命令を埋め込んだので,シグナル発生時には,PCは次の命令を指していることがわかる.
で,次にgdbから「$g」というコマンドが送信されている.gコマンドというのは,レジスタの取得だ.つまりトラップが発生したので,「その時の状態を知るためにレジスタ情報をよこせ」とgdbから指令が来ているわけだ.
スタブからは
という長~~~い返信が返されている.これがレジスタ値の一覧なのだが,各種レジスタの値が,上で説明した regnames で表わされる順番に(区切り無しで,一気に)送信されている.パット見でこれもリトルエンディアンになっているので注意.ちなみに簡単に説明すると,regnames の定義では「EAX, ECX, EDX, EBX, ...」という順番になっているので,上の返信結果では
という内容になっている.
次にgdbから「$P8=7eaa0408」というコマンドが送信されている.Pコマンドというのはレジスタの値設定で,この場合はレジスタ番号が8番目(くどいようだが,レジスタ番号は regnames での定義順番である)であるPC(プログラムカウンタ)に,0x0804aa7e という値を設定している.PCの値は 0x0804aa7f となっていたので,ひとつ前の命令に戻っているようだ.これはなぜかというと,ひとつ前の命令は実はトラップ命令(0xcc)に置き換えてしまっているため,実行されていない.なのでこの後の動作再開を見越して,PCをひとつ手前に戻して,さらに「$M8048088,1:55」「$M804aa7e,1:c7」によってトラップ命令を削除して元の命令コードに戻しておく,ということをしているようだ.
さらに「$m81246fc,4」により,0x081246fc から4バイトの値を読んでいるが,うーんこれはちょっと不明.なにをやっているのだろう.ちなみに値としては0x0000000aというものが返っていて,これは10進数だと「10」になるが,telnetd.c 中での call コマンドでのdummy_func() 呼び出し時の引数がやはり10になっているので,このへんに関係があるのかもしれない.
continue で動作続行してみよう.
画像はこちら
時刻表示が再開されている.無事に continue できているようだ.
まず「$Hc0」というコマンドがgdbからスタブに送信されている.しかしスタブ側では「$#00」という空の応答(よってチェックサムは0x00)が返されている.これは「そのコマンドはこのスタブでは実装されていません」という意味だ.なので,gdb側では別のもっと簡単なコマンドを使う,ということになる.「$Hc0」というコマンドがどーいうものなのかはgdbのソースをよく読んでみないとわからんが,まあ実装されていなくても問題なさそうなのでいいとする.
次に「$s」というコマンドが送信されている.これはステップ実行のフラグをCPUに立てて動作続行しろ,という意味だ.たいていのCPUはデバッグ用にステップ実行フラグというのを持っていて,これを立てておくと,1命令の実行のたびにトラップ割り込みが発生することになる.で,これによってどーいうことが起きるのかというと,動作続行後に1命令を実行した直後,再度スタブに処理が渡ってくることになる.実際に,「$s」の送信の直後に「$T054:ec461208;5:f4461208;8:85aa0408;」がスタブからgdbに送信され,トラップ割り込みが発生していることがわかる.割り込みの発生場所は「8:85aa0408;」によれば0x0804aa85となる.おそらく(i386は命令が可変長なのでよくわからんが)ブレークポイントの次の命令なのだろう.
で,「$m8048088,1」「$M8048088,1:cc」「$m804aa7e,1」「$M804aa7e,1:cc」により,ブレークポイントの設定を再度行っている.ここまでの操作により,ブレークポイント上に設定したトラップ命令は取り払われ,元に戻ってしまっている.このまま動作再開すると再度ブレークポイントで停止することができないので,ステップ実行によりちょっと処理を進めて,ブレークポイントの位置を通り過ぎた後でブレークポイントを再設定する,ということを行っているわけだ(通り過ぎた後でないと,実行再開の直後にまたブレークポイントに引っかかってしまうから).
で,ブレークポイントを設定した後で,最後に「$c」により動作再開している.これでよーやく実行形式が動作再開することになる.
今回はgdbとスタブとのやりとりの内容を説明したが,どうだろうか?まあ実際のところ gdb はけっこういろいろな指示を出していて,なんでこんなことしてるんだろというものも,よく考えてみるとそりゃそうだと思える場合も多い.
ここまで来て,ブレークポイントを試していないことに気がついた.デバッガを利用していてブレークポイントは非常に心強い機能で,ブレークポイントとステップ実行さえできればいいという場合も多いのだが,今回はブレークポイントの実現方法について説明しよう.
そもそもブレークポイントがどのように実現されるかというと,2通りある.まずひとつ目だが,CPUによっては,特定のアドレスの命令を実行しようとすると割り込みがかかるように設定できるものがある.まあそーいう専用のレジスタを持っているわけだが,これを利用してブレークポイントを実現するものをハードウエア・ブレークポイントと呼ぶ.
もうひとつは,ブレークさせたい場所の命令をトラップ命令などに書き換えておき,実行がその場所にさしかかったらトラップ命令が実行されて割り込みが上がるようにする,というものだ.これはソフトウエア・ブレークポイントと呼ぶ.
ハードウエア・ブレークポイントは,テキスト領域の書き換えを行う必要が無いので,メモリ保護で書き込み不可になっている場合でも利用できるという利点がある.ただしレジスタの数は有限なので,設定数に限りがある(そしてその上限はわりと少ない).
これに対してソフトウエア・ブレークポイントは,テキスト領域の書き換えを行うため,メモリ保護で書き込み不可になっていると利用できない.これは場合によってはわりと重要な問題で,KOZOSではこの問題を解消するためにt2w というツールを使ってテキスト領域を書き込み可としてマッピングしている(第11回参照).また,制御がけっこうめんどい.制御については後で説明するが,実際に見てもらうと,うわーデバッガってこんなめんどくせーことやってるんだーと思うことだろう.まあとは言ってもそーいう制御はgdbのお仕事なので,あまり気にする必要は無い.
ただしソフトウエア・ブレークポイントだと,ブレークポイントの個数に制限が無いという利点はある.ブレークポイントをかけまくる機会は結構多く,これは実のところ,けっこうなアドバンテージだ.
で,gdbではハードウエアなのかソフトウエアなのかどちらなのかというと,通常の break コマンドによるブレークポイントには,ソフトウエア・ブレークポイントが利用される.ハードウエア・ブレークポイントの設定には,break ではないなんか別のコマンド(忘れた)が利用されるようだ.
さて,ブレークポイントの実現方法についてなのだけど,こーいうのを調べたい場合には,リモートデバッグの場合には,GDBとスタブの間の通信を覗き見るのが一番手っ取り早い.ということで実際に見てみよう.
今回はソースコードの修正はちょろっとだけ.
修正は telnetd.c に対するものだけだ.
diff -ruN kozos15/telnetd.c kozos16/telnetd.c
--- kozos15/telnetd.c Mon Nov 12 23:01:07 2007
+++ kozos16/telnetd.c Mon Nov 12 23:09:28 2007
@@ -15,6 +15,16 @@
int telnetd_id;
int telnetd_dummy;
+static int dummy_func(int n)
+{
+ int i, sum;
+ sum = 0;
+ for (i = 1; i < n; i++) {
+ sum += i;
+ }
+ return sum;
+}
+
static int command_main(int s, char *argv[])
{
char *p;
@@ -42,6 +52,8 @@
if (!strncmp(buffer, "echo", 4)) {
write(s, buffer + 4, strlen(buffer + 4));
+ } else if (!strncmp(buffer, "call", 4)) {
+ dummy_func(10);
} else if (!strncmp(buffer, "down", 4)) {
int *nullp = NULL;
*nullp = 1;
ブレークポイントを張るテスト用に dummy_func() を追加し,call コマンドを実行すると dummy_func() が呼ばれるようにした.
まずは実行形式 koz を起動して,前回同様 gdb で接続し continue して動作開始し,telnet で接続するところまでやってみよう.
Mon Nov 12 23:31:23 2007
Mon Nov 12 23:31:24 2007
Mon Nov 12 23:31:26 2007
Mon Nov 12 23:31:27 2007
Mon Nov 12 23:31:28 2007
...
上は,動作開始したところ.
% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
>
telnet による接続もOK.
ここでgdbでCtrl-C(emacsからの利用の場合には,Ctrl+Cを2回押す)でブレークする.
画像はこちら
このとき,gdbとスタブの間では以下の通信が行われている.
Mon Nov 12 23:32:06 2007
Mon Nov 12 23:32:07 2007
Mon Nov 12 23:32:08 2007
($T054:58e60e08;5:58e60e08;8:c5a80408;#c0)[+]
[$M8048088,1:55#c2](+)($OK#9a)[+]
(この状態で停止)
時刻表示が行われていたが停止し,gdbコマンドが送受信されている.
まず[]でくくられているのはgdbからスタブへの通信,()でくくられているのはスタブからgdbへの通信だ.で,gdbコマンドの読み方だが,
$<コマンド><パラメータ>#<チェックサム>
という形式になっている.相手からのコマンドを正常に受信した場合にはACKとして'+'を,異常受信の場合には'-'を返すというプロトコルだ.で,上の通信の内容だが,
($T054:58e60e08;5:58e60e08;8:c5a80408;#c0)[+]
まず Ctrl-C により stubd から kz_break() が呼ばれることでブレークし,スタブがTコマンドを送信している.Tコマンドはスタブ側での割り込み発生時にスタブからgdbに発行されるコマンドで,まあパラメータが連続していてわかりにくいのだが,上の通信の内容をパラメータごとに分解すると以下のようになる.
- T05
- 4:58e60e08;
- 5:58e60e08;
- 8:c5a80408;
- #c0
- +
さらに割り込み発生時には,一部の代表的なレジスタの内容を送信する.これはgdbから改めてレジスタの値を取りにいくと通信量が増えるのと,割り込み発生しているのでどうせレジスタ情報が必要になるために,あらかじめ送ってしまうということのようだ.多分.
で,レジスタ情報の送りかたなのだが,「4:58e60e08;」のように,レジスタ番号とその値というフォーマットで送信する.レジスタ番号は,i386-stub.c で定義されている
enum regnames {EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI,
PC /* also known as eip */,
PS /* also known as eflags */,
CS, SS, DS, ES, FS, GS};
の番号になる.つまり「4:58e60e08;」というのは,上記 regnames の4番目(enumはゼロから数えることに注意)であるESP(スタックポインタ)の値が0x080ee658 であることを表わす(リトルエンディアンになっていることに注意).同様に,「5:58e60e08;」はEBPが0x080ee658,「8:c5a80408;」はPC(プログラムカウンタ.i386ではEIPともいう)が0x0804a8c5であることを表わす.だいたいどのアーキテクチャでも,プログラムカウンタとスタックポインタくらいの値は送信するようだ.
最後に「#c0」はチェックサムの値となる.正確にいうと「#」がコマンドの終りを表わし,その後に 0xc0 という値のチェックサムが付加されている.さらにgdbから「+」が返信され,コマンドが無事に受信されたことが通知されている.
スタブ側からTコマンドによるシグナル発生を受信したgdbは,次に
$M8048088,1:55#c2
というコマンドを送信している.(で,スタブ側が「+」を返している)Mコマンドは特定アドレスへの書き込みを意味する.上のコマンドは,
「0x08048088 に1バイトの値として0x55を書き込め」
というgdbからの指示だ.
ここでまず注意したいのは,アドレス値が今度はビッグエンディアンになっているということだ.このようにエンディアンは値によって(そして,ターゲットのプラットホームによって)変わってくるので,注意する必要がある.
さらに,0x08048088 というアドレスだ.これは readelf で実行形式 koz を解析すると
% readelf -a koz
...
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .init PROGBITS 08048074 000074 000011 00 AX 0 0 4
[ 2] .text PROGBITS 08048088 000088 05efb6 00 AX 0 0 4
...
となっており,テキスト領域の先頭であることがわかる.テキスト領域のオフセットは 0x000088 となっているが,実際の実行形式の16進ダンプを見てみると
% hd koz | head
00000000 7f 45 4c 46 01 01 01 09 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 03 00 01 00 00 00 88 80 04 08 34 00 00 00 |............4...|
00000020 e0 d2 07 00 00 00 00 00 34 00 20 00 02 00 28 00 |......4. ...(.|
00000030 16 00 13 00 01 00 00 00 00 00 00 00 00 80 04 08 |................|
00000040 00 80 04 08 3a 7b 06 00 3a 7b 06 00 07 00 00 00 |....:{..:{......|
00000050 00 10 00 00 01 00 00 00 00 80 06 00 00 00 0b 08 |................|
00000060 00 00 0b 08 1c 20 00 00 4c 8c 01 00 06 00 00 00 |..... ..L.......|
00000070 00 10 00 00 83 ec 0c e8 ec 00 00 00 e8 13 ef 05 |................|
00000080 00 83 c4 0c c3 00 00 00 55 89 e5 57 56 53 83 ec |....U..WVS..|
00000090 0c 83 e4 f0 8b 5d 04 89 d7 8d 74 9d 0c 85 db 89 |.....]..t...|
%
となっている.テキスト領域の先頭はオフセット0x000088の位置であり,その位置の値は0x55になっている.つまり「$M8048088,1:55#c2」は,0x08048088 にすでに 0x55 という値があるにもかかわらず,0x55を上書きするということなので,意味の無い操作に見える.
で,gdbがいったいこれで何がしたいのかというと,おそらくテキスト領域への書き込みが行えるかどうかのチェックをしているのだと思う.t2wでテキスト領域の書き込みを可にしてあるので書き込みは成功し,スタブは「OK」という文字列を返している.これによりgdbはテキスト領域に書き込み可能であることを知ることができるわけだ.
で,gdb から break コマンドで,dummy_func() にブレークポイントを張ってみよう.
画像はこちら
[$m804aa78,1#97](+)($55#6a)[+]
[$m804aa78,1#97](+)($55#6a)[+]
[$m804aa78,1#97](+)($55#6a)[+]
[$m804aa78,1#97](+)($55#6a)[+]
[$m804aa79,1#98](+)($89#71)[+]
[$m804aa7a,1#c0](+)($e5#9a)[+]
[$m804aa7b,1#c1](+)($83#6b)[+]
[$m804aa7c,1#c2](+)($ec#c8)[+]
[$m804aa7d,1#c3](+)($08#68)[+]
[$m804aa7e,1#c4](+)($c7#9a)[+]
[$m804aa7e,1#c4](+)($c7#9a)[+]
[$m804aa7e,1#c4](+)($c7#9a)[+]
(この状態で停止)
mコマンドは特定アドレスの読み込みを意味する.このへんのコマンドの意味は,移植元である i386-stub.c を読めばわかるので,必要ならばよく読んでほしい.フォーマットはMコマンドとほぼ同じで,たとえば「$m804aa78,1」ならば,「0x0804aa78というアドレスから1バイトの値を読め」という意味になる.で,応答としてスタブが「$55」という値を返している.つまり0x0804aa78の値は0x55,ということだ.
で,0x0804aa78~0x0804aa7eまでの値を読み出しているのだが,これはいったい何をやっているのだろうか?
まず0x0804aa78なのだが,readelfの結果では
106: 0804aa78 48 FUNC LOCAL DEFAULT 2 dummy_func
となっており,関数dummy_func()があることがわかる.つまりブレークポイントの設定時には,dummy_func()の先頭位置の実行コードを数バイト,gdbが読み出しているようだ.
で,これが何をやっているのかなのだけど,実は関数にブレークポイントを設定した場合には,その関数のほんとうに一番トップの位置にトラップ命令を仕掛けるわけではなくて,スタックの確保とかレジスタのスタック退避を行った後の位置にトラップ命令を仕掛けるようだ.でないと,関数の先頭でのブレーク時にはスタックが未設定となってしまい,ローカル変数の値などが壊れて見えてしまうからだろう.おそらく.
なので,関数の先頭部分の命令を読み出し,スタック確保などが行われた後のブレークポイント設定に適切な位置を調べているわけだ.
ソフトウエアブレークポイントとしてのトラップ命令の設定が行われていないが,実は break コマンドを実行しただけではトラップ命令は設定されない.continue を実行すると,そこで初めてトラップ命令が設定されることになる.
で,continue で動作再開する.
画像はこちら
[$m8048088,1#3e](+)($55#6a)[+]
[$M8048088,1:cc#1e](+)($OK#9a)[+]
[$m804aa7e,1#c4](+)($c7#9a)[+]
[$M804aa7e,1:cc#a4](+)($OK#9a)[+]
[$c#63](+)Mon Nov 12 23:33:51 2007
Mon Nov 12 23:33:52 2007
Mon Nov 12 23:33:54 2007
Mon Nov 12 23:33:55 2007
Mon Nov 12 23:33:56 2007
Mon Nov 12 23:33:57 2007
...
時刻表示が動作再開していることに注目.
continue 実行時には,以下のことを行っているようだ.
- まず「m8048088,1」により,0x08048088 (これはテキスト領域の先頭のアドレス)の値を読み出す → 値として 0x55 が返っている.
- 「M8048088,1:cc」により,テキスト領域の先頭にトラップ命令として0xcc を設定している.
- さらに「$m804aa7e,1」により,関数 dummy_func() の先頭付近の値を読み出している → 値として 0xc7 が返っている.
- さらに「$M804aa7e,1:cc」により,関数 dummy_func() の先頭付近にトラップ命令(0xcc)を設定している.
- 最後に,「$c」を送信して continue を指示している.
dummy_func() の先頭付近にトラップ命令を仕掛けるのはわかるのだが,気になるのはテキスト領域の先頭(readelf の結果を見ると Entry point address もやはり 0x8048088 となっているので,実行が開始される位置)にもトラップ命令が仕掛けられているということだ.うーんなぜだろう,不明.
まあこれでトラップ命令が仕掛けられ,実行形式 koz の実行が続行(continue)されることになる.ちなみにトラップ命令設定の前に,mコマンドにより設定場所の値を読み出しているのは,あとでトラップ命令を削除する際に元の値に戻す必要があるから.
telnetから call コマンドを実行して,dummy_func()を呼び出してみよう.
% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> call
(この状態で停止)
画像はこちら
おー,ブレークした.
このときスタブは以下のような通信を行っている.
Mon Nov 12 23:34:36 2007
Mon Nov 12 23:34:37 2007
Mon Nov 12 23:34:38 2007
Mon Nov 12 23:34:39 2007
Mon Nov 12 23:34:40 2007
($T054:ec461208;5:f4461208;8:7faa0408;#b0)[+]
[$g#67](+)($0000000097730a080000000060300c08ec461208f4461208ec471208000000007faa040812020000330000003b0000003b0000003b0000003b0000001b000000#9e)[+]
[$P8=7eaa0408#ef](+)($OK#9a)[+]
[$M8048088,1:55#c2](+)($OK#9a)[+]
[$M804aa7e,1:c7#78](+)($OK#9a)[+]
[$m81246fc,4#9b](+)($0a000000#b1)[+]
(この状態で停止)
まず「$T054:ec461208;5:f4461208;8:7faa0408;」というコマンドがスタブからgdbに送信されているが,これは先に説明した通り,シグナルの発生をgdbに通知している.「T05」なので,やはり SIGTRAP が発生している.レジスタ情報としてはこれも先と同様に ESP,EBP,PC の値を送信している.これから,トラップ命令によりブレークした場所が特定できる.
PCの値は「8:7faa0408;」となっており,0x0804aa7f であることがわかる.ブレークポイントの設定時には,0x0804aa7e にトラップ命令を埋め込んだので,シグナル発生時には,PCは次の命令を指していることがわかる.
で,次にgdbから「$g」というコマンドが送信されている.gコマンドというのは,レジスタの取得だ.つまりトラップが発生したので,「その時の状態を知るためにレジスタ情報をよこせ」とgdbから指令が来ているわけだ.
スタブからは
$0000000097730a080000000060300c08
ec461208f4461208ec47120800000000
7faa040812020000330000003b000000
3b0000003b0000003b0000001b000000
という長~~~い返信が返されている.これがレジスタ値の一覧なのだが,各種レジスタの値が,上で説明した regnames で表わされる順番に(区切り無しで,一気に)送信されている.パット見でこれもリトルエンディアンになっているので注意.ちなみに簡単に説明すると,regnames の定義では「EAX, ECX, EDX, EBX, ...」という順番になっているので,上の返信結果では
EAX = 0x00000000
ECX = 0x080a7397
EDX = 0x00000000
EBX = 0x080c3060
...
という内容になっている.
次にgdbから「$P8=7eaa0408」というコマンドが送信されている.Pコマンドというのはレジスタの値設定で,この場合はレジスタ番号が8番目(くどいようだが,レジスタ番号は regnames での定義順番である)であるPC(プログラムカウンタ)に,0x0804aa7e という値を設定している.PCの値は 0x0804aa7f となっていたので,ひとつ前の命令に戻っているようだ.これはなぜかというと,ひとつ前の命令は実はトラップ命令(0xcc)に置き換えてしまっているため,実行されていない.なのでこの後の動作再開を見越して,PCをひとつ手前に戻して,さらに「$M8048088,1:55」「$M804aa7e,1:c7」によってトラップ命令を削除して元の命令コードに戻しておく,ということをしているようだ.
さらに「$m81246fc,4」により,0x081246fc から4バイトの値を読んでいるが,うーんこれはちょっと不明.なにをやっているのだろう.ちなみに値としては0x0000000aというものが返っていて,これは10進数だと「10」になるが,telnetd.c 中での call コマンドでのdummy_func() 呼び出し時の引数がやはり10になっているので,このへんに関係があるのかもしれない.
continue で動作続行してみよう.
画像はこちら
[$Hc0#db](+)($#00)[+]
[$s#73](+)($T054:ec461208;5:f4461208;8:85aa0408;#80)[+]
[$m8048088,1#3e](+)($55#6a)[+]
[$M8048088,1:cc#1e](+)($OK#9a)[+]
[$m804aa7e,1#c4](+)($c7#9a)[+]
[$M804aa7e,1:cc#a4](+)($OK#9a)[+]
[$Hc0#db](+)($#00)[+]
[$c#63](+)Mon Nov 12 23:35:44 2007
Mon Nov 12 23:35:45 2007
Mon Nov 12 23:35:46 2007
Mon Nov 12 23:35:48 2007
Mon Nov 12 23:35:49 2007
Mon Nov 12 23:35:50 2007
...
時刻表示が再開されている.無事に continue できているようだ.
まず「$Hc0」というコマンドがgdbからスタブに送信されている.しかしスタブ側では「$#00」という空の応答(よってチェックサムは0x00)が返されている.これは「そのコマンドはこのスタブでは実装されていません」という意味だ.なので,gdb側では別のもっと簡単なコマンドを使う,ということになる.「$Hc0」というコマンドがどーいうものなのかはgdbのソースをよく読んでみないとわからんが,まあ実装されていなくても問題なさそうなのでいいとする.
次に「$s」というコマンドが送信されている.これはステップ実行のフラグをCPUに立てて動作続行しろ,という意味だ.たいていのCPUはデバッグ用にステップ実行フラグというのを持っていて,これを立てておくと,1命令の実行のたびにトラップ割り込みが発生することになる.で,これによってどーいうことが起きるのかというと,動作続行後に1命令を実行した直後,再度スタブに処理が渡ってくることになる.実際に,「$s」の送信の直後に「$T054:ec461208;5:f4461208;8:85aa0408;」がスタブからgdbに送信され,トラップ割り込みが発生していることがわかる.割り込みの発生場所は「8:85aa0408;」によれば0x0804aa85となる.おそらく(i386は命令が可変長なのでよくわからんが)ブレークポイントの次の命令なのだろう.
で,「$m8048088,1」「$M8048088,1:cc」「$m804aa7e,1」「$M804aa7e,1:cc」により,ブレークポイントの設定を再度行っている.ここまでの操作により,ブレークポイント上に設定したトラップ命令は取り払われ,元に戻ってしまっている.このまま動作再開すると再度ブレークポイントで停止することができないので,ステップ実行によりちょっと処理を進めて,ブレークポイントの位置を通り過ぎた後でブレークポイントを再設定する,ということを行っているわけだ(通り過ぎた後でないと,実行再開の直後にまたブレークポイントに引っかかってしまうから).
で,ブレークポイントを設定した後で,最後に「$c」により動作再開している.これでよーやく実行形式が動作再開することになる.
今回はgdbとスタブとのやりとりの内容を説明したが,どうだろうか?まあ実際のところ gdb はけっこういろいろな指示を出していて,なんでこんなことしてるんだろというものも,よく考えてみるとそりゃそうだと思える場合も多い.