ICS 与 8086 复习 13:函数调用与栈帧
add(3,5) 看起来只需要一次加法,为什么反汇编中却会出现 push、mov、leave 和 ret?函数调用不仅要完成计算,还要解决参数放在哪里、返回到哪里,以及原来的寄存器内容由谁保存。考试中的栈帧题,正是在追踪这些约定。
本文先使用 i386 System V ABI、32 位 Linux、传统 EBP 栈帧解释,再对比 x86-64。EBP 栈帧是课程中的常见模型,不是所有优化后二进制都必须采用的形式。
CALL 和函数序言分别做了什么
32 位近调用的核心动作是把下一条指令地址压栈,再跳到目标函数。RET 则从栈中取回返回地址并继续执行。参数传递和旧 EBP 的保存是外围调用约定及函数代码的工作,不能全算在 CALL 指令身上。
典型函数开头如下:
pushl %ebp
movl %esp,%ebp
subl $16,%esp
第一条保存调用者的 EBP,第二条建立本函数帧基址,第三条预留 16 字节局部空间。栈向低地址增长,所以分配空间通常使 ESP 变小。
在这一模型中:
| 相对 EBP 的位置 | 典型内容 |
|---|---|
0(%ebp) |
调用者保存的旧 EBP |
4(%ebp) |
返回地址 |
8(%ebp) |
第一个栈参数 |
12(%ebp) |
第二个栈参数 |
| 负偏移一侧 | 局部变量和其他保存内容 |
具体局部变量偏移取决于布局、对齐和编译器。不能从 C 声明顺序推导出所有栈槽顺序。
一道完整的地址跟踪题
假定调用前 ESP=1000H、EBP=2000H,调用点下一条指令地址为 08049010H。调用者传入 add(3,5),用以下简化序列示意,暂不加入 ABI 栈对齐填充:
pushl $5
pushl $3
call add
addl $8,%esp
先压第二个参数,ESP 变为 0FFCH,该处存 5;再压第一个参数,ESP 为 0FF8H,该处存 3。CALL 压入返回地址后,ESP 为 0FF4H,内容是 08049010H。
被调用者执行 pushl %ebp; movl %esp,%ebp 后,ESP 和 EBP 都是 0FF0H。所以 EBP+8=0FF8H 正好定位参数 3,EBP+12=0FFCH 定位参数 5。
add:
pushl %ebp
movl %esp,%ebp
movl 8(%ebp),%eax
addl 12(%ebp),%eax
leave
ret
算出 EAX=8 后,leave 在这个 32 位模型中相当于 movl %ebp,%esp; popl %ebp:丢弃本帧局部空间并恢复旧 EBP。接着 ret 取出返回地址,ESP 回到 0FF8H。调用者再加 8,回到最初的 1000H。
画题时应把每次“ESP 改变”和“该地址存入什么”分别写清楚。栈恢复不仅是数值碰巧回到原值,还要确保返回地址和保存寄存器被正确取出。
谁保存寄存器 谁清理参数
课程中的 i386 System V 约定把 EAX、ECX、EDX 视为调用者保存寄存器:调用者若还需要它们原来的值,应在调用前保存。EBX、ESI、EDI、EBP 等由被调用者在使用并改写时负责恢复。EAX 常用于整数或指针返回值。
“调用者保存”不表示每次调用都必须把该寄存器压栈,而是调用者不能假定它跨调用保持不变。若旧值已无用,就没有保存的必要。
清理栈参数也由调用约定决定。课件的 i386 C 例子常由调用者清理;一些 Windows 32 位调用约定由被调用者清理。辨认 ret n、调用后的 ESP 调整,以及完整约定,才能判断是否匹配。中文“堆栈”常用来指 stack,不能把它解释成 heap 与 stack 拼在一起。
栈帧题的保存顺序怎样检查
假定函数还要改写 EBX,它可以在建立 EBP 后执行 pushl %ebx。这个动作占用栈空间,也让局部区布局发生变化。结束前必须从对应位置恢复 EBX,并正确撤销局部空间,之后才恢复 EBP 和返回。若直接执行 leave 丢弃局部区,却忘了取回保存的 EBX,即使 ESP 和返回地址看上去正确,仍然违反被调用者保存约定。
因此画栈图时不要只标“参数区”和“局部区”两个大框。应把返回地址、旧帧指针、保存寄存器、对齐填充和局部对象逐项定位。很多偏移题的差异来自多压入一个寄存器,并非处理器换了一种寻址方式。
再看栈对齐。完整 ABI 会要求调用点或被调用入口处满足一定边界;编译器可能用 and 调整 ESP,或额外预留空间。相减的数值可能超过源代码局部变量大小之和。所以看到 sub $24,%esp,不能直接断言函数声明了六个 int;这 24 字节中还可能有参数预留区和填充。
按值和传指针都仍是传一个值
swap(int x,int y) 接收到的是两个整数副本,交换副本不会改变调用者变量。swap(int *x,int *y) 接收到的同样是副本,但副本是地址,函数能够沿地址访问原对象。
void swap(int *x, int *y) {
int t = *x;
*x = *y;
*y = t;
}
假定两个参数都指向有效的 int 对象,这段代码的关键是两级动作:先读出参数中的地址,再读取或修改该地址指向的数据。汇编中 movl 8(%ebp),%eax 取到的是指针,之后 movl (%eax),%edx 才取得对象内容。
递归调用也遵循相同规则:每次调用拥有各自的局部状态和返回地址。递归深度增加通常带来更多栈空间;优化可能消除某些调用,因此不能把某份反汇编的字节开销当成语言固定成本。
另一个易错点是返回局部自动对象的地址。函数返回后,该对象的生存期已经结束;后来观察到原地址处“数据还在”,不代表继续使用该指针有效。反汇编可以解释字节暂时没有被覆盖,却不能恢复已经结束的对象生存期。复习时把“内存中还残留什么”和“语言允许访问什么”分开,能同时看懂实验现象与正确程序边界。
进入 64 位后先确认 ABI
Linux 等采用的 System V AMD64 ABI 中,前六个适合通过通用寄存器传递的整数或指针参数,通常依次使用 RDI、RSI、RDX、RCX、R8、R9;标量浮点参数使用 XMM 寄存器。Windows x64 的前四个参数位置采用 RCX、RDX、R8、R9 或相应 XMM 寄存器,并有不同的栈约定。
因此,64 位函数的第一个参数不再普遍位于 [RBP+8]。复杂聚合类型、可变参数、返回值等还要按 ABI 分类。课程复习先掌握两种平台的主要区别,再查完整 System V ABI 规范入口和 Microsoft x64 调用约定。
自测与简答
- 在传统 32 位 EBP 栈帧中,为什么第一个参数通常从
EBP+8开始? - 调用结束后 EAX 的旧值还能直接依赖吗?
CALL会自动保存调用者所有通用寄存器吗?
答案:① EBP+0 放旧 EBP,EBP+4 放返回地址,之后才是参数。② 在所述 i386 ABI 中不能;EAX 是调用者保存寄存器,也常承载返回值。③ 不会;CALL 的核心是保存返回地址和转移控制,其他保存由代码及约定完成。
资料对应
主要依据《4.程序的机器级表示-V1.0.pptx》第 4—19、22—35、41—47、55—59 页,以及《3.程序转换与指令系统-V1.0.pptx》第 68、85 页。地址跟踪为简化算例,专门省略了栈对齐填充,不能直接用来推导完整函数调用的所有 ABI 布局。
讨论这篇文章
关于《ICS 与 8086 复习 13:函数调用与栈帧》的补充、不同意见或延伸话题,都可以留在这里。
滚动到这里时自动载入留言。