0%

System V AMD64 ABI - Calling Convention

本文参考System V ABI: AMD64 Architecture Processor Supplement,并且不考虑__float128__m128__m256__m512等类型的参数的传递和作为返回值的情况。

主要内容:

  • 函数调用相关的常用寄存器
  • 函数调用时的stack frame:可省略的%rbp和red-zone
  • 函数调用时参数的传递:参数分类,不同类型参数的传递方式
  • 函数返回值的处理

寄存器

AMD64架构有16个64bit的通用寄存器(%rax, %rbx, %rcx, %rdx, %rsp, %rbp, %rsi, %rdi, %r8 - %r15)和16个128bit的SSE寄存器(%xmm0 - %xmm15)。

进行函数调用时,%rbp, %rbx, %r12 - %r15 属于caller,callee 要保管这6个寄存器的内容。
剩下的寄存器%rax, %rcx, %rdx, %rsp, %rbp, %rdi, %r8 - %r11 和 %xmm0 - $xmm15 属于callee。
其中%rdi, %rsi, %rdx, %rcx, %r8, %r9 和 %xmm0 - %xmm7 可用来传递参数。%rax, %rdx,%xmm0, %xmm1 用来保存返回值。不能由寄存器保存的参数,将会被放置在栈上。


通过一个使用所有通用寄存器的函数,我们可以观察到%rbp, %rbx, %r12 - %r15在进入函数时被push放入栈中保存,离开函数时pop从栈中恢复。

栈帧(stack frame)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
                       8 bytes                                       8 bytes
<----------------------> <---------------------->

+----------------------+ +----------------------+
| memory argument | | memory argument |
+----------------------+ +----------------------+
| memory argument | previous frame | memory argument |
+----------------------+ ^ +----------------------+
aligned | memory argument | | | memory argument |
+-----------------------------------------------------------------------------+
|return address | | |return address |
rbp +-->-----------------------+ v +----------------------+
|previous %rbp value | current frame | |
+----------------------+ | saved registers ... |
| | | stack variables ... |
| saved registers ... | | |
| stack variables ... | | |
| | | |
| | rsp +-->-----------------------+
rsp +-->-----------------------+ | |
| | | |
| | | red zone(128 bits) |
| red zone(128 bits) | | |
| | v v
v v
rsp - 128 +--->----------------------+
rsp - 128 +--->----------------------+

caller通过call func,即等价于

1
2
push return_address
jmp func

将控制权转移给函数func后,此时rsp指向左图中rbp的位置。此时要求rbp + 8的位置,也就是栈上用来传递参数部分的末尾地址和16bytes对齐。

控制权交给func后,func进行function prologue,将之前函数的stack-frame base pointer: rbp通过push rbp压栈,并将rbp的值变为当前函数的stack-frame base: move rbp, rsp。之后向下移动rbp以扩展当前栈的空间。

AMD64规定rbp以下128bytes的大小为red zone,并保证该区域不会被signal和interrupt handler修改。但不保证该区域不被之后调用的函数修改。因此该区域常用作不再调用其他函数的leaf function来保存局部变量,且不用移动rsp

函数调用结束后要先回复之前的保存的caller的寄存器,之后进行function epilogue恢复之前的rbp,并跳转到return_address的位置。即

1
2
leave
ret

等价于
1
2
3
mov rsp, rbp
pop rbp
ret


AMD64 ABI规定:可以不使用rbp寄存器保存caller的stack-frame base地址。现代编译器仅靠rsp就可以对栈上的变量进行定位了。不使用rbp可以节省function prelogue和function epilogue中的两条指令,并将rbp寄存器空出。gcc x86-64下,-O优化默认进行不保存rbp。使用-fno-omit-frame-pointer可以保存rbp。其效果可以对比下面使用不同编译参数的结果:


AMD64 ABI中的red-zone

可以观察到上面例子中,使用-fno-omit-frame-pointer参数进行编译的结果中,get_rt()没有在function prelogue通过sub rsp, xxx来扩展当前栈的大小,也没有在function epilogue中使用leave来恢复rsp到之前的rbp。这时因为get_rt()是一个leaf function,他没有调用其他函数,并且栈上的需要的空间小于read zone的128bytes。因此使用read zone来保存栈上的局部变量,注意到
mov DWORD PTR [rbp-4], 1,因此i就被保存在rbp-4的位置上。这里刚好是read zone
如果我们修改编译参数,加上-mno-red-zone来取消read zone,就可以看到函数进行完整的function prelogue和function epilogue了

参数传递

参数分类

参数根据参数传递的方式不同,可以大致分为:

  • NO_CLASS: padding或空struct类型
  • INTEGER: 使用6个通用寄存器(%rdi, %rsi, %rdx, %rcx, %r8, %r9)传递
  • SSE: 使用SSE寄存器(%xmm0 - %xmm7)传递。
  • MEMORY: 使用psuh,在栈上传递

不考虑__float128__m128__m256__m512等类型的参数,basic type的参数可以被分为:

  • INTEGER: bool, char, short, int, long, long long 和 指针
  • SSE: float, double
  • MEMORY: 不通过寄存器,而是通过栈来传递参数

聚合类型(数组 和 无虚函数、自定义构造函数、基类或private/protected数据成员的 structunion)按照以下方式分类:

  • 如果该对象的类型对于调用是non-trivial的,即:
    • 有 non-trivial ctor 或 non-trivial move constructor 或 non-trivial dtor
    • 或其所有的ctors 和 move constructors 都被声明为delete
      ,则将该讲该参数构造在caller的栈上,并将该参数替换成构造在栈上的对象的引用。
  • 如果聚合类型的大小超过16bytes 或 该聚合类型包含未对齐的field,则是MEMORY类型
  • 如果聚合类型的大小大于8bytes且小于16bytes,则将每个8bytes都分开进行分类,将分类的结果两两按以下规则合并:
    • 如果任一为MEMORY,则整个聚合类型以MEMORY方式传递。
    • 如果两个8bytes分类相同,则合并的结果也相同。
    • 如果其一为NO_CLASS,则合并的结果为另一个的类型。
    • 如果其一为INTEGER,则合并的结果为INTEGER
    • 否则为SSE

参数传递过程

一旦所有参数都已经分类完毕,寄存器按照以下规则进行赋值:

  • 跳过MEMORY类型,MEMORY不使用寄存器,而是在push到栈上。
  • INTEGER类型按照 %rdi,%rsi, %rdx, %rcx, %r8, %r9 的顺序赋值给通用寄存器。
  • SSE类型按%xmm0 - %xmm7的顺序赋值给SSE寄存器
  • bool类型的必须保证bit 0包含值,且bit 1 - bit 7 为0,但不保证其他bit的数值。

如果在传递参数的过程中寄存器全被占用了,则将该参数整个通过MEMORY的方式传递。如果寄存器全被占用,则按MEMORY类型传递的参数以从右到左的顺序入栈。


例子1:传递8个int作为参数。其中前6个int被存放在寄存器中,后2个int被放置在栈上。


例子2:传递1个大小为12bytes的聚合类型,该聚合类型的第一个和第二个data members被拼在一起用%rdi传递,第三个data member用%rsi的低位%esi传递。这这证明了聚合类型的参数传递的确是以8byte来分割的。


例子3: 使用__attribute__((packed))的未对齐类型、大小大于16bytes的聚合类型 和 non-trival 的C++ class以MEMORY的方式传递。注意到callee函数中的source都是rsp + offset的形式吗,证明参数的确都被放在了栈上。


例子4:对于函数调用non-trivial的类型 和 trivial的类型对比。可见non-trivial的类型由caller在栈上构造:

1
2
mov     QWORD PTR [rsp], 2
mov DWORD PTR [rsp+8], 4

并且因引用的方式传递给callee(mov rdi, rsp)。可以看到,传引用的形式和传值的形式生成的代码是完全相同的

返回值

返回值的处理和参数传递类似。首先确定返回值的类型,之后根据返回值的类型决定如何返回:

  • 如果返回值是MEMORY类型,则由caller负责提供返回值的存储空间,并将返回值当作第一个参数,将地址存储在%rdi之中。在返回时,%rax中会保存%rdi里传来的地址。
  • 如果返回值是INTEGER类型,按顺序使用%rax, %rdx寄存器。
  • 如果返回值是SSE类型,按顺序使用%xmm0%xmm1寄存器。

在下面的例子中:
struct mm因为大小大于16bytes,因此为MEMORY类型。在caller_mm()中,生成的汇编代码中第17行mov rdi, rsp,即将在caller_mm()栈上划给返回值的空间的地址传递存储在%rdi%中。在返回时,在生成的callee_mm()代码中第4行,将%rdi的内容又传递回了%rax

tuple<int, int, int>作为一个12bytes大小的类型,应按照8,4分割给%rax%rdx寄存器。在生成的代码中第15, 16行,的确看到将使用%rdx%rax分别赋值。在caller_integer中,将%rax%rdx直接相加存储在%rax中作为返回结果。


参考:

What registers are preserved through a linux x86-64 function call
Itanium C++ ABI
System V ABI: AMD64 Architecture Processor Supplement