C言語の制御文・関数・配列:off-by-one を ASan で捕まえる

C言語講座2。Apple clang 21 で実行:if・ループ・関数・配列、= と == の警告、配列のポインタ化と sizeof の罠、範囲外書き込みを ASan で行番号まで特定し、そのコストも実測。

シリーズ:C言語講座 2/6(初級)。 第1回を終えた人、または hello world をコンパイルできる人向けです。読み終えると、if・ループ・自作の関数を書き、配列を正しく関数に渡し、C で最も多い配列のバグ「1つ先まで書いてしまう」を何時間もかけずに数秒で見つけられるようになります。

AtCoder などの競技プログラミングで C を使い、手元では通るのに提出すると RE(実行時エラー)や WA になる。原因の多くはこの回で扱う配列の範囲外アクセスです。Java なら ArrayIndexOutOfBoundsException で止まるところ、C はそのまま走り続け、何が起きるかはコンパイルの仕方次第です。この回ではそれを文字どおり見せます:1つのバグ、4通りのビルド、4通りの結果。すべて Apple Silicon Mac(M1 Pro)、macOS 26、Apple clang 21.0.0、常に -std=c17 -Wall -Wextra で実行し、出力のパスは ~/demo/ に置き換えています。

if・ループ・関数を1本のプログラムで

小さな成績表です。この節の要素をすべて使っています。

#include <stdio.h>

#define N 5

double average(const int scores[], int n) {
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += scores[i];
    return (double)sum / n;
}

char grade(int score) {
    if (score >= 90) return 'A';
    else if (score >= 80) return 'B';
    else if (score >= 70) return 'C';
    return 'F';
}

int main(void) {
    int scores[N] = {88, 92, 79, 65, 95};

    for (int i = 0; i < N; i++)
        printf("student %d: %3d -> %c\n", i + 1, scores[i], grade(scores[i]));

    printf("average: %.1f\n", average(scores, N));

    int i = 0;
    while (i < N && scores[i] < 90)   /* first student with an A */
        i++;
    if (i < N) printf("first A: student %d\n", i + 1);
    return 0;
}
student 1:  88 -> B
student 2:  92 -> A
student 3:  79 -> C
student 4:  65 -> F
student 5:  95 -> A
average: 83.8
first A: student 2

用語を一度だけ説明します。

  • #define N 5 はプリプロセッサの定数で、コンパイル前にすべての N が 5 に置き換わります。
  • int scores[N] は配列。int が 5 個メモリ上に並び、添字は 0 から 4 です。
  • 関数は戻り値の型(double、char)、名前、引数を持ちます。const int scores[] は「この関数は配列を書き換えない」という約束です。
  • for (初期化; 条件; 更新) は条件が真の間くり返し、while は条件だけを見ます。
  • && は「かつ」で、短絡評価されます。i < N && scores[i] < 90 で i が 5 になると右側は評価されないので scores[5] は読まれません。左右を入れ替えるとバグになります。

コンパイラが警告してくれる4つのバグ

次のファイルはコンパイルも実行もできます。

#include <stdio.h>

int sign(int x) {
    if (x > 0) return 1;
    if (x < 0) return -1;
}                                   /* forgot: return 0 */

int main(void) {
    int n = 5;
    if (n = 0)                      /* meant n == 0 */
        printf("n is zero\n");
    printf("n is now %d, sign(n) = %d\n", n, sign(n));

    int a[3] = {1, 2, 3};
    a[3] = 4;                       /* index 3 does not exist */

    switch (n) {
    case 0: printf("zero\n");
    case 1: printf("one\n");         /* fall through */
    }
    return 0;
}

clang の警告(各1行目だけ):

traps.c:6:1: warning: non-void function does not return a value in all control paths [-Wreturn-type]
traps.c:10:11: warning: using the result of an assignment as a condition without parentheses [-Wparentheses]
traps.c:10:11: note: use '==' to turn this assignment into an equality comparison
traps.c:15:5: warning: array index 3 is past the end of the array (that has type 'int[3]') [-Warray-bounds]
3 warnings generated.

実行結果:

n is now 0, sign(n) = 0
zero
one

n = 0 は代入なので if は偽になり、n の値も壊れました。sign(0) は 0 を表示しましたが、これはただの偶然です。戻り値のない経路から抜けてその値を使うのは未定義動作で、戻り値用のレジスタに残っていた値がそのまま出ます。実際、このファイルの下書き(main にループが1つ多かった版)では sign(n) = 1、上のファイルを -O2 でビルドすると sign(n) = -1 でした。ビルドが3つで答えも3つです。そして switch は break がないので次の case にフォールスルーし、zero と one の両方を表示しました。フォールスルーの警告は -Wall -Wextra に含まれません。-Wimplicit-fallthrough を足すとこう出ます。

traps.c:19:5: warning: unannotated fall-through between switch labels [-Wimplicit-fallthrough]
traps.c:19:5: note: insert 'break;' to avoid fall-through

意図的なフォールスルーなら印を付けます。C23 では [[fallthrough]];(clang 21 で -std=c23 にしてコンパイル確認)、C17 では clang が __attribute__((fallthrough)); を提案します。

-Wall -Wextra でも捕まらないループの罠をもう1つ。

for (unsigned i = 2; i >= 0; i--)   /* i >= 0 is always true */
i=2
i=1
i=0
i=4294967295
i=4294967294

(5行で止めています。)unsigned は負にならないので、0 - 1 は 4,294,967,295 に一周します。-Wtype-limits か -Wtautological-unsigned-zero-compare を付けたときだけ result of comparison of unsigned expression >= 0 is always true と出ます。

配列はポインタになる:関数の中の sizeof はうそをつく

#include <stdio.h>

void show(int arr[]) {
    printf("inside show: sizeof(arr) = %zu\n", sizeof(arr));
}

int main(void) {
    int a[5] = {1, 2, 3, 4, 5};
    printf("in main:     sizeof(a)   = %zu\n", sizeof(a));
    printf("elements:    %zu\n", sizeof(a) / sizeof(a[0]));
    show(a);

    printf("a      = %p\n", (void *)a);
    printf("&a[0]  = %p\n", (void *)&a[0]);
    printf("&a     = %p\n", (void *)&a);
    printf("a + 1  = %p  (+%td bytes)\n", (void *)(a + 1), (char *)(a + 1) - (char *)a);
    printf("&a + 1 = %p  (+%td bytes)\n", (void *)(&a + 1), (char *)(&a + 1) - (char *)a);
    return 0;
}
sizeoftrap.c:4:54: warning: sizeof on array function parameter will return size of 'int *' instead of 'int[]' [-Wsizeof-array-argument]
in main:     sizeof(a)   = 20
elements:    5
inside show: sizeof(arr) = 8
a      = 0x16f38e470
&a[0]  = 0x16f38e470
&a     = 0x16f38e470
a + 1  = 0x16f38e474  (+4 bytes)
&a + 1 = 0x16f38e484  (+20 bytes)

配列を関数に渡すと、C は先頭要素のアドレスだけを渡します。これを配列のポインタへの変換(array decay)と呼びます。引数リストの int arr[] は int *arr とまったく同じ意味で、show の中の sizeof(arr) はポインタのサイズ 8 です。アドレスを見ると違いがわかります。a、&a[0]、&a は同じアドレスを表示しますが、a + 1 は int 1個分(4 バイト)、&a + 1 は配列全体(20 バイト)進みます。&a の型が「int 5 個の配列へのポインタ」だからです。アドレスはアドレス空間配置のランダム化により実行のたびに変わります。

ここから出てくるルール:関数は配列の長さを知ることができない。長さは引数で渡します。

double average(const int scores[], int n);       /* C の API はみなこの形 */
int n = (int)(sizeof a / sizeof a[0]);           /* a が本物の配列である場所でだけ有効 */

off-by-one:1つのバグ、4つの結果

最も多い配列バグは、1つ先まで書いてしまうものです。

#include <stdio.h>

int main(void) {
    int guard = 7;
    int a[5];
    for (int i = 0; i <= 5; i++)      /* <= should be < */
        a[i] = i * 100;
    printf("a[4]=%d guard=%d\n", a[4], guard);
    return 0;
}

警告は出ません。添字が変数なので、定数添字しか見ない -Warray-bounds の対象外です。4通りにビルドしました。

ビルド 出力 終了コード
clang -O0(既定) Abort trap: 6 134
clang -O0 -fno-stack-protector a[4]=400 guard=500 0
clang -O2 a[4]=400 guard=7 0
clang -fsanitize=address -g stack-buffer-overflow ... offbyone.c:7 134
図。int a[5] に i = 0 から 5 まで書き込むループというバグを4通りにビルドする。-O0 ではスタック保護が気づき Abort trap: 6、終了コード 134 で落ちる。-O0 に -fno-stack-protector を付けると終了コード 0 で終わり、隣の変数 guard が 7 から 500 に黙って変わる。-O2 では終了コード 0、guard=7 と表示され正しく見える。-fsanitize=address では offbyone.c の 7 行目で stack-buffer-overflow として止まる。
同じ off-by-one バグを4通りにビルドすると結果も4通り。行番号まで示すのは AddressSanitizer だけ。Apple clang 21、arm64 macOS。
  • -O0 では Apple clang がスタック保護を入れています(nm で実行ファイルに ___stack_chk_fail があるのを確認)。はみ出した書き込みが番兵の値を壊し、main の終わりで異常終了しました。行番号は出ません。
  • スタック保護を外すと a[5] がちょうど guard の場所に入り、黙って 500 になりました。データが壊れたのに終了コード 0、いちばん悪いパターンです。
  • -O2 では正しく見えました。バグは残っていて、最適化でメモリ配置が変わっただけです。「リリースビルドでは動く」は何の証明にもなりません。
  • AddressSanitizer(ASan)では最初の不正な書き込みで止まりました。
==74769==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016b9b63b4 ...
WRITE of size 4 at 0x00016b9b63b4 thread T0
    #0 0x0001044489c0 in main offbyone.c:7

  This frame has 1 object(s):
    [32, 52) 'a' (line 5) <== Memory access at offset 52 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow offbyone.c:7 in main

上から読みます。4 バイトの書き込みが 7 行目で、a の 20 バイト(オフセット 32〜52)の直後に行われた。診断はこれで全部です。ASan はメモリアクセスのたびにチェックを足すので遅くなりますが(下で計測)、「ときどきおかしい」を「必ずその行で止まる」に変えてくれます。

よくある間違い → 実際に起きたこと → 直し方

間違い 実際に見たもの 直し方
if (n = 0) 条件は偽、n は上書き ==。-Wparentheses を無視しない
return 忘れ sign(0) がビルドにより 0・1・-1 すべての経路で return。-Wreturn-type
break のない switch zero と one の両方 break;、意図的なら [[fallthrough]];
unsigned i; i >= 0; i-- 0 を越えて 4294967295 int を使うか for (unsigned i = n; i-- > 0; )
関数内の sizeof(arr) 20 ではなく 8 長さを引数で渡す
a[N] に i <= N 異常終了・黙って破壊・「正常」 i < N。-fsanitize=address でテスト

ASan のコストは?(実測)

ASan を普段のビルドで付けっぱなしにできるか判断するため、int 100 万個の配列を 200 回なめる処理(2 億回の読み出し、値は種を固定した生成器)を各7回実行し中央値を取りました。毎回少しずつ短い範囲を合計し、最適化で1回分の結果を使い回されないようにしています。

ビルド 中央値 遅くなった倍率 実行ファイル
-O0 190.1 ms 33,528 B
-O0 -fsanitize=address 314.4 ms 1.7 倍 51,272 B
-O2 10.2 ms 33,512 B
-O2 -fsanitize=address 35.7 ms 3.5 倍 51,320 B
棒グラフ。-O0 は 190.1 ミリ秒、-O0+ASan は 314.4 で約 1.7 倍遅い。-O2 は 10.2 ミリ秒、-O2+ASan は 35.7 で約 3.5 倍遅いが、ASan なしの -O0 よりはるかに速い。
AddressSanitizer のコスト:int 100 万個の配列を 200 回なめる(2 億回の読み出し)。7 回の中央値、Apple Silicon Mac(M1 Pro)。

ASan 付きの -O2 でも素の -O0 の5倍以上速いという結果です。最初の版では毎回配列全体を合計していたため -O2 の数字が不自然に速く、範囲を変えてようやくまともになりました。ベンチマークでは「コンパイラが省略できる仕事」がないか必ず疑いましょう。初心者へのおすすめ:開発中とテストは常に -g -fsanitize=address,undefined で。

正直な限界・バージョンの注意

  • 実行したのは arm64 macOS 26 の Apple clang 21.0.0 のみ。Linux の gcc・clang も同じ -fsanitize=address を持ちます(未実行)。MSVC は /fsanitize=address(未実行)。
  • スタック保護が既定で有効かはプラットフォームやディストリビューション次第です。-O0 での異常終了はここでの Apple clang の挙動で、保証ではありません。
  • for (int i = 0; ...) の中での変数宣言は C99 から。[[fallthrough]] は C23 で、-std=c23 でコンパイルしました。-std=c17 では同じファイルが別の C23 機能 nullptr で use of undeclared identifier 'nullptr' になりました。
  • 可変長配列(int a[n])は C99 で入り C11 でオプション扱いになりました。本文では使っていません。
  • アドレスは実行ごとに変わります。時間は1台での計測です。

練習問題

1. 次の関数は配列の平均を返すはずですが、{10, 20, 30, 40, 50} に対して 15.0 を返します。なぜでしょう。どう直しますか。

double avg(int a[]) {
    int n = sizeof(a) / sizeof(a[0]);
    int s = 0;
    for (int i = 0; i < n; i++) s += a[i];
    return (double)s / n;
}

2. 配列をその場で逆順にする void reverse(int a[], int n) と、最大値を返す int max_of(const int a[], int n) を書き、{3, 9, -2, 7, 5} と要素1個の配列で、-fsanitize=address,undefined 付きでテストしてください。

解答

1. 関数内の a はポインタなので n は 8 / 4 = 2 になり、10 と 20 だけの平均を返していました。clang は -Wsizeof-array-argument と 'sizeof (a)' will return the size of the pointer, not the array itself [-Wsizeof-pointer-div] の2つを警告します。double avg(const int a[], int n) にして、a を宣言した場所から avg(a, (int)(sizeof a / sizeof a[0])) と呼びます。

2. ASan・UBSan 付きで問題なく動いた解答例です。

void reverse(int a[], int n) {
    for (int i = 0, j = n - 1; i < j; i++, j--) {
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    }
}

int max_of(const int a[], int n) {
    int m = a[0];
    for (int i = 1; i < n; i++)
        if (a[i] > m) m = a[i];
    return m;
}
5 7 -2 9 3
max = 9
one = 42, max = 42

i <= j ではなく i < j にすると真ん中の要素を自分自身と交換せずに済み、要素1個ならループ本体は一度も実行されません。max_of は 0 ではなく a[0] から始めるので、全部が負の配列でも正しく動きます。

次回

第3回は、配列のポインタ化でちらっと見えた箱を開けます。ポインタ、malloc と free、そして解放後使用・二重解放・メモリリークを ASan がどう報告するかです。

よくある質問

関数の中で sizeof(arr) が 8 になるのはなぜ?

配列の引数は実際にはポインタだからです。void f(int arr[]) は void f(int *arr) と同じ意味で、sizeof(arr) は 64 ビット Mac ではポインタのサイズ 8 になり、int 5 個分の 20 にはなりません。clang は -Wsizeof-array-argument で警告します。要素数は別の引数で渡し、呼び出し側で sizeof a / sizeof a[0] として求めます。

C言語は配列の範囲チェックをしないの?

しません。int a[5] に a[5] を書いてもコンパイルでき、たいてい動きます。手元では同じバグが -O0 ではスタック保護で異常終了(終了コード 134)、スタック保護なしでは隣の変数が 7 から 500 に黙って変わり、-O2 では正しく見えました。開発中は -fsanitize=address を付けると、該当行で stack-buffer-overflow として止まります。

AddressSanitizer を付けるとどれくらい遅くなる?

配列を読むベンチマークでは -O0 で約 1.7 倍(190 ms → 314 ms)、-O2 で約 3.5 倍(10.2 ms → 35.7 ms)、実行ファイルは約 33 KB から 51 KB になりました。テストとデバッグビルドでは常に付けておいてよいコストです。

  • #C言語
  • #初級
  • #配列
  • #関数
  • #AddressSanitizer
  • #off-by-one

t.laboratory

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

運営者情報 · アプリ

↑