ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Win32 API源码实战:消息循环、文件映射与句柄泄漏排查

Win32 API源码实战:消息循环、文件映射与句柄泄漏排查

简介:《精通Windows API——函数、接口、编程实例(源码)》配套源码包,面向已具备C++基础、希望深入Win32桌面开发与系统级编程的开发者。内容围绕窗口管理、消息循环、内存与文件操作、动态库加载及COM接口等核心主题展开,通过156个可运行实例把API函数、接口规范与工程实践串联起来,适合作为查阅手册和动手实验素材。压缩包为rar格式,共1789个文件、约24.29MB,包含c/cpp/h源码、vcproj与sln工程、exe示例程序,以及pdb、ilk调试符号和manifest、rc、res等资源文件,另有htm说明与dll、lib依赖,目录按示例组织,便于按主题检索和编译调试。已有376人学习。读者可对照源码理解CreateWindowEx、SendMessage、GetMessage、LoadLibrary/GetProcAddress等典型调用,也能从GUI、文件操作、多线程与进程通信案例中积累排错思路,提升Windows平台开发与调试能力。

1. 一个卡死的老上位机,让我重新翻起了 Win32 手册

接手一个跑了七八年的工控上位机,界面每隔几小时假死一次,日志文件写到 4GB 就整个进程崩掉。把代码拉下来一看,没有框架、没有运行时封装,一个wWinMain加两千多行WndProc,全是裸的 Windows API 调用。这类代码最难受的不是难懂,而是没有参考物:CreateFileMapping的大小参数传 0 到底代表什么、GetMessage返回 -1 要不要退出、WM_PAINT里能不能再触发一次重绘,全靠猜和试。

这份带源码的《精通Windows API-函数、接口、编程实例》,补的恰好就是这块空白:它把 Win32 C++ 里最容易含糊过去的函数原型、参数语义和典型调用序列摊开,每个接口配一个能编译、能跑、能改的样例。适合已经会写 C++、但一碰句柄、消息、内存映射就发怵的人;也适合要长期维护存量 Win32 代码、需要一份“参数速查 + 可跑样例”的从业者。

2. Win32 的调用契约:先把 WinMain 到消息循环跑通

Win32 编程的门槛从来不在语法,而在一堆没写进函数签名的隐含规则:哪个参数必须非空、哪个句柄必须成对释放、哪个返回值不是 0 就代表失败。源码包里的例子之所以能跑,是因为这些规则被作者用正确的方式写进去了;照着抄却跑不起来,八成是某条规则被漏掉了。所以第一步不是急着翻目录看有哪些例子,而是先把最小骨架跑通,把规则摸清楚。

2.1 最小可运行骨架:注册窗口类、建窗口、跑消息泵

任何 Win32 图形程序都是同一个三段式结构。下面这段六十行的代码是所有例子的公共底座,先把它编译通过,后面拆模块才有意义。

// minimal_win32.cpp // 编译:cl /EHsc /std:c++17 /DUNICODE /D_UNICODE minimal_win32.cpp user32.lib gdi32.lib #include <windows.h> LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); // 投递 WM_QUIT,消息泵收到 0 后退出 return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 必须与 EndPaint 成对,否则重绘会无限循环 EndPaint(hWnd, &ps); return 0; } default: return DefWindowProcW(hWnd, msg, wParam, lParam); // 未处理的消息交回系统 } } int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmdLine, int nShow) { WNDCLASSEXW wc = { sizeof(WNDCLASSEXW) }; // cbSize 必须是第一个赋值的字段 wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = L"MinimalWnd"; wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); // 系统色要用 +1 偏移 if (!RegisterClassExW(&wc)) return 1; HWND hWnd = CreateWindowExW(0, wc.lpszClassName, L"demo", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 640, 480, nullptr, nullptr, hInst, nullptr); if (!hWnd) return 2; ShowWindow(hWnd, nShow); UpdateWindow(hWnd); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { // 返回 0 退出,-1 表示出错 TranslateMessage(&msg); // 把 WM_KEYDOWN 翻译成 WM_CHAR DispatchMessageW(&msg); // 分发到 WndProc } return (int)msg.wParam; }

这段代码有三处值得单独说清楚。wc.cbSize如果不赋值,RegisterClassExW会直接返回 0,GetLastError()给出ERROR_INVALID_PARAMETER,新手最容易在这里卡半天。hbrBackground用系统色时必须写成COLOR_WINDOW + 1,因为CreateSolidBrush之外的常量是索引,加 1 才落在画刷句柄区间。GetMessageW的循环条件写成> 0而不是!= 0,是因为它出错时返回 -1,若用!= 0判断会进入死循环——这条在长时间运行的服务型界面里是真会翻车的。

参数层面,CreateWindowExW第一个参数是扩展样式,做无边框、置顶、透明时才有用,常规窗口传 0;CW_USEDEFAULT只对 overlapped 窗口的 x/y 有效,width/height 传它没有意义;最后那个lpParam会以WM_CREATE的lParam形式进入窗口过程,多窗口共用一个WndProc时靠它区分实例。

2.2 字符集:TCHAR、_T() 与 W/A 后缀函数的真实关系

源码包里几乎每个字符串都套着_T(),每个函数名都能看到W或A后缀,这不是风格问题,是编译期开关。Win32 里凡是接收字符串的 API,实际上都有两个版本,CreateFile本身只是个宏,展开成CreateFileW还是CreateFileA取决于有没有定义UNICODE。

源码写法定义 UNICODE 后未定义 UNICODE 时说明
CreateFile(TEXT("a.txt"), ...)CreateFileWCreateFileA通用写法,随宏切换
CreateFileW(L"a.txt", ...)CreateFileWCreateFileW强制宽字符,不受宏影响
CreateFileA("a.txt", ...)CreateFileACreateFileA强制窄字符,中文路径会乱码
_T("text")/TEXT("text")L"text""text"字面量跟随宏

结论很简单:新写的代码一律显式用W版本和L""字面量,把UNICODE、_UNICODE两个宏一起定义死,别依赖_T()的自动切换。理由不是“宽字符更先进”,而是窄字符版本走的是系统 ANSI 代码页,一旦路径里出现非当前代码页能表示的字符,CreateFileA会失败得毫无提示——报错码可能还是ERROR_FILE_NOT_FOUND,看上去像文件不存在,实际是编码转不过去。

源码包里如果有混用的情况,改造顺序是:先全局加-DUNICODE -D_UNICODE,然后编译,把所有LNK2019和C2664挨个修。别反向操作去掉宏去迁就老代码,那样只是把问题推后。

2.3 调用约定与句柄:WINAPI、__stdcall 与 HANDLE 到底是什么

WINAPI在 32 位下展开为__stdcall,含义是参数从右往左压栈、由被调用方清理栈;在 x64 下这个修饰被忽略,因为统一走一套调用约定。它存在的意义是回调函数的二进制兼容:窗口过程、线程入口、EnumWindows的回调,都必须带这个修饰,否则 32 位下栈会失衡,程序在回调返回时崩掉,报错位置看起来毫无关系。

HANDLE则是一个不透明指针,void*的 typedef。这里有个反直觉的细节:空句柄有两种,NULL(值为 0)和INVALID_HANDLE_VALUE(值为(HANDLE)-1)。不同 API 用不同的失败值——CreateFileW失败返回INVALID_HANDLE_VALUE,CreateWindowExW失败返回NULL,CreateThread失败返回NULL。判断错的那一个,失败分支永远进不去,后面拿到一个 -1 的句柄去读写,GetLastError()会给出ERROR_INVALID_HANDLE,但你根本不知道是哪个调用出的问题。

一个稳妥的写法是用返回值本身做判断,而不是和某一个常量比较:

HANDLE hFile = CreateFileW(L"data.bin", GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile == INVALID_HANDLE_VALUE) { // CreateFile 系列特殊 DWORD err = GetLastError(); // err == ERROR_FILE_NOT_FOUND / ERROR_ACCESS_DENIED ... } HANDLE hThread = CreateThread(nullptr, 0, Worker, ctx, 0, nullptr); if (hThread == nullptr) { // CreateThread 系列用 NULL DWORD err = GetLastError(); }

线程入口函数的签名也要严格遵守DWORD WINAPI Worker(LPVOID),返回值和参数类型差一点,在 x64 下可能侥幸编译通过、运行时栈错位。这类问题没有报错,只有随机崩溃,属于典型的玄学现场。

3. 把源码包编译起来:工程配置、链接顺序与产物验证

拿到带源码的示例集,第一件事永远不是读代码,而是让它在你本机上编出一个能双击运行的 exe。编不出来之前,代码读得再细也没法验证理解是否正确。这一章按“先看清目录结构、再统一构建脚本、最后处理链接错误”的顺序推。

3.1 先摸清目录结构:头文件、库和示例的分层

这类书配套源码通常分成三层:公共头文件、按章节组织的示例源文件、以及可选的第三方或系统库目录。花五分钟列个清单,比后面到处找#include找不到要省事得多。

目录典型内容处理方式
include/公共结构体定义、宏、辅助函数声明加进头文件搜索路径
src/ch02…src/ch15按章组织的.cpp,多数含独立main每个 cpp 编成一个 exe
lib/预编译静态库(若有)加进链接器输入
res/图标、对话框模板.rc资源脚本一并编进目标
bin/运行时依赖的 dll、数据文件复制到输出目录

资源脚本.rc容易被漏掉。漏了的话编译能过,运行起来图标是默认白纸、对话框创建失败返回NULL,GetLastError()给出ERROR_RESOURCE_TYPE_NOT_FOUND。看到这个码,先回去检查.rc有没有参与编译。

3.2 用 CMake 统一两套工具链,别手点工程文件

如果包里带的是 IDE 工程文件,直接在 IDE 里打开也能编,但几百个示例一个个手动配置不现实,而且换人接手时环境又得重来一遍。写个CMakeLists.txt批量扫描,一条命令出全部产物,是更省事的路子。

cmake_minimum_required(VERSION 3.20) project(win32_samples CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 统一字符集,让源码里的 _T() / TEXT() 一律展开为宽字符版本 add_compile_definitions(UNICODE _UNICODE) # 关掉 min/max 宏,否则和 std::min/std::max 冲突;同时瘦身 windows.h add_compile_definitions(NOMINMAX WIN32_LEAN_AND_MEAN) # 老代码里 strcpy / fopen 一大片,先关掉安全告警,别一开始就被淹 add_compile_definitions(_CRT_SECURE_NO_WARNINGS) file(GLOB SAMPLE_SRC CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/src/ch*/*.cpp") foreach(src ${SAMPLE_SRC}) get_filename_component(name ${src} NAME_WE) add_executable(${name} ${src}) # 按需增减:advapi32 是注册表,shell32 是外壳接口,comctl32 是公共控件 target_link_libraries(${name} PRIVATE user32 gdi32 advapi32 shell32 comctl32) endforeach()

逻辑上就三步:全局宏统一编译环境、file(GLOB)批量收集源文件、循环里给每个示例单独add_executable再挂库。CONFIGURE_DEPENDS让新增的 cpp 不用重新跑 cmake 也能被扫到,改示例时很省事。

参数部分要留意三点。WIN32_LEAN_AND_MEAN会把windows.h里一部分不常用的头(比如 winsock)剔出去,体积小、编译快,但如果某个示例用到了winsock2.h就必须在windows.h之前手动 include,否则会撞重复定义。NOMINMAX是为了避免min/max宏把std::min替换掉,报错信息会变成一堆看不懂的C2589。_CRT_SECURE_NO_WARNINGS只是把告警压下去,不代表那些strcpy没问题,后面做代码改造时要单独处理。

不带 CMake 想单编一个文件,命令行这样写:

# 在 x64 Native Tools 环境下 cl /EHsc /std:c++17 /DUNICODE /D_UNICODE /DNOMINMAX /W3 ^ src\ch04\file_map_demo.cpp ^ /Fe:file_map_demo.exe ^ user32.lib gdi32.lib advapi32.lib

/EHsc打开标准 C++ 异常模型,Visual C++ 下不加它,try/catch里 throw 出的异常不会触发析构,句柄就漏了。/W3是告警级别,调到/W4会冒出一堆老代码的类型转换告警,第一次上手建议先留在/W3。

3.3 链接失败的三类报错与 dumpbin 验证

Win32 项目的链接错误来来回回就三类,认清楚了基本能自己定位。

第一类是LNK2019 unresolved external symbol。九成是漏了 lib:用了RegOpenKeyExW没加advapi32.lib,用了ShellExecuteW没加shell32.lib。剩下的可能是字符集不匹配——源码里定义了wWinMain,编译时却带着/D_UNICODE以外的配置,链接器会去找WinMain,报的符号名和你写的不一样,一眼能看出来是宽窄字符问题。

第二类是LNK2005 已经在 xxx.obj 中定义。常见于混了静态 CRT:一个库用/MT编的,主程序用/MD编的,malloc家族符号重复。解决方式统一运行时库选项,不要试图用/FORCE:MULTIPLE硬压过去,那个开关留下的是内存管理器的双重堆,运行久了直接崩。

第三类是LNK1104 cannot open file 'xxx.lib'。路径不对或者库名拼错,检查链接器附加库目录,注意 x86 和 x64 的库目录是分开的,混着引会在链接阶段报机器类型不匹配。

编出来之后别急着跑,先用dumpbin看一眼依赖,确认没有意外引用:

dumpbin /dependents file_map_demo.exe

输出里应该只有KERNEL32.dll、USER32.dll、GDI32.dll、ADVAPI32.dll这类系统组件。如果冒出来一个VCRUNTIME140.dll而目标机器上没装运行时,双击就是“缺少 dll”弹窗。要发给别人用,编译时改静态链接:CMake 里设set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"),命令行加/MT。

4. 四类高频接口拆开看:窗口、文件映射、进程线程、注册表

源码包覆盖面通常很广,但真到工程里天天用的,无非这四类。这一章每类挑一个能直接改吧改吧就用起来的片段,重点讲“为什么这么写”而不是“函数有几个参数”。

4.1 窗口与 GDI:WM_PAINT 里做双缓冲的正确姿势

直接在WM_PAINT里往窗口 DC 上画,窗口一拖动就闪成一片。原因是系统先发WM_ERASEBKGND刷背景,再让你画内容,两个动作之间的时间差肉眼可见。标准解法是在内存 DC 里画完整帧,再一次性BitBlt过去。

case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); RECT rc; GetClientRect(hWnd, &rc); // 1. 建一个和窗口 DC 兼容的内存 DC HDC memDC = CreateCompatibleDC(hdc); // 2. 建一张和客户区等大的位图,挂到内存 DC 上 HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); HGDIOBJ oldBmp = SelectObject(memDC, bmp); // 3. 所有绘制都在 memDC 上做 FillRect(memDC, &rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); SetBkMode(memDC, TRANSPARENT); TextOutW(memDC, 20, 20, L"double buffer", 13); // 4. 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, rc.right, rc.bottom, memDC, 0, 0, SRCCOPY); // 5. 逆序回收,顺序错了会 GDI 泄漏 SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hWnd, &ps); return 0; }

关键在最后三步的回收顺序:必须先把原来的位图SelectObject回去,才能DeleteObject新位图,否则那张位图还被 DC 选着,删除会失败,句柄表里就一直挂着一个 GDI 对象。GDI 对象有进程级配额(默认一万个),泄漏几百个之后所有绘图 API 开始返回NULL,界面变成一片灰,任务管理器里 GDI 对象数会稳定上涨——这是判断 GDI 泄漏最直接的信号。

CreateCompatibleBitmap(hdc, ...)里第一个参数必须传窗口 DC 而不是内存 DC。传内存 DC 的话,建出来的是 1bpp 单色位图,画上去全是黑白块,这个坑我见过不止一次。

按上面写法每次重绘都新建位图,对低频重绘的场景够用;如果窗口大、刷新频繁,位图应该在窗口创建时建一次,缓存在结构体里,WM_SIZE时重建。源码包里的例子多半是每次新建,那是为了代码短,工程里别照抄。

4.2 文件映射:CreateFileMapping 的页对齐与生命周期

要读一个几 GB 的日志文件,用ReadFile分块读也行,但解析逻辑会被缓冲区切分搞得很难写。文件映射把整个文件当成一块内存,指针加偏移就能访问,代码干净得多。

HANDLE hFile = CreateFileW(L"big.log", GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile == INVALID_HANDLE_VALUE) return; LARGE_INTEGER fileSize{}; GetFileSizeEx(hFile, &fileSize); // 第 4、5 个参数是 64 位大小的高低位;传 0,0 表示与文件等长 HANDLE hMap = CreateFileMappingW(hFile, nullptr, PAGE_READONLY, 0, 0, nullptr); if (!hMap) { CloseHandle(hFile); return; } // 偏移必须按 64KB 对齐;一次映射全文件就传 0 const char* base = (const char*)MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0); if (!base) { CloseHandle(hMap); CloseHandle(hFile); return; } // ... 按 base + offset 访问,注意别越界到 fileSize.QuadPart 之外 UnmapViewOfFile(base); CloseHandle(hMap); CloseHandle(hFile); // hFile 要最后关,映射视图还活着时关文件句柄会失败

CreateFileMappingW的大小参数是“高 32 位 + 低 32 位”两段,传0, 0表示跟随文件实际长度。如果只想映射前 1MB,传0, 1024*1024,但要注意这之后对文件的写入不能超过映射大小,超了会触发EXCEPTION_IN_PAGE_ERROR,不是返回值报错,是直接异常。

MapViewOfFile的偏移必须 64KB 对齐,这是分配粒度决定的。想从第 100 字节开始看,只能整块映射之后用base + 100取指针,不能直接传 100 当偏移——传了会得到ERROR_MAPPING_ALIGNMENT,而这个错误在老文档里常常没写清楚。

还有个容易忽略的点:映射视图是对文件的共享内存视图,不是快照。别的进程往文件里写,你这边读到的内容会变。要保证一致性,得自己加锁(LockFileEx或者命名互斥体)。做多进程日志收集时,很多人以为是“读到了一个副本”,结果解析到一半数据变了,日志里出现半行记录,回头查半天。

4.3 进程与线程:CreateProcess 的命令行缓冲与等待

启动外部程序并等它结束,看着简单,但CreateProcessW有个反直觉要求:lpCommandLine必须是可写缓冲区,不能传字符串字面量。

wchar_t cmdLine[] = L"tool.exe --input data.bin"; // 必须可写,不能是 L"..." STARTUPINFOW si = { sizeof(si) }; PROCESS_INFORMATION pi{}; BOOL ok = CreateProcessW( nullptr, // 应用程序名;为 nullptr 时从 cmdLine 里解析 cmdLine, // 会被就地修改,所以不能是 const nullptr, nullptr, // 进程/线程安全属性 FALSE, // bInheritHandles:是否需要继承句柄 0, // 创建标志:CREATE_NO_WINDOW 可隐藏控制台 nullptr, // 环境块,nullptr 表示继承 nullptr, // 工作目录 &si, &pi); if (!ok) { /* GetLastError() */ } // 等它结束,超时 30 秒 DWORD wr = WaitForSingleObject(pi.hProcess, 30000); if (wr == WAIT_TIMEOUT) { TerminateProcess(pi.hProcess, 1); // 超时强杀,注意这是最后手段 } DWORD exitCode = 0; GetExitCodeProcess(pi.hProcess, &exitCode); CloseHandle(pi.hThread); // 两个句柄都要关 CloseHandle(pi.hProcess);

传字面量的后果是进程启动后,系统在解析命令行时往只读页写数据,触发访问违例,崩溃点看起来在系统库内部,非常难查。改成wchar_t数组就没事。

bInheritHandles传 TRUE 时要特别小心:所有可继承句柄都会被复制进子进程,包括你不希望它拿到的。真要传管道给子进程,正确做法是用STARTUPINFOEXW配合PROC_THREAD_ATTRIBUTE_HANDLE_LIST精确指定,而不是图省事传 TRUE。做命令行工具封装时,这个细节决定了子进程能不能拿到父进程的文件句柄。

4.4 注册表与系统信息:RegOpenKeyEx 的权限与 32/64 位视图

读注册表本身不难,难在两点:权限和视图。前者让你ERROR_ACCESS_DENIED,后者让你明明存在的东西读不到。

HKEY hKey = nullptr; // KEY_READ 是最小权限;要写就换成 KEY_SET_VALUE // KEY_WOW64_64KEY 强制读 64 位视图,避免被重定向到 Wow6432Node LONG ret = RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\SomeVendor\\SomeProduct", 0, KEY_READ | KEY_WOW64_64KEY, &hKey); if (ret != ERROR_SUCCESS) { // ERROR_FILE_NOT_FOUND:键不存在或视图不对 // ERROR_ACCESS_DENIED:权限不足 return; } wchar_t value[512]; DWORD cb = sizeof(value); DWORD type = 0; ret = RegQueryValueExW(hKey, L"InstallPath", nullptr, &type, (LPBYTE)value, &cb); if (ret == ERROR_SUCCESS && type == REG_SZ) { // value 里是安装路径,注意 cb 是字节数不是字符数 } RegCloseKey(hKey);

权限方面,HKEY_LOCAL_MACHINE下的写操作在普通用户令牌下必然失败,读操作也有部分子键受限。程序启动时申请提权会让用户看到一个弹窗,这在无人值守场景里是硬伤,所以读多写少的配置项尽量放HKEY_CURRENT_USER。

视图方面,32 位程序默认会被重定向到Wow6432Node,64 位程序默认读 64 位视图。加KEY_WOW64_64KEY或KEY_WOW64_32KEY可以强制指定,这个标志在排查“明明装了这个软件却读不到注册表”时是第一步要确认的。

RegQueryValueExW的cb是字节数,REG_SZ要除以sizeof(wchar_t)才是字符数,直接拿它当长度去wcslen会读到一堆尾部乱码。这类细节源码包里都有例子对照,值得跑一遍看输出。

5. 避坑与排查:五条血泪记录

示例代码在干净环境里跑通不代表工程里能用。下面这五条是实际维护 Win32 代码时反复撞上的,每条都按“看到什么现象、根子在哪、怎么修”的顺序写。

5.1 句柄数只涨不跌:任务管理器里的长期指标

现象:程序跑八九个小时后,任务管理器里“句柄”数从 300 涨到 8000 多,然后CreateFile开始返回失败,报ERROR_TOO_MANY_OPEN_FILES。

原因:某个错误分支提前return了,跳过了CloseHandle。Win32 没有 RAII,所有句柄都是手动生命周期,if (err) return;这种写法用一次漏一次。

解决:给每类句柄包一层析构函数里CloseHandle的小结构体,或者至少把清理逻辑提到单一出口。临时排查可以在CreateFile前后打日志,配个计数器,看哪个路径进来的多。

struct ScopedHandle { HANDLE h{ INVALID_HANDLE_VALUE }; ~ScopedHandle() { if (h != INVALID_HANDLE_VALUE && h != nullptr) CloseHandle(h); } operator HANDLE() const { return h; } };

用的时候ScopedHandle fh{ CreateFileW(...) },函数任何地方return都会自动关。注意CreateFileW的失败值是INVALID_HANDLE_VALUE不是NULL,析构里两个都要排掉。

5.2 界面无响应:在 WndProc 里等一个需要消息的调用

现象:点了按钮之后窗口整个变白,标题栏显示“未响应”,点关闭也没反应,只能任务管理器结束进程。

原因:在WM_COMMAND的处理函数里直接调了WaitForSingleObject等一个工作线程,而那个工作线程完成任务后需要用SendMessage通知主线程——SendMessage是跨线程同步的,等主线程处理,主线程又在等工作线程,直接死锁。

解决:所有阻塞操作移出WndProc。要么起工作线程,完成时PostMessage(异步投递,不等待);要么在主线程里用MsgWaitForMultipleObjects边等边抽消息。判断标准很简单:WndProc里的代码执行时间不能超过几十毫秒,超了就该移出去。

5.3 编译报 LNK2019 找不到 WinMain

现象:源码里明明写的是wWinMain,链接器报unresolved external symbol WinMain,或者反过来找wWinMain。

原因:字符集宏和入口函数不匹配。定义了UNICODE但没定义_UNICODE,或者链接器子系统选项/SUBSYSTEM:CONSOLE与/SUBSYSTEM:WINDOWS搞反了。控制台子系统会去找main/wmain,窗口子系统才找WinMain/wWinMain。

解决:UNICODE和_UNICODE永远成对定义,/SUBSYSTEM:WINDOWS与wWinMain配对。用 CMake 的话,add_executable(name WIN32 ...)里那个WIN32就是设子系统的,别漏。

5.4 文件映射跨进程读到半行数据

现象:多进程往同一个日志文件写,另一个进程映射读,偶尔解析出一行没写完的记录,或者内容对不上。

原因:文件映射提供的是共享视图,不是快照。写端还没写完,读端已经看到了部分数据,而且没有内存屏障保证顺序。

解决:要么用LockFileEx做区域锁,读写前先锁字节范围;要么改成“写临时文件 + 原子改名”的模式,读端只认最终文件名。第二种在日志场景更省事,代价是文件句柄数量多一些。

5.5 提权后启动的程序读不到用户配置

现象:程序加了个requireAdministrator清单,双击弹 UAC,提权之后原来能读的HKEY_CURRENT_USER配置全没了,界面变成默认值。

原因:提权后进程跑在管理员账户的安全上下文里,HKEY_CURRENT_USER指向的是管理员那个用户配置,不是当前登录用户。同样的问题也出现在映射网络盘、读取用户目录文件上。

解决:配置要么放HKEY_LOCAL_MACHINE的公共位置,要么在提权前把当前用户的 SID 和配置路径读出来,通过命令行或临时文件传给提权后的进程。不要指望提权后还能自动找到原来的用户环境。

6. 进阶:用一层薄封装把源码包里的例子串成可复用的调试脚手架

把示例跑通只是第一步。真正在工程里用起来,缺的是一层统一的错误处理和句柄管理,否则每个示例都是孤岛,出问题时只能靠printf猜。我的做法是给源码包里的每个例子加两个小工具:一个统一错误信息输出,一个 RAII 句柄包装,然后把这层抽成一个公共头文件。

// win32_util.h —— 放在公共头目录,所有示例 include 它 #pragma once #include <windows.h> #include <string> // 把 GetLastError() 的数值翻译成可读描述 inline std::wstring LastErrorText(DWORD code = GetLastError()) { wchar_t* buf = nullptr; DWORD n = FormatMessageW( FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, code, 0, (LPWSTR)&buf, 0, nullptr); std::wstring s = (n && buf) ? std::wstring(buf, n) : L"unknown error"; if (buf) LocalFree(buf); while (!s.empty() && (s.back() == L'\r' || s.back() == L'\n')) s.pop_back(); return s; } // 只在失败时打印,成功的路径不产生任何输出 #define WIN32_CHECK(expr, what) \ do { \ if (!(expr)) { \ OutputDebugStringW((std::wstring(L"[FAIL] ") + what + \ L" -> " + LastErrorText()).c_str()); \ } \ } while (0)

FormatMessageW的三面旗标各有作用:ALLOCATE_BUFFER让系统自己分配字符串,用完必须LocalFree;FROM_SYSTEM表示从系统消息表里查;IGNORE_INSERTS防止消息体里的%1占位符没被替换时报警。去掉尾部换行是因为系统返回的描述自带\r\n,拼进日志会多一个空行。

OutputDebugStringW只在调试器挂载时可见,用调试器或者一个简单的调试输出捕获工具就能看到。相比printf,它的好处是不影响控制台窗口的显示节奏,也不会在 Release 下留一堆空输出。宏包一层do { } while (0)是为了能在if后面直接当语句用,不带这个包裹的宏在if...else里会展开错。

把这层挂到示例上只要改一处:所有 Win32 调用外面套WIN32_CHECK,失败时立刻能看到函数名、错误码和系统描述。之前那个“窗口创建失败返回 2”的骨架,改成打印具体错误之后,ERROR_INVALID_PARAMETER和ERROR_NOT_ENOUGH_MEMORY就能一眼分开,不用再靠改代码逐个试。

验证这层封装有没有生效,最快的办法是故意制造一次失败:把CreateFileW的路径改成一个不存在的目录,看调试输出里是不是准确打印出[FAIL] CreateFileW -> 系统找不到指定的路径。。如果打出来的是unknown error,说明FormatMessageW没拿到消息,通常是错误码在调用之前被别的 API 覆盖了——GetLastError()必须在失败之后立刻取,中间插一个OutputDebugString都会被清掉。

从那次排查一个只在客户现场复现的句柄泄漏之后,我每次拿到新的 Win32 示例包,都强制先把这两件小工具加进公共头,再开始读代码。不是因为严谨,是因为吃过亏:没有错误描述的时候,GetLastError()返回的只是一个数字,而人脑对数字是没有记忆的。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表