GetLastError() が返した数値を人が読める文章にするのはFormatMessageです。ところが同じコードで、エラー2なら「指定されたファイルが見つかりません。」が返るのに、エラー193では戻り値が0になって何も入らない。原因はロケールでも権限でもなく、193のメッセージ本文が %1 is not a valid Win32 application. という挿入シーケンスを含んでいることです。最初にFORMAT_MESSAGE_IGNORE_INSERTSを付けます。
番号を文章にする最小コード
エラーコードを渡すと文章を返す関数を1つ持っておけば、ログにもメッセージボックスにも同じ形で出せます。
CString Win32ErrorText(DWORD err)
{
LPTSTR buffer = nullptr;
const DWORD length = ::FormatMessage(
FORMAT_MESSAGE_ALLOCATE_BUFFER |
FORMAT_MESSAGE_FROM_SYSTEM |
FORMAT_MESSAGE_IGNORE_INSERTS, // ← 外すと空になるコードがある
nullptr,
err,
0, // 言語は既定の検索順に任せる
reinterpret_cast<LPTSTR>(&buffer),
0,
nullptr);
CString text;
if (length != 0 && buffer != nullptr)
{
text = buffer;
text.Trim();
}
else
{
// 変換自体が失敗した。番号だけは残す
text.Format(_T("(FormatMessage 失敗 err=%lu)"), ::GetLastError());
}
if (buffer != nullptr)
{
::LocalFree(buffer);
}
return text;
}
並んでいるフラグのうち、メッセージが空になる問題に効くのはFORMAT_MESSAGE_IGNORE_INSERTSだけです。FORMAT_MESSAGE_FROM_SYSTEMはシステムのメッセージテーブルを引く指定、FORMAT_MESSAGE_ALLOCATE_BUFFERはバッファをこちらで用意しなくてよくする指定で、どちらも書き換える理由がありません。
dwLanguageIdに0を渡すと、言語非依存のメッセージ、スレッドのロケール、ユーザー既定、システム既定、英語(米国)の順に検索されます(FormatMessageW)。日本語環境なら日本語の文章が返り、その言語に該当がなければ英語まで落ちます。MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT) を明示しても構いませんが、言語の指定はメッセージが空になる問題とは無関係です。
戻り値はコピーされた文字数で、失敗すると0になります。0が返ったときだけGetLastError() を見れば、変換が失敗した理由が分かります。上のコードでelse側に番号を残しているのはそのためです。文章が取れなかったときに何も残さないと、後でログを見ても何が起きたのか分かりません。なお、GetLastError() の値そのものが的外れに見えるときは、変換ではなく読むタイミングの問題であることがあります(「GetLastError() は失敗直後に読む」)。
空になるのは %1を含むエラーだけ
システムのメッセージ本文には、呼び出し側が値を差し込む前提の %1 %2が埋め込まれているものがあります。システムエラーコード一覧(0-499)を見ると、その分布には偏りがあります。
| コード | 定数 | メッセージ本文 | 挿入 |
|---|---|---|---|
| 2 | ERROR_FILE_NOT_FOUND | The system cannot find the file specified. | なし |
| 3 | ERROR_PATH_NOT_FOUND | The system cannot find the path specified. | なし |
| 5 | ERROR_ACCESS_DENIED | Access is denied. | なし |
| 183 | ERROR_ALREADY_EXISTS | Cannot create a file when that file already exists. | なし |
| 191 | ERROR_INVALID_EXE_SIGNATURE | Cannot run %1 in Win32 mode. | あり |
| 193 | ERROR_BAD_EXE_FORMAT | %1 is not a valid Win32 application. | あり |
| 216 | ERROR_EXE_MACHINE_TYPE_MISMATCH | This version of %1 is not compatible with the version of Windows you’re running.… | あり |
| 217 | ERROR_EXE_CANNOT_MODIFY_SIGNED_BINARY | The image file %1 is signed, unable to modify. | あり |
ファイル操作で日常的に見る2・3・5・183には挿入がありません。だからFORMAT_MESSAGE_IGNORE_INSERTSを書かずに済ませていても、しばらくは問題が起きません。5が返ってきたときの調べ方は「GetLastError 5(アクセス拒否)の確認箇所」にまとめています。
挿入を含むコードが固まっているのは182〜218の帯、つまりexeやDLLを読み込めなかったときのエラーです。182・188・189・190・192・194・195・198・201・202はいずれもThe operating system cannot run %1.、129はThe %1 application cannot be run in Win32 mode. です。プラグインのDLLが読めずにLoadLibraryが193を返したときが、ちょうどこれにあたります。エラーの文章がいちばん欲しい場面が、そのまま空になる場面です。
挿入を処理させると、無い引数を読みにいく
FORMAT_MESSAGE_IGNORE_INSERTSを付けなければ、FormatMessageは %1を「差し込むべき引数がある」と解釈します。引数はArgumentsパラメーターから受け取りますが、上のコードはそこにnullptrを渡しています。存在しない引数配列を参照しにいくわけです。
公式リファレンスの「セキュリティに関する備考」は、この組み合わせを名指しで否定しています。「APIから返された任意のシステムエラーコードを取得し、FORMAT_MESSAGE_IGNORE_INSERTSなしでFORMAT_MESSAGE_FROM_SYSTEMを使用するのは安全ではありません」とあります。どのコードが返ってくるかは呼び出すAPI次第で、そのメッセージに挿入が含まれるかどうかを呼び出し側は選べないからです。
FORMAT_MESSAGE_IGNORE_INSERTSを付けると、%1は展開されず、そのままの文字として出力バッファへ渡されます。%1 is not a valid Win32 application. が読める文章として手元に入り、変換は成功します。システムエラーコードを文章にする用途では、このフラグは常に付けます。付けて困るのは、自分のメッセージテーブルで意図的に挿入を使っているときだけです。
この挙動をVisual Studio 2026のDebug/x64で確認しました。エラー2とエラー193を、フラグあり・なしの4通りでFormatMessageWに渡した結果です。
| コード | IGNORE_INSERTS | 戻り値 | 変換後の文字列 |
|---|---|---|---|
| 2 | あり | 20 | 指定されたファイルが見つかりません。 |
| 2 | なし | 20 | 指定されたファイルが見つかりません。 |
| 193 | あり | 32 | %1は有効なWin32アプリケーションではありません。 |
| 193 | なし | 0 | (空) |

失敗したのは4行目だけです。挿入を含まないエラー2は、フラグの有無で結果が変わりません。エラー193は、フラグを付ければ %1を残したまま変換され、外すと戻り値0になります。そのときのGetLastError() は87(ERROR_INVALID_PARAMETER「パラメーターが正しくありません。」)でした。差し込む引数が渡されていない、という扱いです。今回の実行では構造化例外は発生せず、戻り値0が静かに返っただけでした。
87が返ることはリファレンスに書かれていないので、他のWindowsで同じとは限りません。値が何であっても、変換の失敗は「戻り値が0かどうか」でしか判定できません。FormatMessageの戻り値を捨てて、受け取ったバッファの中身だけを見ていると、この失敗は空文字として素通りします。
LocalFreeを忘れると呼んだ回数だけ漏れる
FORMAT_MESSAGE_ALLOCATE_BUFFERを指定すると、バッファはローカルメモリとして確保され、その所有権は呼び出し側に移ります。解放するのはLocalFreeです(LocalFree)。deleteでもfreeでもありません。
漏れるのはたいてい、途中で抜ける経路を足したときです。
// NG: 早期 return でバッファが残る
LPTSTR buffer = nullptr;
::FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, err, 0,
reinterpret_cast<LPTSTR>(&buffer), 0, nullptr);
if (!IsLoggingEnabled())
{
return; // ← buffer を解放せずに抜けている
}
WriteLog(buffer);
::LocalFree(buffer);
エラー処理の中で呼ぶ関数なので、リトライのたびに1回ずつ漏れます。数バイトずつですが、通信の再接続ループのように失敗が続く経路に置くと、実行時間に比例して増えていきます。前掲のWin32ErrorTextのように確保から解放までを1つの関数に閉じ込め、外へはCStringで返すのが確実です。呼び出し側は解放を意識しなくてよくなります。
デバッグビルドでリークを検出したいなら、CRTのリーク検出と組み合わせます。LocalAlloc経由の確保はCRTのヒープとは別なので、CRTのダンプには出ません。_CrtDumpMemoryLeaksが沈黙していても、このバッファは漏れています。
残った %1は自分で埋めるか、定数名を添える
FORMAT_MESSAGE_IGNORE_INSERTSで変換に成功しても、文章には %1が残ったままです。ログに %1は有効なWin32アプリケーションではありません。 とだけ書いてあっても、どのファイルの話か分かりません。差し込む値はこちらが持っているので、変換後に置き換えます。
CString text = Win32ErrorText(err);
text.Replace(_T("%1"), dllPath); // 読み込もうとしたパスを入れる
TRACE(_T("LoadLibrary 失敗: %s (err=%lu)\n"), (LPCTSTR)text, err);
そして、文章だけをログに残さないでください。ローカライズされた文章は環境によって変わり、検索もしにくくなります。数値・定数名・文章の3つを一緒に出すと、後から読む人がどれでも追えます。
// 193 (ERROR_BAD_EXE_FORMAT): C:\app\plugin.dll は有効な Win32 アプリケーションではありません。
TRACE(_T("%lu (%s): %s\n"), err, ErrorConstantName(err), (LPCTSTR)text);
定数名は自分で表を持つしかありません。全部を並べる必要はなく、そのアプリで実際に出るコードだけで十分です。デバッガで止まっているなら、ウォッチウィンドウに @err,hrと入力すれば、コードを書かずに現在のコードと説明を確認できます。
なお193は、ビット数の取り違えでも出ます。64bitのプロセスに32bitのDLLを読ませた、あるいはその逆の組み合わせです。この場合は変換ではなくDLL側の問題なので、「0xc000007bでアプリが起動しない」が対応します。
まとめ
- システムエラーコードを文章にするときはFORMAT_MESSAGE_IGNORE_INSERTSを必ず付けます。付け忘れると、メッセージ本文に %1を含むコードで変換が失敗します。
- 変換の失敗は戻り値0でしか分かりません。バッファの中身だけを見ていると、空文字として素通りします。
- FORMAT_MESSAGE_ALLOCATE_BUFFERのバッファはLocalFreeで返します。確保から解放までを1つの関数に閉じ込め、外へはCStringで渡します。
