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

第4回 C言語のテクニカルバグ

2014.11.06

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

・・・・・・・・・・

プログラムに入り込むバグは、大きく2種類に分けることができる。

一つは、プログラムの処理方法や手順が、意味的に目的を満足するものではなかったというもので、プログラムの目的や実装するアルゴリズムに対する理解が足りなかったり、事前検討が不十分であった場合に起きる。便宜上、このタイプのバグを アルゴリズムバグ と呼ぶことにしよう。

もう一つは、プログラムの意味は正しいものの、なぜか想定外の動作をしてしまうというもので、記述時の些細なミスや勘違い、あるいはライブラリや言語処理系の動作に対する思い込みや理解不足が原因で起きる。こちらのタイプは、便宜上 テクニカルバグ と呼ぶことにする。

もちろん、両者は排他的な関係ではない。完全なアルゴリズムバグや完全なテクニカルバグもあるが、両方の意味をあわせ持つ境界的なバグもある。

究極的には、どちらのタイプのバグに対しても、「良く考え、良く調べ、注意深く書き、注意深く検証する」ことによって対応するしか無いのだが、ある種のテクニカルバグについては、バグを仕込まないようにするためのポイントや、効果的なデバッグ手法が存在する。これらは、理論的な話ではなく、経験に基づく技法的な話であるため、教科書などにはあまり載っていないように思う。今回は、C言語において私が実践している、そういった技法をいくつか示してみようと思う。

C言語は悪の言語?

C言語は、非常に厄介な言語である。プログラムを書く時、ちょっと記述を誤ると、エラーにならずにコンパイラが全く違う解釈をし、意図しない処理が行われてしまう。これは、実際に動作させてみて、動作不良が起きてはじめて認識できるバグで、しかも原因が非常に見つけにくい。

そもそもの根本的な問題は、C言語の言語仕様が非常に緩やかなことにある。

たとえば、if文の条件節は 論理値 ではなく であると定義されている。式が0と評価される場合は「偽」と判断され、それ以外は「真」と判断される。したがって、if文の条件節には式であれば何でも書くことができ、コンパイルエラーにはならない。

この仕様により、if(func(x)) というような簡素な書き方が可能となる反面、if(a == b) とすべきところを誤って if(a = b) と書いてしまい、非常に見つけにくいバグを仕込んでしまった、ということも起こり得る。

C言語には 論理値 という型が無く で代用していること、代入演算子 = と等値演算子 == の見た目が似ていて誤認しやすいこと [1] などが、C言語の「悪い所」として槍玉に挙げられているのは、技術者の諸君には周知のことであろう。

それでもC言語が書けないと……

プログラミング言語論としては、このような指摘はもっともであり、より誤りの混入しにくい仕様のプログラミング言語を使おう、という流れは間違っていない。たとえば、C++言語やJava言語などは、C言語の簡便性をなるべく維持しつつ、言語仕様をより厳密にして誤りの混入を抑える、という方向性でそれなりにバランスさせた言語であると言える。[2]

しかし、職業プログラマである我々としては、「C言語では正しいプログラムを作る自信がありません」などとは、口が割けても言うことはできない。C言語であれ、アセンブリ言語であれ、機械語であれ、必要とあらば正しいプログラムが書けなければならない。当面、「C言語は捨てましょう」などと言うことは出来そうもないのが現実である。

条件節

if , while , do など、条件節を取る構造では、条件節が であることに十分に注意しなければならない。正しい式であれば何を書いてもコンパイルエラーにはならない。また、C言語では実行文はほぼ全て であることにも注意する必要がある。

===

経験上、最も凶悪なミスは前節でも示した if(a = b) である。これは本当に見つけにくい。残念ながら、有効な対策はあまりない。感覚的ではあるが、書くときに == を単一のキーワードとして一気に書く癖を付けること、コードを見る時に === を直感的に見分けられるように訓練すること、くらいである。また、このミスが一番ヤバいという意識を常に持っていれば、バグ現象を解析する段階で、「あそこの === に間違えて書いたかも」と気が付く可能性も高くなる。

=== のミス以外にも、条件節が式であることに起因するミスはあり得るが、そのほとんどは、「条件節は必ず論理値とする」とコーディング時に制約を課してしまうことで避けられる。実際にはC言語には論理値型は無いので、比較演算子または等値演算子の結果、およびそれらを論理演算子で結合した結果を論理値型と考えておくとよい。

たとえば、成功時に0を返し、失敗時に0以外を返す関数を呼び出して判断したい場合、if(func()) と書くのではなく、if(func() == 0)if(func() != 0) など、必ず比較・等値演算子を使って論理値としておくということである。

評価順序

式は、実行時に評価されて値が計算されるが、式の評価順序が問題になることも多い。いくつか例を見てみよう。

関数引数

評価順序の問題で最もヤバいのは、関数引数の評価順序である。たとえば、

a = f1(g1(b), c, g2(), g3(d));

という式を書いたとする。これが「ヤバい」と直感的に感じるならば、C言語書きとしては、まずまず合格である。この式は、たまたま動くこともあれば、たまたま動かないこともある。

もし、上記 f1() 関数の引数として渡される g?() の呼び出し順が重要である場合、たとえば、まず g1() を実行してから g2() を実行し、最後に g3() を実行することを想定している場合、多くのコンパイラではプログラムは正しく動かない。C言語の仕様では、関数引数の評価順序については実装依存とされているため、希に正しく動くコンパイラもあるとは思うが、いずれにせよ、関数引数の評価順序に依存したプログラムは、C 言語仕様としては誤ったプログラムということになる。

g?() の呼び出し順序が重要である場合は、次のように書かなければならない。

x1 = g1(b);
x2 = g2();
x3 = g3(d);
a = f1(x1, c, x2, x3);

関数の引数は、何となく「前から後ろへ」順序付けて書いてしまうものである。引数はその位置により意味が確定しているので、左から順に、まずこれ、次にこれ、それからこれ、という具合に考えてしまう。たとえば上の例では、まず g1(b)、次に c、それから g2()、最後に g3(d) という意識をもって書いているということである。このため、引数として渡される関数の呼び出しも g1(b)g2()g3(d) の順に行われると思ってしまい、落とし穴にはまるというわけだ。

なお、実際の多くのコンパイラでは、関数の引数は後ろから前へ逆順で評価されることを知っていると、デバッグの際に多少は役に立つ。

中置演算子

続いて評価順序で問題になるのが、+* などの二項中置演算子を組み合わせて書かれた式の各項の評価順序である。たとえば、

a = g1(b) + c + g2() + g3(d);

という式を書いたとする。これも「ヤバい」と感じれば上々である。この式も、たまたま動くこともあれば、動かないこともある。

前の例と同様、g?() の呼び出し順序が重要である場合、多くのコンパイラでは正しく動かない。関数引数と同様、なんとなく見た目には g1()g2()g3() の順に処理されそうに思うが、C言語では式中の可換な項の評価順序は実装依存なので、C 言語仕様としては誤ったプログラムである。

g?() の呼び出し順序が重要である場合は、次のように書かなければならない。

x1 = g1(b);
x2 = g2();
x3 = g3(d);
a = x1 + c + x2 + x3;

可換な演算子の被演算数については、数学的に可換だということに気づけば、「さて呼び出される順序は?」と、気にする可能性はあるものの、一般的には前から後ろへと読みがちであるから、落とし穴にはまることになる。

なお、関数引数の評価順序と同じで、可換な演算子の被演算数も、後ろから前に評価するコンパイラが多いことを知っていると、たまには役に立つ。

式の実行順序

一つの式を構成する要素(引数または被演算数)を、独立した一つの式として文にし(式の後ろにセミコロン付けると文になる)、これを順に列挙することで評価順序を保証できるのは、C言語の仕様に「文は順に実行される」という決まりがあるからである。もし、複数の文を構成せずに評価順序を保証したい場合は、コンマ演算子を使うことができる。たとえば、

a = (x1 = g1(b), x2 = g2(),
     x3 = g3(d), x1 + c + x2 + x3);

と書けば、g1()g2()g3() の評価順序を保証しつつ、単一の式として記述することが可能となる。[3]

結局、このような評価順序の落とし穴にはまらないためには、次のようなコーディング規則を自分自身に課しておくのが得策である。

  • 関数は、なるべく呼び出し順序に依存しないように作っておく。
  • 関数呼び出しは、なるべく変数への直接的な代入文で実行させる。

さらに評価順序(上級者向け)

「文は順に実行される」というC言語の規則を盲信してはいけない。C言語仕様の言わんとしていることは、変数や関数というC言語の論理的な構成要素に対してであって、実際の物理的なメモリオブジェクトやマシンコードを想定しているわけではないたとえば、

a = a1 + a2;    .... S1
b = b1 + b2;    .... S2

という文の並びがあったとする。C言語仕様としては、まずS1が実行されて a という変数の値が決定され、続いてS2が実行されて b という変数の値が決定される、と言っているだけで、S1の実行時に変数 a を保持する物理的なメモリオブジェクトの内容が書き換えられる、と言っているわけではない。

実際、変数 a の物理的なメモリオブジェクトの内容は、S1の実行時に書き込まれるかもしれないし、S2が終了しても書き込まれないかもしれない。いずれ、どうしても必要な時点で書き込まれるはず、としか言えない。[4]

これは、変数へのポインタを使って内容をアクセスする場合には、相当に注意しなければならないことを意味する。たとえば、

void f()
{
   int a;
   int b;
   int *x;

   x = g(&a);

   a = x1 + x2;    .... S1
   b = x3 + *x;    .... S2
   ....
}

int *g(int *y)
{
   return y;
}

というコードでは、S2において現実には変数 a のメモリオブジェクトを参照するため、S1とS2は相互依存関係にあるにもかかわらず、関数 f() の解析だけではそのことを知ることができない。したがって、コンパイラは変数 a のメモリ内容の書き込みをS2以降に遅延するかもしれず、そうなるとS2の計算結果は意図したものと異なる結果となり得る。[5]

メモリオブジェクトへの実際の書き込みが保証されない、という点については、共有メモリによって結合される複数のプロセスまたはスレッド、あるいはデバイスレジスタを直接アクセスするデバイスドライバなどにおいて、特に注意する必要がある。たとえば、デバイスドライバにおいて、

unsigned long *reg = 0x10000000;
unsigned long x;

reg[0] = 0x00000001;           .... S1
reg[1] = 0x00000002;           .... S2
x = reg[2];                    .... S3
....

というコードを書いたとしよう。デバイスレジスタが仮想アドレス 0x10000000 番地に割り付けられており、レジスタ0(1つ目のロングワード)に値1を、レジスタ1 (2つ目のロングワード)に値2を書き込み、続いてレジスタ2(3つ目のロングワード)から値を読み込む、というコードである。

ここで、ハードウェア的に、レジスタ0への書き込みがレジスタ1への書き込みより先に実行されていなければならない、という制約があるとすると、このコードは正しく動かない可能性がある。C言語の仕様上は、S1 ⇒ S2の順で実行するのだが、S1とS2には相互依存性が無いため、実際のメモリオブジェクトへの書き込みは遅延され得るし、順序も保証されない。

このようなコードを正しく動かすためには、reg 変数の宣言に、以下のように volatile修飾子が必要である。

volatile unsigned long *reg = 0x10000000;

ポインタ変数 reg がvolatileであると宣言することにより、reg 経由のメモリアクセスはC言語としての文の実行時に確実に実行されることが保証される。つまり、S1でレジスタ0へ値1を本当に書き込み、S2でレジスタ1へ値2を本当に書き込むコードが生成される。

デバイスレジスタへのアクセスでなくとも、共有メモリでデータを交換するプロセスやスレッドでも、同様の問題が生じる。いずれも、volatile修飾子を適切に使うことが必要となる。

さらにさらに評価順序(もっと上級者向け)

前節のデバイスドライバのコード例では、reg の宣言にvolatile修飾子を付けることにより、プロセッサはS1 ⇒ S2 ⇒ S3という順序でレジスタをアクセスするマシンコードを吐く。ここまでは良い。しかし、最近のプロセッサには、パイプラインやらキャッシュメモリやらライトバッファなどがあり、プロセッサコアのマシンコードとして実行されるメモリアクセスが、本当に物理メモリにそのまま適用されるとは限らないのだ。

実際、前節のS1とS2の書き込みについても、もしレジスタ領域がキャッシュに載っていたりすると、正しく動作しない。もし、書き戻しキャッシュがあるとすれば、レジスタ0とレジスタ1のアクセスは書き戻しキャッシュ内の該当ラインへの変更として実行され、後のいずれかの時点で、キャッシュ書き戻し動作としてまとめて書き込まれることになる。読み出しキャッシュも同様に問題になる。

したがって、デバイスレジスタについては、キャッシュはすべて無効とするのが普通である。キャッシュが無効であれば、書き込み・読み出しはマシンコードとほぼ同期して実際のメモリオブジェクトに対して行われるはずである。

なお、共有メモリによるプロセスやスレッドの場合は、通常はキャッシュを気にする必要はない。プロセスやスレッドを実行するプロセッサは、たとえマルチプロセッサ構成であったとしても、キャッシュを透過的にアクセスできることが保証されているからである。

さて、前節のデバイスドライバの例において、S3でレジスタ2を読む前に、レジスタ0とレジスタ1への書き込みが確実に実行されていなければならない、というハードウェア的な制約があるとしよう。レジスタ領域はキャッシュ無効の状態であるとして、S3でのレジスタ 2 の読み込みは正しく実行されるであろうか?残念ながら、近年の多くのプロセッサでは、正しく実行される保証はないのである。

近年のプロセッサでは、キャッシュを無効にしてもライトバッファというものがあって、実際のメモリオブジェクトへの書き込みを遅延させたり、あるいは順序を入れ替えたりする。この影響で、S3の実行の前にS1とS2でのレジスタへの書き込みが本当に行われたと保証することができないのである。

前節のデバイスドライバの例を、近年のプロセッサに対応させて書くと、次のようになる。

volatile unsigned long *reg = 0x10000000;
unsigned long x;

reg[0] = 0x00000001;    .... S1
reg[1] = 0x00000002;    .... S2
memory_barrier();
x = reg[2];             .... S3
....

memory_barrier() 関数は、OSが用意している「メモリバリア」機能の呼び出しで、メモリバリア呼び出し以前にC言語的に実行されたメモリオブジェクトへの読み出し・書き込みを、確実に完了させるという意味を持つ。これにより、S3の実行時には、S1とS2による書き込みが確実に完了していることが保証される。実際には、メモリバリア機能の呼び出しにより、ライトバッファのフラッシュ(貯めてあった書き込みを全て実行してしまう)処理が行われることになる。

ここまで来ると、C言語仕様による「文の実行順序」など、いかに上っ面のものであるか、良くわかるであろう。いくらC言語の字面を工夫しようとも、キャッシュやライトバッファは制御できないのだ。結局、正しいプログラムを書くには、物理的に何が起きているのかを正確に把握し、それを精密に制御するようにする以外に手はないのである。

[1]通常ALGOL系のプログラミング言語では、代入には := が使われる。
[2]しかしながら、C++言語には依然批判多いことも周知のことであろう。
[3]しかし、実際にコンマ演算子を使うことはほとんどなく、また使われている例を見ることもほとんどない。
[4]a がレジスタ変数で済んでしまうならば、書き込むべきメモリさえ割り当てられないかもしれない。
[5]f()&a を参照している時点で、a をレジスタ変数に割り付けるという案が棄却され、また a がポインタとして使われていることを考慮して、a のメモリオブジェクトへの書き込みをS1時点で実行するようなコードを吐くコンパイラが多いとは思う。特に、f()g() が同じファイルにある場合は、f() のコンパイル時に g() の処理も参照できるため、正しくコンパイルされる可能性は高い。

注釈

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

著者プロフィール

tom

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

記事一覧Index