按照Titus Winters在提案P2028中所解释的概念,ABI是指在一个翻译单元中的实体(如函数、类型等)如何交互,平台相关、(编译器)供应商相关。
原文:ABI is the platform-specific, vendor-specified, not-controlled-by-WG21 specification of how entities (functions, types) built in one translation unit interact with entities from another.
ABI本身并没有在C++标准中出现过,这导致C++的ABI问题比较混乱;这也是C++相关提案出现的原因——"not controlled by WG21"。事实上C标准也没有这个概念。
翻译单元(TU)在标准中有明确的概念;以笔者的理解,大概可以认为生成的每个object file都是一个翻译单元。
具体地,C++的ABI可以分为两个方面,我们也会按两方面讨论:
这是笔者之前在reddit的一个帖子上看到的分类,觉得很合理,但当时居然没有标记下来,如果有人确实需要看原帖,笔者可以找找。
自然的,因为库本身是由语言编写的,通常情况下语言ABI的改变都会使库的ABI不兼容。
C++的ABI由编译器、操作系统和硬件的体系结构共同决定;按照道理来说C应该也是,但是由于操作系统本身具有了底层的C ABI,因此相应平台上的编译器都会遵循这个ABI,于是C的ABI一般不由编译器的诸多选项等决定。
当然,这不意味着不同的C编译器产生的object file可以一起link。如果两个编译器产生可互相辨认的object file(即格式一致),这应该是可行的;但反之,像MSVC和MinGW的gcc,它们编译产生的符号表完全不一致,因此不能链接。如下图:
MSVC 19.29编译出的目标文件
MinGW gcc 8.1.0编译出的可执行文件
解析工具见 GitHub - gitGNU/objconv。
如果使用相同的库,clang和gcc的C编译器应该可以产生可链接的object file。
C的ABI主要包括以下5个方面:
但是对于C++,它的ABI还十分取决于编译器(我想这也是为什么Language ABI也称作compiler ABI)。也就是说,就算两个目标文件在以上方面都一致,而且符号表等也可互相识别,但他们仍可能链接出一个错误的可执行文件。这通常出现在用一个更早版本的编译器去链接更晚版本的编译器产生的目标文件,或者相同版本但选择了某些改变ABI的编译器选项的目标文件。
具体地,C++由编译器决定的ABI主要包括:
namespace Namespace {int function(int x);}
,在GCC中会修饰为_ZN9NameSpace8functionEi
,而在MSVC中会修饰为?function@NameSpace@@YAHH@Z
。将修饰后的名称转化会原名称的过程称为demangle;一个demangle的网站是 http://demangler.com/
编译器决定的ABI的分类主要来自于 GCC manual about compatibility.
C++的主流语言ABI应该有两套:
特别地,Clang好像有一些选项可以尽量(但不完全)兼容MSVC的ABI;见https://clang.llvm.org/docs/MSVCCompatibility.html。不知道GCC/MSVC有没有兼容其他ABI的选项?
由于编译器一般都使用供应商所提供的标准库实现,因此标准库的ABI也事实上成为了C++ABI的一部分。具体地,如果一个动态库在更新后,原来的可执行文件仍然能正常地使用动态库的函数,而不需要让源代码重新编译,则称库的ABI保持了下去 / 二进制兼容。静态库本身应该不需要考虑这个问题,因为静态库更新之后总是需要重新编译。
std::string
和std::list
的ABI改变了(为了适应C++11关于COW的规定),造成在新编译器中链接之前的代码会运行崩溃(我觉得这是很多公司维持gcc版本在4.9的重要原因,防止老的库用不了,但似乎有些因噎废食)。这给库程序员造成很大的麻烦,因为C++程序员几乎不可避免使用标准库;如果要兼容所有版本,保险起见就需要每个ABI break的版本都提供新的库。如果想跨平台,还要考虑操作系统的问题;甚至可能需要考虑编译器选项的问题,之前笔者遇到过VS中Release模式编译的库在Debug模式使用会报warning。
如果注意前面提到的几个方面,那么我们可以编写出一个二进制兼容的库。也就是说,在库更新后,一个实体根据它原来的索引方式仍然能索引到正确的实体:
__stdcall
和__cdecl
在Windows中不要混用;这是为了让语言ABI维持统一。class A { public: int a; int b;};
变为class A{ public: int b; int a;};
,由于用户代码实际上使用偏移量索引的,改变之后会让用户代码想索引a
时索引到b
,想索引b
时索引到a
。或者增加了类的成员,使得栈的分配出现问题。std::string
就是因为改变了成员造成了不兼容。有两篇文章详述了维持库ABI时需要注意的事项,说的很到位,见
KDE ABI regulationcommunity.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B
20 ABI breaking changeswww.acodersjourney.com/20-abi-breaking-changes
其次注意一下标准库的使用版本,也就考虑了标准库的ABI。
二、ABI常识
有相当一部分程序员弄不清楚API和ABI的差别,甚至根本就没说过ABI。这并不是ABI不重要,而是在小型开发团队中ABI问题不容易遇到,当团队扩大,开发程序使用的组件增多,这个问题就会变得严重。我们倾向于从头说起,就当是科普了。
API,Application Programming Interface
ABI,Application Binary Interface
从名字上就可以看出,API是面向编程人员的,是面向人的;而ABI,编程人员是不容易直接看到的,但编程人员的行为可以直接影响到ABI,简单来说,ABI是面向编译器的。举个简单的例子
void print(int tag1, int tag2, int tag3)
{
printf("%d\n", tag1 + tag2 + tag3);
}
这段程序提供一个API,函数原型为
void print(int, int, int)
对于开发人员而言,到这里已经不需要再深入了。对于编译器而言,却有很多工作要考虑,例如
这些工作发生在二进制层面,因此就称作ABI,意味着二进制层面的接口规范。编译器如何处理这些问题和处理器密切相关,一般不足以引起什么问题,更严重的问题发生在语言和编译器之间,例如:
既然知道了什么是ABI,那么就可以考虑ABI的兼容问题了。
假设有一个程序A,依赖一个库libA中的某个函数,假设为
void __cdecl A_calc(int s1, int s2);
A由程序员John维护,libA由程序员CHIV维护,某一天CHIV就突然升级了一下
void __cdecl A_calc(int s1, int s2, int s3);
John习惯性的更新了libA,这就出现了问题,这既是典型的ABI不兼容问题。有人可能会说了,john重新编译一下不就能发现问题吗?显然,是的。如果随随便便更新某个lib,john就得重新编译程序,那么请问把软件分模块的意义是不是打了个大折扣。一个很大的程序A,居然因为一个小小的组件libA,就大动干戈重新编译一下,这不就是因小失大吗?
当使用的开发语言成为C++之后,ABI的问题就更严重了。主要体现在STL和虚函数上,试想一下,你的开发伙伴给你的头文件里一大堆的STD,你是不是有点发疯,你们的编译器都不一样好吗?过来一大堆C++11的代码,你连编译过去都不可能,VS2013支持的C++11连继承构造也没实现你信不?
既然如此,那大家都是用支持C++11的编译器行吗?还是不行,因为C++11没有规定ABI,编译器如何实现谁也不知道?于是你接口里的什么容器之类的就只能等着崩溃,VC自家的编译器都不能纵向兼容,不同厂家的还能指望吗?
是不是没有办法了?也不是,你只要给你的合作伙伴提供源代码就行,让他直接从源代码编译,无限期增加他的Build工作量,让他996,然后某一天他上班的时候也许会带把枪(这个梗能get到吗)什么的?
说完了STL,然后再说一下虚函数。
class U2B {
public:
U2B() {}
void Start();
virtual void Stop();
virtual void OnCompleted();
void Shutdown();
};
这是CHIV给你的接口,看着是不是很感人?但是没过多久,CHIV就增加了一个方法
class U2B {
public:
U2B() {}
virtual void Log();
void Start();
virtual void Stop();
virtual void OnCompleted();
void Shutdown();
};
就当所有编译器都一样吧,把所有的虚函数安排在类实例的起始位置,那里就放了一张虚函数表,表中的函数是个什么顺序?当然是虚函数出现的顺序。升级后,很明显表中的第一项变了,那么对Stop的调用,就指向了Log,这不就错了吗?关键是这个函数也能正常执行,于是John根本就找不到问题。更为要命的是,CHIV提供的这个lib是以组件形式管理的,你就算重新编译一下也找不到问题,john的妈妈每天都得喊他回去吃饭。这个ABI不兼容不崩溃、不报错,程序就是执行不正常。或者这个功能干脆就不常用,客户偶尔碰到一次,干脆一票否决了。
首先说结论,没有根本的办法防止ABI不兼容的问题。只能通过规范管理、增强安全编程的意识、提高对与程序的运行原理的认识才能降低这种概率,从编程技巧上,前辈们已经总结出了一些,这一节主要就介绍Pimpl方法。
在开发中不使用STL或者不使用虚函数确实能避免不少ABI问题,如果这样的话,还是用C++干啥。所以前辈们就想出了Pimpl方法,它的中心思想就是,给合作伙伴的接口里面没有STL,也没有虚函数,所有能引起ABI兼容问题的特征都被封死在库的本身范围之内。这需要用到C++的前置声明,还是举例说明。
class U2BImpl;
class U2B {
public:
U2B() {}
void Start();
void Stop();
void OnCompleted();
void Shutdown();
private:
U2BImpl* mImpl;
};
这就是给合作伙伴的接口,U2BImpl是真正的实现,U2B是这个实现的封装,U2BImpl并不需要用户直接访问,从而隔离了ABI不兼容的问题。
一般的软件为了模块分割,思路都是这样的:
模块写在so/dll文件中,使用exe加载并执行功能,更新只用更新dll、so就可以了。不用重新编译exe
这是一篇ABI兼容的文章 https://www.jianshu.com/p/895451c7b678
ABI兼容的目的就是为了保证改变了dll,so以后,不用重新编译exe就可以直接使用。
当ABI不兼容,或者ABI出错的时候,会发生什么呢?我们来看一个例子
先给出一个继承图:
vclass -> obj
//interface.h
#include
class vclass{
public:
std::string name;
virtual std::string get_name()=0;
};
//lib.h,继承自interface,重写虚函数
#include"interface.h"
#include
class obj : public vclass{
public:
std::string get_name();//获得name
obj(){
name="子类";
}
};
extern "C" vclass* get_obj(){ //使用C格式的函数命名
return new obj;
}
//lib的实现
#include"lib.h"
std::string obj::get_name(){
return name;
}
很简单的例子吧,就是实现了一个接口,然后返回name的值,随后我们在main中调用dlopen,来打开这个动态链接库:
#include
#include
#include
#include"interface.h"
using namespace std;
int main(){
void *handle=dlopen("./lib.so",RTLD_NOW);//加载so文件
using func= vclass*(*)(void);
func get_obj;
get_obj=(func)dlsym(handle,"get_obj");//从so中获取函数
vclass *obj1=(get_obj()); //获取一个对象
cout<
return 0;
}
运行的结果很显而易见,直接会输出动态库中的"子类“
#makefile
lib:lib.h lib.cpp
g++ -g -fPIC -shared -o lib.so lib.cpp
main:main.cpp
g++ -g main.cpp -o main -ldl
ok,现在问题来了,现在有另外一个同事,由于某种需求,在父类加了一个虚函数xxxx(),并且在get_name()虚函数前(这个顺序非常重要,因为虚函数表):
#include
class vclass{
public:
std::string name;
virtual std::string xxxx(){
return "哈哈哈哈";
}
virtual std::string get_name()=0;
};
那么现在保持子类不动,main也不编译,直接重新编译 lib
#makefile
lib:lib.h lib.cpp
g++ -g -fPIC -shared -o lib.so lib.cpp
现在运行main,你就会发现,我的get_name()呢?去哪了?怎么变成哈哈哈哈了?
这就是ABI的一个特点。
因为加入了新的一个虚函数,使得原来虚函数表的顺序由:
get_name;
变成了:
xxxx
get_name
但是main.cpp 中的obj->get_name(),在汇编中只是访问虚函数表中特定偏移量的函数,在这个例子中,访问的是虚函数表中第一个函数。 那么我们通过修改虚函数表的大小,就会导致函数运行出错。所以,一旦涉及到需要动态运行so的,就要考虑到abi了。业界比较常用的,保持C++ ABI兼容的方法是Q指针和D指针。