v2-c19a6038d2535e1c12ab602e261dc388_720w.jpg

什么是调用约定

什么叫函数调用约定?先看一个c函数。

  1. int add (int first, int second) {
  2. return first + second;
  3. }

调用时只要用int result = add(1,2)这样的方式就可以使用这个函数。但是,当高级语言被编译成计算机可以识别的机器码时,有一个问题就凸现出来:在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,需要一个函数调用约定。不同语言,不同的平台,不同的架构的处理器。处理器位数的函数调用约定都是不一样的。

C语言原理

创建文件main.c

int main() {
    int first = 1;
    int second = 2;
    int result = first + second;
    return 0;
}

执行 gcc -S main.c -o main.s得到汇编文main.s


    .text
    .globl    main
    .type    main, @function
main:
    pushq    %rbp;
    movq    %rsp, %rbp
    movl    $1, -12(%rbp)
    movl    $2, -8(%rbp)
    movl    -12(%rbp), %edx
    movl    -8(%rbp), %eax
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    ret
  1. .text:伪指令。为AT&T的伪指令代表着代码代码区。还有.data代表数据去。bss代表着堆栈空间等。
  2. .global: 伪指令、表示标号main为全局作用域。
  3. .type:伪指令。表示标号main为一个函数类型。

在我们编写的程序中以main函数作为入口函数,但mian函数却不是整个应用程序的入口函数。编译器会使用对应平台的入口函数作为整个应用程序的入口函数。然后再调用用户编写的入口函数。在这里mian函数作为一个子程序来执行。我们看一下main函数的栈帧。
image.png

pushq    %rbp;
movq    %rsp, %rbp

这两条汇编指令保存着调用子程序的堆栈。首先把调用子程序的的栈底地址保存到堆栈中。再把调用子程序的栈顶作为被调用子程序的栈底。这样可以达到保存调用子程序的堆栈空间。当需要恢复调用子程序的堆栈的时候执行取反执行即可。

movq %rbp %rsp
popq %rbp

image.png
在linux 64位系统下,int类型大小为4字节,在mian函数中一个有3个int类型局部变量first , secondresult。所有main函数的局部变量大小位12字节。由于linux系统的堆栈由高地址向低地址增长。所以RSP寄存器地址总是小于或者等于RBP寄存器地址。

    movl    $1, -12(%rbp)
    movl    $2, -8(%rbp)
    movl    -12(%rbp), %edx
    movl    -8(%rbp), %eax
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
  1. 把常量1移到rbp -12的堆栈空间上。
  2. 把常量2移到rbp -8的堆栈空间上。
  3. 把堆栈空间rbp -12上的内存移到edx寄存器上。
  4. 把堆栈空间rbp -8上的内存移到eax寄存器上。
  5. edxeax执行加法指令并把结果保存在eax寄存器上。
  6. eax计算结果存放在堆栈空间rbp -4上。

    c语言函数调用原理

    编写man.c文件


int add (int args1, int agrs2) {
    return args1 + agrs2;
}
int main() {
    int first = 1;
    int second = 2;
    int result = add(first, second);
    return 0;
}

执行 gcc -S main.c -o main.s得到汇编文main.s


    .text
    .globl    add
    .type    add, @function
add:
    pushq    %rbp
    movq    %rsp, %rbp
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %edx
    movl    -8(%rbp), %eax
    addl    %edx, %eax
    popq    %rbp
    ret

    .globl    main
    .type    main, @function
main:
    pushq    %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp
    movl    $1, -12(%rbp)
    movl    $2, -8(%rbp)
    movl    $2, %esi
    movl    $1, %edi
    call    add
    movl    %eax, -4(%rbp)
    movl    $0, %eax
    leave
    ret

先看这个程序的函数调用帧。
image.png

  1. 初始化mian函数的堆栈。
  2. subq $16, %rsp:当前堆栈初始化为16个字节大小。
  3. movl $1, -12(%rbp):把常量1保存到堆栈 rbp - 12的堆栈空间上。
  4. movl $2, -8(%rbp):把常量1保存到堆栈 rbp - 8的堆栈空间上。
  5. movl $2, %esi:把常量2保在寄存器esi上。
  6. movl $1, %edi:把常量1保在寄存器edi上。
  7. call add:执行子程序add。把当前指令寄存器EIP存放在堆栈中。把子程序的地址赋值到EIP寄存器上。
  8. 初始化add函数堆栈。
  9. movl %edi, -4(%rbp):在上面可以知道。main函数把常量1存放在edi寄存器上。在这里把参数。存放在add函数的堆栈上。
  10. movl %esi, -8(%rbp):和上面一样。
  11. movl -4(%rbp), %edx:把参数1存放在寄存器edx上。
  12. movl -8(%rbp), %eax:把参数2存放在寄存器eax上。
  13. addl %edx, %eax:执行两个寄存器值相加,把结果存放在eax寄存器上。
  14. popq %rbp:恢复rbp寄存器的值。
  15. _ret_:恢复EIP寄存器,使程序执行main函数。
  16. movl %eax, -4(%rbp):把add子程序的执行结果值移到自己的堆栈空间 rbp-4上。

在上面流程中可以知道。在调用子程序之前。main函数把参数存放在esiedi寄存器上。执行子程序add的时候,add把esiedi寄存器的值移动到自己的堆栈空间上。执行操作后把结果存放在eax寄存器上。最后main函数在eax寄存器上获取子程序add的执行结果。
在这里有一个问题,main函数把参数存放在寄存器上。在执行add函数后又把参数从寄存器中移动到自己的堆栈空间中。能不能直接在main函数中直接把参数存放在add函数的堆栈空间里。这样就不用通过寄存器作为中介。减少参数的获取指令的执行。

    .text
    .globl    add
    .type    add, @function
add:
    pushq    %rbp
    movq    %rsp, %rbp
    movl    -4(%rbp), %edx
    movl    -8(%rbp), %eax
    addl    %edx, %eax
    popq    %rbp
    ret
    .globl    main
    .type    main, @function
main:
    pushq    %rbp
    movq    %rsp, %rbp
    subq    $8, %rsp
    movl    $1, 8(%rsp)
    movl    $2, 12(%rsp)
    call    add
    movl    %eax, -4(%rbp)
    movl    $0, %eax
    leave
    ret

在代码中 movl $1, 8(%rsp)。因为需要留下4个字节给执行call add的时候存放eip指令。这样也可以实现函数的调用。那为何还需要寄存器去作为中介值呢?这设计到一个新的讨论。ABI(Application Binary Interface)应用程序二进制接口。

c/c++语言在x86-64

what_is_an_abi.svg
ABI就是我们说的函数调用约定。应用程序二进制接口,描述了应用程序和操作系统之间,一个应用和它的库之间,或者应用的组成部分之间的低接口约定只要包括几方面。

  1. 参数的传递方式(寄存器传递或者堆栈传递)
  2. 参数是传递顺序(从左到右还是从右到左入堆栈,还是选择不同的寄存器)。
  3. 数据类型的大小、布局和对齐(4个字节对齐还是8个字节还是不需要);
  4. 子程序的返回值(通过寄存器还是堆栈)
  5. 寄存器的使用与清理

我们知道一个c程序采用了文件模块化的开发机制。在链接前,不同文件的c函数互相调用的时候并不知道实际的实现。只能等到链接的时候才去填充对用的调用实现。因为被调用的函数可能存在另一方c文件,可能是一个动态链接库,也可能是一个静态链接库。也可能是系统的调用函数。不同的文件可能通过不同的人,不同语言,不同的编译编译完成。如果需要分布在不同文件的函数互相调用,必须大家遵循相同的ABI约定。这也是ABI接口的重要性。不同的编程如果遵循相同的ABI,其实也可以实现不同语言的互相调用。

x86调用约定类型

x86处理器没有对字节对齐要求。x86 处理器将自动纠正未对齐的内存访问,但会降低性能。没有异常被提出。如果地址是对象大小的整数倍,则认为内存访问是对齐的。例如,所有的BYTE访问都是对齐的(一切都是1的整数倍),对偶数地址的WORD访问是对齐的,而DWORD地址必须是4的倍数才能对齐。
x86 架构有几种不同的调用约定。幸运的是,它们都遵循相同的寄存器保存和函数返回规则。

  • 函数必须保留所有寄存器,但eaxecxedx除外,它们可以在函数调用中更改,而esp必须根据调用约定进行更新。
  • 整形值和地址值存放在EAX寄存器接收函数的返回值,如果结果是32位或更小。如果结果是 64 位,则结果存储在edx:eax对中。
  • 浮点型结果存放在寄存器ST0 x87寄存器中返回中

    __cdecl(c语言默认调用约定)

  1. 参数是从右向左传递的,放在堆栈中。
  2. 函数的堆栈平衡操作是由调用函数执行的(参数堆栈)。
  3. 编译后的函数名前缀以一个下划线字符
  4. 调用新函数时,浮点寄存器 ST0 到 ST7 必须为空(弹出或释放),退出函数时 ST1 到 ST7 必须为空。当不用于返回值时,ST0 也必须为空。

    __stdcall

    windows系统在x86下默认使用了函数调用方式。

  5. 参数是从右向左传递的,放在堆栈中。

  6. 函数的堆栈平衡操作是由被调用函数执行的(参数堆栈)。
  7. 在函数名的前面用下划线修饰,在函数名的后面由@来修饰并加上栈需要的字节数的空间
  8. 调用新函数时,浮点寄存器 ST0 到 ST7 必须为空(弹出或释放),退出函数时 ST1 到 ST7 必须为空。当不用于返回值时,ST0 也必须为空。

__fastcall

__fastcall调用的主要特点就是快,因为它是通过寄存器来传送参数的。

  1. 前两个DWORD类型或者占更少字节的参数被放入ECX和EDX寄存器,其他剩下的参数按从右到左的顺序压入栈。
  2. 函数的堆栈平衡操作是由被调用函数执行的。
  3. 在函数名的前面用下划线修饰,在函数名的后面由@来修饰并加上栈需要的字节数的空间
  4. 调用新函数时,浮点寄存器 ST0 到 ST7 必须为空(弹出或释放),退出函数时 ST1 到 ST7 必须为空。当不用于返回值时,ST0 也必须为空。

    __thiscall

    仅仅应用于“C++”成员函数的调用。

  5. 参数是从右向左传递的,放在堆栈中。

  6. 函数的堆栈平衡操作是由被调用函数执行的。
  7. this指针存放于ECX寄存器。

    x86 linux系统的c/c++调用约定

    linux在x86下使用__cdecl标准

    x86-64 linux系统c/c++调用约定

  8. 一个函数在调用时,如果参数个数小于等于 6 个时,前 6 个参数是从左至右依次存放于 RDI,RSI,RDX,RCX,R8,R9 寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈;

  9. 如果参数个数大于 6 个时,前 5 个参数是从左至右依次存放于 RDI,RSI,RDX,RCX,RAX 寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈;
  10. 对于系统调用,使用 R10 代替 RCX;
  11. XMM0 ~ XMM7 用于传递浮点参数;
  12. 小于64位(bit)的参数传递时高位并不填充零(例如只传递ecx),也就是说结构体或union如果大小是1,2,4,8字节,用值传递(相应的寄存器),大于8字节(64位)必须按照地址(指针)传递;
  13. 被调用函数的返回值是64位以内(包括64位)的整形或指针时,则返回值会被存放于 RAX,如果返回值是128位的,则高64位放入 RDX;
  14. 如果返回值是浮点值,则返回值存放在XMM0;
  15. 更大的返回值(比如结构体),由调用方在栈上分配空间,并由 RCX 持有该空间的指针并传递给被调用函数,因此整型参数使用的寄存器依次右移一格,实际只可以利用 RDI,RSI,RDX,R8,R9,5个寄存器,其余参数通过栈传递。函数调用结束后,RAX 返回该空间的指针(即函数调用开始时的 RCX 值)。
  16. 可选地,被调函数推入 RBP,以使 caller-return-rip 在其上方8个字节,并将 RBP 设置为已保存的 RBP 的地址。这允许遍历现有堆栈帧,通过指定GCC的 -fomit-frame-pointer 选项可以消除此问题。
  17. 调用者 (caller) 负责清理栈,被调用函数 (callee) 不用清栈;
  18. 除了 RDI,RSI,RDX,RCX,R8,R9 以外,RAX,R10,R11 也是“易挥发”的,不用特别保护,其余寄存器需要保护。
  19. 在调用 call 指令之前,必须保证堆栈是16字节对齐的;
  20. 对于 R8~R15 寄存器,我们可以使用 r8, r8d, r8w, r8b 分别代表 r8 寄存器的64位、低32位、低16位和低8位。

    x86 windows系统c/c++调用约定

    windows在x86下使用__stdcall标准。

    x86-64 windows系统c/c++调用约定

    默认情况下,windows x64 应用程序二进制接口 (ABI) 使用四寄存器 fast-call 调用约定。 系统在调用堆栈上分配空间作为影子存储,供被调用方保存这些寄存器。

  21. 函数调用的参数与用于这些参数的寄存器之间有着严格的一一对应关系。 任何无法放入 8 字节或者不是 1、2、4 或 8 字节的参数都必须按引用传递。 单个参数永远不会分布在多个寄存器中。

  22. 所有浮点数运算都使用 16 个 XMM 寄存器完成。
  23. 整数参数在寄存器 RCX、RDX、R8 和 R9 中传递。 浮点数参数在 XMM0L、XMM1L、XMM2L 和 XMM3L 中传递。 参数按引用传递。
  24. 对于原型函数,在传递参数之前,所有参数都将转换为所需的被调用方类型。 调用方负责为被调用方的参数分配空间。 调用方必须始终分配足够的空间来存储 4 个寄存器参数,即使被调用方不使用这么多参数。