お詫び
この文書は、他文書の位置づけや解釈を誤っており、不適切です。不備の指摘の記録として残します。
「C言語のことなら分かってます」よね?
私は株式会社 ACCESS の三原と申します。ACCESS Advent Calendar 2016 の4日目をお読みいただき感謝申し上げます。
C言語。最近では影が薄くなりましたが、一昔前ならPCでプログラムを作るとなるとプログラミング言語は一択に近い状況でした(実際にはC++でしたが)し、組込みプログラミングではいまだ現役。手習いした方も多いはず。なにより文法・機構が単純。皆さん、C言語のことなら分かってますよね?
私もそう思っていたんです。あるトラブルまで。その、お恥ずかしい話をここで披露します。
動いていた製品が新しいCコンパイラの最適化で動作が変わってしまった!
弊社に、手直しを続けながら十数年売り続けている製品がございます。売りは、C89 に沿って書かれているので、組込み機器向けのマイナーなCコンパイラでもバグが起きないこと。だから移植はコンパイルするだけ。そう思っていました。
ところがある時、顧客指定のCコンパイラでコンパイルしたところ、プログラムがある段階から進まないトラブルが発生しました。調べてみると、最適化を強くすると処理の進行に不可欠なフラグが立たないことが分かりました。
問題の箇所は次のようなコードでした。(製品そのままではありません。模擬コードです)
/*
* これが親構造体A。
* 自分自身のメンバflagを持ち、
* かつ内部に子構造体Bを抱えている。
*/
struct A {
int flag;
struct B child;
};
/*
* これが子構造体B。
* 親構造体Aを参照できる。
*/
struct B {
struct A *parent;
};
/*
* 子構造体Bのポインタ取る関数が、
* 戻り値を戻す前に、
* 親構造体Aのメンバflagを変更する
*/
int
func_b(struct B *self) {
self->parent.flag |= 0x1;
return 0x2;
}
struct A instance;
int prev_flag, bits;
/*
* 親構造体Aのフラグはすべてクリア。
* 子構造体Bから親構造体Aへの参照を設定。
*/
instance.flag = 0;
instance.child.parent = &instance;
/*
* 親構造体Aのフラグの値を参照。
*/
prev_flag = instance.flag;
/*
* 子構造体Bを引数に関数呼び出し。
* 戻り値は変数に保存。
*/
bits = func_b(&instance.child);
/*
* 関数の戻り値を親構造体AのフラグにORで設定。
* フラグの値が累積して 0x3 になるはずが……
*/
instance.flag |= bits;
/*
* ここで構造体Aのフラグを確認すると0x2。
* func_b() で設定したフラグが消えている!
*/
printf("0x%x\n", instance.flag);
関数内でフラグを設定して、かつ戻った後に他のフラグをORで立てると、関数内で設定したフラグが消えるのです。これでは意図通りに動きません。
顧客指定のCコンパイラはIAR Embedded Workbench for ARM Ver 6.7。ベンダWebサイトには高速な機械語を出力することが大きく宣伝されています。最適化のし過ぎでバグが出てるんじゃないのか。最初はそう思いました。
Cコンパイラは正しかった。原因はこちらの勉強不足……
Cコンパイラのバグとしても根拠がいるだろう、と思って探して見つけた資料が"Rationale for International Standard— Programming Languages— C"。リンクを張ったのはC99の仕様書ですが、これから参照する箇所にはC89との異同が記されていません。
そこには、上記のようなCコンパイラの挙動を認める記述がありました。
次の1段落は誤解によるものです。平に謝り、訂正いたします。 ~~~上の文書の"6.5.16 Assignment operators"において、変数の値を変更する副作用がどのようなコードで起きるか規定されています。そこで、関数呼び出しにおいて副作用が起きるのは、関数がその変数そのものを引数として参照した場合だけと規定されています。~~~
次の1段落は誤解によるものです。平に謝り、訂正いたします。~~~上のコードでは、親構造体Aにではなく子構造体Bに対して関数を呼び出しているので、Cコンパイラは親構造体Aには副作用は起きないとみなしていいんです。そして、直前のコードで親構造体Aのメンバを参照しているので、その値は先に読み出したときから変化していないとみなしていいんです。~~~
訂正後は次の箇所です。上の文書の"6.7.3 Type qualifiers"、75頁10行目から14行目にかけて次のように記されています。
Type qualifiers were introduced in part to provide greater control over optimization. Several important optimization techniques are based on the principle of “cacheing”: under certain circumstances the compiler can remember the last value accessed (read or written) from a location, and use this retained value the next time that location is read.
(拙訳) Type qualifiers は最適化に対する制御の一部として導入された。いくつかの重要な最適かテクニックは "caching" の原則を基盤においている: 一定の条件において、コンパイラは、あるロケーションに最後にアクセス(読み出したか書き込んだ)値を記憶し、その保存した値を次にそのロケーションが読み出されるときに使用してよい。
ならびに75頁20行目から24行目にかけて次のように記されています。
volatile: (引用略) In the absence of this qualifier, the contents of the designated location may be assumed to be unchanged except for possible aliasing.
(拙訳) volatile: (中略) この修飾子がない場合、指定されたロケーションの値はエイリアシングがない場合を除いて変更がないとみなしてよい。
上のコードでは、関数func_b()を呼び出す前にinstance.flagの値を読み出しています。そしてfunc_b()はinstance全体を参照していません。よってfunc_b()がinstance.flagへのエイリアシングがないとみなせばinstance.flagの値は事前に読みだした値を再利用してよい、つまり現在のinstace.flagを再評価しなくてよいことになります。
再びコードを引用して説明します。
/*
* ここで親構造体Aのフラグの値を参照したので、
* フラグの値は分かっている。
*/
prev_flag = instance.flag;
/*
* 関数の呼び出しは子構造体Bが対象なので、
* 親構造体Aへの副作用はないとみなしていい。
*/
bits = func_b(&instance.child);
/*
* 親構造体Aのフラグの値を参照してから
* 副作用を生じる操作がなかったため、
* Cコンパイラは親構造体Aのフラグが
* 変わっていないとみなしてよく、
* 先ほど読みだした値を再利用することが
* 許されている。
* だから、関数呼び出し内で
* 設定したフラグの値が消える。
*/
instance.flag |= bits;
考えてみればCコンパイラベンダはC言語規格の専門家です。C言語プログラムの最適化で許される範囲なんて知り尽くしている訳です。こちらがCコンパイラのバグだと言い張れば恥をかくところでした。
この後のプログラムの修正は、ロジックの検証が必要な改変を避けたかったので、volatile 宣言付きポインタにアドレスを代入してそちらを参照するようにしました。フラグを1回1回参照するだけなのでそんなに遅くなってない、と思っています。
どのように避けるか
上のプログラムの根本的問題は、子構造体が親構造体のメンバを変更するという変則的な構成になっていたことです。副作用を起こすつもりなら、副作用を起こす変数を引数に取らなければいけません。
え? もっと根本的な問題? C言語の規定を知らないためのバグをどう避ければいいかって?
それは難しいです。プロジェクトメンバ全員がC89やC99の規定を熟知しているなんて夢のような話です。上のような問題を知っているメンバが1人いることすら稀でしょう。
現実には、上記のような文書の存在を頭に入れておいて、Cコンパイラのバグと思われる挙動を見つけたら、まず自分を疑って、おもむろに文書にあたるしかないと思います。
明日の ACCESS Advent Calendar 2016 は @akegashi さんです。どうぞお楽しみに。