我们上一篇开头列举了好几个例子,其中都没有调用拷贝构造函数,这一篇我们还是以那几个例子,并且这次我们定义了拷贝构造函数,来分析c++编译器是怎么理解我们写的代码,也理解编译器是怎么转化我们的代码的。
5.1 明确的初始化操作
侯捷老师翻译过来的,就抄过来了,按我们大白话就是直接赋值,下面还是上代码吧。
5.1.1 例子
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "构造函数" << endl;
}
A(const A& a)
{
cout << "拷贝构造函数" << endl;
}
};
int main(int argc, char **argv)
{
A a0; // 这个会调用一个构造函数
A a1(a0); // 定义a1,会调用拷贝构造函数
A a2 = a0; // 定义a2,会调用拷贝构造函数
A a3 = A(a0); // 定义a3,会调用拷贝构造函数
return 0;
}
编译运行:
root@ubuntu:~/c++_mode/05
构造函数
拷贝构造函数
拷贝构造函数
拷贝构造函数
root@ubuntu:~/c++_mode/05
这次调用的现象就是老师说的了,3种初始化方式,都调用拷贝构造函数。但是我们这一篇不会停留在这么简单的层次,所以我们反汇编查看一下,编译器是怎么理解我们写的代码的。
5.1.2 反汇编代码
我们就直接看反汇编代码:
main:
.LFB1027:
.cfi_startproc # .cfi_startproc被用在每个函数的开头,这些函数应该在.eh_frame中有一个条目
pushq %rbp # 保存父函数的栈帧
.cfi_def_cfa_offset 16 # .cfi_def_cfa_offset修改一个计算CFA的规则。寄存器保持不变,但偏移量是新的。注意,绝对偏移量将被添加到一个已定义的寄存器中来计算CFA地址。
.cfi_offset 6, -16 # 寄存器先前的值保存在从CFA的offset offset处。
movq %rsp, %rbp # 把父函数的栈顶指针赋值给当前函数栈底指针
.cfi_def_cfa_register 6
subq $32, %rsp # 预留0x20字节的空间
movl %edi, -20(%rbp) # edi进栈
movq %rsi, -32(%rbp) # rsi进栈
movq %fs:40, %rax
movq %rax, -8(%rbp)
xorl %eax, %eax
leaq -12(%rbp), %rax # 这个是取地址,a0的地址
movq %rax, %rdi
call _ZN1AC1Ev # 调用类A的构造函数
leaq -12(%rbp), %rdx
leaq -11(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call _ZN1AC1ERKS_
leaq -12(%rbp), %rdx
leaq -10(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call _ZN1AC1ERKS_
leaq -12(%rbp), %rdx
leaq -9(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call _ZN1AC1ERKS_
movl $0, %eax
movq -8(%rbp), %rcx
xorq %fs:40, %rcx
je .L5
call __stack_chk_fail
刚刚学习了一波汇编,发现编译完成之后,栈的大小确实是固定下来了,真的很神奇啊,之前一直以为是动态进栈之类,不过也确实是动态进栈,但是栈的大小是已经申请好了。
我们就提取拷贝构造函数这部分来分析:
leaq -12(%rbp), %rdx # a0的地址
leaq -11(%rbp), %rax # a1的地址
movq %rdx, %rsi # 把a0的地址做为函数参数1
movq %rax, %rdi # 把a1的地址做为函数参数2
call _ZN1AC1ERKS_
汇编函数参数传递是使用了这6个寄存器:位置也是对应的:
%rdi,%rsi,%rdx,%rcx,%r8,%r9。
所以上面我们看的就是在准备_ZN1AC1ERKS_的参数。
那接下来就有人问了,怎么知道哪几句汇编代码是取a0,a1的地址,下面就来介绍一下怎么看。
5.1.3 gdb反汇编的使用
很简单,我是使用gdb来分析的。
这里隆重的介绍一位很牛逼的命令:disassemble。
反汇编命令,=》指向的位置就是当前执行的命令语句:
(gdb) disassemble
Dump of assembler code for function main(int, char**):
0x00000000004008b6 <+0>: push %rbp
0x00000000004008b7 <+1>: mov %rsp,%rbp
0x00000000004008ba <+4>: sub $0x20,%rsp
0x00000000004008be <+8>: mov %edi,-0x14(%rbp)
0x00000000004008c1 <+11>: mov %rsi,-0x20(%rbp)
=> 0x00000000004008c5 <+15>: mov %fs:0x28,%rax
0x00000000004008ce <+24>: mov %rax,-0x8(%rbp)
0x00000000004008d2 <+28>: xor %eax,%eax
0x00000000004008d4 <+30>: lea -0xc(%rbp),%rax
0x00000000004008d8 <+34>: mov %rax,%rdi
0x00000000004008db <+37>: callq 0x400988 <A::A()>
0x00000000004008e0 <+42>: lea -0xc(%rbp),%rdx
0x00000000004008e4 <+46>: lea -0xb(%rbp),%rax
0x00000000004008e8 <+50>: mov %rdx,%rsi
0x00000000004008eb <+53>: mov %rax,%rdi
0x00000000004008ee <+56>: callq 0x4009b4 <A::A(A const&)>
0x00000000004008f3 <+61>: lea -0xc(%rbp),%rdx
0x00000000004008f7 <+65>: lea -0xa(%rbp),%rax
0x00000000004008fb <+69>: mov %rdx,%rsi
0x00000000004008fe <+72>: mov %rax,%rdi
--Type <RET> for more, q to quit, c to continue without paging--q
我们要分析一下调用构造函数之前,寄存器的里的值,这样看到寄存器的值,再分析汇编代码,相互认证就不会错太多。
我们看到的调用构造函数的地址为0x00000000004008d8,gdb也提供了直接断点在固定地址上,命令如下:(需要在地址前面加个*)
(gdb) b *0x00000000004008d8
Breakpoint 3 at 0x4008d8: file 5_1.cpp, line 22.
点已经断到了,接下来就需要看寄存器的值了,怎么看,还是gdb命令:
gdb) info r
rax 0x7fffffffe524 140737488348452
rbx 0x0 0
rcx 0xc0 192
rdx 0x7fffffffe628 140737488348712
rsi 0x7fffffffe618 140737488348696
rdi 0x1 1
rbp 0x7fffffffe530 0x7fffffffe530
rsp 0x7fffffffe510 0x7fffffffe510
r8 0x7ffff7dd4ac0 140737351862976
r9 0x7ffff7dc9780 140737351817088
r10 0x32f 815
r11 0x7ffff76c5290 140737344459408
r12 0x4007c0 4196288
r13 0x7fffffffe610 140737488348688
r14 0x0 0
r15 0x0 0
rip 0x4008d8 0x4008d8 <main(int, char**)+34>
eflags 0x246 [ PF ZF IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
--Type <RET> for more, q to quit, c to continue without paging--
es 0x0 0
fs 0x0 0
gs 0x0 0
这个是常见寄存器的值查看,只要是看rax的值,因为调用构造函数之前是rax赋值给rdi,rax的值是0x7fffffffe524。这个一看就有点懵逼,有点想栈的地址,所以就用gdb打印一下a0的值,已打印就吓了一跳:
(gdb) p &a0
$6 = (A *) 0x7fffffffe524
(gdb) p &a1
$7 = (A *) 0x7fffffffe525
(gdb) p &a2
$8 = (A *) 0x7fffffffe526
就刚好就是a0的地址,因为我们这是一个空类,所以类对象大小在栈中占了一个字节,这也响应了我们第一篇文章写的。
5.1.4 构造函数反汇编代码查看
_ZN1AC2ERKS_:
.LFB1025:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movq %rdi, -8(%rbp) # 保存rdi
movq %rsi, -16(%rbp) # 保存rsi的值
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
我们这个拷贝构造函数是空的,所以并没有做什么操作,就这样就返回了。
5.1.5 总结
总结一波,直接我们直接赋值的操作是编译器提供给我们的语法糖,事实上编译器最后还是需要转换成这样子:
A a1;
_ZN1AC1ERKS_(&a0, &a1);
这个风格是c风格,c会把类的函数全部通过名字修饰,成这种独一无二的格式。
如果是c++的方式,可以是这样写:
// c++的方式
A a1;
a1.A::A(a0);
好像编译还是不通过,由a1的对象调用拷贝构造函数来初始化。当然最后还是会转化成c语言那种方式。
5.2 参数的初始化
第二种是参数的初始化,说是参数的初始化,还不如说对象作为函数的参数,我们来看看。
5.2.1 例子
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "构造函数" << endl;
}
A(const A& a)
{
cout << "拷贝构造函数" << endl;
}
~A()
{
cout << "析构函数" << endl;
}
};
int foo(A a)
{
return 0;
}
int main(int argc, char **argv)
{
A a0; // 这个会调用一个构造函数
foo(a0);
return 0;
}
编译运行:
root@ubuntu:~/c++_mode/05
构造函数
拷贝构造函数
析构函数
析构函数
root@ubuntu:~/c++_mode/05
这个也符号我们的想法,对象做为函数参数的时候,会调用一次拷贝构造函数,函数退出之后,这个变量的作用域已经失效了,就会调用析构函数。
main:
.LFB1031:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
pushq %rbx
subq $40, %rsp
.cfi_offset 3, -24
movl %edi, -36(%rbp)
movq %rsi, -48(%rbp)
movq %fs:40, %rax
movq %rax, -24(%rbp)
xorl %eax, %eax
leaq -26(%rbp), %rax
movq %rax, %rdi
call _ZN1AC1Ev
leaq -26(%rbp), %rdx
leaq -25(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call _ZN1AC1ERKS_
leaq -25(%rbp), %rax
movq %rax, %rdi
call _Z3foo1A
leaq -25(%rbp), %rax
movq %rax, %rdi
call _ZN1AD1Ev
movl $0, %ebx
leaq -26(%rbp), %rax
movq %rax, %rdi
call _ZN1AD1Ev
movl %ebx, %eax
movq -24(%rbp), %rcx
xorq %fs:40, %rcx
je .L8
call __stack_chk_fail
看着这个反汇编代码就很熟悉,在5.1中已经详细分析了方法,这里就不详细分析了。
5.2.2 分析
通过上面的反汇编代码,明显看到了先申请了一个临时对象,然后调用拷贝构造函数,最后把这个临时对象的引用传给foo函数,等到函数退出之后,然后再析构这个临时对象。
转化成c++的方式:
A temp; // 编译器产生一个临时对象
temp.A::A(a0); //编译器对拷贝构造函数调用
foo(&temp); // foo函数调用
temp.A::~A(); // 函数返回了,析构临时对象
如果转化成c语言:
A temp;
_ZN1AC1ERKS_(&a0, &temp);
foo(&temp);
_ZN1AD1Ev(&temp);
c调用的方式其实就是把c++中类的方式全部转换成c的函数。
5.2.3 疑问
分析到这里就有一个疑问了,侯捷老师说有另一种实现方法,是以"拷贝构造"的方式把实际参数直接建构在其应该的位置上,该位置视函数活动范围的不同记录于程序堆栈中,在函数返回之前,局部对象的析构函数(如果有定义的话)会被执行。
安排g++分析的,感觉不属于这种,是属于老编译器的方法,所以就比较奇怪,难道是我分析出错了,还是就是这样,新方式又是如何?以后碰到知道了答案在回来分析了。
5.3 返回值的初始化
还有一哥情况,函数返回一个类对象,这时候会调用什么?我们来试试。
5.3.1 例子
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "构造函数" << endl;
}
A(const A& a)
{
cout << "拷贝构造函数" << endl;
}
~A()
{
cout << "析构函数" << endl;
}
int a;
// int fun1(int a) {return a;}
};
A foo(int a)
{
A x;
x.a = a;
return x;
}
int main(int argc, char **argv)
{
// A a0; // 这个会调用一个构造函数
A a1 = foo(0);
// a1.fun1(1);
return 0;
}
编译运行:
root@ubuntu:~/c++_mode/05
root@ubuntu:~/c++_mode/05
构造函数
析构函数
结果发现,只有构造函数,没有拷贝构造函数,真的是尴尬了。这是因为编译器优化了,g++编译器对这种返回对象的会进行优化,这个优化我们下节分析,目前我们就设置参数为不优化,编译运行看看:(不进行优化的参数:-fno-elide-constructors)
root@ubuntu:~/c++_mode/05
root@ubuntu:~/c++_mode/05
构造函数
拷贝构造函数
析构函数
拷贝构造函数
析构函数
析构函数
root@ubuntu:~/c++_mode/05
这一波确实不优化了,但是好像多调用了一个拷贝构造函数,临时变量会再次把值赋值给真正的变量。
5.3.2 main函数反汇编代码查看
虽然结果不太一样,还是要硬着头皮看一下反汇编代码:
main:
.LFB1046:
.cfi_startproc
.cfi_personality 0x3,__gxx_personality_v0
.cfi_lsda 0x3,.LLSDA1046
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
pushq %rbx
subq $56, %rsp
.cfi_offset 3, -24
movl %edi, -52(%rbp)
movq %rsi, -64(%rbp)
movq %fs:40, %rax
movq %rax, -24(%rbp)
xorl %eax, %eax
leaq -32(%rbp), %rax
movl $0, %esi
movq %rax, %rdi
.LEHB4:
call _Z3fooi
.LEHE4:
leaq -32(%rbp), %rdx
leaq -48(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
.LEHB5:
call _ZN1AC1ERKS_
.LEHE5:
leaq -32(%rbp), %rax
movq %rax, %rdi
.LEHB6:
call _ZN1AD1Ev
.LEHE6:
movl $0, %ebx
leaq -48(%rbp), %rax
movq %rax, %rdi
.LEHB7:
call _ZN1AD1Ev
.LEHE7:
movl %ebx, %eax
movq -24(%rbp), %rcx
xorq %fs:40, %rcx
je .L14
jmp .L17
分析了一下,编译器没有优化的代码确实做了很多无用功。
上面的汇编代码比较难看,我这里转化成c++代码:
A temp0; // 会申请一个临时对象
foo(&temp0); // 临时变量会作为参数传入
a1.A::A(temp0); // 通过函数操作一般后,temp是函数操作后的对象,然后用拷贝构造函数赋值给a1
temp0.A::~A(); // 然后把临时对象析构
a1.A::~A(); // 最后才把a1对象析构
确实很复杂。下面我们来看看foo函数内部的代码。
5.3.3 foo函数反汇编代码查看
_Z3fooi:
.LFB1045:
.cfi_startproc
.cfi_personality 0x3,__gxx_personality_v0
.cfi_lsda 0x3,.LLSDA1045
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
pushq %rbx
subq $40, %rsp
.cfi_offset 3, -24
movq %rdi, -40(%rbp)
movl %esi, -44(%rbp)
movq %fs:40, %rax
movq %rax, -24(%rbp)
xorl %eax, %eax
leaq -32(%rbp), %rax
movq %rax, %rdi
.LEHB0:
call _ZN1AC1Ev // 构造一个x对象
.LEHE0:
movl -44(%rbp), %eax
movl %eax, -32(%rbp)
leaq -32(%rbp), %rdx
movq -40(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
.LEHB1:
call _ZN1AC1ERKS_ // temp0.A::A(x) 通过x拷贝构造一个temp0
.LEHE1:
nop
leaq -32(%rbp), %rax
movq %rax, %rdi
.LEHB2:
call _ZN1AD1Ev // 析构x对象
.LEHE2:
nop
movq -40(%rbp), %rax
movq -24(%rbp), %rcx
xorq %fs:40, %rcx
je .L7
jmp .L9
直接看反汇编代码比较难看,我们转换成c++代码:
A x; // 定义一个x对象
x.A::A(); // 调用构造函数
temp0.A::A(x); // 通过x拷贝构造一个temp0
x.A::~A(); // 析构x对象
确实发现这种做法很麻烦,之后还是不要设置这个参数,按照默认的就好,我这里是为了分析。
5.4 其他
重点是前面的3项,不过侯捷老师,在最后还提供了两个函数的调用,我们也来看看吧。
foo(11).fun1(11); // 返回类对象,然后在调用函数
这个可能被转化为:
X temp; // 编译器产生临时对象
(foo(temp, 11), temp).fun1(11);
这个是c语言的逗号表达式,先求左边的值,然后整体的值是右边的,这样去调用函数,反汇编代码已经看不出这个。
同样道理,如果程序声明了一个函数指针,像这样:
A(*pf)(int);
pf = foo;
pf(11).fun1(11);
可以转化成:
A x;
void (*pf)(A&, int);
pf = foo;
pf(x, 11);
x.fun1(11);
感觉就是展开的意思。
5.5 总结
今天这一篇就到这里了,今天学习了汇编知识,也终于分析了汇编代码,汇编能用于分析就差不多了,也不用仔细学习。
|