C の未定義動作:-O0 と -O2 で結果が変わる理由

C 講座 第5回。符号付きオーバーフロー、消えるループ、strict aliasing、未初期化変数、消された NULL チェックを -O0・-O2 で実行。機械語、UBSan の報告、サニタイザーのコストまで実測。

シリーズ:C 講座 第5回/全6回(上級)。 C を書いてデバッグでき、デバッグビルドとリリースビルドで動きが違う経験をした方向けの記事です。読み終えると、よくある未定義動作を見分け、最適化器がそれで何をしたかを説明し、UBSan で見つけ、症状を隠すフラグではなく「定義された C」で直せるようになります。

組込みの現場でよく聞く「デバッグビルドでは再現しない、リリース(最適化あり)のときだけ値がおかしい」、競技プログラミングで AtCoder に提出したら手元と答えが違う。どちらも、原因はコンパイラのバグではなく未定義動作(Undefined Behavior、UB)であることがほとんどです。ジャッジは通常最適化付きでコンパイルし、手元は最適化なしで試していた、という組み合わせで表に出ます。この記事では小さな UB の例を 5 つ、-O0 と -O2 で実行し、最適化器が出した機械語とサニタイザーの報告をそのまま見せます。

実行環境: Apple clang 21.0.0(clang -std=c17 -Wall -Wextra)、Apple Silicon Mac(M1 Pro)、macOS、arm64。出力はすべて実際の実行からのコピーで、逆アセンブルは objdump -d --no-show-raw-insn で取りました。ジャッジや組込み向けの GCC はこの Mac では動かしていません。学習の全体像はプログラミング学習ロードマップへ。

「未定義」の意味と最適化器

C 規格は移植性のない動作を 3 つに分けています。

  • 処理系定義(implementation-defined): コンパイラが 1 つを選んで文書化する。例:sizeof(long)、負の数の右シフトの結果。
  • 未規定(unspecified): いくつかのうちどれかが起きるが、どれかを教える必要はない。例:f() + g() で 2 つの関数を呼ぶ順番。
  • 未定義(undefined): 規格はプログラムの実行全体について何も要求しない。例:符号付き整数のオーバーフロー、未初期化のローカル変数の読み出し、配列の範囲外アクセス、NULL の参照外し、互換性のない型のポインタ経由のアクセス。

困るのは最後の 1 つで、ハードウェアが変なことをするからではありません。arm64 のレジスタで 2147483647 に 1 を足せば毎回 -2147483648 になります。驚きは最適化器から来ます。最適化器は UB は決して起きないと仮定し、そこから推論してよいのです。「この加算はあふれない」→「だから x + 1 > x は常に真」→「だから 1 を返す」。各段階は合法なのに、結果はコンパイラがコードを無視したように見えます。

4段階の流れ図。段階1:ある入力で未定義になる演算がソースにある(INT_MAX に対する x + 1 など)。段階2:最適化器は未定義動作は起きないと仮定する。段階3:x + 1 > x は常に真、という事実を導く。段階4:コードを return 1 に単純化する。段階1から -O0 への分岐では CPU がそのまま折り返して 0 を返す。点線の分岐では UBSan が実行時にその行を報告する。
未定義動作が -O2 で別の結果になる流れ。各段階は正当な推論だが、結果はコンパイラがコードを無視したように見える。

3 つの関数、2 通りの答え

1 ファイルに小さな関数が 3 つ。noinline は、各関数を本物の呼び出しとして残し機械語を読めるようにするためです。

/* ub.c: -O レベルで結果が変わる 3 種類の UB。
   clang -std=c17 -Wall -Wextra -O0 ub.c -o ub0 && clang -std=c17 -Wall -Wextra -O2 ub.c -o ub2 */
#include <stdio.h>
#include <limits.h>

/* 1. 符号付きオーバーフロー:x+1 は x より大きいか */
__attribute__((noinline)) int plus_one_is_bigger(int x) { return x + 1 > x; }

/* 2. strict aliasing:int* と float* で同じ場所に書く */
__attribute__((noinline)) int alias(int *i, float *f) {
    *i = 1;
    *f = 0.0f;          /* f が同じバイトを指していれば *i は 0 になる */
    return *i;
}

/* 3. カウンタがあふれるループ */
__attribute__((noinline)) int count_steps(int start) {
    int steps = 0;
    for (int i = start; i > 0; i += 100000000) steps++;   /* 「負になるまで」 */
    return steps;
}

int main(void) {
    printf("plus_one_is_bigger(INT_MAX) = %d\n", plus_one_is_bigger(INT_MAX));
    int x = 0;
    printf("alias(&x, (float*)&x)      = %d\n", alias(&x, (float *)&x));
    printf("count_steps(1)             = %d\n", count_steps(1));
    return 0;
}

どちらのレベルでも警告は 1 つも出ません。結果:

-- O0
plus_one_is_bigger(INT_MAX) = 0
alias(&x, (float*)&x)      = 0
count_steps(1)             = 22
-- O2
plus_one_is_bigger(INT_MAX) = 1
alias(&x, (float*)&x)      = 1
count_steps(1)             = 0

3 行とも変わりました。-O2 のバイナリに実際に入っている機械語(コメントは筆者が追加):

<_plus_one_is_bigger>:
    mov  w0, #0x1          ; return 1
    ret
<_alias>:
    mov  w8, #0x1
    str  w8, [x0]          ; *i = 1
    str  wzr, [x1]         ; *f = 0
    mov  w0, #0x1          ; *i を読み直さずに return 1
    ret
<_count_steps>:
    mov  w0, #0x0          ; return 0:ループが消えた
    ret

1. 符号付きオーバーフロー。 -O0 では CPU が加算してあふれ、-2147483648 > 2147483647 は偽なので 0。-O2 では clang が「x + 1 はあふれないので常に x より大きい」と判断し、関数を return 1 に置き換えました。符号付きオーバーフローを「折り返す」と定義する -fwrapv を付けると、-O2 でも 0 に戻りました。

2. Strict aliasing。 C は int のオブジェクトに float の左辺値でアクセスしてはならないと定めています(C17 6.5p7)。そのため clang は *f = 0.0f が *i を変えられないと仮定し、直前に格納した 1 をそのまま返します。-fno-strict-aliasing を付けると -O2 でも 0。Linux カーネルなど大きな C のコードがこのフラグでビルドされている理由です。

3. 消えたループ。 for (int i = 1; i > 0; i += 100000000) から抜ける唯一の道はオーバーフローです。-O0 では 22 回であふれて終わりました。-O2 では、オーバーフローがなければループは終わらず、副作用のないこの種のループは終了すると仮定してよい(C11 6.8.5p6)ので、2 つの仮定を合わせると実行するものが残りません。clang は return 0 を出しました。22 でも無限ループでもなく 0 です。-fwrapv で 22 に戻ります。

未初期化変数:実行するたびに違う数

#include <stdio.h>

__attribute__((noinline)) int grade(int score) {
    int bonus;                       /* "= 0" を書き忘れた */
    if (score >= 90) bonus = 10;
    return score + bonus;
}

__attribute__((noinline)) void scribble(void) {   /* スタックにゴミを残す */
    volatile int junk[16];
    for (int i = 0; i < 16; i++) junk[i] = 7777;
}

int main(void) {
    printf("grade(95) = %d\n", grade(95));
    scribble();
    printf("grade(50) = %d\n", grade(50));
    return 0;
}

今度は clang が警告し、ノートに直し方まで出ます。

uninit.c:5:9: warning: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
    5 |     if (score >= 90) bonus = 10;
      |         ^~~~~~~~~~~
uninit.c:6:20: note: uninitialized use occurs here
uninit.c:4:14: note: initialize the variable 'bonus' to silence this warning
    4 |     int bonus;                       /* forgot "= 0" */
      |              ^
      |               = 0

無視するとこうなります。

-- O0、3 回実行
grade(50) = 76989834
grade(50) = 47941002
grade(50) = 43156874
-- O2
grade(95) = 105
grade(50) = 60

-O0 の bonus はそのスタック領域に残っていた値で、アドレス空間配置のランダム化(ASLR)のため実行ごとに違う数でした。-O2 では答えが安定しますが、別の形で間違えます。機械語は add w0, w0, #0xa の 1 行。未初期化の bonus を読むのは UB なので、コンパイラは if が常に実行されると見なし、無条件に 10 を足します。50 点の生徒が毎回 60 点になり、メモリのバグではなくロジックのバグに見えます。「リリースのときだけ値がずれる」の典型がこれです。

-ftrivial-auto-var-init=zero(clang 21 で受け付けた)はこうしたローカル変数を 0 で埋め、grade(50) = 50 を出しました。リリースビルドの防御としては有用ですが、見えていたバグを静かな前提に変えてしまいます。本当の修正は int bonus = 0; で、コンパイラに強制させるなら -Werror=sometimes-uninitialized(またはすべて -Werror):

uninit.c:5:9: error: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized]

よくある間違い:コンパイラが消した NULL チェック

間違い: 先に参照外しして、後でチェックする。

struct cfg { int retries; };

__attribute__((noinline)) int get_retries(struct cfg *c) {
    int r = c->retries;          /* 先に参照外し ... */
    if (c == NULL) return -1;    /* ... 後でチェック:消されることがある */
    return r;
}

-O2 で実際に起きること:

<_get_retries>:
    ldr  w0, [x0]
    ret

チェックが消えました。c->retries で既に c を参照外ししたので c は NULL ではありえず、c == NULL は偽、その分岐は死んだコードという結論です。-O0 には比較が残っています。この関数が NULL を受け取ると -O2 のビルドはロードでそのまま落ち、ソースに見える「防御コード」は一度も実行されません。修正: 最初に使う前にチェックする。2009 年の Linux カーネルの脆弱性がまさにこの形で、カーネルは今 -fno-delete-null-pointer-checks でビルドされています(ここでは未実行)。

UBSan:UB を騒がしくする

-fsanitize=undefined(UndefinedBehaviorSanitizer)は UB になりうる演算の前に検査を入れ、引っかかると 1 行出します。ub.c を -O0 で:

ub.c:7:68: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:7:68
ub.c:19:34: runtime error: signed integer overflow: 2100000001 + 100000000 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:19:34
plus_one_is_bigger(INT_MAX) = 0
alias(&x, (float*)&x)      = 0
count_steps(1)             = 22
exit=0

2 点に注目してください。オーバーフロー 2 か所は正確な値付きで見つけましたが、aliasing 違反は見つけていません。UBSan にはその検査がないのです。そして報告だけして実行を続けました(exit 0)。CI では失敗させたいので -fno-sanitize-recover=undefined を付けます。同じファイルが最初の報告で止まり exit 134 で終わりました。

別のファイルでさらに 3 つの検査(引数なしで実行):

int n = argc > 1 ? atoi(argv[1]) : 32;
int table[4] = {10, 20, 30, 40};
int idx = argc > 2 ? atoi(argv[2]) : 4;
printf("1 << %d = %d\n", n, 1 << n);               /* ビット幅以上のシフト */
printf("table[%d] = %d\n", idx, table[idx]);       /* 1 つはみ出し */
int d = argc - 1;                                  /* 引数なしなら 0 */
printf("100 / %d = %d\n", d, 100 / d);
more_ub.c:8:35: runtime error: shift exponent 32 is too large for 32-bit type 'int'
more_ub.c:9:37: runtime error: index 4 out of bounds for type 'int[4]'
more_ub.c:11:38: runtime error: division by zero
1 << 32 = 1
table[4] = -16
100 / 0 = 0

最後の行が大事です。サニタイザーなしの -O0 と -O2 でも、この Mac は 100 / 0 = 0 と表示して正常終了しました。arm64 の除算命令はゼロで割るとトラップせず 0 を返します。x86-64 では同じコードが SIGFPE(整数除算なのに「Floating point exception」という名前)で落ちます(ここでは未実行)。Apple Silicon の Mac でだけ「テスト済み」のプログラムが、最初に出会う Linux サーバーやジャッジで落ちうるということです。table[4] は 3 種類のビルドで -16、-178814720、1010573361 と毎回違いました。

実測:最適化レベルとサニタイザーのコスト

同じ 2,000 万要素のループ(ヒストグラム+重み付き和、固定シードの xorshift で作ったデータ)を、フラグだけ変えて実行しました。7 回の最速値、Apple Silicon Mac(M1 Pro)。もう 1 周回した結果も 5% 以内で同じでした。チェックサムは全ビルドで一致しているので、仕事を省いたビルドはありません。

ビルド 時間(ms) -O2 比
-O0 30.22 3.1 倍
-O1 9.77 1.0 倍
-O2 9.63 1.0 倍
-O3 6.97 0.72 倍
-O2 -fwrapv 10.03 1.04 倍
-O2 -fsanitize=undefined 25.22 2.6 倍
-O2 -fsanitize=address 17.25 1.8 倍
-O0 -fsanitize=address,undefined 241.33 25 倍
ミリ秒の棒グラフ。-O0:30.22、-O1:9.77、-O2:9.63、-O3:6.97、-O2 -fwrapv:10.03、-O2 + UBSan:25.22、-O2 + ASan:17.25、-O0 + ASan + UBSan:241.33(-O2 の約25倍)。
2,000 万要素のループを異なるビルドで実行。Apple clang 21、Apple Silicon Mac (M1 Pro)、7回の最速値、固定シード、すべてのビルドでチェックサム一致。

読み取れることは 3 つ。-fwrapv はこのループではほぼタダでした。「オーバーフローを定義すると遅くなる」という話は、自分のホットループで測ってから信じてください(インデックスの拡張など一部のループでは差が出ることがありますが、ここでは示していません)。-O2 のサニタイザーはテスト全体を回せるほど安い。そして普段使うデバッグ+サニタイザーのビルドはリリースの 25 倍遅いので、テストには良くても性能測定に使ってはいけません。計測プログラム全体(bench5.c)は英語版にあります。

判断表:症状、道具、定義された修正

症状 最初に使う道具 定義された修正
-O0 と -O2 で結果が違う、ジャッジでだけ WA -fsanitize=undefined、CI では -fno-sanitize-recover UB を取り除く。-fwrapv などは応急処置
あふれうる算術 UBSan の signed integer overflow 先に確認するか、C23 <stdckdint.h> の ckd_add/ckd_mul
float のビットを見る、電文・パケットの解析 (検出するサニタイザーなし) memcpy で別の型へコピー、または unsigned char でアクセス
実行ごとに変わるゴミの値 -Wall(-Wsometimes-uninitialized) 宣言時に初期化、その警告は -Werror
NULL・範囲チェックが「効かない」 -O2 の逆アセンブルを読む 最初の参照外しの前にチェック
別の OS・CPU でだけ落ちる 手元で UBSan 1 つのプラットフォームの幸運な結果を信じない(arm64 の x / 0 は 0)

定義された置き換えを 2 つ。-O0 と -O2 で出力は同じでした。

#include <stdckdint.h>          /* C23 */
static uint32_t float_bits(float f) {      /* バイトを見る定義された方法 */
    uint32_t u;
    memcpy(&u, &f, sizeof u);
    return u;
}
/* ... */
int sum;
if (ckd_add(&sum, INT_MAX, 1)) puts("ckd_add: overflow detected");
if (!ckd_add(&sum, 40, 2))     printf("ckd_add: 40 + 2 = %d\n", sum);
printf("bits of 1.0f = 0x%08X\n", float_bits(1.0f));
ckd_add: overflow detected
ckd_add: 40 + 2 = 42
bits of 1.0f = 0x3F800000

memcpy は -O2 でレジスタ移動 1 命令(fmov w0, s0)になります。コストはありません。組込みでよく見る *(uint32_t *)&f のようなキャストによる型の読み替えは、まさに 2 番目の例と同じ規則違反です。

限界とバージョンのメモ

  • 結果はすべて arm64 の Apple clang 21.0.0 です。ほかのコンパイラ(GCC、ジャッジ、組込み向け)や別バージョンは同じ UB を違う形で最適化するか、まったくしないかもしれません。それが UB の本質です。上の -O0/-O2 の出力はこのコンパイラがしたことで、C が保証することではありません。
  • C23 は符号付き整数を 2 の補数で表すことを求めましたが、符号付きオーバーフローは C23 でも未定義のままです。「2 の補数」を「あふれたら折り返す」と読んではいけません。
  • <stdckdint.h> と ckd_add は C23 です。clang 21 は -std=c17 -Wpedantic でも警告なしに受け付けましたが、ほかのコンパイラはそうとは限らないので、使うときは -std=c23 でコンパイルしてください。
  • UBSan は strict aliasing 違反を報告しませんでした。-fsanitize=type(TypeSanitizer、LLVM の新しい機能)はドライバに受け付けられましたが、libclang_rt.tysan_osx_dynamic.dylib が Apple clang 21 に含まれずリンクに失敗しました。未初期化の読み出しを見つける MemorySanitizer は macOS では使えません(未実行)。
  • C11 6.8.5p6(ループは終了すると仮定してよい)は C11・C17 にあります。2009 年のカーネルの NULL チェックの件と x86 の SIGFPE は文書に基づく説明で、実行していません。
  • 時間は 1 台で 2 周、7 回の最速値です。絶対値より比率が大事です。

練習問題

  1. int same_after_double(int x) { return (x * 2) / 2 == x; } で、same_after_double(21) と same_after_double(INT_MAX) が -O0 と -O2 で何を出すか予想し、UBSan が何を報告するか答えてください。
  2. 上の grade 関数について、コードを変えずに、警告ではなくビルドが失敗するようにしてください。

解答

  1. -O0 は 1 0:INT_MAX * 2 が -2 にあふれ、-2 / 2 は -1。-O2 は 1 1:乗算は「あふれない」ので、コンパイラが (x * 2) / 2 を x に簡約しました。UBSan:ex5.c:3:68: runtime error: signed integer overflow: 2147483647 * 2 cannot be represented in type 'int'。
  2. -Werror=sometimes-uninitialized(または -Werror)を付けます。clang は error: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized] を出し、バイナリを作りませんでした。

メモリエラーとして現れる UB(範囲外書き込み、解放後使用)は、第3回と第4回の ASan レポートを見てください。


前回: 第4回:文字列・構造体・バッファオーバーフロー · 次回: 第6回:キャッシュを意識したコード・make・lldb

よくある質問

デバッグビルドでは正しいのに、リリースビルド(-O2)だけ結果がおかしいのはなぜですか?

多くの場合、-O0 ではたまたま問題にならなかった未定義動作(UB)があります。最適化器は UB が起きないと仮定してコードを単純化してよいのです。実測では INT_MAX に対する x + 1 > x が -O0 で 0、-O2 で 1 になり、オーバーフローに頼るループは -O0 で 22 回回り、-O2 では丸ごと消えました。-fsanitize=undefined で行番号を探してください。

-fsanitize=undefined はすべての未定義動作を検出しますか?

いいえ。符号付きオーバーフロー、大きすぎるシフト、ローカル配列の範囲外添字、ゼロ除算は検出しましたが、strict aliasing 違反は検出せず、未初期化変数の読み出しも対象外です。-fsanitize=type は Apple clang 21 にランタイムライブラリがなくリンクに失敗しました。

サニタイザーを有効にするとどれくらい遅くなりますか?

2,000 万要素のループで -O2 は 9.63ms、-O2 + UBSan は 25.22ms(2.6 倍)、-O2 + ASan は 17.25ms(1.8 倍)でした。-O0 に両方を付けると 241ms で -O2 の 25 倍。テストと CI で有効にし、リリースビルドでは外します。

  • #C
  • #C言語
  • #未定義動作
  • #UB
  • #UBSan
  • #最適化
  • #clang
  • #上級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑