小览call stack(调用栈)

栈在计算机领域中是个经常提到的名词,数据结构中有栈;网络传输中有协议栈。今天我们讨论的调用栈(call stack),指的是在程序的执行过程中存储函数调用信息的动态数据结构。



这个定义可能太抽象了一些,在给出具体的例子之前,请大家先思考一个问题,哪些信息是函数调用过程中所需要的?或者这么问,一个编译器,在面对一个函数的调用指令时,该生成哪些代码?



首先,函数的返回地址要保存下来。就好像你和你的小狗玩仍飞碟游戏,每一个函数调用好比扔一个飞碟,当你的狗狗哼兹哼兹的捡来飞碟,函数完执行的时候,它一定得知道去哪里把飞碟还给你。



然后,函数的参数是个必不可少的元素,这个很直观,就不多罗嗦了。第三,被调用的函数的局部变量也要存储在栈上。因为根据局部标量的定义,对相同函数的不同调用,局部变量有不同的存储空间,不会互相影响,所以这些数据也是跟函数调用息息相关的信息。



下面,我们通过一个例子,来看看函数的调用栈中的信息:

对于下面一段c++程序

view plaincopy to clipboardprint?
#include <stdio.h>

int SumFromOne(int d)
{
int sum = 0xabcd;
if (d == 1)
sum = 1;
else
sum = d + SumFromOne(d-1);
return sum;
}

void main()
{
int sum = SumFromOne(10);
printf("sum=%d", sum);
}
#include <stdio.h>

int SumFromOne(int d)
{
int sum = 0xabcd;
if (d == 1)
sum = 1;
else
sum = d + SumFromOne(d-1);
return sum;
}

void main()
{
int sum = SumFromOne(10);
printf("sum=%d", sum);
}




编译之,Cl /Zi a.cpp (/Zi生成pdb,调试的时候使用)


大家选用熟悉的调试器,在这里,笔者用的是windbg 大家可以去这个地址下载(http://www.microsoft.com/whdc/devtools/debugging/installx86.Mspx)


从调试器中启动程序:Windbg a.exe

然后在第4行设置一个断点(F9)。开始执行这个程序(F5),直到程序中断在断点处


找到程序的调用栈:

1. 察看当前的ebp,在command窗口中应该已经看到。否则的话,在command中输入r

2. 在memory察看窗口中,virtual栏中输入ebp-10的值,并且把display format改成long hex,以利于观察栈中的值


我把我的windbg截图粘贴如下,并和大家一起观察几个地方



1. 返回地址0040106b。参见反汇编的结果,0040106b正是main调完SumFromOne之后的那条指令。

2. 参数。主程序传给他的是10,(0xa),在memory窗口ebp+8的位置找到他。

3. 局部变量,我在程序中故意将sum初始化为0xabcd,大家可以在memory窗口ebp-4的位置找到他。


有兴趣地同学可以按F5,在下一个断点中察看相关信息。

在上一篇博客中小览call stack(调用栈) (一)中,我展示了如何在windbg中观察调用栈的相关信息:函数的返回地址,参数,返回值。这些信息都按照一定的规则存储在固定的地方。这个规则就是调用约定(calling convention)。



调用约定在计算机界不是什么新鲜的概念,已经有许多相关的文献给予详细的介绍。比较全面的介绍可以参见wikipedia上的相关页面。然而,如果你和我一样,在第一次接触调用约定的时候,觉得这个概念是个高深神秘的冬冬,那么就请跟随我一起,在这篇博客中看看他的由来,他的范畴以及他的用途。



为什么需要调用约定?

在具体介绍调用约定的定义之前,我们先来看看为什么我们需要一个称之为调用约定的冬冬。如果各位了解汇编语言(不了解的话,看下面的这段会稍微有些费力,不过我尽可能把汇编的相关知识解释的清楚一些),那么回忆一下我们是怎么来做一个函数调用的。



汇编语言提供了一条指令,call ptr,其功能是把CS:IP (指令段:指令指针,决定着下一条执行指令的地址)压栈,并且修改CPU的指令指针,作一个跳转。在函数结束的地方,我们使用另一条指令,ret,其功能是把栈中的返回地址取出,并且跳转到那条指令。



在这里汇编语言只提供了指令跳转的命令,作为函数调用另一个重要组成部分的参数传递,其方式就很灵活,你可以通过寄存器传值,可以通过调用栈传值,可以通过某一块具体的内存传值(类似全局变量)。然后在被调用函数中,从寄存器,栈或者是内存中读取这些信息。想象一下如果被调用函数是某一个程序员所编写的,调用者是另一个程序员,那么他俩之间对于参数的传递方式就有了一个约定。



高级语言的出现,把这个问题隐藏了起来。我们在编写一般的c++程序的时候,通常不需要顾虑参数传递的底层实现,但是,这并不意味着这一问题不再出现——我们只是把责任推给了编译器。编译器作为一个计算机程序,总是遵照一定的规则工作,每一个规则对应了一种调用约定。



久而久之,那些经典的规则所产生的调用约定,就成了耳熟能详的冬冬:



耳熟能详的调用约定

在介绍这些调用规范之前,我想先说明的是,下面所涉及的调用规范是在32位x86处理器windows平台上的。把范畴限定在32位处理器的原因是:16位处理器已经退出CPU的历史舞台,64微处理器无论是IA64还是AMD64都只有一个调用规范——只有32位处理器呈现百家成名,百花齐放的景象。(对了,你当然明白调用规范是绑定在处理器架构上的概念,因为它涉及太多的诸如寄存器之类的处理器架构细节。)聚焦于windows则是因为我现在的工作只涉及这一平台。

下表的出处来自于The Old New Thing以及张羿的csdn专栏,并作了适当修改。


首先来看所有的调用规范都遵循的规定:返回值存储在EDX:EAX中,EDI,ESI,EBP,EBX是保留的存储器。(即函数可以任意使用这些寄存器,无需担心破坏了调用者的寄存器状态)

调用约定名称
清理堆栈
参数压栈顺序
备注

cdecl
调用者 (Caller)
从右往左
因为是调用者清理Stack,因此允许变参 (如printf)

stdcall
被调用者 (Callee)
从右往左
一般在Windows API和COM中使用,也是.NET和Native代码调用的缺省Calling Convention。
顺便提一下,Windows中API的Calling Convention所使用到的WINAPI宏在PC机上是__stdcall,而在WinCE上则是__cdecl,并非一成不变。

Thiscall (Microsoft)
被调用者 (Callee)
从右往左
基本上等价stdcall, 除了this指针用ECX传递

Fastcall (Microsoft)
被调用者 (Callee)
从右往左
和Stdcall类似,但是会选择两个从左往右数最先可以放在寄存器里面的参数放在ECX和EDX中



大家可能对清理堆栈,参数压栈顺序这些概念不是很清楚,在这里我会通过一个具体的例子来说明。下面列出了一小段程序和它的汇编代码:

view plaincopy to clipboardprint?
#include <stdio.h>
int __stdcall Test(int a, char b, short c)
{
printf("%d %c %d", a, b, c);
return a+c;
}
void main()
{
int a = Test(5, 'a', 10);
}
#include <stdio.h>
int __stdcall Test(int a, char b, short c)
{
printf("%d %c %d", a, b, c);
return a+c;
}
void main()
{
int a = Test(5, 'a', 10);
}

在main中对Test的调用对应了如下的汇编代码:

view plaincopy to clipboardprint?
00412004 6a0a push 0Ah
00412006 6a61 push 61h
00412008 6a05 push 5
0041200a e800f0feff call test!ILT+10(?TestYGHHDFZ) (0040100f)
0041200f 8945fc mov dword ptr [ebp-4],eax ss:002b:001
00412004 6a0a push 0Ah
00412006 6a61 push 61h
00412008 6a05 push 5
0041200a e800f0feff call test!ILT+10(?TestYGHHDFZ) (0040100f)
0041200f 8945fc mov dword ptr [ebp-4],eax ss:002b:001



在这个例子中,我们可以观察到如下信息:

1. 压栈顺序:栈中首先压入的是0A(十进制中的10),是最后一个参数,其次是’a’,最后是5,所以说__stdcall的压栈顺序是从右向左。

2. 返回值存放在eax中:在call指令之后,把eax的值存入到[ebp-4]中,对应了c++代码中对a的赋值,可见eax是返回值的存放之所。

3. 被调用函数清理栈:在call指令和mov指令没有额外的其他指令,可见之前放到栈里的参数,都已经被函数Test清理了(Test的最后一条指令是ret 0c),把栈的指针调整了三个变量的位置。

4. 函数更名:细心的读者会发现call指令后面跟的是如同乱码般的test!ILT+10(?TestYGHHDFZ),这是编译器做的手脚(name mangling),不同的调用规范下,编译器会按照不同的规则对函数进行更名。我不想细究的原因在于:一方便,函数更名的规则本身就在变化,我目前使用的编译器,会按照以前__thiscall的规则来更名__stdcall的函数。另一方面,许多debuger比如windbg,会自动的把命名调整回来。


如何指定调用约定

通常,我们真正需要考虑到调用约定的场景,是对一些外部类库的使用。举例来说,如果我们要调用的函数由另外一个类库提供,那么,我们需要根据这个函数所声明的调用约定来使用这个函数。也就是说,我们要告诉编译器,请按照这个调用约定,生成相关的代码,来使用那个来自于类库的函数。对于MSVC的编译器来说,有下面的这些开关:

编译器开关
调用规范

/Gd
__cdecl

/Gr
__fastcall

/Gz
__stdcall


其中/Gz是c++的默认选项。



另外一个例子是,提供给别人的回调函数,需要根据调用者的要求,声明调用约定,举一个例子来说,在windows中开始一个新的线程。

这时候,可以在函数声明的语句中,在返回值类型后面插入相关的调用规范,如前面的例子中所示。

view plaincopy to clipboardprint?
int __stdcall Test(int a, char b, short c)
int __stdcall Test(int a, char b, short c)


如果你是一个.NET用户(终于,我可以谈及一些我们的产品了),那么你在P/Invoke的时候仍然需要调用约定。DllImportAttibute中,有一个字段CallingConvention,就是对应这个需求生成的。



view plaincopy to clipboardprint?
[DllImport("ole32.dll", EntryPoint="CoCreateInstance", CallingConvention=CallingConvention.StdCall)]
public static extern int CoCreateInstance(ref Guid rclsid, IntPtr pUnkOuter, uint dwClsContext, ref Guid riid, ref System.IntPtr ppv) ;
[DllImport("ole32.dll", EntryPoint="CoCreateInstance", CallingConvention=CallingConvention.StdCall)]
public static extern int CoCreateInstance(ref Guid rclsid, IntPtr pUnkOuter, uint dwClsContext, ref Guid riid, ref System.IntPtr ppv) ;


调用约定的用武之地

看了上面的介绍之后,你可能会想,我们只需要根据文档上声明的调用约定,在自己的代码中指定相应的调用约定就可以了。那么,了解清楚每一个调用约定的具体内容对我们有什么帮助呢?

我认为,了解调用约定首先可以帮助我们深入了解函数调用部分的汇编代码的原理。有很多时候,错误的使用了调用规范是一个很难察觉的bug。

其次,了解调用约定在只拥有公共符号(public symbol)进行调试的时候对我们帮助很大,公共符号通常只能让我们观察到调用栈信息。那么了解了调用约定之后,我们至少能利用调用栈找到函数参数,函数返回值等信息。



总结以及下期预告

今天我花费了蛮多笔墨讲解调用规范,对于这一系列的主题“调用栈”来说,调用规范是一个息息相关的概念。下一次,我将通过一个windbg调试脚本来观察遵循stdcall的调用栈,作为这一系列的收尾,敬请期待。

在这一系列之前的两篇文章中,我介绍了如何在windbg中查看调用栈的相关信息(详见小览call stack(调用栈)(一)),以及调用约定(详见小览call stack(调用栈) (二)——调用约定)。今天的这篇博客在二者的基础之上,介绍如何使用调式器脚本程序来观察调用栈。对CallStack感兴趣的朋友可以在此基础上开发更加详尽的脚本来观察CallStack的信息;对调试感兴趣的朋友则可以看一下DScript的用处。

我们先来看一个例子,下面的程序并不是一个优美的程序片段,但是它能够帮助我们说明问题。程序使用了一个简单的递归,把1到参数d的和累加到sum之上。在main中,我们把d设为10,这样,在断点处,我们就能获得一个深度为11的调用栈。

Code
#include < stdio.h >

int SumToOne( int d, int sum)
{
sum += d;
if (d != 1 )
sum = SumToOne(d - 1 , sum);
else
sum = sum; // 这条语句方便设置断点
return sum;
}

void main()
{
int sum = SumToOne( 10 , 0 );
printf( " sum=%d " , sum);
}
然后,在当前文件夹下,编辑调试器脚本文件DumpStack.txt,内容如下

. printf " Dump %d frames\n " , $ { $ arg1}
r $ t1 = @ebp ;
. for ( r $ t0 = 1 ; $ t0 <=$ { $ arg1} ; r $ t0 =$ t0 + 1 )
{
. printf " frame %d, d=%d sum=%d\n " , $ t0 , poi ($ t1 + 8 ), poi ($ t1 + c )
r $ t1 = poi ($ t1 )
}
在windbg中,运行程序,当程序停止在断点处时,执行脚本

$$>a< “dumpStack.txt”a

如下图所示


我们看到了10个frame以及它的参数信息。

现在,对这个调试脚本稍加解释,稍显来看看脚本的语法:

调试脚本的调用方法,windbg的语法是$$>a< “脚本文件名”参数。其中$$>a<中的a示意运行脚本的时候传入参数(argument)
调试脚本的参数:在调试脚本中,用${$argi}来引用第i个参数。由于windbg默认16进制数,所以我们在调用这个参数的时候,用了a($$>a< "dumpStack.txt" a)
脚本变量的赋值和引用:这里使用了windbg别名(alias)的语法,大家可以把别名类比成c中的宏。在赋值的时候,用r $别名= 的格式,引用的时候,使用$别名
取值操作:c中的*p操作在windbg中,要用poi(p),原因是因为windbg默认支持MSAM语法。
控制语句:.for语句的使用和任何一种语言的for语句思想一样,不再多述
输出语句:.printf和c中的printf也基本相似,这里也不多述
了解了语法之后,来看看算法:

脚本通过poi($t1+8), poi($t1+c)来显示每个frame中d和sum的值,这里$t1代表了每个frame中ebp的值,所以简单的说,就是把每个frame中ebp+8,ebp+c的值输出。在介绍调用约定的博客中,我讲述了这个偏移量的由来,在这里重温一下。由于函数SumToOne是stdcall,压栈顺序从右往左,如下表所示前一个ebp
eip
d
sum
ebp指向存储前一个ebo的位置,所以d的位置在ebp+8,sum在ebp+c
前往下一个frame,只需要把栈上ebp位置的值取出,作为新的ebp就可以了。因为基本上每一个程序在进行栈操作之前都会备份老的ebp(push ebp),然后把当前的esp作为新的ebp(mov ebp, esp)
总结一下,今天这篇博文作为这个系列的结束,通过一个调式器脚本,复习了之前讲述的调用栈的相关概念。同时也展示了调试器脚本的相关语法。


文章来自: 本站原创
Tags:
评论: 0 | 查看次数: 8141