14.1.2 调用规约

调用规约是程序员需要遵守的关于函数调用顺序的约定。

如果所有人都遵守同样的规则的话,那么就可以流畅地完成合作。一旦有人破坏规则,例如,修改或者在一个函数中不恢复 rbp 的值,那么可能会发生:啥事都没有,稍后程序崩溃,或者程序马上就崩。由于其它函数在编写的时候都假定自身以外的函数是遵守这些调用规则的,且保证 rbp 寄存器不被修改。

调用规约定义的其中之一就是参数的传递算法。我们这里使用传统的 *nix 的 x86 64 下的习惯(在[24]中详细定义),下面的描述对于函数如何被调用是一个相对精确的近似。

  1. 首先,需要对值进行保护的寄存器需要保存好原来的值。除了七个 callee-saved 寄存器(rbx,rbp,rsp,r12 - r15)都可能被被调用函数所修改,所以如果这些寄存器的值重要的话,就需要把这些寄存器的值保存起来(一般都在栈上保存)。
  2. 寄存器和栈都会被参数填充。 每个参数都会被 round 到 8 字节。 参数会被分为三个列表: (a) 整数和指针参数 (b) Float 和 double 参数 (c) 通过内存中的栈传入的参数 第一个列表中的前六个参数通过六个通用寄存器传入(rdi,rsi,rdx,rcx,r8 和 r9)。第二个列表中的前八个参数通过 xmm0 ~ xmm7 这八个寄存器传入。如果前两个列表还有更多的参数传入,那么这些多余的参数会被以反序存储在栈上传入。也就是说,在函数被执行前,传入的最后一个参数应该是在栈顶上。 整数和浮点数参数传递比较简单,结构体传入则稍微复杂一些。 如果一个结构体大于 32 字节,或者有未对齐的字段,那么就会通过内存传入。 小结构体会按照其字段被分解为多个字段,每一个字段都被分别处理,如果结构体内又有结构体,那么也会被递归做相同处理。所以一个包含两个元素的结构体可以用两个参数的同样的方式进行传入。如果结构体的某个字段被认为是 "内存",那么就会冒泡到结构体本身。 rbp 寄存器像我们即将看到的,会用来定位通过内存传入的参数以及局部变量。 返回值往哪里填呢?整数和指针会存储在 rax 和 rdx 中返回。浮点数会在 xmm0 和 xmm1 返回。大结构体会以一个指针形式返回,该指针以隐藏的附加参数返回,像下面这个例子:
struct s {
    char vals[100];
};

struct s f( int x ) {
    struct s mys;
    mys.vals[10] = 42;
    return mys;
}

void f( int x, struct s* ret ) {
    ret->vals[10] = 42;
}

3.然后就可以调用 call 指令了。call 的参数是需要调用的函数的第一条指令的地址。call 指令会将该地址 push 到栈上。

每一个程序都可以有同一个函数的多个实例同时执行,这些同时执行的函数并不一定是在不同的线程中,且有可能是由于递归导致的多实例。这个函数的每一个实例都会被存储在栈上,因为栈的主要规则是后进先出,因此该特性会反映在函数的运行和销毁上。一个函数 f 在运行后调用了函数 g,那么 g 会先被销毁(而 g 从时间上来讲是后被调用的),f 之后才被销毁 (然而 f 在时间上是先被调用的)。

栈帧是为某一函数所专用的栈的一部分。栈帧保存了局部变量,临时变量和保存的寄存器。

函数代码一般会被一对 prologue 代码和 epilogue 代码包裹,对所有函数来说都一样。prologue 用来初始化栈帧,epilogue 用来逆向初始化(销毁)。

函数执行过程中,rbp 保持不变并一直指向该函数栈帧的起始位置。这样就可以用 rbp 寄存器外加偏移量来对局部变量进行寻址了。列表 14-1 中的代码对此有所反映。

Listing 14-1.prologue.asm

func:
push rbp
mov rbp, rsp

sub rsp, 24      ; given 24 is total size of local variables

老的 rbp 值被存储起来以便之后在 epilogue 中恢复。然后 rbp 被设置为当前的栈的栈顶值(顺便说一下,栈顶现在存储的是 rbp 的老值)。接下来为局部变量分配空间的话,就只需要让 rsp 的值减去该变量的大小就可以了。这也是我们在栈上分配 buffer 空间的方式。

局部变量现在若要分配内存则可以直接用 rsp 减去其大小

函数结束的 epilogue 片段如列表 14-2 所示。

Listing 14-2.epilogue.asm

mov rsp, rbp
pop rbp
ret

通过将栈帧的起始地址移动到 rsp,我们可以确保所有在栈上分配的空间都被释放掉了。然后老的 rbp 值也就被恢复了,现在 rbp 会指向前一个栈帧的起始地址。最后 ret 指令会将返回地址从栈弹出到 rip 中。

编译器一般使用的是一组完全等价的替代指令,参见列表 14-3。

Listing 14-3.epilogue_alt.asm

Leave
ret

leave 指令是特别为栈帧销毁所发明的指令。其反义指令 enter 则不太被很多编译器所接受,因为这条指令提供了比列表 14-1 更多的功能。指令本身是针对那些内嵌函数支持的编程语言设计的。

4.在离开函数之后,并不是说我们的工作就结束了。由于一些参数是通过内存(栈)传入的,我们也需要把这些也清理掉。

results matching ""

    No results matching ""