简介《精通Windows API——函数、接口、编程实例》配套源码包面向已具备C基础、希望深入Win32桌面开发与系统级编程的读者围绕窗口管理、消息机制、内存与文件操作、COM接口、动态库加载及多线程等核心主题提供156个可对照实践的示例工程。压缩包共1789个文件约24.29MB包含C/C源码、头文件、vcproj与sln工程、rc资源脚本、lib/dll库文件以及编译产生的pdb、obj、ilk、idb等中间与调试文件另有htm说明和manifest清单便于还原编译环境、跟踪调试信息。已有376人学习适合按章节拆解示例、验证API调用流程并借助现成工程理解Win32项目组织与构建配置提升调试与问题定位能力。1. 从一次窗口无响应说起这套 Win32 C 源码能给你什么接手一个老工具点下加载按钮后界面转圈三分钟不动日志一行没有任务管理器里线程挂在WaitForSingleObject上。这种时候没有 Win32 C 的底子排查基本靠猜。这套源码干的就是去掉猜测把窗口、消息、GDI、文件、进程线程、动态库这些系统接口拆成一个个能单独编译运行的小例子每个例子对着一个函数或一组函数跑完就知道参数怎么填、返回什么、失败时错在哪。它不适合想直接复制粘贴出成品的人适合已经会写 C但一碰系统层就发怵、想按图索骥把 API 语义啃下来的开发者。下面按工程骨架、核心模块、编译运行、踩坑排查、二次开发的顺序往下拆。2. 工程骨架从 WinMain 到消息循环Win32 C 程序的骨架长什么样刚接触 Win32 的人最容易卡在一个认知落差上C 控制台程序从main开始一行行往下走Win32 图形程序从WinMain开始但进去之后不是“往下走”而是注册一个回调函数交给系统反复调用。这个回调就是WndProc整个程序的行为逻辑都堆在这里面。骨架看不透后面所有示例都会读成一团乱麻。2.1 示例怎么组织的一个函数一个可编译单元这类源码包常见做法是按功能分目录而不是按章节排例如basic/放窗口与消息gdi/放绘图file/放文件与注册表process/放进程线程dll/放动态库。每个.cpp独立成一个可编译单元带自己的wWinMain编译出来就是一个独立的 exe不需要依赖别的示例。这种组织方式看着原始但对学习特别友好想验证CreateFileW的共享标志就把file/那个例子单独编出来改两行不用在一个大工程里翻半天入口。公共部分通常抽一到两个头文件比如common.h里放错误日志函数、字符集相关宏、简单的字符串转换避免每个示例都重写一遍。提示如果目录里出现同时带_ansi和_unicode后缀的两个文件多半是同一个示例的两种字符集版本先编 unicode 版本能省掉后面一半的乱码问题。判断一个示例能不能单独跑看两点有没有自己的入口函数有没有包含windows.h之外的私有依赖。两点都满足就可以直接丢进命令行编译。2.2 注册窗口类与创建窗口WNDCLASSEX 里每个字段都在管什么窗口不是new出来的是向系统“注册一个类”再“按类创建实例”。这一步是骨架里最容易漏的地方尤其cbSize这个字段漏赋值在 Debug 下有时还能跑Release 下直接创建失败属于典型的玄学现场。#include windows.h LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int nCmdShow) { WNDCLASSEXW wc { sizeof(wc) }; // 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); // 系统色不用自己 DeleteObject wc.lpszClassName LDemoWindowClass; // 类名创建窗口时要对得上 if (!RegisterClassExW(wc)) return 1; // 失败通常是类名重复或 cbSize 错了 HWND hWnd CreateWindowExW( 0, LDemoWindowClass, LDemo, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, nullptr, nullptr, hInst, nullptr); if (!hWnd) return 1; // 返回 NULL 时配合 GetLastError 看原因 ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); // 强制立刻发一次 WM_PAINT MSG msg; while (GetMessageW(msg, nullptr, 0, 0) 0) { // 0 表示收到 WM_QUIT-1 表示出错 TranslateMessage(msg); // 把按键消息翻译成字符消息 DispatchMessageW(msg); // 投递给 WndProc } return (int)msg.wParam; }几个参数值得单独拎出来说。wc.hbrBackground用COLOR_WINDOW 1这种系统色索引时画刷归系统所有绝对不能DeleteObject只有自己CreateSolidBrush出来的才需要删。CreateWindowExW的第一个参数是扩展样式WS_EX_CLIENTEDGE、WS_EX_TOPMOST这类都填这儿普通窗口填 0 就行。UpdateWindow不能省不然窗口第一次显示时可能是一片空白得等你拖动一下才刷出来。GetMessageW的返回值判断是个高频翻车点写成while (GetMessage(...))看着没问题但函数出错返回 -1 时是“非零”循环会继续然后拿着无效的MSG往下跑表现就是程序莫名奇妙卡死或崩在DispatchMessage里。2.3 WndProc 的分发逻辑与返回值约定WndProc的签名固定四个参数分别是窗口句柄、消息 ID、两个消息相关的附加参数。返回值语义是“我处理结果如何”返回 0 通常表示已处理交给DefWindowProcW表示“我不管系统你自己看着办”。LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: OnPaint(hWnd); // 绘制逻辑单独抽函数别在 switch 里堆 return 0; // 已处理不再往下传 case WM_SIZE: OnResize(LOWORD(lParam), HIWORD(lParam)); // 宽高从 lParam 拆 return 0; case WM_COMMAND: // LOWORD(wParam) 是控件或菜单 IDHIWORD(wParam) 是通知码 OnCommand(LOWORD(wParam), HIWORD(wParam), (HWND)lParam); return 0; case WM_DESTROY: PostQuitMessage(0); // 投递 WM_QUIT让消息循环退出 return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); }WM_DESTROY和WM_CLOSE的区别要分清WM_CLOSE是用户点右上角叉默认行为是销毁窗口WM_DESTROY是窗口已经在销毁了此时该做清理并向消息循环发退出信号。想在关闭前弹个“确定退出吗”的确认框拦WM_CLOSE不是拦WM_DESTROY。WM_COMMAND里的lParam是控件句柄wParam高低位拆开分别是控件 ID 和通知码按钮点击时通知码是BN_CLICKED。这套编码方式现在看很不现代但它是 Win32 所有控件交互的统一入口读懂它菜单、按钮、加速键就都能对上号了。2.4 字符集UNICODE 宏与 W 后缀到底怎么选源码里同一个函数常出现CreateFileA和CreateFileW两个版本CreateFile这个不带后缀的名字是宏编译期根据有没有定义UNICODE展开成其中一个。工程里同时定义UNICODE和_UNICODE前者管 Windows API后者管 C 运行库只定义一个就会出现一半宽字符一半窄字符的诡异状态。我的习惯是新代码一律走宽字符字符串字面量统一加L前缀参数类型用wchar_t*或std::wstring。跟外部库交互需要char*时在边界处做一次WideCharToMultiByte转换别让两种类型在业务代码里混着流。这套源码里的示例如果两种版本都有先看_unicode那份把宽字符的路子走顺窄字符版本当对照看就行。TCHAR、_T(...)这套兼容宏是老工程的历史包袱新写代码不太建议继续用读的时候知道它会展开成什么就够了。3. 核心模块拆解句柄、GDI 绘制与文件访问的实例代码骨架跑通之后剩下的内容基本是“系统资源怎么拿、怎么用、怎么还”。Win32 的资源和 C 的 RAII 思路完全不同全靠手动管理一套示例看下来真正能带走的能力就是养成“拿了就还”的肌肉记忆。3.1 句柄类型与释放规则谁创建谁释放句柄本质是个不透明的整数你不需要知道它内部长什么样但必须知道它归谁管、用什么函数还回去。这套源码里出现的句柄类型大致就这几类句柄类型典型来源释放方式忘记释放的后果HWNDCreateWindowExDestroyWindow窗口一直存在进程退出前不回收HDCGetDC/BeginPaintReleaseDC/EndPaintGDI 资源耗尽界面开始画不出来HBITMAPHPENHBRUSHCreatePen/CreateCompatibleBitmapDeleteObject进程 GDI 句柄上限一万越用越卡HANDLECreateFile/CreateThreadCloseHandle文件被占用删不掉句柄数持续上涨HMODULELoadLibraryFreeLibraryDLL 引用计数不归零卸不掉有个反直觉的点SelectObject把画笔选进 DC 之后这支笔就处于“正在使用”状态此时DeleteObject会直接失败返回 FALSE而且不报错。必须先把旧的选回去再删。这是 GDI 泄漏最常见的成因。3.2 GDI 绘制从 BeginPaint 到双缓冲直接在WM_PAINT里往窗口 DC 上画窗口一拖动、一遮挡就闪成一片。示例里通常会给一个双缓冲版本先在内存位图上画完再一次性贴到屏幕上。static void OnPaint(HWND hWnd) { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 必须与 EndPaint 配对别用 GetDC 代替 RECT rc; GetClientRect(hWnd, rc); int w rc.right - rc.left, h rc.bottom - rc.top; HDC memDC CreateCompatibleDC(hdc); HBITMAP bmp CreateCompatibleBitmap(hdc, w, h); // 要兼容屏幕 DC不是 memDC HBITMAP oldBmp (HBITMAP)SelectObject(memDC, bmp); FillRect(memDC, rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); HPEN pen CreatePen(PS_SOLID, 2, RGB(200, 0, 0)); HPEN oldPen (HPEN)SelectObject(memDC, pen); MoveToEx(memDC, 0, 0, nullptr); LineTo(memDC, w, h); SelectObject(memDC, oldPen); // 先还原pen 才删得掉 DeleteObject(pen); BitBlt(hdc, 0, 0, w, h, memDC, 0, 0, SRCCOPY); // 一次性贴屏 SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hWnd, ps); }CreateCompatibleBitmap(hdc, ...)的第一个参数必须是屏幕 DC传memDC会得到一张单色位图画出来全是黑白这个坑很隐蔽因为代码不报错只是颜色不对。BitBlt的最后一个参数SRCCOPY是光栅操作码直接覆盖要做半透明叠加就得换成AlphaBlend但那需要目标窗口支持分层。窗口尺寸变化时离屏位图要跟着重建位置一般放在WM_SIZE里把旧的DeleteObject掉再建新的。如果只在WM_PAINT里建每次重绘都创建一张新位图很快就泄漏。3.3 文件读写与 GetLastError错误码必须当场取Win32 的错误处理套路统一函数返回失败标识紧接着调GetLastError()。关键是“紧接着”中间只要插进任何一次别的 API 调用错误码就可能被覆盖这是排查问题时的黑匣子级细节。bool ReadWholeFile(const wchar_t* path, std::string out, DWORD err) { HANDLE h CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (h INVALID_HANDLE_VALUE) { err GetLastError(); return false; } LARGE_INTEGER size{}; if (!GetFileSizeEx(h, size)) { err GetLastError(); CloseHandle(h); return false; } out.resize((size_t)size.QuadPart); DWORD read 0; BOOL ok ReadFile(h, out.data(), (DWORD)out.size(), read, nullptr); err ok ? ERROR_SUCCESS : GetLastError(); // 立刻取别等 CloseHandle 之后 CloseHandle(h); return ok read out.size(); }几个参数值得说清楚。dwShareMode填FILE_SHARE_READ表示别人还能以只读打开这个文件填 0 表示独占此时如果别的程序已经打开它你这儿会拿到ERROR_SHARING_VIOLATION这个错误码在排查“文件明明存在却打不开”时几乎必现。dwCreationDisposition用OPEN_EXISTING是“有就开、没有就失败”想“没有就创建”换成OPEN_ALWAYS。ReadFile的第四個参数是实际读到的字节数可能小于请求的字节数尤其在读管道、读网络流的时候。文件读取一般能一次读完但严谨的写法仍要比较一下示例里如果直接忽略这个值说明它假设了同步文件和本地磁盘。错误码转文字别自己查表用FormatMessageW直接转支持多语言的系统上会给出本地化描述。3.4 动态库导出与运行时加载dll/目录下的例子一般演示两件事怎么把函数导出去怎么在运行时按名字找回来。导出端加extern C是为了关掉 C 的名字修饰否则GetProcAddress里要填一大串带的符号名非常难维护。// 导出端 mathlib.cpp extern C __declspec(dllexport) int __stdcall Add(int a, int b) { return a b; } // 调用端 HMODULE h LoadLibraryW(Lmathlib.dll); if (h) { using AddFn int(__stdcall*)(int, int); // 调用约定必须与导出端一致 auto fn (AddFn)GetProcAddress(h, Add); if (fn) int r fn(1, 2); FreeLibrary(h); }调用约定不一致在 x86 上会让栈失衡表现为函数返回后程序直接崩而且崩的位置离出错点很远x64 上只有一种调用约定这个问题自动消失。所以这类问题常常“32 位编出来必崩64 位跑得好好的”排查时先看位数。4. 编译与运行把示例逐个跑起来的三条路径源码包给的多半是散的.cpp文件没有现成工程文件也不一定带构建脚本。这时候别急着一个一个手动点先确定工具链再决定用命令行还是构建系统批量编。4.1 工具链选择cl.exe 与 MinGW-w64 的差异两条路都能走通。MSVC 的cl.exe对 Windows SDK 的适配最顺windows.h里的新结构体、新宏基本不缺MinGW-w64 的g免费、轻量但在一些较新的 API 声明上可能滞后遇到某个结构体找不到定义多半是 SDK 版本问题而不是代码问题。选择建议很直接主要做 Windows 桌面开发用cl.exe只是想把示例跑起来验证概念MinGW-w64 装起来更快。两边都可以在同一台机器上共存用不同的命令行环境切换。4.2 命令行逐个编译与链接库对照用cl.exe编一个窗口程序命令长这样cl /nologo /EHsc /W4 /DUNICODE /D_UNICODE demo.cpp ^ /link /SUBSYSTEM:WINDOWS user32.lib gdi32.lib kernel32.lib comctl32.lib/EHsc开标准 C 异常处理/W4把警告开到较高级别/DUNICODE /D_UNICODE定义字符集宏。/SUBSYSTEM:WINDOWS告诉链接器这是个 GUI 程序入口是wWinMain而不是main不加这个参数链接器会去找main然后报一个“找不到入口点”的错。需要链接哪些库看代码里用了哪些 API用到的 API需要链接的库窗口、消息、菜单user32.lib绘图、字体、位图gdi32.lib文件、进程、线程、注册表kernel32.lib多数情况自动链通用控件、任务对话框comctl32.lib外壳函数、文件对话框shell32.libcomdlg32.libCOM 初始化与接口ole32.lib链接错误LNK2019: unresolved external symbol先查这个表九成是少了个.lib。MinGW-w64 的等价命令g -municode -mwindows demo.cpp -o demo.exe -luser32 -lgdi32 -lcomctl32-municode对应宽字符入口wWinMain-mwindows对应 GUI 子系统。漏掉-municode会报undefined reference to WinMain这个报错信息很误导人实际原因是入口函数签名对不上。4.3 CMake 统一构建一次配置批量编译示例一多一条条敲命令就不现实了。写一个顶层CMakeLists.txt把每个.cpp扫成一个 targetcmake_minimum_required(VERSION 3.15) project(win32_demos CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) file(GLOB DEMO_SOURCES CONFIGURE_DEPENDS basic/*.cpp gdi/*.cpp file/*.cpp) foreach(src ${DEMO_SOURCES}) get_filename_component(name ${src} NAME_WE) add_executable(${name} WIN32 ${src}) target_compile_definitions(${name} PRIVATE UNICODE _UNICODE) target_link_libraries(${name} PRIVATE user32 gdi32 comctl32 shell32 ole32) endforeach()add_executable里的WIN32关键字等价于设置WIN32_EXECUTABLE属性CMake 会自动加上/SUBSYSTEM:WINDOWS。CONFIGURE_DEPENDS让 CMake 每次构建时重新扫目录新增.cpp不用手动重跑配置。注意file(GLOB ...)在正式项目里一般不建议用因为依赖关系不明确、增量构建容易漏文件但用来批量编译一堆互不相关的教学示例它是最省事的方式。4.4 调试OutputDebugString 与断点配合GUI 程序没有控制台printf看不到输出。最轻量的办法是OutputDebugStringW输出会进调试器的“输出”窗口不挂调试器时可以用轻量的日志查看工具接。void LogLastError(const wchar_t* where) { DWORD e GetLastError(); wchar_t* buf nullptr; FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, e, 0, (LPWSTR)buf, 0, nullptr); OutputDebugStringW(where); OutputDebugStringW(L: ); OutputDebugStringW(buf ? buf : L(unknown)); OutputDebugStringW(L\n); if (buf) LocalFree(buf); // FORMAT_MESSAGE_ALLOCATE_BUFFER 分配的要用 LocalFree }FORMAT_MESSAGE_ALLOCATE_BUFFER让函数自己分配缓冲区所以传的是指针的地址用完必须LocalFree不能delete。FORMAT_MESSAGE_IGNORE_INSERTS是防止错误文本里的%1占位符被当成格式化参数处理。断点方面WndProc里断在switch上可以看每条消息的到达顺序。窗口创建过程中的WM_CREATE、WM_SIZE、WM_PAINT顺序是固定的看到不符合预期的顺序基本就能定位到是哪一步的返回值写错了。5. 踩坑排查Win32 示例跑不起来时先看这几处这类代码的血泪经验是编译过了不代表能跑跑起来了不代表行为对。下面几条是我在照着示例改代码时反复撞上的每条都按“现象—原因—解决”记下来遇到了直接对号入座。5.1 链接报 LNK2019 找不到入口点或符号现象编译阶段干净链接阶段报unresolved external symbol WinMain或者某个 API 的名字。原因入口点和子系统不匹配或者缺库。写了wWinMain但没加/SUBSYSTEM:WINDOWS链接器按控制台程序去找main反过来写了WinMain窄字符却定义了UNICODE预处理后入口函数名变了同样找不到。解决先看用的是WinMain还是wWinMain再决定要不要/SUBSYSTEM:WINDOWS和/DUNICODE。符号找不到就查第 4 章那张库对照表GetProcAddress、RegOpenKeyEx这类都对应着固定库。5.2 CreateWindowEx 返回 NULL什么都显示不出来现象程序启动后没有任何窗口进程却活着或者直接退出。原因九成是窗口类没注册成功或者类名对不上。WNDCLASSEX的cbSize漏赋值会让RegisterClassEx静默失败wc.lpszClassName和CreateWindowEx里传的字符串差一个字符也会创建失败。解决每个失败分支都加上LogLastErrorRegisterClassExW失败通常是ERROR_CLASS_ALREADY_EXISTS类名重复注册或者ERROR_INVALID_PARAMETER结构体字段有问题。把所有字符串字面量统一加L前缀能排除掉字符集不一致导致的类名匹配失败。5.3 界面上中文全是问号或方块现象按钮、标题栏上的中文显示成???或者一串方块。原因工程用窄字符 APICreateWindowExA传了 UTF-8 或 GBK 的字节串系统按当前代码页解释对不上就变成问号。另一种情况是用了宽字符 API但字体选的是不支持中文的字体。解决所有字符串统一走宽字符字面量加LAPI 用W后缀版本从配置文件、网络读进来的 UTF-8 文本进 UI 前用MultiByteToWideChar(CP_UTF8, ...)转一次。字体用CreateFontW时字符集参数填DEFAULT_CHARSET让系统自己挑。5.4 界面用久了越来越卡最后画不出来现象程序跑几十分钟后界面刷新变慢任务管理器里 GDI 对象数一直涨。原因GDI 对象泄漏。典型是CreatePen之后SelectObject进 DC用完直接DeleteObject因为还在 DC 里选着删除失败但不报错或者GetDC拿了 DC 却没ReleaseDC。解决养成“先 SelectObject 还原旧对象再 DeleteObject”的固定顺序选中的旧对象句柄必须保存下来。GetDC/ReleaseDC成对出现能在WM_PAINT里解决的绘制不要用GetDC。任务管理器的“GDI 对象”列可以直接观察正常程序这个数字应该稳定在一个区间内。5.5 Debug 下正常Release 下崩溃现象同一份代码Debug 配置跑得好好的切到 Release 直接崩或者行为完全不对。原因未初始化的局部变量。Debug 下编译器会把栈填成特定值常见是0xCC某些“碰巧能用”的代码就蒙对了Release 下栈上是上次留下的垃圾数据指针、长度、结构体字段全都是随机值。另一个常见原因是结构体的cbSize字段只在某些构造方式下被赋值。解决所有 Win32 结构体一律用 { sizeof(x) }或ZeroMemory初始化别指望栈上的默认值。变量声明时就给初值尤其是DWORD、LONG这类看不出“未初始化”的整型。Release 下开/W4甚至/Wall编译器对“可能未初始化”的警告命中率相当高。6. 二次开发从跑通示例到做出自己的小工具示例跑通之后真正有价值的一步是挑一个最接近你需求的例子把它改成能用的东西。我的做法是先别急着加功能把原示例的WndProc拆成“消息分发 业务处理”两层WndProc只负责把消息转给对应的成员函数业务逻辑全放到一个自己写的类里。改完之后窗口行为一模一样但后续加控件、加线程、加配置都只是给这个类加方法。具体的改造路径一般是这样先把WinMain和WndProc收进一个MainWindow类hWnd存成成员变量再把绘制、命令处理、尺寸变化各自拆成方法最后把资源创建放在WM_CREATE、释放放在WM_DESTROY形成对称。这套结构一旦立起来后面不管加多线程读文件还是加托盘图标都不会把代码搅成一锅粥。改动位置和验证方式可以对着这张表走想改的地方动哪个函数怎么验证改对了窗口初始大小、样式CreateWindowExW的样式与尺寸参数启动后看窗口边框、能否缩放自定义绘制WM_PAINT里的绘制函数拖动窗口、最小化再还原看是否闪烁加一个按钮WM_CREATE里CreateWindowExW(LBUTTON, ...)点击后WM_COMMAND是否命中对应 ID后台读文件新建工作线程 PostMessage回主线程读大文件时界面不卡进度能刷新退出前清理WM_DESTROY任务管理器里句柄数、GDI 对象数是否归零后台线程那块要特别注意工作线程绝对不能直接碰窗口控件所有 UI 更新都得通过PostMessage把结果投回主线程让WndProc去改。直接跨线程调SetWindowText在多数情况下“看起来能用”但那是个定时炸弹压力一上来就随机崩崩的位置还跟真正的错误点没关系。验证改动是否干净我一般看两个指标程序连续开关二十次任务管理器里的句柄数和 GDI 对象数回到启动时的水平把窗口反复拉伸到极限尺寸不闪、不黑、不错位。这两条过不了说明还有资源没还回去或者绘制路径有问题功能加得再多也是空中楼阁。踩过最贵的一次坑是省了SelectObject的还原测试阶段完全正常上线跑了两天开始有用户反馈界面变白要重启——那之后我每次写完一段 GDI 代码都强制走一遍“创建、选入、还原、删除”的顺序检查缺一步就不提交。希望这些能帮到你少走一遍我走过的弯路。本文还有配套的精品资源点击获取