静态链接中的那点事儿(2):C++二进制兼容性及跨平台初步

时间:2021-02-10 21:00:31

C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要的有两个方面,其一,C++的重复代码的消除;其二,全局构造与析构。此外,由于C++的各种特性,比如虚函数、函数重载、继承、异常等,使得C++背后的数据结构异常复杂。而且最为不幸的是,这些数据结构往往在不同的编译器和链接器之间不能相互通用,使得C++程序的二进制兼容性成为一个难题。本篇博客将结合项目经验初步讨论C++程序的二进制兼容性问题。

1.C++中重复代码是如何消除的?

C++编译器在很多时候会产生重复的代码,比如模板(Templates)、外部内联函数(External Inline Function)和虚函数表(Virtual Function Table)都有可能在不同的编译单元里生成相同的代码。最简单的情况就是拿模板来说事,模板从本质上来讲很像宏,当模板在一个编译单元里被实例化时,他并不知道自己是否在别的编译单元也被实例化了。所以当一个模板在多个编译单元同时实例化成相同的类型的时候,必然会生成重复的代码。当然,最简单的方式就是不管这些,就将这些重复的代码都保留下来。但这会存在以下几个问题:

1.空间浪费。可以想象一个由好几百个编译单元的工程同时实例化了许多个模板,最后链接的时候必须将这些重复的代码消除掉,否则最终程序的大小坑定会膨胀的很厉害。

2.地址容易出错。有可能两个指向同一个函数的指针会不想等。

3.指令运行效率低。因为现代的CPU都会对指令和数据进行缓存,如果同样一份指令有多个副本,那么指令Cache的命中率就会降低。

一个比较有效的做法就是将每个模板的示例代码都单独地存放在一个段里,每个段只包含一个模板实例。比如有个模板函数是add<T>(),某个编译单元以int类型【add<int>()】和float类型【add<float>()】实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板实例的段,为了简单起见,我们假设这两个段的名字分别叫.temp.add.<int>和 .temp.add<float>。这样,当别的编译单元也以int和float类型实例化该模板函数后,也会生成相同的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将他们合并入最后的代码段。

这种重复代码消除对于模板来说是这样的,对于外部的内联函数和虚函数表的做法也类似。

函数级别链接

由于现在的程序和库通常来讲都非常庞大,一个目标文件可能包含成百上千个函数或变量。当我们需要用到某个目标文件中的任意一个函数或变量时,就必须把这个文件整个的链接起来,也就是说那些没有用到的函数也被一起链接了进来。这样导致的直接结果就是:链接输出文件的输出结果会变得相当的庞大,所有用到到没用到的变量和函数都一起塞到了输出文件中

无论是VC++编译器还是GCC编译器都提供了类似模板代码冗余消除的方法!具体为:让所有的函数氮素保存到一个段里面,当连接器需要用到某个函数的时候,就会将他直接合并到输出文件中,对于那些没有用到的函数进行舍弃。该方法很大程度上减小了输出文件的长度,减少了空间浪费。

2.全局的构造函数与析构函数是如何执行的?

我们知道一般一个C++/C程序是从main函数开始执行的,随着main函数的结束而结束。然而,其实在main函数被调用之前,为了保证程序可以顺利地进行,首先要初始化进程执行环境,比如堆分配初始化(malloc/free)、线程子系统等,C++全局对象的构造函数就是在该时期被执行的。【实际上C++的全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行】

根据前面的情境,我们能够发现,对于某些场合,程序的一些特定的操作必须在main函数之前被执行,还有一些操作必须在main之后进行执行。当然啦,我最习惯举得例子就是,C++中全局对象的构造与析构的过程。为了满足这一特殊情况,linux系统下的ELF文件结构还定义了两种特殊的段:

.init: 该段里面保存的是可执行指令,他构成了进程的初始化代码。在main函数之前被调用。

.fini: 该字段保存着进程终止代码指令,在main函数执行结束之后被调用。


3.C++的兼容问题及跨平台编程初步

既然每个编译器都能将源代码编译成目标文件,那么不同编译器编译出来的目标文件可以相互链接吗?比如说:MSVC编译出来的目标文件和GCC编译出来的目标文件能否链接在一起,从而形成可执行文件呢?

对于上面这些问题,首先可以想到的是,如果要将两个不同编译器编译出来的编译结果链接到一起,那么,链接器必须同时支持这两个编译器产生的目标文件格式。比如说,MSVC编译器的目标文件是PE/COFF格式,而GCC编译的结果是ELF格式的。只有连接器同时认识这两个格式才行,否则肯定没戏!那么链接器是不是只要同时认识目标文件的格式就可以了呢?不见得!!!

事情绝对没有这么简单,如果我们要使两个编译器编译出来的目标文件能够相互链接,那这两个/多个目标文件必须满足下面的几个条件:

1.采用相同的目标文件格式;

2.拥有同样的函数符号修饰标准;

3.变量的内存分布方式相同;

4.函数的调用方式相同。

其中,国际惯例,把符号修饰标准、变量内存分布布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。

C++一直为人诟病的一大原因就在于他的二进制兼容性不好!或者说,比起C语言来说更不容易。一方面,不同的编译器编译出来的二进制代码之间无法相互兼容,甚至有时候,连同一个编译器的不同版本之间的兼容性也不好。最普通的例子:我有一个库A是Company A 用Compiler A编译的;还有一个库B是Company A 用Compiler B编译的,当我写一个C++程序来同时使用库A和库B是,就会相当的棘手!!!我的朋友曾和我说过,只要我可以把一个开源项目的所有源代码在同一个编译环境下跑一遍,那不就OK了?!当然,这对于小型项目而言是容易可行的,但是考虑到一些大型程序,逐一进行编译明显是不合理的,这从某种意义上来讲,就造成软件开发的效率低下。

此外,很多时候,库厂商往往不希望用户看到哭的源代码,所以一般都会以二进制的方式提供给用户。这样就会带来一个问题,如果库厂商提*品的编译器型号和版本编译器型号不同时,代码基本上就被判了死刑。当然,这个问题不仅仅存在用户和库厂商之间;当一个程序由多个部门或多个公司联合开发时,类似的问题更明显。

所以绝大多数程序员一直期待能有统一的C++二进制兼容标准(C++ ABI),诸多的团体以及社区都在致力于C++ ABI标准的统一,但是前景非常的不乐观。基本形成了以微软的Visual C++和GNU的GCC为首的两大派系。

4.难兄难弟——API与ABI

很多时候,我们都会碰到这两个词,他们长得太像了,仅仅一字之差。API = Application Programming Interface; ABI = Application Binary Interface,实际上他们都是应用程序接口。只是他们所描述的接口所在的层面不一样。API往往是指源代码的应用程序接口;而ABI是指二进制层面的接口,所以ABI的兼容程度要比API的兼容性严格得多。比如:我们可以说C++的对象内存分布(object Memory Layout)是C++ ABI的一部分。相比之下API更关注源代码层面。
ABI的概念相比较之下历史更悠久,因为每一个程序员都希望在不经过任何修改的情况下,重新利用辛辛苦苦写的程序!如果可以,最好的情况就是,二进制的指令和数据还能够不加修改地得到重用,但是由于MS和GNU恶性竞争的原因,二进制级别的重用任重而道远。
下面是百度给的一段理解:
静态链接中的那点事儿(2):C++二进制兼容性及跨平台初步

正式因为这些挑战的存在,才有了诸君存在的价值,开源社区算是MS和GNU之外的第三股力量,希望某天开源社区能够 统ABI,这样广大程序猴们就能够去做更有意义的事了......