第6篇-戏说程序栈 x86_64过程调用
这是程序栈话题的最后一篇,可能有人会问,你前面5篇写那么多x86程序栈的文章干什么?请耐心看下去,即便现在x64硬件流行的今天,x86的过程调用约定仍然有存在的现实意义,这个戏说程序栈的最终篇,我们探讨以下x86程序栈和x86_64的差异性。
x86 vs x86_64 过程调用约定
下表是对x86寄存器和x86_64寄存器的一个使用约定的对比表,从该表可知,由于x86_64中的物理寄存器数量比x86的多出了一倍,因此可以在很大程度上减轻了程序调用对栈的依赖,并且更好地利用寄存器交换临时数据,例如我们可以将参数直接缓存到寄存器中,并且也可以将局部变量直接缓存到寄存器,当然我们会耗尽所有可用的寄存器,然后又回到x86程序栈的约定的操作方式:参数先入栈,需要调用该参数时又加载到寄存器中。但是多数情况下,我们仍然尽最大努力尝试绕过函数栈直接利用寄存器,并避免过多地使用栈内存写入
回到我们上表,16个通用的寄存器,分别是8字节的位宽,我们知道
-
被调用者函数保存函数数据状态的寄存器,分别是RBX,RBP,R12,R13,
R14,R15. -
用于调用者函数传递参数的寄存器是RCX,RDX,RSI,RDI,R8,R9,也就是说在理想情况下,我们编写的调用者函数的参数控制在6个参数以内的话,在x86_64环境中,被调用者函数可以直接从这些寄存器中获取参数值,而且我们知道访问寄存器的速度远远快于从调用者栈桢中的参数域。但如果我们的被调用者函数的参数个数多于6个时,额外的参数不得不重新写入栈中,这又回到x86架构的过程调用的约定。
-
关于返回值的处理,在大多数情况下,x86_64的过程调用仍然遵循x86的传统,仍然使用RAX寄存器作为返回值,只不过它是EAX的扩展版本而已。
-
关于栈指针的使用约定,x86_64的过程调用仍然遵循x86的传统,使用RSP寄存器指向栈顶。
-
关于局部变量的使用约定,x86_64的过程调用,若存在闲置的寄存器的话,局部变量可以直接缓存到寄存器中,若局部变量太多的情况下,再次回到x86过程调用的约定,将额外的局部变量写入栈中。
-
callq指令在写入栈的返回地址是64位的尺寸,也就是说栈指针是隐含按照指令movq -8(%rsp),%rsp移动栈指针。
-
函数可以访问%rsp之后最多128个字节的内存:“红色区域”,意味着可以在通过%rsp的来在“红色区域”内存储一些临时数据。而不必使用使用多条指令。
-
关于帧指针的使用约定,如果深入研究过栈的话,可能大部分人会接受如下观点
x86_64的过程调用取消了帧指针,所有对当前栈帧中的内存字段的访问引用,由%rsp进行相对寻址来实现,%rbp即被作为视为通用寄存器。
严谨地说这个问题并不是所有C/C++编译器一致地遵守的,所以如果你使用不同种类C/C++编译器那么你反编译的汇编可能会得出不同的结论。我这里就举一个反例来看看。
gcc x86_64 7.4.0 编译输出的汇编文件
我们很容易就看到默认状态下gcc在x86_64环境中仍然会按照x86使用约定那样将%rbp作为帧指针来对待的。有趣的是,stackflow也是有老外对“x86_64帧指针有无卵用?”挺感兴趣的链接在这里,其中连栈的老黄历都挖出来说事了Orz 囧!!。所以要支持上面约定俗成的结论需要指定以下两个前提:
- 用的是什么C/C++编译器。
- 并且编译的时候执行了哪些编译选项为前提。
红色区域
%rsp所指向的位置之外的128字节区域被视为已保留,并且不得由信号或中断处理程序修改。 因此,函数可以使用此区域存储跨函数调用不需要的临时数据。 尤其是,叶子函数(就是位于整个函数调用链的末端的函数)可以在整个堆栈框架中使用此区域,而不是在序言和结语中调整栈指针。 这个地区被称为“红色区域(Red Zone)”
需要注意的是,红色区域会被函数调用所破坏,因此通常在叶子函数(不调用其他函数的函数)中使用。
入门示例导入
我之前在《x86/x86_64汇编的常用指令的比较》中已经用了一个x86_64的程序栈调用示例,这里不再复述。
x86_64栈帧
通常情况下,x86-64函数不在需要栈帧,唯一写入栈的操作就是执行到callq指令时候的“返回地址”8个字节。这使得栈帧的结构变得非常简单。那么什么情况下函数需要栈帧呢?
- 局部变量太多,64位寄存器处理不过来。
- 局部变量中存在数组或者结构体(或者叫做类类型)的变量
- 使用取址操作符&就计算局部变量的的内存地址。
- 调用另外一个超过6个参数的函数
- 需要在修改它们之前保存被调用者保存寄存器的状态
复杂的x86_64示例分析
long div(long x,long y,long z){
long s=(x+y)/z;
return s;
}
long calc(long x, long y, long z, long p,
long q, long s, long u, long v){
long m = x * y * z /p + q * s * u / v;
long n = (x + y + z + p)/(q - s + u * v);
long L = div(m, n, m % n);
return L + 20;
}
int main(void){
long s=calc(10,15,16,23,24,32,51,31);
return 0;
}
本示例使用如下指令执行反编译,使用 -O1的优化选项
gcc -S x64stack.c -O1 -o x64stack.s -fno-asynchronous-unwind-tables -fno-stack-protector
仅仅是calc函数得到汇编代码有40行之多,所以这里不打算贴出来了 这里主要x86_64栈的调用过程
第一,下图调用main函数执行callq指令后,并且main函数已经传递给calc的8个参数发生如下细节:
- 前6个参数分别直接加载到寄下图的六个参数寄存器中,这是x86_64过程调用约定。
- 后2个参数即是压入main的栈帧的参数域,这返回到x86的过程调用约定。
- calc函数要用后两个两个参数必须从通过%rsp指针分别使用移位寻址表达式24(%rsp)和32(%rsp)从main栈帧中加载参数到寄存器RBX和R11.
- “mov %rdi,%rax”这条指令,将寄存器中的第一个参数值拷贝到RAX寄存器,准备和其他参数寄存器进行数学运算。
屏幕快照 2019-10-28 下午8.20.50.png
第二,在calc函数中,编译器会首先处理“long m=x*y*z/p+q*s*u/v;”这条语句的前半部分,如下图红色所示,转换为如下等价的指令集。根据我们前面文章提过,imul指令会计算后的结果存储到目标操作数RAX寄存器当中。
- imulq %rsi,%rax 处理前两个参数,等价于“RAX=x*y=RSI*RAX=150”,此时RAX寄存器的值是150。
-
imuq %rdx,%rax RDX寄存器的第三个参数也参与乘法运算,即
“RAX=RAX*z=RAX*RDX=2400” -
idivq %rcx 同理,第RCX寄存器的参数#4参与除法运算,此时RAX寄存器的值是104,即“RAX=RAX/RCX=104”
第三,同理,编译会处理“long m=x*y*z/p+q*s*u/v;”后半部分的表达式“q*s*u/v”,等价的语句请看如下图红色的汇编指令,这里需要注意的是,
- rax仍然保留着上一步的计算结果,所以此时会将rax的缓存的值“备份”起来,然后腾出RAX参与其他运算,于是mov %rax,%r10这条指令这里没有像x86的“被调用者保存约定”将计算结果写入栈而是直接缓存到R10寄存器,这里遵循x86_64的寄存器使用约定。
- 之后RAX再次参与mov %rax,%r10之后的指令集的运算,执行到idivq指令,此时“q*s*u/v”表达式的计算结果是1263,并且缓存到RAX寄存器中,这是表达式“q*s*u/v”的运算结果。
接下来执行“add %rax,%10”“这条指令并且会将前面两个步骤计算的计算的结果RAX寄存器和R10相加,并且计算结果会重新修改R10寄存器的值为1367,这是局部变量m的值,这是遵循x86_64局部变量的使用约定。
同理,编译器处理处理的表达式“long n=(x+y+z+p)/(q-s+u*v)”会等价剩余的指令集合,此时的RCX缓存的计算结果为0,到目前为止,calc函数目前执行的任何数学运算没有执行执行任何calc栈帧的入栈和出栈的操作,这说明了
- x86_64环境中,只要有可用数量的寄存器,编译器会将局部变量和参数参与算术运算的整数和计算得出的中间值优先缓存到寄存器当中,这就避免了入栈和出栈的时间开销.
addq %rsi, %rdi
addq %rbp, %rdi
addq %rdi, %rcx
subq %r9, %r8
imulq %rbx, %r11
addq %r11, %r8
movq %rcx, %rax
cqto
idivq %r8
movq %rax, %rcx
执行到long n=(x+y+z+p)/(q-s+u*v);这条语句时,我们局部变量m,n的值是缓存到rcx中,更深一层地说,到目前为止,calc函数中的局部变量并没有缓存到“红色区域”,我们从本文最后贴出的完整汇编代码你也没有找到类似“mov %rax -N(%rsp)” 其中N>0这样的表达式,因此可以引证本文的例子没有使用到红色区域来存储局部变量的状态,而是优先使用寄存器来缓存局部变量。
其实接下来,步骤我们就没必要往下说,这里只是贴出一些关键的步骤,到这里读者可能会问,为什么calc没有调用div函数,你应该记得我们本示例编译的时候使用的是-O1的优化选项,在调用函数中,编译器会将被调用函数div内部的简单表达式语句,替换掉调用函数中的 div(m, n, m %(n+1))语句,即被替换的伪代码如下:
long L=(m+n)/(m%(n+1))
。
这类似于C++中的inline函数得到的编译效果,这样有助于避免了函数调用过程中的产生额外函数栈的时间消耗,下面附上完整的代码,从下面的两点证实了这个说法。
- 红框中的汇编指令集就是等价于 L=(m+n)/(m%(n+1))
-
没有类似 callq div的指令
calc完整汇编代码
总结:
- 我们x86_64过程调用,大量地使用来寄存器来传递参数,甚至保存函数的局部变量,因为寄存器的存取速度远远高于内存访问和内存写入。
- 因此这使得x86_64过程调用的每个函数的栈尺寸非常少,甚至不需要栈。
- 同时我们也通过详细的示例来阐述了一个观点:x86_64架构中,在过程调用会优先x86_64过程调用,其次是使用x86过程调用约定,就这说明了理解x86的程序栈的原理对x86_64的过程调用是非常重要的。
后记:
我原本打算在本文示例中使用double类似作为calc的参数的,但为了简化对栈话题的阐述,大部分写栈话题的软文写手,都会绕开浮点数作为示例(包括我),如果你对浮点数的内存表示有了解过的话,浮点数的入栈操作和数据处理,跟其他基本数据类型是不一样的,甚至有特定浮点数寄存器用于处理它们。在汇编语法中,浮点数有自己一套的特殊指令.如果你打算深入了解类似C中的结构体(或C++的类)在栈内存中是如何分布的,浮点数在汇编层面的操作是个绕不过去的坎,因为我们平时用类来封装算法实现,经常需要使用到浮点数。
所以关于浮点数的内存管理的话题,我有空会再补上,因为光浮点数,足以分开3-4编文章来阐述它。