(第16回)ブレーク動作を追いかけてみる

2007/11/13

あなたは 人目のお客様です.

ここまで来て,ブレークポイントを試していないことに気がついた. デバッガを利用していてブレークポイントは非常に心強い機能で, ブレークポイントとステップ実行さえできればいいという場合も多いのだが, 今回はブレークポイントの実現方法について説明しよう.

そもそもブレークポイントがどのように実現されるかというと,2通りある. まずひとつ目だが,CPUによっては,特定のアドレスの命令を実行しようとすると 割り込みがかかるように設定できるものがある.まあそーいう専用のレジスタを 持っているわけだが,これを利用してブレークポイントを実現するものを ハードウエア・ブレークポイントと呼ぶ.

もうひとつは,ブレークさせたい場所の命令をトラップ命令などに書き換えておき, 実行がその場所にさしかかったらトラップ命令が実行されて割り込みが上がるように する,というものだ.これはソフトウエア・ブレークポイントと呼ぶ.

ハードウエア・ブレークポイントは,テキスト領域の書き換えを行う必要が無いので, メモリ保護で書き込み不可になっている場合でも利用できるという利点がある. ただしレジスタの数は有限なので,設定数に限りがある (そしてその上限はわりと少ない).

これに対してソフトウエア・ブレークポイントは,テキスト領域の書き換えを 行うため,メモリ保護で書き込み不可になっていると利用できない. これは場合によってはわりと重要な問題で,KOZOSではこの問題を解消するために t2w というツールを使ってテキスト領域を書き込み可としてマッピングしている (第11回参照).また,制御がけっこうめんどい.制御については後で説明するが, 実際に見てもらうと,うわーデバッガってこんなめんどくせーことやってるんだー と思うことだろう.まあとは言ってもそーいう制御はgdbのお仕事なので, あまり気にする必要は無い.

ただしソフトウエア・ブレークポイントだと,ブレークポイントの個数に 制限が無いという利点はある.ブレークポイントをかけまくる機会は結構多く, これは実のところ,けっこうなアドバンテージだ.

で,gdbではハードウエアなのかソフトウエアなのかどちらなのかというと, 通常の break コマンドによるブレークポイントには, ソフトウエア・ブレークポイントが利用される. ハードウエア・ブレークポイントの設定には,break ではないなんか別のコマンド (忘れた)が利用されるようだ.

さて,ブレークポイントの実現方法についてなのだけど,こーいうのを調べたい 場合には,リモートデバッグの場合には,GDBとスタブの間の通信を覗き見るのが 一番手っ取り早い.ということで実際に見てみよう.

今回はソースコードの修正はちょろっとだけ.

(2009/04/10 ライセンスに関する文書として,KL-01とLICENSEを追加. 詳しくは第43回を参照)

修正は 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に発行されるコマンドで, まあパラメータが連続していてわかりにくいのだが, 上の通信の内容をパラメータごとに分解すると以下のようになる.
  1. T05
  2. 4:58e60e08;
  3. 5:58e60e08;
  4. 8:c5a80408;
  5. #c0
  6. +
まずひとつめの「T05」だが,「T」は割り込みの発生を表わす. で,割り込みの内容は 0x05,つまり SIGTRAP である. (割り込みの内容はシグナル番号で表現される.シグナル番号については man signal 参照)

さらに割り込み発生時には,一部の代表的なレジスタの内容を送信する. これは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 実行時には,以下のことを行っているようだ.

  1. まず「m8048088,1」により,0x08048088 (これはテキスト領域の先頭のアドレス) の値を読み出す → 値として 0x55 が返っている.
  2. 「M8048088,1:cc」により,テキスト領域の先頭にトラップ命令として 0xcc を設定している.
  3. さらに「$m804aa7e,1」により,関数 dummy_func() の先頭付近の値を 読み出している → 値として 0xc7 が返っている.
  4. さらに「$M804aa7e,1:cc」により,関数 dummy_func() の先頭付近に トラップ命令(0xcc)を設定している.
  5. 最後に,「$c」を送信して continue を指示している.
これを見る限り,0xcc と言うのが i386 のトラップ命令のようだ(未確認だけど). つまり0xccを実行すると,CPUにトラップ割り込みが上がることになる.

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 はけっこういろいろな指示を出していて, なんでこんなことしてるんだろというものも,よく考えてみるとそりゃそうだと 思える場合も多い.


メールは kozos(アットマーク)kozos.jp まで