用Windbg调试一个Windows自带扫雷程序的逻辑bug

来源:互联网 发布:单片机开发工程师 编辑:程序博客网 时间:2024/05/21 18:45

大二的时候玩Windows自带的扫雷很多,发现过一个在XP下让扫雷计时器停止的bug,具体参见过去我在mop上发的一个帖子http://dzh.mop.com/topic/readSub_5573508_0_0.html .

重现这个bug的步骤很简单,

1.打开扫雷游戏;
2.
开始游戏,点开一块;
3.
在没有点中雷而结束游戏之前的任何一个时刻,按下开始键+D(开始键就是左边ctrlalt中间的那个键);
4.
这个时候你会发现游戏窗口变成了最小化在屏幕最下面的任务栏里;
5.
在任务栏里选中扫雷,让它成为原先大小的当前工作窗口;
6.
计时器停止了。

说到为什么能发现这个bug,现在看来,当时的想法纯属误打误撞。在早期的一些Windows Media Player版本里,如果我将鼠标挪到窗口的最小化按钮上,并且点击左键不放开,会发现WMP的进度条和显示时间不动了,虽然歌会照样的播放。(原因是主UI线程在等待我下一步松开鼠标的动作,于是挂在那里,歌和视频应该是另外一个线程里播放的)。经过实践发现,扫雷也同样是这样的,当左击最小化按钮不松开的时候,主UI线程挂住,所以计时器不会更新。而我当时的想法比较幼稚,停留在表面,只觉得这和我想要最小化当前窗口有关。于是想,能不能不用鼠标,只用键盘去最小化这个窗口呢,如果可以的话,扫雷的计时器会不会停止呢?Windows最小化所有窗口(显示桌面)的快捷键是,开始键+D。于是用开始键+D最小化所有窗口,然后再还原扫雷窗口后,竟然真的发现计时器停在那里。

过去从来没有好好去研究过Windows编程方面的东西。所以对于这个现象想出来的解释也很简单,也没有用实践去验证一下自己的想法。我当时的想法是,我觉得这个扫雷程序中至少应该存在两个线程,一个负责主界面的逻辑,一个负责定时更新计时器。由于按开始键+D最小化,然后再还原了扫雷窗口,可能是程序设计上的错误,主UI线程恢复了,但是定时线程却没有被唤醒或已经结束。

最近学习Windbg调试,终于基本上摸清楚了这个bug的本质面目。调试后证明,程序运行的主要逻辑包括UITimer都是在主线程中实现的。

首先可以下载最新版本的Windbg(Debugging Tools for Windows)http://www.microsoft.com/whdc/DevTools/Debugging/default.mspx

还有这篇文章介绍了如何使用Microsoft Symbol Serverhttp://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的处理函数,MSDNhttp://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,而另外一个却直接跳过去了呢。用bpwinmine!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恢复这些信息。很奇怪,按照代码而言,PauseGame1保存在fOldTimerStatus中,为什么ResumeGame在把fOldTimerStatus的值loadfTimer中的时候就变成0了呢。再用.restart命令重新调试一下,并且在PauseGameResumeGame上都设断点,重复一下bug重现步骤,竟然PauseGame根本没有被执行,只有ResumeGame被执行了。这应该就是为什么fTimer在我们的repro steps中会为0的原因。

那这对从名字上看应该成对执行的函数,为啥落单呢?PauseGame啥时候被调用?随意操纵程序后发现,在点击最小化按钮,而不是通过开始键+D来最小化窗口的时候,Windbg断在了PauseGame上。

至于为什么会有不同,通过进一步的查看调用堆栈可以看到这两种情况的不同之处在于,

1.点击最小化按钮是,主线程发送的是0x112 WM_SYSCOMMANDwParam中为0xF020 SC_MINIMIZE的消息。MainWndProc处理这个消息的逻辑是,遇到此消息,先PauseGame,然后交由USER32!DefWindowProcW去处理,实现最小化窗口

2.开始键+D最小化时,Shell向所有的顶级窗口广播0x0046 WM_WINDOWPOSCHANGING0x0047 WM_WINDOWPOSCHANGED0x0003 WM_MOVE0x0005 WM_SIZE消息来最小化窗口

第二种情况绕过了WM_SYSCOMMAND&SC_MINIMIZE消息,从而PauseGame函数没有执行,fTimer没有被保存到fOldTimerStatus中,fOldTimerStatus0。当下一次窗口还原的时候,ResumeGamefOldTimerStatus0值赋给fTimer。从而DoTimer函数判断fTimer0后,就不再按正常逻辑去DrawTime了。哈哈看来Microsoft的程序员也有粗心的时候。