通过汇编来看结构体对象如何在函数中传递


实验环境: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 
  
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 
  400620:	90                   	nop
  400621:	c9                   	leaveq 
  400622:	c3                   	retq   

0000000000400623 
: 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 
  
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 
: 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 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 
: 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 400655: b8 00 00 00 00 mov $0x0,%eax 40065a: c9 leaveq 40065b: c3 retq

可以看到,当关闭返回值优化后,得到最终的 cls nn 对象只需要申请一次空间,进行一次构造即可
在 main() 中申请内存,在 func() 中构造对象,前面也多次出现这种方式

C++