堆栈平衡与对齐
C语言的种种调用约定中,以__cdecl最为常见,本小节以__cdecl约定为切入点,剖析计算机在函数调用过程的细节,主要分析堆栈的状态变化。
调用过程主要分以下几个阶段【1】:
参数压栈
自右向左将参数逐一压栈。在编译过程中,编译器会根据压栈参数所占用的空间大小,生成相应的代码,用于函数返回时释放这段空间。释放空间的本质只是将esp指针加上释放的长度,并不对释放的空间做清空处理。
函数调用
在本阶段,处理器将执行 call
指令,具体操作是:将指令寄存器eip压栈,并跳转到被调用函数的起始地址。相当于连续执行了 push
和 jmp
指令。
处理ebp寄存器
ebp作为基址指针寄存器(base pointer【2】),最常见的应用场景便是通过对ebp附加偏移来访问堆栈中的变量。对于一个特定的函数而言,ebp通常是不变的。这一阶段常见的操作是:
push ebp
mov ebp, esp
分配局部变量空间
局部变量存在于堆栈中,这也是局部变量的访问往往比全局变量更快的原因。为局部变量分配空间的操作很简单:
sub esp, # ;'#'号表示一个立即数,是局部变量占用总空间的大小
保护寄存器状态
由于x86架构的CPU只有8个通用寄存器,对于函数间的切调用操作,需要将原先的寄存器状态保存起来,以免调用结束后,调用者的运行异常。通常是几个push指令。
执行函数
在此过程中,代码所访问到的局部变量和函数参数都是通过ebp的偏移进行寻址。如,第一个局部变量的地址是(ebp - 4),第一个参数的地址是(ebp + 8)。而函数的返回地址则保存在(ebp + 4)中。
恢复寄存器状态
函数功能执行完毕后,准备返回,此时可以将之前保存的寄存器值恢复到对应的寄存器中。一般通过pop指令实现。
释放局部变量空间
函数功能执行完毕后,需要释放局部变量的空间,以保持堆栈平衡。与分配局部变量空间类似,通常只是将esp寄存器加上一个值:
add esp, #
处理ebp寄存器
对应的,要恢复调用者的ebp值,执行操作:
pop ebp
函数返回
这是子函数的最后一步操作,执行ret
指令,将eip寄存器从栈中弹出,并跳转到eip指向的地址。若函数有返回值,一般会存放在eax寄存器中。
释放参数空间
对于__cdecl约定而言,释放参数空间的任务是由调用者来执行的。调用者将栈中的参数pop到无关紧要的寄存器中,或者,更常见的是直接将esp寄存器加上参数占用的总空间大小,类似于子函数清空局部变量空间。
堆栈对齐
不久前在做一个RGB->YUV的SSE优化,得知SSE的许多指令都要求访问的内存地址是16字节对齐的。对于全局变量而言,我们可以轻易地利用 #pragma pack(n)
或者 __attribute__(aligned(n))
告知编译器进行对齐。但对于在堆栈中的变量而言,编译器不可能得知运行时堆栈指针的状态,所以也就无法在编译时进行对齐。
思路1:进入函数后,将ebp寄存器对齐到16字节,然后再分配局部变量空间。
分析:如果这么做,那局部变量将可以通过ebp的偏移来访问,并且是16字节对齐,但在函数执行之前入栈的参数就无法正确访问了,因为编译器默认(ebp + 8)是第一个参数。如此看来,在函数内部进行处理似乎不太容易实现栈对齐。那么,如果在函数外部做一些处理呢?我们来看看开源编码器x264是怎么做的【3】:
;-----------------------------------------------------------------------------
; void stack_align( void (*func)(void*), void *arg );
;-----------------------------------------------------------------------------
cglobal stack_align
push ebp
mov ebp, esp
sub esp, 12 ;预留堆栈空间,以足够保存三个参数
and esp, ~31 ;32字节对齐
mov ecx, [ebp+8] ;将原函数指针存入ecx
mov edx, [ebp+12] ;将三个参数重新存入栈中,此时不能用push指令
mov [esp], edx
mov edx, [ebp+16]
mov [esp+4], edx
mov edx, [ebp+20]
mov [esp+8], edx
call ecx ;调用原函数
leave
ret
我们看到,x264通过传入函数指针和函数参数(三个),在真正调用函数前进行堆栈对齐,以解决ebp访问参数错误的问题。其核心思想是将参数保存到正确的位置。但是以上实现要求参数的个数不得多于3个,对于更多参数,可以增加以下指令组合:
mov edx, [ebp+n]
mov [esp+n-12], edx
以此为参考,我们可以实现函数内部的堆栈对齐,具体实现如下:
push ebp
mov ebp, esp ;正常的函数入口
mov eax, ebp ;保存ebp,便于后续的搬运工作
sub esp, 4 ;预留空间,用于保存此时的ebp,便于恢复ebp
and esp, ~31 ;32字节对齐
mov ebp, esp ;对齐ebp
mov edx, [eax]
mov [ebp], edx ;搬运调用者的ebp
mov edx, [eax+4]
mov [ebp+4], edx ;搬运eip
mov edx, [eax+8]
mov [ebp+8], edx ;搬运第一个参数
mov edx, [eax+12]
mov [ebp+12], edx ;搬运第二个参数
;mov edx, [eax+n]
;mov [ebp+n], edx ;搬运更多的参数
mov [ebp+n+4], eax ;将原ebp保存至堆栈,便于后续平衡堆栈
sub esp, 16 ;分配局部变量空间,16可以为其他值
;执行函数功能
mov ebp, [ebp+n+4] ;恢复原ebp
mov esp, ebp ;释放局部变量空间并恢复原esp
pop ebp ;恢复调用者的ebp
ret
思考:能否实现任意参数函数的堆栈对齐,并以宏的方式提供接口?如:
__ALIGN_CALL__(foo(arg1, arg2, arg3, ...));
参考
【1】 http://www.unixwiz.net/techtips/win32-callconv-asm.html
【2】 http://www.swansontec.com/sregisters.html
【3】 http://git.videolan.org/?p=x264.git;a=blob_plain;f=common/x86/cpu-a.asm;hb=refs/heads/master
Post Info
- Copyright Notice: Creative Commons BY-NC-ND 3.0