这篇文章放到现在似乎有点头重脚轻,一直对这些概念只有模糊印象。写下这篇文章也终于算是搞清楚堆栈的细节部分了。
简述
简单来讲,每一个函数调用都有一个对应的栈帧
先后顺序来看依次包括
- arguments 参数
- ret addr 返回地址
- old ebp 上一个栈帧
- local variables/others 局部变量/其他数据
arguments
在x86下,函数调用通常使用堆栈传递参数,因此,在执行一个函数前,首先执行的是将参数压入堆栈,例如执行puts("Welcome!")
push 0x080aaaaa ; "Welcome!", 0
call 0x080ddddd ; puts
同样的,多参传递,采用先进后出(i.e. 从右到左压入堆栈),例如以下执行printf("Hello %s!\n", "Mason")
push 0x080ccccc ; "Mason", 0
push 0x080bbbbb ; "Hello %s!\n", 0
call 0x080eeeee ; printf
call
call指令做了两件事
- 将下一条指令的地址压入堆栈(i.e. 函数执行完成后返回的地址)
- 将eip指向调用的函数
用intel风格描述call指令:
push rip + 4 ; etc. 保存下一条指令
mov rip, foo_addr ; 指向调用函数
local variables / others
进入执行函数后的第一件事就是保存旧的栈帧,而后开辟局部变量或其他数据需要的空间
汇编表示:
push ebp ; 保存上级栈帧
mov ebp, esp ; ebp指向新栈帧
sub esp, 8 ; 开辟栈空间
做完这些,放一个简化版本的栈排布图
要知道的是栈是往低地址增长的
上图做的比较简单,下面结合代码、图文详细说明。
完整过程
假定执行如下代码
#include <stdio.h> int foo(int arg0, int arg1) { int sum; int a = 2, b = 3, c = 4, d = 5; sum = arg0 + arg1 + a * b / c * d; return sum; } int main() { int a, b, c; a = 1; b = 2; c = foo(a, b); printf("c = %d\n", c); return 0; }
堆栈初始排布如下,esp指向当前栈帧栈顶
首先做的就是将函数所需的参数压入堆栈,而后调用call
指令,将返回地址入栈,eip指向调用函数地址。
在执行函数逻辑之前,保存上级栈帧,而后将ebp指向esp
开辟栈空间
直到函数调用完成,下面就是恢复上级栈帧
最后ret返回
一般情况下,下一条执行指令会把esp恢复到函数调用前的指向,在这里即add esp, 8
整个函数调用过程中,堆栈并不会清除内容,而是在下一次调用时直接覆盖,也就是说函数调用完成后相关数据仍然在堆栈上。
real world
实际环境下,需要注意堆栈对齐。
举例而言,有以下函数定义
int foo(int arg0, int arg1) { int sum; int a = 2, b = 3, c = 4, d = 5; sum = arg0 + arg1 + a * b / c * d; return sum; }
去除保护、优化后编译得到汇编如下
函数只需要0x14大小的栈空间,但是编译器给出了0x20的栈空间
这里就需要提到x86调用约定(i.e. x86 calling conventions)
简单来讲就是调用函数时,堆栈必须16字节对齐
发表回复