-
-
[原创]软件调试基础--04调用堆栈
-
发表于: 2016-1-21 14:45 4437
-
首先,回顾一下上一节讲的调试符号中的结论,也就是pdb文件中,包含了如下信息:
符号(函数或变量)在文件(可执行模块)中的偏移OFFSET、符号所在源文件路径、行号。而其中的OFFSET在可执行模块被加载到内存后,会有一个VA,这个VA=模块加载基地址+OFFSET。(其实并不是这么简单的相加就完事的,为了讲解方便,我们可以先这么理解)。
基于这些,我们可以做如下猜想性结论:
1.如果给出一个VA,我们应该可以用VA - 模块基地址得到OFFSET,如果这个OFFSET恰好是某个符号所在的可执行文件的偏移,则拿这个OFFSET去pdb中搜索,应该可以获取到这个符号的名称,符号名,所在源文件路径和源文件行号。
基于如上结论,我们下面来看实例。
这是一个很简单的实例,_tmain函数中调用func1,而func1调用了func2,func2又调用了func3,此时,我们在func3中下一个断点,调试运行,然后我们可以看到调用堆栈(如果默认没有这个窗口,可以这样打开:调试--窗口--调用堆栈)中的情况,如下图。
我们先看前四行,正如我们前面所说的,_tmain(宏替换为wmain)调用了func1,func1调用了func2,func2又调用了func3,最终在func3中断点处中断下来。这便是我们能看得懂的调用堆栈部分。
一般情况下,我们调试,也就是看我们能看得懂的部分的调用堆栈,知道这个调用堆栈表示了什么意思即可。但是如果仅仅看明白这么一点点,是不足以我们后续进行的所谓高端软件调试的。在这里,我们提出几个问题:
1.这个调用栈是怎么形成的?
2.下面的灰色部分是什么情况,为什么显示的不是符号?
3.这个调用堆栈,在任何情况下,都能显示出来么?并且正确么?
如果以上问题,你都能回答的清楚,那么本小节可以跳过了。
下面,我们一点一点分析。从本节开始,我们将使用Windbg来分析程序,本节不对windbg细节做介绍,下一节开始,我们讲述windbg中,常用的一些调试命令。这一节,如果你看不懂windbg命令,不要紧,只要能懂一件事,就够了,那就是“调用堆栈到底是怎么形成的”。
现在,程序已经中断在func3函数的左大括号处,我们观察调用堆栈,发现最上面的四行,和VS中所观察到的几乎一样。但是VS中下面原本是灰色的部分,在Windbg中已经显示出是哪些函数了。因为我们已经为ntdll和kernel32这两个模块加载了符号,所以这两个模块中的函数调用,已经可以显示出具体的函数名了。但是到底是怎么显示出来的,稍后我们分析我们自己的这几个函数后,你就应该知道ntdll和kernel32这两个模块里的函数名是怎么显示出来的了。
下面我们稍微回顾一下《函数的调用过程》一节中讲述的,一个函数在调用另外一个函数的时候,使用的call指令。比如说A函数要调用B函数。
void A()
{
B();
printf(“Test”);
}
这个时候,当call B函数的时候,其实已经把当前eip压入栈中,也就是下一行printf函数所在的地址,已经压入到栈中,如果B函数内部又去调用C函数,那么C函数的下一行代码所在地址也会压入栈中,这样,我们可以脑补出一个画面,就是栈中的情景如下:
随着函数的逐级调用,栈中的情形,大概就是这样,当我们执行到func3中,并且断下来的时候,我们已经命中了断点,但是我们如何才能知道当前断点命中是在func3这个函数内部呢?
我们在下断点的时候,其实我们已经指定好断点所在源文件和行号了,拿这个信息去pdb中查找,我们就能知道当前断点所在函数为func3函数内部。但这并不是唯一方式,而且这种方式也只能知道当前断点在哪个函数内,对于整个调用堆栈的回溯是不足够的。另一种可能是,我们知道当前断点的VA,用这个VA - 当前模块基地址 = OFFSET,拿着这个OFFSET直接去pdb中查找,是找不到结果的,因为当前这个OFFSET并不恰好是该模块中某个符号的开始处,但是我们可以这个OFFSET让调试器帮我们找到一个离当前OFFSET最近的一个前面的符号,作为当前断点所在的函数名称。(这句话很关键,一定要好好理解)。
其实说白了也就是从当前断点所在VA往前找,直到找到一个有符号的地址,并且用这个符号,作为当前断点所在的函数名。
从当前断点往前找,那最先找到的有符号的地址,肯定是func3所在的地址了,那也就断定当前断点在func3这个函数内部。
那么func3这个函数最终是要执行完的啊,然后会返回掉调用func3这个函数的地址的下一行继续执行,这个地址,也就是func3函数的返回地址。前面我们已经脑补过栈中的情景了,func3的返回地址已经在栈里了,func3的返回地址处,也肯定不恰好是某个函数的开头,那么如何才能知道是谁调用了func3呢?一样的方法,用func3的返回地址继续往上搜索,结果最先搜索到的是func2这个函数,那么也就得到了调用堆栈中的第二行,func2调用了func3. 如此一级一级下去,便形成了我们现在看到的调用堆栈。
现在我们来看看当前断点处的指令
也就是当前断点处的指令地址为:011813d0,我们在windbg中输入ln 011813d0,即可得到该地址往上离的最近的一个符号名称。如下图:
可以看到这正是func3函数。再多看一个,我们看看func3函数的返回地址在哪个函数内部,如下图:
这个0118146a就是func3的返回地址了,ln一下如下图:
哈哈,正是func2啊。说明我们前面所总结的函数调用堆栈的形成过程貌似还是靠谱的啊。另外我们再仔细观察一个东西,也就是CodeTest!func2+0x3a 这个后面的0x3a是个什么东西呢?
细心一点其实可以看到:0118146a = 01181430 + 3a,我们观察的是离0118146a 最近的、前面的有效的符号(func2)。也就是func2实际上的开始处是01181430 ,而在func2后面偏移了3a处才调用的func3函数,并且调用func3函数的时候的返回地址为0118146a 。这几个微妙的数字之间的关系,其实就这么简单。仔细品味一下,其实是很简单的一个逻辑。
本节总结:其实调用堆栈中,可以讲述的东西太多了,但是我们知道了如上这些小小的细节,足够我们对常规软件调试用了。本文中,的确存在几处理论错误,但请相信我,之所以这么讲解,是想让调试者更快的掌握调试所需的理论基础,即便是错的、即便我们以后不对其进行更正、也几乎不会影响到我们后续的软件调试。以后我们可以参照讲解更详细的书籍来更正本文中所出现的错误,我相信,你会很容易理解我为什么这个时候要这么讲。