copy elision 语义的汇编理解
实验环境:os: centos8.5 / kernel: 4.18.0 / gcc: 8.5.0 / arch: x86-64
1. 函数参数的传递和参数的返回
我们知道,在 x86-64 linux 系统机器上,参数不超过 6 个时,优先使用 rdi, rsi, rdx, rcx, r8, r9 这几个 cpu 寄存器进行传递,多于 6 个,剩下的参数压入 caller 的栈帧中,函数返回时,只能通过 rax 这一个 cpu 寄存器传递返回值,这是 ABI 规则的一部分。
函数参数和返回值可以是立即数,也可以是一个内存地址
但是存在一个疑问,一个寄存器的大小是固定的,不能存储多于 8 字节的内容,那么如果传递一个栈中分配的结构体/类对象或者返回一个栈中分配的结构体/类对象,计算机是如何实现的呢?本篇文章即探究这个问题
2. 参数传递
首先有如下代码:
#include <stdio.h>
class cls {
public:
cls() {
printf("default construct\n");
}
cls(const cls& obj) {
a = obj.a;
b = obj.b;
printf("copy construct\n");
}
int a;
int b;
};
void func(cls mm) {
printf("%d %d\n", mm.a, mm.b);
}
int main() {
cls nn;
nn.a = 333;
nn.b = 777;
func(nn);
return 0;
}
通过 g++ pass.cpp -o pass
生成可执行程序,运行:
结果分析:
- main() 函数中
cls nn;
语句会在 main 的栈中分配 8 字节的空间,然后调用 cls 类的构造函数 - 调用 func() 函数时,生成栈对象
cls mm
会调用 cls 类的拷贝构造函数,拷贝cls nn
的内容 - 在 func() 中打印 mm 对象成员的值
通过汇编来看下,执行 objdump -d pass
,查看 main 和 func 的代码段:
00000000004005f6 <_Z4func3cls>:
4005f6: 55 push %rbp
4005f7: 48 89 e5 mov %rsp,%rbp
4005fa: 48 83 ec 10 sub $0x10,%rsp
4005fe: 48 89 7d f8 mov %rdi,-0x8(%rbp) // rdi 存储了 mm 对象的起始地址, 关于此句,见下面的分析
400602: 48 8b 45 f8 mov -0x8(%rbp),%rax
400606: 8b 50 04 mov 0x4(%rax),%edx
400609: 48 8b 45 f8 mov -0x8(%rbp),%rax
40060d: 8b 00 mov (%rax),%eax
40060f: 89 c6 mov %eax,%esi
400611: bf 79 07 40 00 mov $0x400779,%edi
400616: b8 00 00 00 00 mov $0x0,%eax
40061b: e8 d0 fe ff ff callq 4004f0 <printf@plt>
400620: 90 nop
400621: c9 leaveq
400622: c3 retq
0000000000400623 <main>:
400623: 55 push %rbp
400624: 48 89 e5 mov %rsp,%rbp
400627: 48 83 ec 10 sub $0x10,%rsp // 分配栈空间(注意,似乎分配大小需要满足某种地址对齐方式)
40062b: 48 8d 45 f0 lea -0x10(%rbp),%rax // nn 起始地址给 rax
40062f: 48 89 c7 mov %rax,%rdi // nn 起始地址继续给 rdi 传参寄存器, 作为 cls 构造函数的 this 指针
400632: e8 35 00 00 00 callq 40066c <_ZN3clsC1Ev> // 为 nn 调用 cls 的构造函数
400637: c7 45 f0 4d 01 00 00 movl $0x14d,-0x10(%rbp) // 赋值 333 给 nn.a, -0x10(%rbp) 即为 nn 对象的起始地址
40063e: c7 45 f4 09 03 00 00 movl $0x309,-0xc(%rbp) // 赋值 777 给 nn.b
400645: 48 8d 55 f0 lea -0x10(%rbp),%rdx // nn 起始地址给 rdx
400649: 48 8d 45 f8 lea -0x8(%rbp),%rax // mm 起始地址给 rax
40064d: 48 89 d6 mov %rdx,%rsi // cls 拷贝构造函数参数2: nn 的起始地址
400650: 48 89 c7 mov %rax,%rdi // cls 拷贝构造函数参数1: mm 的起始地址, 即 this 指针
400653: e8 2e 00 00 00 callq 400686 <_ZN3clsC1ERKS_> // 为 mm 调用 cls 的拷贝构造函数
400658: 48 8d 45 f8 lea -0x8(%rbp),%rax // mm 起始地址给 rax, 此时 mm 已经构造完成
40065c: 48 89 c7 mov %rax,%rdi // mm 起始地址继续给 rdi 传参寄存器
40065f: e8 92 ff ff ff callq 4005f6 <_Z4func3cls> // 调用 func()
400664: b8 00 00 00 00 mov $0x0,%eax
400669: c9 leaveq
40066a: c3 retq
40066b: 90 nop
可以看到,cls nn 对象和 cls mm 对象都在 main() 函数中生成,即两个对象占用的空间都在 main() 函数的栈帧中
调用 func() 函数的时候,传递的参数 cls mm 因为已经提前生成,所以函数调用时通过 rdi 寄存器将 cls mm 的地址传递过去
但是注意,在 func() 函数中,mov %rdi,-0x8(%rbp)
这句话非常可疑,好像是 func() 又将 main() 传递的 cls mm 保存到自己的栈空间中,实际上不是这样。这里只是保存了 rdi 到 func() 的栈中, 接着又会从栈中取出来,后面取 a, b 参数始终操作的都是 main() 函数生成的 cls mm 的地址。如果在编译时,使用 O1
来编译,将更加清晰
3. 参数返回
首先有如下代码:
#include <stdio.h>
class cls {
public:
cls() {
printf("default construct\n");
}
cls(const cls& obj) {
a = obj.a;
b = obj.b;
printf("copy construct\n");
}
int a;
int b;
};
cls func() {
cls mm;
mm.a = 333;
mm.b = 777;
return mm;
}
int main() {
cls nn = func();
printf("%d %d\n", nn.a, nn.b);
return 0;
}
我们知道,c++ 是有返回值优化这个特性的,即函数返回栈对象时,可以将返回对象提前申请空间但不构造,再通过参数传递进来,然后在函数中构造,所以下面的讨论需要区分是否开启返回值优化特性
3.1 关闭返回值优化
通过 g++ return.cpp -o return -fno-elide-constructors
生成可执行程序,运行:
结果分析:
- func() 函数中
cls mm;
语句会在 func() 的栈中分配 8 字节的空间,然后调用 cls 类的构造函数 - 返回 mm 时,先产生一个零时 cls 对象,临时对象也在栈中分配空间,然后调用 cls 类的拷贝构造函数,以 cls mm 为拷贝对象
- 在 main 中,为 cls nn 分配栈空间,然后调用 cls 类的拷贝构造函数,以 func() 函数返回的临时对象为拷贝对象
- 在 main() 中打印 nn 对象成员的值
通过汇编来看下,执行 objdump -d return
,查看 main 和 func 的代码段:
00000000004005f6 <_Z4funcv>:
4005f6: 55 push %rbp
4005f7: 48 89 e5 mov %rsp,%rbp
4005fa: 48 83 ec 20 sub $0x20,%rsp // 分配栈空间(注意,似乎分配大小需要满足某种地址对齐方式)
4005fe: 48 89 7d e8 mov %rdi,-0x18(%rbp) // rdi 中存储了零时对象的地址
400602: 48 8d 45 f8 lea -0x8(%rbp),%rax // cls mm 对象的起始地址给 rax
400606: 48 89 c7 mov %rax,%rdi // 继续将 cls mm 对象的起始地址给 rdi,作为 cls 类构造函数的参数,即 this 指针
400609: e8 6c 00 00 00 callq 40067a <_ZN3clsC1Ev> // 调用 cls 对象的构造函数
40060e: c7 45 f8 4d 01 00 00 movl $0x14d,-0x8(%rbp) // 立即数 333 赋值给 mm.a
400615: c7 45 fc 09 03 00 00 movl $0x309,-0x4(%rbp) // 立即数 777 赋值给 mm.b
40061c: 48 8d 55 f8 lea -0x8(%rbp),%rdx // cls mm 对象的起始地址给 rdx
400620: 48 8b 45 e8 mov -0x18(%rbp),%rax // 临时对象的起始地址给 rax
400624: 48 89 d6 mov %rdx,%rsi // cls 类拷贝构造函数参数2: cls mm 对象的起始地址
400627: 48 89 c7 mov %rax,%rdi // cls 类拷贝构造函数参数1: 临时对象起始地址,即 this 指针
40062a: e8 65 00 00 00 callq 400694 <_ZN3clsC1ERKS_> // 调用 cls 类拷贝构造函数,构造临时对象
40062f: 48 8b 45 e8 mov -0x18(%rbp),%rax
400633: c9 leaveq
400634: c3 retq
0000000000400635 <main>:
400635: 55 push %rbp
400636: 48 89 e5 mov %rsp,%rbp
400639: 48 83 ec 10 sub $0x10,%rsp // 分配栈空间(注意,似乎分配大小需要满足某种地址对齐方式)
40063d: 48 8d 45 f8 lea -0x8(%rbp),%rax // 将临时对象的起始地址给 rax
400641: 48 89 c7 mov %rax,%rdi // 继续将临时对象的起始地址给传参寄存器 rdi
400644: e8 ad ff ff ff callq 4005f6 <_Z4funcv> // 调用 func
400649: 48 8d 55 f8 lea -0x8(%rbp),%rdx // 临时对象已经构造,将临时对象的起始地址给 rdx
40064d: 48 8d 45 f0 lea -0x10(%rbp),%rax // cls nn 对象起始地址给 rax, 注意,cls nn 起始地址与临时对象起始地址刚好差 8 个字节
400651: 48 89 d6 mov %rdx,%rsi // cls 类拷贝构造函数参数2: 临时对象的起始地址
400654: 48 89 c7 mov %rax,%rdi // cls 类拷贝构造函数参数1: cls nn 对象起始地址,即 this 指针
400657: e8 38 00 00 00 callq 400694 <_ZN3clsC1ERKS_> // 调用 cls 类拷贝构造函数,构造 cls nn 对象
40065c: 8b 55 f4 mov -0xc(%rbp),%edx
40065f: 8b 45 f0 mov -0x10(%rbp),%eax
400662: 89 c6 mov %eax,%esi
400664: bf 89 07 40 00 mov $0x400789,%edi
400669: b8 00 00 00 00 mov $0x0,%eax
40066e: e8 7d fe ff ff callq 4004f0 <printf@plt> // 调用 printf
400673: b8 00 00 00 00 mov $0x0,%eax
400678: c9 leaveq
400679: c3 retq
可以看到,返回 cls mm 时,需要一个临时对象,再由临时对象构造 cls nn 对象
临时对象和 cls nn 对象都在 main() 函数中生成,即两个对象占用的空间都在 main() 函数的栈帧中,临时对象的构造在 func() 函数中,调用 func() 时,将临时对象的起始地址通过参数的方式传递给 func() 函数
3.2 开启返回值优化
通过 g++ return.cpp -o return
生成可执行程序,运行:
结果分析:
- 与关闭返回值优化的输出结果有很大不同,一次拷贝构造都没有调用过
- 在 main 中,为 cls nn 分配栈空间。调用 func() 时,构造 cls nn
- 在 main() 中打印 nn 对象成员的值
通过汇编来看下,执行 objdump -d return
,查看 main 和 func 的代码段:
00000000004005f6 <_Z4funcv>:
4005f6: 55 push %rbp
4005f7: 48 89 e5 mov %rsp,%rbp
4005fa: 48 83 ec 10 sub $0x10,%rsp
4005fe: 48 89 7d f8 mov %rdi,-0x8(%rbp) // rdi 中存储了 cls nn 对象的起始地址
400602: 48 8b 45 f8 mov -0x8(%rbp),%rax // cls nn 对象的起始地址给 rax
400606: 48 89 c7 mov %rax,%rdi // 继续将 cls nn 对象的起始地址给 rdi,作为 cls 类构造函数的参数,即 this 指针
400609: e8 4e 00 00 00 callq 40065c <_ZN3clsC1Ev> // 调用 cls 对象的构造函数
40060e: 48 8b 45 f8 mov -0x8(%rbp),%rax // cls nn 对象的起始地址给 rax
400612: c7 00 4d 01 00 00 movl $0x14d,(%rax) // 立即数 333 给 nn.a
400618: 48 8b 45 f8 mov -0x8(%rbp),%rax // cls nn 对象的起始地址给 rax
40061c: c7 40 04 09 03 00 00 movl $0x309,0x4(%rax) // 立即数 777 给 nn.b
400623: 90 nop
400624: 48 8b 45 f8 mov -0x8(%rbp),%rax
400628: c9 leaveq
400629: c3 retq
000000000040062a <main>:
40062a: 55 push %rbp
40062b: 48 89 e5 mov %rsp,%rbp
40062e: 48 83 ec 10 sub $0x10,%rsp // 分配栈空间(注意,似乎分配大小需要满足某种地址对齐方式)
400632: 48 8d 45 f8 lea -0x8(%rbp),%rax // cls nn 起始地址给 rax
400636: 48 89 c7 mov %rax,%rdi // 继续将 cls nn 起始地址给 rdi 作为 参数传递给 func
400639: e8 b8 ff ff ff callq 4005f6 <_Z4funcv> // 调用 func
40063e: 8b 55 fc mov -0x4(%rbp),%edx
400641: 8b 45 f8 mov -0x8(%rbp),%eax
400644: 89 c6 mov %eax,%esi
400646: bf 2a 07 40 00 mov $0x40072a,%edi
40064b: b8 00 00 00 00 mov $0x0,%eax
400650: e8 9b fe ff ff callq 4004f0 <printf@plt> // 调用 printf
400655: b8 00 00 00 00 mov $0x0,%eax
40065a: c9 leaveq
40065b: c3 retq
可以看到,当关闭返回值优化后,得到最终的 cls nn 对象只需要申请一次空间,进行一次构造即可
在 main() 中申请内存,在 func() 中构造对象,前面也多次出现这种方式