CreateWindow・ShowWindow:メッセージループとDPI対応まで書くWin32

CreateWindowEx・ShowWindowでウィンドウを出す完全なコードとメッセージループ、WM_DESTROY、DPI対応、ANSI/Unicodeのコンパイルエラー、C# LibraryImport版まで解説。

2026年11月改訂: この記事は2021年に韓国語で書いた CreateWindow・ShowWindow の講座を、日本語向けに書き直したものです。元の例は Visual Studio テンプレートの InitInstance 関数の一部だけで、1つ目は閉じ波かっこが抜けており、2つ目は MinGW-w64 の既定設定で cannot convert 'WCHAR*' to 'LPCSTR' になりコンパイルできませんでした。今回はクラス登録からメッセージループ、終了処理、DPI 対応までを含む完全なプログラムと、C# の LibraryImport 版を新しく書いています。すべて MinGW-w64(GCC 16.2.0)または .NET 10 SDK で Windows 向けにコンパイルしましたが、執筆に使った Mac では実行していません。実行時の動きは Microsoft Learn に書かれた仕様をもとに説明します。

Win32 API でウィンドウを1枚出したいのに、「閉じてもプロセスが終わらない」「4K ディスプレイで文字がにじむ」「サンプルを貼ったらコンパイルが通らない」と詰まった人向けの記事です。先に結論を書くと、CreateWindowEx はウィンドウを作るだけ、ShowWindow が表示し、その後の仕事はすべてメッセージループとウィンドウプロシージャがします。どれか1つでも欠けると上の症状が出ます。

CreateWindow は関数ではなくマクロ

2021年版は CreateWindow を関数として紹介していましたが、winuser.h ではマクロです。CreateWindow(...) は拡張スタイル0の CreateWindowEx(0, ...) になり、さらに UNICODE の有無で CreateWindowExW か CreateWindowExA に分かれます。また元の記事では10番目の引数を HANDLE と書いていましたが、正しくは HINSTANCE です。

HWND CreateWindowExW(
    DWORD     dwExStyle,    // WS_EX_...。CreateWindow は0を渡す
    LPCWSTR   lpClassName,  // RegisterClassExW で登録したクラス名か ATOM
    LPCWSTR   lpWindowName, // タイトルバーの文字列
    DWORD     dwStyle,      // WS_OVERLAPPEDWINDOW など
    int X, int Y, int nWidth, int nHeight,
    HWND      hWndParent,   // 親(またはオーナー)ウィンドウ
    HMENU     hMenu,        // メニュー。子ウィンドウならコントロールID
    HINSTANCE hInstance,
    LPVOID    lpParam);     // WM_CREATE の CREATESTRUCT.lpCreateParams に届く

スタイルの表は元の記事でおおむね合っていました。実際によく使うものだけ挙げます。

スタイル 意味
WS_OVERLAPPEDWINDOW 普通のウィンドウ。タイトルバー、システムメニュー、サイズ変更枠、最小化・最大化ボタン
WS_POPUP タイトルバーのないポップアップ。WS_CHILD とは併用不可
WS_CHILD ボタンやエディットなどの子ウィンドウ。hWndParent が必須
WS_VISIBLE 作成と同時に表示。ShowWindow を別に呼ばなくてよい
WS_CLIPCHILDREN 親の描画で子の領域を除外(ちらつき軽減)
WS_BORDER 細い枠。サイズ変更不可

元の2つ目の例は WS_BORDER だけを指定していました。WS_OVERLAPPED の値は0なので、これもオーバーラップウィンドウとして作られ、Windows はオーバーラップウィンドウに必ずタイトルバーを付けます。当時のスクリーンショットにタイトルバーがあったのはそのためで、なくなるのはサイズ変更と最小化・最大化ボタンです。

2021年のコードを MinGW でコンパイルすると

元の例は Visual Studio の「Windows デスクトップ アプリケーション」テンプレートから InitInstance を切り出したものなので、単体ではコンパイルできません。テンプレートと同じグローバル変数(WCHAR szWindowClass[100] など)を足して x86_64-w64-mingw32-g++ に通しました。

1つ目は関数末尾の } がありませんでした。

create2021_first.cpp:23:16: error: expected '}' at end of input
   23 |    return TRUE;
      |                ^

かっこを補った2つ目は、MinGW の既定設定で次のエラーになりました。

create2021.cpp:14:24: error: cannot convert 'WCHAR*' {aka 'wchar_t*'} to 'LPCSTR' {aka 'const char*'}
   14 |    hWnd = CreateWindow(szWindowClass, L"타이틀", WS_BORDER,
      |                        ^~~~~~~~~~~~~
…/winuser.h:2203:65: note: initializing argument 2 of 'HWND__* CreateWindowExA(DWORD, LPCSTR, LPCSTR, …)'

原因は ANSI と Unicode の混在です。Visual Studio のプロジェクトは構成プロパティの「文字セット」が「Unicode 文字セットを使用する」になっていて UNICODE が定義されるため、マクロは CreateWindowExW を選び、WCHAR 配列も L"..." もそのまま通ります。MinGW は何も定義しないので CreateWindowExA が選ばれ、char の文字列を要求します。-DUNICODE -D_UNICODE を付けると同じファイルが警告なしで通りました。

ビルドで UNICODE を定義してもよいのですが、この記事のおすすめは CreateWindowExW、RegisterClassExW、DefWindowProcW のように W 版を直接呼ぶことです。ビルド設定が変わっても意味が変わらず、日本語のタイトルが Shift_JIS(CP932)を経由して化けることもありません。

メッセージループと終了処理まで入れた完全なコード

元の記事は最後に「クラス定義 → 登録 → 作成 → 表示 → メッセージループ」と順番だけを書いていました。全部を1ファイルにすると次のとおりです。

// window.cpp — Unicode、Per-Monitor V2 DPI 対応(Windows 10 1703 以降)
#include <windows.h>

static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
    switch (msg) {
    case WM_PAINT: {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hwnd, &ps);
        TextOutW(hdc, 16, 16, L"Hello, Win32", 12);
        EndPaint(hwnd, &ps);
        return 0;
    }
    case WM_DPICHANGED: {                 // 拡大率の違うモニターへ移動した
        RECT* r = reinterpret_cast<RECT*>(lp);
        SetWindowPos(hwnd, nullptr, r->left, r->top, r->right - r->left,
                     r->bottom - r->top, SWP_NOZORDER | SWP_NOACTIVATE);
        return 0;
    }
    case WM_DESTROY:
        PostQuitMessage(0);               // これがないとプロセスが終わらない
        return 0;
    }
    return DefWindowProcW(hwnd, msg, wp, lp);
}

int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int nCmdShow)
{
    SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

    WNDCLASSEXW wc{};
    wc.cbSize        = sizeof(wc);
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInst;
    wc.hCursor       = LoadCursorW(nullptr, IDC_ARROW);
    wc.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1);
    wc.lpszClassName = L"TLabDemoWindow";
    if (!RegisterClassExW(&wc)) return 1;

    UINT dpi = GetDpiForSystem();         // 96 = 100%、144 = 150%
    HWND hwnd = CreateWindowExW(0, L"TLabDemoWindow", L"Demo",
        WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
        MulDiv(500, dpi, 96), MulDiv(300, dpi, 96),
        nullptr, nullptr, hInst, nullptr);
    if (!hwnd) return 1;

    ShowWindow(hwnd, nCmdShow);
    UpdateWindow(hwnd);

    MSG msg;
    while (GetMessageW(&msg, nullptr, 0, 0) > 0) {   // 0 = WM_QUIT、-1 = エラー
        TranslateMessage(&msg);
        DispatchMessageW(&msg);
    }
    return static_cast<int>(msg.wParam);
}
$ x86_64-w64-mingw32-g++ -std=c++17 -Wall -Wextra -O2 -municode -mwindows window.cpp -o window.exe -luser32 -lgdi32
$ file window.exe
window.exe: PE32+ executable (GUI) x86-64, for MS Windows

-Wall -Wextra でも警告は0件でした。押さえておきたい点があります。

  • -municode を外すとリンクエラー(undefined reference to 'WinMain')になります。MinGW はこのオプションがあるときだけ wWinMain をエントリポイントにします。-DUNICODE を足しても結果は同じでした。さらに UNICODE なしでは IDC_ARROW が char* の定数になり、LoadCursorW に渡すところで cannot convert 'LPSTR' to 'LPCWSTR' になりました。Unicode の設定が中途半端だと、思わぬ場所で壊れます。
  • while (GetMessage(...)) とは書かないこと。 GetMessage はエラー時に -1 を返し、-1 は真なので無限ループになります。Microsoft Learn の GetMessage のページにも同じ注意があります。> 0 と比べれば WM_QUIT(0)でもエラー(-1)でも抜けます。
  • 最初の ShowWindow には nCmdShow をそのまま渡します。 ショートカットの「実行時の大きさ:最小化」などはこの値で届きます。元の表にあった SW_FORCEMINIMIZE の「Windows 2000 以降のみ」という注記は、今は気にしなくてかまいません。
  • UpdateWindow は WM_PAINT をすぐ送って最初の描画を済ませます。 なくてもいずれ描画されますが、テンプレートの慣例です。
フロー図。上段:RegisterClassExW、WM_CREATE を送る CreateWindowExW、ShowWindow と UpdateWindow、GetMessageW のループ。GetMessageW が0より大きい値を返すと DispatchMessageW がウィンドウプロシージャを呼ぶ。ウィンドウを閉じると WM_CLOSE から DestroyWindow、WM_DESTROY と進み、そこで PostQuitMessage を呼ぶ。WM_QUIT が届くと GetMessageW が0を返し、wWinMain は msg.wParam を返して終わる。
最小限の Win32 ウィンドウの一生。CreateWindowExW は作るだけ、ShowWindow で表示し、メッセージループが WM_DESTROY から WM_QUIT が投げられるまで動かし続ける。

WM_DESTROY と PostQuitMessage の役割

閉じるボタンを押すと WM_CLOSE が届き、DefWindowProcW がそれを受けて DestroyWindow を呼びます。破棄の途中で WM_DESTROY が来るので、そこで PostQuitMessage(0) を呼ぶと、スレッドのキューに WM_QUIT が入り、GetMessageW が0を返してループが終わります。

この1行を忘れると、ウィンドウは消えたのにプロセスだけが残ります。ループが来ない WM_QUIT を待って眠り続けるからです。逆に、ウィンドウを複数持つアプリでサブウィンドウまで PostQuitMessage を呼ぶと、サブを1枚閉じただけでアプリ全体が終わります。呼ぶのはメインウィンドウだけが原則です。「保存しますか?」で閉じるのを取り消したいときは、WM_CLOSE を処理して DestroyWindow を呼ばなければよいだけです。

DPI 対応はマニフェストが第一候補

DPI 非対応のプログラムは 96 DPI で描いた画面を Windows がビットマップとして拡大するため、150% のディスプレイでは文字がにじみます。2021年版にはなかった話ですが、今はノート PC のほとんどが100%以外の拡大率で出荷されるので外せません。

上のコードはウィンドウを作る前に SetProcessDpiAwarenessContext(Windows 10 1703 以降)を呼んでいます。ただし Microsoft Learn は API よりもアプリケーションマニフェストでの指定を推奨しています。プロセス起動時点で決まるので、呼ぶ順番を間違える余地がありません。MinGW ではリソースとして埋め込みます。

<!-- app.manifest -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
    </windowsSettings>
  </application>
</assembly>
$ printf '#include <winuser.h>\nCREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "app.manifest"\n' > app.rc
$ x86_64-w64-mingw32-windres app.rc -O coff -o app.res.o
$ x86_64-w64-mingw32-g++ … window.cpp app.res.o -o window_m.exe -luser32 -lgdi32

できた exe に .rsrc セクションがあり、その中に PerMonitorV2 の文字列が入っていることまで確認しました。dpiAwareness を知らない Windows 10 1607 より前の版は dpiAware の行を読みます。マニフェストで指定済みなら API の呼び出しは失敗する(ドキュメント上は ERROR_ACCESS_DENIED)ので、どちらか片方にします。

DPI 対応を宣言すると、拡大の面倒は自分で見ることになります。500×300 の固定値では150%のモニターで小さく見えるため MulDiv(500, dpi, 96) で換算し、別の拡大率のモニターへ移ったときに届く WM_DPICHANGED の lParam(推奨サイズの RECT)をそのまま適用しています。フォントやアイコンも DPI に合わせて作り直す必要があり、ここが素の Win32 でいちばん手間のかかる部分です。

C# の LibraryImport で同じウィンドウを作る

.NET からも P/Invoke で同じことができます。.NET 7 以降は、実行時にマーシャリングのスタブを作る DllImport より、ソースジェネレーターがコンパイル時にコードを生成する LibraryImport が推奨です。たとえば FindWindow を DllImport で宣言し、SYSLIB1054 を警告に上げると、アナライザーがこう教えてくれます。

warning SYSLIB1054: Mark the method 'FindWindow' with 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time

SYSLIB1054 は既定の重大度が「情報」で、普通のビルド出力には出ません。.editorconfig に dotnet_diagnostic.SYSLIB1054.severity = warning を書いて表示させました。以下は net10.0-windows、<AllowUnsafeBlocks>true</AllowUnsafeBlocks>、<OutputType>WinExe</OutputType> のプロジェクトの要点です(構造体や宣言の一部は … で省略)。

using System.Runtime.InteropServices;

unsafe
{
    Native.SetProcessDpiAwarenessContext(-4);  // PER_MONITOR_AWARE_V2
    nint hInst = Native.GetModuleHandleW(null);
    fixed (char* cls = "TLabDemoWindowCs")
    {
        var wc = new WNDCLASSEXW
        {
            cbSize = (uint)sizeof(WNDCLASSEXW),
            lpfnWndProc = &WndProc,                 // [UnmanagedCallersOnly] の static 関数
            hInstance = hInst,
            hCursor = Native.LoadCursorW(0, 32512), // IDC_ARROW
            hbrBackground = 5 + 1,                  // COLOR_WINDOW + 1
            lpszClassName = cls,
        };
        if (Native.RegisterClassExW(in wc) == 0) return 1;
    }
    uint dpi = Native.GetDpiForSystem();
    nint hwnd = Native.CreateWindowExW(0, "TLabDemoWindowCs", "Demo (C#)",
        0x00CF0000 /* WS_OVERLAPPEDWINDOW */, unchecked((int)0x80000000), unchecked((int)0x80000000),
        (int)(500 * dpi / 96), (int)(300 * dpi / 96), 0, 0, hInst, 0);
    if (hwnd == 0) return 1;
    Native.ShowWindow(hwnd, 1);                     // SW_SHOWNORMAL
    MSG msg;
    while (Native.GetMessageW(&msg, 0, 0, 0) > 0)
    {
        Native.TranslateMessage(&msg);
        Native.DispatchMessageW(&msg);
    }
    return (int)msg.wParam;
}

[UnmanagedCallersOnly]
static nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam)
{
    if (msg == 0x0002) { Native.PostQuitMessage(0); return 0; }   // WM_DESTROY
    return Native.DefWindowProcW(hwnd, msg, wParam, lParam);
}

static unsafe partial class Native
{
    [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    public static partial nint CreateWindowExW(uint exStyle, string cls, string title, uint style,
        int x, int y, int w, int h, nint parent, nint menu, nint inst, nint param);
    [LibraryImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)]
    public static partial bool ShowWindow(nint hwnd, int cmd);
    [LibraryImport("user32.dll")]
    public static partial int GetMessageW(MSG* msg, nint hwnd, uint min, uint max);
    // … RegisterClassExW、TranslateMessage、DispatchMessageW、DefWindowProcW など
}
$ dotnet build -c Release -r win-x64 --self-contained false -p:EnableWindowsTargeting=true
Build succeeded.
$ file bin/Release/net10.0-windows/win-x64/WinCreate.exe
WinCreate.exe: PE32+ executable (GUI) x86-64, for MS Windows

.NET SDK 10.0.403 で警告なしにビルドできました(こちらも実行はしていません)。LibraryImport へ移す途中で実際に出た診断は次のとおりです。

うっかり 実際の診断
クラスに partial がない SYSLIB1050、CS0751、CS8795
string 引数に StringMarshalling がない error SYSLIB1051: Marshalling string or char without explicit marshalling information is not supported
bool の戻り値に MarshalAs がない error SYSLIB1051: Marshalling bool without explicit marshalling information is not supported
プロジェクトに AllowUnsafeBlocks がない error SYSLIB1062: LibraryImportAttribute requires unsafe code

ウィンドウプロシージャはデリゲートではなく、[UnmanagedCallersOnly] を付けた static 関数の関数ポインター(delegate* unmanaged<...>)で渡しました。ネイティブ側に渡したデリゲートは、Windows が呼び続ける間 GC に回収されないよう保持しておく必要がありますが、static 関数のポインターならその心配がありません。デリゲートとコールバックそのものは C# のデリゲートとコールバック で扱っています。

実測:実行ファイルのサイズと、2026年に何を選ぶか

MinGW-w64 GCC 16.2.0、-O2 でビルドした上の C++ 版のサイズです。

ビルド サイズ
window.exe(デバッグシンボル付き) 154,440 バイト
window_s.exe(-s でシンボル削除) 17,920 バイト
window_m.exe(-s + DPI マニフェスト) 18,944 バイト
C# の WinCreate.dll + アプリホスト WinCreate.exe 8,192 + 162,304 バイト(別途 .NET 10 ランタイムが必要)

シンボルを削った C++ 版は 19KB 弱で、インポートしている DLL は USER32、GDI32、KERNEL32 と、Windows 10 以降に標準で入っている UCRT(api-ms-win-crt-*)だけでした。インストーラーなしでコピーするだけで動く小さなツールなら、今でも Win32 C++ がいちばん身軽です。

判断図。出発点は新しい Windows デスクトップ UI。1つ目の問い:ランタイム不要の小さなネイティブ exe が要る、または既存の Win32 コードに手を入れるか。はいなら、この記事の Win32 C++。いいえなら2つ目の問い:Windows 11 の Fluent デザインが要るか。はいなら Windows App SDK の WinUI 3、いいえなら .NET 10 の WinForms か WPF。
2026年に Windows デスクトップアプリを新しく作るときの UI の選び方。素の Win32 が正解になるのは限られた場合だけ。

とはいえ、新しいデスクトップアプリを素の Win32 で始める理由はあまりありません。ボタン、フォント、DPI の変化まですべて自分で扱い、アクセシビリティ(スクリーンリーダー)やダークモードも自前です。業務ツールなら .NET 10 の WinForms か WPF のほうがずっと速く作れ、Windows 11 らしい見た目が欲しければ Windows App SDK の WinUI 3 が合います。どれも内部では CreateWindowEx とメッセージループの上に乗っているので、この記事の構造は「UI スレッドが固まった」ときの調査にそのまま役立ちます。

他のプログラムのウィンドウハンドルを探す方法は FindWindow と EnumWindows、そのウィンドウにデッドロックせずメッセージを送る方法は SendMessage と PostMessage に続きます。

検証環境:macOS(Apple Silicon)上の MinGW-w64 x86_64-w64-mingw32-g++ (GCC) 16.2.0、.NET SDK 10.0.403(EnableWindowsTargeting=true)。Windows 向けにコンパイルのみ行い、実行はしていません。

よくある質問

ウィンドウを閉じてもプロセスが残るのはなぜ?

WM_DESTROY で PostQuitMessage(0) を呼んでいないからです。ウィンドウが消えても GetMessage のループは WM_QUIT を受け取るまで終わらないため、プロセスがタスクマネージャーに残ります。メインウィンドウのウィンドウプロシージャで WM_DESTROY を受けたら PostQuitMessage(0) を呼びます。

MinGW で CreateWindow に L"…" を渡すとコンパイルエラーになるのはなぜ?

MinGW は既定で UNICODE を定義しないので、CreateWindow マクロが CreateWindowExA(char 文字列)に展開されます。wchar_t の文字列を渡すと cannot convert 'WCHAR*' to 'LPCSTR' になります。Visual Studio のプロジェクトは「Unicode 文字セットを使用する」が既定なので同じコードが通っていました。CreateWindowExW など W 版の関数を直接呼べば設定に左右されません。

高解像度ディスプレイでウィンドウがぼやけます。

DPI 非対応のプログラムは Windows がビットマップとして引き伸ばして表示するのでぼやけます。マニフェストに dpiAwareness PerMonitorV2 を書くか、ウィンドウを作る前に SetProcessDpiAwarenessContext を呼び、サイズを DPI で換算して WM_DPICHANGED に対応します。Microsoft は API よりマニフェストを推奨しています。

  • #Win32
  • #CreateWindowEx
  • #ShowWindow
  • #メッセージループ
  • #DPI
  • #LibraryImport

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑