谈一谈 ABI, C++ ABI, Rust ABI 的稳定性 (上)
本文写于 2023 年 9 月 1 日.
什么是 ABI
ABI 是 Application Binary Interface, 指两个二进制程序模块之间的接口.
C ABI
二进制代码通过链接器 (linker) 链接到一起, 成为一个完整的程序. 为了让不同的二进制程序可以一起工作, 人们设计了一个二进制程序的规范. 比如有下面这几点:
- 处理器可以使用的指令集.
- 处理器可以处理的基础类型/大小/对齐方式.
- 函数调用应该怎么传参, 用哪些寄存器, 怎么把参数入栈/出栈, 返回值放在哪个寄存器里.
- 二进制文件的格式
- 系统调用的接口
动态链接的语义等等. 在 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).