C++相关问题
C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要有两个方面:C++的重复代码消除 / 全局构造与析构。另外由于C++的各种特性,比如虚拟函数、函数重载、继承、异常等,使得它背后的数据结构异常复杂,这些数据结构往往在不同的编译器和链接器之间相互不能通用,使得C++程序的二进制兼容性成了一个很大的问题。
重复代码消除
C++编译器在很多时候会产生重复的代码,比如模板(Templates)、外部内联函数(Extern Inline Function)和虚函数表(Virtual Function Table)都有可能在不同的编译单元里生成相同的代码。主要问题有以下几方面:
- 空间浪费。程序的大小会膨胀的很厉害。
- 地址较易出错,有可能两个指向同一函数的指针会不相等。
- 指令运行效率较低。因为现代的CPU都会对指令和数据进行缓存,如果同样一份指令有多份副本,那么指令Cache的命中率就会降低。
一个比较有效的做法就是讲每个模板的实例代码都单独地放在一个段里,每个段只包含一个模板实例。这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。这种做法的确被目前主流的编译器所采用。
这种重复代码消除对于模板来说是这样的,对于外部内联函数和虚函数表的做法也类似。
这种方法虽然能够基本上解决代码重复的问题,但还是存在一些问题。比如相同名称的段可能拥有不同的内容,这可能由于不同的编译单元使用了不同的编译器版本或编译优化选项,导致同一个函数编译出来的实际代码有所不同。那么这种情况下链接器可能会做出一个选择,那就是随意选择其中任何一个副本作为链接的输入,然后同时提供给一个警告信息。
函数级别链接
由于现在的程序和库通常来讲都非常庞大,一个目标文件可能包含成千上百个函数或变量。当我们需要用到某个目标文件中的任意一个函数或变量时,就需要把它整个地链接进来,也就是说那些没有用到的函数也被一起链接了进来。这样的后果是链接输出文件会变得很大,所有用到的没用的变量和函数都一起塞到了输出文件中。
VISUAL C++编译器提供了一个编译选项叫函数级别链接(Functional-Level Linking,/Gy),这个选项的作用就是让所有的函数都像前面模板函数一样,单独保存到一个段里面。当链接器需要用到某个函数时,它就将它合并到输出文件中,对于那些没有用的函数则将它们抛弃。这种做法可以很大程度上减小输出文件的长度,减少空间浪费。但是这个优化选项会减慢编译和链接过程,因为链接器需要计算各个函数之间的依赖关系,并且所有函数都保持到独立的段中,目标函数的段的数量大大增加,重定位过程也会因为段的数目的增加而变得复杂,目标文件随着段数目的增加也会变得相对较大。
GCC编译器也提供了类似的机制,它有两个选项作用分别是将每个函数或变量分别保持到独立的段中。
全局构造与析构
我们知道一般的一个C/C++程序是从main开始执行的,随着main函数的结束而结束。然而,其实在main函数被调用之前,为了程序能够顺序执行,要先初始化进程执行环境,比如堆分配初始化(malloc、free)、线程子系统等,C++的全局对象构造函数也是在这一时期被执行的,C++全局对象的析构函数在main之后被执行。
因此ELF文件还定义了两种特殊的段。
- .init 该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段中的代码。
- .fini 该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。
这两个段的存在有着特别的目的,如果一个函数放到.init段,在main函数执行前系统就会执行它。同理,加入一个函数放到.fini段,在main函数返回后该函数就会被执行。利用这两个特性,C++的全局构造和析构函数就由此实现。
C++与ABI
我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)
API(Application Programming Interface)与ABI都是所谓的应用程序接口,只是API往往是指源代码级别的接口,ABI的兼容程度比API要更为严格。也就是说,API相同并不表示ABI相同。
影响ABI的因素非常多,硬件、编程语言、编译器、链接器、操作系统等都会影响ABI。
对于C语言的目标代码来说,以下几个方面会决定目标文件之间是否二进制兼容:
- 内置类型(如int、float、char等)的大小和在存储器中的放置方式(大端、小端、对齐方式等)
- 组合类型(如struct、union、数组等)的存储方式和内存分布
- 外部符号(external-linkage)与用户定义的符号之间的命名方式和解析方式
- 函数调用方式,比如参数入栈顺序、返回值如何保持等
- 堆栈的分布方式,比如参数和局部变量在堆栈里的位置,参数传递方法等
- 寄存器使用约定,函数调用时哪些寄存器可以修改,哪些需要保存,等等。
上面只是一部分因素。
到了C++的时代,语言层面对ABI的影响有增加了很多额外的内容:
- 继承类体系的内存分布,如基类,虚基类在继承类中的位置等
- 指向成员函数的指针(pointer-to-member)的内存分布,如何通过指向成员函数的指针来调用成员函数,如何传递this指针
- 如何调用虚函数,vtable的内容和分布形式,vtable指针在object中的位置等
- template如何实例化
- 外部符号的修饰
- 全局对象的构造和析构
- 异常的产生和捕获机制
- 标准库的徐杰问题,RTTI如何实现等。
- 内嵌函数访问细节
C++一直为人诟病的一大原因是它的二进制兼容不好,或者说比起C语言来更为不易。不仅不同的编译器编译的二进制代码之间无法相互兼容,有时候连同一个编译器的不同版本之间兼容性也不好。
很多时候,库厂商往往不希望库用户看到库的源代码,所以一般是以二进制的方式提供给用户。这样,当用户的编译器型号与版本与变异库所用的不同时,就可能产生不兼容。如果让库厂商提供所有编译器型号和版本编译出来的库给用户,这基本不现实。尤其是那些陈旧的库,库厂商都不维护了,使用起来会是非常头疼的一件事。以上的情况对于系统中已经存在的静态库或动态库需要被多个应用程序使用的情况也几乎想通过,或者一个衡虚由多个公司或多个部门一起开,也有类似的问题、
所以一直期待能有统一的C++二进制兼容标准(C++ ABI),但是目前情况还是不容乐观,基本形成以微软的VISUAL C++和GNU阵营的GCC(采用Intel Itanium C++ ABI标准)为首的两大派系,各抒己见互不兼容。