目录
前两篇请通过这里阅读:
深度解读《深度探索C++对象模型》之C++对象的构造过程(一)
深度解读《深度探索C++对象模型》之C++对象的构造过程(二)
全局对象的构造和析构
C++对象对待全局变量和C语言有点不同,C语言会区分有初始化的变量和未初始化的变量,有初始化的放在数据段中,未初始化的变量则存放在BSS段中,C++则不会区分,统一放在数据段中。当你定义了一个全局的对象,编译器在编译期间就在数据段中分配一段空间并且内容清0,如它有构造函数的话,则需要等到程序启动之时才会调用它的构造函数。如下一个最简单的例子:
class Object {
int x;
int y;
};
Object global_a;
int main() {
Object local_a;
return 0;
}
对应的汇编代码:
main: # @main
push rbp
mov rbp, rsp
mov dword ptr [rbp - 4], 0
xor eax, eax
pop rbp
ret
global_a:
.zero 8
编译器在数据区中给它分配了8字节的空间(地址即标签global_a),因为类中没有定义构造函数,所以并没有调用构造函数。接着扩充例子,为它增加一个类且加上构造函数,如下代码:
class Base {
public:
Base(): a(0) {}
~Base() {}
private:
int a;
};
class Object {
public:
Object(): x(0), y(0) {}
~Object() {};
int x;
int y;
};
Base global_b1;
Base global_b2;
Object global_a;
Object global_b;
int main() {
Object local_a = global_a;
return 0;
}
C++规定必须保证在main函数调用时全局对象要被构造完成,在main函数退出时全局对象要被析构掉。全局对象的内存空间在编译期间就已预先分配好,但构造函数必须要等到程序运行时才会被调用,那么如何保证这些构造函数能在main函数之前被调用,在main函数退出时调用这些析构函数?编译器的做法是为每一个全局对象生成一个函数,这个函数的作用就是用来调用这个类的构造函数,每一个对象都会对应有一个函数,如上面的代码中会生成这几个函数:
__cxx_global_var_init
__cxx_global_var_init.1
__cxx_global_var_init.2
__cxx_global_var_init.3
函数的名称前面的字母相同,后面加上数字编码,每增加一个全局对象,就增加一个这样的函数,函数名称后面的数字依次递增。这个函数的命名规则并不是统一的标准,不同的编译器规则可能不一样,这里不必深究。最后,编译器还会再生成一个函数,把上面的这些函数都收集起来,并且按照它们声明的顺序依次调用它们:
_GLOBAL__sub_I_example.cpp: # @_GLOBAL__sub_I_example.cpp
push rbp
mov rbp, rsp
call __cxx_global_var_init
call __cxx_global_var_init.1
call __cxx_global_var_init.2
call __cxx_global_var_init.3
pop rbp
ret
这个函数的命名也有点奇怪,不过也不必太关心它,只是编译器自己命名的名字而已。在这个函数里依次调用了上面的几个初始化函数,而这个函数将会在程序运行起来后在调用main函数之前被调用,这样就保证了在main函数调用时全局对象都已经构造完成了。
这几个函数的内容都大体相同,随便拿一个来分析下:
__cxx_global_var_init: # @__cxx_global_var_init
push rbp
mov rbp, rsp
lea rdi, [rip + global_b1]
call Base::Base() [base object constructor]
lea rdi, [rip + Base::~Base() [base object destructor]]
lea rsi, [rip + global_b1]
lea rdx, [rip + __dso_handle]
call __cxa_atexit@PLT
pop rbp
ret
第4行是加载全局对象的内存地址到rdi寄存器,这里是每个函数里有区别的地方之一,不同对象替换成对应的全局对象的地址,然后第5行调用对应的构造函数,这里也是根据全局对象的类型调用相应的构造函数。
在main函数退出时必须要将这些全局对象析构掉,这个是如何做到的?仔细分析上面对比代码的第6到第9行,这几行的意思就是取得析构函数的地址和对象的地址以及__dso_handle函数的地址作为参数,注册到__cxa_atexit函数,__cxa_atexit是一个系统库的函数,它的作用就是在程序退出的时候会被调用以清理一些资源,所以上面的意思就是将需要析构的对象的析构函数和它的地址登记起来,在程序退出时就会调用这些析构函数来释放这些对象,如果没有定义析构函数或者编译器自动生成的析构函数,则不会有这些代码,表示不需要调用析构函数来释放它。
局部静态对象的构造和析构
C++标准对于局部静态对象的构造和析构有以下的要求:
- 局部静态对象只能被构造一次,即使多次调用也只会构造一次,而且只能在用到它时才开始构造它,如果没有用到则不需要构造。
- 局部静态对象在程序结束时必须要被析构,如果它已经构造过的话,而且只能析构一次。
- 在构造对象时要保证多线程安全。
编译器要如何来保证满足上面的要求?先来看一个测试例子:
#include <cstdio>
class Base {
public:
Base(): b(0) { printf("%s: this = %p\n", __PRETTY_FUNCTION__, this); }
~Base() { printf("%s: this = %p\n", __PRETTY_FUNCTION__, this); }
private:
int b;
};
class Object {
public:
Object(): x(0), y(0) { printf("%s: this = %p\n", __PRETTY_FUNCTION__, this); }
~Object() { printf("%s: this = %p\n", __PRETTY_FUNCTION__, this); };
int x;
int y;
};
const Object& foo() {
static Object static_a;
return static_a;
}
const Base& bar() {
static Base static_b;
return static_b;
}
Object global_a;
int main() {
Object local_a = foo();
printf("&local_a = %p\n", &local_a);
global_a = foo();
return 0;
}
代码输出结果:
Object::Object(): this = 0x102624020
Object::Object(): this = 0x102624000
&local_a = 0x16d7e33f0
Object::~Object(): this = 0x16d7e33f0
Object::~Object(): this = 0x102624000
Object::~Object(): this = 0x102624020
从结果可以看出,bar函数由于没有被调用,Base类型的局部静态对象static_b就没有被构造出来,foo函数被多次调用,局部静态变量static_a也没有被多次构造,最后静态对象也被正确的析构了。从输出的地址看到,局部静态对象是和全局对象一样存放在数据段中的。那么编译器是如何实现这些功能的?我们来分析编译器生成的汇编代码,重点主要看foo函数对应的代码:
foo(): # @foo()
# 略...
cmp byte ptr [rip + guard variable for foo()::static_a], 0
jne .LBB0_4
lea rdi, [rip + guard variable for foo()::static_a]
call __cxa_guard_acquire@PLT
cmp eax, 0
je .LBB0_4
lea rdi, [rip + foo()::static_a]
call Object::Object() [base object constructor]
jmp .LBB0_3
.LBB0_3:
lea rdi, [rip + Object::~Object() [base object destructor]]
lea rsi, [rip + foo()::static_a]
lea rdx, [rip + __dso_handle]
call __cxa_atexit@PLT
lea rdi, [rip + guard variable for foo()::static_a]
call __cxa_guard_release@PLT
.LBB0_4:
lea rax, [rip + foo()::static_a]
# 略...
局部静态对象的构造函数没有像全局对象那样放在统一初始化的代码里,这样就避免了没有用到的局部静态对象不会被初始化,而是在代码执行到这里的时候才会开始构造它,如foo函数被调用了,执行到foo函数的代码里才会调用Object类的构造函数去构造static_a,而bar函数没有被调用则不会构造static_b对象。
编译器为了实现当反复调用foo函数也只会构造一次,编译器增加了一个全局的变量,如上面汇编代码中的“guard variable for foo()::static_a”,它的作用就是为了指示static_a对象是否已经构造过了,为了避免名称冲突,它的名字经过编码规则和对象命称绑定,如果为0,表示对象还没有被构造过,接着就构造它,如果不为0,则表示已经构造过了,就跳转到标签.LBB0_4处,把对象的地址返回然后就退出函数了。
为了解决多线程的访问竞争问题,比如当有多个不同的线程同时调用foo函数,需要保证不会出现多次构造出static_a对象的问题,代码中使用了互斥锁,见上面汇编代码的第5到18行,在持有锁的状态下,完成了对象的构造以及会将“guard variable for foo()::static_a”变量增加1。
标签.LBB0_3到.LBB0_4之间的代码是向系统库函数__cxa_atexit注册对象的析构函数,这个代码和全局对象的做法一样。这个析构的代码是和构造的代码在一起的,因此也就保证了只有在对象被构造了的情况下才会去调用析构函数,如果对象没被构造,析构函数没有注册也就不会被调用了,同时它也是在互斥锁的保护下的,也保证了只会被注册一次,不会出现多次析构的问题。