C++ 工程实践(4):二进制兼容性(一)

2014-11-24 13:05:55 · 作者: · 浏览: 3

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

本文主要讨论 Linux x86/x86-64 平台,偶尔会举 Windows 作为反面教材。

C/C++ 的二进制兼容性 (binary compatibility) 有多重含义,本文主要在“头文件和库文件分别升级,可执行文件是否受影响”这个意义下讨论,我称之为 library (主要是 shared library,即动态链接库)的 ABI (application binary interface)。至于编译器与操作系统的 ABI 留给下一篇谈 C++ 标准与实践的文章。

什么是二进制兼容性
在解释这个定义之前,先看看 Unix/C 语言的一个历史问题:open() 的 flags 参数的取值。open(2) 函数的原型是

int open(const char *pathname, int flags);

其中 flags 的取值有三个: O_RDONLY, O_WRONLY, O_RDWR。

与一般人的直觉相反,这几个值不是按位或 (bitwise-OR) 的关系,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以读写方式打开文件,必须用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。为什么?因为 O_RDONLY, O_WRONLY, O_RDWR 的值分别是 0, 1, 2。它们不满足按位或。

那么为什么 C 语言从诞生到现在一直没有纠正这个不足之处?比方说把 O_RDONLY, O_WRONLY, O_RDWR 分别定义为 1, 2, 3,这样 O_RDONLY | O_WRONLY == O_RDWR,符合直觉。而且这三个值都是宏定义,也不需要修改现有的源代码,只需要改改系统的头文件就行了。

因为这么做会破坏二进制兼容性。对于已经编译好的可执行文件,它调用 open(2) 的参数是写死的,更改头文件并不能影响已经编译好的可执行文件。比方说这个可执行文件会调用 open(path, 1) 来写文件,而在新规定中,这表示读文件,程序就错乱了。

以上这个例子说明,如果以 shared library 方式提供函数库,那么头文件和库文件不能轻易修改,否则容易破坏已有的二进制可执行文件,或者其他用到这个 shared library 的 library。操作系统的 system call 可以看成 Kernel 与 User space 的 interface,kernel 在这个意义下也可以当成 shared library,你可以把内核从 2.6.30 升级到 2.6.35,而不需要重新编译所有用户态的程序。

所谓“二进制兼容性”指的就是在升级(也可能是 bug fix)库文件的时候,不必重新编译使用这个库的可执行文件或使用这个库的其他库文件,程序的功能不被破坏。

见 QT FAQ 的有关条款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul

在 Windows 下有恶名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,这是动态链接库的本质问题,怪不到 MFC 头上。

有哪些情况会破坏库的 ABI
到底如何判断一个改动是不是二进制兼容呢?这跟 C++ 的实现方式直接相关,虽然 C++ 标准没有规定 C++ 的 ABI,但是几乎所有主流平台都有明文或事实上的 ABI 标准。比方说 ARM 有 EABI,Intel Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文规定的 ABI,等等。x86 是个例外,它只有事实上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 还有多个版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 编译器也得按照 Visual C++ 或 G++ 的 ABI 来生成代码,否则就不能与系统其它部件兼容。

C++ ABI 的主要内容:

函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
struct 和 class 的内存布局,通过偏移量来访问数据成员
name mangling
RTTI 和异常处理的实现(以下本文不考虑异常处理)
C/C++ 通过头文件暴露出动态库的使用方法,这个“使用方法”主要是给编译器看的,编译器会据此生成二进制代码,然后在运行的时候通过装载器(loader)把可执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。

这里举一些源代码兼容但是二进制代码不兼容例子

给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取巧行为,因为你的 class 可能已被继承。)
增加默认模板类型参数,比方说 Foo 改为 Foo >,这会改变 name mangling
改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4。这会造成错位。当然,由于 enum 自动排列取值,添加 enum 项也是不安全的,除非是在末尾添加。
给 class Bar 增加数据成员,造成 sizeof(Bar) 变大,以及内部数据成员的 offset 变化,这是不是安全的?通常不是安全的,但也有例外。

如果客户代码里有 new Bar,那么肯定不安全,因为 new 的字节数不够装下新 Bar。相反,如果 library 通过 factory 返回 Bar* (并通过 factory 来销毁对象)或者直接返回 shared_ptr,客户端不需要用到 sizeof(Bar),那么可能是安全的。
如果客户代码里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因为 memberA 的新 Bar 的偏移可能会变。相反,如果只通过成员函数来访问对象的数据成员,客户端不需要用到 data member 的 offsets,那么可能是安全的。
如果客户调用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是个 inline function,那么肯定不安全,因为偏移量已经被 inline 到客户的二进制代码里了。如果 setMemberA() 是 outline function,其实现位于 shared library 中,会随着 Bar 的更新而更新,那么可能是安全的。
那么只使用 header-only 的库文件是不是安全呢?不一定。如果你的程序用了 boost 1.36.0,而你依赖的某个 library 在编译的时候用的是 1.33.1,那么你的程序和这个 library 就不能正常工作。因为 1.36.0 和 1.33.1 的 boost::function 的模板参数类型的个数不一样,其中一个多了 allocator。

这里有一份黑名单,列在这里的肯定是二级制不兼容,没有列出的也可能二进制不兼容,见 KDE 的文档: