大二的时候玩Windows自带的扫雷很多,发现过一个在XP下让扫雷计时器停止的bug,具体参见过去我在mop上发的一个帖子http://dzh.mop.com/topic/readSub_5573508_0_0.html .
重现这个bug的步骤很简单,
1.打开扫雷游戏;
2.开始游戏,点开一块;
3.在没有点中雷而结束游戏之前的任何一个时刻,按下 开始键+D(开始键就是左边ctrl和alt中间的那个键);
4.这个时候你会发现游戏窗口变成了最小化在屏幕最下面的任务栏里;
5.在任务栏里选中扫雷,让它成为原先大小的当前工作窗口;
6.计时器停止了。
说到为什么能发现这个bug,现在看来,当时的想法纯属误打误撞。在早期的一些Windows Media Player版本里,如果我将鼠标挪到窗口的最小化按钮上,并且点击左键不放开,会发现WMP的进度条和显示时间不动了,虽然歌会照样的播放。(原因是主UI线程在等待我下一步松开鼠标的动作,于是挂在那里,歌和视频应该是另外一个线程里播放的)。经过实践发现,扫雷也同样是这样的,当左击最小化按钮不松开的时候,主UI线程挂住,所以计时器不会更新。而我当时的想法比较幼稚,停留在表面,只觉得这和我想要最小化当前窗口有关。于是想,能不能不用鼠标,只用键盘去最小化这个窗口呢,如果可以的话,扫雷的计时器会不会停止呢?Windows最小化所有窗口(显示桌面)的快捷键是,开始键+D。于是用开始键+D最小化所有窗口,然后再还原扫雷窗口后,竟然真的发现计时器停在那里。
过去从来没有好好去研究过Windows编程方面的东西。所以对于这个现象想出来的解释也很简单,也没有用实践去验证一下自己的想法。我当时的想法是,我觉得这个扫雷程序中至少应该存在两个线程,一个负责主界面的逻辑,一个负责定时更新计时器。由于按开始键+D最小化,然后再还原了扫雷窗口,可能是程序设计上的错误,主UI线程恢复了,但是定时线程却没有被唤醒或已经结束。
最近学习Windbg调试,终于基本上摸清楚了这个bug的本质面目。调试后证明,程序运行的主要逻辑包括UI和Timer都是在主线程中实现的。
首先可以下载最新版本的Windbg(Debugging Tools for Windows) http://www.microsoft.com/whdc/DevTools/Debugging/default.mspx
还有这篇文章介绍了如何使用Microsoft Symbol Server http://support.microsoft.com/kb/311503 .
用Windbg启动扫雷,然后重现问题,Ctrl+Break下来,用~*kb2000命令查看当前运行的线程和他们的调用堆栈,
0 Id: 1010.afc Suspend: 1 Teb: 7ffdf000 Unfrozen
ntdll!KiFastSystemCallRet
USER32!NtUserGetMessage
USER32!GetMessageW
winmine!WinMain
winmine!WinMainCRTStartup
kernel32!BaseThreadInitThunk
ntdll!__RtlUserThreadStart
ntdll!_RtlUserThreadStart
# 1 Id: 1010.9f8 Suspend: 1 Teb: 7ffde000 Unfrozen
ntdll!DbgBreakPoint
ntdll!DbgUiRemoteBreakin
kernel32!BaseThreadInitThunk
ntdll!__RtlUserThreadStart
ntdll!_RtlUserThreadStart
第一个一看便是主线程,第二个看不出来什么信息,貌似也不是我原本以为的hang住的“定时器线程”,或者说“定时器线程”有可能运行完退出了?为了证明这个假想的线程到底还在不在运行,最简单的方法是用Spy++看一下问题重现后的扫雷窗口还能不能收到WM_TIMER消息,结果是WM_TIMER消息从来就没有停止过。
为什么WM_TIMER消息一直在发送,界面上的计时器却停止更新了呢?我们很容易想到,应该设断点查看一下WM_TIMER的处理函数,MSDN http://msdn.microsoft.com/en-us/library/ms644906.aspx 上说SetTimer的第四个参数是回调函数的地址,如果为NULL那么系统会post一个WM_TIMER到消息队列,那么这个消息就应该由窗体的过程函数来处理,也就是说我们应该找到扫雷窗体的过程函数,
用x winmine!*命令打印出扫雷程序的所有符号,发现窗体的过程函数是winmine!MainWndProc(),用bp命令在上面设一个断点,
0:001> bp winmine!MainWndProc
(注意这里调试的时候,鼠标还有其他窗口不要碰到扫雷的主窗体,保证MainWndProc函数被调用是因为WM_TIMER)
然后用wt命令可以跟踪MainWndProc执行的整个过程,
Tracing winmine!MainWndProc to return address 76f9f8d2
26 0 [ 0] winmine!MainWndProc
3 0 [ 1] winmine!DoTimer
34 3 [ 0] winmine!MainWndProc
在正常情况下,即计时器不停止的时候再用wt跟踪一下MainWndProc函数的执行路径会发现,
Tracing winmine!MainWndProc to return address 76f9f8d2
26 0 [ 0] winmine!MainWndProc
6 0 [ 1] winmine!DoTimer
3 0 [ 2] winmine!DisplayTime
3 0 [ 3] USER32!NtUserGetDC
2 0 [ 4] ntdll!KiFastSystemCall
1 0 [ 3] USER32!NtUserGetDC
6 6 [ 2] winmine!DisplayTime
8 0 [ 3] winmine!DrawTime
32 0 [ 4] GDI32!GetLayout
25 32 [ 3] winmine!DrawTime
18 0 [ 4] winmine!DrawLed
3 0 [ 5] GDI32!SetDIBitsToDevice
21 0 [ 6] GDI32!__SEH_prolog4
24 21 [ 5] GDI32!SetDIBitsToDevice
32 0 [ 6] GDI32!pbmiConvertInfo
29 0 [ 7] GDI32!CalculateColorTableSize
58 29 [ 6] GDI32!pbmiConvertInfo
51 108 [ 5] GDI32!SetDIBitsToDevice
22 0 [ 6] GDI32!cjBitmapScanSize
98 130 [ 5] GDI32!SetDIBitsToDevice
3 0 [ 6] GDI32!NtGdiSetDIBitsToDeviceInternal
2 0 [ 7] ntdll!KiFastSystemCall
1 0 [ 6] GDI32!NtGdiSetDIBitsToDeviceInternal
106 136 [ 5] GDI32!SetDIBitsToDevice
11 0 [ 6] GDI32!__SEH_epilog4
107 147 [ 5] GDI32!SetDIBitsToDevice
19 254 [ 4] winmine!DrawLed
38 305 [ 3] winmine!DrawTime
18 0 [ 4] winmine!DrawLed
3 0 [ 5] GDI32!SetDIBitsToDevice
21 0 [ 6] GDI32!__SEH_prolog4
24 21 [ 5] GDI32!SetDIBitsToDevice
32 0 [ 6] GDI32!pbmiConvertInfo
29 0 [ 7] GDI32!CalculateColorTableSize
58 29 [ 6] GDI32!pbmiConvertInfo
51 108 [ 5] GDI32!SetDIBitsToDevice
22 0 [ 6] GDI32!cjBitmapScanSize
98 130 [ 5] GDI32!SetDIBitsToDevice
3 0 [ 6] GDI32!NtGdiSetDIBitsToDeviceInternal
2 0 [ 7] ntdll!KiFastSystemCall
1 0 [ 6] GDI32!NtGdiSetDIBitsToDeviceInternal
106 136 [ 5] GDI32!SetDIBitsToDevice
11 0 [ 6] GDI32!__SEH_epilog4
107 147 [ 5] GDI32!SetDIBitsToDevice
19 254 [ 4] winmine!DrawLed
45 578 [ 3] winmine!DrawTime
18 0 [ 4] winmine!DrawLed
3 0 [ 5] GDI32!SetDIBitsToDevice
21 0 [ 6] GDI32!__SEH_prolog4
24 21 [ 5] GDI32!SetDIBitsToDevice
32 0 [ 6] GDI32!pbmiConvertInfo
29 0 [ 7] GDI32!CalculateColorTableSize
58 29 [ 6] GDI32!pbmiConvertInfo
51 108 [ 5] GDI32!SetDIBitsToDevice
22 0 [ 6] GDI32!cjBitmapScanSize
98 130 [ 5] GDI32!SetDIBitsToDevice
3 0 [ 6] GDI32!NtGdiSetDIBitsToDeviceInternal
2 0 [ 7] ntdll!KiFastSystemCall
1 0 [ 6] GDI32!NtGdiSetDIBitsToDeviceInternal
106 136 [ 5] GDI32!SetDIBitsToDevice
11 0 [ 6] GDI32!__SEH_epilog4
107 147 [ 5] GDI32!SetDIBitsToDevice
19 254 [ 4] winmine!DrawLed
52 851 [ 3] winmine!DrawTime
9 909 [ 2] winmine!DisplayTime
7 0 [ 3] USER32!ReleaseDC
10 0 [ 4] GDI32!GdiReleaseDC
25 0 [ 5] GDI32!pldcGet
40 25 [ 4] GDI32!GdiReleaseDC
10 65 [ 3] USER32!ReleaseDC
3 0 [ 4] USER32!NtUserCallOneParam
2 0 [ 5] ntdll!KiFastSystemCall
1 0 [ 4] USER32!NtUserCallOneParam
12 71 [ 3] USER32!ReleaseDC
11 992 [ 2] winmine!DisplayTime
8 1003 [ 1] winmine!DoTimer
3 0 [ 2] winmine!PlayTune
9 1006 [ 1] winmine!DoTimer
34 1015 [ 0] winmine!MainWndProc
Winmine!DoTimer函数很奇怪,为什么一个调用了DisplayTime,而另外一个却直接跳过去了呢。用bp在winmine!DoTimer函数上设断点,在两种情况下分别单步调试一下(需要重启动扫雷程序,可以用.restart命令),发现DoTimer函数首先会判断一个叫winmine!fTimer的全局变量的值是否为零,如果为零,就退出,如果非零,就继续往下执行一系列正常的UI逻辑。
cmp dword ptr [winmine!fTimer (01005164)],0 ds:0023:01005164=00000000
je winmine!DoTimer+0x27 (01003007)
fTimer是个什么东西?用来干啥的?呵呵,估计只有写程序的人知道了。不过为什么fTimer正常情况下为1,不正常情况下为0呢?我们可以用ba w4 winime!fTimer命令来设置断点,这个命令表示当有代码写全局变量fTimer的时候停下来。
重新调试程序,设置如上断点,按步骤去重现我们的bug,发现上面的断点一共断了两次,一次在我们第一次点击开始扫雷的时候,查看调用堆栈,发现没有可挖掘的信息。第二次断在,重现步骤的第五步
“5.在任务栏里选中扫雷,让它成为原先大小的当前工作窗口;”
切换到主进程,查看调用堆栈如下,
winmine!ResumeGame
winmine!MainWndProc
USER32!InternalCallWinProc
USER32!UserCallWinProcCheckWow
USER32!DispatchMessageWorker
USER32!DispatchMessageW
winmine!WinMain
winmine!WinMainCRTStartup
kernel32!BaseThreadInitThunk
ntdll!__RtlUserThreadStart
ntdll!_RtlUserThreadStart
哈哈,越来越近了,在窗口中反汇编ResumeGame函数,发现附近还有一个函数PauseGame,他们的汇编代码如下
winmine!PauseGame:
call winmine!EndTunes (010038d7)
test byte ptr [winmine!fStatus (01005000)],2
jne winmine!PauseGame+0x18 (01003434)
mov eax,dword ptr [winmine!fTimer (01005164)]
mov dword ptr [winmine!fOldTimerStatus (01005168)],eax
test byte ptr [winmine!fStatus (01005000)],1
je winmine!PauseGame+0x28 (01003444)
and dword ptr [winmine!fTimer (01005164)],0
or dword ptr [winmine!fStatus (01005000)],2
ret
winmine!ResumeGame:
test byte ptr [winmine!fStatus (01005000)],1 ds:0023:01005000=01
je winmine!ResumeGame+0x13 (0100345f)
mov eax,dword ptr [winmine!fOldTimerStatus (01005168)]
mov dword ptr [winmine!fTimer (01005164)],eax
and dword ptr [winmine!fStatus (01005000)],0FDh
ret
由代码可以看出PauseGame保存了游戏的状态信息,而ResumeGame恢复这些信息。很奇怪,按照代码而言,PauseGame将1保存在fOldTimerStatus中,为什么ResumeGame在把fOldTimerStatus的值load到fTimer中的时候就变成0了呢。再用.restart命令重新调试一下,并且在PauseGame和ResumeGame上都设断点,重复一下bug重现步骤,竟然PauseGame根本没有被执行,只有ResumeGame被执行了。这应该就是为什么fTimer在我们的repro steps中会为0的原因。
那这对从名字上看应该成对执行的函数,为啥落单呢?PauseGame啥时候被调用?随意操纵程序后发现,在点击最小化按钮,而不是通过开始键+D来最小化窗口的时候,Windbg断在了PauseGame上。
至于为什么会有不同,通过进一步的查看调用堆栈可以看到这两种情况的不同之处在于,
1.点击最小化按钮是,主线程发送的是0x112 WM_SYSCOMMAND,wParam中为0xF020 SC_MINIMIZE的消息。MainWndProc处理这个消息的逻辑是,遇到此消息,先PauseGame,然后交由USER32!DefWindowProcW去处理,实现最小化窗口
2.开始键+D最小化时,Shell向所有的顶级窗口广播0x0046 WM_WINDOWPOSCHANGING,0x0047 WM_WINDOWPOSCHANGED,0x0003 WM_MOVE,0x0005 WM_SIZE消息来最小化窗口
第二种情况绕过了WM_SYSCOMMAND&SC_MINIMIZE消息,从而PauseGame函数没有执行,fTimer没有被保存到fOldTimerStatus中,fOldTimerStatus为0。当下一次窗口还原的时候,ResumeGame把fOldTimerStatus的0值赋给fTimer。从而DoTimer函数判断fTimer为0后,就不再按正常逻辑去DrawTime了。哈哈看来Microsoft的程序员也有粗心的时候。