缘起
前一段时间在折腾拆分 rc
的问题,已经把遇到的问题整理成文了。感兴趣的小伙伴儿可以参考这里,这里 和 这里。本以为不会有问题了,后续流程就请其它同事帮忙处理了,没想到在拆分实际项目时遇到了一个非常奇怪的链接问题。
本文总结了使用 process monitor
监听进程创建,查看进程参数、使用 gflags
设置 Image File Excution Options
、使用 IDA
静态分析相关函数的业务逻辑以及使用 windbg
进行动态调试的整个过程。我认为这是一个由不良的编程习惯与 crt
的限制共同导致的问题。快来一起看看吧。
初闻错误
前些日子,在家隔离办公的某日中午,收到同事发来的信息说 rc
拆分的编译问题已经解决了,但是遇到了链接错误,还发送了链接错误的截图,并且给出了一个解决方案。
尝试把
.rc
文件排除几十个就链接过去了。
听到这个问题的时候,我怀疑是不是哪里操作有问题。从错误提示看是 无法打开 xxx.res 进行读取
,所以第一感觉是文件路径不对。于是赶紧跟同事聊了一下,同事觉得是 vs
的限制,可能这个限制数量是 512
。
但是我从没听过同一个工程中的 .rc
文件有数量限制,不管怎样,还是建个简单的工程验证下吧。
尝试重现
带着怀疑 + 好奇的心态,我快速新建了一个 MFC
对话框工程。然后在 vs
中不断复制默认对话框(大概复制了600
个,已经比同事所说的 512
上限要多了,如果有问题应该能重现了),然后使用工具把每个对话框拆分成独立的 .rc
文件并添加到工程文件中。保存好工程后,开始编译。等待一段时间后,果然报错了,错误截图如下:
从错误提示看,处理 dialog_testmultiplerccompile_dialog507.rc
文件的时候报错了。按照同事说的,删除若干个 .rc
文件,只保留 500
个,再次编译,没有报错。
看来,在同一个工程中包含太多 .rc
文件真可能有问题。难道真有限制?为什么会做这种限制呢?不管为什么要做限制,我需要找到一个解决方案。
开始深入调查前,先看看报错信息。
熟悉的错误
之前遇到过错误 LINK : fatal error LNK1123: 转换到 COFF 期间失败: 文件无效或损坏
,是由于 link.exe
与 cvtres.exe
的版本不一样导致的。这次报错不是这个原因。通过 process monitor
看,这两个程序的路径是一样的。
再看错误 error CVT1101: 无法打开“dialog_testmultiplerccompile_dialog507.res”进行读取
。猜测是在读取这个文件的时候发生了错误,可以在 process monitor
中查看相关事件。
过滤相关事件
在 process monitor
中根据路径名进行过滤。如果路径以 dialog_testmultiplerccompile_dialog507.res
结尾则包含,如下图:
没想到一条记录都没有,一片空白。这是怎么回事?说实话,我有点不知所措,看来只能硬着头皮调试 + 用 IDA
逆向了。在调试之前,先用 IDA
看看有没有什么发现。
请出 IDA
使用 ida32
打开 cvtres.exe
,IDA
会提示是否查找符号(真是一个好消息),当然选择是。等待 IDA
分析完成后,在左侧的 Function window
中找到 _main
,双击查看反汇编代码,直接在反汇编窗口按 F5
,查看伪代码( IDA
的 F5
真香!)。
大概浏览后,基本明白了 main()
函数的整体流程。首先,解析传入的参数,确定第一个文件在参数列表中的索引位置。然后,从此索引开始循环调用 ReadResFile()
读取每个文件,读取完所有的文件后统一调用 CvtRes()
函数进行转换。
下图是在 IDA
中对 main()
函数使用 F5
获得的伪代码的后半部分。
其中的 CvtRes()
函数应该是转换的主要函数,非常值得怀疑。迫不及待的启动 windbg
准备调试,但是 cvtres.exe
是被 link.exe
调用的,该如何调试呢?
搭建调试环境
如果 cvtres.exe
启动的时候,能够自动中断到调试器中,就可以方便的调试了。之前在 调试实战 | 全局变量初始化顺序探究 中介绍过使用 gflags
进行设置的方法。
根据之前调试 cl.exe
的经验,如果长时间中断到调试器中,调用者会重新启动 cl.exe
。猜想这里也会有类似的逻辑。为了避免这种问题,需要根据 link.exe
启动 cvtres.exe
的参数手动运行 cvtres.exe
。
可以通过 process monitor
很快找出 cvtres.exe
需要的参数。经过简单观察,发现传递给 cvtres.exe
的参数比较简单直接,而且根据 cvtres.exe /?
提供的帮助信息,可以很快确定各个参数的意义。
于是很快写出了一个批处理脚本,如下图:
没想到,双击脚本运行的时候,出现了如下错误:
提示找不到 cvtres.exe
。看来需要使用完整路径。正确的脚本如下:
说明: 为了避免命令行参数过长,我特意简化了
.res
文件名,之前的名字太长了。而且经过测试,打开510.res
的时候就能重现,没必要准备600
多个.res
进行测试,这里只准备了511
个.res
文件进行测试。
猜错了
双击脚本启动 cvtres.exe
,立刻就中断到了 windbg
中。
在 windbg
中执行 x cvtres!*main
即可找到入口函数,输入 bp cvtres!wmain
即可在 wmain()
函数入口处设置好断点。
同理,执行 x cvtres!*CvtRes
即可找到 cvtres!CvtRes()
函数,输入 bp cvtres!CvtRes
即可在 CvtRes()
函数入口处设置好断点。
设置好断点后,输入 g
让程序跑起来,可以发现 wmain()
函数内的断点命中了,但是 CvtRes()
函数内的断点并没有命中,进程直接退出了。
有些出乎意料,居然不是在 CvtRes()
函数里出的错。没(有)关(点)系(懵),继续挖掘有效信息。
继续努力
虽然进程退出了,但是依然可以通过 k
系列命令查看调用栈,在 windbg
中输入 kp
,如下图:
上图中红色高亮部分就是关键调用栈。从上图还可以得到一个非常有用的信息 —— exit code
的值是 1
。可以猜测,link.exe
就是根据 cvtres.exe
的返回值来判断其是否执行成功的。
调用栈中的 OurFileOpen()
函数,应该是负责打开文件的函数。在继续调试之前,先在 IDA
中看看 OurFileOpen()
函数的实现。
回到 IDA
双击 OurFileOpen
,当然是直接查看 F5
的结果啦,有细节需要确认再看反汇编代码。
可以看到这个函数实现的非常简单,就是调用 _wfsopen()
,如果失败(result == 0
)那么调用 ErrorPrint()
打印错误信息。如果 open_mode
(第二个参数)是 0
,那么传递给 ErrorPrint()
的第一个参数是 1101
,否则是 1108
。
而调用 OurFileOpen
时传递的第二个参数是通过 edx
传递的,对应的值是 0
,所以如果出错,那么会传递 1101
。
说实话,看到 OurOpenFile()
函数中的 1101
,我太激动了,因为在vs
中看到的错误提示是 error CVT1101: 无法打开“xxx.res”进行读取
。为了进一步确认猜想,在 IDA
中查看 ErrorPrint()
函数的反汇编代码,如下图:
从上方红色高亮语句 CVTRES: fatal error CVT%04u:
基本可以确定猜测是正确的。从上图底部的红色高亮区域还可以知道该函数内部确实会调用 exit(1)
来结束进程。
接下来需要调查的问题是 _wfsopen
为什么失败了?
为什么 _wfsopen 会失败?
在 windbg
中输入 .restart
重启目标程序,输入 bp MSVCR120!_wfsopen
,然后执行 g
命令。因为已经设置好了符号查找路径,所以 windbg
自动打开了对应的源码文件。
这个函数虽然很简单,加上注释不到 50
行。但是会被调用很多次,根据经验,前面的 500
多次调用都没有问题,在尝试打开 510.res
的时候会有问题,所以设置一个条件断点非常有必要。
简单查看反汇编代码发现,_wfsopen()
函数的第一个参数是通过 ecx
传递的,可以设置如下的条件断点(真是烧脑还不好理解,我不会告诉你,我尝试了很久才写出了下面这段蹩脚的脚本):
1 | bp MSVCR120!_wfsopen "aS /mu $myFileName @ecx; .block {.echo $myFileName; r @$t0=$spat(@\"$myFileName\", @\"*510.res\"); .if(1==$t0){.echo **** bang ****} .else{ gc;} };" |
耐心等待一会就中断下来了,如下图:
单步走两步,发现是 _getstream()
出错了。
_getstream 错在哪里了?
输入 .restart
重启目标程序,并且设置好条件断点,重新运行程序,当中断到 _wfsopen()
函数后,单步步入到 _getstream()
函数中。
可以看到 _getstream()
函数逻辑也不复杂,根据注释可以很简单的理解此函数的逻辑 —— 从 __piob
中(大小是 _nstream
,通过 dt _nstream
可知其大小是 512
)找到一条可用的记录项。判断一条记录项是否可用的标准是 __piob[i] == NULL
,或者 !inuse( (FILE *)__piob[i] ) && !str_locked( (FILE *)__piob[i] )
。
直接在函数末尾加好断点,g
起来,发现确实没有找到一条可用的记录项。
至此,我大概明白了整个过程。cvtres.exe
在 main()
函数中会循环调用 ReadResFile()
函数(内部会调用 _wfsopen()
)读取所有的 .res
文件,但是读取完一个 .res
文件后,并没有关闭,当打开一定数量的文件后会导致 __piob
被占满。再尝试打开一个文件的时候就报错了。
看来,crt
还有最大打开文件数的限制,赶紧 google
搜索是否有什么设置可以调整最大文件打开数量。
google 一下
在 google
中输入 crt max open file
找到了几个相关的网址。
虽然可以通过 _setmaxstdio() 调整 crt
的最大文件打开数,但是好像不能通过修改配置文件或者修改注册表的方式调整。
发帖询问
说实话,第一次分析到这个结果的时候我是有些不信的。于是我再三确认了 ReadResFile()
函数内部确实没有关闭文件的操作。难道有什么特殊的理由不关闭打开的文件?但是我实在想不出有什么理由。所以我觉得这是一个 bug
,于是我在微软官方论坛上发了一个帖子,希望能得到一些回复。
目前只有一位网友回复(另外一个是我自己),为了方便大家阅读,截图如下:
虽然到现在还没收到官方的确认回复,不过我依然认为这是一个 bug
,而不是 feature
。
解决方案
既然没有设置选项或者配置文件可以简单的调整最大文件打开数量,对 cvtres.exe
打补丁又不太现实(每台机器上都要做处理),等待微软修复这个问题也不现实(远水解不了近渴)。所以我们的解决方案是通过合并一些 .rc
以减少工程中的 .rc
文件数量来规避这个问题。
虽然问题已经调查清楚了,但是还有几个问题值得探究。
几个值得深究的问题
- 为什么链接的时候需要调用 cvtres.exe 呢?
- 有没有更好的设置条件断点的方式?目前的语法实在是太难用了。
- 有什么简单的办法可以查看
__piob
数组中元素的内容吗? - 为什么在打开
510.res
的时候就报错了?应该可以打开 512 个文件才对?
由于本篇已经太长了,下一篇文章中继续把残留的这几个问题解答。
总结
crt
有最大打开文件数的限制,可以通过_setmaxstdio()
进行调整。- 在一个工程中最好不要同时包含太多
.rc
文件,一般应该不会遇到我遇到的这种情况。 - 在不需要使用文件的时候,一定要及时关闭。
- 进程退出后,依然可以使用
k
系列命令查看调用栈,有时候可以快速定位进程退出的原因。
参考资料
https://stackoverflow.com/questions/61581826/visual-studio-2019-cvt1101-lnk1123-fatal-error
https://docs.microsoft.com/en-us/cpp/build/reference/dot-res-files-as-linker-input?view=msvc-170
https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/setmaxstdio?view=msvc-170
vs2013
自带的 crt
源码