资讯动态

Win32 C++消息循环、GDI泄漏与Unicode避坑实战

发布时间:2026/10/24 3:04:37 来源:尧图企业网站定制
简介《精通Windows API——函数、接口、编程实例》配套源码包面向具备C基础、希望深入Win32桌面开发与系统级编程的读者。配套源码对应书中156个实例覆盖窗口管理、消息循环、COM接口、动态库加载、内存与文件操作、多线程及进程通信等主题并把知识点落到可编译工程中适合按章节对照学习、调试运行。压缩包共1789个文件约24.29MB以c、cpp、h源码和vcproj、sln工程文件为主体辅以exe、dll、lib等可执行与库文件以及htm说明、rc资源脚本、manifest和pdb、ilk调试符号目录结构便于按示例检索。已有376人学习读者可借助完整工程复现GUI创建、用户输入响应、文件读写与进程通信等实验还能从系统托盘、剪贴板查看、控制台与控件示例中理解Win32编程范式在跟踪调用栈和排查编译链接问题时夯实接口封装与资源管理能力。1. 一个窗口拖一下就白屏的现场让我重新翻了 Win32 C 的老底前阵子帮人看一个内部工具编译零警告、链接零错误、双击也能起来但鼠标拖一下边框整个客户区就白成一片任务管理器里 GDI 对象数从 200 一路爬到 10000然后界面彻底不刷新了。这不是什么高深问题是 Win32 C 最典型的两个坑撞在一起WM_PAINT 没走 BeginPaint/EndPaint 的配对以及画笔对象在每次重绘里 new 一个从来不删。这类毛病编译器不会拦你运行时也不崩就是慢慢烂掉。这份《精通 Windows API——函数、接口、编程实例》配套源码价值不在函数字典而在于它把消息循环、句柄生命周期、Unicode 转换这几条主线用可编译的完整例子串起来了。适合已经会写 C、但一碰窗口程序就靠搜索框拼代码的人也适合写过 MFC 却说不清底下发生了什么的熟手。2. 消息循环与窗口过程Win32 程序的骨架到底长什么样源码包里的例子绝大多数是三段式注册窗口类、创建窗口、跑消息泵。看起来简单但每一段都有必须踩过才知道的细节。我的建议是不要一上来就按函数名字母序读而是先把最小窗口这个例子逐行啃透后面所有例子都是在这副骨架上挂肉。2.1 注册窗口类WNDCLASSEXW 里每个字段都在为后面处理问题先看一段可以直接编译的最小实现。用cl /EHsc /DUNICODE /D_UNICODE minimal_win.cpp user32.lib gdi32.lib就能出 exe。// minimal_win.cpp —— 最小可运行的 Win32 窗口 #include windows.h LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CLOSE: DestroyWindow(hWnd); // 显式销毁才会触发 WM_DESTROY return 0; case WM_DESTROY: PostQuitMessage(0); // 投递 WM_QUIT消息泵才会退出 return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); // 未处理的消息必须回抛 } int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmdLine, int nShow) { WNDCLASSEXW wc { sizeof(WNDCLASSEXW) }; // 第一件事就是把 cbSize 填对 wc.style CS_HREDRAW | CS_VREDRAW; // 宽高变化时整窗重绘 wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.hCursor LoadCursorW(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); // 系统色要 1 再强转 wc.lpszClassName LMinimalWin32Class; if (!RegisterClassExW(wc)) return 1; // 失败十有八九是类名重复 HWND hWnd CreateWindowExW(0, wc.lpszClassName, L最小窗口, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, nullptr, nullptr, hInst, nullptr); // hInst 必须和注册时是同一个 if (!hWnd) return 2; ShowWindow(hWnd, nShow); UpdateWindow(hWnd); // 强制立刻来一发 WM_PAINT MSG msg; while (GetMessageW(msg, nullptr, 0, 0) 0) { // 注意 0-1 也要能退出 TranslateMessage(msg); DispatchMessageW(msg); } return (int)msg.wParam; }这段代码里三个参数最容易写错。cbSize用聚合初始化{ sizeof(WNDCLASSEXW) }一次到位剩下成员自动清零比逐字段赋值安全得多一旦忘了填RegisterClassExW直接返回 0 且GetLastError()给ERROR_INVALID_PARAMETER。hbrBackground填系统色索引时必须1再转HBRUSH直接写COLOR_WINDOW是拿 5 当画刷句柄用窗口背景会变成随机色块。hInstance在RegisterClassExW和CreateWindowExW两处必须是同一个值从 DLL 里注册类再从 EXE 创建窗口这种跨模块玩法就得改用GetModuleHandleW显式取否则CreateWindowExW返回 NULL 且错误码是ERROR_CANNOT_FIND_WND_CLASS。2.2 消息泵while (GetMessage(...))是一个流传很广的错误写法GetMessageW的返回类型是BOOL但它其实有三个状态大于 0 表示取到正常消息等于 0 表示收到了WM_QUIT等于 -1 表示出错比如hWnd无效。写成while (GetMessageW(msg, nullptr, 0, 0))时-1 在 C 里转成 bool 是 true循环不会退出程序卡在一个永远取不到消息的死循环里CPU 占满但界面毫无反应。我在老项目里见过这种写法存活好几年因为正常路径永远走不到 -1。循环体里两个函数各司其职。TranslateMessage只做一件事把WM_KEYDOWN/WM_KEYUP合成WM_CHAR不做字符输入的程序可以不调但调了没坏处。DispatchMessageW才是把消息按msg.hwnd找到对应窗口过程并调用它。注意消息泵里的msg是复用的栈变量别把它的地址存起来跨轮次用。另外两个过滤参数wParam最小消息值和lParam最大消息值平时都传 0但在做后台计算线程加 UI 线程的架构时很有用工作线程里跑一段while (PeekMessageW(msg, nullptr, 0, 0, PM_REMOVE))把队列抽干避免长时间计算时窗口假死。这是源码包里多线程那个例子用的手法比WaitForSingleObject死等友好。2.3 窗口过程DefWindowProcW不是可选项WM_CLOSE也不是终点窗口过程里最容易被删掉的一行就是最后那句DefWindowProcW。它的作用不是兜底返回 0而是处理掉几十种你没写的消息WM_NCCALCSIZE算非客户区、WM_NCHITTEST判断点在哪、WM_SYSCOMMAND处理最大化最小化、WM_SETCURSOR换鼠标指针。少了它窗口能创建出来但拖不动、点不了、边框画不出来看起来像程序卡了。销毁链路要记清楚用户点右上角叉系统发WM_CLOSE默认窗口过程收到WM_CLOSE会调DestroyWindowDestroyWindow触发WM_DESTROY在WM_DESTROY里调PostQuitMessage(0)才产生WM_QUIT消息泵才退出。所以如果你在WM_CLOSE里直接PostQuitMessage而没DestroyWindow窗口对象不会被释放进程退出时能看到句柄泄漏计数。反过来如果只DestroyWindow不PostQuitMessage窗口没了但进程挂在后台任务管理器里能看到一个没有界面的进程。自定义消息从WM_APP0x8000往上加比如#define WM_MY_TASK_DONE (WM_APP 1)别用WM_USER之后的值和控件内部消息撞车。源码包里凡是涉及线程回调的例子都是这个套路工作线程算完PostMessageW(hWnd, WM_MY_TASK_DONE, 0, (LPARAM)result)UI 线程在窗口过程里接。3. 句柄与内核对象把泄漏掐死在第一次提交之前Win32 里句柄是个被滥用的词。窗口句柄、文件句柄、线程句柄、事件句柄、GDI 画刷句柄长得都是HANDLE或它的 typedef但语义完全不同内核对象句柄走CloseHandleGDI 对象画笔、画刷、字体、位图、DC走DeleteObject/DeleteDC/ReleaseDC窗口句柄走DestroyWindow。用错释放函数返回值是 FALSE错误码ERROR_INVALID_HANDLE而很多人根本不看返回值。3.1 句柄表与引用计数为什么复制一个句柄不等于多一个对象每个进程有一张句柄表表项指向内核对象内核对象自己带引用计数。CreateEventW返回的句柄值通常是 4 的倍数表索引左移两位所以同值在不同进程里指向不同对象。DuplicateHandle才是真的加一次引用计数跨进程传句柄必须走它。这意味着把一个HANDLE强转成DWORD塞进PostMessageW的wParam传给另一个进程接收方拿到的几乎必然是个废句柄——这是源码包里跨进程通信那一节的第一个反面例子。同理GetCurrentProcess()返回的是伪句柄(HANDLE)-1永远不要CloseHandle它也不要拿它去调需要真实句柄的 API。这个坑下一节马上会遇到。3.2 一个够用的 RAII 守卫别硬套shared_ptrC 里最直接的防漏手段是 RAII。下面这个不足 20 行的结构体我在所有 Win32 项目里都会先贴进去。// handle_guard.h —— 轻量句柄守卫禁用拷贝、允许移动 #pragma once #include windows.h class HandleGuard { public: explicit HandleGuard(HANDLE v nullptr) : h_(v) {} ~HandleGuard() { reset(); } HandleGuard(const HandleGuard) delete; // 不许拷贝 HandleGuard operator(const HandleGuard) delete; HandleGuard(HandleGuard o) noexcept : h_(o.release()) {} HANDLE get() const { return h_; } explicit operator bool() const { return h_ ! nullptr h_ ! INVALID_HANDLE_VALUE; } HANDLE release() { HANDLE t h_; h_ nullptr; return t; } // 交出所有权 void reset(HANDLE v nullptr) { if (h_ h_ ! INVALID_HANDLE_VALUE) CloseHandle(h_); h_ v; } private: HANDLE h_; };reset()里那个h_ ! INVALID_HANDLE_VALUE判断不能省CreateFileW失败返回的是INVALID_HANDLE_VALUE即 -1CreateEventW、OpenProcess失败返回的是nullptr两套约定并存判断少一个就会在失败路径上CloseHandle(-1)。release()的存在是为了兼容WaitForSingleObject之后要交给别人的场景比如线程句柄交给_beginthreadex的收尾逻辑。禁止拷贝这一条要严格执行——shared_ptr那种共享所有权在这里是反模式句柄该谁关就该谁关共享只会让关闭时机变得不可推理。3.3 用 GDI 对象计数验证而不是看着没问题泄漏这种东西不能靠肉眼。Windows 提供GetGuiResources可以查当前进程的 GDI 对象数和 USER 对象数两者默认配额都是 10000。// 每次重绘前后各调一次差值不为 0 就是漏了 DWORD CountGdiObjects() { HANDLE hp OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, GetCurrentProcessId()); if (!hp) return 0xFFFFFFFF; // 打不开进程先查权限 DWORD n GetGuiResources(hp, GR_GDIOBJECTS); // USER 对象用 GR_USEROBJECTS CloseHandle(hp); return n; }注意这里必须用OpenProcess拿真句柄直接把GetCurrentProcess()的伪句柄喂进去在部分系统版本上会失败。用法很简单把CountGdiObjects()塞进WM_PAINT的开头打个日志然后用鼠标疯狂拖边框十秒正常情况下数值应该稳定在一个小数上下来回跳只涨不跌就是有对象没删而且能立刻定位到是哪次重绘引入的。这个手法比跑一整天的压力测试有用得多。4. 字符集与字符串Unicode 是翻车最密集的一段路Win32 有 A/W 两套 APIMessageBoxA和MessageBoxW是两个真实存在的导出符号不是一个函数的重载。工程里最省事的做法是全程只走 W 版本把L和wchar_t用到底彻底放弃TCHAR和_T()那套宏。4.1 统一走宽字符/DUNICODE和/D_UNICODE是两个宏VS 工程里那个使用 Unicode 字符集选项实际是往命令行塞了/D_UNICODE和/DUNICODE两个定义。少了任何一个TCHAR会展开成char而MessageBox之类没写后缀的宏会绑到 A 版本。两个宏的差别在于UNICODE管 Windows API 头文件里的宏_UNICODE管 C 运行库_tcslen、_stprintf那一套。自己手写编译命令时这两个都要加并且入口点也要从WinMain换成wWinMain否则链接器会报unresolved external symbol WinMain。.vcxproj里对应的两行大概是这样改工程文件比在 IDE 里点复选框更不容易漏PropertyGroup Condition$(Configuration)|$(Platform)Release|x64 CharacterSetUnicode/CharacterSet /PropertyGroup ItemDefinitionGroup ClCompile PreprocessorDefinitionsUNICODE;_UNICODE;WIN32_LEAN_AND_MEAN;%(PreprocessorDefinitions)/PreprocessorDefinitions /ClCompile /ItemDefinitionGroupWIN32_LEAN_AND_MEAN顺手加上能把winsock.h这类不常用的头从windows.h里剔掉编译时间肉眼可见地短一截顺带避开和winsock2.h的重复定义冲突——这是源码包编译时最常见的报错之一。4.2 边界转换MultiByteToWideChar一定要调两次从配置文件、命令行参数、网络包里拿到的大多是 UTF-8 的char*进 Win32 API 之前必须转宽字符。// utf8 - wide 的安全转换两次调用是必须的 #include string #include windows.h std::wstring Utf8ToWide(const std::string s) { if (s.empty()) return std::wstring(); int n MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); if (n 0) return std::wstring(); // 非法 UTF-8 直接返回空 std::wstring w(n, L\0); MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), w.data(), n); return w; } std::string WideToUtf8(const std::wstring w) { if (w.empty()) return std::string(); int n WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), nullptr, 0, nullptr, nullptr); if (n 0) return std::string(); std::string s(n, \0); WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), s.data(), n, nullptr, nullptr); return s; }第一次调用传nullptr拿所需长度第二次才真正写。省掉第一次直接开个固定 256 字节的栈缓冲就是路径超过 255 字节时被静默截断的根源——这种 bug 只在特定用户目录下复现测都测不出来。CP_UTF8这个参数也不能换用CP_ACP会跟随系统区域设置同一份配置在英文系统和中文系统上解析出不同结果。wchar_t在 Windows 上是 2 字节的 UTF-16超过0xFFFF的字符是代理对wcslen算的是代码单元数不是字符数做光标定位的时候要留意。4.3 缓冲区 APIGetWindowTextW的返回值不是长度一个高频误用GetWindowTextW(hWnd, buf, 256)返回的是拷贝的字符数但如果窗口标题是别的进程设的跨进程取文本会失败返回 0此时buf里的内容是未定义的。所以拿到返回值后必须自己补buf[返回长度] L\0或者干脆一开始就wchar_t buf[256] {0};清零。同理GetClassNameW、GetModuleFileNameW这个返回 0 表示缓冲区不够要用GetLastError() ERROR_INSUFFICIENT_BUFFER判断。稳妥写法是换成StringCchCopyW系列长度参数传的是字符数不是字节数这一点和strncpy_s的直觉相反。5. 避坑现场五个编译能过、跑起来不对的 Win32 故障这一章全是血泪经验。下面每一条我都至少在生产代码里见过一次共同点是编译器一声不吭运行时行为诡异。5.1 创建窗口返回 NULL但GetLastError()给 0现象CreateWindowExW返回 NULL紧接着GetLastError()返回 0错误码查不到任何东西代码看起来完全正确。原因窗口创建过程中系统会先给窗口过程发WM_NCCREATE再发WM_CREATE。如果窗口过程在这两条消息的处理里返回了 FALSE或者抛了 C 异常被吞掉CreateWindowExW会失败并销毁半成品窗口但错误码不会被设置——因为它压根不是系统层面的失败是你自己的回调拒绝了创建。解决窗口过程里处理WM_NCCREATE/WM_CREATE时成功路径必须return TRUEWM_NCCREATE返回 FALSE 会导致创建中止并且不要在这两个消息里做可能抛异常的事。调试时在窗口过程入口打一行OutputDebugStringW看消息是不是压根没走到能省掉半天。5.2 类注册失败但代码只跑一次所以以前是好的现象加了热重载或者单元测试之后第二次初始化时RegisterClassExW返回 0。原因窗口类是进程级资源同名类注册第二次会返回ERROR_CLASS_ALREADY_EXISTS。单次启动的程序永远碰不到一旦代码被复用两次就必炸。解决注册前先UnregisterClassW或者把类名加上模块名/实例地址后缀做唯一化。更稳的做法是把注册逻辑放进一个只执行一次的std::once_flag里。5.3 界面假死但 CPU 占用是 0现象点了个按钮之后窗口完全不响应标题栏拖动无效任务管理器显示 CPU 0%等十分钟也不恢复。原因在 UI 线程里做了一个同步等待比如WaitForSingleObject(hThread, INFINITE)或者同步的ReadFile。UI 线程被占住消息泵不再转WM_PAINT、WM_MOUSEMOVE全堆在队列里系统认为程序无响应。解决长耗时操作扔到工作线程用PostMessageW回主线程更新 UI。万不得已要在 UI 线程等就换成MsgWaitForMultipleObjects并在等待循环里抽干消息队列。这一条在任何 Win32 教程里都写着但真正踩过的人才知道CPU 0% 的假死和死循环在任务管理器里长得完全不同。5.4 x86 编译通过、x64 崩在SetWindowLong现象32 位构建一切正常切到 x64 之后窗口一创建就访问违例崩在SetWindowLongW或者某处指针强转。原因LONG在 Windows 上永远是 32 位x64 下指针是 64 位SetWindowLongW(hWnd, GWLP_USERDATA, (LONG)ptr)会把高 32 位截断。解决一律用SetWindowLongPtrW/GetWindowLongPtrW和LONG_PTR、LRESULT、WPARAM、LPARAM这些指针宽度的类型别用long和int存指针。同理wParam里塞指针要用reinterpret_castWPARAM不要先转int。5.5 控制台程序改成窗口程序后链接器报找不到入口现象把main改成窗口代码链接报LNK2019: unresolved external symbol WinMain。原因链接器的/SUBSYSTEM决定了它去找哪个入口符号。/SUBSYSTEM:CONSOLE找main/wmain/SUBSYSTEM:WINDOWS找WinMain/wWinMain。工程属性里改了窗口应用程序但代码里还是main或者反过来都会撞这个错。解决两者必须配对。想在窗口程序里保留控制台方便打日志就保持/SUBSYSTEM:CONSOLE然后自己建窗口运行时用AllocConsole()freopen_s重定向标准输出想彻底无控制台就老老实实写wWinMain。6. 从示例到工具把窗口枚举例子改成一个能交付的探测程序源码包里EnumWindows那个例子只有二十行打印句柄就完事。稍微改一改就能变成一个日常真用得上的小工具——排查某个弹窗是哪个进程弹的这类问题时比任何现成软件都直接。// winprobe.cpp —— 枚举可见顶层窗口输出句柄/进程/尺寸/类名/标题 #include windows.h #include cstdio BOOL CALLBACK EnumProc(HWND hWnd, LPARAM) { if (!IsWindowVisible(hWnd)) return TRUE; // 隐藏窗口直接跳过 wchar_t title[256] {0}, cls[256] {0}; GetClassNameW(hWnd, cls, 256); GetWindowTextW(hWnd, title, 256); // 跨进程可能拿不到返回 0 if (title[0] L\0) return TRUE; // 无标题的多是内部辅助窗口 DWORD pid 0; GetWindowThreadProcessId(hWnd, pid); // 第二个参数可为 nullptr RECT rc{}; GetWindowRect(hWnd, rc); wprintf(L0x%p | pid%-6lu | %ldx%-4ld | %-22s | %s\n, (void*)hWnd, pid, rc.right - rc.left, rc.bottom - rc.top, cls, title); return TRUE; // 返回 FALSE 会中止枚举 } int wmain() { EnumWindows(EnumProc, 0); return 0; }EnumWindows只枚举顶层窗口子窗口要用EnumChildWindows按类名找特定窗口用FindWindowExW取当前前台窗口用GetForegroundWindow。四个函数的分工如下表选错了就是枚举不到或者枚举出一堆看不见的窗口。函数枚举范围典型用途注意点EnumWindows所有顶层窗口全局窗口清单包含大量不可见窗口必须过滤EnumChildWindows指定父窗口的子窗口定位对话框里的控件传nullptr会枚举所有顶层子窗口FindWindowExW按类名/标题精确匹配找已知窗口只返回第一个匹配要循环取下一个GetForegroundWindow单个前台窗口抓当前活动窗口可能返回nullptr真正让它好用的一步是加过滤条件把IsWindowVisible和标题非空组合起来能滤掉七成噪音再加上进程名解析OpenProcessQueryFullProcessImageNameW就能一眼看出弹窗是哪个 exe 弹的。注意OpenProcess对高权限进程会失败返回ERROR_ACCESS_DENIED这是 UIPI 的正常行为不是代码问题失败时把 pid 打出来就够了。最后提醒一句GetWindowTextW对同权限进程有效对更高完整性级别的进程拿不到标题别在这上面钻牛角尖。从那以后我每次新建 Win32 工程都先把消息泵那段 0的判断和HandleGuard贴进去再写第一行业务逻辑——这两个地方省下的十分钟后面要用一整天来还。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑