☰
MFC消息映射机制解析:静态表、签名标签与窗口过程分派
2026/10/29 14:12:20 网站建设 项目流程

简介:《精通MFC(光盘)源代码》面向具备C++基础、希望系统掌握MFC框架与Windows桌面开发的中高级读者,提供与教程章节配套的可编译示例,帮助突破消息机制、文档/视图架构及COM组件封装等难点。压缩包共1114个文件,约8.01MB,以279个头文件和229个源文件为核心,辅以工程配置、资源脚本、图标位图及少量动态库与可执行文件,便于按章节还原和调试示例工程。内容贯穿面向对象建模、窗口与消息循环、消息映射、对话框、文档/视图、GDI与GDI+绘图、进程线程、动态链接库、COM组件和托管C++扩展等主题,示例既独立成篇又呼应书中知识脉络。已有514人学习,读者可通过源码对照理解框架运行路径、跟踪消息处理流程,并复用工程模板与绘图、线程、组件编程思路。

1. 一张静态表替掉了半张虚函数表

先把结论摆出来:MFC 里的CWnd有几百个消息处理函数,虚函数却只有十几个;WM_PAINT、WM_LBUTTONDOWN、WM_COMMAND最终调到的OnPaint、OnLButtonDown、OnFileSave全是普通成员函数,靠「查一张静态结构体数组 + 一次 switch」跳过去。这在当年是反直觉的——面向对象框架的本能反应是把每个消息做成虚函数,交给编译器生成 vtable,一行代码都不用自己写。想真正读懂「精通MFC 源代码」光盘里那几个工程附带的框架源文件,wincore.cpp、msgmap.cpp、objcore.cpp这几份是绕不开的,而它们回答的正是同一个问题:为什么宁可用一张手工维护的表,也不让编译器代劳。

2. 消息映射表:三条宏在编译后到底生成了什么

消息映射在源码里看起来像语法糖:类头写一行DECLARE_MESSAGE_MAP(),实现文件里用BEGIN_MESSAGE_MAP/END_MESSAGE_MAP夹住一串ON_WM_xxx。把这层糖剥掉,剩下的东西非常朴素——一个静态常量数组,外加几个函数。

2.1 AFX_MSGMAP_ENTRY 的六个字段各自在回答什么问题

先看核心结构体,整个分派机制的数据基础就是它。

// 简化自框架头文件中的两个核心结构 struct AFX_MSGMAP_ENTRY { UINT nMessage; // 消息号:WM_PAINT、WM_COMMAND、WM_NOTIFY…… UINT nCode; // 通知码:CN_COMMAND 或 BN_CLICKED 这类控件通知码 UINT nID; // 命令 ID / 控件 ID 的下界 UINT nLastID; // 命令 ID 的上界,ON_COMMAND_RANGE 靠它支持区间 UINT_PTR nSig; // 函数签名标签 AfxSig_xxx,分派时据此还原类型 AFX_PMSG pfn; // 成员函数指针,被统一抹成 AFX_PMSG 这个通用类型 }; struct AFX_MSGMAP { const AFX_MSGMAP* (PASCAL* pfnGetBaseMap)(); // 注意:是函数指针,不是对象指针 const AFX_MSGMAP_ENTRY* lpEntries; // 本类静态表首地址 };

六个字段里,nID和nLastID承担的是「一个条目覆盖一个 ID 区间」的职责,ON_COMMAND_RANGE这类宏之所以能一条记录管住几十个菜单项,靠的就是这里。nSig是整张表里最关键也最容易被忽略的字段:它不参与匹配,只在命中之后决定「这个成员函数指针该按哪种签名调用」。pfn被强转成统一的AFX_PMSG,是因为 C++ 不允许不同类型成员函数指针共存于一个数组,只能先抹平类型再挂标签。

AFX_MSGMAP里的pfnGetBaseMap是函数指针而非const AFX_MSGMAP*,这个设计不是随手写的。如果直接存基类表的地址,整个程序的静态初始化就产生了顺序依赖:派生类的静态表构造时,基类的静态表可能还没构造完,读到的是零。改成存一个返回地址的静态函数,真正取值推迟到第一次调用时,顺序问题就消失了。这是典型的「用间接层换初始化安全」。

2.2 三条宏展开后的真实形态

看一个具体的类展开成什么样。

// 类声明里 class CMyView : public CView { DECLARE_MESSAGE_MAP() // 只声明两个函数:一个静态、一个虚 }; // 实现文件里 BEGIN_MESSAGE_MAP(CMyView, CView) // 这里开始一个函数体 ON_WM_PAINT() ON_COMMAND(ID_FILE_SAVE, &CMyView::OnFileSave) END_MESSAGE_MAP() // 这里把静态表挂到基类上

DECLARE_MESSAGE_MAP()展开只做两件事:声明一个静态成员函数GetThisMessageMap(),声明一个虚函数GetMessageMap() const。表本身一行都没有。真正的表在BEGIN_MESSAGE_MAP到END_MESSAGE_MAP之间生成:

const AFX_MSGMAP* CMyView::GetMessageMap() const { return GetThisMessageMap(); // 虚函数只有这一层跳转,之后全是静态数据 } const AFX_MSGMAP* PASCAL CMyView::GetThisMessageMap() { typedef CMyView ThisClass; typedef CView TheBaseClass; static const AFX_MSGMAP_ENTRY _messageEntries[] = { { WM_PAINT, 0, 0, 0, AfxSig_vv, (AFX_PMSG)(AFX_PMSGW)( static_cast<void (AFX_MSG_CALL CWnd::*)(void)>(&ThisClass::OnPaint)) }, { WM_COMMAND, CN_COMMAND, (WORD)ID_FILE_SAVE, (WORD)ID_FILE_SAVE, AfxSigCmd_v, (AFX_PMSG)( static_cast<void (AFX_MSG_CALL CCmdTarget::*)(void)>(&ThisClass::OnFileSave)) }, { 0, 0, 0, 0, AfxSig_end, (AFX_PMSG)0 } // 哨兵,扫描到这里停 }; static const AFX_MSGMAP messageMap = { &TheBaseClass::GetThisMessageMap, // 指向基类的静态函数 &_messageEntries[0] }; return &messageMap; }

三个关键点。第一,_messageEntries和messageMap都是函数内的静态局部变量,构造发生在第一次调用GetThisMessageMap()时,天然规避了全局构造顺序问题。第二,messageMap里的基类指针指向TheBaseClass::GetThisMessageMap这个静态函数——它返回基类自己的表,于是整条继承链被串成一条单向链表,表头是派生类,表尾是最顶层基类。第三,数组末尾的哨兵项AfxSig_end值为 0,扫描循环拿它当终止条件,不需要单独存长度。

2.3 派生类在前、基类在后:查找语义如何等价于虚函数覆盖

AfxFindMessageEntry的实现简单到几乎不像框架代码:从lpEntries开始一项项往后比,nMessage匹配、nCode匹配、ID 落在[nID, nLastID]区间内,三条同时成立就返回。

const AFX_MSGMAP_ENTRY* AFXAPI AfxFindMessageEntry( const AFX_MSGMAP_ENTRY* lpEntry, UINT nMsg, UINT nCode, UINT nID) { while (lpEntry->nSig != AfxSig_end) // 哨兵收尾 { if (lpEntry->nMessage == nMsg && lpEntry->nCode == nCode && nID >= lpEntry->nID && nID <= lpEntry->nLastID) return lpEntry; ++lpEntry; } return NULL; }

调用方拿到GetMessageMap()返回的表头,先在派生类自己的表里找,找不到再顺着pfnGetBaseMap()走到基类的表继续找。派生类在前、基类在后,等价于虚函数的覆盖语义:子类重写了OnPaint,就一定先命中子类的条目。区别在于这个「覆盖」是运行时可查的——同一个CWnd*在分派时走哪张表,取决于它真实的运行时类型,而不是声明类型。这正是虚函数本来的行为,只不过底层从 vtable 索引换成了链表遍历。

这里有个值得单独拎出来的分叉:WM_COMMAND和WM_NOTIFY根本不走上面这条通用查找路径。它们进入OnWndMsg后立刻被拦下来转给OnCommand/OnNotify,再通过OnCmdMsg沿「视图 → 文档 → 框架窗口 → 应用对象」的命令路由链派发。所以菜单项和工具栏按钮的处理函数可以写在文档类里,尽管消息是发给框架窗口的。两条路径并存,是很多人第一次读框架源码时最困惑的地方。

3. 从窗口过程到成员函数:那一跳怎么把通用指针变回正确签名

表找到了、条目命中了,剩下的问题是:pfn是一个被抹平过类型的成员函数指针,怎么把它还原成一个能接收WPARAM和LPARAM、能返回LRESULT的调用?

3.1 签名标签与联合体:C++ 类型系统的一道缝隙

标准 C++ 里,void (CWnd::*)()和LRESULT (CWnd::*)(WPARAM, LPARAM)是两个完全不同的类型,既不能互相赋值,也不能放进同一个数组。框架的解法是先把所有成员函数指针强转成统一的AFX_PMSG存进表里,再用一个union和一个switch在调用点还原。

union MessageMapFunctions { AFX_PMSG pfn; // 通用槽,接收表里的原始值 void (AFX_MSG_CALL CWnd::*pfn_vv)(); // AfxSig_vv:无参无返回 void (AFX_MSG_CALL CWnd::*pfn_vw)(UINT); // AfxSig_vw:只取 wParam BOOL (AFX_MSG_CALL CWnd::*pfn_bw)(UINT); // AfxSig_bw:带返回值 LRESULT (AFX_MSG_CALL CWnd::*pfn_lwl)(WPARAM, LPARAM); // AfxSig_lwl:ON_MESSAGE 专用 }; // 分派点的简化形态 union MessageMapFunctions mmf; mmf.pfn = lpEntry->pfn; // 先按通用指针把值取回来 switch (lpEntry->nSig) // 再按标签选正确的成员函数指针类型 { case AfxSig_vv: (this->*mmf.pfn_vv)(); // 直接调,无参 break; case AfxSig_vw: (this->*mmf.pfn_vw)((UINT)wParam); // 只传 wParam break; case AfxSig_lwl: lResult = (this->*mmf.pfn_lwl)(wParam, lParam); // 两个参数都传 break; default: ASSERT(FALSE); // 标签表与函数表不一致 break; }

AfxSig_系列枚举是一张「调用约定清单」:AfxSig_vv表示无参无返回,AfxSig_vw表示只吃一个UINT,AfxSig_bw表示吃一个UINT返回BOOL,AfxSig_lwl表示吃WPARAM + LPARAM返回LRESULT。不同的消息宏会挑不同的标签:ON_WM_PAINT用AfxSig_vv,ON_WM_TIMER用AfxSig_vw(只关心定时器 ID),ON_MESSAGE用AfxSig_lwl。

这里有个血泪经验值:ON_MESSAGE的处理函数签名必须严格是LRESULT (CWnd::*)(WPARAM, LPARAM),写成void返回或者参数写成UINT都能编过(因为被强转了),但运行时会读到栈上错位的返回值。编译器一句警告都不给,全靠nSig这一个整数在运行时兜底。

3.2 消息缓存:线性扫描之外还有一层哈希

如果每条消息都要从派生类表头一路扫到最顶层基类,一个WM_MOUSEMOVE密集的界面会很难看。所以OnWndMsg在查表之前先过一层缓存。

缓存是一张固定大小的全局表,每一项记录三元组:pMessageMap(哪张表)、nMsg(哪个消息)、lpEntry(上次命中的条目)。索引由(表地址 ^ 消息号)哈希得到。命中就直接拿条目,未命中才调用AfxFindMessageEntry做线性扫描,扫完把结果填回缓存。表地址参与哈希意味着不同窗口类、不同继承链各自占不同的槽位,不会互相踩。

缓存是跨线程共享的全局资源,因此访问被CRIT_WINMSGCACHE这把临界区锁包住。这个细节解释了一个常见现象:多线程同时创建大量窗口时,界面会有一瞬间的顿挫——不是绘制慢,是几十个线程在抢同一把锁填缓存。

3.3 静态窗口过程怎么找回 CWnd 对象:从 HWND 到 this

入口处是一个全局的AfxWndProc,它拿到的是裸HWND,没有任何this指针。找回对象靠CWnd::FromHandlePermanent(hWnd)——只查线程状态里的永久映射表。如果查到了,就走AfxCallWndProc,在进入用户代码之前把当前消息保存进线程状态的m_lastSentMsg(这是GetCurrentMessage()能工作的前提),然后用 try/catch 把WindowProc包起来,异常统一交给ProcessWndProcException。

对象与窗口的绑定发生在创建阶段。CWnd::CreateEx在调用CreateWindowEx之前,会先挂一个 CBT 钩子;窗口过程第一次被调用时,钩子回调把this和刚创建的HWND绑定进映射表,然后摘钩。这个「先挂钩子、再建窗口、建完摘钩」的顺序解决了鸡生蛋问题:WM_NCCREATE到来时,FromHandlePermanent还查不到东西,但钩子已经把它填进去了。

3.4 永久映射与临时映射:两条完全不同的命运

FromHandle是另一个入口,行为和FromHandlePermanent不一样。它先查永久表,查不到就new一个临时CWnd对象,把m_hWnd塞进去,挂在线程状态的临时表里。这意味着「用一个HWND就能拿到一个CWnd*」这件事,代价是可能凭空造出一个对象。

临时表不会无限增长。消息泵在空闲阶段会调用AfxUnlockTempMaps统一清理,所有临时对象被delete掉。这个设计的用意是让「随手拿一个CWnd*用完就扔」变得廉价,不必为每个窗口句柄维护一个长期对象。

代价也很直接:临时对象的寿命不跨消息循环。把FromHandle的返回值存进成员变量、或者PostMessage时把CWnd*当参数传出去,下一个消息循环里这个指针大概率已经指向释放过的内存。这是框架里最经典的一类崩溃来源,第 6 章还会展开。

至于永久对象的销毁,CWnd::PostNcDestroy默认实现里有一句delete this,由成员m_bAutoDelete控制。也就是说,DestroyWindow()是销毁HWND,delete是销毁 C++ 对象,两者被刻意分开。框架窗口、视图窗口会覆写这个函数把自动删除关掉,因为它们的所有权在文档模板手里。搞混这两条销毁路径,就是重复释放那一类问题的源头。

4. 文档、视图、框架:三个角色靠什么协议串起来

消息能派发了,窗口对象不丢了,接下来是整个框架最像「框架」的部分:把「数据」「显示」「外壳」拆成三个类,再用一套模板对象把它们装配起来。这套装配不是靠继承,而是靠约定好的调用顺序和数据流。

4.1 CDocTemplate 的装配顺序:谁先 new,谁后收到 OnInitialUpdate

单文档模板打开一个文件的骨架大致如下。

// 单文档模板打开文件的骨架(简化,省去错误处理与多文档分支) CDocument* CSingleDocTemplate::OpenDocumentFile(LPCTSTR lpszPathName, BOOL bMakeVisible) { CDocument* pDocument = NULL; if (m_pOnlyDoc != NULL) pDocument = m_pOnlyDoc; <p> <a href="https://download.csdn.net/download/chqzeng/1716399" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询