C++ ABI总结

2023-06-20 16:15:31 浏览数 (1)

本文由知乎答主我是龙套小果丁提供

前注:笔者在暑假时偶然关注到C 的ABI问题,对此进行了比较长时间的探究。事实上距离现在,这已经有比较长的时间;而写这篇文章的目的,一方面可以给其他想了解这个话题的人一点思路,权当抛砖引玉;另一方面更想与大家做以探讨,以防止对此概念产生误解而不自知,希望大家可以指正这篇文章的错误。我也十分希望可以有人推荐给我相关的精彩文章,让我更进一步地理解这一概念。

What is ABI?

按照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可以分为两个方面,我们也会按两方面讨论:

  • 语言ABI/编译器ABI。
  • 库的ABI(尤其是标准库的ABI)。

这是笔者之前在reddit的一个帖子上看到的分类,觉得很合理,但当时居然没有标记下来,如果有人确实需要看原帖,笔者可以找找。

自然的,因为库本身是由语言编写的,通常情况下语言ABI的改变都会使库的ABI不兼容。

Language ABI / Compiler 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个方面:

  • 对象布局(Object layout)
  • 数据类型的大小和对齐(Size and default alignment of data types)
  • 函数调用方式(Calling Convention)
  • 寄存器使用(Register usage convention)
  • 目标文件的格式(这里的格式指ELF / COFF等,不是产生的内容的格式)

但是对于C ,它的ABI还十分取决于编译器(我想这也是为什么Language ABI也称作compiler ABI)。也就是说,就算两个目标文件在以上方面都一致,而且符号表等也可互相识别,但他们仍可能链接出一个错误的可执行文件。这通常出现在用一个更早版本的编译器去链接更晚版本的编译器产生的目标文件,或者相同版本但选择了某些改变ABI的编译器选项的目标文件。

具体地,C 由编译器决定的ABI主要包括:

  • 名称修饰/重整(Name mangling):C 具有函数重载、模板、名称空间等,他们在目标文件中应该具有不同的名称,来让可执行文件可以调用到唯一的函数。将函数的名称变换为另一个唯一名称的过程称为名称修饰/重整;例如,对于函数 namespace Namespace {int function(int x);} ,在GCC中会修饰为_ZN9NameSpace8functionEi,而在MSVC中会修饰为?function@NameSpace@@YAHH@Z
  • 异常处理(Exception handling):例如在遇到异常时,栈如何展开(unwind)。
  • 调用构造/析构函数(Invoking ctor & dtor):规定了一个类的成员如何构造/析构,例如如何构造成员中的C数组。
  • class的布局和对齐,例如多继承中成员变量的排布。
  • 虚表的布局和对齐,例如虚函数在虚表中的顺序。

将修饰后的名称转化会原名称的过程称为demangle;一个demangle的网站是demangler.com/ 编译器决定的ABI的分类主要来自于GCC manual about compatibility.

C 的主流语言ABI应该有两套:

  • Itanium ABI;可见itanium-cxx-abi.github.io
  • MSVC的ABI;根据Herb Sutter的提案N4028,提到MSVC的语言ABI不公开,但是是相对稳定的(尽管标准库ABI经常变化)。笔者只找到Name mangling和Exception handling两个文档,其他的如果有人可以找到可以在评论区留言。

特别地,Clang好像有一些选项可以尽量(但不完全)兼容MSVC的ABI;见clang.llvm.org/docs/MSV。不知道GCC/MSVC有没有兼容其他ABI的选项?

Library ABI

由于编译器一般都使用供应商所提供的标准库实现,因此标准库的ABI也事实上成为了C ABI的一部分。具体地,如果一个动态库在更新后,原来的可执行文件仍然能正常地使用动态库的函数,而不需要让源代码重新编译,则称库的ABI保持了下去 / 二进制兼容。静态库本身应该不需要考虑这个问题,因为静态库更新之后总是需要重新编译。

  • MSVC使用的是STL(这里不是C 98的STL之意,但微软就起这个名字也没什么办法),具体到文件上就是msvcprtd。每个主要版本都会具有新的ABI,来尽快更新C 的新特性。根据微软官方文档,从VS2015(toolset v140)开始,MSVC保证后来版本的工具链总可以使用之前版本的ABI。
  • GCC使用的是libstdc ,根据这个库的编写团队的成员所说,这个库在5.1/7.1/8,1/9.1/11.1都发生了ABI变化。比较有名的是5.1中std::stringstd::list的ABI改变了(为了适应C 11关于COW的规定),造成在新编译器中链接之前的代码会运行崩溃(我觉得这是很多公司维持gcc版本在4.9的重要原因,防止老的库用不了,但似乎有些因噎废食)。
  • Clang使用的是libc ,根据libcxx.llvm.org/DesignD应该是只用2个ABI版本,可能快到3了。

这给库程序员造成很大的麻烦,因为C 程序员几乎不可避免使用标准库;如果要兼容所有版本,保险起见就需要每个ABI break的版本都提供新的库。如果想跨平台,还要考虑操作系统的问题;甚至可能需要考虑编译器选项的问题,之前笔者遇到过VS中Release模式编译的库在Debug模式使用会报warning。

Maintain library ABI compatibility

如果注意前面提到的几个方面,那么我们可以编写出一个二进制兼容的库。也就是说,在库更新后,一个实体根据它原来的索引方式仍然能索引到正确的实体:

  • 名称修饰:注意不要改变函数的名称,也不要改变const/volatile属性,因为用户代码在编译时是认为A名称,会找不到改为B名称的新符号。
  • 虚表:注意不要改变虚函数在类中的次序或增加基类的虚函数(但单纯增加无子类的类的虚函数应该有可能保持,只是用户调用不到)。
  • 调用方式:例如__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++

20 ABI breaking changeswww.acodersjourney.com/20-abi-breaking-changes

其次注意一下标准库的使用版本,也就考虑了标准库的ABI。

一种比较常见的维护ABI的技术是PImpl,这是一个比较重要的技术,像图形学中重要的模型库assimp就在代码中使用了这项技术,但是总体上来说比较简单,暂不是本文讨论的重点;如果有人想看,笔者可以单独写另外一篇文章。

0 人点赞