添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
首发于 smy的笔记

谈一谈 ABI, C++ ABI, Rust ABI 的稳定性 (上)

本文写于 2023 年 9 月 1 日.

什么是 ABI

ABI 是 Application Binary Interface, 指两个二进制程序模块之间的接口.

C ABI

二进制代码通过链接器 (linker) 链接到一起, 成为一个完整的程序. 为了让不同的二进制程序可以一起工作, 人们设计了一个二进制程序的规范. 比如有下面这几点:

  1. 处理器可以使用的指令集.
  2. 处理器可以处理的基础类型/大小/对齐方式.
  3. 函数调用应该怎么传参, 用哪些寄存器, 怎么把参数入栈/出栈, 返回值放在哪个寄存器里.
  4. 二进制文件的格式
  5. 系统调用的接口

动态链接的语义等等. 在 x86-64 Linux 上, 程序都遵守 System V ABI, 这才让跨二进制的调用成为可能. 我们继续以 x86-64 Linux 介绍.

数据在内存中的布局 Data Layout

编译器把 C 语言翻译成满足 System V ABI 的二进制代码. 对于结构体而言, 我们还需要确定它在内存中布局.

比如这样一个结构

struct A {
    int x;
    int y;
    long z;
} a;

那么 a.y 这个表达式会被翻译为: 求出 a 结构体首地址, 加上 y A 结构体中的偏移量. 然后把在这个位置取出 4 个字节, 再把这四个字节按照 int 类型来理解.

所以说, y A 中的偏移量这个事情是非常重要的. clang 编译器可以帮你展示类的布局, 加上 -fdump-record-layouts 选项就可以.

*** Dumping AST Record Layout
         0 | struct A
         0 |   int x
         4 |   int y
         8 |   long z
           | [sizeof=16, align=8]

可以看出每一个成员的偏移量. 这里不介绍具体规则, 但是确实有这样一个规则, 才让编译器每次看到长得一样的 A, 会有一样的偏移量.

C++ 和 Itanium C++ ABI

后来有了 C++. 早期 C++ 是先翻译成 C, 再由 C 编译器翻译成二进制. 既然已经是合法的 C code, 那么自然解决了链接的问题.

我在这里不深入介绍, 只是举一些例子来展示 C++ 如何改变了 ABI, C++ ABI 又指什么.

第一个例子

大家知道 C 函数不支持参数重载, C++ 函数支持参数重载. 比如

void foo(int) {}
void foo(double) {}
void bar() {
    foo(1);
    foo(1.0);

为了翻译成合法的 C 代码, 一种可能的实现就是变成如下 C 代码

void foo__int(...) {}
void foo__double(...) {}
void bar() {
    foo__int(1);
    foo__double(1.0);
}

于是最后生成的二进制文件里面就会有 foo__int foo__double 两个函数. 完全正常, 可以工作.

但试想我们用两个不兼容的编译器编译, 编译器 1 翻译成

void foo_int(...) {}
void foo_double(...) {}

编译器 2 翻译成

void foo___int(...);
void foo___double(...);
void bar() {
    foo___int(1);
    foo___double(1.0);
}

那结局就是链接器找不到 foo___int 这个函数, 因为编译器 1 生成的函数是 foo_int 而不是 foo___int .

这个看似不太会发生, 但在 C++ 标准化前确实是实实在在发生的, 把 foo 变成 foo__int 的过程叫做 name mangling, 中文叫命名修饰. 不同的编译器可以有不同的命名修饰规范, 比如 MSVC 和 gcc 的命名修饰规范就不同.

第二个例子

虚函数. C++ 有了虚函数机制.

struct A {
    virtual void foo() {}
struct B : A {
    virtual void foo() {}
void bar(A* _a) {
    _a->foo(); // 到运行时才知道这里是哪个 foo
int main() {
    A a; B b;
    bar(&a); // 调用 A 的 foo
    bar(&b); // 调用 B 的 foo

可以用如下 C 的伪代码翻译

struct A;
struct B;
void foo__A(A*) {}
void foo__B(A*) {}
struct VTable {
    void (*foo) (A*);
struct VTable _vtable_a { .foo = &foo__A }; // 全局变量
struct VTable _vtable_b { .foo = &foo__B }; // 全局变量
struct A {
    struct VTable* vtable = &_vtable_a; // 放在对象头部
struct B {
    struct VTable* vtable = &_vtable_b; // 放在对象头部
void bar(struct A* _a) {
    // 先取出 _a 最前面的虚表指针
    VTable* vtable = _a.vtable;
    // 再调用
    (vtable.foo)(_a);
}

来进行. 我们在 Linux 内核中会发现这种手写虚表的动态派发方式非常常见. 当然因为多重继承的存在, C++ 中虚表的机制远比这里展示的复杂. 对于 clang 编译器, 我们也可以用 -fdump-vtable-layouts 来导出虚表布局.

*** Dumping AST Record Layout
         0 | struct A
         0 |   (A vtable pointer)
           | [sizeof=8, dsize=8, align=8,
           |  nvsize=8, nvalign=8]
Vtable for 'A' (3 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::foo()
VTable indices for 'A' (1 entries).