プログラマーの理想と現実

第7回 メモリ破壊バグをつきとめる

2015.02.04

この記事は、『UNIX Magazine』2004年3月号(2004年2月18日発売)に掲載された同名記事の初稿(著者から編集部に提出したもの)を元に、Web掲載用に一部を修正したものです。10年以上前に執筆したものなので、現在のUNIXを取り巻く環境とは色々と異なることがありますが、プログラミングに対する心構えとしては現在でも通用するものと思い、再掲してみることにしました。

・・・・・・・・・・

前回は、メモリ破壊バグの生じる原因について考察したところで終わってしまった。今回は、メモリ破壊バグの位置を特定する方法について考察しよう。

メモリ破壊バグを突き止める

典型的なメモリ破壊バグでは、プログラムコード上で誤っている(メモリを書き壊す)場所と、実際にプログラムがコケる場所に、位置的にも時間的にも隔たりがある。また、プログラムがコケた場合、スタックが破壊されていることもあり、デバッガなどで動作状況が把握できないこともある。

これはすなわち、ブレークポイントを仕掛けて特定の場所でプログラムの動作を止め、変数の内容を確認して誤りを見つけるというような、一般に最も利用されているであろうデバッガの機能が直接的には使えないことを意味する。

では、メモリ破壊バグに対して、デバッガが全く役に立たないかというと、そういうわけでもない。十分に役に立つ。

ウォッチポイント機能

多くの高機能デバッガには、ウォッチポイント機能 という便利なモノが備えられている。GDBではwatchまたはawatchコマンドとして、SunOSのdbxではstop / when / traceコマンドに対するイベント指定として利用できる。

大雑把に言えば、ウォッチポイントとは、デバッガがプログラムの実行を監視し、指定した番地に対する特定のアクセス—読み出し、書き込み、または値変更—が発生した時点で、実行を止めるか、あるいは表示を行うなどのアクションを実行する機能である。

メモリ破壊バグにおいて、意図に反して破壊されてしまった、つまり想定していない値が書き込まれてしまったメモリ番地が特定できるのであれば、原理的にはウォッチポイント機能を用いて、問題のメモリ番地に書き込む瞬間を捕まえることができる。もし、うまく捕まえられれば、その時に実行していたプログラム箇所がバグの原因であり、一件落着である。

ウォッチポイント機能は、メモリ破壊バグに対して唯一、デバッガがバグを直接的に”見つけてくれる”機能である。こんな便利で直接的な機能があるのなら、なぜ「メモリ破壊バグにはデバッガはあまり役に立たない」などと言うのか。実は、ウォッチポイント機能には、プログラムの実行速度が極端に遅くなるという、非常に重大な問題がある。たいていの場合、この制約のため、ウォッチポイント機能をメモリ破壊バグのために活用することが現実的には不可能になってしまうのだ。

ウォッチポイント機能を使用した場合のプログラム実行速度は、使用しない場合の10倍以上、悪い値だと1000倍以上遅くなる。仮に100倍だったとしても、たとえば1分で終了していたプログラムが100分(1時間40分)かかるようになる。悪いケースだと1000分(16時間超)になってしまう。これだけ実行速度が低下して、デバッグ方法として使いモノになるだろうか?

第一に、長くて付き合っていられないという問題がある。端末の前でGUIの操作を1分間おこなうのは何も問題はないが、1000倍に引き延ばされて16時間も操作し続けられるだろうか?通常はボタンのクリックを1秒間隔でおこなうとすると、単純計算で1000秒間隔(16分強)でクリックを繰り返すことになる。これは現実的には無理であろう。

また、実行時間が引き延ばされることにより、実時間制約的な問題も生じる。たとえば、サーバ・クライアント・モデルのクライアント側プログラムをデバッグする場合、クライアントの動作が1000倍遅くなって、サーバはタイムアウトなどを起こさないだろうか?ホストはTCP/IP接続を維持し続けてくれるだろうか?

ウォッチポイント機能は大変便利であるが制約も多い。色々と試してみて、許容範囲内であれば積極的に利用するべきだが、私の感覚としては基本的に使いモノにならないと考えている [1]

ウォッチポイント機能の実現方法

本筋からは少々脱線してしまうが、ウォッチポイント機能の実現方法について考察してみよう。そもそも、何でそんなに遅いのか?

あらかじめ断っておくが、ウォッチポイント機能が遅いのは、UNIXなどの保護されたOS上で安全に稼働するユーザプログラム用のデバッガに限ってである。OSの強力な(あまり安全でない)サポート機能があったり、ハードウェアを直接制御できるような仕組みがある場合は、実行速度を低下させないウォッチポイント機能を実現することができる。たとえばUNIXのカーネルデバッガ、CPU の動作をハードウェア的にエミュレートするICE (In-Circuit Emulator)装置、CPUに組み込まれたハードウェアデバッグ機能を用いるJTAGデバッガ装置などでは、原理的に実行速度を低下させないウォッチポイント機能が実装可能である。

ハード屋の感覚からすると、”指定された番地へのアクセスを検出する”という機能は次のように実装することになる。

  • CPUのアドレスバスにコンパレータを介してレジスタ(フリップフロップ群)を接続し、レジスタの内容とアドレスバスの内容が一致した時に信号が出るようにしておく。
  • 一致時に出る信号をCPUのHALT端子に接続してCPUの動作を凍結させるか、あるいはNMIなどの割り込み線に接続して割り込みハンドラを起動し、スタック上に保存された状態から直前の実行番地を割り出す。

現実的には色々と難しい問題があるが、まあだいたいこのようなハードウェア的な補助があれば、実行速度の低下を伴わずにウォッチポイント機能を実現できる。

しかし、UNIXにおけるユーザプログラムのデバッグにおいて、このようなハードウェアの手助けを得ることはほぼ不可能である。第一に、一般の計算機にこのような特殊なハードウェアを積む積極的な理由がないこと。第二に、たとえ積んだとしても、このようなハードウェアはシステムで1つ(または限られた数)しかなく、同時に複数のデバッガプロセスが自由に利用するのが難しいこと。第三に、このような機能はOS側で標準化しにくく、デバッガが機種依存の実装になってしまうこと。などが理由として挙げられる。

では、ハードウェアの補助なしにウォッチポイント機能を実現するとしたら、どのような方法が可能であろうか?一般的なデバッガで実際に行われている方法は、次のようなものである。

  • デバッガは、デバッグ対象プロセスの実行を1命令単位で止るように、つまり機械語レベルでのシングルステップ実行を行うように設定する。
  • 各シングルステップ動作の終了時に、指定された番地の内容を読み出し、変更されているかどうか調べる。

実際には、もう少し複雑な処理が必要であるし、より効率的なやりかた(ページ保護によるアクセス違反を検出するなど)もあるが、原理的にはデバッグ対象プロセスにシングルステップ動作を行わせて、逐一確認するという方法になる。

デバッグ対象プロセスのシングルステップ動作は、OSがサポートしていることもあるし、サポートしていない場合はデバッガがデバッグ対象プロセスのコード領域を直接書き直して、命令実行後にトラップがかかるようにするなどの処理で実現される。いずれにせよ、デバッグ対象プロセスのステップ実行が終了すると、デバッガにシグナルが送られ、デバッガが起き上がり、ptraceシステムコールで対象プロセスのメモリを調べる、というような処理が実行される。

もうおわかりだとと思うが、CPUがたった1命令実行するたびに、シグナル配送、コンテクストスイッチ、システムコール呼び出しなどが伴う。これらの実現には数百~数千の命令が必要であることは想像に難くない。これで簡単に実行速度が1000分の1程度に落ちてしまう。また、特に最近の高速RISCにおいては、デバッグ対象プロセスのパイプラインがきれいに流れなくなるため、速度低下はさらにひどいものになる。

これが、ウォッチポイント機能を使うとプログラムの実行が極端に遅くなる理由である。

二分探索法

デバッガのウォッチポイント機能が実用に耐えうるならば、メモリ破壊バグの発見は比較的容易である。また、メモリ破壊バグやメモリリークバグの発見をサポートするツールも色々とあるので、それらが使えるならばそれも良い。しかし、そういった便利な機能やサポートツールが使えない場合は、より低レベルなデバッグをおこなわざるを得ない。

デバッグ対象のプログラムを動作させると、いずれアクセス違反などでプログラムがコケるとする。動作開始からコケるまでの間の、どの時点でメモリが破壊されたのかを知りたい、というのが課題だ。このような課題には、二分探索法 が有用だとアルゴリズムの教科書は教えてくれる。

まずは、問題となる現象の再現性を確保する必要がある。たいていの場合、プログラムに同じ入力を与えれば、同じ結果—すなわち、メモリ上の特定のアドレスを毎回同じように破壊して同じようにコケる—が得られる。問題の再現性が得られれば、あとは二分探索法を粛々と実行するだけだ。

コケまでの時間のほぼ半分くらいで実行される関数か何かにブレークポイントを仕掛ける。場合によっては、ブレークポイントの経過カウント数を設定する必要があるかもしれない。これで、対象を前半と後半に二分できた。プログラムを実行し、ブレークポイントで実行が中断されたところで、問題となるメモリがすでに破壊されているかどうかを調べる。すでに破壊されていれば、開始からブレークポイントまでを全区間として、まだ破壊されていなければ、ブレークポイントから最後にコケるまでを全区間として、同じ処理を繰り返し実行する。原理的には、この方法で かならず メモリが破壊される原因をつきとめることができる。

しかし、この素晴らしい方法にも残念ながら欠点がある。原因が特定できるまでに、相当の回数プログラムを実行しなければならない。その各々で、毎回異なるブレークポイントの位置や条件を検討して設定しなければならない。つまり、ものすごく手間がかかるのだ。10~20回の実行で見つかれば上出来。運が悪いと100回近く実行してもまだ原因に到達しないということもありうる。

初級者から中級者くらいの技術者に、この素晴らしい方法を試すように勧めると、かなりの割合の技術者が途中で諦めてしまう。同じような操作を何度繰り返しても原因に到達できないので、不安になってしまうのだ。原理的にはかならず見つかるのだから、諦めずにやり抜くことが重要だ。

プログラマには忍耐力も必要なのである。

[1]私の手元で確認できる範囲では、少なくともSun UltraSPARC + Solaris8の組合わせでは、ほとんど速度低下を伴わないウォッチポイント機能が実装されている。

注釈

  • 初稿に由来する表現の差異、ならびにWeb掲載に伴う修正のため、雑誌掲載の内容とは一部文章等が異なることがあります。
  • 転載許諾を頂いた株式会社KADOKAWAアスキー・メディアワークスブランドカンパニーならびに旧『UNIX Magazine』編集部の皆様に深く御礼を申し上げます。

著者プロフィール

tom

当社設立直後に入社して約30 年、UNIX の移植、日本語化、デバイスドライバ開発、周辺機器ファームウェア開発などに継続的に携わり、現在も現役でUNIX 系OS の移植、改造などの開発業務を行う。社内でもっともプログラムを書いている人の一人。代表取締役社長。

記事一覧Index