Intel体系过程调用实现

概述

过程调用是现代绝大多数编程语言实现所依赖的基础抽象机制。过程调用通过使用一组指定的参数和可选的返回值实现了某种功能,然后,可以在程序的不同地方进行调用这个过程以实现特定的需求。为了实现函数调用,各个硬件体系都约束了在函数调用过程需要遵循的一系列规则,包括控制转移、硬件寄存器使用、参数传递以及返回值处理等,本文着重关注于Intel体系结构下的函数调用机制实现。

运行时堆栈

过程调用的过程中,有各种数据需要进行处理,典型的数据包括函数参数、局部变量、函数返回值处理等等。此外,函数需要支持嵌套调用,并在子函数调用返回后恢复执行函数的上下文状态。综合这些特性,现代体系结构普遍使用堆栈来实现函数调用。如下分别是x86和x86_64体系结构下,函数运行时堆栈的模型图:
-w1036

栈帧

这里牵涉到一个重要的概念:栈帧。对于每一个过程调用,系统都会为其分配一个栈帧,用于保存函数执行过程中所要处理的数据。栈帧是函数调用栈中的一段,它包含起始地址和结束地址,在x86_64体系下,栈帧的起始地址保存在rbp寄存器(帧指针)中,结束地址存放在rsp寄存器(栈指针)中。

在实际运行时,帧指针和栈指针的分工是比较明确的:

  • 帧指针的值通常保持不变,程序使用帧指针加偏移的方式,用来寻址函数参数、局部变量等数据
  • 栈指针则用来控制栈帧中内存的申请和释放,减小栈指针的值则相当于分配内存,增大栈指针的值则等同于释放内存

参数传递

在过程调用过程中,Intel体系结构支持使用两种方式进行函数参数传递:

  • 使用通用寄存器:将参数存放在寄存器中,函数执行时,访问特定的寄存器以获取参数数据
  • 使用堆栈:寄存器数量是有限的,如果需要将大量参数传递给被调用过程,可以将参数放在堆栈中进行传递。当然,相对于使用寄存器,堆栈的传递方式在效率上要低一些

现代的硬件体系结构下,通常是混合使用了寄存器传参和堆栈两种方式,在通用寄存器数量足够的情况下,优先使用寄存器进行传参,当参数数量过多,则将多余的参数通过堆栈的方式传递。考虑上图中的函数调用栈,我们是从第7个函数参数进行统计的,这就是因为其它的参数使用特定的寄存器进行传递了,x86_64体系结构可支持使用最多6个寄存器来传递参数给被调用函数。

局部变量在栈上的存储

一个函数在执行过程中使用的局部变量的空间在编译期间就已经明确了,因此通过修改栈指针的值,就可以提前在堆栈中预留空间。

返回值

通常情况下,函数参数的返回值都是通过一个单独的寄存器进行存放。在x86体系下,使用eax寄存器存放返回值,到了x86_64体系中,使用扩展成64位长度的rax寄存器。

寄存器使用规则

对于Intel体系结构,函数调用过程中的寄存器使用都需要遵循统一的规则,对于32位体系和64位体系,规则略有差异。

x86体系寄存器使用规则

  • %eax作为函数返回值使用
  • %esp栈指针寄存器,指向栈顶
  • x86体系下,参数传递默认是通过堆栈实现的,除非使用GCC扩展显示指定使用寄存器传参,不然不会使用

C语言函数参数压栈的顺序与形参定义顺序是相反的,即从函数的最后一个参数开始压栈

x86_64体系寄存器器使用规则

  • %rax:作为函数返回值使用
  • %rsp:栈指针寄存器,指向栈顶
  • %rdi,%rsi,%rdx,%rcx,%r8,%r9:用作函数参数,依次对应第1参数,第2参数。。。
  • %rbx,%rbp,%r12,%r13,%14,%15:用作数据存储,遵循被调用者保存规则
  • %r10,%r11:用作数据存储,遵循调用者保存规则

调用者保存与被调用者保存规则

  • 调用者保存寄存器: 这类寄存器被视为易失的,因此在过程调用时视为已销毁。若要在过程调用之后恢复该值,则调用者有责任将这些寄存器进行保存
  • 被调用者保存寄存器:这类寄存器被视为非易失的,必须由使用它们的函数进行保存和还原。简单来说,当调用者进行调用时,期望这些寄存器在被调用者返回后保持原值,则被调用者有义务保存并在返回调用者之前将其还原

过程调用实例

考虑一个简单的函数调用示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int do_add_func(int arg1, int arg2)
{
    int result = 0;
    int val1 = arg1, val2 = arg2;

    result = val1 + val2;
    printf("The result is %ld.\n", result);

    return result;
}

int main(int argc, char *argv[])
{
    int result = 0;

    result = do_add_func(4, 9);

    return result;
}

在此,我们关注do_add_func函数的栈帧形成,查看do_add_func的反汇编实现:

0000000000001145 <do_foo_func>:
    1145:   55                      push   %rbp
    1146:   48 89 e5                mov    %rsp,%rbp
    1149:   48 83 ec 20             sub    $0x20,%rsp
    114d:   89 7d ec                mov    %edi,-0x14(%rbp)
    1150:   89 75 e8                mov    %esi,-0x18(%rbp)
    1153:   c7 45 f4 00 00 00 00    movl   $0x0,-0xc(%rbp)
    115a:   8b 45 ec                mov    -0x14(%rbp),%eax
    115d:   89 45 f8                mov    %eax,-0x8(%rbp)
    1160:   8b 45 e8                mov    -0x18(%rbp),%eax
    1163:   89 45 fc                mov    %eax,-0x4(%rbp)
    1166:   8b 55 f8                mov    -0x8(%rbp),%edx
    1169:   8b 45 fc                mov    -0x4(%rbp),%eax
    116c:   01 d0                   add    %edx,%eax
    116e:   89 45 f4                mov    %eax,-0xc(%rbp)
    1171:   8b 45 f4                mov    -0xc(%rbp),%eax
    1174:   89 c6                   mov    %eax,%esi
    1176:   48 8d 3d 87 0e 00 00    lea    0xe87(%rip),%rdi        # 2004 <_IO_stdin_used+0x4>
    117d:   b8 00 00 00 00          mov    $0x0,%eax
    1182:   e8 b9 fe ff ff          callq  1040 <printf@plt>
    1187:   8b 45 f4                mov    -0xc(%rbp),%eax
    118a:   c9                      leaveq
    118b:   c3                      retq

补充说明一点,这里的程序是使用-O0选项(即不优化)进行编译的。在gcc更高的优化等级下,rbp寄存器已经没有再作为帧指针使用,而是另作他用。

顺着函数do_foo_func的汇编指令代码,我们可以总结出x86_64体系下过程调用实现的一些基本步骤:
**第1步:**备份帧指针(rbp寄存器)的值(一般来说,rbp寄存器保存了上层过程调用的帧指针的值),设置帧指针的值为当前栈指针

push %rbp
mov %rsp, %rbp

**第2步:**根据过程调用内存的使用情况,调整栈指针的值,预留空间

sub $0x20, %rsp

第3步: 使用栈帧中保存的数据,继续后续指令的执行
**第4步:**使用帧指针中的值,恢复栈指针,完成当前过程调用栈帧的释放。然后从堆栈中弹出帧指针的原先值,用以恢复帧指针

leaveq      /* leave指令是x86_64体系新加入的指令,用于执行过程返回前的准备工作 */

**第5步:**过程调用返回

retq

相关参考

  • 《深入理解计算机系统》
  • 《Intel处理器手册》
  • 《Linux汇编语言》
posted @ 2020-03-22 18:17  Aspiresky  阅读(46)  评论(0)    收藏  举报