Win32 C++源码实战:句柄、消息循环与GDI泄漏排查 简介《精通Windows API-函数、接口、编程实例(源码)》配套源码包面向已具备C基础、希望深入Windows平台开发的程序员与学习者围绕窗口管理、消息处理、内存与文件操作、COM接口及多线程等核心主题提供可对照实践的工程素材。压缩包共1789个文件、约24.29MB包含c/cpp/h源码、vcproj与sln工程文件、exe示例程序、pdb/ilk调试文件、htm说明页以及dll/lib库文件等既有可编译项目也有运行与调试产物便于按章节还原示例环境。已有380人学习下载。书中156个实例的源码覆盖CreateWindowEx、SendMessage、GetMessage、LoadLibrary等常用API以及IUnknown等COM接口实践读者可借助完整工程理解消息机制、线程管理和内存管理的落地方式并通过调试信息排查编译与运行问题适合系统级编程、桌面应用与游戏开发方向的中高级读者查漏补缺。1. 接手一份两百多处 CreateFile 的老代码我从 Win32 C 这层重新捋上一个接手的桌面工具需求很朴素把常驻内存从 300MB 压到 80MB。源码摊开两百多处CreateFileW、几个从没DeleteObject的画笔、随手_beginthread起线程却不WaitForSingleObject的收尾逻辑。翻框架文档解决不了这类问题得退回到 Win32 C 这一层把 Windows API 的句柄生命周期、消息循环、同步对象重新过一遍。手边这份《精通 Windows API-函数、接口、编程实例源码》正好干这个窗口与消息、GDI 绘图、文件与内存映射、进程线程与同步、注册表、动态库、托盘热键每一块都配了能单独编译运行的示例不是只有函数原型和参数表。它适合已经会 C 语法、却被句柄、字符集、消息泵这些细节绊住的人也适合从 MFC 或托管框架转过来、想看清框架底下到底调了什么的人。2. 工程结构与编译环境把一堆示例拆成能单独跑的项目一套 Win32 示例源码最容易死在“编译不过”上而不是“看不懂”。原因往往很无聊一个解决方案里塞了几十个.cpp每个都有自己的WinMain一编译就报重复定义或者字符集一会儿UNICODE一会儿 MBCS字符串一半乱码一半链接报错。所以拿到源码先别急着点“生成”花二十分钟看清工程是怎么组织的后面能省掉一整晚。2.1 编译环境与字符集先把 UNICODE 这条线定死Win32 的字符串有两套char走A后缀函数wchar_t走W后缀函数中间的TCHAR宏靠UNICODE宏切换。源码包里的示例如果不同章节写法不一致混在一个工程里必然出事。我一般的做法是整个解决方案统一开UNICODE代码里不再用裸TCHAR而是显式写W后缀。// 统一显式调用宽字符版本避免 TCHAR 宏在不同工程配置下变脸 #include windows.h #include string int wmain() { // 错误示范MessageBoxA 宽字符串编译期就报类型不匹配 // MessageBoxA(nullptr, Lhello, Ltip, MB_OK); std::wstring text L当前工作目录; wchar_t cwd[MAX_PATH] {}; GetCurrentDirectoryW(MAX_PATH, cwd); // 显式 W 版本 text cwd; MessageBoxW(nullptr, text.c_str(), L环境检查, MB_OK | MB_ICONINFORMATION); return 0; }GetCurrentDirectoryW的第二个参数是缓冲区字符数而不是字节数返回的是实际写入的字符数不含结尾\0返回值大于缓冲区大小说明被截断了这种细节在示例里通常只写一次。工程属性里对应三个地方要一致C/C → 预处理器里的UNICODE和_UNICODE、链接器 → 系统 → 子系统控制台选Console窗口程序选Windows、以及入口点wmain对应控制台wWinMain对应窗口乱配就是LNK2019。_UNICODE影响 CRT 的_t系列函数UNICODE影响 Windows API 的宏展开两个漏一个就会出现“能编译但行为不一致”的玄学问题。链接库这块Win32 常用的kernel32.lib、user32.lib、gdi32.lib、advapi32.lib、shell32.lib在 VS 的默认工程里通常已经带上但示例如果单独抽出来稳妥的做法是在源文件顶部直接写#pragma comment(lib, user32.lib)省得换台机器又要去翻项目属性。2.2 目录结构示例工程、公共代码、资源文件怎么分层这类源码包常见两种组织方式一种是每章一个文件夹、每节一个.cpp配一个大而全的.sln另一种是每个示例独立.vcxproj。前者省事但会撞上“一个工程只能有一个入口点”的限制后者啰嗦但每一项都能单独调试。实际落地我倾向混合Win32ApiSamples/ ├─ Common/ # 公共静态库 │ ├─ Common.vcxproj │ ├─ StrUtil.cpp/.h # 宽字符拼接、路径处理 │ └─ Trace.h # OutputDebugString 封装 ├─ Ch03_Window/ │ ├─ WindowDemo.vcxproj │ ├─ main.cpp # 唯一入口 wWinMain │ └─ res/app.rc # 图标、菜单、版本资源 ├─ Ch05_Gdi/ │ ├─ GdiDemo.vcxproj │ └─ main.cpp └─ Win32ApiSamples.sln公共库这一层别嫌麻烦。示例代码本身写得很直白但一到自己改就发现字符串拼接、路径拼接、错误码转文本这些辅助函数每节都要重写一遍抽成静态库之后改一次全工程生效。资源文件.rc单独放res目录也很关键图标 ID、菜单 ID、对话框模板都在里面编译时会生成resource.h跟代码里的常量一一对应托盘图标和自定义光标都靠它。我见过把.rc和.cpp塞同一个目录、然后手写#include resource.h结果路径对不上的情况改半天以为是编译器问题。2.3 最小可运行骨架从 wWinMain 到消息循环逐行读不管源码包里有几百个示例最小窗口程序的骨架只有一份读懂它别的都是在这个骨架上挂分支。下面这段是去掉了业务逻辑的骨架#include windows.h // 窗口过程所有发给这个窗口的消息都从这里进来 LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 必须配对 EndPaint TextOutW(hdc, 20, 20, Lhello win32, 11); EndPaint(hWnd, ps); return 0; } case WM_DESTROY: PostQuitMessage(0); // 投递 WM_QUIT return 0; default: return DefWindowProcW(hWnd, msg, wParam, lParam); // 未处理消息必须回落 } } int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int nCmdShow) { WNDCLASSEXW wc { sizeof(wc) }; wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.lpszClassName LDemoWndClass; wc.hCursor LoadCursorW(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); if (!RegisterClassExW(wc)) return 0; // 类名重复会失败 HWND hWnd CreateWindowExW(0, LDemoWndClass, LDemo, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, nullptr, nullptr, hInst, nullptr); if (!hWnd) return 0; ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessageW(msg, nullptr, 0, 0) 0) // 注意 0不是 ! 0 { TranslateMessage(msg); // 把 WM_KEYDOWN 翻译成 WM_CHAR DispatchMessageW(msg); // 分发到对应窗口的 WndProc } return (int)msg.wParam; }几个参数值得单独说。WNDCLASSEXW的第一个字段必须是sizeof(wc)这是结构体版本兼容的约定忘了写RegisterClassExW直接返回FALSEGetLastError给的是ERROR_INVALID_PARAMETER。hbrBackground写成(HBRUSH)(COLOR_WINDOW 1)是系统颜色的老约定1表示“这不是句柄而是系统颜色索引”漏掉1就会拿一个无效画刷去擦背景。GetMessageW的判断用 0而非常见的! 0它返回-1表示出错返回0表示收到WM_QUIT用! 0会把错误当成“还有消息”继续死循环这在调试里表现为点关闭按钮后进程挂在后台不下去血泪经验。DefWindowProcW那一行也不能省WM_NCCREATE、WM_CLOSE这些消息都靠它兜底自己吞掉会导致窗口关不掉或者边框拖动异常。2.4 单跑一个示例调试配置、工作目录与命令行参数多项目解决方案里右键某个.vcxproj→ 设为启动项目然后F5。这一步容易翻车的点是工作目录VS 默认把调试工作目录设成$(ProjectDir)而示例里读的相对路径文件比如data.bin往往跟.cpp放一起或者放在$(OutDir)。文件读不到CreateFileW返回INVALID_HANDLE_VALUE示例又不打日志看着就是“点了没反应”。调试配置的常见做法是Debug 配置下开/RTC1运行时检查能抓未初始化变量、/MDd运行时库跟公共库保持一致别一个/MT一个/MD否则满屏LNK2005重复符号Release 配置开/O2并把警告级别调到/W4Win32 代码在W4下会吐出一堆“有符号无符号比较”“强制类型转换丢精度”的提示这些提示里藏着真正的类型转换 bug。命令行参数调试也别忽略。不少示例带参数运行比如文件路径、端口、进程名在“项目属性 → 调试 → 命令参数”里填上再启动比在代码里硬编码路径干净得多。如果是控制台程序wmain(int argc, wchar_t** argv)拿到的就是宽字符参数别用char**版本去接接完再转换纯属自找麻烦。3. 核心模块拆解窗口消息、GDI、文件映射、线程同步、注册表示例源码的价值不在“能跑”在于每个模块都用一个最小可复现的例子把边界情况暴露出来窗口不响应怎么办、GDI 画完不释放会怎样、大文件一次读进内存会不会炸。这一章按模块过一遍关键实现每段代码都说明白参数和失败点。3.1 窗口与消息RegisterClassExW 到 DispatchMessageW 的完整链路窗口程序的本质是一个“消息驱动的状态机”所有交互都以MSG结构体流进消息队列。自定义控件、子窗口、快捷键往上加东西的前提是搞清这条链路系统把消息塞进线程队列 →GetMessageW取出 →TranslateMessage做键盘翻译 →DispatchMessageW按hwnd找到窗口过程调用。// 注册一个带自定义背景的子窗口类并绑定到主窗口 ATOM RegisterChildClass(HINSTANCE hInst) { WNDCLASSEXW wc { sizeof(wc) }; wc.style CS_HREDRAW | CS_VREDRAW; // 宽高变化触发重绘 wc.lpfnWndProc ChildProc; wc.hInstance hInst; wc.hCursor LoadCursorW(nullptr, IDC_HAND); wc.lpszClassName LChildPanelClass; return RegisterClassExW(wc); // 返回 0 表示失败 } // 在主窗口的 WM_CREATE 里创建子窗口 case WM_CREATE: { HWND hChild CreateWindowExW(0, LChildPanelClass, nullptr, WS_CHILD | WS_VISIBLE, 10, 10, 300, 200, hWnd, (HMENU)1001, ((LPCREATESTRUCT)lParam)-hInstance, nullptr); if (!hChild) return -1; // 返回 -1 会中止窗口创建 return 0; }WM_CREATE的lParam是LPCREATESTRUCT指针里面带着hInstance这比自己存全局变量干净。返回-1会让CreateWindowExW整体失败并返回NULL这是唯一能在创建阶段中止的返回值。子窗口 ID 用(HMENU)1001父窗口再用GetDlgItem(hWnd, 1001)取回来比维护一个全局HWND变量可读性好。还有一类容易被忽略的消息WM_COMMAND处理菜单和按钮通知WM_NOTIFY处理更复杂控件的通知WM_TIMER处理定时器。这些消息都发到父窗口wParam/lParam的含义各不相同示例里通常只演示一两种自己扩展时对着消息文档逐个查别凭感觉猜——WM_COMMAND高字节是通知码、低字节是控件 ID猜错的结果就是“点了按钮没反应”。3.2 GDI 绘图DC 的取得、释放与双缓冲改造GDI 是这套源码里最值得细看的一块因为它把句柄泄漏这个坑暴露得最彻底。每个CreateXxx出来的 GDI 对象都占一个系统句柄单个进程默认上限是 10000 个超了之后CreatePen、CreateFont全部返回NULL程序表现是窗口突然整片变白或者控件画不出来。// 双缓冲绘制先画到内存 DC再一次性 BitBlt 到屏幕 void PaintWithDoubleBuffer(HWND hWnd, HDC hdcTarget, const RECT rc) { const int w rc.right - rc.left; const int h rc.bottom - rc.top; if (w 0 || h 0) return; HDC hdcMem CreateCompatibleDC(hdcTarget); // 内存 DC HBITMAP hBmp CreateCompatibleBitmap(hdcTarget, w, h); // 与屏幕同色深 if (!hdcMem || !hBmp) { /* 句柄耗尽记录日志 */ return; } HGDIOBJ hOldBmp SelectObject(hdcMem, hBmp); // 保存旧对象 HPEN hPen CreatePen(PS_SOLID, 2, RGB(200, 40, 40)); HGDIOBJ hOldPen SelectObject(hdcMem, hPen); FillRect(hdcMem, RECT{0, 0, w, h}, (HBRUSH)(COLOR_WINDOW 1)); MoveToEx(hdcMem, 0, 0, nullptr); LineTo(hdcMem, w, h); BitBlt(hdcTarget, rc.left, rc.top, w, h, hdcMem, 0, 0, SRCCOPY); SelectObject(hdcMem, hOldPen); // 先还原 SelectObject(hdcMem, hOldBmp); DeleteObject(hPen); // 再删除 DeleteObject(hBmp); DeleteDC(hdcMem); }SelectObject的返回值必须接住再还原这是个硬性规则一个 GDI 对象被选进 DC 期间删除会失败DeleteObject返回FALSE泄漏就这么来的。CreateCompatibleBitmap的宽高不能为 0最小化状态下GetClientRect拿到的就是 0不判断就会拿到无效位图。COLOR_WINDOW 1这类系统画刷不需要DeleteObject只有自己Create出来的才需要搞混了就是“删了不该删的”或者“该删的没删”。双缓冲不是必须的小面积刷新直接BeginPaint画也没问题。但只要窗口里有动画、闪烁明显就该上双缓冲。示例源码里如果每个WM_PAINT都直接往窗口 DC 上画拖动窗口的时候会看到明显的撕裂改造成上面这个样子代码量增加不到二十行。3.3 文件与内存映射大文件读取的两种写法文件操作这块示例里通常给了两种一种是最朴素的CreateFileWReadFile另一种是CreateFileMappingWMapViewOfFile。前者适合几百 KB 的配置文件后者适合几十 MB 以上的日志或数据文件因为内存映射把文件内容直接映射到进程地址空间读的时候由系统按页调入不用自己管缓冲区大小。// 方式一一次性读入内存适合小文件 std::vectorchar ReadAll(const wchar_t* path) { HANDLE h CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (h INVALID_HANDLE_VALUE) return {}; // 用 GetLastError 查原因 LARGE_INTEGER size{}; if (!GetFileSizeEx(h, size) || size.QuadPart (64LL 20)) { CloseHandle(h); // 超过 64MB 走映射 return {}; } std::vectorchar buf(static_castsize_t(size.QuadPart)); DWORD read 0; if (!ReadFile(h, buf.data(), (DWORD)buf.size(), read, nullptr)) buf.clear(); CloseHandle(h); return buf; } // 方式二内存映射适合大文件按需调入 void ScanLargeFile(const wchar_t* path) { HANDLE hFile CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile INVALID_HANDLE_VALUE) return; HANDLE hMap CreateFileMappingW(hFile, nullptr, PAGE_READONLY, 0, 0, nullptr); if (!hMap) { CloseHandle(hFile); return; } const char* base static_castconst char*(MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0)); if (base) { // 直接按字节访问注意越过文件末尾访问会触发 EXCEPTION_IN_PAGE_ERROR size_t firstLine 0; while (firstLine 4096 base[firstLine] ! \n) firstLine; UnmapViewOfFile(base); } CloseHandle(hMap); CloseHandle(hFile); }CreateFileW的第三个参数FILE_SHARE_READ决定别人能不能同时打开示例里常写成 0结果自己程序开着文件时日志工具就打不开了调试时找半天原因。ReadFile的第四个参数是实际读到的字节数循环读的时候要用它推进偏移不能假设一定读满。内存映射的坑在于MapViewOfFile返回的指针没有边界保护越界读不会立刻崩而是在访问到未映射页时抛EXCEPTION_IN_PAGE_ERROR这种异常在调试器里表现为“莫名其妙断在访问数组的地方”。所以在映射区里做字符串扫描必须先用GetFileSizeEx拿到长度自己判边界。3.4 线程与同步为什么示例里用 _beginthreadex 而不是 CreateThread示例源码里如果直接调CreateThread还在线程里用了malloc、strtok、printf这些 CRT 函数那就是个隐患。CreateThread不会初始化 CRT 的线程局部数据多线程下这些函数的内部状态会互相踩表现是随机崩溃或者内存统计不准。#include windows.h #include process.h #include atomic static std::atomiclong g_Total{ 0 }; static CRITICAL_SECTION g_Cs; unsigned __stdcall Worker(void* arg) { const int loops static_castint(reinterpret_castintptr_t(arg)); for (int i 0; i loops; i) { EnterCriticalSection(g_Cs); g_Total; // 受临界区保护的写 LeaveCriticalSection(g_Cs); } return 0; } int wmain() { InitializeCriticalSection(g_Cs); HANDLE threads[4] {}; for (int i 0; i 4; i) { // _beginthreadex 会为线程初始化 CRT 数据返回的句柄仍需 CloseHandle threads[i] reinterpret_castHANDLE( _beginthreadex(nullptr, 0, Worker, reinterpret_castvoid*(100000), 0, nullptr)); } WaitForMultipleObjects(4, threads, TRUE, INFINITE); // bWaitAll TRUE for (HANDLE h : threads) CloseHandle(h); // 句柄必须关否则泄漏 DeleteCriticalSection(g_Cs); wprintf(Ltotal %ld\n, g_Total.load()); return 0; }_beginthreadex的第二个参数是栈大小0 表示用默认值1MB第三个是线程函数签名必须是unsigned __stdcall (void*)最后一个参数是输出线程 ID 的指针不需要就传nullptr。它返回的句柄和CreateThread一样要用CloseHandle关很多人以为_beginthreadex会自己回收。临界区适合“锁的范围很小、竞争不激烈”的场景它比互斥量轻但不能跨进程。如果示例里用的是InterlockedIncrement这类原子操作那连锁都省了性能更好代价是只能做简单的加减和交换。还要注意WaitForMultipleObjects一次最多等 64 个句柄超过就得分组等或者用 IOCP。示例里通常只起两三个线程看不出这个限制真到批量任务的时候就会撞上WAIT_FAILEDERROR_INVALID_PARAMETER。3.5 注册表与系统信息读写配置的边界在哪注册表和系统信息这类示例看着最简单实际最容易被权限问题卡住。HKEY_LOCAL_MACHINE下的很多键普通用户没写权限示例在管理员账户下跑得好好的换台机器就ERROR_ACCESS_DENIED。// 读写当前用户下的配置键不需要管理员权限 bool SaveFlag(DWORD value) { HKEY hKey nullptr; // KEY_SET_VALUE 是最小权限别直接上 KEY_ALL_ACCESS LONG ret RegCreateKeyExW(HKEY_CURRENT_USER, LSoftware\\DemoTool, 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, hKey, nullptr); if (ret ! ERROR_SUCCESS) return false; ret RegSetValueExW(hKey, LFlag, 0, REG_DWORD, reinterpret_castconst BYTE*(value), sizeof(value)); RegCloseKey(hKey); return ret ERROR_SUCCESS; } DWORD LoadFlag(DWORD defaultValue) { HKEY hKey nullptr; if (RegOpenKeyExW(HKEY_CURRENT_USER, LSoftware\\DemoTool, 0, KEY_QUERY_VALUE, hKey) ! ERROR_SUCCESS) return defaultValue; DWORD type 0, size sizeof(DWORD), value 0; LONG ret RegQueryValueExW(hKey, LFlag, nullptr, type, reinterpret_castLPBYTE(value), size); RegCloseKey(hKey); return (ret ERROR_SUCCESS type REG_DWORD) ? value : defaultValue; }RegQueryValueExW的type输出参数必须校验因为键可能被别的程序改成了REG_SZ这时候把字符串前四个字节当 DWORD 用就是个经典事故。RegCreateKeyExW的第六个参数是“是否已存在”的输出不需要就传nullptr但传了能用来判断首次运行。系统信息那部分GetSystemInfo拿 CPU 核数和页大小GetVersionEx这类函数在新系统上会受兼容性清单影响返回假版本号示例里如果用它判断系统版本实际项目里得换成RtlGetVersion或者直接读注册表否则永远返回 Windows 8 的版本号。4. 排查手册Win32 示例跑不通的五类高频问题这套源码里每个示例都是独立可编译的但仍然会有一批人在第一步就卡住。下面这几条是我在不同机器、不同 VS 版本上反复遇到的按“现象 → 原因 → 解决”记下来遇到同类问题直接对号入座。4.1 编译期就挂字符集不匹配与链接错误现象LNK2019: 无法解析的外部符号 _WinMain16或者一堆MessageBoxA相关的类型不匹配错误。原因通常是子系统设成了Windows但入口写的是main/wmain或者工程开了UNICODE而代码里还在调A后缀函数并传宽字符串。解决窗口程序入口统一用wWinMain控制台用wmain工程属性里“链接器 → 系统 → 子系统”跟着改字符串统一L前缀配W后缀函数不要指望TCHAR宏自动兼容它只会把问题推迟到运行时。现象某个示例单独编译没问题整个解决方案一编就报LNK2005: 已经在 xxx.obj 中定义。原因是一个解决方案里多个项目引用了同一份.cpp或者运行时库混用一个/MT一个/MD。解决把公共代码抽成静态库项目其它项目引用库而不是源文件所有项目的“C/C → 代码生成 → 运行时库”保持同一个选项。4.2 程序退不掉、窗口白屏消息循环与 GDI 泄漏现象点了窗口右上角关闭窗口消失了但进程还在任务管理器里。原因是WM_DESTROY里没调PostQuitMessage或者消息循环写成了while (GetMessageW(...) ! 0)把返回-1的错误当成有效消息继续转。解决WM_DESTROY必须PostQuitMessage(0)循环条件改成 0循环结束后把msg.wParam作为wWinMain的返回值带出去。现象程序跑十几分钟之后整个窗口变白、控件全消失但进程没崩。原因是 GDI 句柄泄漏常见于WM_PAINT里每次都CreatePen/CreateFont却不DeleteObject或者SelectObject之后没还原旧对象导致删除失败。解决所有CreateXxx配对DeleteObjectSelectObject的返回值必须接住并在删除前还原用任务管理器“详细信息”里加一列“GDI 对象”盯着看数字只涨不跌就是泄漏。4.3 高分屏模糊与坐标错位DPI 感知现象程序在 4K 屏上窗口和文字被系统拉伸得发虚鼠标点击的位置和控件对不上。原因是进程没有声明 DPI 感知系统用位图拉伸的方式兼容旧程序。解决在工程里加一个 manifest 声明PerMonitorV2或者在程序启动最前面调SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)。声明之后所有坐标都变成物理像素GetSystemMetrics拿到的屏幕宽高也会变布局代码里写死的 800×600 就得换成按 DPI 缩放的算法否则界面会小得看不清。现象在多显示器之间拖动窗口界面错位或者部分区域画不出来。原因是WM_DPICHANGED消息没有处理窗口在新显示器上没重新调整尺寸。解决在窗口过程里处理WM_DPICHANGED用lParam里给出的RECT调用SetWindowPos重新定位。4.4 句柄耗尽与权限失败回收与 UAC现象程序跑久了CreateFileW全部失败GetLastError返回ERROR_TOO_MANY_OPEN_FILES。原因是文件句柄或内核对象没关CreateFileW、CreateEventW、CreateMutexW返回的句柄全部要CloseHandle包括那些你以为“进程退出会自己回收”的。解决把所有句柄的创建和关闭收敛到 RAII 包装类里析构时统一关调试时用 Process Explorer 看进程的 Handle 数量曲线。现象读写HKEY_LOCAL_MACHINE或往Program Files写文件时报ERROR_ACCESS_DENIED但代码在开发机上一直正常。原因是开发机用管理员账户跑普通用户没有写权限。解决配置写到HKEY_CURRENT_USER或%LOCALAPPDATA%确实需要管理员权限的功能单独拆一个提权进程用ShellExecuteExW带runas启动主程序保持普通权限运行。5. 从示例到工具用托盘、热键和进程枚举拼一个常驻小工具把几个示例拼起来是最快建立手感的方式。目标不大一个常驻托盘的小工具全局热键唤起能列出当前进程并做简单过滤配置存本地。这套东西用到的 API 全在示例源码里出现过拼装的过程就是把零散知识点串成工程。5.1 单实例与托盘常驻让程序活在后台上常驻程序第一个要解决的是重复启动。用户双击两次图标不能起两个实例抢托盘图标。用命名互斥量做单实例是最省事的int wmain() { // Global\ 前缀让互斥量跨会话可见普通用户下也可用 Local\ HANDLE hMutex CreateMutexW(nullptr, TRUE, LGlobal\\DemoTrayTool); if (!hMutex) return 1; if (GetLastError() ERROR_ALREADY_EXISTS) { // 已有实例找到它的窗口发个自定义消息把它唤到前台 HWND hPrev FindWindowW(LDemoTrayWnd, nullptr); if (hPrev) PostMessageW(hPrev, WM_APP 100, 0, 0); CloseHandle(hMutex); return 0; } // ... 正常创建窗口、进消息循环 return 0; }CreateMutexW的第二个参数bInitialOwner传TRUE表示创建即持有第三个是名字。GetLastError要在CreateMutexW之后立刻读中间插任何 API 都可能把它覆盖掉。FindWindowW按窗口类名找所以主窗口注册时类名要固定别用随机字符串。托盘图标用Shell_NotifyIconW关键是回调消息号要选WM_APP之后的区间避免和系统消息冲突#define WM_TRAYICON (WM_APP 1) NOTIFYICONDATAW nid { sizeof(nid) }; nid.hWnd hWnd; nid.uID 1; nid.uFlags NIF_ICON | NIF_MESSAGE | NIF_TIP; nid.uCallbackMessage WM_TRAYICON; nid.hIcon LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP)); wcscpy_s(nid.szTip, LDemo 工具); Shell_NotifyIconW(NIM_ADD, nid);uFlags决定哪些字段生效只填NIF_ICON的话提示文字不会显示。退出时记得Shell_NotifyIconW(NIM_DELETE, nid)否则托盘区会留一个僵尸图标鼠标划过去才消失这是最典型的“看起来像系统 bug”的问题。5.2 全局热键与自定义消息WM_APP 之后的那段编号全局热键用RegisterHotKey注册成功后系统会把WM_HOTKEY发到指定窗口wParam是热键 ID。它不需要键盘钩子也不用管理员权限比低级钩子干净得多。// 注册 CtrlAltK if (!RegisterHotKey(hWnd, 1, MOD_CONTROL | MOD_ALT, K)) { DWORD err GetLastError(); // ERROR_HOTKEY_ALREADY_REGISTERED被别的程序占了换组合键 wprintf(Lregister hotkey failed: %lu\n, err); } // 窗口过程里处理 case WM_HOTKEY: if (wParam 1) { ShowWindow(hWnd, SW_SHOW); SetForegroundWindow(hWnd); } return 0;MOD_*常量可以按位或但MOD_WIN组合在部分系统上会被系统保留注册失败率较高。RegisterHotKey是进程级的程序退出时必须UnregisterHotKey(hWnd, 1)不然下次启动可能注册不上表现是“第一次好用第二次没反应”。5.3 进程枚举Toolhelp32 快照的正确遍历姿势进程列表用CreateToolhelp32Snapshot这是最不需要额外权限的方式。遍历有个固定套路先Process32FirstW拿第一个再循环Process32NextW。void EnumProcesses() { HANDLE snap CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap INVALID_HANDLE_VALUE) return; PROCESSENTRY32W pe { sizeof(pe) }; // dwSize 必须先赋值 if (Process32FirstW(snap, pe)) { do { // szExeFile 是文件名th32ProcessID 是 PID wprintf(L%-40s %6lu\n, pe.szExeFile, pe.th32ProcessID); } while (Process32NextW(snap, pe)); } CloseHandle(snap); }PROCESSENTRY32W的dwSize不赋值Process32FirstW直接返回FALSEGetLastError给ERROR_BAD_LENGTH这个坑几乎每个新手都踩过。快照本身是个内核对象必须CloseHandle。要拿进程的可执行文件全路径或内存占用得再OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...)配QueryFullProcessImageNameW注意用LIMITED_INFORMATION而不是PROCESS_ALL_ACCESS后者对系统进程会失败。5.4 配置持久化ini 文件与注册表怎么选配置存哪取决于程序要不要跟着用户漫游。WritePrivateProfileStringW写 ini 文件好处是可读、可手改、卸载时删目录就干净坏处是不支持并发写、路径要自己拼。注册表的好处是跟着用户账户走坏处是卸载残留、权限问题多。我一般的判断标准单机工具用 ini 放%APPDATA%企业环境需要集中管理的用注册表。// 写 ini第二个参数是节名第三个是键名 WritePrivateProfileStringW(LGeneral, LLastPath, LD:\\data, L.\\config.ini); // 读 ini拿到实际字符数缓冲区不够会截断 wchar_t buf[MAX_PATH] {}; GetPrivateProfileStringW(LGeneral, LLastPath, L, buf, MAX_PATH, L.\\config.ini);GetPrivateProfileStringW的第三个参数是默认值键不存在时返回它比先判断存在再读简单。这两个函数内部会做文件锁多进程同时写同一个 ini 有概率丢数据配置量大的话还是自己用CreateFileW 内存映射或者干脆上 SQLite 更稳。6. 验证与二次开发的收尾技巧用句柄计数和调试输出自查改完代码最怕“看起来没问题”。我自己的验证习惯是三步前两步几乎不花时间但能拦住八成回归问题。第一步在程序里挂一个SetTimer每五秒把GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)和GR_USEROBJECTS打到OutputDebugStringW用调试器或者任意能看调试输出的工具盯着。这两个数字如果在一小时内单调上涨不用怀疑一定有 GDI 或 USER 对象没释放。第二步把文件句柄和内核句柄的数量也一起看任务管理器加一列“句柄”就够正常程序跑一天应该是平稳的锯齿线不是阶梯上升。第三步才是功能回归把每个按钮、每个热键、每个托盘菜单项点一遍重点看“关闭再打开”这类操作泄漏基本都藏在这里。二次开发上有个具体技巧值得说别在原示例上直接改先复制一份出来把窗口类名、互斥量名、注册表键名全部换掉再动手。我吃过一次亏两个工具用了同一个窗口类名FindWindowW找到的是另一个进程的窗口PostMessageW发过去对方不处理调试了半天以为是消息丢了。从那以后我每次新建工程第一件事就是把所有全局名字加前缀第二件事是把GetLastError的返回值统一转成文本打日志第三件事才是写业务代码。这套习惯救过我不止一次尤其是那种“只在一台机器上复现”的问题。源码包里的示例按章节分好了建议从第 2 章那个最小骨架跑通开始再按模块往里加东西比一上来就啃大杂烩工程顺得多。希望帮到你。本文还有配套的精品资源点击获取