ABI 二进制接口介绍

2019-04-10 linux c/cpp

如果将编译后的代码反汇编,会发现传参过程中有很多寄存器相关的操作,包括了传参、数据的清理等等。

那么具体是怎么工作的?为什么要按照这一规则?

简介

应用二进制接口 Application Binary Interface, ABI 用来规范程序模块之间的接口,主要包括了:A) 调用约定,参数、返回值如何传递等;B) 类型表示,大小、布局、对齐方式等;C) 名称修饰,例如 C++ 的名称规范等。

这里仅介绍调用过程的约定。

标准介绍

不同编译器实现时略有区别,这导致不同编译器的代码无法混合使用;有些被作为 API 标准 (例如微软的 stdcall 调用约束,通过 Windows API 标准进行约束) ,那么编译器的实现基本一致。

其中 gcc 通常有三种规范 cdecl fastcall stdcall ,这里仅介绍常见的 cdecl ,是 C 语言的一种调用约束,也是 C 语言的事实上的标准。

cdecl

其全称为 C Declaration ,由调用者自己清理堆栈上的参数,采用从右至左的顺序入栈,这样就可以实现可变参数列表,例如 printf()

在 x86 架构上,其内容包括:

  • 函数实参在线程栈上按照从右至左的顺序依次压栈。
  • 函数结果保存在寄存器 EAX/AX/AL 中,浮点型存放在寄存器 ST0 中。
  • 调用者负责从线程栈中弹出实参,也就是清理传参时的栈信息。
  • 将 1 字节或者 2 字节的整型参数提升为 4 字节。

栈帧 Stack Frame

每次函数的调用,都会在栈上维护一个独立的栈帧,栈从高地址向低地址延伸,通过 ebp 指向当前的栈帧底部,esp 始终指向栈顶的顶部,所以,ebp 被称为 “帧指针”,而 esp 被称为 “栈指针”。

栈帧中主要包括了:

  • 函数的返回地址、栈基址和参数,严格来说应该是上个栈帧的;
  • 临时变量,包括了函数内定义的局部变量,以及编译器自动生成的其它临时变量;

与栈帧操作相关的就是函数的调用以及数据的返回。

函数调用

在函数的调用过程中,包括了调用函数 (Caller) 以及被调用函数 (Callee) 有函数的调用者(caller)和被调用的函数(callee)。调用者需要知道被调函数的返回值。被调用者需要知道传入的参数和返回地址。 分为以下几步:

调用者只需要将参数按照规范传入即可。

  1. 参数入栈,如果小于等于6个会直接只用寄存器;
  2. 调用 call 指令,改指令会将当前下一条指令压栈,并跳转到对应函数地址执行;

被调用函数会进行如下处理。

  1. 保存上个函数的栈基址,也就是将 rbp 压栈,对应 pushq %rbp 指令,用来在函数结束时恢复 Caller 的栈帧;
  2. 切换栈帧,直接将当前栈顶 rsp 赋值给 rbp 即可,也就是 movq %rsp, %rbp

接下来不同的函数就会有不同的操作,如果函数包含了函数内的临时变量,那么会通过 sub esp 给新栈分配空间;也可能会先将通过寄存器传递的参数保存到当前栈中,也即是 movl %edi, -4(%rbp) 类似的指令。

函数返回

相对来说,函数的返回要简单很多。

  1. 将计算结果保存到 eax 寄存器中;
  1. 恢复之前函数的栈帧信息,也就是恢复帧指针 popq %rbp
  2. 调用 ret 指令返回,会自动从栈中获取地址,并跳转到改地址执行。

总结

所以,通常在被调用函数中会执行如下的语句。

pushq %rbp
movq  %rbp, %rsp

这样基本的栈就如上所示,那么每个函数的栈信息,实际上会有两个地址位的空白,而当设置好了 rbp 之后,在函数中就不会再修改该寄存器的值。