调用约定是一种定义函数从调用处接受参数以及返回结果的方法的约定。
不同调用约定的区别在于:
- 参数和返回值放置的位置(在寄存器中;在调用栈中;两者混合);
- 参数传递的顺序(或者单个参数不同部分的顺序);
- 调用前设置和调用后清理的工作,在调用者和被调用者之间如何分配;
- 被调用者可以直接使用哪一个寄存器有时也包括在内。(否则的话被当成ABI的细节);
- 哪一个寄存器被当作volatile的或者非volatile的,并且如果是volatile的,不需要被调用者恢复。
调用约定与名称修饰(名称修饰决定了代码中的符号名称如何映射到链接器中的符号名)和编程语言中对于大小和格式的分配紧密相关。调用约定,类型表示和名称修饰这三者的统称即应用二进制接口(ABI)。
不同编译器在实现这些约定总是有细微的差别存在,所以在不同编译器编译出来的代码很难接合起来。另一方面,有些约定被当作一种API标准(如stdcall),编译器实现都较为一致。
调用约定根据函数调用结束时进行清理的具体的对象是谁而分为被调用者清理、调用者清理和调用者或被调用者清理。
以下的调用约定是x86上的调用约定。
1. 调用者清理
调用者清理即调用者自己清理堆栈上的参数(arguments),这样就允许了可变参数列表的实现,如printf()。
1.2 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寄存器)。
另外不同编译器实现也有不同:
- Visual C++规定函数返回值如果是POD值且长度如果不超过32比特,用寄存器EAX传递;长度在33-64比特范围内,用寄存器EAX:EDX传递;长度超过64比特或者非POD值,则调用者为函数返回值预先分配一个空间,把该空间的地址作为隐式参数传递给被调函数;
- GCC的函数返回值都是由调用者分配空间,并把该空间的地址作为隐式参数传递给被调函数,而不使用寄存器EAX。GCC自4.5版本开始,调用函数时,堆栈上的数据必须以16B对齐(之前的版本只需要4B对齐即可)。
int __attribute__((__cdecl__)) add(int a, char b)
{
return a + b;
}
int main()
{
int a = 1;
char b = 2;
int ret = add(a, b);
ret += 5;
return 0;
}
使用gcc -O0 main.cpp -m32
生成汇编代码中,如下分别为函数add和主程序main:
!gcc 4.9
_Z3addic:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $4, %esp !esp=esp-4
movl 12(%ebp), %eax !eax=ebp+12
movb %al, -4(%ebp) !ebp-4=al
movsbl -4(%ebp), %edx !b=ebp-4=edx
movl 8(%ebp), %eax !a=eax=ebp+8
addl %edx, %eax !a+b
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
main:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $16, %esp
movl $1, -8(%ebp) !a=ebp-8=1
movb $2, -9(%ebp) !b=ebp-9=2
movsbl -9(%ebp), %eax !eax=b
pushl %eax !push b
pushl -8(%ebp) !push a
call _Z3addic !add(a, b)
addl $8, %esp !esp+=8
movl %eax, -4(%ebp) !ret=ebp-4=eax
addl $5, -4(%ebp) !ret+=5
movl $0, %eax !return 0
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
从上面的汇编可以看出参数的压栈顺序是从右向左:先将b存放到寄存器中,再处理a,并且b因为是char
类型,再进入到函数中时和int
进行了对齐。同时调用者负责清除栈中的局部变量(addl $8, %esp
)。
另外函数名为_Z3addic
其中ic
分别指代int,char
。
1.2 syscall
与cdecl
类似,参数被从右到左推入堆栈中。EAX, ECX和EDX
不会保留值。参数列表的大小被放置在AL
寄存器中。 syscall
是32位OS/2 API的标准。
1.3 optlink
参数也是从右到左被推入堆栈。从最左边开始的三个字符变元会被放置在EAX, EDX和ECX中,最多四个浮点变元会被传入ST(0)
到ST(3)
中,虽然这四个参数的空间也会在参数列表的栈上保留。函数的返回值在EAX或ST(0)中。保留的寄存器有EBP, EBX, ESI和EDI
。 optlink
在IBM VisualAge
编译器中被使用。
2. 被调用者清理
如果被调用者要清理栈上的参数,需要在编译阶段知道栈上有多少字节要处理。因此,此类的调用约定并不能兼容于可变参数列表,如printf()。
然而,这种调用约定也许会更有效率,因为需要解堆栈的代码不要在每次调用时都生成一遍。 使用此规则的函数容易在asm代码被认出,因为它们会在返回前解堆栈。x86 ret指令允许一个可选的16位参数说明栈字节数,用来在返回给调用者之前解堆栈。
2.1 pascal
基于Pascal
语言的调用约定,参数从左至右入栈(与cdecl
相反)。被调用者负责在返回前清理堆栈。 此调用约定常见在如下16-bit 平台的编译器:OS/2 1.x,微软Windows 3.x,以及Borland Delphi版本1.x。
2.2 register
Borland fastcall
的别名。
2.3 stdcall
stdcall
是由微软创建的调用约定,是Windows API的标准调用约定。非微软的编译器并不总是支持该调用协议。
stdcall
是Pascal调用约定与cdecl
调用约定的折衷:被调用者负责清理线程栈,参数从右往左入栈。其他各方面基本与cdecl相同。但是编译后的函数名后缀以符号"@",后跟传递的函数参数所占的栈空间的字节长度。寄存器EAX, ECX和EDX
被指定在函数中使用,返回值放置在EAX
中。stdcall
对于微软Win32 API和Open Watcom C++是标准。
微软的编译工具规定:PASCAL, WINAPI, APIENTRY, FORTRAN, CALLBACK, STDCALL, __far __pascal, __fortran, __stdcall
均是指此种调用约定。
同样是上面的程序,汇编如下:
!gcc
_Z3addic:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $4, %esp !esp=esp-4
movl 12(%ebp), %eax !
movb %al, -4(%ebp) !b=al
movsbl -4(%ebp), %edx !edx=b
movl 8(%ebp), %eax !eax=a
addl %edx, %eax !a+b
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret $8
.cfi_endpro
main:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $16, %esp
movl $1, -8(%ebp) !a=ebp-8=1
movb $2, -9(%ebp) !b=ebp-9=2
movsbl -9(%ebp), %eax !eax=b
pushl %eax !push b
pushl -8(%ebp) !push a
call _Z3addic
movl %eax, -4(%ebp) !ret=ebp-4
addl $5, -4(%ebp) !ret+=5
movl $0, %eax !return 0
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
可以清晰的看到参数的入栈顺序是从右向左,由被调用者自动清除栈,但是可以看到gcc
下函数名称并未用@
。
2.4 fastcall
此约定还未被标准化,不同编译器的实现也不一致。
2.4.1 Microsoft/GCC fastcall
Microsoft
或GCC
的__fastcall
约定(也即__msfastcall
)把第一个(从左至右)不超过32比特的参数通过寄存器ECX/CX/CL
传递,第二个不超过32比特的参数通过寄存器EDX/DX/DL
,其他参数按照自右到左顺序压栈传递。
2.4.2 Borland fastcall
从左至右,传入三个参数至EAX, EDX
和ECX
中。剩下的参数推入栈,也是从左至右。 在32位编译器Embarcadero Delphi
中,这是缺省调用约定,在编译器中以register
形式为人知。 在i386上的某些版本Linux也使用了此约定。
!gcc
_Z3addic:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $8, %esp
movl %ecx, -4(%ebp) !a=ecx
movl %edx, %eax !eax=edx
movb %al, -8(%ebp) !b=al
movsbl -8(%ebp), %edx !edx=b
movl -4(%ebp), %eax !eax=a
addl %edx, %eax !a+b
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
main:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $16, %esp
movl $1, -8(%ebp) !a=ebp-8=1
movb $2, -9(%ebp) !b=ebp-9=2
movsbl -9(%ebp), %edx !edx=b
movl -8(%ebp), %eax !eax=a
movl %eax, %ecx !ecx=eax=a
call _Z3addic
movl %eax, -4(%ebp)
addl $5, -4(%ebp) !清理工作
movl $0, %eax
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
从上面的可以看出fastcall
会优先使用寄存器,并且由调用者清除栈。
3. 调用者或被调用者清理
3.1 thiscall
在调用C++非静态成员函数时使用此约定。基于所使用的编译器和函数是否使用可变参数,有两个主流版本的thiscall
。
- 对于GCC编译器,
thiscall
几乎与cdecl
等同:调用者清理堆栈,参数从右到左传递。差别在于this
指针,thiscall
会在最后把this
指针推入栈中,即相当于在函数原型中是隐式的左数第一个参数。 - 在微软Visual C++编译器中,
this
指针通过ECX
寄存器传递,其余同cdecl约定。当函数使用可变参数,此时调用者负责清理堆栈(参考cdecl)。thiscall
约定只在微软Visual C++ 2005
及其之后的版本被显式指定。其他编译器中,thiscall
并不是一个关键字(反汇编器如IDA使用__thiscall
)。
对上面的代码修改如下:
class test{
public:
int __attribute__((__thiscall__)) add(int a, char b)
{
return a + b;
}
};
int main()
{
test t;
int a = 1;
char b = 2;
int ret = t.add(a, b);
ret += 5;
return 0;
}
编译得到的汇编代码如下:
!gcc
_ZN4test3addEic:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $8, %esp
movl %ecx, -4(%ebp)
movl 12(%ebp), %eax
movb %al, -8(%ebp) !b=al
movsbl -8(%ebp), %edx !edx=b=al
movl 8(%ebp), %eax !a=ebp+8
addl %edx, %eax !a+b
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret $8
.cfi_endproc
main:
.LFB1:
.cfi_startproc
leal 4(%esp), %ecx
.cfi_def_cfa 1, 0
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
.cfi_escape 0x10,0x5,0x2,0x75,0
movl %esp, %ebp
pushl %ecx
.cfi_escape 0xf,0x3,0x75,0x7c,0x6
subl $20, %esp
movl %gs:20, %eax
movl %eax, -12(%ebp)
xorl %eax, %eax
movl $1, -20(%ebp) !a=ebp-20=1
movb $2, -21(%ebp) !b=ebp-21=2
movsbl -21(%ebp), %edx !edx=b
leal -22(%ebp), %eax !eax=&(ebp-22)
subl $8, %esp
pushl %edx !push b
pushl -20(%ebp) !push a
movl %eax, %ecx !ecx=&t
call _ZN4test3addEic
addl $8, %esp !清理工作
movl %eax, -16(%ebp) !ret
addl $5, -16(%ebp) !ret+=5
movl $0, %eax
movl -12(%ebp), %ecx
xorl %gs:20, %ecx
je .L5
call __stack_chk_fail
.L5:
movl -4(%ebp), %ecx
.cfi_def_cfa 1, 0
leave
.cfi_restore 5
leal -4(%ecx), %esp
.cfi_def_cfa 4, 4
ret
.cfi_endproc
可以看到入栈顺序为从右向左,且最后会压入test实例的地址,清理工作由调用者进行。
4 其他
4.1 WINAPI
WINAPI
是平台的缺省调用约定。Windows
操作系统上默认是StdCall
;Windows CE
上默认是Cdecl
。
4.2 x86-64调用约定
4.2.1 微软x86-64调用约定
在Windows x64环境下编译代码时,只有一种调用约定,也就是说32位下的各种约定在64位下统一成一种了。
微软x64调用约定使用RCX, RDX, R8, R9四个寄存器用于存储函数调用时的4个参数(从左到右),使用XMM0, XMM1, XMM2, XMM3来传递浮点变量。其他的参数直接入栈(从右至左)。整型返回值放置在RAX中,浮点返回值在XMM0中。少于64位的参数并没有做零扩展,此时高位充斥着垃圾。
在微软x64调用约定中,调用者的一个职责是在调用函数之前(无论实际的传参使用多大空间),在栈上的函数返回地址之上(靠近栈顶)分配一个32字节的“影子空间”;并且在调用结束后从栈上弹掉此空间。影子空间是用来给RCX, RDX, R8和R9提供保存值的空间,即使是对于少于四个参数的函数也要分配这32个字节。
例如, 一个函数拥有5个整型参数,第一个到第四个放在寄存器中,第五个就被推到影子空间之外的栈顶。当函数被调用,此栈用来组成返回值----影子空间32位+第五个参数。
在x86-64体系下,Visual Studio 2008在XMM6和XMM7中(同样的有XMM8到XMM15)存储浮点数。结果对于用户写的汇编语言例程,必须保存XMM6和XMM7(x86不用保存这两个寄存器),这也就是说,在x86和x86-64之间移植汇编例程时,需要注意在函数调用之前/之后,要保存/恢复XMM6和XMM7。
4.2.2 System V AMD64 ABI
此约定主要在Solaris,GNU/Linux,FreeBSD和其他非微软OS上使用。头六个整型参数放在寄存器RDI, RSI, RDX, RCX, R8和R9上;同时XMM0到XMM7用来放置浮点变元。对于系统调用,R10用来替代RCX。同微软x64约定一样,其他额外的参数推入栈,返回值保存在RAX中。 与微软不同的是,不需要提供影子空间。在函数入口,返回值与栈上第七个整型参数相邻。
64bit程序添加任何的调用约定都会报告该约定被忽略的warning。
//此处添加任何约定都会被忽略掉
int add(int a, char b)
{
return a + b;
}
int main()
{
int a = 1;
char b = 2;
int ret = add(a, b);
ret += 5;
return 0;
}
_Z3addic:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp) !a=edi
movl %esi, %eax !eax=esi
movb %al, -8(%rbp) !b=eax
movsbl -8(%rbp), %edx !edx=b
movl -4(%rbp), %eax !eax=a
addl %edx, %eax !a+b
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $1, -8(%rbp) !a=rbp-8=1
movb $2, -9(%rbp) !b=rbp-9=2
movsbl -9(%rbp), %edx !edx=b
movl -8(%rbp), %eax !eax=a
movl %edx, %esi !esi=b
movl %eax, %edi !edi=a
call _Z3addic
movl %eax, -4(%rbp) !ret
addl $5, -4(%rbp) !ret=ret+5
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
可以看到linux上64位调用约定压栈顺序为从从右向左,并由被调用者自行清理栈。
5 总结
调用约定 | 压栈顺序 | 谁清除栈上参数 | 可变参数个数的函数 | 函数名修饰规范 |
---|---|---|---|---|
cdecl | 从右向左 | 调用者 | 支持 | 下划线+函数名 |
syscall | 从右向左 | 调用者 | 支持 | 下划线+函数名 |
syscall | 从右向左 | 被调用者 | 不支持 | 下划线+函数名+@+参数的字节数 |
fastcall | 从左向右 | 被调用者 | 不支持 | @+函数名+@+参数的字节数 |
关于函数名修饰规范我并未复现出来,具体来源于网上。