「未定義動作」は「予測不能」の専門用語のように聞こえます。それより強い意味です。C標準がある構文について未定義動作だと言うとき、それはプログラムが何をするかについて標準がいっさい何の要求も課さないという意味です - 値についても、文についても、プログラムについても。
人を驚かせるのはその点です。未定義動作は問題の行に閉じ込められていません。コンパイラはそれが決して起きないと仮定してよく、その仮定にもとづいて周囲のコードを書き換えてよいのです。結果として、明らかに書いたはずの検査がバイナリに存在しないプログラムができあがりえます。
契約のモデル
標準を、あなたとコンパイラの間の契約だと考えてください。あなたは特定のことをしないと約束し、その代わりコンパイラはあなたのプログラムが書いたとおりの意味を持つと約束します。
配列の外を添字づけしないこと。符号付き整数をあふれさせないこと。未初期化の値を読まないこと。解放後のポインタを使わないこと。1つの式の中で同じオブジェクトを、間に順序点なしに2回変更しないこと。
条項を破れば取引は無効です - その行だけでなく、プログラム全体について。「妥当な代替動作」もなければ、クラッシュする義務もありません。
関連する3つの用語を区別しておく価値があります。
- 未定義動作 - 何が起きてもよい。範囲外アクセス、符号付きオーバーフロー、use after free。
- 未規定動作 - いくつかの妥当な結果のうち1つになり、コンパイラはどれかを教える必要がない。たとえば関数の引数が評価される順序。
- 処理系定義動作 - 処理系が選び、その選択を文書化しなければならない。
charが符号付きかどうか、intの大きさなど。
「最適化器が私のコードを消した」という意味で危険なのは最初のものだけです。
大きな発生源
符号付き整数のオーバーフロー
符号なしの算術は巻き戻り、標準もそう述べています。符号付きはそうではなく、範囲を超えることは未定義です。
if (a > INT_MAX - b) の検査は完全に有効範囲の内側で行われ、だからこそ正しいオーバーフローの判定になります。if (a + b < 0) と書くと先にオーバーフローを起こしてから結果を尋ねることになり、オーバーフローは決して起きなかったと仮定する権利を持つコンパイラは、その検査を取り除きうるのです。
範囲外アクセス
配列の外を読み書きすることは、クラッシュするかどうかにかかわらず未定義です。
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: 添字 5 は存在しない */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB: このポインタを計算するだけでも */
最後の行に注目してください。末尾の1つ先より遠いポインタを作ることは、たとえ参照外ししなくても未定義です。標準は arr + 5(末尾の1つ先。ループの終了判定のため)を許しますが、arr + 6 は許しません。
小さなはみ出しこそ危険なものです。たいていセグフォルトせず、静かに隣の変数を上書きし、間違った答えは無関係なところに現れます。
未初期化の読み取り
int x;
printf("%d\n", x); /* UB: 不定値の読み取り */
int *p;
*p = 42; /* UB: 不定なポインタの参照外し */
「ゴミが入っているだけだ」と考えたくなりますが、標準はそう述べていませんし、コンパイラはその差を利用します。GCCは、代入前に読まれた変数が何の値でも取りうる - 分岐が畳み込まれるような値も含めて - と結論づけることが知られています。
ダングリングポインタ
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: use after free */
free(p); /* UB: 二重解放 */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: オブジェクトの生存期間が終わっている */
実行時の帰結はセグメンテーション違反で扱っています。ここでの要点は、クラッシュしてくれるのは幸運な結末だということです。
間違った printf の指定子
printf("%d\n", 3.14); /* UB: double に対する %d */
printf("%s\n", 42); /* UB: int に対する %s - たいていクラッシュ */
printf("%d %d\n", 1); /* UB: 指定子より引数が少ない */
long n = 5;
printf("%d\n", n); /* long が int より広いシステムでは UB */
printf は可変長引数です。書式文字列に従って引数を読み、それらを検査できません。不一致があると、間違った場所から間違ったバイト数を読むことになります。-Wall を付けてコンパイルすればコンパイラが書式文字列を検査してくれます - この一式の中でも最も価値の高い警告の1つです。
1つの式の中で同じオブジェクトを2回変更する
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
これらは単に「コンパイラ依存」なのではなく未定義です。i = i++ + ++i が何に評価されるかを問う教科書のパズルには、正解が存在しません。
厳密なエイリアシング
互換でない型のポインタを通じてオブジェクトにアクセスすることは未定義で、これは経験豊富なプログラマをも驚かせます。
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: int * を通じて float を読む */
コンパイラは int * と float * が同じメモリを指すことは決してないと仮定し、それに応じて並べ替えます。バイトを再解釈する定義された方法は memcpy(同じ命令に最適化されます)か共用体です。
char * は例外です - どのオブジェクトのバイトも unsigned char * を通じて調べて構いません。
「自分のマシンでは動く」が何も証明しない理由
未定義動作はしばしば動いているように見え、それこそが危険なところです。プログラムは開発とテストの間ずっと正しく走り、それからまったく無関係な何かが変わったときに壊れます。
- より賢い最適化器を持つ新しいコンパイラのバージョン。
- リリースビルドのために
-O0から-O2へ切り替えること。 - 無関係な関数を追加してスタックの配置がずれ、はみ出しが今度は重要なものに着地すること。
- 別のマシン、別の libc、別のOS。
動いているように見えることは正しさの証拠ではありません。標準は何も約束していなかったのですから。それは休眠状態のバグであり、引き金はたいていリリースビルドです。
最適化器がそれをどう利用するか
たいてい議論に決着をつける例がこれです。あるプログラマがヌル検査を書きます。
void process(int *p) {
int value = *p; /* 参照外し */
if (p == NULL) { /* そのあとヌルの確認 */
return;
}
printf("%d\n", value * 2);
}
順序が逆です - 検査が参照外しのあとに来ています - が、検査はきっと実行されるでしょう?
そうとは限りません。コンパイラはこう推論します。*p が参照外しされた、したがって p は NULL ではありえない(NULL の参照外しは未定義なので、動作が定義されたどのプログラムでもそれは NULL ではない)、したがって p == NULL は常に偽、したがって if の本体はまるごと到達不能なコードであり削除できる。
コンパイルされた関数には、ヌル検査が1つも入っていません。このパターンの実例がLinuxカーネルで CVE-2009-1897 になりました。GCCがまさにそのような検査を取り除き、無害に見える順序の誤りを悪用可能な脆弱性に変えたのです。
もう1つ、小さめの例です。
/* 動かないオーバーフロー検査 */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* 「巻き戻ったか?」 */
return -1;
}
return sum;
}
符号付きの型についてコンパイラは a + b があふれなかったと仮定してよく、その場合 sum < a は b < 0 のときにしか起こりえません。その仮定のもとでは、この検査は書き手の意図とは別のものを判定しており、b >= 0 なら完全に最適化で消されうります。動く版は事前に確認します。
どちらの判定も表現可能な範囲の内側にとどまるので、オーバーフローは決して起きず、最適化器が消し去る仮定もありません。(GCCとclangは __builtin_add_overflow も提供していて、これを1命令で行います。)
検出する
まず静的な警告から - 無料です。
gcc -Wall -Wextra -Wpedantic program.c -o program
これで書式文字列の不一致、いくつかの未初期化の読み取り、怪しい比較、到達不能なコードが捕まります。
次にサニタイザです。プログラムを計装し、違反の瞬間に報告します。
gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
メモリの側面のために AddressSanitizer と組み合わせます。
gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program
両者を合わせれば、範囲外アクセス、use-after-free、二重解放、符号付きオーバーフロー、不正なシフト、不整列なポインタ、ヌル参照外しが - どれもファイル名、行番号、スタックトレースつきで - 捕まります。おおよそ2倍遅くなりますが、開発中には無に等しいコストです。
valgrind ./program は再コンパイル不要で、未初期化の読み取りとメモリのエラーを捕まえます(算術の未定義動作は捕まえません)。clang --analyze と gcc -fanalyzer はプログラムを走らせずに一部を見つけます。
実用的な規則です。CIでテストをサニタイザの下で走らせること。 手元では動いているように見える未定義動作こそ、それらが暴くために存在するものです。
付き合っていく
注意深くしていれば未定義動作を避けられる、というわけにはいきません - 誰でもいずれ書いてしまいます。効くのは、それを騒がしくすることです。
- 初日から
-Wall -Wextraでビルドし、すべての警告を直す。 - テストを
-fsanitize=address,undefinedの下で走らせる。 - すべての変数を宣言時に初期化し、すべてのポインタを
NULLにする。 - 配列の添字を長さと照らして確認し、
i < nでループする。 - 算術の前に
<limits.h>を使ってオーバーフローを確認する。 - 解放したらポインタを
NULLにする。 - 巻き戻りが意図した動作である場所では
unsignedの型を使う - そこでは定義されています。 - バイトを再解釈するときは、ポインタのキャストより
memcpyを選ぶ。
Cの速さは、あなたが契約を守ったとコンパイラが仮定してよいことから来ています。それは設計の欠陥ではなく本物の取引であり、上の道具は開発時のコストをほぼゼロにして安全性の大半を取り戻させてくれます。
これらの規則の実行時の顔についてはセグメンテーション違反を、先に出会うコンパイル時の間違いについてはよくあるエラーを参照してください。
よくある質問
C言語の未定義動作とは何ですか?
C標準がまったく何の要求も課さないコードのことです。コンパイラは何でも生成して構いません。クラッシュ、間違った答え、動いているように見えるコード、あるいは問題の分岐がまるごと取り除かれたコード。「処理系定義」でも「ランダム」でもありません - あなたが破った契約であり、その後は何も約束されないのです。
なぜCはすべてを定義せず未定義動作を持つのですか?
速度と可搬性のためです。すべての配列アクセスに範囲検査を要求すれば、Cが払わないよう設計された性能の代償が生じます。符号付きオーバーフローを巻き戻りと定義すれば、代わりにトラップするハードウェアで余計な命令が強いられます。それらを未定義のままにしておけば、コンパイラはそれが決して起きないと仮定して最適化できます。
C言語で符号付き整数のオーバーフローは未定義動作ですか?
はい。INT_MAX + 1 は未定義です - INT_MIN へ巻き戻ることは保証されません。符号なしのオーバーフローは別で、完全に定義されており 2^N を法として巻き戻ります。だからこそコンパイラは符号付きの x について x + 1 > x が常に真だと仮定でき、そう書かれたオーバーフロー検査を削除しうるのです。
自分のCプログラムの未定義動作を検出するには?
-Wall -Wextra を付けてコンパイルし、コンパイラが静的に見えるものを捕まえます。それからテストを -fsanitize=address,undefined で走らせると、範囲外アクセス、use-after-free、符号付きオーバーフローなどが起きた瞬間にファイル名と行番号つきで報告されます。valgrind は再コンパイルなしで似た一式を捕まえます。