0.目录
- 定义
- ABI和API
- 二进制兼容的相关问题
- C++抽象类和Java的接口
- 总结
- 参考
1.定义
所谓二进制兼容就是在做版本升级(也可能是Bug fix)库文件的时候,不必要做重新编译使用这个库的可执行文件或使用这个库的其他库文件,同时能保证程序功能不被破坏。
先明确两个概念:二进制兼容和源码兼容:
- 二进制兼容:升级库文件时,不必重新编译使用此库的可执行文件或其他库文件,且程序的功能不被破坏
- 源代码兼容:升级库文件时,不必修改使用此库的可执行文件或其他库文件的源代码,只需重新编译应用程序,即可使程序的功能不被破坏
2.ABI和API
应用二进制接口(application binary interface,缩写为
ABI
)描述了应用程序(或者其他类型)和操作系统之间或其他应用程序的低级接口。ABI
涵盖了各种细节,如:数据类型的大小、布局和对齐;调用约定等。
在了解二进制兼容和源码兼容两个定义以后,我们再看与其类似且对应的两个概念:ABI
和API
。ABI
不同于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的这次升级是二进制兼容的。
3.二进制兼容的相关问题
3.1 破坏二进制兼容的几种常见方式
添加新的虚函数
修改虚函数表内的排列顺序,即使把新增加的虚函数放到最后一个,也可能会引起问题,如该类作为父类被其他类继承等;修改函数的参数列表
由于C++支持同名函数重载,C++编译时,会对函数名字进行name mangling,如果修改了函数的参数列表,经过C++编译器编译后,函数的名称就变了,现有的可执行文件无法传这个额外的参数;不导出或者移除一个导出类
改变类的继承
改变虚函数声明时的顺序
偏移量改变,导致调用失败添加/删除非静态成员变量
改变该类的对象的大小,类的内存布局改变,偏移量也发生变化,如:
pfoo = new Foo(); // 由于sizeof(Foo)发生了变化,分配的内存可能不够
pfo->member_variable; // 可能会出错,偏移量变化
// 当使用 inline setxxx(x)时,也可能会出错,因为inline函数可能已经编译进使用该库的程序代码中。
改变非静态成员变量的声明顺序
偏移量改变增加默认模板类型参数
// 如:
template <typename T> class Grid {}; // old
template <typename t, typenameContainer=vector> class Grid{}; // new
- 改变
enum
的值
enum Color { Red = 3}; // old
enum Color { Red = 4}; // new
// 这会造成错位。当然,由于enum自动排列取值,添加enum项也是不安全的,除非是在末尾添加。
3.2 不会破坏二进制兼容的几种常见方式
- 添加非虚函数(包括构造函数)
- 添加新的类
- 在已存在的枚举类型中添加一个枚举值
- 添加新的静态成员变量
- 修改成员变量名称(偏移量未改变)
- 增减类的友元声明
只要我们知道了程序是以什么方式访问动态库的(C++的ABI
),那么我们就很好判断,哪些操作会破坏二进制兼容。更多方式请参见Policies/Binary Compatibility Issues With C++
3.3 解决二进制兼容问题的相关方法
编写库时最大的问题是,不能安全地添加数据成员,因为这会改变每个包含类对象(包括子类)的类,结构或数组的大小和布局。
1. 使用Bitflags
即位域
//old
uint m1 : 1;
uint m2 : 3;
uint m3 : 1;
//new
uint m1 : 1;
uint m2 : 3;
uint m3 : 1;
uint m4 : 2; // new member without breaking binary compatibility.
不会破坏二进制兼容性。 但需要根据字节对其取整,否则可能会因改变了整个类的大小导致sizeof()之类的方法出问题,使用最后一位可能会在某些编译器上引起问题。
2. 使用静态库(当然也随之带来一系列弊端)
3. D指针设计模式(PImpl
机制)
4. COM理论
COM (Component object model) 组件对象模型是微软提出的一个想法,它其实是一个规范,并且是二进制规范,也就是说只要遵循这个规范,任何语言、任何平台都可以相互调用相应组件。
COM涉及到几个概念:
- class ID,可以是CLSID - class的GUID 或者 IID - interface的GUID。COM通过这个ID来保证快语言,因为基本上所有语言都可以处理GUID字符串;另外COM开发者可以通过GUID来获取到准确的对象结构。
-
coclass - component object class,简单来说就是COM组件提供给使用者的接口类,这些类其实都是都继承
IUnkown
接口的抽象类,里面都是纯虚函数。这个IUnknown
包含三个方法:-
AddRef
- 增加对象引用计数 -
Release
- 减少引用计数,如果计数为0,则销毁 -
QueryInterface
- 根据GUID来查到对象
-
COM组件还涉及到注册表,它可以注册到操作系统的注册表中,这样就算当前这个组件DLL物理位置与运行文件不在同一个目录,也可以加载并获取DLL的导出对象或者函数。更多了解可以看 CodeProject - Introduction to COM - What It Is and How to Use It。
那为什么可以说COM能保证二进制兼容呢?
其实通过上面两个概念可以有点思绪,所谓二进制兼容对于C++ 来说就是要保证第三方使用DLL提供的接口对象时,保证内存布局不会改变,或者说不会影响。对于C++来说,对象内存布局的主要包括:
变量
虚函数 - 每个实例都会有一个虚函数列表(包括基类的)
对于COM实现来说,因为是通过GUID来获取对象,并且这些对象都是由接口来提供的实例化(抽象类不能创建实例,这些实例都是继承的子类实现),就像 caller ----> coclass (interface) --create--> instance 这样调用。
由于 instance 是在COM组件类(DLL)实例化以及释放,所以其内存布局对于 caller 来说是没有影响的。
4.C++抽象类和Java的接口
之前我一直认为C++的抽象类就类似于Java的接口,现在发现,如果把一个C++的抽象类作为动态库的接口发布,那将是毁灭的。因为你无法增加虚函数,无法增加成员变量,这使得这个接口变得非常的不友好。这也就是Java接口的优势所在。Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做,便不存在上述二进制兼容的问题。
理解Java二进制兼容的关键是要理解延迟绑定(Late Binding)。延迟绑定是指Java直到运行时才检查类、域、方法的名称,而不象C/C++的编译器那样在编译期间就清除了类、域、方法的名称,代之以偏移量数值——这是Java二进制兼容得以发挥作用的关键。
由于采用了延迟绑定技术, 方法、域、类的名称直到运行时才解析,意味着只要域、方法等的名称(以及类型)一样,类的主体可以任意替换。
5.总结
- 尽可能的不要使用虚函数作为接口
- 使用
pimpl