简介一份面向已具备 C/C 基础、准备进入 Windows 桌面开发的初学者的 VC6.0 MFC 入门文档围绕「如何学好 VC」这一核心问题给出可执行的学习路线。内容从语言基本功切入强调先写字符界面程序以熟悉语法与 C 特性再逐步过渡到消息机制的组成、窗口句柄HWND与各类 GUI 句柄的用途以及 MFC 消息映射如何把消息与成员函数关联起来。文中还配有 WNDPROC 与消息循环的伪代码说明未处理消息如何交由默认窗口过程处理并对比 16 位与 32 位系统的消息队列差异帮助读者建立对 Windows 消息驱动模型的整体认识同时给出参考书选择原则、Help Online 查阅与阅读他人代码的练习建议。资源包仅含 1 个 doc 文件约 521KB篇幅紧凑、便于随查随看。目前已有 731 人学习下载适合作为 MFC 入门阶段的方法指引与概念速查。1. MFC 入门为什么先写字符界面程序反而是最快的路MFC 入门这件事多数人的第一反应是赶紧拖控件、让一个窗口弹出来但真正拦住你的从来不是窗口怎么显示而是消息从哪来、句柄指向谁、GDI 对象什么时候必须还回去。VC6.0 这套东西的价值恰恰在于它把 Windows 的消息驱动模型压缩成了一个能读完的骨架向导生成的代码量小到可以一行行看完ClassWizard 写下的消息映射宏展开后就是一段 switch-case。反直觉的结论是——先花两周老老实实写字符界面的 C 程序再回来碰窗口进度反而更快因为 MFC 的坑九成不在界面而在你对 C 对象生命周期和消息分派的理解深度。把这套机制拆开看崩溃就不再是玄学而是一条条能推演的路径。2. Windows 消息机制一条消息从产生到被处理的完整路径Windows 是消息驱动的操作系统这句话被念了太多遍以至于很少有人真去追问消息到底长什么样谁决定它归哪个窗口没被处理的消息又去哪了。这三个问题答不上来后面看 MFC 的消息映射就只能靠背。这一章把一条消息的完整生命周期走一遍从产生、入队、取出、分派到最终落进某个窗口过程里被消化掉或者退回给系统。理解这条链路的意义在于MFC 后来做的所有封装——消息映射表、OnPaint、OnLButtonDown——本质上都是在替你在链路的末端挂上钩子。你先知道钩子挂在哪儿才知道它为什么有时候挂不上。2.1 消息的三段式结构UINT、WPARAM、LPARAM 各装了什么一条消息最核心的部分只有三样一个消息名称UINT类型和两个参数WPARAM、LPARAM。消息名称是常量比如WM_PAINT、WM_LBUTTONDOWN、WM_COMMAND两个参数的含义完全由消息名称决定同一个wParam在WM_COMMAND里是命令 ID在WM_CHAR里就变成了字符编码。// MSG 结构入队的消息比三段式多了窗口句柄和时间、位置信息 typedef struct tagMSG { HWND hwnd; // 目标窗口句柄决定这条消息最终归谁 UINT message; // 消息名如 WM_PAINT、WM_COMMAND WPARAM wParam; // 随消息变化的参数32 位 LPARAM lParam; // 随消息变化的参数32 位 DWORD time; // 消息入队的时间戳 POINT pt; // 入队时的鼠标屏幕坐标 } MSG;WM_COMMAND是最典型的复合参数消息拆开看能明白为什么老手能一眼读出意图HIWORD(wParam)是菜单项或控件的命令 IDLOWORD(wParam)是控件的通知码菜单消息时无意义lParam在控件场景下是控件窗口句柄、在菜单场景下是 0。记住这一组拆法调试菜单没响应的问题时能省掉大量瞎猜。用户也可以自定义消息名称惯例是从WM_USER往上加偏移比如WM_USER 100。自定义消息常见于工作线程向主窗口报告进度——线程不能直接碰界面对象只能PostMessage一条消息回去这是跨线程更新 UI 的标准做法。2.2 窗口句柄与窗口过程谁决定这条消息归谁系统用窗口句柄HWND在整个系统范围内唯一标识一个窗口。发送消息时必须指定一个窗口句柄这等于告诉系统这条消息由谁接收。句柄本身不是一个可以随便解引用的指针它是内核对象表里的一个索引所以伪造或者复用已销毁的句柄会引发难以定位的异常。每个窗口都绑定一个窗口过程WNDPROC消息最终在这里被分析、分派。关键在于窗口过程是代码句柄是实例。两个窗口完全可以共用同一份窗口过程代码靠传入的hWnd区分自己是谁。鼠标在窗口一上按下消息带着窗口一的句柄进来窗口过程的switch里处理的就一定是窗口一的状态。// 窗口过程的标准骨架所有消息都从这里过一遍 LRESULT CALLBACK YourWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: // 客户区需要重绘在这里做输出 return 0; case WM_LBUTTONDOWN: // lParam 低字是 x 坐标高字是 y 坐标 return 0; case WM_COMMAND: // 菜单/控件命令wParam 高字才是命令 ID if (HIWORD(wParam) ID_FONT_DROPDOWN) { DoNothing(); return 0; } break; default: break; } // 不感兴趣的消息全部交还给系统的默认窗口过程 return DefWindowProc(hWnd, msg, wParam, lParam); }这里最关键的一行是末尾的DefWindowProc。系统为窗口准备了一个默认窗口过程负责处理那些你没写的消息——拖动窗口产生的一连串消息、非客户区绘制、系统菜单响应全归它管。正因为有这个兜底你才不必对每条消息都表态。漏掉这一句的后果很直接窗口拖不动、边框画不出来、关闭按钮失灵。2.3 消息循环与消息队列GetMessage 不返回时 CPU 在干什么系统维护一个或多个消息队列产生的消息先进队列再由各程序自己的消息循环取出、翻译、分派。没有消息时GetMessage不会返回而是挂起等待——这是新手最容易误解的地方不返回不等于在空转它是在内核里等一个事件对象CPU 占用是零。MSG msg; // GetMessage 取到 WM_QUIT 时返回 0 退出出错时返回 -1 while (GetMessage(msg, NULL, 0, 0) 0) { // 把 WM_KEYDOWN/WM_KEYUP 组合成 WM_CHAR不做这一步就收不到字符输入 TranslateMessage(msg); // 按 msg.hwnd 找到对应窗口过程并调用返回值写回 msg.wParam DispatchMessage(msg); } return msg.wParam;while (GetMessage(...))这种写法埋着一个经典隐患GetMessage出错时返回 -1在布尔上下文里被当成真循环继续跑于是变成死循环。写成 0才是稳妥的。TranslateMessage的作用常被忽视。键盘按下产生的原始消息是WM_KEYDOWN里面只有虚拟键码经过翻译后才多出一条WM_CHAR携带真正的字符编码。只处理WM_KEYDOWN而不调用TranslateMessage你在编辑框里敲字母会毫无反应。DispatchMessage是链路里真正投递的一步拿hwnd去内核里查窗口对象取出登记的窗口过程地址把四个参数原样传进去。到这一步消息才真正落到 2.2 节那段switch上。2.4 16 位协同式与 32 位抢先式一个死循环为什么能卡死整个系统消息队列的组织方式直接决定了一个程序卡住时会不会拖垮别人。16 位系统全局只有一个消息队列系统必须等当前任务把消息处理完才能把下一条发给别的程序。这种模型叫协同式多任务——多个程序靠自觉轮流让出控制权。任一程序陷入死循环或者做长耗时运算系统就拿不回控制权整个机器僵住。早期的 16 位 Windows 就是这套模型。32 位系统把消息队列拆到了每个 GUI 线程一份。常见的实现思路是线程创建窗口时系统为它准备一个消息队列GetMessage只从自己线程的队列里取。于是 A 程序卡死只卡住它自己那个线程系统仍然可以在多个队列之间切换把控制权交给别人。这套模型叫抢先式多任务。理解了这一层OnDraw里写Sleep(1000)的后果就清楚了不是画面慢一秒而是这一个线程整整一秒不处理任何消息窗口被遮挡后重绘请求堆积、鼠标点击石沉大海、标题栏显示无响应。耗时计算要么拆小、要么扔进工作线程绘制函数里只留绘制。3. MFC 消息映射宏是怎么把 switch-case 藏起来的手写窗口过程的问题不在于难而在于重复。每加一个窗口就要抄一遍switch每加一个菜单项就要在WM_COMMAND分支里多一个if写到最后没人愿意维护。MFC 的解法是用一组宏在编译期把散落在各处的成员函数织进一张静态表运行时再靠这张表把消息分派给正确的成员函数。这一章拆开宏的外壳看里面的数据结构长什么样。3.1 从 BEGIN_MESSAGE_MAP 到 END_MESSAGE_MAP宏展开后的真实形态ClassWizard 自动生成的消息映射块长这样中间那些带花括号的注释是向导的标记位它靠这些标记定位手改很容易破坏。BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) //{{AFX_MSG_MAP(CMainFrame) ON_WM_CREATE() // 映射 WM_CREATE 到 OnCreate //}}AFX_MSG_MAP ON_COMMAND(ID_FONT_DROPDOWN, DoNothing) // 映射某命令 ID 到 DoNothing END_MESSAGE_MAP()概念上这段声明等价于给CMainFrame生成一个专属的窗口过程逻辑上就是一张大的分派表// 编译后的等价形态简化示意真实实现是一张静态表而非 switch LRESULT CMainFrame::WindowProc(UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // ON_WM_CREATE 展开的结果 return OnCreate((CREATESTRUCT*)lParam); case WM_COMMAND: // ON_COMMAND 展开的结果 if (HIWORD(wParam) ID_FONT_DROPDOWN) { DoNothing(); return 0; } break; } return CFrameWnd::WindowProc(msg, wParam, lParam); // 交回基类继续找 }凡是这个类的实例创建的窗口窗口过程都指向这一份代码。派生类的处理函数先在子类里找找不到就调基类的WindowProc一路往上冒泡到CWnd最后由CWnd::DefWindowProc兜底。这就是为什么你重写了OnPaint却不用管其他几十种消息——它们仍在基类那条链上被消化。3.2 ON_WM_XXX 与 ON_COMMAND两条分发路径的差别映射宏分两类走的是两条不同的查找路径混用会直接导致处理函数不触发。第一类是ON_WM_XXX比如ON_WM_CREATE、ON_WM_PAINT、ON_WM_LBUTTONDOWN。这类宏只按消息名匹配处理函数的签名是固定的——OnCreate必须收LPCREATESTRUCTOnPaint必须无参OnLButtonDown必须收(UINT, CPoint)。ClassWizard 生成时会自动写对签名手写时签名错了编译期不一定报运行期就崩在栈上。第二类是ON_COMMAND、ON_CONTROL它们按命令 ID 匹配。ON_COMMAND(ID_FONT_DROPDOWN, DoNothing)的意思是任何WM_COMMAND消息只要HIWORD(wParam)等于ID_FONT_DROPDOWN就调DoNothing。这就是 2.1 节那段参数拆解的实际用途。两类宏在底层都会走进同一张表只是匹配的字段不同前者比nMessage后者比nMessage nCode nID区间。3.3 表驱动而不是虚函数MFC 为什么选宏这是全篇最值得停下来想的设计取舍。消息种类数以百计而一个具体的窗口类通常只关心其中十几种。如果每种消息都在CWnd里挂一个虚函数虚函数表会被撑得极大每个窗口对象都背着一份根本用不上的分派开销。MFC 的选择是表驱动。宏展开后生成一张静态的映射条目数组每条记录把消息名 通知码 命令 ID 区间对应到一个成员函数指针// 编译后实际生成的条目结构简化示意 struct AFX_MSGMAP_ENTRY { UINT nMessage; // 消息名如 WM_CREATE UINT nCode; // 通知码WM_COMMAND 场景才用 UINT nID; // 命令 ID 下界 UINT nLastID; // 命令 ID 上界支持一段连续 ID 映射到同一函数 UINT nSig; // 函数签名编码用于把 pfn 还原成正确类型的成员函数指针 AFX_PMSG pfn; // 处理函数的实际地址 };查表时从当前类的表开始逐条比对消息名和 ID 区间命中就按nSig把pfn转成对应签名的成员函数指针并调用没命中就顺着一张指向基类表的指针上溯直至CWnd。nSig这个字段是必要的妥协——各处理函数签名完全不同统一存成同一种指针类型后再按签名转回去绕开了类型系统但保住了正确性。代价也很真实宏在编译期展开调试器无法在宏本身下断点只能在展开后的处理函数里下映射不生效时错误往往是静默的程序照跑只是那件事没人做。这一条在避坑章节还会再遇到。4. GDI 对象与设备环境为什么选入 DC 再恢复是不能省的绘图部分的坑密度最高因为它同时牵涉三种资源C 对象栈上、自动析构、GDI 句柄系统资源、必须显式释放、设备环境DC一个抽象的画布。三者生命周期不同步一旦错位现象就表现为界面偶尔花掉这种极难复现的玄学。这一章把三者的关系理清。4.1 DC 的设备无关性一份 OnDraw 代码怎么同时喂给屏幕和打印机DC设备环境是一个抽象的作图环境它可能对应屏幕、打印机、内存位图。同一套作图指令——MoveTo、LineTo、TextOut——作用在不同 DC 上输出到不同设备。这就是 Windows 的设备无关性好比同一幅画用照相机拍和用复印机印画本身不用改。MFC 把这份能力集中体现在OnDraw(CDC* pDC)上。窗口需要重绘时框架调它传进来的是屏幕 DC用户选打印时框架还是调它传进来的是打印机 DC打印预览时传进来的是一个预览专用的 DC。你只写一份绘制代码窗口、预览、真实打印三处全部复用。这也是为什么初学者把绘图代码写到OnPaint里而不是OnDraw里打印功能就整个失效——OnPaint收到的永远是屏幕 DC。4.2 创建、选入、恢复、销毁四步一步都不能少GDI 对象的使用有固定套路先创建再选入 DC用完把 DC 里原来的对象选回去最后销毁自己创建的对象。void CMyView::OnDraw(CDC* pDC) { CPen penGray; penGray.CreatePen(PS_SOLID, 2, RGB(128, 128, 128)); // 创建这时才真正分配 GDI 句柄 CPen* pOldPen pDC-SelectObject(penGray); // 选入返回 DC 里原来的画笔 pDC-MoveTo(10, 10); pDC-LineTo(200, 10); pDC-SelectObject(pOldPen); // 恢复先把旧的换回去 penGray.DeleteObject(); // 销毁GDI 句柄配额有限必须归还 // 同一个 CPen 变量可以复用但重新创建前必须先删掉旧的 penGray.CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen2 pDC-SelectObject(penGray); pDC-MoveTo(10, 30); pDC-LineTo(200, 30); pDC-SelectObject(pOldPen2); penGray.DeleteObject(); }顺序里最容易被省略的是恢复。penGray是栈上对象函数返回时析构函数会跑但它对应的 GDI 句柄此时可能还挂在 DC 里——DC 拿着一个指向已销毁对象的指针下一次重绘就会画出不可预期的结果或者直接异常。稳妥的顺序是先恢复再销毁。也不要在同一个对象上连续调两次CreatePen第二次之前必须先DeleteObject否则第一次分配的句柄就永久泄漏了。进程的 GDI 对象配额是有上限的几千次泄漏之后所有绘图接口集体失灵界面上表现为大片空白。4.3 库存对象为什么它留在 DC 里是安全的系统预置了一批库存 GDI 对象黑白灰各种固定颜色的刷子、单像素的画笔、几种标准字体。它们由系统持有你不需要创建也不需要销毁。pDC-SelectStockObject(NULL_BRUSH); // 空心刷子画矩形只描边不填充 pDC-SelectStockObject(BLACK_PEN); // 黑色单像素画笔 pDC-Rectangle(10, 10, 200, 200);库存对象的价值有两层。一层是省事画个空心矩形不必专门创建再销毁一个刷子。另一层更重要——它是恢复操作的可靠兜底。当你不确定pOldPen是不是有效或者旧对象本身已经出了问题把库存对象选进 DC 至少能保证状态是干净且合法的。库存对象不会被销毁留在 DC 里也不会有悬空指针。SelectStockObject接受BLACK_BRUSH、GRAY_BRUSH、HOLLOW_BRUSH、NULL_BRUSH、BLACK_PEN、NULL_PEN、WHITE_PEN、SYSTEM_FONT等一批固定索引够应付绝大多数临时绘图场景。4.4 文字输出TextOut 与 DrawText 的排版差异单行文字用TextOut(x, y, str)按坐标硬画。要折行、要对齐、要省略号就得用DrawText(str, lpRect, nFormat)它的nFormat是一组可以按位或组合的标志。CRect rc(10, 10, 300, 60); // 第一次调用只算不画返回折行后需要的矩形高度 int h pDC-DrawText(_T(一段需要在矩形内自动折行的文字), rc, DT_LEFT | DT_WORDBREAK | DT_CALCRECT); // 按算好的高度把矩形底部撑开再正式输出 rc.bottom rc.top h; pDC-DrawText(_T(一段需要在矩形内自动折行的文字), rc, DT_LEFT | DT_WORDBREAK);DT_CALCRECT是个很实用的组合——它让DrawText只测量不输出返回文字占用的高度。手工排多行文字时先算后画能避免行距错位。常用标志还有DT_SINGLELINE强制单行、DT_CENTER/DT_RIGHT水平对齐、DT_VCENTER垂直居中必须配合单行、DT_END_ELLIPSIS尾部省略号、DT_NOPREFIX关闭助记符处理、DT_TABSTOP设置制表位宽度高位字节存字符数。字体创建走两条路。库存字体用SelectStockObject直接拿自定义字体要填一个LOGFONT结构再调CreateFontIndirect或者调参数多达十四个的CreateFont。LOGFONT里lfHeight用负值表示字符本身的高度、正值表示单元格高度lfWeight决定粗细FW_NORMAL是 400、FW_BOLD是 700lfFaceName是本地化的字体名硬编码英文名在某些环境下匹配不到。需要精确排版时GetTextExtent(str)返回字符串输出后的宽高GetTextMetrics(tm)填充TEXTMETRIC结构给出字符平均宽度、行高、基线偏移这些精细数据。多行手工排版靠这两个函数算行距才靠谱。5. 避坑清单MFC 新手最容易翻车的五个地方下面这些每一条都是能复现的真实故障现象、原因、解决三件套写清楚遇到时对着排。5.1 GDI 资源相关花屏、白屏、跑一会就画不出来现象程序启动后界面正常运行几分钟后窗口某些区域变成白色鼠标划过才恢复或者干脆整片空白重启程序又可以了。原因绘图代码里反复CreatePen、CreateFont却从不DeleteObject。每调一次OnDraw泄漏一个 GDI 句柄累计到接近进程配额上限时所有绘图接口静默失败——不抛异常只是画不上东西。解决把创建—选入—恢复—销毁当四件套固定下来检查每一处CreateXxx是否都有配对的DeleteObject并且删除之前已经把旧对象选回 DC。一时改不完的话用pDC-SaveDC()/pDC-RestoreDC(n)把 DC 状态整体存栈比一个个记录旧指针稳妥。现象调试版跑得好好的换到发布版随机崩溃堆栈还指在一些莫名其妙的地方。原因ASSERT在发布版里被整体编译掉如果你把带副作用的表达式写进了断言——比如在ASSERT里调用一个会创建对象的函数——发布版下这段代码压根不执行后续逻辑全部错位。TRACE同理只在调试版输出。解决断言只放纯判断表达式绝不放赋值、函数调用这类有副作用的写法。确实需要在两种构建下都求值的用VERIFY它在发布版下依然会执行括号里的表达式。5.2 消息与生命周期相关改了没反应、拖动就卡死现象明明在类里写了OnPaint也加了ON_WM_PAINT()编译链接都过运行时就是不进断点。原因消息映射需要三处保持一致——头文件里的afx_msg函数声明、cpp 里BEGIN_MESSAGE_MAP与END_MESSAGE_MAP之间的映射条目、以及函数实现。用 ClassWizard 生成时三处会自动同步手改就很容易漏掉其中一处。另一种可能是DECLARE_MESSAGE_MAP()宏被误删或被放到了错误位置。解决出问题时按这三处逐一核对重点看带{{AFX_MSG_MAP注释的标记块有没有被破坏——向导靠这些花括号定位破坏了它就不再往里面加新条目。现象窗口一拖动就花屏标题栏显示无响应松手后恢复正常。原因OnDraw或OnPaint里塞了耗时计算把消息线程占死了。WM_PAINT本身是低优先级消息只在消息队列空了才合成队列被堵住它就永远排不上界面自然不刷新。解决绘制函数里只留绘制耗时的计算挪到工作线程线程算完后PostMessage一条自定义消息回主窗口主窗口收到后Invalidate触发重绘。绝对不能在工作线程里直接操作窗口对象。现象程序里新建一个字体明明CreateFontIndirect成功了输出却还是原来的字体。原因创建了CFont对象却忘了SelectObject选进 DC。画笔和刷子也一样——GDI 对象创建之后只是存在不选入 DC 就不参与任何绘制当前生效的永远是 DC 里那支。解决记住这条因果链DC 里当前选中的对象决定绘制效果SelectObject才是切换的动作。创建只是准备好素材。6. 设计取舍MFC 封装掉的东西什么时候该重新捡回来MFC 替开发者挡掉了两样东西各种句柄的具体操作和消息分派的样板代码。这两样挡得对不对值得单独算一笔账。虚函数方案和表驱动方案摆在一起看维度每个消息一个虚函数宏 静态映射表分派开销每次调用一次间接跳转查表比对未命中还要上溯基类对象体积虚表随消息数量膨胀所有窗口都背表是静态的与对象数量无关调试体验断点清晰栈完整宏不可断错误容易静默扩展方式重写虚函数即可必须用宏配对声明与实现未处理消息需要基类默认实现兜底天然沿基类链上溯结论是清晰的消息数量远大于单个类实际关心的数量虚表方案的空间代价是全局的而查表代价只在收到消息时付一次。MFC 选表驱动是对的代价转移到了可维护性上——宏让改了一处忘了另一处成为常态。CRect、CPoint、CString、CStringArray这些值类型是另一笔划算的买卖。CString自己管内存、支持Format、Find、CompareNoCase省掉的是大量手写缓冲区管理CRect把四个坐标和Width、Height、CenterPoint、矩形相加取并集一次封好比裸RECT结构好用太多。这些类几乎没有隐藏成本该用就用。但封装也有该拆的时候。句柄被藏进对象之后一旦出现资源泄漏或者句柄失效堆栈里全是框架代码黑匣子的感觉就来了。我一般的做法是怀疑资源问题时绕过 MFC 的封装直接查该类型的底层句柄GetSafeHandle是否为空、句柄数量是否在异常增长怀疑消息问题时在WindowProc里加一句TRACE把消息名打出来比在映射表里瞎猜快得多。VC6.0 这个环境本身也有它的取舍。它老、它对现代 C 标准的支持停在很早的阶段但它的向导生成的代码量小到你能完整读完ClassWizard把消息映射的三处同步做成了一键。用它理解机制是合适的用它写新项目就不必了。血泪经验是这么来的早年我在一个绘图程序里OnDraw中每次都用局部CPen画虚线网格为了省事直接DeleteObject而没把旧画笔选回去本地跑一整天都正常交付到另一台机器上半小时就整片白屏。从那以后我每次写涉及 GDI 的代码都会强制走一遍创建—选入—恢复—销毁的四步检查哪怕只是一个临时画笔哪怕只画一条线。这类问题没有后悔药可吃唯一的办法是让正确顺序变成肌肉记忆。希望帮到你。本文还有配套的精品资源点击获取