准确的说,函数调用约定是C/C++到汇编语言的翻译阶段要考虑的时候。在32位的系统上,这种调用约定有很多种方式,本文不考虑所谓的fastcall stdcall cdcel三种调用方式的区别,而以现代编译器在64位体系架构下默认流行的方式进行描述和总结。调用约定描述了函数之间变量的传递规则。如果使用汇编语言编程,你需要知道在哪些寄存器中放置该函数需要使用的值。另外,在构建高性能底层算法库的时候,需要将核心的代码做汇编级的加速,此时需要严格遵循这些约定,需要确定哪些寄存器用于哪些函数的参数。所幸的是,早就有一套统一的标准来规范这些事情,但在不同的平台或系统上还是有很大的不同。例如对于32位的ARM架构和32位的x86架构则完全不同.其中32位arm架构的参数传递规则,可以参考笔者早年之前的一篇文章总结,具体见参考文献1. 本文主要总结64位x64架构和64位 arm-cortex架构下的参数传递规则。
Linux平台 AMD64 ABI调用约定
- 非浮点参数传递规则
第一个参数 | RDI |
---|---|
第二个参数 | RSI |
第三个参数 | RDX |
第四个参数 | RCX |
第五个参数 | R8 |
第六个参数 | R9 |
更多的参数通过栈以相反的顺序传递,这样就可以按照正确的顺序弹出。例如,一个函数有10个参数,则依次压入第10个参数,第9个参数,第8个参数,第7个参数。同时,要记住每压入一个参数,栈指针RSP将减少8个字节。压入第7个参数之后,栈指针较小了8x4等于32个字节。之后的指令是调用函数,此时RIP指令被压入栈,保存了函数的返回地址,同时栈指针又减少了8个字节。此时栈指针总共较少了40个字节,但还必须符合栈按照16字节的边界对其的要求,因此还可能需要将RSP较小8个字节,总共达到48个字节的位置。
- 浮点参数传递规则
浮点型参数通过xmm寄存器来传递,如果参数多于8个,则仍然通过栈来传递。
第一个参数 | XMM0 |
---|---|
第二个参数 | XMM1 |
第三个参数 | XMM2 |
第四个参数 | XMM3 |
第五个参数 | XMM4 |
第六个参数 | XMM5 |
第七个参数 | XMM6 |
第八个参数 | XMM7 |
- 关于函数返回值
无论是linux平台还是windows平台,函数使用xmm0返回浮点运算的结果,使用RAX保存整数或地址的返回值。
Windows平台Microsoft x64调用约定
windows平台只使用4个寄存器来保存参数,更多的参数要保存到栈里面以此完成参数的传递。
参数位置 | int/pointer/obj /array | float/double |
---|---|---|
1st | RCX | XMM0 |
2nd | RDX | XMM1 |
3rd | R8 | XMM2 |
4th | R9 | XMM3 |
more | stack | stack |
实际上,window系统会为每一个参数预留对应的栈空间。例如,假设函数有6个参数,实际情况如下:
void func(a,b,c,d,e,f)
// ASM code:
SUB RSP,48
MOV RCX,a
MOV RDX,b
MOV R8, c
MOV R9, d
MOV [RSP+20h],e
MOV [RSP+28h],f
CALL func
ADD RSP,48
在函数func内部会再次把a,b,c,d 保存到对应的栈的位置,即实际代码为:
MOV [RSP+0x8] ,a
MOV [RSP+0x10],b
MOV [RSP+0x18],c
MOV [RSP+0x20],d
// now e is [RSP+0x28], f is [RSP+0x30]
// when the last ret instruction will correct the RSP plus 8 bytes.
寄存器的保护规则
在写汇编优化的过程中,有些寄存器是可以直接拿来使用的,而有些寄存器要拿来用就必须要先入栈保存起来,用完之后,在从栈里面弹出以恢复之前的值。可以直接被拿来用的寄存器叫scratch register, 需要保护才能使用的寄存器叫volatile register.相对而言scrach register也叫non-volatile register,volatile register也叫preserved register。具体总结如下:
寄存器名称 | 寄存器属性 |
---|---|
RAX | scratch/non-volatile |
RBX | preserved/volatile |
RCX | scratch/non-volatile |
RDX | scratch/non-volatile |
RSI | scratch/non-volatile |
RDI | scratch/non-volatile |
RBP | preserved/volatile |
RSP | preserved/volatile |
R8 | scratch/non-volatile |
R9 | scratch/non-volatile |
R10 | scratch/non-volatile |
R11 | scratch/non-volatile |
R12 | preserved/volatile |
R13 | preserved/volatile |
R14 | preserved/volatile |
R15 | preserved/volatile |
64位 ARM-Cortext平台
- 31个64位通用寄存器和32个128位矢量寄存器
31个通用寄存器的命名为X0,X1,... X30,其对应的低32位物理视图寄存器可以用于32位的运算,命名为W0,W1,... W30. 32个矢量寄存器命名为V0,V1,...,V31,其对应的低64位物理视图寄存器为D0,D1,...,D31,低32位物理视图寄存器为S0,S1,... S31. 此处,和32位ARM-Cortext平台的矢量寄存器的物理视图是不同的。
- 参数传递与寄存器使用规则
从上面的图片可以看出,前8个寄存器用于函数参数的传递,其中X0还用于函数的返回值。X8到X18可以直接使用,X19到X28需要入栈保护之后才能被使用。实际上,X29和X30是否需要保护取决于当前函数是否有调用其它的函数,如果没有可以不用入栈保护。
和通用寄存器类似,矢量寄存器中的前8个用于参数传递或函数返回值。矢量寄存器的V8到V15不能直接使用,需要入栈保护,但只需要入栈保护低64位的物理视图寄存器,即D8到D15。而从V16到V31可以直接使用无需入栈保护。
- 整数与浮点混合参数传递
假设函数原型为
double function(long, double, long, double)
则第一个整数类型的参数传给X0,第一个双精度浮点的参数传给D0,第二个整数参数传给X1,第二个双精度浮点参数传给D1,返回值位双精度存储到D0
- 关于入栈和出栈
// code snippet 1
// Several pushes can share a single preparation step.
sub sp, x28, #32 // preparation
stp x3, x2, [x28, #-16]! // push {x2}; push {x3};
...
stp x1, x0, [x28, #-16]! // push {x0}; push {x1};
// code snippet 2
// push {x0, x1, x2, x3}
stp x0, x1, [sp, #-16]!
stp x2, x3, [sp, #8]
// code snippet 3
stp x4, x5, [sp, #-8]!
stp x2, x3, [sp, #-8]!
stp x0, x1, [sp, #-8]!
// vs.
stp x0, x1, [sp, #-24]!
stp x2, x3, [sp, #8]
stp x4, x5, [sp, #16]
把一些有用的资源放到参考文献里面:
参考文献:
- ARM参数传递规则_celerychen2009的博客-CSDN博客
- windows x64 ABI conventions
- https://cs140e.sergio.bz/docs/ARMv8-A-Programmer-Guide.pdf ARMv8编程指南
- Compiler Explorer 非常全面的任意一种高级语言和汇编语言的代码对比平台godbolt.org
- https://developer.arm.com/architectures/instruction-sets/intrinsics/ ARM官方intrinsics函数集
- https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html x86/x64 intrinsics函数集
- An Open Optimized Software Library Project for the Arm Architecture @ GitHub NE10 Project
- Quick C++ Benchmarks C++代码性能对比测试平台