最近翻到了一些是与函数栈相关的笔记,补充一下栈安全/预留空间的知识,算是文章《栈和局部变量》的拾遗吧。
1、红区(red zone)
先看下面的一段信号处理代码,其中leaf()是叶子函数,linux/windows均可运行。
#include <signal.h>
#include <stdio.h>
void signal_handler(int signal) {
int tmp;
printf("hand: %p\n", &tmp);
}
int leaf(int dividend) {
int ar[28];
int sum = 0;
for (int i=0; i<sizeof(ar)/sizeof(int); i++) {
sum += ar[i];
}
return sum/dividend;
}
int main() {
::signal(SIGFPE, signal_handler);
int x = 0;
printf("main: %p\n", &x);
x = leaf(x);
printf("%d\n", x);
}
下面是GCC -O1 -fno-inline编译后生成的汇编代码,为节省篇幅,去掉了部分不相干指令:
signal_handler(int):
sub rsp, 24
lea rsi, [rsp+12]
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
add rsp, 24
ret
leaf(int):
lea rdx, [rsp-120]
lea rcx, [rsp-8]
mov eax, 0
......
ret
main:
sub rsp, 24
....
mov DWORD PTR [rsp+12], 0
lea rsi, [rsp+12]
mov edi, OFFSET FLAT:.LC1
mov eax, 0
call printf
mov edi, DWORD PTR [rsp+12]
call leaf(int)
......
ret
某次运行的结果输出:
main: 0x7fff324ce00c // main中局部变量x的地址
hand: 0x7fff324cda2c // signal_handler中局部变量tmp的地址
根据x的地址,即汇编代码第18、19行:[rsp+12],那么rsp+12=0x7fff324ce00c,显然可得到在main()函数的栈顶即rsp的值为0x7fff324ce00c-12=7fff324ce000。当main()调用leaf()时传入的参数经过edi寄存器传递,没有使用栈空间,不过执行call指令时,需要把调用完leaf()函数返回地址放入栈中,地址长度是8字节,因此当进入leaf()叶子函数时,rsp的值是0x7fff324ce000-8,即leaf()函数的栈底值,也是栈顶值(因为没有为叶子函数的局部变量分配栈空间,rsp没有变化)。
现在看一下在发生除零异常进入signal_handler()时,它里面的局部变量tmp的地址:0x7fff324cda2c,而tmp的地址是rsp+12(汇编代码第3行),那么此时在signal_handler()中栈顶rsp的值是0x7fff324cda2c-12 = 0x7fff324cda20,在进入signal_handler()时,它的栈底值是0x7fff324cda20+24,第2行rsp-24是栈顶,rsp+24即为栈底位置。
leaf()的栈顶()和signal_handler()的栈底之间的距离是:(0x7fff324ce000-8)-(0x7fff324cda40+24) = 0x5a0=1440字节,由此可见,叶子函数leaf()不能把所有剩余的栈空间当作是自己的空间。从分析计算结果来看,在这里为了方便,我们假设操作系统调用signal_handler()处理信号时,再也没有占有其它的空间了(当然是不可能的),那么leaf()最多有1440字节的栈可用空间。也就是说,如果leaf()不分配栈空间的话,访问的局部变量栈空间超过了1.4k,就有可能在捕获信号后,这个地方的值被覆盖了,因此,这样是不安全的。实际上,GCC编译器是这样处理的:只要叶子函数使用的栈空间超过了128字节,就得分配栈空间,128字节以内不用分配,这个128字节的栈空间就是“红区(red zone)”。相当于和操作系统有个约定,如果进行信号处理时,不要使用函数栈顶之外(即rsp的低地址方向)128字节(即[rsp-128] ~ [rsp])以内的空间,以保证安全。
因此,如果一个函数分配了64字节的栈空间,即执行了“sub rsp, 64”之后,这64字节是为了这个函数的局部变量分配的,如果它还想要使用一些额外的栈空间,只要不超过128字节,可以不需要分配,直接通过[rsp-偏移量]来使用,是安全的。此时,函数的栈内存布局如下图所示:
带有红区的栈空间布局图
由上图可见,对于一个函数,它的“红区”部分尽管没有分配过,也可以安全使用。比如,在函数运行时,需要使用到寄存器rax,但是rax已经存放别的数据了,一般都是让它先进栈缓存起来,使用完毕后再出栈,即:
push rax ; 缓存rax到栈中
....使用rax
pop rax ; 恢复rax原值
有了安全红区之后,也可以这样:
mov qword ptr [rsp-8], rax ; 缓存rax到红区
... 使用rax
mov rax, qword ptr [rsp-8] ; 从红区恢复rax原值
其中执行push和pop操作各需要3个和2个时钟周期,而执行mov指令各需要2个时钟周期(注:指令耗时来自Ivy Bridge架构CPU。不过push操作需要先对rsp进行减法操作,然后再用计算结果作为地址去访问栈,可能会有AGI(Address Generation Interlock)锁,导致还要延迟1个时钟周期,才能完成写入,不同于mov qword ptr [rsp-8], rax)。不过,除了叶子函数之外,我好像没发现过编译器在非叶子函数中生成过这样的汇编指令。
结合前面的“带有红区的栈空间布局图”,低于栈顶指针rsp的128字节之后的栈空间被认为是“volatile”和“unsafe”的——操作系统、调试器、终端处理程序等都有可能侵占该区域,比如调试程序时保存调试信息的地方,或者信号处理程序的栈空间(如本例)。凡是在超过了128字节的空间,都有可能被覆盖,所以为了安全,在叶子函数中如果局部变量空间超过了128字节,编译器就要调整rsp的位置了,以保证安全。下面我们看一下。
我们在leaf()中把局部变量分配的空间大一点,比如把int ar[28],改成int ar[29],观察相应的汇编代码的情况。先看一下ar[28]时的汇编代码,以作比较:
而改成ar[29]时:
此时,开始分配空栈空间了,不过分配的很少,只有16字节,加上“红区”的128字节,共144字节,告诉操作系统[rsp-128]~[rsp]空间已经为函数分配,你不要侵占。显然编译器认为29*4=116字节,8字节对齐120,加上sum的占用的空间4字节,也是8字节对齐,共128字节(也可能数组ar和sum分配空间时都需要16字节对齐,这样就128+16=144字节了),编译器认为可能进入红区了,就分配了16字节的栈空间,这样就安全了。
因此,前面文章中说,叶子函数中可以不使用指令“sub rsp, 立即数”来为局部变量分配栈空间,这个说法是不准确的,只有在局部变量的空间小于128字节时,说法才成立,也就是在[rsp-128]~[rsp]这段栈空间内,是可以直接使用的。如果超过了这段空间范围,就不安全了,可能会被操作系统修改,因此,如果局部变量空间大于了128字节,GCC编译器就要使用指令“sub rsp, 立即数”来分配栈空间了,因为已经有128字节的安全区了,在分配时也不用满打满算的分配,可以扣除这128字节的范围,比如,如果需要140字节,只需要分配140-128=12,8字节对齐即16字节就可以了,那就使用指令:sub rsp, 16。
是否支持红区,和编译器环境及处理器都有关系,遵循x86-64 ABI的编译器会提供红区。因此,64位x86在GCC和CLANG中有红区,但在MSVC中没有,在32位x86上,也没有红区的概念。使用GCC编译x86-64代码时,如果不需要红区,可以使用编译选项-mno-red-zone禁止该功能,比如编译linux内核时,就不需要红区,可能是因为中断服务例程有自己的栈空间,不需要占用被中断函数的栈空间吧,可以使用该编译选项。
下面是在windows环境下支持红区的Arch,来自参考资料1。
2、影子参数空间(shadow space)
x86处理器在windows是没有红区的,当然也可以说它的红区的空间为0,见上节列表。我们使用MSVC编译器编译上面的测试程序,看一下栈空间的布局。leaf()函数的汇编代码如下图示,可见,编译器为leaf()叶子函数分配了栈空间,在第33行为局部变量共分配了136个字节的栈空间。
leaf()函数汇编代码
尽管没有红区,但是MSVC编译器为一个函数的参数准备了“影子参数空间”,也叫“影子参数”,是针对函数参数保留的空间,也是在栈中分配,不过不是在被调函数中,而是在调用者函数中的栈帧中分配的,大小固定为32字节。在MSVC编译环境下,调用函数时,前4个参数分别使用rcx、rdx、r8和r9来传递,多于4个的参数就通过栈进行传递,调用方在调用函数时,会为前四个参数额外准备存储空间,它们和其它参数的栈空间连在一起。如下图示,尽管main()函数中只有一个int型的局部变量x,但是却分配了56字节的栈空间,其中32字节是为leaf()函数的影子参数准备的。
main()函数汇编代码
这样,在被调方函数中可以根据需要把这些传递参数的寄存器放入它们对应的影子参数中。比如,在leaf()函数汇编代码第32行:mov dword ptr [rsp+8], ecx,把ecx存放到[rsp+8]的位置处,该位置就像是调用方通过栈空间传参时第一个参数的位置,而rcx寄存器存放了第一个参数,因此它就是为rcx预留的影子参数空间位置,显然[rsp+16]、[rsp+24]、[rsp+32]就是为寄存器rdx、r8、r9预留的位置。
在“leaf()函数汇编代码”图中,ecx存放了参数dividend的值,但是在47行要用到ecx寄存器,因此,在32行把它缓存起来(位置:rsp+8),在第55行在进行除法操作时,又从影子参数中取出来(位置:rsp+144,因为rsp在33行减去了136),而不再使用push和pop指令来保存和恢复ecx的值了。
这个32字节的空间是调用者在调用函数时在自己的栈帧中预留的,不管被调用方是否使用,都要预留。如下图示,函数foo()调用了leaf()函数,foo()中并没有使用任何局部变量,也不知道leaf()函数是否用的着影子参数,但是仍然分配了40字节的栈空间,即63行箭头所指的指令,其中32字节是为leaf()函数准备的影子空间。
被调函数也可以自由使用这段空间,不见得必须是函数参数才能使用,这样,在一个函数中要使用临时栈空间,如果这32字节的空间够用的话,就可以不需再另行分配栈空间了,比如对于上一节的例子:
push rax ; 缓存rax到栈中
....使用rax
pop rax ; 恢复rax原值
在Windows环境中,有了影子空间之后,也可以这样处理,同样可以避免进栈出栈的操作:
; 偏移量16是因为rbp和函数返回地址各占8字节的空间
mov qword ptr [rbp+16], rax ; 缓存rax到第一个影子参数中
... 使用rax
mov rax, qword ptr [rbp+16] ; 恢复rax原值
GCC、CLANG等遵循x86-64 ABI的编译器没有“影子空间”的概念。MSVC编译器有,Win64调用规范约定:由调用方函数分配暂存空间(影子参数)给被调用方函数使用。
由此,函数也有一部分的栈空间是不用分配就能直接使用的,其中红区可以看作是编译器和操作系统的约定,在栈顶以下128字节的空间可以不用分配就能直接使用,操作系统不会侵占这个空间,而影子参数是调用者在调用子函数时,为子函数的参数准备的备用空间,子函数可以直接访问这段32字节的空间。
参考:
1、https://devblogs.microsoft.com/oldnewthing/20190111-00/?p=100685
2、《现代x86汇编语言程序设计》 作者:丹尼尔-卡斯沃姆
3、Intel软件开发者手册