80x86汇编与C语言-3

官方信息技术老师·14 页·深入(追求细节与边界)·0 次浏览·3 天前
汇编逆向栈帧机制调用约定

80x86汇编与C语言-3

透彻理解C代码在80x86上的汇编映射,掌握栈帧、调用约定与反汇编逆向

按 空格/→ 演示下一步

1 / 14 页

全部页面点击任意一页,跳回舞台从这页播放

汇编逆向栈帧机制调用约定

80x86汇编与C语言-3

透彻理解C代码在80x86上的汇编映射,掌握栈帧、调用约定与反汇编逆向

1第 1 页 · 80x86汇编与C语言-3

程序运行栈的基本操作-1

调用函数时,CPU要记下「等会回到哪继续执行」,函数里又要腾出空间放临时变量——这些都靠一块由硬件辅助管理的特殊内存区域:程序运行栈。

栈的本质
一段内存区域,遵循 LIFO(后进先出),x86 上向低地址增长
ESP 栈顶指针
专用寄存器,始终指向当前栈顶
push 压栈
ESP 先减 4,再把数据写入新栈顶
pop 出栈
从栈顶读出数据,ESP 再加 4 回收空间
典型用途
保存返回地址、传递参数、存放局部变量与现场寄存器
弹簧式餐盘架对应 →程序运行栈

餐盘只能从顶部放入或取出,弹簧压住的那块就是栈顶——对应 ESP 始终指向栈顶

2第 2 页 · 程序运行栈的基本操作-1

程序运行栈的基本操作-2

上一页只动了 ESP(压栈出栈),没解释「函数调用怎么自动完成」。这一页补全:调用方如何跳过去,被调方如何跳回来,以及中间的栈空间怎么划分。

CALL 指令
调用时压入返回地址(下一条指令),再跳转到目标函数
RET 指令
从栈顶弹出返回地址送入 EIP,跳回 CALL 的下一条
栈帧(Frame)
以 EBP 为锚点的数据区:参数、返回地址、局部变量都在这
序言与尾声
Prologue 三步建帧;Epilogue 用 LEAVE+RET 一并还原
临时工位轮转对应 →CALL/RET 与栈帧

开会时放下旧工位牌、领新牌、用桌面放资料;散会还原工位、按便签回原处

参数:[EBP+8],[EBP+12]局部变量:[EBP4],[EBP8]\text{参数:}[EBP+8],[EBP+12]\ldots\quad\text{局部变量:}[EBP-4],[EBP-8]\ldots
3第 3 页 · 程序运行栈的基本操作-2

过程调用与栈

过程调用与栈:定义、要点与典型应用

过程调用与栈
过程调用与栈:定义、要点与典型应用
4第 4 页 · 过程调用与栈

栈帧

上一页我们看到 call 把返回地址压栈、ret 又把它弹出——但一次调用在栈上占多大地方?里面又放些什么?这一格「函数专属的栈区」就是栈帧。

栈帧定义
一次函数调用在栈上独立划分的一块区域,装着这次调用所需的全部上下文
典型布局
从高到低:实参、返回地址、保存的EBP、局部变量;EBP是固定锚点
双指针分工
ESP追着 push/pop 跑,EBP 一旦建好就不动,方便用偏移定位参数和局部量
序言与尾声
进入函数建帧(push ebp; mov ebp,esp; sub esp,N),退出时反向拆帧
存在的理由
让每次调用数据互不串扰,并支持递归、回调等可重入场景
酒店客房对应 →栈帧

每次入住分一间房,退房收回;EBP 像固定房号,方便定位行李,行李不会混进隔壁房间

局部变量地址=EBPk,形参地址=EBP+k\text{局部变量地址}=\text{EBP}-k,\quad\text{形参地址}=\text{EBP}+k
5第 5 页 · 栈帧

一个实例

前几页我们拆开了栈帧的零件——参数压栈、保存 ebp、call 推返回地址。这页把这些零件装回原位,看一段真实的 C 函数怎样被翻译成汇编指令。

C 源码示例
add(int a,int b){return a+b;},main 调用 add(3,4)
cdecl 参数压栈
实参从右往左依次 push:先压 b=4,再压 a=3
栈帧 prologue
被调函数进门先 push ebp、mov ebp,esp 立新帧底
call 推返回地址
call add 把 call 的下一条指令地址压栈后跳转
epilogue 拆栈帧
mov esp,ebp 回收局部;pop ebp 还原旧帧;ret 跳回 main
酒店入住与退房对应 →函数栈帧的建立与销毁

入住=prologue 记旧房号领新钥匙;退房=epilogue 归还钥匙回到自己房间

6第 6 页 · 一个实例

寄存器使用惯例

拆完栈帧我们看到调用前后寄存器值变了。问题来了:哪些寄存器被调函数必须原样还回来,哪些可以随手改写?分开编译的模块靠什么对接参数和返回值?这就是寄存器使用惯例要约定的事。

约定的必要性
独立编译的模块靠约定对接,让参数、返回值、寄存器状态正确传递
Caller-saved
EAX/ECX/EDX:被调函数可任意修改,调用前若需保留须自己保存
Callee-saved
EBX/ESI/EDI/EBP:被调函数必须保证进出值不变,常用作帧指针
参数与返回值
32位cdecl:参数从右向左压栈,返回值放EAX
借同事工位办公对应 →寄存器使用惯例

工位上原有物品(callee-saved)借完要放回,自己带的(caller-saved)丢了自负

7第 7 页 · 寄存器使用惯例

一个递归调用的实例-1

有了栈帧和寄存器使用惯例的基础,我们来看一个完整的递归调用实例。从最简单的阶乘函数入手,看每次递归调用时栈帧是如何一层层叠加又缩回的。

阶乘的 C 代码
最简单的递归函数:n! = n * (n-1)!,边界条件 n<=1 时返回 1
每次调用新建栈帧
call 指令压入返回地址,保存旧 EBP,为局部变量分配空间
递归必须有出口
n<=1 的判断决定了递归何时停止,没有它栈会无限增长直至溢出
参数通过栈传递
实参 n 在 call 前压栈,函数内通过 [ebp+8] 读取参数值
返回值在 EAX
计算结果存放在 EAX,ret 指令弹出返回地址跳回调用者继续执行
俄罗斯套娃对应 →递归调用的栈帧

外层套娃对应外层调用,内层是递归进入的下一层;打开是进入递归,合上是逐层返回

8第 8 页 · 一个递归调用的实例-1

一个递归调用的实例-2

上一节我们看到 factorial(3) 的入口代码如何保存寄存器、建立自己的栈帧。这一节接着看:当它发现还需要继续算时,如何「叫醒」下一个自己——也就是递归自调用是如何发生的。

参数准备
调用前把下一层的参数 n-1 压栈,作为新栈帧的入口实参
CALL 指令
CALL 把返回地址压栈并跳转,形成调用链的一环
EBP 链
每次保存的 EBP 形成链表,把所有栈帧串成一棵调用树
基准情形
当参数等于终止值时进入基准分支,停止继续递归
一摞书一本本往上摞对应 →递归调用栈帧层层建立

每摞一本对应一次递归调用,最底下的先放最后才能取

9第 9 页 · 一个递归调用的实例-2

另一个递归调用的实例-1

上例的 n! 是「单链」递归,每层只调自己一次。这次换成斐波那契 fib(n)=fib(n-1)+fib(n-2)——每层同时展开两个子调用,调用栈会变成什么样?

问题定义
fib(0)=0, fib(1)=1, fib(n)=fib(n-1)+fib(n-2)
双分支递归
每层同时产生两个子调用,调用树呈二叉展开
返回值合并
两个子结果相加后才算本层结果,逐层向上回传
指数复杂度
递归深度仅 n,但调用次数呈 O(2^n) 爆炸
领导等两份部门报告才决策对应 →斐波那契的双递归调用

领导(调用层)必须等两个下属(两次递归)都交报告,再合并上报给上级

fib(n)={0n=01n=1fib(n1)+fib(n2)n2fib(n) = \begin{cases} 0 & n = 0 \\ 1 & n = 1 \\ fib(n-1) + fib(n-2) & n \geq 2 \end{cases}
10第 10 页 · 另一个递归调用的实例-1

另一个递归调用的实例-2

上一个例子我们看到调用链一路压栈到基例。这一页反过来走:基例算出结果后,栈帧怎么逐个弹出、返回值怎么一层层回传上去。

逆序返回
递归返回顺序与调用顺序相反,严格遵循栈的 LIFO
返回值通道
子调用结果放 eax,父调用在 call 之后紧接 mov 取走
现场恢复
每层返回前还原保存的 ebx/ebp,esp 升回调用前位置
残留数据
弹掉的栈帧内存并未清零,旧值可能被新调用覆盖
穿脱多层衣服对应 →递归压栈与弹栈

穿时从里到外一件件套上,退时从外到里一件件脱下

11第 11 页 · 另一个递归调用的实例-2

x86-32过程调用小结

看了两个递归调用的完整流程后你会发现,每一层进入都建栈帧、退出都拆栈帧——背后的规则其实非常一致。下面把 x86-32 过程调用涉及的全部约定归为五条核心要点。

栈帧三件套
%ebp 是本层基准,%esp 指向栈顶,返回值固定走 %eax。三者共同定义「我这一层」长什么样。
参数右向左
实参按反序压栈,callee 用 [ebp+8]、[ebp+12]... 依次取出第 1、2... 个参数。
返回值出口
标量返回值在 %eax,64 位整数用 %edx:%eax;结构体由调用者分配空间,callee 填指针。
寄存器两类
eax/ecx/edx 是调用者保存,ebx/esi/edi/ebp 是被调用者保存,后者 callee 必须先存后还原。
call/ret 双指令
call 自动压入下一条指令地址并跳转;ret 自动弹该地址并返回,进出栈帧由硬件托管。
银行窗口办业务对应 →过程调用

材料按反序压栈,柜员在固定工位(%ebp)办事,办完回执从窗口(%eax)推出,再清理桌面(拆栈帧)。

12第 12 页 · x86-32过程调用小结

本节要点

  • 栈帧是过程调用的核心:参数、返回地址、局部变量按固定布局
  • 寄存器分两类:caller-save与callee-save
  • 递归本质是反复构造栈帧,深度受栈空间硬性限制
  • cdecl惯例:参数从右往左压栈,调用者负责清理
延伸主题:cdecl与stdcall的差异栈溢出与canary保护机制尾调用优化
13第 13 页 · 本节要点

课后思考

先独立想一遍再看参考答案,重点体会问题背后的设计权衡。

1如果一个函数没有调用任何其他函数(叶子函数),编译器通常会省略哪些栈帧建立步骤?

参考答案叶子函数无需保存 ebp(可用 esp 直接寻址),也无须为被调用者保存寄存器预留空间;多数编译器会省去 push ebp / mov ebp,esp 这两步。

2如果把递归函数改成等价的循环版本,栈空间需求会如何变化?哪种场景下递归不可替代?

参考答案循环版本通常只需常数级栈空间,递归版本随深度线性增长。但树遍历等本质递归问题,循环需自维护栈,反而更复杂,这时递归不可替代。

3x86-64 调用约定新增了被调用者保存寄存器,但栈帧结构大体相同——为什么 ABI 升级了,栈的设计哲学却没变?

参考答案新增寄存器是为了减少调用开销(更多数据放寄存器、少压栈),但调用栈「保存现场、层层返回」的本质没变——栈仍是表达调用嵌套最自然的结构。

14第 14 页 · 课后思考