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() 中构造对象,前面也多次出现这种方式

posted @ 2022-01-14 17:57  小夕nike  阅读(28)  评论(0编辑  收藏  举报