Visual Studio 2022でC++範囲外アクセスに気づきやすくする方法

目次

はじめに
先日、Visual Studio 2022で、配列の範囲外アクセスのデバッグでハマってしまった。

constexpr int S = 20;
struct Maze
{
	Maze()
	{
		for (int y = 0; y < S; ++y)
		{
			h[y][0] = true;
			h[y][S] = true;
		}

		for (int x = 0; x < S; ++x)
		{
			v[0][x] = true;
			v[0][S] = true;	// 配列外アクセス。 v[S][x] = true が正しかった
		}
	}

	bool h[S][S+1];	// 横移動を阻む縦の壁
	bool v[S+1][S]; // 縦移動を阻む横の壁
};

定数で範囲外アクセスしているとても単純なケースなのに、なぜ警告も気づかなかったんだろう?となったので、いろいろ調べた。


配列外アクセスをしたときの比較表

  • コンパイルエラーコンパイル時 警告エディタで警告
    • 最高。実行前に気づける。
  • 実行時エラー例外中断
    • まぁまぁ。実行時に気づける。実行時のabort、グローバル バッファー オーバーフロー、ヒープオーバーフロー等でとまる。
  • なし(オプションで実行時エラーに)
    • 設定によっては、実行時に気づける。
  • なし
    • 最悪で、範囲外のメモリを壊し動作が不定になる。未処理例外で止まる可能性もあるし、誤動作する場合もあるし、正常に動くこともある。
Visual Studio gcc clang
変数の種類 Intellisense Debug Release Sanitizer
オプション
デバッグ寄り*1 デバッグ寄り*2
ローカル
生配列
エディタで警告 1次元配列→コンパイルエラー
2次元配列→なし
なし なし(終了時に不明の例外) コンパイル時 警告(部分的) コンパイル時 警告
グローバル
生配列
エディタで警告 なし なし 例外中断 コンパイル時 警告(部分的) コンパイル時 警告
ローカル
std::array
エディタで警告 実行時エラー なし(オプションで実行時エラーに) なし(終了時に不明の例外) 実行時エラー 実行時エラー
グローバル
std::array
エディタで警告 実行時エラー なし(オプションで実行時エラーに) 例外中断 実行時エラー 実行時エラー
ヒープ
std::vector
なし 実行時エラー なし(オプションで実行時エラーに) 例外中断 実行時エラー 実行時エラー


範囲外アクセスの参考コードはこちら

Visual Studio 2022で範囲外アクセスを気づきやすくする方法5つ

エラー一覧を「ビルド+Intellisense」にする

f:id:shindannin:20220328054022p:plain
f:id:shindannin:20220328054310p:plain

  • インテリセンスで範囲外アクセスの警告が表示されて、基本的には強力である。
    • 逆にビルドの出力ログには警告すら表示されないので、それしか見ていないと気づかずに大失敗するので、要注意。今回気づかなった一番の原因はこれ。
    • インテリセンスなので、すぐに表示されなかったり、なにかのきっかけでリフレッシュされず古い情報が残ったりする(おそらくMicrosoftのバグ)。ターゲットの切り替えや、コード編集後にリビルドをすると直ったりするが不確定で緩慢なこともある。

sanitizerを積極的に使う

f:id:shindannin:20220328054555p:plain
(訂正)さらに調べてみたところ、ローカル変数を使用していないコードでも終了時に常に例外(0xE0736171)が出ることが分かりました。おそらくVisual Studioの問題?
- ローカル変数の例外(0xE0736171)は例外設定に入っておらずログには情報が流れるもの中断はしないので気づきにくい

    • 中断するなら、以下のように例外設定ウィンドウで追加すればよい。ただしデバッグ情報は他のオーバーフローのように表示されない。
  • 今回のような配列範囲外のミスだけではなく、ダングリングポインタ・メモリリークなども見つけることができる。

生配列よりstd::arrayがよい

  • ローカル1次元配列だけなぜか親切にコンパイルエラーを出してくれるが、それ以外はチェック目的ならstd::arrayが常に良い。
  • 特にstd::arrayの2次元以上の書き方が順番が逆で、慣れないと間違えやすいが、テンプレートを使うことで緩和できる。以下のページがおすすめ。

koturn.hatenablog.com

Release版でSTLの範囲チェックしたいときは、 _CONTAINER_DEBUG_LEVEL=1 にする

f:id:shindannin:20220328055135p:plain

  • 実行時に、Debug版だと遅すぎて問題箇所までたどり着かないとき、Release版でSTLの範囲チェックしたいときに使える。
  • Debug, Releaseターゲット以外に、最適化するけど範囲外チェックするデバッグ用のターゲットを用意してもよい。

devblogs.microsoft.com

インテリセンスが安定せず嫌な場合、clangを代わりに使うのがよい

  • clangでもgccでも、警告の種類は"-Warray-bounds"だが、結果は微妙に違う。
    • Clangは、ほぼコンパイルオプションなし"clang++ -std=gnu++17"でも、コンパイル時に生配列への不正アクセスの警告をしてくれる。
    • gccは、 "g++ -O2 -std=gnu++17 -Wall -g -fsanitize=undefined,address -D_GLIBCXX_DEBUG" の設定をすればコンパイル時に生配列への不正アクセスを警告してくれるが、最初の1箇所だけの警告のみで、全ての箇所の警告してくれない



おわりに
ちなみに、今回はコンパイラオプションでの対応を取り上げたが、より複雑な問題に対応するために以下のようなツールもあります。

  • 静的解析ツールを使う
  • メモリリークの検証ツールを使う(Valgrind、WindowsならAppVerifier

*1:オプション g++ -O2 -std=gnu++17 -Wall -g -fsanitize=undefined,address -D_GLIBCXX_DEBUG
バージョン g++ (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0

*2:オプション clang++ -O2 -std=gnu++17 -Wall -g -fsanitize=undefined,address -D_GLIBCXX_DEBUG
バージョン clang version 10.0.0-4ubuntu1