调试实战 | 记一次有教益的递归栈查看

缘起

最近,遇到了一个由于递归导致的卡死问题。这个问题非常有意思,值得总结。

你知道什么情况下无限递归会卡死,而不崩溃吗?你知道递归层数过多时,如何找到导致递归调用的函数吗?你知道如何快速找到关键线程吗?你知道如何附加到一个正在被调试的进程吗?你知道如何在 windbg 中显示指定数量的栈帧吗?

带着这些疑问,一起来看看这个非常有意思的问题吧。

说明: 文章末尾有这些问题的答案,可以直接跳到末尾查看。

初遇错误

程序在执行某个功能时,迟迟不能完成,通过任务管理器可以发现 CPU 使用率比较高(12.47%),大概耗尽了一个核心(机器是八核的,每个核心占 12.5%)。

high-cpu

心中暗喜,大概率是遇到了死循环,应该很好解决。赶紧用 vs 附加上去看看。

了解错误

附加到被调试进程后,手动暂停,然后通过并行堆栈找到可疑线程。

温馨提示: 可以通过 调试 -> 窗口 -> 并行堆栈 打开并行堆栈视图,也可以使用快捷键 Ctrl+Shift+D, S 打开 。

一般情况下调用栈最长的线程就是可疑线程。即使不是,也可以在并行堆栈视图中快速切换线程。相比于手动一个个切换线程,并行堆栈简直是太方便了!

view-thread-stack

通过并行堆栈视图,可以观察到当前线程的调用栈非常深,已经超出了 vs 所支持的最大栈帧数。仔细观察调用栈,可以发现 00007ffc9dcb3cb5 这个地址会重复出现,说明这很可能是一个递归问题。

然而,只知道这是一个递归问题还不够,我们需要找到引发递归调用的函数。如果能看到完整的调用栈,那么就可以找到罪魁祸首了。由于 vs 不能显示更多的调用栈帧,我们可以请老朋友 windbg 出马。

请出 windbg

启动 windbg,以 Noninvasive 模式附加到被调试进程(由于该进程正在被 vs 调试,如果不以 Noninvasive 模式附加,windbg 无法成功附加)。

windbg-noninvasive-attach

附加成功后,通过 ~~[12544]s 切换到目标线程,没想到报错了。

windbg-switch-thread-error

没关系,直接切不过去,还有其它方法可以找到目标线程。可以简单粗暴的使用 ~* k 命令显示所有线程的调用栈,然后根据调用栈判断哪个线程是目标线程,也可以通过 !runaway 查看所有线程的运行时间,根据运行时间长短快速找出目标线程。

!runaway

windbg 中输入 !runaway 可以查看所有线程的运行时间。一般,CPU 占用率越高的线程,运行时间也越长。

windbg-runaway

可以发现 0 号线程运行时间最长,然后是 32 号线程。先切换到 0 号线程,执行 k 命令查看调用栈,发现是主线程(一般情况下 0 号线程都是主线程),不是我们关心的线程。再执行 ~32s 切换到运行时间排名第二的线程,然后执行 k 命令查看调用栈,发现与在 vs 中看到的调用栈吻合,32 号线程是目标线程了。

*说明: * 当时比较着急,忘了 windbg 中默认使用十六进制。如果执行 ~~[0n12544]s 即可正常切换过去了。0n 表示使用十进制。

switch-to-0n12544-successfully

找到对应的线程后,接下来的任务是查看完整调用栈。

查看完整调用栈

默认情况下,windbgk 命令最多只显示 256 个调用栈帧,最大的栈帧号是 ff,从 0 开始计数。

windbg-k-output-default

我们可以在 windbg 中执行 kN来指定要显示的栈帧数,如果 N 足够大,那么应该可以显示出完整的调用栈。

先尝试输入 k200,发现看不到头,再试试 k2000,依然看不到头,k5000 依然看不到头(这调用栈不是一般的深啊~)。 直接输入 k50000,这次应该够了吧?没想到报错了。

windbg-k50000-error

根据提示可知,可以输入的最大值是 0xffff。在 windbg 中输入 k0xffff,耐心等待一会儿就可以看到完整的调用栈了。如下图:

view-full-callstack-in-windbg

说明: 不要输入 kffff,因为会被解释为 kf fff,第一个 f 会被解释为选项,用来显示两个栈帧的间距。windbg-k-command-help

调用栈深的异常

调用栈深的有点异常,总共有 0x9032 + 1 个栈帧(即 36915 个)。这样深的调用栈却未发生栈溢出,实属不可思议。要知道,线程栈预留空间默认只有 1MB

windbg 中查看当前线程栈信息,重点查看线程栈总大小和当前已使用大小(具体查看方法可以参考这篇文章)。

view-stack-total-size-and-current-used-size

可以发现,线程栈预留空间大约是 1.757 GB,当前已使用大小大约是 64.07 MB

说实话,我还是头一次遇到这么巨大的栈空间,难怪调用栈如此深却没有发生栈溢出。

但是等一下,线程栈怎么会这么大?默认不是只有 1MB 吗?是在创建线程的时候指定了线程栈预留空间大小?还是 64 位程序编译时使用的线程栈预留空间的默认值发生了变化,或者被手动修改了?又或者是有人调整了 PE 文件头中的 SizeOfStackReserve 值?

不论是修改编译参数,还是手动修改 PE 头,这些操作最终都会体现在 PE 文件上。先使用 CFF Explorer 查看 PE 文件头。

查看 PE 头

果然,PE 文件头中的 SizeOfStackReserve 变成了 0x0000000070800000 ,与上面在 windbg 中看到的线程栈总大小是一致的。
view-pe-header

为了验证是不是 64 位程序默认编译参数导致的,我特意建了一个简单的控制台程序,查看了工程设置参数,发现与 32 位程序一样,线程栈预留空间默认大小是 1MB

一般不会有人修改生成的 PE 文件,回想到总是遇到栈溢出问题,猜测极有可能是某位同事修改了工程设置。不过栈空间修改的这么大,确实有待商榷。

至此,基本可以结案了。

结案

虽然递归了,调用栈很深,但是由于栈空间非常大,所以一时半会儿还不会导致栈溢出。最终看到的现象就是卡死、CPU 占用率高,而不是崩溃。当然,最终栈空间耗尽后,还是会触发栈溢出异常的。

总结

  • windbg 中可以通过 kN 查看指定数量的调用栈。只要 N 足够大,基本上可以看到完整的调用栈,但是默认情况下,N 不能超过 0xffff
  • vs 中可以通过并行堆栈快速查看各个线程的调用栈,从而可以快速找到关键线程。强烈推荐!
  • windbg 中可以使用 ~* k 快速查看所有线程的调用栈,与 vs 中的并行堆栈功能不相上下。
  • windbg 中可以通过 !runaway 查看运行时间最长的线程,从而可以快速找到关键线程。
  • windbg 可以以 Noninvasive 的形式附加到一个正在被调试的进程。
  • PE 文件头中的 SizeOfStackReserve 决定了线程栈预留空间的大小,可以手动修改此值来调整线程的默认栈预留空间大小。
  • vs 工程中可以通过修改堆栈保留大小选项(单位是字节)来控制 PE 文件头中的 SizeOfStackReserve 的值。

More

如果调用栈深度超出了 0xffff,该如何查看完整的调用栈呢?下篇更精彩,敬请期待~~

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%