【C++】0xC0000005「アクセス違反」の原因とは?|落ちたときのアドレスを読むだけで、nullptr・解放済み・未初期化を見分ける方法

【C++】0xC0000005「アクセス違反」の原因とは?|落ちたときのアドレスを読むだけで、nullptr・解放済み・未初期化を見分ける方法 C++

0xC0000005。ウィンドウも出ず、ログも残らず、プログラムが突然消えます。アクセス違反はC++でいちばんよく見る落ち方なのに、「何にアクセスしたのか」が分からないと、どこを直せばいいのか見当がつきません。

このエラーは、落ちたときに、「読み取りか書き込みか」と「どのアドレスにアクセスしたか」という2つの情報を持っています。実は、この2つだけで、原因をかなり絞り込めます。そこで、nullptrや解放済みメモリなど、原因8通りをわざと起こして、実際に出たアドレスの値を並べました。

検証は、Visual Studio 2022付属のMSVC(19.44、x64)で、デバッグビルド(/Od /MDd)で行いました。デバッグビルドでは、メモリの未使用領域に決まった値が詰められるため、原因の手がかりが増えます。リリースビルドでは、同じコードでも値や挙動が変わりえます(今回は未検証)。

アクセス違反とは?

アクセス違反とは、

・プログラムが、使ってはいけないメモリ(確保していない場所や、書き込み禁止の場所)を読み書きしようとしたときに、OSがそのプログラムを止める現象

です。コードは0xC0000005(STATUS_ACCESS_VIOLATION)で、Visual Studioのデバッガにも、同じ情報が出ます。この情報には、2つの手がかりが入っています。

  • 読み取りか、書き込みか:何をしようとして、止められたのか
  • どのアドレスか:どこにアクセスしようとしたのか

この2つを、自分のプログラムの中から取り出して表示するコードが、これです。

▼av.cpp(落ちたときに表示する)
// 落ちたときに「読み取りか書き込みか」「どのアドレスか」を表示する
static LONG WINAPI OnCrash(EXCEPTION_POINTERS* ep) {
    const EXCEPTION_RECORD* r = ep->ExceptionRecord;
    if (r->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) {
        const char* op = (r->ExceptionInformation[0] == 0) ? "read" : (r->ExceptionInformation[0] == 1) ? "write" : "execute";
        std::printf("code=0x%08lX  %-5s  address=0x%016llX\n", r->ExceptionCode, op,
                    (unsigned long long)r->ExceptionInformation[1]);
    } else {
        std::printf("code=0x%08lX\n", r->ExceptionCode);
    }
    std::fflush(stdout);
    return EXCEPTION_EXECUTE_HANDLER;
}

SetUnhandledExceptionFilterで登録しておくと、落ちる直前にこの関数が呼ばれて、読み書きの種類とアドレスを表示できます。デバッガで見る場合も、同じ値(読み取りか書き込みか、どのアドレスか)が、例外の情報に出ます。

実験:8通りのコードを動かしてみる

対象は、ゲームで出てきそうな、次の構造体です。メンバの位置(先頭から何バイト目か)に注目してください。

▼av.cpp(Enemy構造体)
struct Enemy {
    int    id;        // 先頭から 0 バイト目
    int    hp;        // 4 バイト目
    Enemy* target;    // 8 バイト目(追いかける相手)
};

この構造体を使って、8通りのコードを動かしました。

①②nullptrを読む

▼av.cpp(①と②)
case 1: {                                   // nullptr を直接読む
    int* p = nullptr;
    volatile int x = *p;
    (void)x;
    break;
}
case 2: {                                   // nullptr のオブジェクトのメンバを読む
    Enemy* e = nullptr;
    Enemy* t = e->target;
    (void)t;
    break;
}

①は、nullptrを直接読んでいます。②は、nullptrのポインタ経由で、メンバtargetを読んでいます。

③解放済みのオブジェクトの中身をたどる

▼av.cpp(③)
case 3: {                                   // 解放済みのオブジェクトの中身をたどる
    Enemy* a = new Enemy{ 1, 10, nullptr };
    Enemy* b = new Enemy{ 2, 20, a };
    delete b;
    Enemy* t = b->target;                   // b は解放済み。中身は「解放済み」を示す値で埋められている
    std::printf("target = %p" "\n", (void*)t);
    std::fflush(stdout);
    t->hp = 0;
    break;
}

delete bのあとでb->targetを読んでいます。解放済みの領域は、まだ読めてしまうことが多く、ここでは落ちません。落ちるのは、その中身(target)を、ポインタとしてたどった瞬間です。

④初期化していないポインタメンバをたどる

▼av.cpp(④)
case 4: {                                   // 初期化していないポインタメンバをたどる
    Enemy* e = new Enemy;                   // target を初期化していない
    std::printf("target = %p" "\n", (void*)e->target);
    std::fflush(stdout);
    e->target->hp = 0;
    break;
}

⑤〜⑦範囲外・リテラル・再帰

▼av.cpp(⑤⑥⑦)
case 5: {                                   // 配列の範囲を大きく超える
    int a[4] = {};
    volatile size_t i = (size_t)1 << 40;
    a[i] = 1;
    break;
}
case 6: {                                   // 文字列リテラルに書き込む
    char* s = (char*)"abc";
    s[0] = 'x';
    break;
}
case 7: {                                   // スタックオーバーフロー(別のコードになる)
    std::printf("%d\n", Recurse(0));
    break;
}

⑧解放済みのメモリに「書くだけ」

▼av.cpp(⑧)
case 8: {                                   // 解放済みのメモリに「書くだけ」
    Enemy* a = new Enemy{ 1, 10, nullptr };
    delete a;
    a->hp = 99;                             // 解放済み。でも落ちないことがある
    std::printf("no crash (hp=%d)\n", a->hp);
    break;
}

8つを実行して、落ちたときに表示した結果が、これです。

[1] nullptrを直接読む
    code=0xC0000005  read   address=0x0000000000000000
    (終了コード 0xC0000005)
[2] nullptrのオブジェクトのメンバを読む
    code=0xC0000005  read   address=0x0000000000000008
    (終了コード 0xC0000005)
[3] 解放済みのオブジェクトの中身をたどる
    target = DDDDDDDDDDDDDDDD
    code=0xC0000005  read   address=0xFFFFFFFFFFFFFFFF
    (終了コード 0xC0000005)
[4] 初期化していないポインタメンバをたどる
    target = CDCDCDCDCDCDCDCD
    code=0xC0000005  read   address=0xFFFFFFFFFFFFFFFF
    (終了コード 0xC0000005)
[5] 配列の範囲を大きく超える
    code=0xC0000005  write  address=0x0000042B050FF8B0
    (終了コード 0xC0000005)
[6] 文字列リテラルに書き込む
    code=0xC0000005  write  address=0x00007FF759538F58
    (終了コード 0xC0000005)
[7] スタックオーバーフロー(止まらない再帰)
    code=0xC00000FD
    (終了コード 0xC00000FD)
[8] 解放済みのメモリに「書くだけ」
    no crash (hp=99)
    finished normally
    (終了コード 0x00000000)
落ちたときのアドレスの値から原因を絞り込む表。nullptr、解放済み、未初期化、範囲外などの実測値
実際に出た値と、読み方

読み解く:アドレスの値で、原因が変わる

表を、上から順に見ていきます。

  • ①アドレスが0:nullptrを読んだ。もっとも分かりやすい
  • ②アドレスが0x8:0ではないのに、とても小さい。これは、nullptrのオブジェクトの、先頭から8バイト目のメンバを読んだ印です。Enemyで先頭から8バイト目はtargetなので、「targetを読もうとしたが、Enemyのポインタがnullptrだった」と、メンバ名まで特定できます。小さい値(数十〜数千程度)が出たら、まず「nullptr + メンバのオフセット」を疑います
  • ③④アドレスが0xFFFFFFFFFFFFFFFF:①②と違って、アドレスから何も読み取れません。これは、x64では存在しえない形のアドレス(非正規アドレス)にアクセスしたためで、OSが-1として報告します。この場合は、アドレスではなく、デバッガで変数の値を見ることで、原因を絞り込めます
  • ⑤巨大でたらめなアドレス:配列の添字が大きすぎる、サイズが間違っているなど。実行のたびに値が変わっていました
  • ⑥実行ファイルの中のアドレス:書き込み禁止の領域(文字列リテラルなど)に書いた

③④は、変数の値を表示すると、決まった値が見つかりました。

③ 解放済みのメモリ   target = DDDDDDDDDDDDDDDD
④ 未初期化のメモリ     target = CDCDCDCDCDCDCDCD

デバッグビルドのメモリ管理は、解放したメモリを0xDDで、確保しただけで初期化していないメモリを0xCDで埋めます。ポインタの中身が0xDDDD…なら解放済みのオブジェクト、0xCDCD…なら初期化し忘れ、と、デバッガで見た一瞬で原因が分かります。

【重要】罠:解放済みメモリに書いても、落ちないことがある

⑧は、このまとめの中でいちばん怖い結果でした。解放済みのメモリに書き込んだのに、落ちなかったのです。

[8] 解放済みのメモリに「書くだけ」
    no crash (hp=99)
    finished normally
    (終了コード 0x00000000)

解放した直後のメモリは、OSの上ではまだ「自分のプログラムの領域」として残っていることが多く、書き込んでも、OSは止めません。しかも、その領域は、次のnewで別のオブジェクトに再利用されうるので、別のオブジェクトの値を、知らないうちに書き換えてしまうことがあります。落ちるのは、ずっとあとの、全然関係のない場所になります。

「落ちた」のは、まだ幸運なほうです。アクセス違反が出ないまま、値が少しずつおかしくなるほうが、原因を追うのは大変です。

アクセス違反ではない落ち方:スタックオーバーフロー

⑦の無限再帰は、0xC0000005ではなく、0xC00000FD(スタックオーバーフロー)で落ちました。コンパイル時に、すでに警告が出ていました。

C:\blogtest\av\av.cpp(29) : warning C4717: 'Recurse': すべてのコントロールのパス、関数を回帰するとランタイム スタック オーバーフローが発生します。

「落ちた」というだけで、すべてが0xC0000005ではありません。終了コードが違うときは、原因も違います。

落ちたときの調べ方

  1. 読み取りか書き込みかを見る(書き込みで、アドレスが実行ファイルの中なら、リテラルやconstへの書き込み)
  2. アドレスが、0や小さい値なら、nullptr。その値が、構造体のどのメンバの位置かを、メンバのオフセットと照らして、落ちたメンバを特定する
  3. アドレスが0xFFFFFFFFFFFFFFFFなら、ブレークした行のポインタ変数の値をデバッガで見る。0xDDDD…は解放済み、0xCDCD…は未初期化
  4. アドレスが巨大でたらめなら、配列の添字や、サイズの計算を疑う
  5. 終了コードが違うなら、アクセス違反ではない(0xC00000FDはスタックオーバーフロー)

デバッガの具体的な操作は、デバッグのやり方の記事で解説しています。

アクセス違反のよくある失敗例と対処法

①nullptrチェックを入れたら、落ちなくなったので安心する

nullptrでないことを確認するのは正しい対策です。ただし、解放済みのポインタは、nullptrではありません。③のように、中身が0xDDDD…のポインタは、nullptrチェックを通り抜けます。deleteのあとで、ポインタにnullptrを入れるか、スマートポインタを使います。

②落ちなくなったので、直ったと思う

⑧のとおり、解放済みメモリへの書き込みは、落ちないことがあります。落ちなくなっただけでは、直ったかは分かりません。

③リリースビルドでだけ落ちる

今回の0xDDや0xCDのような手がかりは、デバッグビルドの機能です。リリースビルドでは、同じバグが、別の形で出ます(今回は検証していません)。再現しにくいときは、まずデバッグビルドで試します。

注意点

  • アクセス違反は、「読み書きの種類」と「アドレス」の2つで、原因を絞り込める
  • アドレスが小さい値なら、nullptr+メンバのオフセット。構造体のメンバの位置と照らす
  • x64では、アドレスが0xFFFFFFFFFFFFFFFFになることがある。その場合は、変数の値を見る
  • デバッグビルドでは、0xDDが解放済み、0xCDが未初期化(今回の環境での確認)
  • 解放済みメモリへの書き込みは、落ちないことがある。落ちなくても、安全ではない
  • 今回は、MSVC 19.44のデバッグビルドで確認した。リリースビルドや他のコンパイラは未検証

ここまでのおさらい

  • 0xC0000005は、使ってはいけないメモリを触ったときのエラー。落ちたときの「読み書き」と「アドレス」が手がかりになる
  • 8通り再現した。アドレスが0や小さい値ならnullptr、0xFFFF…なら変数の値を見る、巨大でたらめなら添字やサイズ
  • デバッグビルドでは、0xDDDD…が解放済み、0xCDCD…が未初期化
  • 解放済みへの書き込みは、落ちないことがある。落ちないバグのほうが危ない

アクセス違反は、メッセージが同じでも、原因は1つではありません。落ちた瞬間のアドレスを1つ読むだけで、調べる範囲がぐっと狭くなります。次に落ちたときは、まずアドレスを見てみてください。

あわせて読みたい