前言
MFC是一项比较古老的编程技术,MFC将Win32窗口程序的函数封装成类,简化了编程难度。相对早期编程技术而言,MFC中新颖的就是消息映射了,记得本科时经常写MFC的基于对话框的窗口程序。那个时候对消息映射不理解,只知道这么用就可以了。现在有时间了,就可以研究一下。
代码分析
使用VS2017创建了一个基于对话框的MFC程序,解决方案的名称是Test,在窗口上添加了一个按钮,并且创建了按钮的单击事件,该事件完成MessageBox(L"Hello World!");的弹出。运行时,点击按钮将会产生如下运行结果
这里,我们主要关注的是这个MFC程序的消息映射部分。在代码中也就是:
`BEGIN_MESSAGE_MAP(CTestDlg, CDialogEx)`
`ON_WM_SYSCOMMAND()`
`ON_WM_PAINT()`
`ON_WM_QUERYDRAGICON()`
`ON_BN_CLICKED(IDC_BUTTON1, &CTestDlg::OnBnClickedButton1)`
`END_MESSAGE_MAP()`
这是一些宏组合而成的一段代码,需要对宏进行展开。代码未进行宏展开前是这样的:
代码宏展开后是这样子的:(为了便于观察,我又删除了影响阅读的内容,调整了代码的格式,但并未改变程序的逻辑)
从展开的代码可以看出,这个消息映射部分展开成两个函数,分别是CTestDlg::GetMessageMap()和CTestDlg::GetThisMessageMap()。注意:CTestDlg是本Test窗口的窗口类的类名。
CTestDlg::GetMessageMap()函数是直接调用了本类的CTestDlg::GetThisMessageMap()函数。而CTestDlg::GetThisMessageMap()函数内部则更简单,仅仅是创建了2个静态全局变量:_messageEntries数组和messageMap变量。然后返回messageMap的地址。静态全局变量在编译的时候就会初始化完毕并且不再改变值。因此CTestDlg::GetThisMessageMap()函数实际上就是仅仅返回了messageMap的地址。
_messageEntries数组元素的类型是AFX_MSGMAP_ENTRY,其定义可以通过查看源代码进行查看。如下:
能够看出结构中重要的部分是:nMessage消息类型、nID控件的ID、pfn回调函数的地址。
这个结构体表明了MFC中消息映射的保存方式:保存在AFX_MSGMAP_ENTRY结构体中。
_messageEntries数组的长度等于映射的消息数量+1,最后一个是NULL,表明消息映射结束。
再次查看消息映射的部分:
实际上BEGIN_MESSAGE_MAP宏定义了CTestDlg::GetMessageMap()函数和CTestDlg::GetThisMessageMap()函数的前部分,CTestDlg::GetThisMessageMap()的中间部分由BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间的消息映射宏定义,而END_MESSAGE_MAP宏则完成了CTestDlg::GetThisMessageMap()后半部分的定义。
而这两个函数的声明则在CTestDlg的头文件中用宏来定义。
综上可以看出,MFC的消息映射通过宏来简化定义和严格规范,而这些宏的实质就是在为窗口类添加两个成员函数GetMessageMap()和GetThisMessageMap(),以便父类进行调用(由于本类未调用,那父类一定进行了调用),而这两个函数中GetMessageMap()是虚函数,那么我们寻找CTestDlg类的父类对这个虚函数的定义和调用。我们不断的查找父类发现类的继承关系如下:
CTestDlg <- CDialogEx <- CDialog <- CWnd <- CCmdTarget <- CObject
我们在CWnd类中查找到了对GetMessageMap()函数的调用,调用发生在CWnd::OnWndMsg(UINT message, WPARAM wParam, LPARAM lParam, LRESULT* pResult)函数中,如下图所示:
我们查看CWnd类对本函数的实现:
好吧,CWnd类对这两个函数也是用宏进行定义的。
那么,我们在其基类中继续寻找吧。终于,在CCmdTarget中找到了对GetMessageMap()的定义:
这里的定义已被子类们反复重写了,已经没有用了。不过,我们看到了子类对这两个函数的定义与CCmdTarget类的定义形式上是一致的。
此时,思考下,微软为什么从CWnd类开始都使用宏来定义这两个函数呢?
这是因为MFC的控件都会继承该类;另外,用宏定义能够故意的将这两个函数拆分,可以防止MFC编写者不按照约定编写这个函数也可以防止在这两个函数中添加其它的代码。也就是说,微软为了强制要求MFC程序员一定要这样编写,不这样编写就编译不通过。如果不用宏而让MFC程序员手动编写的话,MFC程序员很容易不按照规范来,虽然编译通过了,但是却无法正常运行。
好了,看到这里,我们大概了解了消息映射的基本过程:MFC程序为了获得程序的事件消息回调函数表,只需获得messageMap这个静态全局变量的地址就好了。因为它的第二个元素记录了_messgaeEntries数组的地址。获得了这个地址,就可以轻松的查看所有的MFC窗口程序的事件的回调函数地址了。
现在,我们通过做试验验证以上结论。
验证试验
我们在CTestDlg类的GetMessageMap()函数处下断点:
点击Visual Studio 2017的调试按钮进行调试,发现成功在该处命中断点:
查看调试窗口下方的调用堆栈选项卡窗口,可以看到调用来自mfc140ud.dll,双击跟进去,来到了第2207行。(注意:随着MFC库版本不同。对应的mfcxxx.dll文件也不同,调试的时候需要加载该dll的符号文件,才能查看这个dll的源码)
发现调用源头的确是在CWnd::OnWndMsg(UINT message, WPARAM wParam, LPARAM lParam, LRESULT* pResult)函数中。该函数依然是一个虚函数。
于是乎,验证了上面的猜想。注意:由于mfc具有不同的版本,因此会有不同的诸如mfcxxx.dll的文件与之对应。那么dll里的函数地址就不是固定的。因此,我们没有必要从mfc的dll着手找到messageMap的地址。
说了这么多,如何才能用最快的方式得到消息映射的回调函数地址呢?
回调函数地址定位方法
方式一:利用虚函数表
前面讲到GetMessageMap()函数是窗口类的一个虚函数,那么可以通过mfc程序的窗口类,在创建该类的对象的时候获得的this指针,找到虚函数表。虚函数表里面一定有一个是GetMessageMap()函数的地址,然后再跟进去找到GetMessageMap()函数的地址,最后拿到函数的返回值存放在eax寄存器中,就是messageMap的地址。那么messageMap+4就是消息映射的AFX_MSGMAP_ENTRY数组。然后在这个数组里面就能找到消息映射的回到函数的地址。
问题1:如何快速获得窗口类的实例化对象的this指针?
MFC程序在启动时会调用getMoudleHandleA函数,在其后面会接着call _twinmain函数,这个函数的内部只有一个调用,即call afxWinMain函数,该函数的返回值,保存在eax中即是this指针。注意:只有getMoudleHandleA这个函数是win32 API,其他函数都是mfcxx.dll中的函数,并未导出函数名称,因此是找不到这些函数的名称的。
问题2:通过this指针找到虚函数表?
This指针保存的值是对象关联的私有成员的起始地址,这个起始地址就是对象的虚函数表之后是其它成员的数据。
找到虚函数表后就可以在这个表中确定GetMessageMap()函数的地址。接下来的事情就顺理成章了。
方式二:内存搜索法
为什么要使用内存搜索呢?
因为保存AFX_MSGMAP_ENTRY数组的数据声明的时候就是全局静态变量。这表明,一般而言:这个数组一定存放在mfc程序的exe镜像的全局数据区域,并且这些数据是在编译的时候确定的,在程序运行的时候不会对这些数据进行修改(可能会在程序运行之前被操作系统使用重定向功能修改)。我们再思考一下,一般一个mfc程序都是用模版创建的,而且只要mfc程序员不刻意修改消息映射部分的代码,那么消息映射中必定含有如下部分:
这里面的红色框框部分是他们必定含有部分,一般来说这三行代码的顺序不会发生变化(发生变化是由于mfc程序员可以调整顺序导致的,或者是程序发生了重定向,一般exe不会发生重定向),因为他们对应着窗口类的源代码的消息映射部分的代码:
由此可知,他们编译后的数据一定会存在这样几种组合:
12 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1f 00 00 00 xx xx xx xx
0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 13 00 00 00 xx xx xx xx
37 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 29 00 00 00 xx xx xx xx
这样一来,我们只需要在内存中对mfc的exe的全局数据区域搜索这三条中的某一条即可。
这样我们就找到了所有的消息映射函数地址了。
问题1:有没有更快的内存搜索方法?
有的,对于按钮的单击事件,我们只要知道按钮的资源ID就可以快速搜索到其按钮事件的地址。
我们根据AFX_MSGMAP_ENTRY的结构计算前20个字节。
nMessage=0x0111,nCode=0,nID=按钮ID,nLastID=按钮ID,nSig=58,
本试验程序按钮ID是1000=0x03e8因此应当搜索
11 01 00 00 00 00 00 00 e8 03 00 00 e8 03 00 00 3a 00 00 00
下面,我们用OD调试程序:
用OD载入Test.exe程序的release版本。注意:测试程序为了方便观察,连接程序时关闭了随机基址并且设定程序基址为0x400000。
在OD中使用快捷键Alt+M切换到内存映射窗口,选择Test.exe的起始地址段,使用快捷键Ctrl+B弹出搜索窗口,在其中输入搜索的数据,注意:不要勾选“整个块”选项,同时也应该注意数据搜索的内存区域是否在范围内:
点击确定就开始进行搜索了。不到1秒中,就得到了结果(如果没有搜索到,就使用Ctrl+L快捷键继续查看下一个搜索结果,直到OD底部提示没有找到)。
好了,可以看出0x401860就是按钮事件的地址。
我们在OD的CPU窗口转到地址0x401860处,发现它正好是按钮的事件函数。注意:这里由于OD载入了Test.exe的pdb文件,所以能够显示其函数名。
我们将Test.exe路径下的Test.pdb删除,同时删除OD保存的调试数据的备份文件,再次用OD查看该地址:
实际上,我们可以更简单的搜索:直接搜索按钮的ID。
比如按钮ID是1000=0x03e8,那么我们使用上面同样的方式,直接搜索e8 03 00 00 e8 03 00 00就可以了。这样搜索,一般搜索的第一次结果都是正确的。
不过,这样搜索可能会搜索得到的是该控件其它的事件的回调函数地址,因为一个控件可以映射多个事件。
所以保险的搜索方法是:
使用11010000 00000000 IDIDIDID IDIDIDID 3a000000来搜索控件被单击事件回调函数的地址。
使用12010000 00000000 00000000 00000000 1f000000来搜索映射表的起始地址
注意:最后的四个字节可能会随着版本不同而不同。
问题2:诸如按钮等控件的ID如何获得?
在OD启动被调试的MFC程序的窗口显示后,暂停该程序。在OD的查看窗口中即可查看所有控件的ID。如下图所示,我们获得了按钮的ID是0x03e8。
作者:J坚持C