ABI兼容性

ABI 是 Application Binary Interface 的缩写,当我们以二进制形式(非源码形式)发布我们的动态库时,就需要关心ABI兼容(也称二进制兼容)。

对于静态库,更新静态库始终都需要该库的使用方重新编译,因此不存在ABI兼容的说法。

什么是ABI兼容

假设我们开发了某个动态库(名为 something),以动态库的形式提供:something.h、something.lib、something.dll。

有人使用该动态库开发了程序 w(w可以是可执行程序,也可以是库),即程序w链接了动态库 something,并将程序w打包交付给终端用户,打包文件包含:w.exe、something.dll。

something 库是否具有ABI兼容性决定了在更新 something.dll 时,是否需要重新编译 w.exe?

如果不需要重新编译 w.exe,则 something 库是二进制兼容的,否则就不是的。

Microsoft C++(MSVC)编译器工具集在 Visual Studio 2015 之前未实现ABI兼容,但在 Visual Studio 2015(含)之后实现了ABI兼容。

与 ABI 有关的知识

在理解哪些行为是否会破坏 ABI 兼容性之前,我们需要预先了解一些C++基础知识。

类如何访问成员变量

在 C++ 中,struct 和 class 是通过偏移量来访问成员变量的,如果改变了成员变量的顺序,偏移量也会相应改变。

虚函数表

虚函数是通过虚函数表来管理和访问的,改变虚函数顺序、新增/删除虚函数都会改变虚函数表,可以参考之前有关虚函数的文章:深入理解C++虚函数

Name Mangling

C++ 编译器会把函数的名字、参数等信息(或者叫函数签名)编码成一个唯一的字符串,用作链接符号,这样就能在编译期完成检查,从而避免运行时报错,这种行为称作Name Mangling,例如:

1
2
3
4
5
6
7
namespace wikipedia 
{
class article {
public:
std::string format(void);
}
}

format 函数经过 Name Mangling 之后变成了:_ZN9wikipedia7article6formatEv

Name Mangling使用的算法是可逆的,http://demangler.com/ 网站提供了通过新函数名逆向推演出原有函数名的功能。

类如何访问成员函数

C++ 编译器在编译成员函数时会根据它所在的命名空间、所属类、以及参数等信息,通过 Name Mangling 生成一个新的函数名。

类的成员函数最终被编译成与对象无关的全局函数,为了使该全局函数可以访问类的其他成员函数和变量,编译器在编译成员函数时会额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量/成员函数。

头文件的作用

在 C/C++ 中,动态链接库通常会附带头文件,这个头文件可以理解成动态库的“使用说明书”,库的使用者会按照头文件使用该库,编译器会根据该头文件生成二进制代码,然后在运行的时候通过装载器(loader)把可执行文件和动态库绑到一起。

破坏ABI兼容性的行为

如何判断一个改动是不是二进制兼容,主要看老版的头文件能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。

总结起来,ABI 不兼容主要是因为:

  • sizeof(class) 大小改变。
  • 数据成员偏移量发生改变。
  • 虚函数表发生改变。
  • Name Mangling 名发生改变。

下面列举了会破坏 ABI 兼容性的操作。

3.1 添加或删除非静态成员变量

因为 sizeof(class) 大小发生改变,如之前占用8个字节,新增一个成员变量后占用12字节,但外部代码new class时仍然只分配了8字节,所以会出问题。

但是,如果动态库提供了创建对象的方法,始终在动态库内部创建对象,则没有该问题,如:

1
2
3
4
5
6
class EXPORT_API Koi {
public:
// ....
};

EXPORT_API Koi* GetKoi();

也可以通过Impl设计模式来解决该问题。

1
2
3
4
5
6
7
class EXPORT_API Koi {
public:
// ....
private:
class Impl;
Impl* impl_;
};
1
2
3
4
5
6
// 动态库内部
class Koi::Impl {
public:
int a_;
// ...
}

3.2 改变非静态成员变量的顺序

改变了非静态成员变量的顺序就改变了变量的内存偏移,会导致变量读写出错,因此破坏了ABI兼容性。

这个问题也可通过上面 3.1 节的方式来解决。

3.3 修改函数的名称

这个导致会 Name Mangling 名发生改变,因此 ABI 不兼容。

3.4 添加默认的模板类型参数

Foo<T> 改为 Foo<T, Alloc=alloc<T> >,这个导致会 Name Mangling 名发生改变,因此不 ABI 兼容。

3.5 为函数添加默认参数

现有的动态库使用方(可执行程序或另外一个动态库)是基于老的头文件进行编译的,无法传递该默认参数给新的动态库,因此不 ABI 兼容。

3.6 修改函数参数传递方式

如 __cdecl 修改为 __stdcall。函数参数的传递方式有多种,如压栈方式、寄存器方式。如果选择压栈方式,在维持栈平衡上有分调用者维持、函数自身维持,在参数的传递顺序也有多种,从左到右,还是从右到左。

具体介绍可以参考之前的文章:从汇编的角度分析函数调用过程

3.7 添加虚函数

添加虚函数会导致虚函数表发生了变化,即便是在最尾端添加虚函数也可能不行,因为当前类可能已经被其他类继承了。

3.8 不同系统的动态库

不同操作系统所支持的动态库的二进制格式不一样,因此不同系统的动态库肯定是无法兼容的。

不破坏ABI兼容性的行为

下面的操作不会破坏 ABI 兼容性:

  • 添加新的类
  • 修改成员变量的名称
  • 更改非虚成员函数的顺序

Windows COM 实现 ABI 兼容的方式

很多时候,由于功能更新,对接口的修改不可避免的,既然接口已经发生大改动,那显然很难满足ABI兼容性,此时可以通过版本管理的方式来保证 ABI 兼容性,如:

1
2
3
4
5
6
7
// 版本1
class Interface {
public:
virtual void API sendMessage(const char* message) = 0;
};

Interface* CreateInterface(int version);
1
2
3
4
5
6
7
// 版本2
class Interface2 {
public:
virtual void API sendMessage(const char* message, int messageSize) = 0;
};

Interface2* CreateInterface(int version);

上面方式也是 COM 实现 ABI 兼容性的方式,需要在动态库中仍要保留老的接口和实现,以实现向前兼容,这种方式有个弊端就是会存在很多版本的接口,如:

1
2
IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3

本文参考了:

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

MSVC版本的二进制兼容性

Visual Studio 2013 及更早版本中的 Microsoft C++ (MSVC) 编译器工具集不保证主版本间的二进制兼容性,无法链接由不同版本工具集生成的对象文件、静态库、动态库和可执行文件,因为ABI、对象格式和运行时库不兼容。

微软在 Visual Studio 2015 及更高版本中改变了这个行为。对于自 Visual Studio 2015 以来的所有版本(该版本号都以 14 开头,如Visual Studio 2015、2017、2019 和 2022工具集的版本分别为 v140、v141、v142 和 v143)由其中任一版本编译器编译的运行时库和应用都具有二进制兼容性。

假设你使用 Visual Studio 2015 生成第三方库,你仍可在 Visual Studio 2017、2019 或 2022 生成的应用程序中使用它们,无需使用匹配工具集重新编译。 同时最新版本的 Microsoft Visual C++ 可再发行程序包(运行时库)也兼容所有老版本,无需为不同版本安装不同的运行时库,统一安装最新版本即可。

对二进制兼容性的限制

v140、v141、v142 和 v143 工具集与次要版本号更新之间的二进制兼容性方面存在三个重要限制:

  • 你可以混合使用由 v140、v141、v142 和 v143 工具集的不同版本生成的二进制文件。 但是,必须使用至少与应用中最新二进制文件同样新的工具集进行链接。 下面是一个示例:可以将使用任何版本的v141工具集(版本 15.0 到 15.9)编译的应用链接到使用 Visual Studio 2019 版本 16.2 (v142) 编译的静态库。 只是必须使用版本 16.2 或更高版本工具集链接它们。只要使用 16.4 或更高版本工具集,便可以将版本 16.2 库链接到版本 16.4 应用。

  • 应用使用的可再发行程序包具有类似的二进制兼容性限制。 混合使用由工具集的不同受支持版本生成的二进制文件时,可再发行程序包版本必须至少与任何应用组件使用的最新工具集一样新。

  • 使用 /GL(全程序优化)编译器开关编译或是使用 /LTCG(链接时间代码生成)链接的静态库或对象文件不在各个版本间二进制兼容(包括次要版本更新)。

    使用 /GL/LTCG 编译的所有对象文件和库必须将完全相同的工具集用于编译和最终链接。 例如,使用 Visual Studio 2019 版本 16.7 工具集中的 /GL 生成的代码无法链接到使用 Visual Studio 2019 版本 16.8 工具集中的 /GL生成的代码。 编译器会发出错误 C1047

从 Visual Studio 2015 及更高版本升级 Microsoft Visual C++ 可再发行程序包

对于 Visual Studio 2015、2017、2019 和 2022,微软将 Microsoft Visual C++ 可再发行程序包的主版本号保持一致。 这意味着我们一次只能安装可再发行程序包的一个实例。 较新版本会覆盖已安装的任何较旧版本。 例如,一个应用可能会从 Visual Studio 2015 安装可再发行程序包。 随后另一个应用从 Visual Studio 2022 安装可再发行程序包。 2022 版本会覆盖较旧版本,但由于它们具有二进制兼容性,早期应用仍可正常工作。 微软确保最新版本的可再发行程序包具有所有最新的功能、安全更新和 bug 修补程序。 这便是为什么微软始终建议升级到最新可用版本。

同样,已安装了较新版本时,无法安装较旧的可再发行程序包。 如果尝试,则安装程序会报告错误。 如果在已具有 2022 版本的计算机上安装 2017 或 2019 可再发行程序,则会看到如下所示的错误:

1
0x80070666 - Another version of this product is already installed. Installation of this version cannot continue. To configure or remove the existing version of this product, use Add/Remove Programs on the Control Panel.

此错误是微软故意这样设计的,以确保Microsoft Visual C++ 可再发行程序包为最新版本。

参考:Visual Studio 版本之间的 C++ 二进制兼容性