ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C语言Win32 API实战:从零打造Windows窗口程序

2026/9/18 3:32:33 拓冰建站 浏览量
C语言Win32 API实战:从零打造Windows窗口程序 大概是很多年前了我第一次用 VC6 写 Win32 窗口当时还闹出过把 WinMain 写成 main 的笑话。后来在 Windows 上做各种小工具从桌面悬浮钟、窗口置顶器、串口调试器到内部分析用的进程观察器基本都用这套 API。每次换语言最后还是会回到 C 语言和 Win32 API。原因很简单Windows 的窗口机制就是 Win32 API 的形状所有上层框架都只是在它外面包了一层。有人问现在 C# 写界面那么快为什么还要折腾 C 语言我的答案通常是因为你想知道自己点一下按钮之后电脑里到底发生了什么。这篇文章不打算讲空道理直接聊项目实战。我会以“用 C 语言写一个带窗口的小工具”为主线把 Win32 API 里跟窗口相关的核心知识点包括注册窗口类、创建窗口、消息循环、控件交互、窗口置顶全部过一遍。适合刚学完 C 语言基础知识、想写图形界面但又不想一上来就碰 Qt 或 Electron 的人也适合那些已经写过一点 Win32 却老是被各种诡异问题卡住的朋友。很多初学者在网上搜“C 语言窗口”经常会看到“滑动窗口滤波”“窗口函数”之类的信号处理内容和我们要聊的完全是两回事。这里说的窗口是操作系统中带标题栏和边框的界面元素是 Win32 API 里最重要的对象。下面我按自己做项目时的习惯来拆。1. 项目场景与整体设计1.1 项目开头为什么用 C 语言直接调 Win32 API先说动机。我写过不少界面程序现在做桌面小工具时还是会优先考虑纯 C 加 Win32 API。原因有几个一是体积小一个窗口程序编译出来往往不到几十 KB丢到任何 Windows 电脑上就能跑二是依赖少不需要装运行时库不像某些框架需要带一堆 DLL三是可控窗口消息、控件行为、绘制过程都暴露在眼皮底下出了问题可以用调试器一路追进去。很多人第一次学 C 语言的时候最大的疑问就是C 语言不是面向过程的吗怎么能做出带窗口的软件这是对 C 语言能力的误解。C 语言本身只是一种语言它能不能做窗口取决于运行环境有没有提供对应的函数库。Windows 系统的 user32.dll、kernel32.dll、gdi32.dll 里就包含了窗口管理和图形绘制相关的函数这些函数对外暴露的接口就是 Win32 API。C 语言调用这些函数没有任何障碍只要包含 windows.h 并链接对应库就行。所以“C 语言能不能写窗口程序”这个问题背后其实是在问你能不能理解句柄、消息、回调函数、结构体指针这些概念。而这些恰恰是 C 语言程序设计里最值得花时间啃的部分。从这个角度说Win32 API 是练习 C 语言指针和结构体的绝佳素材比单纯刷题有意思得多。1.2 理解窗口模型面向过程世界的“窗口类”C 语言里没有 class 关键字但 Win32 API 里却有一个叫“窗口类”Window Class的概念。很多新手听到这个词就懵以为是 C 里的类。其实它就是一个结构体用来描述一类窗口的统一属性图标长什么样、鼠标光标是什么样式、背景色是什么、窗口消息由哪个函数处理等等。这个设计很巧妙。系统不是让你每次创建一个窗口都把全部属性写一遍而是先把公共属性集中定义好、注册到系统里之后每次创建窗口只需要引用这个“类”的名字。你可以在同一个窗口类的基础上创建多个窗口实例就像用同一张图纸盖好几栋楼一样。每个窗口有自己的句柄HWND相当于每栋楼的地址但它们的建筑风格和结构都来自同一份图纸。理解了这一点后面很多事情就顺了。RegisterClassEx 是把“图纸”交给系统备案CreateWindowEx 是拿“图纸”实际盖楼ShowWindow 是把楼门打开让人看见消息循环则是源源不断地往楼里送信件。整个模型不复杂关键是脑子里得有这张图。1.3 项目设计窗口逻辑和业务逻辑要分开用 C 语言写 Win32 程序很容易写出一个几千行的 WndProc把所有逻辑都塞进消息处理函数。窗口消息来一个处理一个处理不完就在里面跑循环、做计算最后界面就卡死了。我现在的习惯是WndProc 只做界面分拣收到消息之后调用独立的功能模块。比如“点击开始”按钮WndProc 里只调用 StartWork()StartWork 内部起线程、改状态、发消息通知界面刷新。这样窗口响应始终保持流畅代码也方便测试。后面文章里我会反复强调这个原则因为很多看似莫名其妙的假死问题根源都在于把耗时操作放到了消息处理流程里。另外做窗口程序还要提前想清楚关闭逻辑。用户点右上角 X走的不是 WM_DESTROY而是 WM_CLOSE。WM_CLOSE 默认行为是调用 DestroyWindowDestroyWindow 触发 WM_DESTROYWM_DESTROY 里面再调用 PostQuitMessage 让消息循环退出。这个链路适合做确认提示、保存配置等收尾工作。我会在第四部分详细说。2. 核心 API 与细节解析2.1 窗口程序的最小组成WinMain、WndProc、MSG一个最简单的 Win32 窗口程序必须有三个部分入口函数 WinMain、窗口过程 WndProc、消息循环 MSG。先看完整代码我下面逐段解释。#include windows.h LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); } int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASSEX wc { sizeof(WNDCLASSEX) }; wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName MyFirstWindow; if (!RegisterClassEx(wc)) return 1; HWND hwnd CreateWindowEx( 0, wc.lpszClassName, C语言窗口, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL); if (!hwnd) return 1; ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessage(msg, NULL, 0, 0) 0) { TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam; }WinMain 里的 hInstance 表示当前程序实例的句柄。在 Windows 里同一个程序可以启动多个进程每个进程都有自己的实例句柄它主要用于加载资源、图标、字符串等。hPrevInstance 在老式 16 位 Windows 里用于检查是否已经有实例在运行Win32 里没有意义忽略即可。WndProc 是窗口过程负责处理发给窗口的消息。系统会用 SendMessage 或 PostMessage 把各种事件送过来比如窗口被移动、鼠标按下、按钮被点击。它是一个回调函数不是我们主动调用的而是系统在合适的时机调用它。后面会说函数指针和这个回调的关系。消息循环里 GetMessage 会阻塞等待消息有消息时取走TranslateMessage 负责把键盘消息转换成字符消息DispatchMessage 会把消息交给窗口过程去处理。GetMessage 返回 0 表示收到 WM_QUIT消息循环退出程序结束。2.2 RegisterClassEx 内部逻辑与窗口类概念再仔细看 WNDCLASSEX 结构体。这是一个典型的 C 语言结构体你的 C 语言指针和结构体知识在这里直接派上用场。结构体比较大但只要抓住几个关键字段就不容易被绕晕。WNDCLASSEX wc { sizeof(WNDCLASSEX) }; wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName MyFirstWindow;cbSize 是结构体大小用来做版本识别。style 里的 CS_HREDRAW 和 CS_VREDRAW 表示窗口大小改变时强制重绘避免界面出现拖影。lpfnWndProc 里存放的是函数指针指向我们自己写的 WndProc。hCursor 指定鼠标悬停在这个窗口上时光标的样子LoadCursor 的第一个参数传 NULL 表示使用系统预置光标。hbrBackground 是背景画刷。这里有个很常见的坑很多人直接写 wc.hbrBackground COLOR_WINDOW编译不报错但窗口背景是黑色的甚至花屏。原因是这个字段要求的是 HBRUSH 句柄而 COLOR_WINDOW 只是颜色索引系统约定要加 1 再强制转换成 HBRUSH。我最初不清楚还以为是系统坏了后来查文档才发现下面这种写法才是标准的wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1);注册窗口类时如果返回 0 说明注册失败常见原因是同名类已经注册过。很多初学者在同一个程序里多次执行 RegisterClassEx第二次就会失败。写工具类程序时我一般用一个全局变量或 static 标志来保证只注册一次。2.3 CreateWindowEx 参数逐条拆解CreateWindowEx 大概是 Win32 系列里参数最多的函数之一第一次看到的人都会头大。我把它拆开看其实还好HWND CreateWindowEx( DWORD dwExStyle, LPCSTR lpClassName, LPCSTR lpWindowName, DWORD dwStyle, int X, int Y, int nWidth, int nHeight, HWND hWndParent, HMENU hMenu, HINSTANCE hInstance, LPVOID lpParam );第一个参数 dwExStyle 是扩展样式比如 WS_EX_TOPMOST 可以让窗口始终置顶。第二个参数是窗口类名必须和注册时一致。第三个参数是窗口标题会显示在标题栏上。第四个参数 dwStyle 是最重要的普通样式WS_OVERLAPPEDWINDOW 是标准主窗口组合具体包含标题栏、边框、系统菜单、最大化最小化按钮。坐标宽高都好理解但要注意 X、Y 是相对于屏幕左上角的子窗口则是相对于父窗口客户区左上角。hWndParent 是父窗口句柄顶层窗口传 NULL。hMenu 可以是菜单句柄也可以是子窗口 ID对于普通窗口来说只有当它是一个子窗口时这个参数才被当作控件 ID 使用。hInstance 传 WinMain 里的实例句柄lpParam 可以在创建窗口时传一个结构体指针在 WM_NCCREATE 和 WM_CREATE 里拿到。菜单相关的内容在写菜单时单独讲。CreateWindowEx 之后窗口并不会马上显示必须调用 ShowWindow。我见过不少新手写完代码注册窗口、创建窗口都没问题最后一个 ShowWindow 忘了写窗口死活不出来还以为是 API 写错了。2.4 句柄、函数指针与消息分发的关系C 语言的指针知识在 Win32 API 里体现得非常充分。WndProc 本身就是一个函数指针的“实例”。你在 WNDCLASSEX 里写 wc.lpfnWndProc WndProc就是向系统注册这个函数的地址。窗口收到消息后系统会拿着这个函数指针以类似 fn(hwnd, msg, wParam, lParam) 的方式调用它。为什么强调这个因为很多人调试时看到调用栈里有 user32.dll 转跳到自己写的 WndProc会觉得很神秘。说白了就是操作系统维护了一张回调函数表。理解这一点之后你自然就会明白几个问题为什么 WndProc 必须用 CALLBACK 修饰因为 Windows 默认使用 stdcall 调用约定如果函数声明成 C 默认的 cdecl参数入栈和返回清理不一致轻则参数错乱重则程序直接崩溃。还有 HWND 是什么HWND 是窗口句柄本质上是一个不透明指针你不需要也没办法直接访问它指向的结构。系统内部维护了一个窗口对象表HWND 是这张表的索引。所以你会看到 Windows 程序里到处传递 HWND但从来没有人去解引用它。理解了这一点你就能明白为什么 GetWindowText、SetWindowText 这类 API 都需要先传一个 HWND 进去系统要根据句柄找到对应的窗口对象再去读写它的属性。3. 实操过程与核心实现3.1 从零搭建一个可复用的窗口骨架写 Win32 窗口程序我最怕每次从零敲一遍样板代码所以通常把第一节里的最小骨架存在一个文件里当模板。这个骨架里有几个地方是后面所有项目都通用的注册类、创建窗口、消息循环、WndProc 里的默认处理。实际开发时为了让窗口类可以在多个模块间复用我会把窗口类名定义成宏并且把每个窗口对应的 WndProc 拆到单独文件里。比如一个窗口置顶小工具的代码至少分成 main.c、win_main.c、tool_logic.c 三个文件。main.c 只管 WinMain 入口win_main.c 管窗口创建和消息处理tool_logic.c 管具体的业务逻辑。一个经常被忽视的小细节是WinMain 里的 lpCmdLine 参数它是命令行字符串的指针。写小工具时可以用它来支持命令行参数。比如窗口置顶工具启动时可以带一个参数表示指定窗口标题程序启动后自动查找并置顶。这样工具就可以被其他脚本一键调用。在空骨架基础上继续开发时建议先用 Project Explorer 里的资源文件添加图标和版本信息。没有图标的程序虽然能跑但任务栏和资源管理器里看起来像临时产物正式发布时不太像话。加图标就是把 .ico 文件加到 .rc 资源脚本里然后在 WNDCLASSEX 的 hIcon 和 hIconSm 字段里用 LoadIcon 加载。3.2 给窗口添加按钮和编辑框完成简单交互一个只有空窗口的程序没有实际价值。常见的做法是用 CreateWindowEx 创建控件控件本质上也是窗口只是样式不同。下面这段代码在客户区创建一个按钮和一个编辑框#define IDC_BTN_TOP 1001 #define IDC_EDIT_INPUT 1002 HWND hEdit CreateWindowEx( WS_EX_CLIENTEDGE, EDIT, , WS_CHILD | WS_VISIBLE | WS_BORDER | ES_LEFT, 20, 20, 200, 24, hwnd, (HMENU)IDC_EDIT_INPUT, g_hInstance, NULL); HWND hBtn CreateWindowEx( 0, BUTTON, 置顶窗口, WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 20, 60, 100, 32, hwnd, (HMENU)IDC_BTN_TOP, g_hInstance, NULL);控件类名是系统预定义的字符串EDIT、BUTTON、STATIC、LISTBOX、COMBOBOX 都是常规操作。创建时必须加上 WS_CHILD 和 WS_VISIBLE否则控件不会显示。hMenu 参数在子窗口里被复用了这里不是菜单句柄而是给控件分配的数字 ID。点击按钮后父窗口会收到 WM_COMMAND 消息可以从 wParam 的低 16 位拿 ID高 16 位拿通知码。编辑框接收文本后用 GetWindowText 取出内容。这个函数名字容易让人误解以为只有窗口标题能用其实任何带文本的窗口都能用它读文本。按钮文本、编辑框内容、窗口标题都走同一个 API。case WM_COMMAND: if (LOWORD(wParam) IDC_BTN_TOP) { char buf[256] { 0 }; GetWindowText(hEdit, buf, 255); SetWindowText(hwnd, buf); } break;上面这段的逻辑是点击按钮后把编辑框里的文字读取出来设置到主窗口标题上。虽然简单但已经把“控件事件 - 读取数据 - 更新界面”这条完整链路走通了。后面做任何小工具本质上都是在重复这个过程。3.3 实现窗口置顶功能SetWindowPos 与 WS_EX_TOPMOSTWindow 置顶是桌面小工具里很常见的功能。实现方式有很多种最直接的是在创建窗口时指定 WS_EX_TOPMOST 扩展样式HWND hwnd CreateWindowEx( WS_EX_TOPMOST, wc.lpszClassName, 置顶小工具, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 400, 300, NULL, NULL, hInstance, NULL);但很多时候我们希望运行时动态切换置顶状态这时候用 SetWindowPos 更灵活。SetWindowPos 能干的活很多改窗口大小、位置、Z 轴顺序、显示状态。下面是把窗口切到置顶状态的代码SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);HWND_TOPMOST 表示把窗口放到置顶层后面的 SWP_NOMOVE 和 SWP_NOSIZE 表示不改变坐标和尺寸。如果要把窗口恢复成普通状态就把第二个参数换成 HWND_NOTOPMOST。Z 轴顺序是窗口管理里一个很重要的概念。所有窗口在系统里按前后顺序排列置顶窗口始终排在普通窗口上面。SetWindowPos 可以在运行时调整这个顺序这也是“窗口置顶工具”这类程序的核心原理。我把这个功能做成了菜单项点击“总在最前”时勾选菜单状态再次点击取消。菜单创建可以用 CreateMenu 和 AppendMenu也可以直接在资源里定义菜单然后在创建窗口时把菜单句柄传给 CreateWindowEx。菜单消息会通过 WM_COMMAND 传回来处理方式跟按钮点击一样。3.4 从窗口骨架扩展成完整小工具再进一步我通常会给窗口程序加一个定时器用于周期性刷新界面。比如写一个“窗口置顶工具”用户输入一个窗口标题后程序每隔 1000ms 检查目标窗口是否存在存在就自动置顶。定时器用 SetTimer 开启SetTimer(hwnd, 1, 1000, NULL);在 WndProc 里接收 WM_TIMERcase WM_TIMER: CheckAndTopMost(hwnd); break;不再需要定时器时用 KillTimer 关闭。定时器属于非常轻量的机制适合做界面轮询但不适合精密计时。如果要做低于 10ms 级别的调度应该用 timeBeginPeriod 加高精度时钟或者干脆移到工作线程里。写这类小工具有个原则不要让单次刷新逻辑占用太多时间。如果 CheckAndTopMost 里要遍历窗口建议把 FindWindow、EnumWindows 这类函数放在后台线程或分片执行避免 WM_TIMER 消息处理时间过长导致窗口响应卡顿。到这里一个简单的窗口工具已经成型有窗口、有控件、有菜单、有定时器、有置顶逻辑。再往下加东西无非是增加更多控件和消息分支。保持住“消息处理尽量短”的原则程序就不会难维护。4. 常见问题与排查技巧4.1 窗口显示不出来程序也没报错这类问题最折磨人。编译链接都过了程序运行后进程存在但界面上什么也没有。大多数情况是下面几个原因之一一是忘了调用 ShowWindow。创建完窗口后窗口默认是隐藏的必须 ShowWindow 才能显示。二是窗口类名不匹配注册时写的是 LHiddenClass创建时却写成 MyWindowCreateWindowEx 返回 NULL。三是注册窗口类失败然后代码里忽略了返回值继续往下执行最后创建的窗口是无效的。排查办法很简单每调用一个关键 API就检查返回值加 OutputDebugString 或者日志。很多人为了省事不检查出了问题才追着 debug 半天。把检查返回值当成习惯后这类问题一般几分钟就能定位。还有一个小细节如果控制台窗口一闪而过说明你写的是控制台程序入口是 main 而不是 WinMain。用链接器参数 /SUBSYSTEM:WINDOWS或者直接把入口函数改成 WinMain。4.2 窗口假死为什么不能随意 Sleep 或循环等待窗口假死是 Win32 编程最常见的坑。症状是鼠标移到窗口上变成转圈点哪里都没反应标题栏显示“未响应”。原因几乎都是某个消息处理函数里执行了耗时操作阻塞了消息循环。Windows 的消息循环本质上是串行的一个窗口在同一时间只能处理一条消息。如果 WM_PAINT 或 WM_COMMAND 处理函数里 Sleep(5000)这 5 秒内窗口无法处理鼠标键盘消息系统就会判定它无响应。所以我的原则是耗时操作绝不放在 WndProc 里。如果必须等待某个事件也一定用一个单独的线程去等等完后再用 PostMessage 把结果发回主窗口。比如要等待一个网络请求或文件搜索正确的做法是在工作线程里执行然后PostMessage(g_hDlg, WM_APP_FINISH, 0, (LPARAM)result);这样主窗口线程不会阻塞界面始终能响应重绘和用户输入。模态窗口也会制造“卡死”的假象。MessageBox、DialogBox 都会进入一个模态消息循环在模态窗口弹出期间父窗口被禁用用户点父窗口没反应。这是正常现象不是 Bug。但要注意不能在 WM_PAINT 这种绘制消息里弹模态窗口否则会无限刷新导致界面疯狂闪烁甚至崩溃。4.3 关闭窗口不退出进程WM_CLOSE 和 WM_DESTROY 的关系很多初学者在主窗口的 WM_CLOSE 里写了不少清理逻辑却忘记调用 DestroyWindow结果窗口关不掉。WM_CLOSE 默认行为是调用 DestroyWindow但如果你拦截了 WM_CLOSE 并且没有调用 DestroyWindow窗口就一直还在。我习惯在 WM_CLOSE 里先做用户确认比如弹 MessageBox 问“是否保存”如果用户点“是”再调用 DestroyWindow。在 WM_DESTROY 里释放资源、保存设置、调用 PostQuitMessage(0)。不要把清理逻辑都堆到 WM_CLOSE 里否则用户点关闭但选择取消时你的清理代码也被执行了状态就乱了。需要注意 PostQuitMessage 只是向消息队列投递 WM_QUIT让 GetMessage 返回 0并不会立即销毁窗口。真正销毁窗口的时机在 DestroyWindow 执行完、WM_DESTROY 处理完之后。如果窗口没有正确销毁消息循环也可能迟迟退不出去进程就一直留在任务管理器中。4.4 编码问题ANSI、Unicode、TCHARC 语言字符串在 Win32 API 里有两个版本A 版本接收 char*W 版本接收 wchar_t*。比如 SetWindowTextA 和 SetWindowTextW它们都来自同一个宏 SetWindowText。项目里如果定义了 UNICODE就默认调用 W 版本否则调用 A 版本。早期我写程序时偷懒直接用 char 数组和 strcpy。窗口标题里有中文时在简体中文系统上看起来没问题但放到英文系统上就成了乱码。后来统一用宽字符 wchar_t 和 wcs 系列函数问题少了很多。我的建议是新项目直接按 Unicode 来写所有字符串用 L 前缀函数用 w 版本或者干脆用 TCHAR 宏保持灵活。毕竟现在 Windows 系统底层全是 UTF-16。编码问题里还有一个常见操作用 GetWindowText 拿到的是窗口标题或控件文本可能是宽字符也可能被系统转成 ANSI。如果你拿到文本后要和其他字符串拼接注意先确认两边编码一致。我踩过最离谱的坑是用 strcat 拼接了 A 和 W 混用的字符串结果界面显示出一段乱码还伴随内存越界。简单给一个对比场景A 版本W 版本函数MessageBoxAMessageBoxW字符串类型char*wchar_t*字节数1 字节/字符2 字节/字符中文支持依赖代码页原生支持C 语言里指针和字符串本来就是重点到了 Win32 API 里又会多一层编码维度基础不牢很容易翻车。建议动手之前先把 strcpy、strlen、strstr 的宽字符版本都熟悉一遍至少知道 wcscpy、wcslen、wcsstr 的存在。4.5 运行环境相关控制台字体、拖拽文件、DPI继续看运行环境。很多人在命令行里跑 C 程序发现控制台字体太小其实很简单在标题栏上右键选择“属性”或“默认值”里面可以调字体大小和窗口大小。Windows Terminal 则可以在配置文件里设置 font size。这件事和 Win32 API 本身没关系但属于 C 语言学习阶段“运行环境体验”的一部分顺手记一下不亏。另一个常见问题是 Windows 11 上管理员权限的窗口不能拖文件到命令行窗口。这是因为以管理员身份运行时普通权限的 Explorer 进程默认不允许往高权限窗口拖文件属于系统安全机制。解决办法通常是避免以管理员身份运行或者用 ShellExecute 的 runas 提权。最后聊 DPI。现在高分屏很普及如果程序不声明 DPI 感知系统会做缩放界面可能模糊。在 WinMain 开头调用 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)可以避免大部分模糊问题。如果还想让窗口字体跟着 DPI 变化可以用 CreateFont 按系统字体创建逻辑字体也算基础操作。#include windows.h #include wingdi.h HFONT hFont CreateFont( -MulDiv(10, GetDeviceCaps(hdc, LOGPIXELSY), 72), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, TEXT(微软雅黑)); SendMessage(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE);这一段是在做控件字体适配。创建字体时用了屏幕逻辑分辨率来计算磅值避免在 150% 缩放的屏幕上字体小到看不清。新手阶段不需要背这些参数但要知道 Win32 程序里的“字体”也是一个句柄资源用完要 DeleteObject 释放。5. 一些从实践里沉淀出来的小经验如果让我给刚接触“C 语言 Win32 API”的人一条最实在的建议那就是多写多断点调试别怕看汇编。写窗口程序的本质就是和操作系统交互任何一步想当然都会在运行时给你教训。与其去背 API 名字不如亲手把最小窗口打出来然后从改标题、改背景色、加按钮开始试。调试时我建议多做一件事在 WndProc 开头统一打印收到的消息编号。Windows 有大量定时消息比如 WM_PAINT、WM_MOVE、WM_SIZE日志打多了你会慢慢形成直觉。尤其是控件布局出问题的时候一看日志就能知道是窗口尺寸变化没处理好还是消息处理顺序有问题。从选题到落地一个简单的窗口置顶工具我前前后后改过十几版。有人问这样值不值得我觉得挺值的。因为写完之后你不但会调 Win32 API还会更理解操作系统的窗口机制、消息驱动模型和资源管理方式。这些东西放到任何语言、任何框架里都是底层的通识。最后再分享一个我实际用的小习惯写窗口代码时把每个 API 的关键参数都注释清楚。Win32 API 很多隔几个月再看代码真不一定记得当时为什么传那个值。注释写清楚比到时候翻文档省事得多。