缘起
最近,遇到了一个由于递归导致的卡死问题。这个问题非常有意思,值得总结。
你知道什么情况下无限递归会卡死,而不崩溃吗?你知道递归层数过多时,如何找到导致递归调用的函数吗?你知道如何快速找到关键线程吗?你知道如何附加到一个正在被调试的进程吗?你知道如何在 windbg
中显示指定数量的栈帧吗?
带着这些疑问,一起来看看这个非常有意思的问题吧。
说明: 文章末尾有这些问题的答案,可以直接跳到末尾查看。
初遇错误
程序在执行某个功能时,迟迟不能完成,通过任务管理器可以发现 CPU
使用率比较高(12.47%
),大概耗尽了一个核心(机器是八核的,每个核心占 12.5%
)。
心中暗喜,大概率是遇到了死循环,应该很好解决。赶紧用 vs
附加上去看看。
了解错误
附加到被调试进程后,手动暂停,然后通过并行堆栈找到可疑线程。
温馨提示: 可以通过
调试 -> 窗口 -> 并行堆栈
打开并行堆栈视图,也可以使用快捷键Ctrl+Shift+D, S
打开 。
一般情况下调用栈最长的线程就是可疑线程。即使不是,也可以在并行堆栈视图中快速切换线程。相比于手动一个个切换线程,并行堆栈简直是太方便了!
通过并行堆栈视图,可以观察到当前线程的调用栈非常深,已经超出了 vs
所支持的最大栈帧数。仔细观察调用栈,可以发现 00007ffc9dcb3cb5
这个地址会重复出现,说明这很可能是一个递归问题。
然而,只知道这是一个递归问题还不够,我们需要找到引发递归调用的函数。如果能看到完整的调用栈,那么就可以找到罪魁祸首了。由于 vs
不能显示更多的调用栈帧,我们可以请老朋友 windbg
出马。
请出 windbg
启动 windbg
,以 Noninvasive
模式附加到被调试进程(由于该进程正在被 vs
调试,如果不以 Noninvasive
模式附加,windbg
无法成功附加)。
附加成功后,通过 ~~[12544]s
切换到目标线程,没想到报错了。
没关系,直接切不过去,还有其它方法可以找到目标线程。可以简单粗暴的使用 ~* k
命令显示所有线程的调用栈,然后根据调用栈判断哪个线程是目标线程,也可以通过 !runaway
查看所有线程的运行时间,根据运行时间长短快速找出目标线程。
!runaway
在 windbg
中输入 !runaway
可以查看所有线程的运行时间。一般,CPU
占用率越高的线程,运行时间也越长。
可以发现 0
号线程运行时间最长,然后是 32
号线程。先切换到 0
号线程,执行 k
命令查看调用栈,发现是主线程(一般情况下 0
号线程都是主线程),不是我们关心的线程。再执行 ~32s
切换到运行时间排名第二的线程,然后执行 k
命令查看调用栈,发现与在 vs
中看到的调用栈吻合,32
号线程是目标线程了。
*说明: * 当时比较着急,忘了
windbg
中默认使用十六进制。如果执行~~[0n12544]s
即可正常切换过去了。0n
表示使用十进制。
找到对应的线程后,接下来的任务是查看完整调用栈。
查看完整调用栈
默认情况下,windbg
的 k
命令最多只显示 256
个调用栈帧,最大的栈帧号是 ff
,从 0
开始计数。
我们可以在 windbg
中执行 kN
来指定要显示的栈帧数,如果 N
足够大,那么应该可以显示出完整的调用栈。
先尝试输入 k200
,发现看不到头,再试试 k2000
,依然看不到头,k5000
依然看不到头(这调用栈不是一般的深啊~)。 直接输入 k50000
,这次应该够了吧?没想到报错了。
根据提示可知,可以输入的最大值是 0xffff
。在 windbg
中输入 k0xffff
,耐心等待一会儿就可以看到完整的调用栈了。如下图:
说明: 不要输入
kffff
,因为会被解释为kf fff
,第一个f
会被解释为选项,用来显示两个栈帧的间距。
调用栈深的异常
调用栈深的有点异常,总共有 0x9032 + 1
个栈帧(即 36915
个)。这样深的调用栈却未发生栈溢出,实属不可思议。要知道,线程栈预留空间默认只有 1MB
。
在 windbg
中查看当前线程栈信息,重点查看线程栈总大小和当前已使用大小(具体查看方法可以参考这篇文章)。
可以发现,线程栈预留空间大约是 1.757 GB
,当前已使用大小大约是 64.07 MB
。
说实话,我还是头一次遇到这么巨大的栈空间,难怪调用栈如此深却没有发生栈溢出。
但是等一下,线程栈怎么会这么大?默认不是只有 1MB
吗?是在创建线程的时候指定了线程栈预留空间大小?还是 64
位程序编译时使用的线程栈预留空间的默认值发生了变化,或者被手动修改了?又或者是有人调整了 PE
文件头中的 SizeOfStackReserve
值?
不论是修改编译参数,还是手动修改 PE
头,这些操作最终都会体现在 PE
文件上。先使用 CFF Explorer
查看 PE
文件头。
查看 PE 头
果然,PE
文件头中的 SizeOfStackReserve
变成了 0x0000000070800000
,与上面在 windbg
中看到的线程栈总大小是一致的。
为了验证是不是 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
,该如何查看完整的调用栈呢?下篇更精彩,敬请期待~~