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で渡します。
