Skip to content

ABI ​

Application Binary Interface,应用程序二进制接口,“二进制” —— 这是规定了各个软件组件在二进制级别交互的规范。

比如对于一个 add(int, int) 方法,我们看到的是它需要传入什么,会得到什么效果,那么在我的源代码中如何调用它 —— 即 API;

但是在它编译之后,CPU 看到是什么参数放在什么寄存器上,如何找到它,如何进行堆栈的清理等,这就是 ABI 的范畴,引用一下百度百科 ——

应用二进制接口(ABI)描述应用程序与操作系统、应用与库、应用组成部分间的低层接口,实质为API编译版本

其实说的已经挺清楚,也就是 ABI 实际上描述的二进制模块之间如何彼此理解的规则。

例子 ​

函数调用 ​

举一个简单的例子,假设现在有 ——

C++
int Add(int a, int b);

然后在某种 ABI 规定下,规定(使用 x86-64 CPU 架构提供的通用寄存器)

  • a -> RDI
  • b -> RSI
  • ret -> RAX

然后 caller 调用 Add(1, 2) 生成了 ——

asm
mov rdi, 1
mov rsi, 2
call Add

然后 callee 实现是 ——

asm
Add:
    mov eax, edi
    add eax, esi
    ret

这就非常不错,实现方中是从这俩寄存器中读取,并且把结果压入一个寄存器中,然后调用方的规范也是如此,所以可以正常调用。假设二者编译出的结果是把参数放入不同的寄存器,返回值也是从不同的计算器中读写,那么整个程序就变得很“神秘”(

符号名称 ​

依旧给出简单小例子,众所周知 C++ 中有函数重载这样的高级功能

C++
int Add(int a, int b);
double Add(double a, double b);

那么在编译的时候,由于它们都叫做 Add,所以二进制下不能都这么叫,否则链接器不知道哪个对应哪个,于是在编译阶段,必须经过一次 "Name Mangling",那么假设现在没有任何的规范,调用方在编译的时候把当前代码翻译作

add_int
add_double

然后库实现的时候编译器 B 编译出来却是

_Z23Addi
_Z23Addd

那么库导出之后,链接器最后检查之时只能一脸懵逼然后抛出 "undefined symbol"

内存布局 ​

比如对于一个对象而言

C++
struct Player
{
    int hp;
    float speed;
};

访问 speed 这个成员的时候,其关于首地址的偏移量在编译过程中是已经确定的,比如说假设此处是 [player + 4]

然后在修改实现的时候新添加了 ——

C++
struct Player
{
    int hp;
    int level;
    float speed;
};

那么此处新的实现在访问 speed 的时候可能就需要翻译成 [player + 8],这很坏,也就是对于旧的依赖程序而言,如果不重新编译,那么通过 [player + 4] 访问到的成员就不是真正想要的东西了

维持稳定 ​

有时候我们会听到 “ABI 稳定性” 这样的说法 —— 它主要解决的是一个模块能否“独自升级”

比如现在我们的 main.exe 依赖一份动态库 math.dll,假设 math.dll 中的实现更新了,但是其 ABI 不变,也就是其空间布局,参数调用等什么都没有发生变化。那么非常好的,我们的 main.exe 就可以不需要重新编译,仅仅替换 math.dll 即可

因为它们共用一份契约 —— main.exe 在编译的时候已经说好了,在调用 Add 的时候要把两个参数放进哪两个寄存器中,而由于 math.dll 在实现 Add 方法的时候没有破坏这个 ABI 契约,依旧是从那两个寄存器中读取数据进行运算。

也就是使得我们写的程序,和其依赖的其他第三方库进行了一次“解耦”

综上 ​

ABI 是一种规范,帮助回答了这样一个问题 ——

两个彼此独立编译出来的二进制模块,如何对于同一段数据,同一次函数调用可以产生完全相同的“理解”

Released under the MIT License.