Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

10.2 栈与调用惯例

10.2.1 什么是栈

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

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

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

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


图10-2 现实生活中的栈:叠起来的书

图10-3是一个栈的实例。


图10-3 程序栈实例

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

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

  • 函数的返回地址和参数。
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器。

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


图10-4 活动记录

在参数之后的数据(包括参数)即是当前函数的活动记录,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模式)得到的结果如图10-5所示(非粗体部分为调试用的代码)。

我们可以看到头两行保存了旧的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步在栈上分配出来的空间。


图10-5 foo函数汇编代码分析

【小知识】

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

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

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


图10-6 未初始化的局部变量

之所以会出现"烫"这么一个奇怪的字,就是因为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(不可在此编译单元之外访问)。
  • 函数在本编译单元仅被直接调用,没有显示或隐式取地址(即没有任何函数指针指向过这个函数)。

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

【小知识】Hot Patch Prologue

在Windows的函数里,有些函数尽管使用了标准的进入指令序列,但在这些指令之前却插入了一些特殊的内容:

mov edi, edi

我们知道这条指令没有任何用处,事实上也确实如此。这条指令在汇编之后会成为一个占用2个字节的机器码,纯粹作为占位符而存在。使用这条指令开头的函数整体上看起来是这样的:

nop
nop
nop
nop
nop
FUNCTION:                           ; 函数的实际入口
mov edi, edi                        ; 2 字节的占位符
push ebp                                ; 标准的进入序列
mov ebp, esp

其中nop指令占1字节,本身不做任何操作,也是以占位符的形式存在,FUNCTION为一个标号,表明函数的入口,本身不占据任何空间。

被设计成这样的函数在运行的时候可以很容易被其他函数"替换"掉。在上面的指令序列中调用的函数是FUNCTION,但是我们可以做一些改动,就可以在运行时刻修改成调用函数REPLACEMENT_FUNCTION。首先我们需要在进程的内存空间里的任意某处写入REPLACEMENT_FUNCTION的定义:

REPLACEMENT_FUNCTION:
push ebp
mov ebp, esp
...
mov esp, ebp
pop ebp
ret

然后将原函数的内容稍作修改即可:

LABEL:
jmp REPLACEMENT_FUNCTION
FUNCTION:          ; 函数的实际入口
jmp LABEL
push ebp           ; 标准的进入序列
mov ebp, esp

在这里,我们首先将占用5个字节的5个nop指令覆盖为一个jmp指令(恰好5字节),然后将占用两个字节的mov edi, edi指令替换为另一个jmp指令。为什么第二个jmp指令只占用2个字节呢?因为这个jmp的目标距离这个jmp指令本身非常近,因此这个jmp指令就被汇编器翻译成了一个"近跳"指令,这种指令只占用2个字节,但只能跳跃至当前地址前后127字节范围的目标位置。在经过这样的替换之后,原函数的调用就被转换为新函数的调用。

这里替换的机制往往可以用来实现一种叫做钩子(Hook)的技术,允许用户在某些时刻截获特定函数的调用,如图10-7所示。

10.2.2 调用惯例

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


图10-7 函数调用惯例犹如语言

假设有一个foo函数:

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

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


图10-8 错误的调用惯例

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

  • 函数参数的传递顺序和方式

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

  • 栈的维护方式

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

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

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

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

int _cdecl foo(int n, float m)

注意

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

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


表10-1

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

  • 将m压入栈。

  • 将n压入栈。

  • 调用_foo,此步又分为两个步骤:

    a. 将返回地址(即调用_foo之后的下一条指令的地址)压入栈; b. 跳转到_foo执行。

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


图10-9 foo函数栈布局

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


图10-10 foo函数栈布局(2)

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

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

实际执行的操作如图10-11所示。


图10-11 main函数的执行流程

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

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

这些代码形成的堆栈格局如图10-12所示。


图10-12 多级调用栈布局

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


表10-2

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

【小实验】

我们可以让函数的调用方使用错误的调用惯例,看看能发生什么事情:

//a.c
void __fastcall foo(int, int);

int main()
{
    foo(1, 3);
    return 0;
}

//b.c
#include <stdio.h>
void __cdecl foo(int a, int b)
{
    printf("a=%d,b=%d", a, b);
}

这里有2个.c文件,分别定义和调用了函数foo,但在a.c中,调用foo所使用的调用惯例是错误的fastcall。编译并链接这两个.c文件会发现链接失败,因为在a.c中,foo函数被修饰为@foo@8,而在b.c中,foo函数被修饰为_foo。为了使得程序能够运行,我们可以把b.c单独编译为DLL(或so),并导出符号foo,而main则加载b.c导出的DLL(或so),并导入符号foo。(具体步骤在动态链接部分已经有详细的说明,这里就不再细说。)如此处理之后程序就可以运行了,运行的结果(可能)是:

a=8458637,b=1

可见参数没有正确的传入。

10.2.3 函数返回值传递

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

对于返回5~8字节对象的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。其中eax存储返回值要低4字节,而edx存储返回值要高1~4字节。而对于超过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的功能,加粗的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)也正好处于这个扩大区域的内部。至于这块区域剩下的内容,则留作它用。下面我们就可以把思路理清了:

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

整个流程如图10-13所示。

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

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));
}


图10-13 返回值传递流程

毋庸置疑,如果返回值类型的尺寸太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。因而不到万不得已,不要轻易返回大尺寸的对象。为了不失一般性,我们再来看看在Linux下使用gcc 4.03编译出来的代码返回大尺寸对象的情况。测试的代码仍然使用以下代码:

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();
}

下面是其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();
}

在没有开启任何优化的情况下,直接运行一下,可以发现程序输出为:

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++的返回值优化可以将这两步合并,直接将对象构造在传出时使用的临时对象上,因此可以减少一次复制过程。