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)
読み解く:アドレスの値で、原因が変わる
表を、上から順に見ていきます。
- ①アドレスが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ではありません。終了コードが違うときは、原因も違います。
落ちたときの調べ方
- 読み取りか書き込みかを見る(書き込みで、アドレスが実行ファイルの中なら、リテラルや
constへの書き込み) - アドレスが、0や小さい値なら、
nullptr。その値が、構造体のどのメンバの位置かを、メンバのオフセットと照らして、落ちたメンバを特定する - アドレスが
0xFFFFFFFFFFFFFFFFなら、ブレークした行のポインタ変数の値をデバッガで見る。0xDDDD…は解放済み、0xCDCD…は未初期化 - アドレスが巨大でたらめなら、配列の添字や、サイズの計算を疑う
- 終了コードが違うなら、アクセス違反ではない(
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つ読むだけで、調べる範囲がぐっと狭くなります。次に落ちたときは、まずアドレスを見てみてください。


