从WinMain开始:手写第一个Win32窗口程序
写第一个 Windows 图形程序好多人第一反应是打开 Visual Studio新建项目向导自动把一堆文件生成好拖几个控件就跑起来。但等你真接手一个老项目或者碰到链接错误、窗口刷不出来、托盘图标消失这类问题还是得回头补底层那套东西。我一直觉得哪怕平时全靠框架写界面也值得用记事本和编译器亲手从 WinMain 函数开始写出第一个 Win32 窗口程序。窗口类注册、消息循环、窗口过程这几块骨头啃下来后面学 MFC、Qt、WinUI 都会顺很多因为框架只是帮你包了一层皮核心依然是这些 API。这个项目适合两类人刚学完 C 语言、想在 Windows 上画出第一个窗口的初学者以及用了很久界面库却没亲手注册过窗口类的开发者。我这里不依赖任何 IDE打开任意文本编辑器一条命令行就能完成编译机器上有 Windows 系统和 MinGW-w64 就够了。一步一步来把代码、参数、常见报错全部讲透。1. 准备开发环境编译器和编辑器选择1.1 为什么用 MinGW-w64 而不是 Visual Studio写 Win32 程序不一定非用 Visual Studio。VS 的 MSVC 编译器只是个工具它强制你使用解决方案和项目文件对新手来说有太多无关概念比如 .vcxproj、Debug 和 Release 配置、附加依赖项。MinGW-w64 是 GCC 在 Windows 上的移植版它直接用命令行编译一条命令就出结果所有细节都摆在明面上非常适合理解构建过程。另外一个现实原因是体积。VS 的安装包动辄几个 GMinGW-w64 的离线压缩包几百 MB 就够。很多开源项目、跨平台开发者、Windows 下用 Vim 或 VS Code 写代码的人都默认用 MinGW-w64。它生成的程序在运行时对系统库的依赖纯粹依赖 Windows 自带的 kernel32、user32、gdi32 等 DLL不需要额外运行时这一点在需要分发 exe 时特别省事。不过 MSVC 也不是完全没有优势它对 Windows 新 API 的导入库更新更快调试器更强。但对“手写第一个 Win32 窗口程序”这个目标MinGW-w64 足够而且更容易上手。用软件包管理器安装也行手动从官网下压缩包也行一条 PATH 配置好控制台里敲个 gcc --version 就开工。1.2 环境安装与验证步骤我以 MinGW-w64 为例补全这个过程。下载解压后把 bin 目录加到系统 PATH 环境变量里然后在命令提示符或 PowerShell 里验证gcc --version能输出类似 gcc (MinGW-W64) 12.2.0 这样的信息就说明编译器可用了。如果命令提示无法识别检查 PATH 是否真的配好或者打开一个新终端再试因为终端不会自动读取修改后的环境变量。编辑器方面纯文本就可以记事本能写但体验一般。我建议用 VS Code 或者 Notepad能显示行号还能顺手改改编码。Win32 程序源码我用 UTF-8 保存代码里如果有中文宽字符常量需要留意编码问题这个后面讲。2. 拆解 Win32 窗口程序的骨架结构2.1 入口函数和普通函数的区别大多数 C 程序入口是 mainWindows 图形界面程序的入口是 WinMain 或者 wWinMain。区别在于 main 收到的参数来自命令行而 WinMain 还额外拿到两个特殊值当前程序实例句柄 hInstance以及上一个实例句柄 hPrevInstance。hInstance 表示当前 exe 模块在内存里的加载地址注册窗口类、创建窗口、加载图标时经常要传入用来告诉系统“这个资源属于哪个模块”。hPrevInstance 在 16 位 Windows 时代还有意义32 位以后永远为 NULL。所以不用对它做任何判断留着只是为了兼容旧代码接口。那么为什么是 WinMain因为图形界面程序需要进入消息驱动的运行模式。它不会像控制台程序那样从入口函数一路顺序执行完就退出而是先创建窗口再进入一个循环持续从系统消息队列里取消息、分发消息直到用户关窗口最后退出循环。入口函数的名字不能随便改链接器默认在子系统程序里查找这个名字。2.2 窗口类、句柄、消息循环的理解方式很多人第一次看 Win32 程序会懵因为代码里全是“注册”——窗口类注册、消息注册。窗口类和 C 的类完全是两回事。Win32 的“窗口类”是一个结构体用来描述一类窗口的共同属性消息处理函数、背景画刷、光标、图标、类名。系统把窗口分为不同类别每个窗口创建时都要指定一个已注册的类名。RegisterClass 就是把你自己定义的这种窗口属性告诉系统之后 CreateWindow 时按类名创建实例。窗口实例的代表是句柄 HWND可以理解成操作系统给每个窗口发的身份证号。你不需要维护指向窗口对象的指针所有对窗口的操作都通过 HWND 交给系统内部处理。你会经常发现同一个消息处理函数被多个相同类的窗口共用此时 HWND 就是区分不同窗口实例的关键。消息循环是 GUI 程序的心脏。程序启动后调用 GetMessage 从当前线程的消息队列里取出消息如果是键盘消息就交给 TranslateMessage 做字符转换然后再调用 DispatchMessage 把消息分发给对应的窗口过程函数。窗口过程收到消息后根据消息编号决定做什么。整个过程几乎是死循环只有收到 WM_QUIT 消息时 GetMessage 返回 0循环才结束。2.3 谁在调用窗口过程函数初学者常疑惑窗口过程明明是我们自己写的函数但它不是我们直接调用的而是系统在适当的时候回调。这也是为什么窗口过程必须声明为 CALLBACK其实就是 __stdcall 调用约定。这个回调发生在 DispatchMessage 里系统找到消息目标窗口的类再找到类里注册的过程函数地址然后调用它。窗口过程就是个大号 switch。常见消息有 WM_PAINT窗口需要重绘、WM_LBUTTONDOWN鼠标左键按下、WM_SIZE窗口尺寸改变、WM_DESTROY窗口被销毁。处理不了的消息必须交给 DefWindowProc 这个默认处理函数否则窗口的行为会非常怪异比如你什么都不写窗口连重绘都做不了。3. 完整代码实现从空白到可运行窗口3.1 第一版完整代码我直接把自己验证过的完整代码贴出来然后逐段解释。#include windows.h LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); RECT rc; GetClientRect(hWnd, rc); DrawTextW(hdc, L我的第一个 Win32 窗口, -1, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); EndPaint(hWnd, ps); } return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); } int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nCmdShow) { const wchar_t CLASS_NAME[] LFirstWindowClass; WNDCLASSW wc {0}; wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hCursor LoadCursorW(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName CLASS_NAME; RegisterClassW(wc); HWND hwnd CreateWindowExW( 0, CLASS_NAME, LWIN 开发起步, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL ); if (hwnd NULL) { return 1; } ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessageW(msg, NULL, 0, 0) 0) { TranslateMessage(msg); DispatchMessageW(msg); } return (int)msg.wParam; }这个程序编译后会弹出一个 800x600 的窗口标题是“WIN 开发起步”客户区中央显示一行文字。关闭窗口后程序退出。3.2 注册窗口类的关键参数讲解WNDCLASSW 结构体里的每一项都值得理解。wc.style 取的是类样式。CS_HREDRAW 和 CS_VREDRAW 分别表示窗口水平或垂直尺寸变化时客户区全部重绘。这两个值加起来的效果是拖动窗口边框调整大小后文字位置自动重新计算不会留下残影。如果只追求性能可以不设置但第一次手写程序我建议加上能少踩很多刷新不干净的坑。wc.lpfnWndProc 就是窗口过程函数指针。注意这里直接写函数名即可因为函数名本身代表地址。它必须和函数签名完全一致返回 LRESULT参数是 HWND、UINT、WPARAM、LPARAM。wc.hCursor 用 LoadCursor 加载系统预设的光标 IDC_ARROW也就是普通箭头。如果不加载窗口可能没有光标或者显示默认值看起来不专业。wc.hbrBackground 是背景画刷。这里用 (HBRUSH)(COLOR_WINDOW1)取系统预定义颜色 COLOR_WINDOW窗口背景色的控件色加 1。加 1 是因为 COLOR_WINDOW 的值 5 不能直接作为画刷句柄系统约定要加 1 才能让 GetSysColor 取色。用这个写法意味着当窗口需要擦除背景时系统用标准窗口底色填充通常是白色。wc.lpszClassName 是类名随便起但必须是唯一字符串。注意我加了后缀 W 使用 WNDCLASSW 和 RegisterClassW因为源码是宽字符版本。如果你看到别人代码里用 WNDCLASS 没带后缀那是编译器根据 UNICODE 宏自动把类型和函数编译成 W 版本。3.3 消息循环和窗口过程的工作关系窗口创建好之后程序进入消息循环代码是这几行MSG msg; while (GetMessageW(msg, NULL, 0, 0) 0) { TranslateMessage(msg); DispatchMessageW(msg); } return (int)msg.wParam;GetMessageW 从当前线程队列取消息。取到 WM_QUIT 时返回 0循环结束。返回 -1 是出错平时不会遇到。只要消息不是 WM_QUIT就先 TranslateMessage 把键盘的虚拟键消息翻译成字符消息比如把 WM_KEYDOWN 翻译成 WM_CHAR然后 DispatchMessageW 按消息的 hwnd 字段找到目标窗口调用前面注册的 WndProc 函数。窗口过程收到消息后执行回调。WM_DESTROY 是一个很重要的消息当用户点击右上角关闭按钮系统先销毁窗口然后向窗口过程发送 WM_DESTROY。此时窗口过程调用 PostQuitMessage(0)把 WM_QUIT 消息放到消息队列里消息循环看到 WM_QUIT 才退出程序才结束。有人会问关闭窗口的时候为什么不直接在 WM_CLOSE 里退出程序因为 WM_CLOSE 是在销毁前给出的一个机会你可以在里面拦截比如弹出“是否保存”的对话框。默认的 DefWindowProc 处理 WM_CLOSE 时调用 DestroyWindow这才产生 WM_DESTROY。所以你是不是在 WM_CLOSE 里拦截决定了关闭行为是否可控。WM_PAINT 是绘制代码所在。BeginPaint 获得设备描述表GetClientRect 拿到客户区尺寸DrawTextW 在客户区居中画字符串EndPaint 释放画笔并校验绘制区域。这个流程是固定套路不能反过来也不能漏掉 BeginPaint 和 EndPaint 配对否则系统认为绘制没完成WM_PAINT 会一直发导致 CPU 占用高。3.4 字符编码问题A 版本和 W 版本的区别Win32 API 其实有两个版本比如 MessageBoxA 和 MessageBoxWCreateWindowExA 和 CreateWindowExW。A 版本接受 ANSI 字符串也就是当前系统代码页中文系统上是 GBKW 版本接受 UTF-16 宽字符串。现代 Windows 内部几乎全用 Unicode所以新代码建议直接用 W 版本避免中文乱码和跨系统兼容问题。我在代码里用 wWinMain 而不是 WinMain也是这个原因。wWinMain 的 lpCmdLine 是 LPWSTR直接就是宽字符main 函数则可能是 ANSI。如果链接器不认 wWinMain记得在编译时加 -municode 选项告诉 GCC 用 Unicode 版本的入口。4. 实操过程编译运行第一版窗口程序4.1 命令行编译的参数解析把代码保存成 first_window.c 后打开命令提示符进入源码目录执行gcc -municode -mwindows first_window.c -o first_window.exe这条命令里两个参数很关键。一个依赖 -mwindows让链接器选择 Windows 子系统而不是控制台子系统。这决定了程序运行时不弹出黑色控制台窗口。有意思的是如果你忘了加这个参数程序功能一样但启动时旁边会带一个黑色控制台窗口。作为 GUI 程序这显然不行。另一个依赖 -municode因为入口函数用 wWinMain需要链接器找到 Windows 宽字符版本入口。全部编译成功后当前目录会出现 first_window.exe。命令行直接运行first_window.exe或者双击 exe 文件看到窗口弹出来程序就算成功跑起来了。第一次看到自己手写的窗口成就感还是很强的。4.2 加一条消息提示验证程序是否健壮现在窗口只有一个 WM_PAINT 绘制交互还很少。想看程序对消息处理是不是真的正常可以在 WM_PAINT 之外的某个位置临时加一条 Windows 消息框case WM_LBUTTONDOWN: MessageBoxW(hwnd, L鼠标按下了, L提示, MB_OK); return 0;不过编译前要注意一点加上这条代码后每次点击窗口都会弹窗。如果点多了每弹一个窗相当于进入一个新的消息循环。MessageBox 是模态窗口内部有独立的循环来分发消息这会导致外面那层按下的消息还没处理完里面的消息又开始读。在窗口过程里弹模态框时要留意行为是否符合预期用户关掉后流程要能继续。我实际测试过这个小改动没有问题点一次弹一次。但如果你在 WM_PAINT 里 MessageBox就会有重入问题WM_PAINT 在未完成绘制时又触发新的绘制轻则闪烁重则卡死所以绘制回调里一定不要弹窗或者等待用户输入。5. 常见问题与排查技巧实录5.1 编译失败的典型报错与处理我见过不少新手卡在编译环节最常见的报错是 undefined reference to WinMain。如果你的代码用的是 WinMain但编译器是按控制台程序链接或者入口函数声明写错都会出这个问题。还有一种是链接器报 cannot find -lgcc 之类那通常是 MinGW 安装不完整或者 PATH 配置有误。这种时候我建议重新解压一套干净的 MinGW-w64避免以前装过旧版互相污染。如果你用 MSVC 编译器命令会不一样。cl 编译时默认不指定子系统需要在链接参数里加 /SUBSYSTEM:WINDOWS所以用 MSVC 还是 MinGW 都要理解一个核心GUI 程序必须告诉链接器目标是窗口子系统。把这一点想明白报错就能顺着思路解。下面把常见的编译报错整理成表格方便对照。报错信息可能原因解决策略undefined reference to WinMain链接器在找 ANSI 入口但入口实际是 wWinMain加 -municodeundefined reference toWinMain提示 collect2.exe error链接器还没进入子系统正确分支加 -mwindows 或检查 main 是否写成 WinMain中文乱码源码编码和编译器不一致或使用了 MessageBoxA 传中文源码存 UTF-8宽字符常量加 L使用 W 系列 API窗口一闪而过注册或创建窗口失败后直接 return不急着 return用 GetLastError 或 MessageBox 临时提示错误码5.2 运行时窗口显示异常的处理思路窗口创建成功但白屏通常是背景画刷没有正确设置。把 wc.hbrBackground 设为 NULL不画背景窗口就会用默认色看起来像是不刷新。加上 (HBRUSH)(COLOR_WINDOW1) 就能解决。窗口创建成功但点击关闭按钮没反应十有八九是 WM_DESTROY 里没有调 PostQuitMessage。系统默认窗口过程在销毁后不会帮你结束程序你需要自己把退出消息放进队列。如果把 WM_DESTROY 的分支误删程序关闭窗口后还残留在后台任务管理器里能看到进程还在。还有一种诡异现象窗口刚出来往里拖一个其他窗口被盖住的部分再露出来时有黑块或者文字消失。这是 WM_PAINT 重绘没做完整。你在 WM_PAINT 里绘制了文字但如果没有对 WM_ERASEBKGND 做正确处理或者绘制代码偶尔没有执行就可能出现刷新残影。最简单的修复是保留背景画刷并且确保 BeginPaint 和 EndPaint 正常配对。5.3 从函数返回值排查程序退出码异常程序结束时返回 msg.wParam这个值的含义是传给 PostQuitMessage 的数字。你在 PostQuitMessage(0) 里写什么最终返回值就是什么。如果开发中需要给其他程序或脚本设置退出标识可以用这个值传递结果。有时候控制台里运行 GUI 程序看不出退出码因为 -mwindows 会脱离当前控制台。此时可以在 PowerShell 里运行echo $LASTEXITCODE即可看到程序退出码。如果你改成 PostQuitMessage(5)退出码就是 5说明这块机制是完全可控的不必依赖 IDE 黑盒。6. 走完完整流程后的进阶方向6.1 建议继续做的几个实验第一个窗口跑通后建议按顺序做几个小实验把知识巩固住。做自定义窗口类的背景。把背景画刷换成 CreateSolidBrush 创建的颜色或者在 WM_ERASEBKGND 里用 FillRect 画渐变。这能帮你理解背景擦除和绘制的分工。创建多个相同类的窗口。在 WinMain 里连续 CreateWindowEx 两次创建两个窗口看它们如何共用同一个 WndProc。你会发现窗口过程函数里 hWnd 永远是当前消息对应的窗口这说明句柄在设计上非常优雅。处理键盘和鼠标消息。捕获 WM_KEYDOWN 和 WM_LBUTTONDOWN在窗口标题栏显示按键坐标把客户区变成交互区域能直观理解消息参数的拆分逻辑。wParam 通常代表按键或鼠标键值lParam 里包含坐标和状态位。6.2 学习 Win32 遇到的难点与突破方法Win32 最难的不是语法而是信息量大到扑面而来。宏、回调、类型定义一堆容易劝退。我的建议是有意识地忽略无关细节。比如 wParam 和 lParam 到底是 32 位还是 64 位、具体语义随消息变化这些等用到再查不要追求第一次全部记住。每读一段代码问自己一个“为什么”为什么背景画刷是 COLOR_WINDOW1为什么窗口过程返回 0为什么消息循环用 WHILE 而不是 FOR。这些问题能把你从机械抄代码推向理解设计意图。更重要的是养成查文档的习惯。微软官方文档对每个函数都有参数说明和样例加上 MSDN 的备注信息你就能知道某个 API 在极端情况下会怎么表现。我自己工作里写控件很多细节都是现查文档再分析不是靠记忆力。6.3 小技巧用 Win32 调试消息流的实用工具在 VS 的调试器里或者 Visual Studio 之外可以用 OutputDebugString 在调试器输出窗口里打消息日志。但更推荐方法是写一个常量字符串表把消息编号映射成名称在 WndProc 里临时打印每条消息。比如void DebugMsg(UINT msg) { OutputDebugStringW(L收到消息 ); wchar_t buf[32]; wsprintfW(buf, L%d\n, msg); OutputDebugStringW(buf); }在窗口过程最开头调用 DebugMsg(msg)然后去 DebugView 或者 VS 输出窗口看日志你就能清楚看到窗口从创建到销毁经历了哪些消息再结合 WM_PAINT、WM_SIZE、WM_DESTROY 的发送时机去理解机制。这个习惯我写任何复杂控件都一直用比盲目猜测高效得多。手动把第一扇门推开的这段路说长不长说短也不短。我觉得对一个 Windows 开发者来说这一天花得值。里面围绕窗口创建的整套逻辑和直接拖控件的体验完全不同它让你知道界面程序真正的驱动力是消息而不是一排按钮的排列。等你真正做到这一步再看那些封装好的框架心里就有底了。