缘起
实际项目中,resource.h 文件中的控件太多太乱了,合并代码的时候非常痛苦。为了解决这个问题,需要对 resource.h 中的 ID 进行整理。根据之前整理的成果,很快把控件 ID 按对话框分类整理好了。没想到测试的时候遇到了各种崩溃,废了好大劲儿才解决。究其原因,是对 MFC 资源管理机制认识不够深刻。尝试创建某个模块内的对话框的时候,意外地找到了其它模块中的对话框资源。MFC 到底是怎么查找资源模块的呢?应该如何排查这种问题呢?一起来看看吧。
背景介绍
根据之前的认知,同一个模块中,对话框 ID 不能重复,对话框之间的控件 ID 是可以重复的。因为查找资源的时候是分层查找的,先找到对应的对话框资源,然后在此对话框范围内再查找控件。如果认知没错,那么不同对话框可以使用重复的控件 ID ,也就是说对话框 A 用到的控件 ID 值可以与对话框 B 用到的控件 ID 值一样。以此为依据,我遍历并解析了 .rc 文件中的所有对话框及其控件 ID,并以对话框为组,把每个对话框用到的控件 ID 重新归类。整理后的 resource.h 如下图所示:

IDD_DIALOG1 和 IDD_DIALOG2 都用到宏名为 IDC_BUTTON1 和 IDC_BUTTON2 的按钮。因为有多个对话框用到了同样的控件名,需要把重复的控件名整理到 IdGroup.Common 组下,IDC_BUTTON3 和 IDC_BUTTON4 的值都被设置为了 20003。整理前,控件 ID 是递增排列的,整理后两个对话框中的控件 ID 值是完全重复的。
理想很丰满,现实很骨感
在本地测试通过后,就开始在实际项目中实战了。没想到遇到了各种各样的编译问题。
比如,.rc 中用了一个控件名,实际代码中用了另外一个名字(使用 vs 修改控件名就会产生名称不同,但是值相同的宏)。
又比如,代码中用到了 .rc 中没有的控件,GetDlgItem(IDC_NOT_EXISTED)->ShowWindow(SW_HIDE),这个肯定是错误,只不过相关的代码已经废弃不用了,所以一直没出问题。
说明: 因为整理后的
resource.h中的控件id完全是根据.rc文件来的,所以会有编译问题。
解决完这些编译问题后,终于可以测试功能是否受影响了。没想到刚开始测试就“翻车”了……
意外的崩溃
没想到测试到第 2 个功能的时候就崩溃了。心里咯噔一下。难道之前的认知错了?不管怎么样,需要先把崩溃问题解决掉。先看看是哪里崩溃了,原来是下面的代码导致了崩溃:
1 | GetDlgItem(IDC_BTN_TITLE)->ShowWindow(SW_HIDE); |
而且从错误提示看,是个内存访问违例,很显然应该是 GetDlgItem(IDC_BTN_TITLE) 没取到对应的控件,返回了 NULL。这让我更虚了,因为这次主要的改动就是整理控件 ID。根据经验,很可能是 IDC_BTN_TITLE 宏的值与生成后的二进制资源没对应上。
使用 resource hacker 查看编译生成模块中的对话框资源,可以正常解析。又在 vs 中确认了 IDC_BTN_TITLE 的值,跟模块中的结果是匹配的。因为 vs 有时候会出现一些诡异的 bug,我又确认了对应的反汇编代码,确定一切都是正常的。
难道真的是我之前的认知出现了偏差?于是赶紧又写了一段测试代码,还是没问题。google、bing、baidu 都没搜到有用的资料。
难道就这样放弃,不处理了?放弃是不可能放弃的。找不到资料,只能自己调试了。
上调试器
一般调用某个 API 失败都会有错误码,可以通过 GetLastError() 或者在 vs 的 watch 窗口中输入 $err,hr 查看。

从错误提示可知,无效的窗口句柄,说明创建对话框的时候就失败了,而不是调用 GetDlgItem(IDC_BTN_TITLE) 在对话框中查找控件的时候才失败。这说得通,因为使用 resource hacker 查看到的资源确实是对的。
说明:
为了更好的调查问题,我把原代码中的
GetDlgItem(IDC_BTN_TITLE)->ShowWindow(SW_HIDE);拆分成了两句auto title = ::GetDlgItem(m_hWnd, IDC_BTN_TITLE); title->ShowWindow(SW_HIDE);
GetDlgItem(IDC_BTN_TITLE)内部会调用CWnd::FromHandle(::GetDlgItem(m_hWnd, nID));,会导致LastError的值被覆盖。如下图:
但是对话框为什么会创建失败呢?调呗,单步步入对话框创建函数(这里用的是 Create()),发现跟不进去,因为没有对应的调试符号。Create() 失败后,使用$err,hr 或者手动调用 GetLastError() 函数得到的都是 ERROR_SUCCESS,没有任何帮助。到这里真有打退堂鼓的想法了。查也查不到,调也调不了(缺少调试符号,没法调试源码)。
说明: 当时调试的时候,是在内网环境,无法连接微软的符号服务器。
救星来了
费了好大劲儿终于把调试符号拷贝到内网环境。但是因为调试符号与内网的 dll 不匹配,vs 又不能像 windbg 那样强制加载不匹配的调试符号。又折腾了半天符号加载的问题,使用 chkmatch 修改调试符号后, vs 还是加载不上。最后无奈,只能使用 windbg 调试了。
说明: 关于
chkmatch还有一段故事,后面有机会总结成文,分享给大家
上 windbg
使用 windbg 附加进程后,加载好对应模块的调试符号并在调用 Create() 函数的地方设置好断点,然后让程序重新运行起来,中断后,单步步入(跟 vs 一样,F11),简单跟踪了一下,没发现异常。::CreateDialogIndirect() 返回 NULL,LastError 的值是 0。后面调试时,发现 hInst 的值有些奇怪,不是预期的模块。hInst 本来应该是 AssemblyDesign_Tools.dll,但是现在却是 PBimsPCDrawing.dll。

这很不正常!说明在创建对话框的时候,在错误的模块 PBimsPCDrawing.dll 中找到了对应 ID 值的对话框。手动修改 PBimsPCDrawing.dll 中相应的对话框 ID 为其它值,再次编译运行,果然不再崩溃了。
但是为什么在创建对话框的时候,会到 PBimsPCDrawing.dll 中查找,而不是到预期的 AssemblyDesign_Tools.dll 中查找呢?
hInst 从哪来
简单排查后发现 hInst 来自 AfxFindResourceHandle() 的返回值。此函数内部会按照如下顺序查找:
如果
AfxGetModuleState()不是系统模块(通过m_bSystem判断),则调用AfxGetResourceHandle(),内部又会调用afxCurrentResourceHandle宏,展开后是AfxGetModuleState()->m_hCurrentResourceHandle。如果步骤 1 没找到的话,会继续到
AfxGetModuleState()->m_libraryList列表里的非系统模块中查找。如果步骤 2 也没找到的话,会到
AfxGetModuleState()->m_appLangDll中找。如果步骤 3 也没找到的话,并且当前是系统模块,会到
AfxGetResourceHandle()中找。如果步骤 4 也没找到的话,会继续到
AfxGetModuleState()->m_libraryList列表里的系统模块中查找。如果以上步骤都没找到的话,会直接返回
AfxGetResourceHandle()。
在我们的程序中,在 步骤 2 找到了错误的模块。因为在我们的程序中,会把模块信息添加到 AfxGetModuleState()->m_libraryList 中,而且 PBimsPCDrawing.dll 在 AssemblyDesign_Tools.dll 前面。
说明: 根据上述逻辑,只要在创建对话框之前,调用
AfxSetResourceHandle()把对话框所在的模块设置为当前的资源模块即可保证加载正确的对话框资源。
CDynLinkLibrary
经过简单代码搜索,发现每个模块都会使用 CDynLinkLibrary 把自己加到 AfxGetModuleState()->m_libraryList 中。
CDynLinkLibrary 的构造函数会保存传入的 hModule,并把自己加到 m_pModuleState->m_libraryList 中。

至此,基本弄清了创建对话框失败的原因 —— AfxFindResourceHandle() 返回了错误的模块。
AssemblyDesign_Tools.dll 模块先被加载,在其 DllMain() 中把自己加到 AfxGetModuleState()->m_libraryList 中,然后 PBimsPCDrawing.dll 模块也被加载,也在 DllMain() 中把自己添加到 AfxGetModuleState()->m_libraryList 中,而且添加到 AssemblyDesign_Tools.dll 模块的前面。当创建 AssemblyDesign_Tools.dll 中的对话框时,如果 PBimsPCDrawing.dll 模块中包含相同 ID 的对话框,那么会加载错误模块中的对话框资源。
收工?
本以为到此就可以再水一篇文章了。但是当我在本地按照上述逻辑新建测试工程,准备重现时,发现测试程序能正常创建对话框。调试确认在创建 Dialog1 中的对话框时,AfxFindResourceHandle() 返回的确实是另外一个模块的句柄。
为什么同样是找到了错误的模块,在测试程序中创建对话框成功了,但是在实际项目中却创建失败了?看来,虽然加载了其它模块的对话框资源,但是创建对话框不一定会失败。难道是其它原因导致的崩溃?
继续调查
使用 windbg 附加到进程,中断下来后,执行到创建对话框的关键函数 ::CreateDialogIndirect() (实际上是从此函数开始没有源码可跟了)。在此函数开头位置执行 wt,经过漫长的等待,查看统计结果,发现有对 win32u!NtUserCreateWindowEx 的调用。
在 win32u!NtUserCreateWindowEx() 中设置断点,重新运行程序。中断后,执行 gu 让该函数执行完,然后执行 r 命令查看寄存器,rax 寄存器保存了执行结果。当 rax 的值是 0 时,说明创建窗体失败了。这时候执行 !gle 可知错误码是 0x57f(也就是十进制的 1407)。

经过查询可知,这个错误码是 ERROR_CANNOT_FIND_WND_CLASS,也就是 找不到窗口类。居然是窗口类没有注册!要知道一般的控件已经注册过了。遇到窗口类没注册的错误,大概率是使用了自定义控件类。
说明: 可以使用如下命令在
NtUserCreateWindowEx()失败时自动中断下来,并显示LastError。
bp win32u!NtUserCreateWindowEx "bp /1 @$ra \".if (@rax == 0) {.echo ****; r; !gle } .else {gc;}\";gc;"
@$ra表示返回地址。bp /1 @$ra表示在函数返回地址处设置一次性断点。
MFCGridCtrl
于是赶紧查看 .rc 中对应的对话框内容,发现了一个可疑的控件 —— MFCGridCtrl。

这个名字太有迷惑性了,乍一看还以为是 MFC 提供的呢。google 之后发现是三方控件,源码可以在 codeproject 上找到。
下载相应源码并把关键文件添加到项目中,关联好对应的控件后,重新编译运行,这次果然没报错了。
破案
至此,这个问题算是水落石出了 —— 实际项目中创建对话框失败是因为对话框中使用了未注册窗口类的控件。创建对话框的时候会依次创建对话框中的控件,在创建这个没注册窗口类的控件时失败了,从而导致整个对话框创建失败。
这个 bug 这么长寿?
按理说,这个问题应该很容易被测试出来才对。带着这个疑问,搜索了一下项目代码,发现已经没有代码在使用这个对话框了。所以,实际项目中不会出问题。直到这次意外的 “撞衫”,这个问题才显现出来,有点“坑”。
后记
后来在折腾符号加载时,发现 chkmatch 修改完的调试符号是可以被 vs 加载的。是我当时手动修改 dll 名字的时候改错了,vs 在加载 mfc140u.dll 的调试符号时,只会查找名字为 mfc140u.amd64.pdb 的调试符号,而我却手动改成了 mfc140u.pdb,所以 vs 加载调试符号失败了。可以在模块对话框(可以通过 ctrl + alt + u 打开)中指定的模块上右键,符号加载信息 查看某个模块对应的符号文件加载信息。

总结
调试符号对于调试简直太重要了。
创建对话框之前,务必确保对话框所在的模块是默认的资源模块,否则可能加载到其它模块中的对话框资源。
不同对话框中的控件
ID可以重复,但是同一个对话框中的控件ID不能重复。一般
vs不能加载不匹配的调试符号,可以使用chkmatch修改pdb使其与dll匹配。
参考资料
https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--1300-1699-
https://learn.microsoft.com/en-us/cpp/mfc/reference/afx-extension-module-structure?view=msvc-170
