ABI的调用约定,类型表示和名称修饰

ABI的调用约定,类型表示和名称修饰

首先先看下面的一张架构图:
在这里插入图片描述

为什么要懂调用约定

  1. 知道汇编的工作方式以及特定体系结构的调用约定的工作方式是一项极为重要的技能。它使您可以观察没有源代码的函数参数,并可以修改传递给函数的参数。此外,有时甚至最好进入汇编级别,因为您的源代码可能对您不知道的变量使用不同或未知的名称(译注:例如C++使用了函数签名,使C++支持函数重载)。来自Assembly Register Calling Convention Tutorial

  2. 大多数情况下程序员不需要考虑上面这些东西,但出问题的时候才会发现有些还是应该知道的。我之所以了解这些正是因为遇到了相关的问题。

    在一个汇编语言与C语言混合编程的项目中,我用Visual Studio生成程序时32位配置下能成功,64位配置下却出现链接错误。 找了好久才发现原因正是上面提到的一点:64环境下Visual Studio没有对C语言进行名称修饰。汇编代码是不经编译的,自然也没有名称修饰这一步, 所以里面程序员对函数的命名就应该是修饰后的版本;当它与C代码的修饰规则不一致时,二者一起链接就会提示找不到符号。 因此,在32位和64位下汇编代码中函数的命名要分别处理。如果早知道了本文前面介绍的内容,就很容易定位这种问题的原因了。

    另外一个编程实践是大多数人都已经知道的,即在C++代码里调用C库函数的话,需要用extern "C"把调用的C函数声明包起来(很多C头文件干脆会在所有函数声明外面特意为C++包上这句)。 这是用来告诉C++编译器,这些函数是C语言的,不要对其进行名称修饰;一旦按C++的方法进行了修饰,修饰后的函数名称就在C库里面找不到了,链接时会出问题。 不过这是C语言发展为C++时不可避免的问题,相比于那些因为不同实现厂商导致的复杂状况,这个容易接受多了。来自C/C++中的调用约定和名称修饰

如果懂汇编,或者有分析过函数调用栈帧的汇编语句,就可以深深体会ABI了

  • 上面的图很容易看懂。
  • ABI规范的基础数据类型及大小:在硬件CPU上面,肯定是ABI规范的基础数据类型大小。随着CPU硬件架构的不同,基础数据类型可能也不同。
  • 类型适配层: 但是我们还要写程序,为了方便写程序,在ABI规范的上面肯定要有一个适配层。这个适配层是将ABI规范层重新定义成我们方便写代码的样子。并且平台改变时只需要重新定义类型适配层。typedef就是属于类型适配层。类型及大小不随平台而改变。
  • 应用程序框架层与应用程序逻辑层:我们写代码最接近的就是这两层了,尤其是逻辑层,你写代码写的大多是逻辑层。牛逼点的可能去写框架。

上面的类型适配层可能没有讲明白。其实就是针对ABI规范的基础数据类型,自己给重新定义一下。比如基础数据类型int是4字节,我们可以使用typedef int INT,这样我们以后写代码可以就完全使用INT代表4字节的整形。 但是如果换了一个平台,假如这个平台没有int基础数据类型,假设它有一个叫做newint 的基础数据类型是4字节整形(当然这是我们假设的,实际上没有)。此时我们只需要将typedef int INT修改为typedef newint INT。而我们的应用程序框架和应用程序代码,就完全不用修改。这大大地提高了我们的应用程序的移植性。

说到这里,应该明白了为什么本节的小标题叫做ABI与移植性了吧。如果不懂,再看两遍。

这里给出两个代码,下面配合下面的概念,有不懂的可以翻上来对照着看

  • C程序
void add (int, int, int);

main(void)
{
	int a = 1;
	int b = 2;
	int c = 0;

	add(a,b,c);
	c++;
}

void add(int a, int b, int c)
{
	c = a + b;
}
  • 编译后的汇编程序
mov bp,sp
sub sp,6
mov word ptr [bp-6],0001	:int a
mov word ptr [bp-4],0001	:int b
mov word ptr [bp-2],0001	:int c
push [bp-2]
push [bp-4]
push [bp-6]
call ADDR
add sp,6
inc word ptr [bp-2]

ADDR:
	push bp
	mov bp,sp
	mov ax,[bp+4]
	add ax,[bp+6]
	mov [bp+8],ax
	mov sp,bp
	pop bp
	ret

根据以上汇编画出函数调用过程中,栈的动态变化过程,之后,会深刻理解ABI。

以下部分内容搬运自维基,详见X86调用约定

调用约定

调用约定并不是编程语言本身规定的内容,而是实现相关的,也就是编译器在将源程序转换为二进制时才涉及的。 不同的编译器对函数调用的实现是不同的,包括如何传递参数和返回结果,以及如何清理栈,等等。具体如何实现还与平台(例如有多少寄存器可用)有关。 区分这些不同做法的就是调用约定。

在有些平台上,例如ARM上,调用约定真的是大家约好的,也就是各家编译器都遵循唯一且同样的函数调用实现方法。

但在另一些平台上,例如x86上,却存在多种调用约定。常见的__stdcall, __cdecl, __fastcall, WINAPI
这些东西都是来指定x86上的调用约定的。WINAPI被定义为__stdcall;__cdecl是大多数编译器生成32位x86程序的默认调用约定;__fastcall则是生成64位x86程序(x64)的默认调用约定。

以上引自C/C++中的调用约定和名称修饰

调用约定描述了被调用代码的接口:

  • 参数的分配顺序
  • 参数是如何被传递的(放置在堆栈上,或是寄存器中,亦或两者混合)
  • 被调用者应保存调用者的哪个寄存器
  • 调用函数时如何为任务准备堆栈,以及任务完成如何恢复

调用约定是一种定义子过程从调用处接受参数以及返回结果的方法的约定。不同调用约定的区别在于:

  • 参数和返回值放置的位置(在寄存器中;在调用栈中;两者混合)
  • 参数传递的顺序(或者单个参数不同部分的顺序)
  • 调用前设置和调用后清理的工作,在调用者和被调用者之间如何分配
  • 被调用者可以直接使用哪一个寄存器有时也包括在内。(否则的话被当成ABI的细节)
  • 哪一个寄存器被当作volatile的或者非volatile的,并且如果是volatile的,不需要被调用者恢复

这与编程语言中对于大小和格式的分配紧密相关。另一个密切相关的是名称修饰,这决定了代码中的符号名称如何映射到链接器中的符号名。调用约定,类型表示和名称修饰这三者的统称,即是众所周知的应用二进制接口(ABI)。

调用者清理
在这些约定中,调用者自己清理堆栈上的参数(arguments),这样就允许了可变参数列表的实现,如printf()。

  • cdecl

cdecl(C declaration,即C声明)是源起C语言的一种调用约定,也是C语言的事实上的标准。在x86架构上,其内容包括:

  • 函数实参在线程栈上按照从右至左的顺序依次压栈
  • 函数结果保存在寄存器EAX/AX/AL中
  • 浮点型结果存放在寄存器ST0中
  • 编译后的函数名前缀以一个下划线字符
  • 调用者负责从线程栈中弹出实参(即清栈)
  • 8比特或者16比特长的整形实参提升为32比特长。
  • 受到函数调用影响的寄存器(volatile registers):EAX, ECX, EDX, ST0 - ST7, ES, GS
  • 不受函数调用影响的寄存器: EBX, EBP, ESP, EDI, ESI, CS, DS
  • RET指令从函数被调用者返回到调用者(实质上是读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)
int callee(int, int, int);
  int caller(void)
  {
      register int ret;
      
      ret = callee(1, 2, 3);
      ret += 5;
      return ret;
  }

在x86上, 会产生如下汇编代码(AT&T 语法):

 .globl  caller
  caller:
        pushl   %ebp
        movl    %esp,%ebp
        pushl   $3
        pushl   $2
        pushl   $1
        call    callee
        addl    $12,%esp
        addl    $5,%eax
        leave
        ret

cdecl调用约定通常作为x86 C编译器的默认调用规则,许多编译器也提供了自动切换调用约定的选项。如果需要手动指定调用规则为cdecl,编译器可能会支持如下语法:

return_type _cdecl funct();
  1. syscall

与cdecl类似,参数被从右到左推入堆栈中。EAX, ECX和EDX不会保留值。参数列表的大小被放置在AL寄存器中。 syscall是32位OS/2 API的标准。

被调用者清理
如果被调用者要清理栈上的参数,需要在编译阶段知道栈上有多少字节要处理。因此,此类的调用约定并不能兼容于可变参数列表,如printf()。然而,这种调用约定也许会更有效率,因为需要解堆栈的代码不要在每次调用时都生成一遍。 使用此规则的函数容易在asm代码被认出,因为它们会在返回前解堆栈。x86 ret指令允许一个可选的16位参数说明栈字节数,用来在返回给调用者之前解堆栈。代码类似如下:

ret 12

指令ret n的含义用汇编语法描述为:

pop ip
add sp,n

因为用栈传递参数,所以调用者在调用程序的时候要向栈中压入参数,子程序在返回的时候可以用ret n指令将栈顶指针修改为调用前的值。

  • stdcall

stdcall是由微软创建的调用约定,是Windows API的标准调用约定。非微软的编译器并不总是支持该调用协议。GCC编译器如下使用:

int __attribute__((__stdcall__ )) func()

寄存器EAX, ECX和EDX被指定在函数中使用,返回值放置在EAX中。

  • Microsoft/GCC fastcall
    Microsoft或GCC的__fastcall约定(也即__msfastcall)把第一个(从左至右)不超过32比特的参数通过寄存器ECX/CX/CL传递,第二个不超过32比特的参数通过寄存器EDX/DX/DL,其他参数按照自右到左顺序压栈传递。

  • register
    Borland fastcall的别名。

  • Borland fastcal

从左至右,传入三个参数至EAX, EDX和ECX中。剩下的参数推入栈,也是从左至右。 在32位编译器Embarcadero Delphi中,这是缺省调用约定,在编译器中以register形式为人知。 在i386上的某些版本Linux也使用了此约定。

调用者或被调用者清理

  • Intel ABI
    根据Intel ABI,EAX、EDX及ECX可以自由在过程或函数中使用,不需要保留。

x86-64调用约定
x86-64调用约定得益于更多的寄存器可以用来传参。而且,不兼容的调用约定也更少了,不过还是有2种主流(windows和Linux)的规则。

这里只关注Linux的

  • System V AMD64 ABI

此约定主要在Solaris,GNU/Linux,FreeBSD和其他非微软OS上使用。头六个整型参数放在寄存器RDI, RSI, RDX, RCX, R8和R9上;同时XMM0到XMM7用来放置浮点变元。对于系统调用,R10用来替代RCX。同微软x64约定一样,其他额外的参数推入栈,返回值保存在RAX中。 与微软不同的是,不需要提供影子空间。在函数入口,返回值与栈上第七个整型参数相邻。

名称修饰:符号修饰(name decoration)或称符号改编(name mangling)

我们在源程序中起的函数名经过编译后会被修改,这就是名称修饰。

名称修饰是编译器为了满足链接时名称的唯一性要求采取的一种手段。一些语言(例如C++)支持命名空间、函数重载等特性,使得程序员可以对不同的函数使用同一个标识符。这些函数在编译时就要经过名称修饰来作区分,例如通常把函数的参数类型和返回值类型加入函数名称中。

名称修饰规则没有标准方式,所以不同的编译器(甚至同一编译器的不同版本,或相同编译器在不同平台上)的名称修饰规则都截然不同。

本来像C语言这样简单纯粹的语言是不需要名称修饰的,但像微软这样喜欢把简单问题复杂化的公司则反其道而行之。微软之所以要给C语言也进行名称修饰竟然是为了支持多种调用约定:在链接时,通过观察修饰后的名称来判断是哪种调用约定的函数!不过微软这种做法只针对32位环境,在64位环境下又取消了C语言的名称修饰。

以上引自链接C/C++中的调用约定和名称修饰
以下引用自该链接 符号表

在链接中,我们将函数和变量统一称为符号(Symbol),函数名或者变量名就是符号。

正因为链接时符号作为各个目标文件的的链接的主要的依据,因此管理好目标文件的符号非常重要。在可执行文件中将符号统一交由符号表(Symbol Table)进行管理。

在编译后,C 会将符号保存至符号表中,且符号是用于链接同一个函数或变量的唯一标志,也就是说相同的一个程序中不可以拥有两个相同函数的实现。

但这种方式导致了另外一个问题,一旦 C程序变得庞大,函数或者全局变量的命名重名变得难以避免。当引用到其他的库时,需要时刻小心函数命名以防出现函数重名便需要非常的小心。这是C函数的一个历史包袱,为避免这种情况,一般的C 函数库都加上特定的前缀进行区分。

但这种原始简单的区分方式只能暂时避免符号重名的情况,并不能根本的解决这问题。为解决这个问题,目前大部分新出的语言都提出了称为命名空间的方式用以解决这个问题,同样作为 C 语言的升级版 C++ 也通过支持命名空间(namespace)的方式解决符号冲突的问题。

我们知道 C++ 语言支持函数重载,也支持两个不同类中可以声明相同函数名的函数。这其实是通过符号修饰(name decoration)或称符号改编(name mangling)来实现的。

在这里插入图片描述
终极参考
【软件开发底层知识修炼】二十一 ABI-应用程序二进制接口一

发布了42 篇原创文章 · 获赞 18 · 访问量 7549

猜你喜欢

转载自blog.csdn.net/weixin_44395686/article/details/105010298