5

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

 8 months ago
source link: https://bianchengnan.gitee.io//articles/how-to-view-startup-function-from-a-deep-recursive-stack/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2024-01-06

|

2024-01-06

| 调试

| 热度: 1℃

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

你知道什么情况下无限递归会卡死,而不崩溃吗?你知道递归层数过多时,如何找到导致递归调用的函数吗?你知道如何快速找到关键线程吗?你知道如何附加到一个正在被调试的进程吗?你知道如何在 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 占用率越高的线程,运行时间也越长。

switch-to-thread-no-15

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

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

switch-to-0n12544-successfully

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

查看完整调用栈

默认情况下,windbgk 命令最多只显示 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 的值。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK