最近,在做一个开源软件移植到平台过程中出现了一个很诡异的bug。
1.开源软件的整体架构
开源软件在几年前已经停止更新了,是一个FC模拟器。在80年代出生的人基本上都玩过的(在电视机上连接一个类似于小霸王的学习机,然后学习机上面可以插上游戏卡,通常比较普遍的游戏卡有坦克大战、超级马里奥、魂斗罗,等等)。这种机器即为--任天堂红白机,也称为Family Computer。
2001年有人在电脑上通过模拟红白机的原理,开发了FC模拟器,随后一直维护到2007年就不在维护了。FC模拟器通过读取电脑中的rom文件,映射为cpu指令,并执行该指令,指令的执行伴随着画面的更新。整个程序有两个线程:
1. 窗体主线程,负责显示和接受键盘输入;
2. 读取指令解析指令并刷新画面的循环线程。
窗体在接受WM_CLOSE或者WM_DESTROY消息时,停止线程2,销毁相关资源;然后关闭窗体,销毁窗体资源,退出主线程。
2.移植到平台要求
移植到平台中有如下要求:
1. 去掉菜单栏,程序允许开始时,自动加载配置文件中的rom文件,并自动运行。
2. 允许多实例运行。
3. 允许强杀正在运行的进程(当然需要先发送WM_DESTROY消息)。
3.bug产生了
移植后,功能1、2都实现了,但是3存在问题。确切的说:不是移植后的程序存在的问题,而是杀死本程序存在处理逻辑上的问题,暂且定义杀死本程序的程序为父进程。父进程在杀死本进程时,做了如下操作:
1. 调用SendMessageTimeout()函数对本程序窗口发送WM_DESTROY消息。
2. 立即调用TerminateProcess()函数杀死本程序。
bug现象:程序被父进程杀掉后,会存在一定的概率弹出对话框: 你访问的内存不能为读/写错误。 也就是常见的访问违例异常,该对话框由系统进程csrss.exe弹出。并且弹出后该对话框一直处于桌面最顶层。并且伴随着多次运行该程序,弹出的对话框会覆盖整个桌面,占用资源变多,系统运行变慢。
4.解决bug
bug既然产生了,那就需要拿着显微镜找出bug,找出来后给它喷点杀虫剂。
通过初步分析程序在退出的时候崩溃了,但是没有任何证据证明是在退出的时候崩溃了,与其说是退出,还不如说是被kill掉了。
方案1
在程序中添加dump转储输出,当程序崩溃的时候,自动生成dump文件,当前比较成熟的自动生成dump文件的库 CrashRpt。在程序中集成了CrashRpt库后,运行程序,在程序被kill掉的时候,理论上应该生成了dump文件了,但是一个奇怪的现象出现了:当父进程kill当前程序的时候,当前程序崩溃触发CrashRptSender.exe启动,但是CrashRptSender.exe崩溃了,看到这个场景,一下子懵了。这是为什么了?
改进方案
既然程序自身导致崩溃转储库崩溃了,是否可以采取其他方式生成转储文件了? 通常我们用Windbg调试一个进程的时候会采用附着(Attach)方式调试。为了规避程序自身崩溃导致的问题是否可以采用附着调试的方式了?
既然想到了,就查找资料,发现systeminternal(微软工具包)中存在一个叫做procdump.exe的工具,可以对进程退出进行转储,通过在命令行中输入:
procdump /? 查找可以通过 -e(程序出现SEH结构化异常的时候,生成转储文件) -t (程序在退出的时候生成转储文件)并且可以通过输入进程id,即可附着调试。
新问题?
由于生成父进程杀死本程序的时候会造成崩溃是偶然发生的,偏偏在我输入命令行后,崩溃并不出现,怎么办了?
如果有一种办法:在程序刚启动的时候,通知一下自己的进程id给一个守护进程,守护进程调用procdump 并传入进程id进行监控,这样是不是可以避免人为的操作,而且可以实现完全的自动化了?
既然想到了,那就付诸行动,通过生产者--消费者模型方式,进程id注册作为生产者生产的产品。通知给守护进程(消费者),守护进程提取进程id并启动一个监控线程,监控该id。(监控方式:通过命令行方式调用procdump -e -t ...)
批量运行父进程和本程序,发现守护进程在每一次本程序退出时都会生成一个dump文件,仅仅运行了10分钟,就生成了100多个dump文件,到底哪个是真正崩溃的转储文件了,那些是正常退出的崩溃文件了。。。又一次陷入了沼泽。。。
问题出在了调用procdump的参数上。 procdump的-t参数是程序退出时生成一个dump文件。那么程序退出时就会生成很多的正常dump文件,去掉该参数。测试,通过1个小时的大批量并发测试,终于崩溃转储文件生成了。
通过转储文件,直接调试,一个意想不到的崩溃地方让我又一次陷入了沉思。在模拟器的取指令线程中读取了被释放的内存对象造成了程序的访问违例异常。最后导致弹出了内存不能为read/write错误。
仔细分析发现,崩溃的地方是一个全局变量,(或者函数内部的静态变量)被释放了。该对象是一个栈对象,而不是堆对象。那么我就大胆的进行一些推测。在收到WM_DESTROY消息后,开始停止线程(程序中停止线程采用的是等待线程真正退出,即所有资源都释放,并且线程信号置为有信号状态),由于线程中的循环处理部分会占用大量的cpu时间,线程并未真正退出时,便被TerminateProcess了,而TerminateProcess会将栈对象释放,而线程此时访问了栈对象,就出现了违例访问。
既然已经分析到了问题的原因,可以采取如下策略:
1. 收到WM_DESTROY消息后,不在死等线程真正退出,等待30ms后线程没有信号,直接TerminateThread。
结果可想而知,仍然崩溃输出dump文件,但是概率小了很多,本来以为可以解决问题了,都运行了上午半天(3个小时左右),吃完午饭,回来打开一看,冒出来了一个dump文件,气死我也!@#¥%……&。
仔细分析: 为什么是30ms的等待时间了?如果在30ms内TerminateProcess而导致销毁了对象栈了?所以上面的方案并没有解决本质问题,栈销毁问题。
2.既然栈销毁了,那么堆中的对象了?立即动手行动,将所有的栈对象改为new方式,并且不在销毁该对象。加长测试时间,整整一天,未出现崩溃。。。
3.至此,问题算基本解决了,但是通过查找相关资料,发现一个更好的方式:
__try {
thread proc code;
} __except(GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SERCH) {
;//donothing.
}
通过对会抛出访问违例异常的代码添加__try, __except块来处理系统的结构化异常(SEH)
4. 问题解决,对于TerminateProcess和TerminateThread总算有了新的认识。