深入探索c++对象模型(五、程序转化语义)

我们上一篇开头列举了好几个例子,其中都没有调用拷贝构造函数,这一篇我们还是以那几个例子,并且这次我们定义了拷贝构造函数,来分析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# ./5_1
构造函数
拷贝构造函数
拷贝构造函数
拷贝构造函数
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    # 当前pc指向的地址
   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# ./5_2
构造函数
拷贝构造函数
析构函数
析构函数
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);		// foo函数调用
_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# g++ 5_3.cpp -o 5_3
root@ubuntu:~/c++_mode/05# ./5_3
构造函数
析构函数

结果发现,只有构造函数,没有拷贝构造函数,真的是尴尬了。这是因为编译器优化了,g++编译器对这种返回对象的会进行优化,这个优化我们下节分析,目前我们就设置参数为不优化,编译运行看看:(不进行优化的参数:-fno-elide-constructors)

root@ubuntu:~/c++_mode/05# g++ -fno-elide-constructors 5_3.cpp -o 5_3
root@ubuntu:~/c++_mode/05# ./5_3
构造函数
拷贝构造函数
析构函数
拷贝构造函数
析构函数
析构函数
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  // 申请一个temp0临时对象,作为参数,进入foo函数
	movl	$0, %esi
	movq	%rax, %rdi
.LEHB4:
	call	_Z3fooi		// 调用
.LEHE4:
	leaq	-32(%rbp), %rdx		
	leaq	-48(%rbp), %rax
	movq	%rdx, %rsi			// foo函数返回temp0
	movq	%rax, %rdi		// a1
.LEHB5:
	call	_ZN1AC1ERKS_  // a1.A::A(temp0)  通过拷贝构造函数,构造一个a1
.LEHE5:
	leaq	-32(%rbp), %rax
	movq	%rax, %rdi
.LEHB6:
	call	_ZN1AD1Ev	// 析构temp0临时对象
.LEHE6:
	movl	$0, %ebx
	leaq	-48(%rbp), %rax
	movq	%rax, %rdi	
.LEHB7:
	call	_ZN1AD1Ev	// 析构a1对象
.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 总结

今天这一篇就到这里了,今天学习了汇编知识,也终于分析了汇编代码,汇编能用于分析就差不多了,也不用仔细学习。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值