このトピックは、C言語を学ぶうえで最も重要な安全教育です。文法よりも先に、体に刻んでください。
int a[3] と宣言したのに a[3] や a[5] に触れる。これを境界外アクセスと呼びます。そしてCは、それを黙って通します。
#include <stdio.h>
int main(void)
{
int a[3] = {10, 20, 30};
int x = 99;
a[3] = 777; // 配列の外!
printf("x = %d\n", x); // x = 777 になることがある
return 0;
}x は一度も書き換えていないのに、値が変わることがあります。配列のすぐ隣に置かれていた x の場所を、a[3] が踏んだからです。下のビジュアライザで、その瞬間を見てください。
なぜCは止めてくれないのか
多くの言語は、範囲外の添字を検出してエラーで止めてくれます。Cがそうしないのは、チェックの費用を払わないという設計思想だからです。要素にアクセスするたびに「範囲内か?」と確認すれば、その分だけ必ず遅くなります。Cは「プログラマが正しく書く」ことを前提に、その確認を省いて速度を取りました。OSや組込み機器がCで書かれてきた理由でもあり、同時に、事故がプログラマの責任になる理由でもあります。
いちばん怖いのは「すぐ落ちない」こと
境界外アクセスは未定義動作です。何が起きるかは決まっていません。
- たまたま何も起きず、正常に動いてしまう(いちばん危険)
- 別の変数の値が壊れる(原因の場所と症状の場所が離れる)
- 実行時に異常終了する
- 手元では動くのに、本番環境や最適化を有効にすると壊れる
「動いたから正しい」がまったく通用しないのが、この事故の恐ろしさです。数か月後、まったく別の場所を修正した日に突然表面化します。しかも壊れるのは「はみ出した側」ではなく「踏まれた側」なので、調査は無関係な変数から始まります。原因にたどり着くまでの遠回りが、この事故の本当のコストです。
セキュリティの入口でもある
配列の外へ書き込める状態は、攻撃者から見れば「プログラムの記憶を書き換えられる穴」です。歴史上の深刻な脆弱性の多くが、このバッファオーバーフローに起因しています。第6部で正面から扱いますが、いまの段階では「配列の外に書くのは、鍵をかけ忘れた玄関と同じ」と覚えておいてください。
防ぎ方
- ループ条件は必ず
i < n。<=を書いたら一度立ち止まる - 要素数は
#defineなどで1か所にまとめ、配列とループで同じ名前を使う - 添字を計算で作るときは、その式が取りうる最小値と最大値を必ず確かめる
- 外から来た数値(入力・計算結果)を添字にする前に、
if (i >= 0 && i < n)で確認する - コンパイラの警告を必ず有効にする(多くの単純なはみ出しは検出できます)
とくに4番目が実戦では効きます。添字が変数で決まるときは、触る前に範囲を確かめる。それだけで多くの事故は防げます。
#include <stdio.h>
#define N 3
int main(void)
{
int a[N] = {10, 20, 30};
int i = 3; // 外から来た値のつもり
if (i >= 0 && i < N) { // 触る前に確認する
printf("%d\n", a[i]);
} else {
printf("範囲外です\n");
}
return 0;
}まとめ: 境界外アクセスは未定義動作。落ちないことすらある。だからこそ「範囲は 0 〜 n−1」を、書くたびに確認する習慣が最大の防御になります。