デバッグの方法論

ここに書いてある情報は相当に古いです。 デバッグについてのスライドを作ったので以下も参照してください。

130613-debug from kaityo256

 言うまでも無いことだが、プログラミングにおいてもっとも 時間がかかる部分はデバッグである。従って、最初から手間が かかってもバグが無いように注意してプログラムを組むのが望ましいが、 それでもバグは出るものだ。ここでは、そうやって出てしまったバグの つぶし方を紹介する。

注意:

 一般的にバグは二種類に分けることができる。すなわち、 プログラムが途中で止まってしまうバグと、実行はできるが 結果がおかしいバグである。前者にはFloating Exceptionや Divided by Zero、Arithmatic Exceptionなどが 含まれ、後者はエネルギー発散、臨界点のずれなどで現れる。

 ここで扱うのは主に前者、途中で止まってしまうバグのつぶし方である。 個人的な意見だが、こういうバグは速攻でつぶせるべきだと 考えている。なぜなら、プログラムが止まってくれるのだから、 バグの存在個所を探しやすいからだ。バグの存在個所を特定できたら、 そのバグは99%つぶせたも同然である。従って、 プログラムとはデバッグであり、デバッグとはバグの個所を特定することであると 僕は考えている。

 また、ここに書かれていることは、バグっていることが分かっている プログラムのバグ個所発見に限定しており、一見バグっていないプログラムに 潜むバグを探す方法は書いていない。当然だが、後者の方が一般的には 重要である。また、言語はFortranを想定している。

入門編:

 デバッグするためには、今どんなバグが起きたかを認識する必要がある。 そのためには、どんなミスでどんなバグが起こるのかを知っておかなくては ならない。以下に、起こりやすいバグとその対処法を列挙する。

配列の引数の範囲が不正

 たとえば、DIMENSION A(10)と宣言されているのにA(11)にアクセスしたり する場合である。これは、引数が変数である場合によく起きる。 すなわち、A(I)などとなっていて、ある条件でIが10を越えたりする。

 通常、配列の外を触るとメモリ共有違反(segmentation fault、 Windowsなら不正な処理)が起きるのでバグの存在がわかるがある条件でFortranでは メモリ共有違反にならず実行される場合がある(処理系によっては Cなどでも起きるかもしれない)。たとえば

DIMENSION A(10),B(10)
 と宣言された場合、Fortranは連続して20個の領域を確保する。 このままA(11)を参照しようとすると、実際にはB(1)の内容を見ていることに なる。このメモリの場所はFortranが確保した領域内のため、メモリ共有違反が おこらない。このような場合に対処するため、多くの言語処理系で 「-C」オプションを用意してある。このオプションは配列の引数が 宣言された範囲に収まっているかチェックする。 このオプションは処理系によって異なるので注意すること。 手元のg77では「-fbounds-check」だった。 いずれにしても「man g77」で「check」か「array」をキーワードに 検索すれば見つかるはずだ。

 コンパイラは、配列を触るところの直前に、引数が宣言された範囲に 収まっているかチェックする命令を挿入することでこの機能を実装している。 もし宣言された範囲をオーバーしていたら、エラーを表示し、プログラムは 止まる。従って、このオプションをつけると実行は遅くなる。 エラーメッセージは処理系によって異なるが、 たとえばg77なら「break point で trapされました」的な文章が表示される。 これにより、「少なくとも配列の引数の範囲が不正であるバグが存在する」ことが わかるので、一度は「-C」付きでコンパイル&実行するべきである。 ただ、エラー個所までは表示してくれないことが多い。これは 後で紹介するgdbで解決する。

変数名の入力間違い

 これは特にFortranでimplicit宣言を使っている場合に起きる。 implicit宣言を使うと、新しい変数を宣言せずに使えるから便利であるが、 それはスペルミスをして入力した変数もコンパイラが通してしまうという ことである。たとえば、(研究室でよくあることだが)他人が書いたプログラムを 改造するとき、O(オー)と0(ゼロ)、l(小文字のエル)と1(数字の一)など、 判別が難しい変数を間違えて入力することがある。このとき、implicit宣言が あると、これらを新しい変数としてコンパイルするため、意図した プログラムにならないし、間違えた場所と別の場所でプログラムが不具合を起こすことが多い。

 ちなみにこのたぐいのミスを探すのは地獄である。 なにしろ、もともとぱっと見にはi1なのかilなのかは区別がつかない上に、 「自分は正しく書いたはず」という先入観でプログラムを見ることが多いので 他の場所を延々と探し回ることになる。そしてさまざまな物理的な理由を 考えた挙句、1とLのうち間違いだったことに気が付いた時には、 もうプログラムを組む気力を無くすこと請け合いである。 こういう事態を防ぐためにも、普段からimplicit none宣言で プログラムを組む習慣をつけておくべきであろう。

ゼロ除算、数学的なエラー

 たとえば、引数に正の数しか許されない関数sqrtに負の数を いれて呼んだり、1/Eを計算するときに、ある条件でEがゼロに なる場合である。この場合はプログラムは「Arithmatic Exception」や 「Divided by zero」などのメッセージを吐いて止まる。

 このメッセージを見たら、自分のプログラムの怪しいところを 見直してみよう。怪しいところというのは、割り算をしているところ、 引数に条件のある関数を使っているところであり、そんなに数は 無いはずである。デバッガ−を使ってバグの個所を一気に特定する方法も後述する。

Overflow、Underflow

 これは、変数の値が浮動小数点で扱える実数の範囲を越えてしまった 場合に起きる。たとえば、指数関数EXPの引数に大きな数(1000とか)を入れたり、 ベキの指数(X**YのY)に大きな数が来たりした場合などに発生する。 経験的に、この種のバグの根は深い。 このバグの個所の特定はデバッガ−などで容易であるが、引数に そんな変な数が来る条件の特定には少し手間がかかる。 デバッグ法としては、地道に引数の値が変になる場所を さかのぼりながら探すしかない。この種のデバッグ法をPRINT文を多用することから PRINT文デバッグと呼ぶこともある。PRINT文デバッグは 全てのデバッグ法の基本である。

型の違い

 Fortran77には型チェックが無いので、思わぬバグを引き起こすことが ある。まずあるのは、INTEGER型の変数の演算の丸めによるバグ。 INTEGER同士の変数の演算結果はINTEGERとなる。 従って、

INTEGER I,J
REAL A
I = 2
J = 3
A = I/J
を実行すると、Aの値は0.0000になってしまう。 コンパイルでエラーも起きず、通常の設定では警告も出ないため 気づきにくいバグだ。これは
A = FLOAT(I)/FLOAT(J)
などとするか、一度REAL型変数に値を移してから計算しなくては いけない。

 さらにこれは変数同士でなく定数同士の割り算でも発生する問題である。 すなわち、

REAL A
A = 1/2
とすると、Aには0が入ってしまう。これは複雑な式を扱う場合によくやる バグである。すなわち、
REAL A,X,Y
A = (EXP(-2*X) + 1/2)*SQRT(2*Y)
等のような式を計算しているとき、1/2がゼロになっていて正しい答えを 出していないことに気が付くのは難しい。浮動小数点を含む式では 全ての定数を実数(整数でない数)であることを明示する、すなわち
REAL A,X,Y
A = (EXP(-2.00*X) + 1.00/2.00)*SQRT(2*Y)
のようにすることを習慣づけておこう。FLOAT(1)/FLOAT(2)などのように 型を明示しても良い。

 次に、CやJavaなどの言語を先に知っていて、Fortranに移った人が やるバグに、関数の変数受け渡しによる型不整合がある。 たとえば以下のようなプログラムを考える。

PROGRAM TEST
REAL A
CALL FUNC(A)
PRINT *,A
END

SUBROUTINE FUNC(A)
INTEGER A
A = 2
END
 メインでは変数AをREALで宣言しているが、サブルーチン内では INTEGERだと宣言している。これはCやJavaならエラーだが、 Fortran77ではエラーにならない。実際に実行すると、 たとえば、2.80259E-45等の意味のない値が表示される。 これは、Fortranの変数の渡し型がポインタ渡しで、 コンパイル時に型チェックを行わないのが原因だが、ここで詳しくは述べない。 このようなバグも発見しづらいバグの一つだ。

 たとえば REALとDOUBLE PRECISIONなどを、 単に浮動小数だとしか認識していないと、関数の呼び出し側と内部で 型が違ってもあまり気にならない。しかし、試してみるとわかるが、 これもおかしな値になる。場合によってはこれらを自動で型変換してくれる 処理系もあるようだが、これが混乱のもとになっているようだ。 できれば使う実数の全ての精度を一致させるのが望ましいが、メモリの 制約などでどうしても二種類以上の精度の実数を使う場合は、かなり注意しながら コーディングを行う必要がある。

中級編:

 デバッグする際、一番楽なのはGUIデバッグ環境でプログラムを 組むことだ。簡単なバグなら対処法まで教えてくれる統合環境なども 存在する。しかし、それらのアプリケーションは(当たり前だが)有料である。

 そこで、無料で使えて、かつ強力なデバッグ支援ソフトである gdbを紹介する。本来ならブレークポイントの設置や変数チェック、 トレースなどの機能も使えるが、ここでは最低限の使い方しか紹介しない。

 まず、プログラムのコンパイル時に、オプション「-g」をつける(処理系により 異なる場合があるのでmanなどでチェックしておくこと)。そうして 実行プログラムとしてa.outというファイルが出来たとして、 コマンドライン上で

% gdb ./a.out
と入力すると、gdbが起動する。gdbはCUIベースのプログラムなので、 命令は全てコマンドとしてプロンプトから入力する。 よく使うコマンドは以下の通り。

run
プログラムを実行する(rリターンと入力しても良い。以下同様)
step
停止したプログラムの実行を次に進める
list
ソースを表示する。範囲指定もできる
quit
gdbを終了する

 gdbのいちばん簡単な使い方は、とりあえず-g付きでコンパイルし、 gdb上で実行させてエラー行を表示させることだ。 エラー行さえわかってしまえば、だいたいのバグの原因はわかる。 たとえば、配列への代入で止まっていたら配列の引数の範囲エラー、 指数関数などの計算で止まっていたら不正な引数、割り算で 止まっていたらゼロ除算、などである。

 たとえば例として、以下のようなプログラムを組んだとする。

       program test
       integer i,j
       do i=1,10
         j = 3/(i-4)
         print *,j
       enddo
       stop
       end
あきらかにi=4の時にゼロ除算が起きる。実行すると、
 -1
 -1
 -3
Floating Exception
Aborted
などと表示され、異常終了する。 このプログラムを-g付きでコンパイルし、
% gdb ./a.out
とgdbからプログラムを起動する。 GPLであるというメッセージのあと、
(gdb)
というプロンプトが表示されるので、
(gdb) r
と、「r(リターン)」と入力するとプログラムがスタートする。 今回の場合、
 -1
 -1
 -3
Program recieved signal SIGFPE, Arithmetic Exception.
0x009a8f9s in MAIN__ () at test.f:5
5          j = 3/(i-4)
Current language:   auto; currently fortran
などと表示されるであろう(詳細は処理系によって異なる)。 まず、「SIGFPE」すなわち「Floating Point Exception」という シグナルを受け取り、プログラムが止まったことを示す。

 場所は test.fというファイルの五行目、メイン関数 MAIN__()の中であり、 デフォルトでその5行目を表示してくれるはずである。 最後の情報はプログラム言語の表示である。通常は自動認識してくれる ので設定の必要は無い。 他にもいろいろ機能があるが、 それはman gdbか、別の書籍を参照して欲しい。

上級編:

 上級編というか、gdbで場所が特定できないようなバグを 探すには地道な努力しかない。それでも、単にソースのプリントアウトと にらめっこをするより効率の良い方法がある。それがPRINT文デバッグである。

 PRINT文デバッグは、要するにどんどんPRINT文をはさんでバグの個所を 特定しようとするデバッグ法で、かなり泥臭い。たとえば、 gdbではエラー行が特定できないが、プログラムが止まってしまう場合、 エラー行を探すのには、PRINT文で二分探索をする。

 すなわち、プログラムを二つの部分に分け、それぞれに

print *,1
  ・
プログラムの前半
  ・
print *,2
  ・
プログラムの後半
  ・
print *,3
とPRINT文をはさんで実行する。これで、1しか表示されなければ、 バグは前半にあるので、前半に対して同じことをする。2まで表示されれば 後半にあるため、後半に対して同じことをする。以下くりかえして エラー行を特定するやり方である。

 かなり泥臭く感じるやり方であり、スマートではないが、 プリントアウトとにらめっこするよりよっぽど早くバグが見つかる。 デバッグしているときは頭が疲れている場合が多い。こういうときは頭を使わず、 上記のような確実な探索の方が効率が良い。

 ちなみに、gdbでエラー行を見つけられないがプログラムが止まる場合、 一番良くあるパターンは、ライブラリを使っている場合である。 ライブラリは-g付きでコンパイルされていないため、デバッグ情報が 付加されておらず、gdbはエラーの起きたアセンブラの番地と その付近のニモニックしか表示できない。 この場合は、上記の方法でエラー行を特定したあと、引数の範囲が 正しいか、などのチェックをする。それで駄目なら、ライブラリが おかしいのかもしれないので、ライブラリ単体をテストするテストプログラムを 作成してチェックするしかない。 処理系の組み込み関数を使っている場合も同様である。

特別編:

 個人的な話だが、友人に 「自分のプログラムにはバグが入らない。もしあったとしても 速攻で発見&デバッグできる」と公言する凄腕のプログラマーがいる。 彼に教わったデバッグ法を紹介しておこう。

 プログラムにわけのわからないバグが発生した場合、 僕らが普通取る行動は、プログラムを眺めて、上から順に追っていく ことであろう。これは、「自分は正しくプログラムを書いている筈」という 先入観が邪魔をして、まず成功しない。だいたいが 最後までプログラムを見終わって「うーん、合っているはずだよなぁ」と つぶやくのがせいぜいである。 合っていたらバグなんて発生しない。そもそもバグが起きたら 「なんでだよ〜」と思う時点で罠にはまっていると言ってよい。 バグは全て自分のせいだということを思い返そう。

 くりかえすが、いきなりプログラムを眺めながら頭の中でプログラムを仮想実行する方法は あまり良くない。そこで、逆に「こういうバグが発生するとしたら どういう原因が考えられるだろう」と考える。

 たとえば、エネルギーが保存しないとする。そのとき、 プログラムを見る前に、エネルギーが保存しないとしたら どんな原因が考えられるかを考え、それをリストアップする。 「精度が足りない」「DOループの添え字などのミス」「力の計算がおかしい(括弧の不整合など)」など いろいろ考えられるだろう。「単純ミス」などとせずに、「あのルーチンの、あそこが間違っている」などとできるだけ具体的に 思い描くのがコツである。

 リストアップした後は、それらを 判別する方法を考える。たとえば「精度が足りない」であれば、 時間刻みを半分にして実行し、その挙動を調べることで精度の問題か どうか分かるはずである。力の計算がおかしいのなら、 二粒子の正面衝突など、エネルギーの時間発展が厳密に計算できる系で解析解と比較する。 余談だが、シミュレーションを組んだら、まず解析解がある(もしくはそれに近い)条件で 計算して結果が正しいか確かめるのが基本である。

 上記の手順をまとめると次の通り

  1. まず、バグの原因となりうる条件をできるだけ多くリストアップする
  2. それぞれの条件について、それが原因であることを確かめるための条件を考える
  3. print文デバッグなどで、上記の条件を一つ一つチェックする
  4. これで発見できなかった場合には、1に戻る
 このように、「考えられる原因」と「その対策」を 十分練って初めてプログラムを見る。そのときには 「自分はあっているはず」という偏見は取れ、 「バグがあるとしたらこのあたりのはず」という見方になっているだろう。 これならバグの発見率が高くなる。

 このデバッグには、かなりの想像力が必要である。 一つの現象から、いくつの原因を考えられるかが勝負である。 そのためには経験も必要だが、普段からこのようなデバッグ法で デバッグする癖をつけておくことが必要だ。


トップページへ戻る