情弱ログ

参考にならないので当てにしないでください

1年生に送るデバッガ(gdb)の使い方(中級編)

前回の記事でコアダンプからセグメンテーション違反の原因を探す方法を書きました。
今回はデバッガからプログラムを実行することで、実行中のプログラムを一時停止したり変数に何が入っているかを確認してみましょう。


突然ですが、以下のプログラムには重大なバグがあります。原因が分かるでしょうか?
1年生向けと題打っておきながらまだ習ってないことがたくさん出てきますが、今年度中にやると思うのでおいおい見ていってください。

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

struct list_t {
	int value;
	struct list_t* next;
};

/*
 * 指定されたアドレスからlengthバイトまでを16進数でダンプする
 */
void debug(void *addr, int length) {
	int i;
	for ( i = 0; i < length; i++ ) {
		printf("%02x ", *(unsigned char *)(addr + i));
	}
	printf("\n");
}

/*
 * i番目のノードのvalueにiが入ったlength個のノードからなるリストを作る
 */
struct list_t* make_list(int length) {
	int i;
	struct list_t *head, *node, *tmp;

	head = node = malloc(sizeof(*node));
	node->value = 0;

	for ( i = 1; i < length; i++ ) {
		tmp = malloc(sizeof(*tmp));
		tmp->value = i;
		node->next = tmp;
		node = node->next;
	}
	return head;
}

/*
 * 後にmallocした領域を意図的に汚すための処理
 */
void pollute_mem(void) {
	struct list_t *head, *tmp;

	head = make_list(16);

	for ( tmp = head; tmp != NULL; tmp = tmp->next ) {
		free(tmp);
	}
}

int main(void) {
	struct list_t *head, *tmp;

	pollute_mem();

	// 16個のリストを作る
	head = make_list(16);

	for ( tmp = head; tmp != NULL; tmp = tmp->next ) {
		// ノードのアドレスとその中身をダンプする
		printf("------%p\n", tmp);
		debug(tmp, sizeof(struct list_t));

		printf("%d\n", tmp->value);
		// 無限ループだと早すぎるので1秒待つ
		sleep(1);
	}
	return 0;
}

コンパイルして実行してみましょう。ちなみに環境によって動作は異なります。僕の環境ではgcc 4.9.3でコンパイルし、x86_64なマシンで動くLinux 4.4.6-gentooカーネル上で実行しています。

$ ./a.out
------0x6021f0
00 00 00 00 00 00 00 00 d0 21 60 00 00 00 00 00
0
------0x6021d0
01 00 00 00 00 00 00 00 b0 21 60 00 00 00 00 00
1
------0x6021b0
02 00 00 00 00 00 00 00 90 21 60 00 00 00 00 00
2
------0x602190
03 00 00 00 00 00 00 00 70 21 60 00 00 00 00 00
3
------0x602170
04 00 00 00 00 00 00 00 50 21 60 00 00 00 00 00
4
------0x602150
05 00 00 00 00 00 00 00 30 21 60 00 00 00 00 00
5
------0x602130
06 00 00 00 00 00 00 00 10 21 60 00 00 00 00 00
6
------0x602110
07 00 00 00 00 00 00 00 f0 20 60 00 00 00 00 00
7
------0x6020f0
08 00 00 00 00 00 00 00 d0 20 60 00 00 00 00 00
8
------0x6020d0
09 00 00 00 00 00 00 00 b0 20 60 00 00 00 00 00
9
------0x6020b0
0a 00 00 00 00 00 00 00 90 20 60 00 00 00 00 00
10
------0x602090
0b 00 00 00 00 00 00 00 70 20 60 00 00 00 00 00
11
------0x602070
0c 00 00 00 00 00 00 00 50 20 60 00 00 00 00 00
12
------0x602050
0d 00 00 00 00 00 00 00 30 20 60 00 00 00 00 00
13
------0x602030
0e 00 00 00 00 00 00 00 10 20 60 00 00 00 00 00
14
------0x602010
0f 00 00 00 00 00 00 00 30 20 60 00 00 00 00 00
15
------0x602030
0e 00 00 00 00 00 00 00 10 20 60 00 00 00 00 00
14
------0x602010
0f 00 00 00 00 00 00 00 30 20 60 00 00 00 00 00
15
以下延々とループ

といった結果になったでしょうか?人によってはセグメンテーション違反となり、また別の人は特にエラーもなく15まで数字が表示されて正常終了したかもしれません。
このバグの原因をデバッガを使って探していきましょう。以下のコマンドを実行し、gdb上でプログラムを実行する準備をしてください。

$ gcc -g seglist.c
$ gdb ./a.out
~略~
Reading symbols from a.out...done.
(gdb)

では、gdbの使い方について説明します。gdbは実行中のプロセスをある段階で一時停止し、その時の変数やレジスタなどを表示することができます。このため、まず一時停止する場所を指定する必要があります。今回はまずmain関数の入り口で止めてみましょう。場所の指定はbreakコマンドを使います。

(gdb) break main
Breakpoint 1 at 0x400788: file seglist.c, line 56.

一時停止する場所を指定できました。ちなみに、この場所はブレークポイントと呼ばれています。また、ブレークポイントは行番号や相対位置で指定することも可能ですし、ある条件を満たした時だけ(例えばある変数が10の時などに)停止させることも可能です。
ブレークポイントを指定したので、プログラムを実行してみましょう。runコマンドでプログラムを実行できます。

(gdb) run
Starting program: /home/vicco/Scripts/a.out

Breakpoint 1, main () at seglist.c:56
56		pollute_mem();

main関数の最初の変数定義等は飛ばされます。これはデバッガの内部ではC言語ではなく機械語で解釈されているためです(多分)。
止めておいて何ですが、この時点ではまだ臭い部分は見当たらないので次に進めてみましょう。ステップ実行という機能を使います。

(gdb) step
pollute_mem () at seglist.c:46
46		head = make_list(16);

main関数からpollute_mem関数に入り、最初の処理でまた停止しました。どんどん進めて行きましょう。

(gdb) s
make_list (length=16) at seglist.c:28
28		head = node = malloc(sizeof(*node));
(gdb) s
29		node->value = 0;
(gdb) s
31		for ( i = 1; i < length; i++ ) {
(gdb) s
32			tmp = malloc(sizeof(*tmp));
(gdb) s
33			tmp->value = i;
(gdb) s
34			node->next = tmp;
(gdb) s
35			node = node->next;
(gdb) s
31		for ( i = 1; i < length; i++ ) {

言い忘れていましたが、ステップ実行はsと省略できます。他にもバックトレースはbt、ブレークポイントはbと略せます。さて、pollute_mem関数からmake_list関数に入ったのはいいのですが、forループに入ってしまいました。長ったらしいのでここは飛ばしたいと思います。untilコマンドでループを飛ばすことができます。

(gdb) until
37		return head;

ループを抜けました。どんどん進めて行きましょう。

(gdb) s
38	}
(gdb) s
pollute_mem () at seglist.c:48
48		for ( tmp = head; tmp != NULL; tmp = tmp->next ) {
(gdb) s
49			free(tmp);
(gdb) s
48		for ( tmp = head; tmp != NULL; tmp = tmp->next ) {
(gdb) until
51	}
(gdb) s
main () at seglist.c:59
59		head = make_list(16);

同じ関数を見るのは面倒くさいですね。飛ばしましょう。next(n)コマンドで関数に入らず次の処理に進めます。

(gdb) n
61		for ( tmp = head; tmp != NULL; tmp = tmp->next ) {

ガンガン飛ばしまくってますが、このあたりで変数の状態を見てみましょう。print(p)コマンドで変数の中身を見ることができます。

(gdb) p head
$1 = (struct list_t *) 0x6021f0
(gdb) p *head
$2 = {value = 0, next = 0x6021d0}

printコマンドの結果から、headは0x6021f0というアドレスを保持しており、head->value=0、head->next=0x6021d0ということが分かります。このように構造体も簡単に見ることができます。便利ですね。
では、少し進めてみましょう。

(gdb) s
63			printf("------%p\n", tmp);
(gdb) s
------0x6021f0
64			debug(tmp, sizeof(struct list_t));
(gdb) n
00 00 00 00 00 00 00 00 d0 21 60 00 00 00 00 00
66			printf("%d\n", tmp->value);
(gdb) s
0
68			sleep(1);
(gdb) s
61		for ( tmp = head; tmp != NULL; tmp = tmp->next ) {

ループが一周したのでリストの次のノードの中身を見てみましょう。

(gdb) p tmp
$1 = (struct list_t *) 0x6021f0
(gdb) p *tmp
$2 = {value = 0, next = 0x6021d0}
(gdb) s
63			printf("------%p\n", tmp);
(gdb) p tmp
$3 = (struct list_t *) 0x6021d0
(gdb) p *tmp
$4 = {value = 1, next = 0x6021b0}

この処理をループの回数だけやるのも面倒くさいですよね。変数が書き換わった時だけ実行を一時停止してみましょう。watchコマンドを使うと変数(メモリ)を監視することができます。また、監視対象をウォッチポイントと呼びます。

(gdb) watch tmp
Hardware watchpoint 2: tmp
(gdb) c
Continuing.
------0x6021d0
01 00 00 00 00 00 00 00 b0 21 60 00 00 00 00 00
1
Hardware watchpoint 2: tmp

Old value = (struct list_t *) 0x6021d0
New value = (struct list_t *) 0x6021b0
0x00000000004007fe in main () at seglist.c:61
61		for ( tmp = head; tmp != NULL; tmp = tmp->next ) {
(gdb) p *tmp
$5 = {value = 2, next = 0x602190}

途中でcontinue(c)コマンドを使って一気に実行しています。continueコマンドはブレークポイントやプログラムの終了まで実行を進めるコマンドです。watchコマンドでtmpを監視しているので、次のノードにtmpが差し替わったところで実行が一時停止しました。
先程のエラーの内容を思い出すとループの14回目から15回目あたりで異常が発生していました。そこまで進めましょう。continueコマンドを連打しました。

~略~
(gdb) c
Continuing.
~略~
(gdb) p *tmp
$6 = {value = 15, next = 0x602030}
(gdb) c
Continuing.
~略~
(gdb) p *tmp
$7 = {value = 14, next = 0x602010}
(gdb) c
Continuing.
~略~
(gdb) p *tmp
$8 = {value = 15, next = 0x602030}

14、15と続き、次はNULLであるはずが14になっていますね。つまりリストが循環してしまっています。main関数内にそんな処理は書いていないので、どうやらmake_list関数に問題がありそうです。一旦全てのブレークポイントを削除し、make_list関数にブレークポイントを設定してやり直しましょう。ブレークポイントの削除はdeleteコマンドで行なえます。deleteコマンドに引数を与えない場合、全てのブレークポイントが削除されます。また、プロセスの再起動は起動時と同じくrunコマンドで行なえます。

(gdb) delete
Delete all breakpoints? (y or n) y
(gdb) break make_list
Breakpoint 3 at 0x4006d5: file seglist.c, line 28.
(gdb) info break
Num     Type           Disp Enb Address            What
3       breakpoint     keep y   0x00000000004006d5 in make_list at seglist.c:28
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/vicco/Scripts/a.out

Breakpoint 3, make_list (length=16) at seglist.c:28
28		head = node = malloc(sizeof(*node));

では、nodeにウォッチポイントを設定して実行を続けましょう。

(gdb) c
Continuing.
~略~
(gdb) p *node
$10 = {value = 1, next = 0x0}
~略~
(gdb) c
Continuing.
(gdb) p *node
$15 = {value = 15, next = 0x0}
(gdb) c
Continuing.

Watchpoint 4 deleted because the program has left the block in
which its expression is valid.
0x0000000000400751 in pollute_mem () at seglist.c:46
46		head = make_list(16);

特に問題はなさそうですが、make_list関数の処理が終わってしまいました。しかし、make_list関数はまた呼び出されるはずなので、とりあえず実行を進めてみましょう。

(gdb) c
Continuing.
Breakpoint 3, make_list (length=16) at seglist.c:28
28		head = node = malloc(sizeof(*node));

とりあえず中身を見てみましょうか。

(gdb) p *node
$16 = {value = -263877816, next = 0xf07d8348f0458948}

  _, ._
(;゚ Д゚) …?!
何だかえらいことになってますね。ウォッチポイントを設定してどんどん見ていきましょう。

(gdb) watch node
Hardware watchpoint 5: node
(gdb) c
Continuing.
~略~
(gdb) p *node
$2 = {value = 6300096, next = 0x0}
(gdb) c
Continuing.
(gdb) p *node
$3 = {value = 1, next = 0x6021f0}

先ほどとの違いが分かりますか?最初のmake_listの処理ではどのノードのnextも初期値は0x0だったのに対し、何故か今回は変な値が既に格納されています。
茶番なので種明かしをすると、このプログラムはmallocした領域をゼロクリア(zero fill)していません。最近のOSではセキュリティの観点からヒープ領域をゼロクリアして返すため、一回目はうまくいきます。しかし、同じプロセスが何度もmallocした場合、過去にfreeしたメモリ領域をゼロクリアせずに再利用するためこのようなバグが発生します。今回は偶然15個目のノードのnext領域に14個目のノードのアドレスが格納されていたため、無限ループになっていました。比較的バグの箇所が分かりづらく、また環境によっては成功するため自分の環境では動くのに先生のPCだと動かない…といった事態を招く厄介なバグでした。
このバグはリストの終端処理を行っていないため発生しています。このため、確保時にnextをNULLで初期化する、mallocした領域は全てゼロクリアする、callocを使いノードをゼロクリアした状態で確保する、for文中で終端となるノードを適当な変数で保持し、for文が終わった後に終端ノードのnextにNULLを書き込むといった対策でこのバグを解消できます。
長くなりましたが、これくらいできればゴリゴリデバッグできるのではないでしょうか。自分は上級編を書けるほどgdbを使いこなしていないので、ネタが溜まったら次回の記事を書く予定です。