简介:《精通Windows API——函数、接口、编程实例》配套源码包面向已具备C++基础、希望深入Win32桌面开发与系统级编程的读者,围绕窗口管理、消息循环、内存与文件操作、动态链接库加载及COM接口等核心主题,提供156个可运行实例,帮助读者从调用单个API过渡到理解完整工程的组织方式。压缩包共1789个文件,约24.29MB,除了c、cpp、h源码和rc资源脚本外,还包含sln、vcproj工程文件,以及exe、dll、lib等编译产物和pdb、obj、idb等调试与中间文件,并附有htm说明和manifest清单,便于直接对照构建过程并排查链接与运行问题。已有376人学习。读者可结合实例源码梳理CreateWindowEx、SendMessage、GetMessage、LoadLibrary和GetProcAddress等函数的典型用法,也能通过COM接口示例理解引用计数与接口查询;工程文件与调试产物还有助于复现编译环境、观察模块依赖。对于准备从事Windows桌面应用、游戏工具或系统编程的开发者,这是一份偏实践导向的参考素材。
1. 接手一台老上位机之后,我重新翻开了这套 Win32 源码
去年帮某公司看一条产线的检测上位机,界面是灰扑扑的原生窗口,串口数据一多就假死。翻代码发现消息循环里直接Sleep等串口回包,窗口过程里还在反复CreatePen不释放。要修这类问题,绕不开 Windows API 这层底座:窗口过程、消息泵、GDI 重绘、线程同步,一个都躲不掉。手边这套《精通Windows API-函数、接口、编程实例(源码)》,不是让你从头读到尾的教程,而是一批可编译的 Win32 C++ 实例工程,按函数与接口分组,从CreateWindowEx到 GDI、进程线程、文件 I/O、DLL 动态加载都有对应的小工程。适合两类人:被 MFC、Qt 挡在门外、想看清 Win32 原始接口长什么样的 C++ 开发者,以及维护老工控、老桌面工具、需要照着改代码的一线人。下面按“能干什么、怎么跑、怎么改”拆一遍。
2. 项目结构与技术栈:一套纯 SDK 的 Win32 C++ 示例集合
2.1 技术栈盘点:不依赖 MFC、Qt 和第三方框架
这套源码最大的价值在于“裸”。它不套 MFC 的CWinApp,也不引 Qt 的信号槽,所有窗口、控件、绘图、线程都直接调 Win32 SDK 接口。工程以.sln加.vcxproj组织,用支持 MSVC 工具链的 IDE 打开就能编译,字符集默认走 Unicode,链接user32.lib、gdi32.lib、kernel32.lib、advapi32.lib这几个系统库。
| 模块 | 主要接口 | 典型示例 |
|---|---|---|
| 窗口与消息 | RegisterClassEx、CreateWindowEx、GetMessage、DispatchMessage | 主窗口、对话框、菜单 |
| GDI 绘图 | BeginPaint、CreateCompatibleDC、BitBlt、CreatePen | 双缓冲画线、贴图、文字 |
| 文件与内存 | CreateFile、ReadFile、CreateFileMapping | 日志读写、大文件映射 |
| 进程与线程 | CreateThread、WaitForSingleObject、CreateMutex | 后台读数据、线程同步 |
| DLL | LoadLibrary、GetProcAddress、__declspec(dllexport) | 插件式调用 |
| 注册表 | RegOpenKeyEx、RegSetValueEx | 配置持久化 |
这套组合的好处是“所见即所得”:你看到的每一行都在和操作系统对话,没有框架帮你兜底,也没有框架挡在你和 API 之间。坏处同样明显,异常处理、资源释放、字符集全得自己扛。熟手看的是接口调用顺序,新手看的是每个参数到底传什么。
2.2 目录结构与示例索引:按 API 主题分组
工程目录大致按主题切分,每个子目录是一个独立可编译的解决方案,公共头文件抽在Common里。这种布局对二次开发友好,想验证哪个接口就单独打开哪个工程,不用被一堆无关代码拖慢编译。
Win32ApiSamples/ ├── 01_WindowBase/ # 窗口创建、消息循环、菜单 ├── 02_GdiDraw/ # 画笔、画刷、双缓冲、位图 ├── 03_FileIO/ # 同步/异步读写、内存映射 ├── 04_ProcessThread/ # 线程创建、互斥量、事件 ├── 05_DllDemo/ # 导出 DLL 与动态加载调用 ├── 06_Registry/ # 注册表读写与配置节 └── Common/ # 公共头、GDI 句柄包装、日志宏我一般会先打开01_WindowBase把窗口跑起来,确认工具链没问题,再按需跳到02_GdiDraw看绘图套路。Common目录值得单独读一遍,里面的Win32Common.h把常用头文件和链接库都收口了,后面每个工程都靠它省事。
2.3 编译链路:字符集、链接库与运行库
Win32 工程最容易在编译期翻车的地方就三处:字符集、附加依赖项、运行库。这套源码用#pragma comment(lib, ...)把链接库写进头文件,省掉手动配链接器的步骤。
// Common/Win32Common.h #pragma once #define WIN32_LEAN_AND_MEAN // 排除不常用头,缩短编译时间 #include <windows.h> #include <tchar.h> #pragma comment(lib, "user32.lib") // 窗口与消息 #pragma comment(lib, "gdi32.lib") // GDI 绘图 #pragma comment(lib, "advapi32.lib") // 注册表 #pragma comment(lib, "kernel32.lib") // 文件、线程WIN32_LEAN_AND_MEAN的作用是砍掉winsock.h这类不常用头,避免和后面引的网络头冲突;#pragma comment让链接器自动找库,工程属性里就不用再逐个填。字符集建议统一选 Unicode,源码里字符串用_T("...")或L"...",别一半 ANSI 一半宽字符,否则LNK2019和乱码会一起来找你。
3. 跑通第一个窗口:从 WinMain 到消息循环的完整链路
3.1 环境准备:工具链与工程打开
先确认本机装了带 MSVC 的 C++ 工具链和对应版本的 Windows SDK。打开.sln后,配置选Debug、平台选x64,直接生成。习惯命令行的可以在开发者命令提示符里跑:
msbuild 01_WindowBase\WindowBase.sln /p:Configuration=Debug /p:Platform=x64/p:Configuration指定 Debug 或 Release,/p:Platform指定 x64 或 Win32。第一次生成如果报找不到 SDK,多半是工程属性里的“Windows SDK 版本”写死了某个旧版本,改成“继承自父级”或本机已装版本即可。生成成功后 exe 落在x64\Debug下,双击能弹窗就说明环境通了。
3.2 WinMain 入口与窗口类注册
Win32 程序的入口不是main,是WinMain或宽字符版的wWinMain。这套源码用_tWinMain宏兼容两种字符集,核心流程分三步:注册窗口类、创建窗口、跑消息循环。
int APIENTRY _tWinMain(HINSTANCE hInst, HINSTANCE hPrev, LPTSTR lpCmd, int nShow) { // 1. 注册窗口类:决定图标、光标、背景和窗口过程 WNDCLASSEX wc = { sizeof(WNDCLASSEX) }; wc.style = CS_HREDRAW | CS_VREDRAW; // 宽高变化时重绘 wc.lpfnWndProc = MainWndProc; // 消息最终都进这里 wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); wc.lpszClassName = _T("MainWindowClass"); if (!RegisterClassEx(&wc)) return -1; // 2. 创建窗口 HWND hWnd = CreateWindowEx(0, wc.lpszClassName, _T("Win32 示例窗口"), WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, nullptr, nullptr, hInst, nullptr); if (!hWnd) return -1; ShowWindow(hWnd, nShow); UpdateWindow(hWnd); // 立刻触发一次 WM_PAINT // 3. 消息循环 MSG msg; while (GetMessage(&msg, nullptr, 0, 0) > 0) { TranslateMessage(&msg); // 把按键消息翻译成字符消息 DispatchMessage(&msg); // 分发给窗口过程 } return (int)msg.wParam; }APIENTRY是调用约定宏,等价于__stdcall,Win32 回调函数必须带。WNDCLASSEX第一个字段必须填结构体大小,否则注册失败。CS_HREDRAW | CS_VREDRAW让窗口在宽高变化时整体重绘,画图示例里少了它会出现拉伸残影。hbrBackground用COLOR_WINDOW + 1取系统色,比自建画刷省事。CreateWindowEx的第四个参数是样式,WS_OVERLAPPEDWINDOW是带边框、标题栏、最大最小化的标准窗口。GetMessage返回 0 表示收到WM_QUIT,返回 -1 表示出错,所以循环条件写> 0而不是!= 0,这是很多人踩过的坑。
3.3 消息循环与窗口过程的分工
窗口过程是消息的最终落点,所有WM_*消息都从DispatchMessage转进来。没处理的消息必须交给DefWindowProc,否则窗口拖动、关闭、最小化全会失效。
LRESULT CALLBACK MainWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // 控件、定时器、GDI 资源在这里初始化 SetTimer(hWnd, 1, 100, nullptr); // 100ms 定时器 return 0; case WM_TIMER: // 只做轻量状态更新,标记需要重绘 InvalidateRect(hWnd, nullptr, FALSE); return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 真正的绘制逻辑放在 4.1 的双缓冲里 EndPaint(hWnd, &ps); } return 0; case WM_DESTROY: KillTimer(hWnd, 1); PostQuitMessage(0); // 让 GetMessage 返回 0,退出消息循环 return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); // 兜底处理 }WM_CREATE是窗口创建后、显示前的时机,适合建控件和定时器;WM_TIMER里只改状态、用InvalidateRect标记脏区,真正的绘制留给WM_PAINT,这样重绘节奏可控。WM_DESTROY里先KillTimer再PostQuitMessage,顺序别反,否则定时器可能还在往已销毁的窗口发消息。DefWindowProc是后悔药,忘了它,窗口拖不动、关不掉,排查起来像黑匣子。
3.4 编译与运行:报错定位清单
第一次生成最常见的报错集中在这几类:
| 报错 | 原因 | 处理 |
|---|---|---|
LNK2019: unresolved external symbol _WinMain@16 | 工程类型选成控制台 | 链接器“系统”里子系统改成 Windows |
LNK2019: unresolved external symbol __imp__CreateWindowExW | 没链接user32.lib | 加#pragma comment(lib,"user32.lib") |
C2664: 无法将参数从 const char* 转成 LPCWSTR | 字符集 Unicode 但用了窄字符串 | 字符串前加L或统一用_T() |
| 窗口一闪而过 | 没跑消息循环或GetMessage条件写错 | 检查while (GetMessage(...) > 0) |
| 点了关闭但进程还在 | WM_DESTROY里没PostQuitMessage | 补上退出消息 |
跑通这一步之后,后面所有示例都是在这个骨架上加模块,窗口过程越写越长,但结构不变。
4. 核心模块拆解:GDI 绘图、资源脚本与后台线程
4.1 WM_PAINT 与双缓冲:把闪烁压下去
直接往屏幕 DC 上画,窗口一拖动或数据一刷新就闪。这套源码里绘图示例统一用内存 DC 双缓冲:先在兼容位图上画完,再一次性BitBlt到屏幕。
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); RECT rc; GetClientRect(hWnd, &rc); // 1. 建内存 DC 和兼容位图 HDC hMem = CreateCompatibleDC(hdc); HBITMAP hBmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); HBITMAP hOld = (HBITMAP)SelectObject(hMem, hBmp); // 2. 全部画到内存 DC FillRect(hMem, &rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); HPEN hPen = CreatePen(PS_SOLID, 2, RGB(200, 30, 30)); HPEN hOldPen = (HPEN)SelectObject(hMem, hPen); MoveToEx(hMem, 0, 0, nullptr); LineTo(hMem, rc.right, rc.bottom); SelectObject(hMem, hOldPen); DeleteObject(hPen); // 3. 一次性拷到屏幕 BitBlt(hdc, 0, 0, rc.right, rc.bottom, hMem, 0, 0, SRCCOPY); // 4. 恢复并释放 SelectObject(hMem, hOld); DeleteObject(hBmp); DeleteDC(hMem); EndPaint(hWnd, &ps); } return 0;CreateCompatibleDC(hdc)建的是与屏幕兼容的内存 DC,CreateCompatibleBitmap的宽度高度必须和客户区一致,传 0 会得到 1×1 的位图。SelectObject的返回值一定要存,否则恢复时找不到原对象。BitBlt的SRCCOPY是直接拷贝,最后SelectObject(hMem, hOld)把原位图选回去,再DeleteObject(hBmp),顺序反了位图删不掉。这套流程看着啰嗦,但它是 Win32 绘图的标配,少了任何一步都会在某个分辨率下出问题。
4.2 GDI 对象生命周期:句柄泄漏比内存泄漏更致命
每个进程的 GDI 句柄配额是有限的(默认一万左右),CreatePen、CreateBrush、CreateFont用完不删,跑到一定次数后创建返回NULL,界面直接不刷新。这类问题比内存泄漏更隐蔽,任务管理器里看“GDI 对象”列才知道。
// Common/GdiHandle.h —— 用 RAII 管 GDI 句柄,避免漏删 class GdiObject { public: explicit GdiObject(HGDIOBJ h = nullptr) : m_h(h) {} ~GdiObject() { if (m_h) DeleteObject(m_h); } GdiObject(const GdiObject&) = delete; // 禁止拷贝,防重复删 GdiObject& operator=(const GdiObject&) = delete; HGDIOBJ get() const { return m_h; } void reset(HGDIOBJ h) { if (m_h) DeleteObject(m_h); m_h = h; } private: HGDIOBJ m_h; };用法就是GdiObject pen(CreatePen(...));,出了作用域自动删,拷贝构造被禁掉,避免两个对象删同一个句柄。注意DeleteObject不能删正在被 DC 选中的对象,选中的必须先SelectObject换出来。我一般会在调试版里加个句柄计数宏,配合任务管理器的“GDI 对象”列,跑十分钟看数字是否持续上涨,涨了就是漏了。
4.3 资源脚本 .rc 与控件消息
控件和对话框不写在 C++ 里,而是放在.rc资源脚本里,编译时由资源编译器转成二进制嵌进 exe。对话框布局、按钮 ID、静态文本都在这定义。
IDD_MAIN_DIALOG DIALOGEX 0, 0, 320, 200 STYLE DS_MODALFRAME | WS_POPUP | WS_CAPTION CAPTION "示例对话框" BEGIN PUSHBUTTON "开始", IDC_BTN_START, 10, 10, 60, 24 LTEXT "状态:就绪", IDC_STATIC_STATUS, 10, 50, 200, 12 ENDDIALOGEX后面的四个数是左上角坐标和宽高,单位是对话框单位不是像素。PUSHBUTTON的第二个参数是控件 ID,C++ 里靠这个 ID 区分是哪个按钮被点。点击消息走WM_COMMAND,高两位是通知码,低两位是控件 ID。
case WM_COMMAND: { int ctrlId = LOWORD(wParam); // 控件 ID int notify = HIWORD(wParam); // 通知码,按钮是 BN_CLICKED if (ctrlId == IDC_BTN_START && notify == BN_CLICKED) { SetDlgItemText(hWnd, IDC_STATIC_STATUS, _T("状态:运行中")); } return 0; }LOWORD/HIWORD拆wParam是 Win32 控件消息的固定套路,别用wParam == IDC_BTN_START直接比,通知码非零时会漏判。SetDlgItemText按控件 ID 改文本,比先GetDlgItem再SetWindowText少一步。
4.4 后台线程与文件 I/O:别在消息循环里搬大数据
消息循环是单线程的,里面做任何耗时操作,窗口就会假死。读大文件、等串口回包这类活必须丢到后台线程,读完用PostMessage把结果送回主线程。
struct ReadTask { HWND hWnd; std::wstring path; }; DWORD WINAPI ReadThreadProc(LPVOID param) { auto* task = static_cast<ReadTask*>(param); HANDLE hFile = CreateFileW(task->path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile != INVALID_HANDLE_VALUE) { char buf[4096]; DWORD read = 0; while (ReadFile(hFile, buf, sizeof(buf), &read, nullptr) && read > 0) { // 不能直接碰 UI,new 一块数据丢回主线程 auto* payload = new std::string(buf, read); PostMessage(task->hWnd, WM_APP + 1, 0, (LPARAM)payload); } CloseHandle(hFile); } delete task; return 0; }CreateFileW的第三个参数FILE_SHARE_READ允许别的进程同时读,OPEN_EXISTING表示只打开已存在的文件。PostMessage是异步投递,投完线程继续跑,不阻塞;SendMessage是同步等待,在工作线程里用容易死锁。WM_APP + 1是自定义消息的安全起点,WM_USER以下留给系统。接收端处理完记得delete payload,谁 new 谁 delete 在这里要约定清楚,不然就是内存泄漏。
5. 避坑与排查:Win32 老代码最容易翻车的五处
5.1 字符集不一致:LNK2019 与乱码同时出现
现象:编译报unresolved external symbol _WinMain@16,或者窗口标题显示成方块乱码。原因:工程字符集设成 Unicode,但代码里混用了char*字符串和MessageBoxA这类 ANSI 接口,链接器找不到对应符号。解决:统一用_T("...")或L"...",接口用MessageBox而不是MessageBoxA;旧代码迁移时先全选改成 Unicode,再逐个改字符串字面量,别两边混着来。
5.2 消息循环里做阻塞:界面假死、定时器不准
现象:点按钮后窗口拖不动,定时器该 100ms 触发一次却隔几秒才来一次。原因:在WM_COMMAND或WM_TIMER里直接Sleep、ReadFile同步读大文件、WaitForSingleObject等锁。解决:耗时操作丢到工作线程,主线程只处理消息;确实要等,用MsgWaitForMultipleObjects边等边泵消息,别让消息队列堵死。
5.3 GDI 句柄泄漏:画着画着就不刷新了
现象:程序跑十几分钟后界面停止刷新,CreatePen返回NULL,任务管理器里 GDI 对象数持续上涨。原因:WM_PAINT里每次创建画笔、画刷、字体,用完没DeleteObject,或者删了正被 DC 选中的对象。解决:用 4.2 的 RAII 包装,创建即托管;调试时打开任务管理器“GDI 对象”列观察,正常应该稳定在一个区间,持续上涨就是漏了。
5.4 高 DPI 下窗口与坐标错位
现象:在 125% 或 150% 缩放的显示器上,窗口比预期小一圈,鼠标点按钮对不上位置。原因:程序没声明 DPI 感知,系统做了位图拉伸。解决:在工程里加清单文件声明PerMonitorV2,坐标用GetDpiForWindow换算,字体用CreateFontIndirect按 DPI 算高度,别写死像素值。
5.5 DLL 动态加载路径不对:GetProcAddress 返回 NULL
现象:LoadLibrary成功,GetProcAddress却返回NULL,或者加载直接失败。原因:DLL 不在 exe 目录也不在系统搜索路径里,或者导出函数名被 C++ 名称修饰过。解决:用绝对路径加载,导出函数加extern "C"和__declspec(dllexport),加载后用GetLastError看错误码,126 是找不到模块,127 是找不到函数。
6. 二次开发:把示例改成自己的骨架工程
这套源码的直接用法是“查接口”,但更有价值的用法是抽成一个自己的 Win32 骨架。我的做法是:保留Common目录,把Win32Common.h、GdiHandle.h、日志宏抽出来,新建一个空工程只放WinMain和窗口过程,后面每加一个功能就往骨架里填一块。窗口过程写长了会变成几百行的switch,可以在Common里加一张简单的消息映射表,把WM_*和成员函数指针关联起来,结构和 MFC 的消息映射类似,但不引框架。
// Common/MessageMap.h —— 轻量消息映射,避免窗口过程无限膨胀 struct MsgEntry { UINT msg; LRESULT (CAppWindow::*handler)(WPARAM, LPARAM); }; static const MsgEntry kMsgMap[] = { { WM_CREATE, &CAppWindow::OnCreate }, { WM_PAINT, &CAppWindow::OnPaint }, { WM_TIMER, &CAppWindow::OnTimer }, { WM_DESTROY, &CAppWindow::OnDestroy }, }; LRESULT CAppWindow::WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { for (const auto& e : kMsgMap) if (e.msg == msg) return (this->*e.handler)(wParam, lParam); return DefWindowProc(hWnd, msg, wParam, lParam); // 没映射的兜底 }MsgEntry把消息 ID 和成员函数指针绑在一起,WndProc里线性查找,消息不多时性能完全够用。this指针通过CreateWindowEx的最后一个参数传进WM_CREATE,在WM_NCCREATE里用SetWindowLongPtr存起来,这套手法在 Win32 里很常见。验证改造是否成功,我会跑三个检查:任务管理器里 GDI 对象数是否稳定、窗口在 100% 和 150% 缩放下坐标是否对得上、后台线程跑满十分钟主线程是否还能拖动窗口。
还得提醒一句,示例代码是“能跑”级别,不是“能上线”级别。文件读写没做错误分支、线程没做退出同步、DLL 加载没做路径校验,这些在二次开发时都得补齐。我吃过一次亏,直接把示例里的ReadThreadProc抄进项目,线程还在读文件主窗口就销毁了,PostMessage投到一个无效句柄上,进程直接挂掉。从那以后我每接一个 Win32 老项目,第一件事就是打开任务管理器看 GDI 对象数,再跑一遍字符集和 DPI 清单,最后才动业务代码。希望帮到你。
本文还有配套的精品资源,点击获取