简介:《精通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, L"hello", L"tip", 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, L"hello 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 = L"DemoWndClass"; wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); if (!RegisterClassExW(&wc)) return 0; // 类名重复会失败 HWND hWnd = CreateWindowExW(0, L"DemoWndClass", L"Demo", 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直接返回FALSE,GetLastError给的是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并把警告级别调到/W4,Win32 代码在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 = L"ChildPanelClass"; return RegisterClassExW(&wc); // 返回 0 表示失败 } // 在主窗口的 WM_CREATE 里创建子窗口 case WM_CREATE: { HWND hChild = CreateWindowExW(0, L"ChildPanelClass", 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 文件与内存映射:大文件读取的两种写法
文件操作这块,示例里通常给了两种:一种是最朴素的CreateFileW+ReadFile,另一种是CreateFileMappingW+MapViewOfFile。前者适合几百 KB 的配置文件,后者适合几十 MB 以上的日志或数据文件,因为内存映射把文件内容直接映射到进程地址空间,读的时候由系统按页调入,不用自己管缓冲区大小。
// 方式一:一次性读入内存(适合小文件) std::vector<char> 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::vector<char> buf(static_cast<size_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_cast<const 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::atomic<long> g_Total{ 0 }; static CRITICAL_SECTION g_Cs; unsigned __stdcall Worker(void* arg) { const int loops = static_cast<int>(reinterpret_cast<intptr_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_cast<HANDLE>( _beginthreadex(nullptr, 0, Worker, reinterpret_cast<void*>(100000), 0, nullptr)); } WaitForMultipleObjects(4, threads, TRUE, INFINITE); // bWaitAll = TRUE for (HANDLE h : threads) CloseHandle(h); // 句柄必须关,否则泄漏 DeleteCriticalSection(&g_Cs); wprintf(L"total = %ld\n", g_Total.load()); return 0; }_beginthreadex的第二个参数是栈大小,0 表示用默认值(1MB);第三个是线程函数,签名必须是unsigned __stdcall (void*);最后一个参数是输出线程 ID 的指针,不需要就传nullptr。它返回的句柄和CreateThread一样要用CloseHandle关,很多人以为_beginthreadex会自己回收。临界区适合“锁的范围很小、竞争不激烈”的场景,它比互斥量轻,但不能跨进程。如果示例里用的是InterlockedIncrement这类原子操作,那连锁都省了,性能更好,代价是只能做简单的加减和交换。
还要注意WaitForMultipleObjects一次最多等 64 个句柄,超过就得分组等,或者用 IOCP。示例里通常只起两三个线程,看不出这个限制,真到批量任务的时候就会撞上WAIT_FAILED+ERROR_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, L"Software\\DemoTool", 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &hKey, nullptr); if (ret != ERROR_SUCCESS) return false; ret = RegSetValueExW(hKey, L"Flag", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value)); RegCloseKey(hKey); return ret == ERROR_SUCCESS; } DWORD LoadFlag(DWORD defaultValue) { HKEY hKey = nullptr; if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\DemoTool", 0, KEY_QUERY_VALUE, &hKey) != ERROR_SUCCESS) return defaultValue; DWORD type = 0, size = sizeof(DWORD), value = 0; LONG ret = RegQueryValueExW(hKey, L"Flag", nullptr, &type, reinterpret_cast<LPBYTE>(&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: 无法解析的外部符号 _WinMain@16,或者一堆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配对DeleteObject,SelectObject的返回值必须接住并在删除前还原;用任务管理器“详细信息”里加一列“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, L"Global\\DemoTrayTool"); if (!hMutex) return 1; if (GetLastError() == ERROR_ALREADY_EXISTS) { // 已有实例,找到它的窗口发个自定义消息把它唤到前台 HWND hPrev = FindWindowW(L"DemoTrayWnd", 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, L"Demo 工具"); Shell_NotifyIconW(NIM_ADD, &nid);uFlags决定哪些字段生效,只填NIF_ICON的话提示文字不会显示。退出时记得Shell_NotifyIconW(NIM_DELETE, &nid),否则托盘区会留一个僵尸图标,鼠标划过去才消失,这是最典型的“看起来像系统 bug”的问题。
5.2 全局热键与自定义消息:WM_APP 之后的那段编号
全局热键用RegisterHotKey,注册成功后系统会把WM_HOTKEY发到指定窗口,wParam是热键 ID。它不需要键盘钩子,也不用管理员权限,比低级钩子干净得多。
// 注册 Ctrl+Alt+K if (!RegisterHotKey(hWnd, 1, MOD_CONTROL | MOD_ALT, 'K')) { DWORD err = GetLastError(); // ERROR_HOTKEY_ALREADY_REGISTERED:被别的程序占了,换组合键 wprintf(L"register 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直接返回FALSE,GetLastError给ERROR_BAD_LENGTH,这个坑几乎每个新手都踩过。快照本身是个内核对象,必须CloseHandle。要拿进程的可执行文件全路径或内存占用,得再OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...)配QueryFullProcessImageNameW,注意用LIMITED_INFORMATION而不是PROCESS_ALL_ACCESS,后者对系统进程会失败。
5.4 配置持久化:ini 文件与注册表怎么选
配置存哪,取决于程序要不要跟着用户漫游。WritePrivateProfileStringW写 ini 文件,好处是可读、可手改、卸载时删目录就干净;坏处是不支持并发写、路径要自己拼。注册表的好处是跟着用户账户走,坏处是卸载残留、权限问题多。我一般的判断标准:单机工具用 ini 放%APPDATA%,企业环境需要集中管理的用注册表。
// 写 ini:第二个参数是节名,第三个是键名 WritePrivateProfileStringW(L"General", L"LastPath", L"D:\\data", L".\\config.ini"); // 读 ini:拿到实际字符数,缓冲区不够会截断 wchar_t buf[MAX_PATH] = {}; GetPrivateProfileStringW(L"General", L"LastPath", 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 章那个最小骨架跑通开始,再按模块往里加东西,比一上来就啃大杂烩工程顺得多。希望帮到你。
本文还有配套的精品资源,点击获取