《程序员的自我修养》阅读笔记(二)栈与调用惯例

 2年前     1,724  

文章目录

阅读笔记主要来自原书第 10 章。该章对发生函数调用时程序内存空间中栈区的变化、调用惯例以及函数返回值的传递进行了详细的介绍。

1、什么是栈

栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今能够看见的所有的计算机语言。在解释为什么栈会如此重要之前,让我们来先了解一下传统的栈的定义:

在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out,FILO),多多少少像叠成一叠的书(如下图所示):先叠上去的书在最下面,因此要最后才能取出。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。

在经典的操作系统里,栈总是向下增长的。 在 i386 下,栈顶由称为 esp 的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。

补充:i386 与 esp

i386 处理器,也叫做 80386,x86 指令集,32 位处理器。详情可参考:i386 和 x86-64 有什么区别?

正因为 i386 是 32 位处理器,所以对应的寄存器名为 esp(低 32 位)。x86-64 则对应 rsp(64位)。下图给出了寄存器对应低 32 位、16 位、8 位的名称。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

图片来源:Notes on x86-64 programming

下图是一个栈的实例。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

这里栈底的地址是 0xbfffffff,而 esp 寄存器标明了栈顶,地址为 0xbffffff4。在栈上压入数据会导致esp 减小,弹出数据使得 esp 增大。相反,直接减小 esp 的值也等效于在栈上开辟空间,直接增大 esp 的值等效于在栈上回收空间。

栈在程序运行中具有举足轻重的地位。最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录(Activate Record)。堆栈帧一般包括如下几方面内容:

  • 函数的返回地址和参数。

  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。

  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器。

补充:x86 处理器使用以下寄存器

EBX:它是一个 32 位基址寄存器,用于变址寻址。

ESI:它是一个 32 位源寄存器,用于存储字符串操作的源索引。

EDI:它是一个 32 位的目的寄存器,用来存储字符串操作的目的索引。

EAX:它是一个 32 位累加器,主要用于算术运算。

ECX:它是一个 32 位计数器寄存器,用来存储循环计数。

EDX:它是一个用于 I/O 操作的 32 位数据寄存器。

在 i386 中,一个函数的活动记录用 ebp 和 esp 这两个寄存器划定范围esp 寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp 寄存器指向了函数活动记录的一个固定位置,ebp 寄存器又被称为帧指针(Frame Pointer)。一个很常见的活动记录示例如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

在参数之后的数据(包括参数)即是当前函数的活动记录,ebp 固定在图中所示的位置,不随这个函数的执行而变化,相反地,esp 始终指向栈顶,因此随着函数的执行,esp 会不断变化固定不变的 ebp 可以用来定位函数活动记录中的各个数据。在 ebp 之前首先是这个函数的返回地址,它的地址是 ebp-4,再往前是压入栈中的参数,它们的地址分别是 ebp-8、ebp-12 等,视参数数量和大小而定。ebp 所直接指向的数据是调用该函数前 ebp 的值,这样在函数返回的时候,ebp 可以通过读取这个值恢复到调用前的值。之所以函数的活动记录会形成这样的结构,是因为函数调用本身是如此书写的:一个 i386 下的函数总是这样调用的:

  • 把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
  • 把当前指令的下一条指令的地址压入栈中。
  • 跳转到函数体执行。

其中第 2 步和第 3 步由指令 call 一起执行。跳转到函数体之后即开始执行函数,而 i386 函数体的“标准”开头是这样的(但也可以不一样):

  • push ebp:把 ebp 压入栈中(保存 old ebp)。
  • mov ebp, esp:ebp = esp(这时 ebp 指向栈顶,而此时栈顶就是 old ebp)。
  • 【可选】sub esp, XXX:在栈上分配 XXX 字节的临时空间。
  • 【可选】push XXX:如有必要,保存名为 XXX 的寄存器(可重复多个)。

把 ebp 压入栈中,是为了在函数返回的时候便于恢复以前的 ebp 值。而之所以可能要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出。不难想象,在函数返回时,所进行的“标准”结尾与“标准”开头正好相反:

  • 【可选】pop XXX:如有必要,恢复保存过的寄存器(可重复多个)。
  • mov esp, ebp:恢复 esp 同时回收局部变量空间。
  • pop ebp:从栈中恢复保存的 ebp 的值。
  • ret:从栈中取得返回地址,并跳转到该位置。

GCC 编译器有一个参数叫做 -fomit-frame-pointer 可以取消帧指针,即不使用任何帧指针,而是通过 esp 直接计算帧上变量的位置。这么做的好处是可以多出一个 ebp 寄存器供使用,但是坏处却很多,比如帧上寻址速度会变慢,而且没有帧指针之后,无法准确定位函数的调用轨迹(Stack Trace)。所以除非你很清楚你在做什么,否则请尽量不使用这个参数。

为了加深印象,下面我们反汇编一个函数看看:

int foo()
{
    return 123;
}

这个函数反汇编(VC9, i386,Debug模式)得到的结果如下图所示(非粗体部分为调试用的代码)。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

可以看到头两行保存了旧的 ebp,并让 ebp 指向当前的栈顶。接下来的一行指令

004113A3 sub esp,0C0h

将栈扩大了 0xC0 个字节,其中多出来的空间的值并不确定。这么一大段多出来的空间可以存储局部变量、某些临时数据以及调试信息。在第 3 步里,函数将 3 个寄存器保存在了栈上。这 3 个寄存器在函数随后的执行中可能被修改,所以要先保存一下这些寄存器原本的值,以便在退出函数时恢复。第 4 步的代码用于调试。这段汇编大致等价于如下伪代码:

edi = ebp – 0x0C;
ecx = 0x30;
eax = 0xCCCCCCCC;
for (; ecx != 0; --ecx, edi+=4)
    *((int*)edi) = eax;

可以计算出,0x30 * 4 = 0xC0。所以实际上这段代码将内存地址从 ebp-0xC0 到 ebp 这一段全部初始化为 0xCC。恰好就是第 2 步在栈上分配出来的空间。

【小知识】

我们在 VC 下调试程序的时候,常常看到一些没有初始化的变量或内存区域的值是“烫”。例如下列代码:

int main()
{
    char p[12];
}

此代码中的数组 p 没有初始化,当我们在 Debug 模式下运行这个程序,在 main 中设下断点并监视(watch)数组 p 时,就能看见如下图所示的情形。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

之所以会出现“烫”这么一个奇怪的字,就是因为 Debug 模式在第 4 步里,将所有的分配出来的栈空间的每一个字节都初始化为 0xCC。0xCCCC(即两个连续排列的 0xCC)的汉字编码就是烫,所以 0xCCCC 如果被当作文本就是“烫”。

将未初始化数据设置为 0xCC 的理由是这样可以有助于判断一个变量是否没有初始化。如果一个指针变量的值是 0xCCCCCCCC,那么我们就可以基本相信这个指针没有经过初始化。当然这个信息仅供参考,编译器检查未初始化变量的方法并不能以此为证据。有时编译器还会使用0xCDCDCDCD 作为未初始化标记,此时我们就会看到汉字“屯屯”。

在第 5 步,函数将 0x7B(即 123)赋值给 eax,作为返回值传出。在函数返回之后,调用方可以通过读取 eax 寄存器来获取返回值。接下来的几步是函数的资源清理阶段,从栈中恢复保存的寄存器、ebp 等。最后使用 ret 指令从函数返回。

以上介绍的是 i386 标准函数进入和退出指令序列,它们基本的形式为:

push ebp
mov ebp, esp
sub esp, x
[push reg1]
...
[push regn]

函数实际内容

[pop regn]
...
[pop reg1]
mov esp, ebp
pop ebp
ret

其中 x 为栈上开辟出来的临时空间的字节数,reg1...regn 分别代表需要保存的 n 个寄存器。方括号部分为可选项。不过在有些场合下,编译器生成函数的进入和退出指令序列时并不按照标准的方式进行。例如一个满足如下要求的 C 函数:

  • 函数被声明为 static(不可在此编译单元之外访问)。

  • 函数在本编译单元仅被直接调用,没有显示或隐式取地址(即没有任何函数指针指向过这个函数)。

编译器可以确信满足这两条的函数不会在其他编译单元内被调用,因此可以随意地修改这个函数的各个方面——包括进入和退出指令序列——来达到优化的目的。

2、调用惯例

经过前面的分析和讨论,我们大致知道了函数调用时实际发生的事件。从这样的信息里能够发现一个现象,那就是函数的调用方和被调用方对函数如何调用有着统一的理解,例如它们双方都一致地认同函数的参数是按照某个固定的方式压入栈内。如果不这样的话,函数将无法正确运行。这就好比我们说话时需要双方对同一个声音(语音)有着一致的理解一样,否则就会产生误解,如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

假设有一个 foo 函数:

int foo(int n, float m)
{
    int a = 0, b = 0;
    ...
}

如果函数的调用方在传递参数时先压入参数 n,再压入参数 m,而 foo 函数却认为其调用方应该先压入参数 m,后压入参数 n,那么不难想象 foo 内部的 m 和 n 的值将会被交换。如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

再者如果函数的调用方决定利用寄存器传递参数,而函数本身却仍然以为参数通过栈传递,那么显然函数无法获取正确的参数。因此,毫无疑问函数的调用方和被调用方对于函数如何调用须要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确地调用,这样的约定就称为调用惯例(Calling Convention)。一个调用惯例一般会规定如下几个方面的内容。

  1. 函数参数的传递顺序和方式

    函数参数的传递有很多种方式,最常见的一种是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序:是从左至右,还是从右至左。有些调用惯例还允许使用寄存器传递参数,以提高性能。

  2. 栈的维护方式

    在函数将参数压栈之后,函数体会被调用,此后需要将被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数本身来完成。

  3. 名字修饰(Name-mangling)的策略

    为了链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。

其实如果函数的参数固定的话,比如void func(int a, int b, int c)的入栈顺序从右到左还是从左到右怎么都行,只不过在调用不定个数参数的函数时使用从左到右压站就麻烦了。

比如printf(“%d-%d-%d\n”,a,b,c);,printf 的参数个数是通过第一个参数 format 中的 %d %dx %xxxx来确定后面跟有几个实际参数的。如果入栈顺序从左到右,则应该是栈底到栈顶为:“%d-%d-%d\n”+a+b+c;。当调用到函数时候,去访问他的参数,则只能访问到栈顶元素 c,但是函数有需要确定有个实际参数,但是 c 有不是保存了 format 信息不知道到底有几个参数。所以从左到右就会很麻烦,于是通过从右到左额方式压栈,就能在栈顶一下子取到“%d-%d-%d\n”信息知道栈里面紧接着有多少实际参数了。然后一个一个的取就可以了。说白了从右到左的目睹就是为了解决不定参函数的取参数方便的目的的。

来源:https://blog.csdn.net/weixin_37871174/article/details/106163042

事实上,在 C 语言里,存在着多个调用惯例,而默认的调用惯例是 cdecl。任何一个没有显式指定调用惯例的函数都默认是 cdecl 惯例。对于函数 foo 的声明,它的完整形式是:

int _cdecl foo(int n, float m)

_cdecl 是非标准关键字,在不同的编译器里可能有不同的写法,例如在 gcc 里就不存在 _cdecl这样的关键字,而是使用 __attribute__((cdecl))

cdecl 这个调用惯例是 C 语言默认的调用惯例,它的内容如下表所示。

参数传递 出栈方 名字修饰
从右至左的顺序压参数入栈 函数调用方 直接在函数名称前加 1 个下划线

因此 foo 被修饰之后就变为_foo。在调用 foo 的时候,按照 cdecl 的参数传递方式,具体的堆栈操作如下。

  1. 将 m 压入栈。
  2. 将 n 压入栈。
  3. 调用 _foo,此步又分为两个步骤:
    1. a) 将返回地址(即调用_foo之后的下一条指令的地址)压入栈;
    2. b) 跳转到_foo执行。

当函数返回之后:sp = sp + 8(参数出栈,由于不需要得到出栈的数据,所以直接调整栈顶位置就可以了)。因此进入 foo 函数之后,栈上大致是如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

然后在 foo 里面要保存一系列的寄存器,包括函数调用方的 ebp 寄存器,以及要为 a 和 b 两个局部变量分配空间(参见开头)。最终的栈的构成会如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

对于不同的编译器,由于分配局部变量和保存寄存器的策略不同,这个结果可能有出入。在以上布局中,如果想访问变量 n,实际的地址是使用 ebp+8。当 foo 返回的时候,程序首先会使用 pop 恢复保存在栈里的寄存器,然后从栈里取得返回地址,返回到调用方。调用方再调整 esp 将堆栈恢复。因此有如下代码:

void f(int x, int y)
{
    ...
    return;
}
int main()
{
    f(1, 3);
    return 0;
}

实际执行的操作如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

其中虚线指向该指令执行后的栈状态,实线表示程序的跳转状况。同样,对于多级调用,如果我们有如下代码:

void f(int y)
{
    printf("y=%d", y);
}
int main()
{
    int x = 1;
    f(x);
    return 0;
}

这些代码形成的堆栈布局如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

上图的箭头表示地址的指向关系,而带下划线的代码表示当前执行的代码。除了 cdecl 调用惯例之外,还存在很多别的调用惯例,例如 stdcall、fastcall 等。下介绍了几项主要的调用惯例的内容。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

此外,不少编译器还提供一种称为 naked call 的调用惯例,这种调用惯例用在特殊的场合,其特点是编译器不产生任何保护寄存器的代码,故称为 naked call。

对于 C++ 语言,以上几种调用惯例的名字修饰策略都有所改变,因为 C++ 支持函数重载以及命名空间和成员函数等等,因此实际上一个函数名可以对应多个函数定义,那么上面提到的名字修饰策略显然是无法区分各个不同同名函数定义的。所以 C++ 自己有更加复杂的名字修饰策略。最后,C++ 自己还有一种特殊的调用惯例,称为 thiscall,专用于类成员函数的调用。其特点随编译器不同而不同,在 VC 里是 this 指针存放于 ecx 寄存器,参数从右到左压栈,而对于 gcc、thiscall 和 cdecl 完全一样,只是将 this 看作是函数的第一个参数。

C++ 符号修饰

众所周知,强大而又复杂的 C++ 拥有类、继承、虚机制、重载、名称空间等这些特性,它们使得符号管理更为复杂。最简单的例子,两个相同名字的函数 func(int) 和 func(double),尽管函数名相同,但是参数列表不同,这是 C++ 里面函数重载的最简单的一种情况,那么编译器和链接器在链接过程中如何区分这两个函数呢?为了支持 C++ 这些复杂的特性,人们发明了符号修饰(Name Decoration)或符号改编(Name Mangling)的机制,下面我们来看看 C++ 的符号修饰机制。

首先出现的一个问题是 C++ 允许多个不同参数类型的函数拥有一样的名字,就是所谓的函数重载;另外 C++ 还在语言级别支持名称空间,即允许在不同的名称空间有多个同样名字的符号。比如下边这段代码。

int func(int);
float func(float);

class C {
    int func(int);
    class C2 {
        int func(int);
    };
};

namespace N {
    int func(int);
    class C {
        int func(int);
    };
}

这段代码中有 6 个同名函数叫 func,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名(Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用于识别不同的人一样,函数的名字只是函数签名的一部分。由于上面 6 个同名函数的参数类型及所处的类和名称空间不同,我们可以认为它们的函数签名不同。

在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)。编译器在将 C++ 源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++ 的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。C++ 编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。上面的 6 个函数签名在 GCC 编译器下,相对应的修饰后名称如下表所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

GCC 的基本 C++ 名称修饰方法如下:所有的符号都以 _Z 开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟 N ,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以 E 结尾。比如 N::C::func 经过名称修饰以后就是 _ZN1N1C4funcE。对于一个函数来说,它的参数列表紧跟在 E 后面,对于 int 类型来说,就是字母 i 。所以整个 N::C::func(int) 函数签名经过修饰为 _ZN1N1C4funcEi

3、函数返回值传递

除了参数的传递之外,函数与调用方的交互还有一个渠道就是返回值。在前面的例子中,我们发现 eax 是传递返回值的通道。函数将返回值存储在 eax 中,返回后函数的调用方再读取 eax。但是 eax 本身只有 4 个字节,那么大于 4 字节的返回值是如何传递的呢?

对于返回 5~8 字节对象的情况,几乎所有的调用惯例都是采用 eax 和 edx 联合返回的方式进行的。其中 eax 存储返回值要低 4 字节,而 edx 存储返回值要高 1~4 字节。

在 64 位系统中直接使用 rax 存储 8 字节的内容进行返回?不过对于 double 好像并不是这么回事。

《程序员的自我修养》阅读笔记(二)栈与调用惯例 《程序员的自我修养》阅读笔记(二)栈与调用惯例

而对于超过 8 字节的返回类型,我们可以用下列代码来研究:

typedef struct big_thing
{
    char buf[128];
}big_thing;

big_thing return_test()
{
    big_thing b;
    b.buf[0] = 0;
    return b;
}

int main()
{
    big_thing n = return_test();
}

这段代码里的 return_test 的返回值类型是一个长度为 128 字节的结构,因此无论如何也不可能直接用过 eax 传递。让我们首先来反汇编(MSVC9)一下 main 函数,结果如下:

big_thing n = return_test();
00411498  lea         eax,[ebp-1D0h] 
0041149E  push        eax  
0041149F  call        _return_test 
004114A4  add         esp,4 
004114A7  mov         ecx,20h 
004114AC  mov         esi,eax 
004114AE  lea         edi,[ebp-88h] 
004114B4  rep movs    dword ptr es:[edi],dword ptr [esi] 

其中第二行:

00411498  lea         eax,[ebp-1D0h]

将栈上的一个地址(ebp-1D0h)存储在 eax 里,接着下一行:

push        eax

将这个地址压入栈中然后就紧接着调用 return_test 函数。这从形式上无疑是将数据 ebp - 1D0h 作为参数传入 return_test 函数,然而 return_test 是没有参数的,因此我们可以将这个数据称为是“隐含参数”。换句话说,return_test 的原型实际是:

big_thing return_test(void* addr);

这段汇编最后 4 行是一个整体,我们可以想象在函数返回之后,函数的调用方需要获取函数的返回对象并对 n 赋值。rep movs 是一个复合指令,它的大致意义是重复 movs 指令直到 ecx 寄存器为 0。于是 rep movs a, b 的意思就是将 b 指向位置上的若干个双字(4字节)拷贝到由 a 指向的位置上,拷贝双字的个数由 ecx 指定,实际上这句复合指令的含义相当于 memcpy(a, b, ecx * 4)。所以说,最后 4 行的含义相当于:

memcpy(ebp-88h, eax, 0x20 * 4)

即将 eax 指向位置上的 0x20 个双字拷贝到 ebp-88h 的位置上。毫无疑问,ebp-88h 这个地址就是变量 n 的地址,如果有所怀疑,可以比较一下 n 的地址和 ebp-88h 的值即可确信这一点。而 0x20 个双字就是 128 个字节,正是 big_thing 的大小。现在我们可以将这段汇编略微还原了:

return_test(ebp-1D0h) 
memcpy(&n, (void*)eax, sizeof(n));

可见,return_test 返回的结构体仍然是由 eax 传出的,只不过这次 eax 存储的是结构体的指针。那么return_test 具体是如何返回一个结构体的呢?让我们来看看 return_test 的实现:

big_thing return_test()
{
... 
    big_thing b;
    b.buf[0] = 0;
004113C8  mov         byte ptr [ebp-88h],0 
    return b;
004113CF  mov         ecx,20h 
004113D4  lea         esi,[ebp-88h] 
004113DA  mov         edi,dword ptr [ebp+8] 
004113DD  rep movs    dword ptr es:[edi],dword ptr [esi] 
004113DF  mov         eax,dword ptr [ebp+8] 
}

在这里,ebp-88h 存储的是 return_test 的局部变量 b。根据 rep movs 的功能,8、9、10 和 11 行所在的 4 条指令可以翻译成如下的代码:

memcpy([ebp+8],&b, 128);

在这里,[ebp+8] 指的是 *(void**)(ebp+8),即将地址 ebp+8 上存储的值作为地址,由于 ebp 实际指向栈上保存的旧的 ebp,因此 ebp+4 指向压入栈中的返回地址,ebp+8 则指向函数的参数。而我们知道,return_test 是没有真正的参数的,只有一个“伪参数”由函数的调用方悄悄地传入,那就是 ebp-1D0h(这里的 ebp 是 return_test 调用前的 ebp)这个值。换句话说,[ebp+8]=old_ebp-1D0h

那么到底 main 函数里的 ebp-1D0h 是什么内容呢?我们来看看 main 函数一开始初始化的汇编代码:

int main()
{
00411470  push        ebp  
00411471  mov         ebp, esp
00411473  sub         esp,1D4h 
00411479  push        ebx  
0041147A  push        esi  
0041147B  push        edi  
0041147C  lea         edi,[ebp-1D4h] 
00411482  mov         ecx,75h 
00411487  mov         eax,0CCCCCCCCh 
0041148C  rep stos    dword ptr es:[edi] 
0041148E  mov         eax,dword ptr [___security_cookie (417000h)] 
00411493  xor         eax,ebp 
00411495  mov         dword ptr [ebp-4],eax

我们可以看到 main 函数在保存了 ebp 之后,就直接将栈增大了 1D4h 个字节,因此 ebp-1D0h 就正好落在这个扩大区域的末尾,而区间 [ebp-1D0h, ebp-1D0h + 128) 也正好处于这个扩大区域的内部。至于这块区域剩下的内容,则留作它用。下面我们就可以把思路理清了:

  1. 首先 main 函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为 temp。
  2. 将 temp 对象的地址作为隐藏参数传递给 return_test 函数。
  3. return_test 函数将数据拷贝给 temp 对象,并将 temp 对象的地址用 eax 传出。
  4. return_test 返回之后,main 函数将 eax 指向的 temp 对象的内容拷贝给 n

整个流程如下图所示。

《程序员的自我修养》阅读笔记(二)栈与调用惯例

也可以用伪代码表示如下:

void return_test(void *temp)
{
    big_thing b;
    b.buf[0] = 0;
    memcpy(temp, &b, sizeof(big_thing));
    eax = temp;
}

int main()
{
    big_thing temp;
    big_thing n;
    return_test(&temp);
memcpy(&n, eax, sizeof(big_thing));
}

毋庸置疑,如果返回值类型的尺寸太大,C 语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。因而不到万不得已,不要轻易返回大尺寸的对象

为了不失一般性,我们再来看看在 Linux 下使用 gcc 4.03 编译出来的代码返回大尺寸对象的情况。测试的代码仍然使用以上代码。

下面是其 main 函数的部分反汇编:

80483bd:  8d 85 f8 fe ff ff     lea eax , [ebp-107h]
 80483c3: 89 04 24            mov  [esp], eax
 80483c6: e8 95 ff ff ff        call  8048360 <return_test>
 80483cb: 83 ec 04              sub esp, 4
 80483ce: 8d 8d 78 ff ff ff     lea ecx, [ebp-87h]
 80483d4: 8d 95 f8 fe ff ff     lea edx , [ebp -107h] 
 80483da: b8 80 00 00 00        mov eax ,80h
 80483df: 89 44 24 08           mov  [esp+8h], eax
 80483e3: 89 54 24 04           mov  [esp+4h], edx
 80483e7: 89 0c 24              mov  [esp], ecx
 80483ea: e8 c1 fe ff ff        call  80482b0 <memcpy@plt>

与 MSVC9 的反汇编对比,可以发现,ebp-0x107 的位置上是临时对象 temp 的地址,而 ebp-0x87 则是 n 的地址。这样,这段代码和用 MSVC9 反汇编得到的代码是一样的,都是通过栈上的隐藏参数传递临时对象的地址,只不过在将临时对象写回到实际的目标对象 n 的时候,MSVC9 使用了 rep movs 指令,而 gcc 调用了 memcpy 函数。可见在这里 VC 和 gcc 的思路大同小异。

最后来看看如果函数返回一个 C++ 对象会如何:

#include <iostream>
using namespace std;

struct cpp_obj
{
    cpp_obj()
    {
        cout << "ctor\n";
    }
    cpp_obj(const cpp_obj& c)
    {
        cout << "copy ctor\n";
    }
    cpp_obj& operator=(const cpp_obj& rhs)
    {
        cout << "operator=\n";
        return *this;
    }
    ~cpp_obj()
    {
        cout << "dtor\n";
    }
};
cpp_obj return_test()
{
    cpp_obj b;
    cout << "before return\n";
    return b;
}

int main()
{
    cpp_obj n;
    n = return_test();
}

在没有开启任何优化(加上 -fno-elide-constructors 编译参数关闭返回值优化)的情况下,直接运行一下,可以发现程序输出为:

ctor
ctor
before return
copy ctor
dtor
operator=
dtor
dtor

我们可以看到在函数返回之后,进行了一个拷贝构造函数的调用,以及一次 operator= 的调用,也就是说,仍然产生了两次拷贝。因此 C++ 的对象同样会产生临时对象

返回对象的拷贝情况完全不具备可移植性,不同的编译器产生的结果可能不同。我们可以反汇编 main 函数来确认这一点:

n = return_test();
00411C2C  lea         eax,[ebp-0DDh] 
00411C32  push        eax  
00411C33  call        return_test (4111F4h) 
00411C38  add         esp,4 
00411C3B  mov         dword ptr [ebp-0E8h],eax 
00411C41  mov         ecx,dword ptr [ebp-0E8h] 
00411C47  mov         dword ptr [ebp-0ECh],ecx 
00411C4D  mov         byte ptr [ebp-4],1 
00411C51  mov         edx,dword ptr [ebp-0ECh] 
00411C57  push        edx  
00411C58  lea         ecx,[ebp-11h] 
00411C5B  call        cpp_obj::operator= (41125Dh) 
00411C60  mov         byte ptr [ebp-4],0 
00411C64  lea         ecx,[ebp-0DDh] 
00411C6A  call        cpp_obj::~cpp_obj (41119Ah)

可以看出,这段汇编与之前的版本结构是一致的,临时对象的地址仍然通过隐藏参数传递给函数,只不过最后没有使用 rep movs 来拷贝数据,而是调用了函数的 operator= 来进行。同时,这里还对临时对象调用了一次析构函数。

函数传递大尺寸的返回值所使用的方法并不是可移植的,不同的编译器、不同的平台、不同的调用惯例甚至不同的编译参数都有权力采用不同的实现方法。因此尽管我们实验得到的结论在 MSVC 和 gcc 下惊人地相似,读者也不要认为大对象传递只有这一种情况。

声名狼藉的 C++ 返回对象

正如我们看到的,在 C++ 里返回一个对象的时候,对象要经过 2 次拷贝构造函数的调用才能够完成返回对象的传递。1 次拷贝到栈上的临时对象里,另一次把临时对象拷贝到存储返回值的对象里。在某些编译器里,返回一个对象甚至要经过更多的步骤。

这样带来的恶果就是返回一个较大对象会有非常多的额外开销。因此 C++ 程序中都尽量避免返回对象。此外,为了减小返回对象的开销,C++提出了返回值优化(Return Value Optimization, RVO)这样的技术,可以将某些场合下的对象拷贝减少 1 次,例如:

cpp_obj return_test()
{
    return cpp_obj();
}

在这个例子中,构造一个 cpp_obj 对象会调用一次 cpp_obj 的构造函数,在返回这个对象时,还会调用 cpp_obj 的拷贝构造函数。C++ 的返回值优化可以将这两步合并,直接将对象构造在传出时使用的临时对象上,因此可以减少一次复制过程。

4、小结

本文介绍了栈在函数调用中所发挥的重要作用,以及与之伴生的调用惯例的各方面的知识。最后,还说明了函数传递返回值的各种技术细节。

版权声明:小傅 发表于 2年前,共 12615 字。
转载请注明:《程序员的自我修养》阅读笔记(二)栈与调用惯例 | 太傅博客

暂无评论

暂无评论...