函数返回值的坑

一. 简介

  最近有同学问为什么函数返回值选择返回对象指针而不是对象,所以就此展开来深入分析一下。

二. 一个简单的小例子

  首先从一个最简单的例子开始开。在如下代码中,foo()返回整型123,main()函数调用foo()之后返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdlib.h>
#include <stdio.h>

int foo()
{
return 123;
}

int main()
{
foo();
return 0;
}

  下面分别是windows下和Linux下的反汇编结果,可见其汇编执行是类似的,均将123(十六进制为0x7b)存放在eax之中。即先用寄存器eax临时保存返回值,然后将该返回值再从eax中取出。

  这里很有趣的是在windows下,可以看到有一步给eax赋初始值的步骤,而初始值为00CCCCCCCCh这就是众所周知的“烫烫烫”的来源了:以文本形式显示的话,cccc恰好对应“烫”这个字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Win10
int foo()
{
00007FF6EF232590 push rbp
00007FF6EF232592 push rdi
00007FF6EF232593 sub rsp,0C8h
00007FF6EF23259A mov rbp,rsp
00007FF6EF23259D mov rdi,rsp
00007FF6EF2325A0 mov ecx,32h
00007FF6EF2325A5 mov eax,0CCCCCCCCh
00007FF6EF2325AA rep stos dword ptr [rdi]
return 123;
00007FF6EF2325AC mov eax,7Bh
}

int main()
{
00007FF66E401D10 push rbp
00007FF66E401D12 push rdi
00007FF66E401D13 sub rsp,0E8h
00007FF66E401D1A lea rbp,[rsp+20h]
00007FF66E401D1F mov rdi,rsp
00007FF66E401D22 mov ecx,3Ah
00007FF66E401D27 mov eax,0CCCCCCCCh
00007FF66E401D2C rep stos dword ptr [rdi]
foo();
00007FF66E401D2E call foo (07FF66E401384h)
return 1;
00007FF66E401D33 mov eax,1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Ubuntu 16.04
00000000004004d6 <foo>:
4004d6: 55 push %rbp
4004d7: 48 89 e5 mov %rsp,%rbp
4004da: b8 7b 00 00 00 mov $0x7b,%eax
4004df: 5d pop %rbp
4004e0: c3 retq

00000000004004e1 <main>:
4004e1: 55 push %rbp
4004e2: 48 89 e5 mov %rsp,%rbp
4004e5: b8 00 00 00 00 mov $0x0,%eax
4004ea: e8 e7 ff ff ff callq 4004d6 <foo>
4004ef: b8 00 00 00 00 mov $0x0,%eax
4004f4: 5d pop %rbp
4004f5: c3 retq
4004f6: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
4004fd: 00 00 00

三. 复杂点的小例子

  第二个例子将返回值从一个整型扩大到了128字节,由此观察eax存放不下的时候该如何是好。一样是很简单的代码,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdlib.h>
#include <stdio.h>

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

  下面照例是Windows和Linux的对比结果。可以看到这里Windows的反汇编相较于Linux简洁了很多,其原因是Windows下使用了复合指令rep movs指令来循环执行,而Linux下则将循环展开直接写了出来,其实做的工作是一样的,下面拿Windows为例介绍。

  rep movs含义为重复movs指令直到ecx寄存器为0,rep movs a, b意思就是将b指向位置上的若干个字节拷贝到由a指定的位置上,循环拷贝的计数器为ecx。在我们这里ecx赋值为0x80,即128。rsi指向raxrdi指向rbp + 180的位置。这里注意,我们一般认为栈是从高地址向低地址增长,但是在windows下并非一定如此,这个是很多人都忽视的重要一点(与之相对,可见Linux指向的是rbp - 0x90)。

  回归正题,这里rep movs实际是将rax存储的结构体b的值传出到指定地址rbp + 180h开始的一块区域临时存储,然后return_test()将该临时区域的首地址赋值给rax并返回main()函数,再从rax读取到临时存储区域,赋值给n。所以在这里big_thing其实总共存储了三份bn以及临时存储区域(rbp + 180h)。换句话说,b并未直接返回赋值给n,而是需要多存储一份在临时区域作为中转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Win10
big_thing return_test()
{
00007FF631E65EB0 mov qword ptr [rsp+8],rcx
00007FF631E65EB5 push rbp
00007FF631E65EB6 push rsi
00007FF631E65EB7 push rdi
00007FF631E65EB8 sub rsp,180h
00007FF631E65EBF lea rbp,[rsp+20h]
00007FF631E65EC4 mov rdi,rsp
00007FF631E65EC7 mov ecx,60h
00007FF631E65ECC mov eax,0CCCCCCCCh
00007FF631E65ED1 rep stos dword ptr [rdi]
00007FF631E65ED3 mov rcx,qword ptr [rsp+1A8h]
big_thing b;
b.buf[0] = 0;
00007FF631E65EDB mov eax,1
00007FF631E65EE0 imul rax,rax,0
00007FF631E65EE4 mov byte ptr b[rax],0
return b;
00007FF631E65EE9 lea rax,[b]
00007FF631E65EED mov rdi,qword ptr [rbp+180h]
00007FF631E65EF4 mov rsi,rax
00007FF631E65EF7 mov ecx,80h
00007FF631E65EFC rep movs byte ptr [rdi],byte ptr [rsi]
00007FF631E65EFE mov rax,qword ptr [rbp+180h]
}

int main()
{
00007FF631E61D10 push rbp
00007FF631E61D12 push rsi
00007FF631E61D13 push rdi
00007FF631E61D14 sub rsp,2C0h
00007FF631E61D1B lea rbp,[rsp+20h]
00007FF631E61D20 mov rdi,rsp
00007FF631E61D23 mov ecx,0B0h
00007FF631E61D28 mov eax,0CCCCCCCCh
00007FF631E61D2D rep stos dword ptr [rdi]
big_thing n = return_test();
00007FF631E61D2F lea rcx,[rbp+208h]
00007FF631E61D36 call return_test (07FF631E61442h)
00007FF631E61D3B lea rcx,[rbp+170h]
00007FF631E61D42 mov rdi,rcx
00007FF631E61D45 mov rsi,rax
00007FF631E61D48 mov ecx,80h
00007FF631E61D4D rep movs byte ptr [rdi],byte ptr [rsi]
00007FF631E61D4F lea rax,[n]
00007FF631E61D53 lea rcx,[rbp+170h]
00007FF631E61D5A mov rdi,rax
00007FF631E61D5D mov rsi,rcx
00007FF631E61D60 mov ecx,80h
00007FF631E61D65 rep movs byte ptr [rdi],byte ptr [rsi]
return 1;
00007FF631E61D67 mov eax,1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// Ubuntu 16.04
0000000000400546 <return_test>:
400546: 55 push %rbp
400547: 48 89 e5 mov %rsp,%rbp
40054a: 48 81 ec a0 00 00 00 sub $0xa0,%rsp
400551: 48 89 bd 68 ff ff ff mov %rdi,-0x98(%rbp)
400558: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
40055f: 00 00
400561: 48 89 45 f8 mov %rax,-0x8(%rbp)
400565: 31 c0 xor %eax,%eax
400567: c6 85 70 ff ff ff 00 movb $0x0,-0x90(%rbp)
40056e: 48 8b 85 68 ff ff ff mov -0x98(%rbp),%rax
400575: 48 8b 95 70 ff ff ff mov -0x90(%rbp),%rdx
40057c: 48 89 10 mov %rdx,(%rax)
40057f: 48 8b 95 78 ff ff ff mov -0x88(%rbp),%rdx
400586: 48 89 50 08 mov %rdx,0x8(%rax)
40058a: 48 8b 55 80 mov -0x80(%rbp),%rdx
40058e: 48 89 50 10 mov %rdx,0x10(%rax)
400592: 48 8b 55 88 mov -0x78(%rbp),%rdx
400596: 48 89 50 18 mov %rdx,0x18(%rax)
40059a: 48 8b 55 90 mov -0x70(%rbp),%rdx
40059e: 48 89 50 20 mov %rdx,0x20(%rax)
4005a2: 48 8b 55 98 mov -0x68(%rbp),%rdx
4005a6: 48 89 50 28 mov %rdx,0x28(%rax)
4005aa: 48 8b 55 a0 mov -0x60(%rbp),%rdx
4005ae: 48 89 50 30 mov %rdx,0x30(%rax)
4005b2: 48 8b 55 a8 mov -0x58(%rbp),%rdx
4005b6: 48 89 50 38 mov %rdx,0x38(%rax)
4005ba: 48 8b 55 b0 mov -0x50(%rbp),%rdx
4005be: 48 89 50 40 mov %rdx,0x40(%rax)
4005c2: 48 8b 55 b8 mov -0x48(%rbp),%rdx
4005c6: 48 89 50 48 mov %rdx,0x48(%rax)
4005ca: 48 8b 55 c0 mov -0x40(%rbp),%rdx
4005ce: 48 89 50 50 mov %rdx,0x50(%rax)
4005d2: 48 8b 55 c8 mov -0x38(%rbp),%rdx
4005d6: 48 89 50 58 mov %rdx,0x58(%rax)
4005da: 48 8b 55 d0 mov -0x30(%rbp),%rdx
4005de: 48 89 50 60 mov %rdx,0x60(%rax)
4005e2: 48 8b 55 d8 mov -0x28(%rbp),%rdx
4005e6: 48 89 50 68 mov %rdx,0x68(%rax)
4005ea: 48 8b 55 e0 mov -0x20(%rbp),%rdx
4005ee: 48 89 50 70 mov %rdx,0x70(%rax)
4005f2: 48 8b 55 e8 mov -0x18(%rbp),%rdx
4005f6: 48 89 50 78 mov %rdx,0x78(%rax)
4005fa: 48 8b 85 68 ff ff ff mov -0x98(%rbp),%rax
400601: 48 8b 4d f8 mov -0x8(%rbp),%rcx
400605: 64 48 33 0c 25 28 00 xor %fs:0x28,%rcx
40060c: 00 00
40060e: 74 05 je 400615 <return_test+0xcf>
400610: e8 0b fe ff ff callq 400420 <__stack_chk_fail@plt>
400615: c9 leaveq
400616: c3 retq

0000000000400617 <main>:
400617: 55 push %rbp
400618: 48 89 e5 mov %rsp,%rbp
40061b: 48 81 ec 90 00 00 00 sub $0x90,%rsp
400622: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
400629: 00 00
40062b: 48 89 45 f8 mov %rax,-0x8(%rbp)
40062f: 31 c0 xor %eax,%eax
400631: 48 8d 85 70 ff ff ff lea -0x90(%rbp),%rax
400638: 48 89 c7 mov %rax,%rdi
40063b: b8 00 00 00 00 mov $0x0,%eax
400640: e8 01 ff ff ff callq 400546 <return_test>
400645: b8 00 00 00 00 mov $0x0,%eax
40064a: 48 8b 55 f8 mov -0x8(%rbp),%rdx
40064e: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
400655: 00 00
400657: 74 05 je 40065e <main+0x47>
400659: e8 c2 fd ff ff callq 400420 <__stack_chk_fail@plt>
40065e: c9 leaveq
40065f: c3 retq

三. C++的小例子

  说了这么多,大家应该已经明白了。C/C++的函数返回值会暂时存在一个临时空间,然后再赋给我们指定的变量,从而完成返回值的获取。因此如果是C++的对象,就会占用很大的临时空间,而且需要多一次拷贝构造和析构的过程,代价很大,所以尽量避免。如下代码是一个小例子来验证我们的想法是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>

class Obj
{
public:
Obj()
{
std::cout << "constructor" << std::endl;
}

Obj(const Obj& obj)
{
std::cout << "copy constructor" << std::endl;
}

Obj& operator=(const Obj& rhs)
{
std::cout << "operator = " << std::endl;
return *this;
}

~Obj()
{
std::cout << "destructor" << std::endl;
}
};

Obj returnTest()
{
Obj b;
std::cout << "before return" << std::endl;
return b;
}

int main()
{
Obj obj;
obj = returnTest();
return 0;
}

  如果没有使用临时空间,那么应该会有两次构造和两次析构过程(bobj),而实际上这里的输出如下所示

1
2
3
4
5
6
7
8
constructor	     // obj构造
constructor // b构造
before return
copy constructor // 临时存储区域拷贝构造,复制b
destructor // b析构
operator = // 重载符号
destructor // 临时存储区域析构
destructor // obj析构

  同样打开反汇编看看,会发现的确是调用了拷贝构造函数保存了一份临时对象数据在临时空间里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    obj = returnTest();
00E4282F lea eax,[ebp-0E1h]
00E42835 push eax
00E42836 call returnTest (0E413A7h)
00E4283B add esp,4
00E4283E mov dword ptr [ebp-0F8h],eax
00E42844 mov ecx,dword ptr [ebp-0F8h]
00E4284A mov dword ptr [ebp-0FCh],ecx
00E42850 mov byte ptr [ebp-4],1
00E42854 mov edx,dword ptr [ebp-0FCh]
00E4285A push edx
00E4285B lea ecx,[obj]
00E4285E call Obj::operator= (0E41140h)
00E42863 mov byte ptr [ebp-4],0
00E42867 lea ecx,[ebp-0E1h]
00E4286D call Obj::~Obj (0E41357h)

总结

  分析到这里,大家应该都已经清楚了,当rax无法直接存储返回值的时候,会在栈上分配一块空间存储返回值,用rax返回其地址,然后再赋值给我们指定的对象接收返回值,因此代价较为高昂,建议尽量少用以提高代码性能。另外,希望大家能通过本文学会一点汇编的简单使用和分析方法。汇编不是返祖,更不是装逼,而是为了帮助我们更好地学习操作系统及硬件知识,更好的了解高级语言背后究竟做了什么,以便于写出更高效的代码,成为更好的程序员。

坚持原创,坚持分享,谢谢鼓励和支持