【MFC】GetLastErrorの数値をFormatMessageで文章にする:メッセージが空になる原因

【MFC】GetLastErrorの数値をFormatMessageで文章にする:メッセージが空になる原因

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 はバッファをこちらで用意しなくてよくする指定で、どちらも書き換える理由がありません。

dwLanguageId0 を渡すと、言語非依存のメッセージ、スレッドのロケール、ユーザー既定、システム既定、英語(米国)の順に検索されます(FormatMessageW)。日本語環境なら日本語の文章が返り、その言語に該当がなければ英語まで落ちます。MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT) を明示しても構いませんが、言語の指定はメッセージが空になる問題とは無関係です

戻り値はコピーされた文字数で、失敗すると 0 になります。0 が返ったときだけ GetLastError() を見れば、変換が失敗した理由が分かります。上のコードで else 側に番号を残しているのはそのためです。文章が取れなかったときに何も残さないと、後でログを見ても何が起きたのか分かりません。なお、GetLastError() の値そのものが的外れに見えるときは、変換ではなく読むタイミングの問題であることがあります(「GetLastError() は失敗直後に読む」)。


空になるのは %1 を含むエラーだけ

システムのメッセージ本文には、呼び出し側が値を差し込む前提の %1 %2 が埋め込まれているものがあります。システムエラーコード一覧(0-499)を見ると、その分布には偏りがあります。

コード定数メッセージ本文挿入
2ERROR_FILE_NOT_FOUNDThe system cannot find the file specified.なし
3ERROR_PATH_NOT_FOUNDThe system cannot find the path specified.なし
5ERROR_ACCESS_DENIEDAccess is denied.なし
183ERROR_ALREADY_EXISTSCannot create a file when that file already exists.なし
191ERROR_INVALID_EXE_SIGNATURECannot run %1 in Win32 mode.あり
193ERROR_BAD_EXE_FORMAT%1 is not a valid Win32 application.あり
216ERROR_EXE_MACHINE_TYPE_MISMATCHThis version of %1 is not compatible with the version of Windows you’re running.…あり
217ERROR_EXE_CANNOT_MODIFY_SIGNED_BINARYThe 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(空)
エラー2と193をFORMAT_MESSAGE_IGNORE_INSERTSあり・なしで変換した実行結果

失敗したのは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 で渡します。
目次