C++的二进制兼容

在深入了解前,首先我们要明确两个概念:二进制兼容和源码兼容。

  • 二进制兼容:在升级库文件的时候,不必重新编译使用此库的可执行文件或其他库文件,并且程序的功能不被破坏。

  • 源码兼容:在升级库文件的时候,不必修改使用此库的可执行文件或其他库文件的源代码,只需重新编译应用程序,即可使程序的功能不被破坏。

ABI和API

应用二进制接口(application binary interface,缩写为 ABI)描述了应用程序(或者其他类型)和操作系统之间或其他应用程序的低级接口。ABI涵盖了各种细节,如:数据类型的大小、布局和对齐;调用约定等。

在了解二进制兼容和源码兼容两个定义以后,我们再看与其类似且对应的两个概念:ABIAPIABI不同于API(应用程序接口),API定义了源代码和库之间的接口,因此同样的代码可以在支持这个API的任何系统中编译,然而ABI允许编译好的目标代码在使用兼容ABI的系统中无需改动就能运行。

举个例子,在Qt和Java两种跨平台程序中,API像是Qt的接口,Qt有着通用接口,源代码只需要在支持Qt的环境下编译即可。ABI更像是Jvm,只要支持Jvm的系统上,都可以运行已有的Java程序。

C++的ABI

ABI更像是一个产品的使用说明书,同理C++的ABI就是如何使用C++生成可执行程序的一张说明书。编译器会根据这个说明书,生成二进制代码。C++的ABI在不同的编译器下会略有不同。

C++ABI的部分内容举例:

  • 函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
  • 虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
  • struct 和 class 的内存布局,通过偏移量来访问数据成员

综上所述,如果可执行程序通过以上说明书访问动态链接库A,以及此库的升级版本A+,若按此说明书上的方法,可以无痛的使用A和A+,那么我们就称库A的这次升级是二进制兼容的。

破坏二进制兼容的几种常见方式

  • 添加新的虚函数
  • 不导出或者移除一个导出类
  • 改变类的继承
  • 改变虚函数声明时的顺序(偏移量改变,导致调用失败)
  • 添加新的非静态成员变量(类的内存布局改变,偏移量也发生变化)
  • 改变非静态成员变量的声明顺序

不会破坏二进制兼容的几种常见方式

  • 添加非虚函数(包括构造函数)
  • 添加新的类
  • 添加Qt中的信号槽
  • 在已存在的枚举类型中添加一个枚举值
  • 添加新的静态成员变量
  • 修改成员变量名称(偏移量未改变)
  • 添加Q_OBJECT,Q_PROPERTY, Q_ENUMS ,Q_FLAGS宏,添加这些宏都是修改了moc生成的文件,而不是类本身

只要我们知道了程序是以什么方式访问动态库的(C++的ABI),那么我们就很好判断,哪些操作会破坏二进制兼容。更多方式请参见Policies/Binary Compatibility Issues With C++

解决二进制兼容问题的相关方法

  • 使用Bitflags即位域
1
2
3
uint m1 : 1;
uint m2 : 3;
uint m3 : 1;
1
2
3
4
uint m1 : 1;
uint m2 : 3;
uint m3 : 1;
uint m4 : 2; // new member without breaking binary compatibility.

C++抽象类和Java的接口

读到这里大家也许会奇怪,作者是不是放错地方了?其实不然,只是在我们了解二进制兼容后,可以更好地理解这组概念。之前我一直认为C++的抽象类就类似于Java的接口,现在发现,如果把一个C++的抽象类作为动态库的接口发布,那将是毁灭的。因为你无法增加虚函数,无法增加成员变量,这使得这个接口变得非常的不友好。这也就是Java接口的优势所在。Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做,便不存在上述二进制兼容的问题。

理解Java二进制兼容的关键是要理解延迟绑定(Late Binding)。延迟绑定是指Java直到运行时才检查类、域、方法的名称,而不象C/C++的编译器那样在编译期间就清除了类、域、方法的名称,代之以偏移量数值——这是Java二进制兼容得以发挥作用的关键。
由于采用了延迟绑定技术, 方法、域、类的名称直到运行时才解析,意味着只要域、方法等的名称(以及类型)一样,类的主体可以任意替换。

参考