Back

/ 5 min read

栈帧结构

开始

栈帧结构

x86下的栈结构

文字叙述流程

  1. 参数入栈。push arg0
    • 按照调用约定逐个入栈,例如cdecl是参数从右向左入栈。
    • esp指针按照入栈次数减少值。
  2. 返回地址入栈。esp减少 call xxx
  3. 函数调用,进入函数内部。
  4. 保存ebp寄存器到栈顶。 push ebp
    • esp是全局的栈顶指针,为了在局部开辟栈帧空间,我们需要一个基准点,这个基准点就是ebp也被称为局部栈底指针。
    • 保存ebp之后,我们便可以使用ebp来描述局部栈空间,例如[ebp - 4]表示一个局部变量。
  5. 赋值ebp到当前局部栈的栈底。mov ebp, esp
  6. 开辟局部变量的空间。sub esp, 32h
    • 此时局部变量1: [ebp - 4],局部变量2: [ebp - 8]
    • 此时参数1: [ebp + 8], 参数2: [ebp + 12]
    • 返回地址: [ebp + 4]
    • 原始的ebp值: [ebp]
  7. 保存寄存器环境(可选)。
    • vc6_x86debug版本中,将会固定入栈12个字节的寄存器环境。
  8. 执行函数代码,直到函数将要结束。
  9. 寄存器环境出栈(可选),此时栈顶为ebp原本的值。
    • 注意出栈顺序和入栈顺序相反
  10. 恢复ebp值。pop ebp
    • 此举可以将ebp栈底指针恢复,不再计算局部变量的空间。
  11. 返回ret。出栈return_address
  12. 清理参数导致的堆栈移动痕迹。add esp, 0Ch
大致栈结构

结合代码

函数调用中栈的结构分两部分讨论:

  1. 函数外部
  2. 函数内部

函数外部

// foo函数签名
int foo(int a, uint64_t b);
int main(){
// 进行函数调用
foo(1, 2l);
}

我们输入到godbolt网站中,查看汇编代码:

_main PROC ; COMDAT
; 参数入栈
push 0
push 2
push 1
; 参数入栈结束
call int foo(int,unsigned __int64) ; 返回地址入栈
add esp, 0Ch ; 清理参数导致的堆栈移动痕迹。
xor eax, eax
ret 0
_main ENDP

在函数调用的外部,我们可以看到符合我们的文字描述,进行了:12312这几步。

函数内部

接下来我们关注函数内部的部分:

为了不被优化,这里使用Debug版本的编译。msvc v19

int foo(int a, uint64_t b){
int val = a + b;
printf("aaa");
return 0;
}

汇编代码

_val$ = -4 ; 可以看到这里已经标注了_val的偏移是[ebp-4]
a$ = 8 ; size = 4
_b$ = 12 ; size = 8
?foo@@YAHH_K@Z PROC
push ebp ; 保存ebp值
mov ebp, esp ; ebp指向局部栈
push ecx ; 不是保存寄存器环境 而是将esp - 4开辟了空间
mov eax, DWORD PTR _a$[ebp]
cdq
add eax, DWORD PTR _b$[ebp]
mov DWORD PTR _val$[ebp], eax
push OFFSET $SG33737
call _printf
add esp, 4
xor eax, eax
mov esp, ebp
pop ebp ; 恢复ebp值 esp+=4
ret 0 ; 跳转到返回地址 esb+=4
?foo@@YAHH_K@Z ENDP

这里面需要注意的是push ecx这一条指令,一般来说这都是保存寄存器环境,然后在函数内部使用ecx寄存器,最后将ecx还原。 但是这里我们没有看到还原的过程,且ecx是一个易失寄存器,一般来说不需要保存环境。 所以这里的作用主要是将esp4个字节,这是一种编译器优化,用更短的指令替换。