当前位置:首页 >> 编程开发 >> Visual C++ >> 内容

小览call stack(调用栈) (二)——调用约定

时间:2015/5/19 21:33:14 作者:平凡之路 来源:xuhantao.com 浏览:

在上一篇博客中小览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中

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

#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的调用对应了如下的汇编代码:

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中开始一个新的线程。

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

int __stdcall Test(int a, char b, short c)
int __stdcall Test(int a, char b, short c) 

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

[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的调用栈,作为这一系列的收尾,敬请期待。

相关文章
  • 没有相关文章
共有评论 0相关评论
发表我的评论
  • 大名:
  • 内容:
  • 徐汉涛(www.xuhantao.com) © 2024 版权所有 All Rights Reserved.
  • 部分内容来自网络,如有侵权请联系站长尽快处理 站长QQ:965898558(广告及站内业务受理) 网站备案号:蒙ICP备15000590号-1