C++二进制兼容
目录
什么是二进制兼容
二进制兼容ABI(application binary interface)主要指动态库文件单独升级,现有用到老动态库的应用程序是否受到影响。
- 升级库文件,不影响使用库文件的程序。
- 新库必然有新头文件,但是旧的二进制可执行文件还是按照旧的头文件中的“使用说明”来调用库。
意思就是你应用程序A调用库B1.0,现在库B升级了,变成B1.1,应用程序A调用库B1.1还是能够正常使用,这种就叫二进制兼容,反之就是不兼容。
如何判断一个改动是不是二进制兼容呢
C/C++ 通过头文件暴露出动态库的使用方法,这个“使用方法”主要是给编译器看的,编译器会据此生成二进制代码,然后在运行的时候通过装载器(loader)把可执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。
二进制不兼容的示例
- 类的普通成员函数
void f(int)
改成了void f(double)
。老EXE会传int进来,新库会用double的长度取数据。从而发生undefined symbol
- 基类增加虚函数会导致基类虚表发生变化。老EXE调用虚表的时候给出的slot是老的,但是新库里面的这个slot已经是另一个函数了。
- 给函数增加默认参数
- 增加默认模板类型
- 改变enum的值
- 给class Bar增加数据成员导致sizeof(Bar)的值变大。这种增加成员变量的情况通常是不安全的,但也有例外:
- 如果客户代码里有
new Bar
,那么肯定不安全,因为new
的字节数不够装下新Bar
。相反,如果 library 通过 factory 返回Bar*
(并通过 factory 来销毁对象)或者直接返回shared_ptr<Bar>
,客户端不需要用到sizeof(Bar)
,那么可能是安全的。同样的道理,直接定义Bar bar;
对象(无论是函数局部对象还是作为其他 class 的成员)也有二进制兼容问题。 - 如果客户代码里有
Bar* pBar; pBar->memberA = xx;
,那么肯定不安全,因为memberA
的新Bar
的偏移可能会变。相反,如果只通过成员函数来访问对象的数据成员,客户端不需要用到data member
的offsets
,那么可能是安全的。 - 如果客户调用
pBar->setMemberA(xx);
而Bar::setMemberA()
是个inline function
,那么肯定不安全,因为偏移量已经被inline
到客户的二进制代码里了。如果setMemberA()
是outline function
,其实现位于shared library
中,会随着 Bar 的更新而更新,那么可能是安全的。
- 如果客户代码里有
- 如果EXE里调用
new Bar
,导致new出来的内存装不下新的Bar
对象(构造函数会使用新DLL中的构造函数来填充数据),从而:- 如果新的库实现访问了新的数据成员肯定会访问到一个无法预知的地方;
- 如果EXE得到的是
shared_ptr<Bar>
由DLL来管理内存,那么此时是安全的。 - 如果EXE调用的是
p->member
那么肯定不对,因为偏移量可能因为member
前面插入了新的成员而被新DLL中构造函数填充了新的成员,从而访问的并不是老的member。 - 如果EXE是使用
p->get_member()
来获取数据,那么是正常的。 - 如果
p->get_member()
是inline的,那么是不安全的,因为偏移量已经在EXE中了。
- 虚函数做接口的基本上都是二进制不兼容的。具体地说,以只包含虚函数的 class (称为 interface class)作为程序库的接口,这样的接口是僵硬的,一旦发布,无法修改。
这里没有列出的一些情况也可能二进制不兼容。
二进制安全的场景
- 增加新的class(定义在新DLL中,老的EXE里没有)
- 增加非virtual函数(定义在新DLL中,老的EXE里没有)
- 增加static成员函数(定义在新DLL中,老的EXE里没有)
解决办法
- 采用静态链接:这个是王道。在分布式系统这,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就能运行,不用考虑它依赖的 libraries。目前 muduo 就是采用静态链接。
- 通过动态库的版本管理来控制兼容性:这需要非常小心检查每次改动的二进制兼容性并做好发布计划,比如 1.0.x 系列做到二进制兼容,1.1.x 系列做到二进制兼容,而 1.0.x 和 1.1.x 二进制不兼容。《程序员的自我修养》里边讲过 .so 文件的命名与二进制兼容性相关的话题,值得一读。
- 用pimpl技法:在头文件中只暴露
non-virtual
接口,并且class
的大小固定为sizeof(Impl*)
,这样可以随意更新库文件而不影响可执行文件。当然,这么做有多了一道间接性,可能有一定的性能损失。见Exceptional C++
有关条款和C++ Coding Standards 101
。
Qt作为一个跨平台的开发框架,应用很广,本身肯定也是实现二进制兼容的。那Qt是怎么实现的呢?其实Qt使用的就是pimpl技法,就是Qt的d指针和q指针的使用。