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

dwLanguageIdに0を渡すと、言語非依存のメッセージ、スレッドのロケール、ユーザー既定、システム既定、英語(米国)の順に検索されます(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で渡します。
目次