80x86汇编与C语言-3(续)

官方信息技术老师·21 页·深入(追求细节与边界)·0 次浏览·2 天前
栈帧机制调用约定x86汇编C语言

x86-64过程调用与运行栈

看清栈帧从创建到销毁,每一行汇编在做什么

按 空格/→ 演示下一步

1 / 21 页

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

栈帧机制调用约定x86汇编C语言

x86-64过程调用与运行栈

看清栈帧从创建到销毁,每一行汇编在做什么

1第 1 页 · x86-64过程调用与运行栈

为什么需要运行栈

call/ret 看上去只是两个指令,背后却隐藏一整套内存管理子系统。为什么必须用栈?为什么不能像全局变量那样直接放在 .data 段?这一页从内存管理的视角拆开来看。

调用嵌套天然LIFO
A 调 B,B 调 C——C 必须先返回 B 才能返回。栈的后进先出正好对应这种嵌套语义。
退栈即自动回收
函数返回时栈帧自动弹出,局部变量随帧消失而释放,原则上不存在泄漏的可能。
硬件原生支持
RSP 寄存器加 push/pop/call/ret 指令,CPU 把栈当作一等公民;堆则要靠库函数模拟。
堆是手动管理
malloc 必须配对 free,忘记 free 就泄漏;堆还会碎片化,分配比栈慢一个数量级。
连续布局缓存友好
栈帧紧挨着分配,CPU 预取命中率极高;堆地址散乱,L1/L2 缓存经常 miss。
餐厅叠放餐盘对应 →函数调用栈

后放的盘子在最上面,洗碗工先拿走;最新调用的函数也最先返回

2第 2 页 · 为什么需要运行栈

x86-64内存布局全景

代码区/数据区/堆/栈的完整映射,栈生长方向

x86-64内存布局全景
代码区/数据区/堆/栈的完整映射,栈生长方向
3第 3 页 · x86-64内存布局全景

栈帧的完整结构

自上而下:地址递减,每格代表栈上一块连续区域;箭头方向即栈增长方向。

图解渲染中…
a2CALL 指令自动压入,位于 [RBP+8]a3push %rbp 后,RBP 寄存器指向此格a4被调用者负责保存:RBX、R12–R15a7栈顶指针 RSP,函数体内随时变动
4第 4 页 · 栈帧的完整结构

反汇编看栈帧布局

bash

用 objdump 看一个简单加法函数,把上一页抽象的栈帧结构映成真实的机器指令。

代码高亮加载中…

前3行序言建帧,中段把参数和局部 c 在 rbp 偏移位置存取,后两行 leave+ret 拆帧返回——上一页的栈帧布局一一对上。

5第 5 页 · 反汇编看栈帧布局

RBP与RSP的角色

反汇编里每个函数都同时出现RSP和RBP两个寄存器。它们各司其职——一个随时在动,一个相对固定。为什么需要两个?分工有何讲究?

RSP·栈顶指针
始终指向栈顶,每次push/pop/call/ret都自动调整,是栈的『游标』
RBP·帧基址指针
进入函数后固定不变,作为定位局部变量和参数的稳定锚点
x86-64的解放
RBP划归通用寄存器,不再强制承担帧基址职责,编译器可自由挪用
帧指针省略优化
GCC选项-fomit-frame-pointer:省去RBP建立,改用RSP+偏移访问局部变量
徒步中的GPS与营地旗对应 →RSP与RBP

GPS(RSP)每步更新坐标;营地旗(RBP)钉在进入此段路时的固定位置,作为回查装备的稳定锚点

6第 6 页 · RBP与RSP的角色

局部变量的栈上位置

数组/结构体/指针在栈帧中的存储方式

局部变量的栈上位置
数组/结构体/指针在栈帧中的存储方式
7第 7 页 · 局部变量的栈上位置

参数传递规则

栈帧是被调用函数的'工作台',那参数是怎么被送进去的?System V ABI给前6个整数参数安排了6条VIP通道——寄存器。多出来怎么办?

前6个参数走寄存器
按顺序对应RDI/RSI/RDX/RCX/R8/R9,从左到右一一对应
超出部分压栈
第7个参数起从右向左压栈,最右参数最后压、在栈顶
易失寄存器
caller-saved,被调函数可任意修改;调用方若需保留原值须自己保存
返回值放RAX
整数/指针返回值放RAX;超64位宽返回值用RAX:RDX组合
6格快递柜对应 →前6个参数走寄存器

6个固定格口对应RDI~R9按序投递;超出的件倒序堆在柜台上,最右参数压在最上面

8第 8 页 · 参数传递规则

寄存器角色分工

从根节点向左分两路:左路调用方需自行保护,右路被调方必须保留。

图解渲染中…
B1RAX 既属 caller-saved,又被 ABI 钦定为返回值寄存器B2前 6 个整型/指针参数依次放这 6 个寄存器,超出走栈C2RBP 默认做帧基址,开 -fomit-frame-pointer 后可当通用寄存器
9第 9 页 · 寄存器角色分工

cdecl vs fastcall vs System V

cdecl 是32位C默认,全走栈;fastcall 把前两参数塞进寄存器;System V 把这条路推到6个寄存器。

cdecl(32位C)
  • 参数全部压栈,从右向左入栈
  • 栈由调用者(caller)清理
  • 参数数量无硬性上限
  • 返回值放EAX
System V 64位
  • 前6参数走RDI/RSI/RDX/RCX/R8/R9
  • 栈由被调用者(callee)清理
  • 第7个起才压栈,从右向左
  • 返回值放RAX
新代码一律用 System V;cdecl 只在32位遗留代码里见;fastcall 是 Windows 32位的折中。
10第 10 页 · cdecl vs fastcall vs System V

call指令的执行过程

call指令内部只做两件大事:把回家的地址记到栈上,然后跳过去。

1
算返回地址
CPU先算出call下一条指令的地址,这个地址稍后要交给ret用
2
压栈返回地址
RSP先减8腾出位置,再把返回地址写入新栈顶[RSP]
3
加载目标到RIP
处理器把目标地址装入RIP,CPU从此开始执行被调用函数
4
标志位不变
call不影响任何标志位,由调用双方按约定各自保存
11第 11 页 · call指令的执行过程

ret指令的执行过程

ret 是 call 的逆操作,三步归还控制权,第四步悄悄把栈对齐复原。

1
读取返回地址
CPU 把 [RSP] 处的8字节作为目标地址读出
2
弹出栈顶
RSP += 8,栈顶指针下移让出空间
3
装入RIP跳转
把读到的地址装入 RIP,CPU 跳回调用点
4
对齐自动复原
call 推 8 字节、ret 弹 8 字节,调用前 16 字节对齐天然回归
12第 12 页 · ret指令的执行过程

递归调用的栈帧链

从上往下读是层层压栈,从下往上是逐层弹栈,直观呈现递归的「洋葱式」展开与回溯。

图解渲染中…
S运行栈,每条消息对应一次 push / popF递归函数,同一份代码在不同深度上的栈帧实例终止递归标出 base case 所在位置,递归到此停止向下
13第 13 页 · 递归调用的栈帧链

函数序言(prologue)

函数序言是每个函数开头必做三件事:把旧现场收好、给新栈帧定锚、再为局部变量腾出空间。

1
压入旧 RBP
把调用者的栈帧基址压栈,归还时凭它还原现场
2
锚定新 RBP
把当前 RSP 赋给 RBP,新栈帧的基址就此确立
3
预留局部区
RSP 减去 N 字节,腾出空间放本函数所有局部变量
14第 14 页 · 函数序言(prologue)

函数尾声(epilogue)

尾声按固定顺序还原栈帧,然后用ret把控制权交还调用者。

1
恢复RSP
mov rsp, rbp,让RSP回到栈帧基址
2
弹出RBP
pop rbp,还原调用者保存的帧基
3
执行ret
弹出返回地址到RIP,跳回call下一条
4
leave等价
leave=前两步合一,机器码更短语义不变
15第 15 页 · 函数尾声(epilogue)

栈帧的优化变体

x86asm

GCC -O2 把 sq_sum 内联掉,norm_sq 成了叶子函数;小结构体走寄存器,帧指针直接省了。

代码高亮加载中…

对照完整序言/尾声:叶子函数 + -fomit-frame-pointer 让 push %rbp、sub %rsp、leave 全消失;16 字节的 pair_t 被 System V ABI 拆进 xmm0/xmm1,参数和返回值全程活在寄存器里。

16第 16 页 · 栈帧的优化变体

栈展开(Stack Unwinding)

崩溃时调试器打印的调用链从何而来?它要逆着刚才讲过的栈帧链一格格回溯,这就是栈展开。

栈展开
从当前栈帧出发逆向回溯调用链,还原每帧现场,服务于异常与调试
RBP 链回溯
顺 RBP 指向上一个帧基址,直到最外层;前提是每帧都保存 RBP
帧指针丢失
高优化下 RBP 被挪用,RBP 链断裂,需要新机制
.eh_frame 表
编译器为每个调用点生成「如何还原寄存器」的描述,运行时按它展开
拆卷纸对应 →栈展开

栈像一卷紧绕的纸,上面印着每层调用信息,展开才能逐层读取

17第 17 页 · 栈展开(Stack Unwinding)

缓冲区溢出攻击原理

先看上方攻击链,再看下方canary防御链

图解渲染中…
a3buf在低地址,ret在高地址,紧邻排列a9canary夹在buf与ret之间作哨兵a11ret前比对canary,不一致则拦截
18第 18 页 · 缓冲区溢出攻击原理

栈溢出调试实战

bash

用 GDB 现场观察:40 字节输入如何涂掉 saved RBP 并触发 canary 检测。

代码高亮加载中…

canary 与 saved rbp 都被 A 涂掉;glibc 在函数尾声比对失败,abort 并打印 'stack smashing detected'。这正是 -fstack-protector 存在的理由:把溢出从 ret 时崩溃提前到 ret 前被发现。

19第 19 页 · 栈溢出调试实战

核心要点回顾

  • ABI是契约,栈帧是物理实现
  • call/ret本质是RSP移动与返回地址存取
  • 寄存器传参与栈传参各有性能边界
  • 帧指针省略时,调试依赖DWARF元数据
  • 递归即栈帧的链式堆叠
延伸主题:Win64 ABI与SysV的差异协程栈与用户态线程切换信号处理时的栈切换机制
20第 20 页 · 核心要点回顾

深入思考题

先遮住答案,自己想一会再写下来;再对照参考答案的思路,不必拘泥文字一致。

1为什么 x86-64 调用约定要求函数被调用时栈必须 16 字节对齐?不齐会出什么问题?

参考答案SSE(如 movaps)要求内存操作数 16 字节对齐;Windows 未对齐直接触发 #GP 崩溃,Linux 由内核兜底但性能更差。call 压入的 8 字节 rip 正是偏移的根源。

2叶子函数(不再调用别的函数)真的可以完全不建栈帧吗?什么情况下反而会出问题?

参考答案形式上可省 push rbp,但 rsp 本就在充当帧底。问题场景:函数后续被改成非叶子、编译器需对齐栈上数组、或调试器与异常处理器依赖标准帧回溯——缺帧会让栈展开失败。

3既然 malloc 也能给局部变量分配空间,为什么不用堆完全替代栈?

参考答案栈是 CPU+ABI 的一等公民:return 时 O(1) 弹帧、寄存器直接寻址、缓存友好、递归深度可预测。堆 malloc/free 开销大、回收不确定,缺硬件 LIFO 支撑,无法一对一替代。

21第 21 页 · 深入思考题