过程调用的机器级表示
一次函数调用要解决四件事,而 ISA 只管其中一件:
| # | 要解决什么 | 谁管 |
|---|---|---|
| 1 | 控制转移:跳过去,而且要能跳回来 | ISA(call / ret 两条指令) |
| 2 | 数据传递:参数进去、返回值出来 | ABI |
| 3 | 局部存储:被调用者的局部变量放哪 | ABI |
| 4 | 寄存器保护:两个函数共用一组寄存器,谁负责保存 | ABI |
这张表就是本节的结构,也是 4.1.1 那条”ABI 在 ISA 之上”最完整的兑现——机器只提供”跳过去并记住回来的路”,其余全部是软件之间的君子协定。协定不同,同一颗 CPU 上编译出的目标文件就不能互相链接。
机制
call 与 ret:ISA 管的那一件事
call addr ≡ push (下一条指令的地址) ; jmp addr
ret ≡ pop → PC
call 压的是它自己的下一条指令的地址,不是 call 自身的地址——理由和 4.2.1 里 PC 相对寻址的基准一样:PC 在取指阶段就已经增量过了。
返回地址存在栈里,这一个设计决定推出了三件事:
- 递归可行——每层调用有自己的返回地址,互不覆盖。若返回地址存在一个固定寄存器里,递归第二层就会把第一层的覆盖掉。
- 调用深度只受栈空间限制,不受硬件限制。
- 返回地址是可以被写坏的数据——缓冲区溢出攻击改的就是它。“控制流信息和数据放在同一块可写内存里”是这个设计的代价。
栈帧:EBP 与 ESP 的分工
高地址
┌─────────────────┐
│ 调用者的栈帧 │
├─────────────────┤
│ 参数 n │
│ ⋮ │ ← 调用者压入(IA-32)
│ 参数 1 │ ← [ebp+8]
├─────────────────┤
│ 返回地址 │ ← call 压入,[ebp+4]
├─────────────────┤ ←──── EBP(帧基址,函数内不变)
│ 旧 EBP │ ← [ebp]
├─────────────────┤
│ 被保存的寄存器 │
├─────────────────┤
│ 局部变量 │ ← [ebp-4], [ebp-8], …
├─────────────────┤ ←──── ESP(栈顶,随压弹变动)
低地址
两个指针分工明确,这是整个栈帧机制的关键:
ESP | EBP | |
|---|---|---|
| 指向 | 栈顶,随每次 push/pop 变动 | 帧基址,函数执行期间固定不变 |
| 用来 | 分配和释放空间 | 以固定偏移访问参数和局部变量 |
关联对照:
[ebp+8]是基址寻址最标准的一次实战回看 4.2.2 那条判据——基址寻址里寄存器是常量、形式地址是变量——在栈帧上字面成立:
对应物 变不变 基址寄存器 BR EBP 整个函数执行期间不变 形式地址 +8、+4、-4、-8每条指令各不相同 而且”程序重定位”这个动机也对上了:同一个函数被调用一千次,每次栈帧落在不同的地址上,但函数体里那些
[ebp-4]一个字节都不用改——只要 EBP 变了就行。这正是基址寻址被称为”面向系统、支持重定位”的原因,在这里从”整个程序相对主存”缩小到了”一个栈帧相对栈”,机制完全一样。
序言与尾声
被调用者入口(prologue):
push ebp ; 保存调用者的帧基址
mov ebp, esp ; ★ 建立本函数的帧基址
sub esp, N ; 为局部变量分配 N 字节
函数体……
被调用者出口(epilogue):
mov esp, ebp ; 丢弃局部变量 ┐
pop ebp ; 恢复调用者帧基址 ┘ = 一条 leave
ret ; 弹出返回地址送 PC
leave 就是前两条的合并,是又一条 CISC 复合指令。这三步的顺序不能乱:必须先恢复 ESP 再弹 EBP,最后 ret 时栈顶恰好是返回地址。
寄存器保护:为什么要分成两类
两个函数共用同一组寄存器,总得有人负责保存。全交给一方都不划算:
| 方案 | 问题 |
|---|---|
| 全部由调用者保存 | 调用前要存全部寄存器,哪怕被调函数一个都没用 |
| 全部由被调用者保存 | 进函数就要存全部,哪怕调用者一个都不再需要 |
于是 ABI 把寄存器切成两半,让双方各自只保存自己真正在乎的那些:
| 类别 | IA-32 的寄存器 | 谁保存 | 语义 |
|---|---|---|---|
| 调用者保存(caller-saved / volatile) | EAX ECX EDX | 调用者在 call 前自己存 | ”调用之后这几个的值我不保证” |
| 被调用者保存(callee-saved / non-volatile) | EBX ESI EDI EBP | 被调用者用之前存、返回前恢复 | ”调用前后这几个的值不变” |
使用判据只有一句:调用之后还要用的值,放进被调用者保存寄存器,或者调用前自己存起来。
参数与返回值
| IA-32(cdecl) | x86-64(System V) | |
|---|---|---|
| 参数 | 全部压栈,从右往左 | 前 6 个用寄存器 RDI RSI RDX RCX R8 R9,多余的压栈 |
| 返回值 | EAX(64 位用 EDX:EAX) | RAX |
| 谁清理参数栈 | 调用者 | 调用者 |
“从右往左压栈”不是随意规定:这样一来第一个参数就落在离栈顶最近的位置,也就是固定的 [ebp+8]。于是被调用者不需要知道一共有几个参数,也能找到第一个——这正是 printf 这类可变参数函数能工作的前提。
x86-64 改用寄存器传参,是一次实打实的性能改进:省掉的是每个参数一次压栈访存(4.1.2 那张表上的一次),而寄存器访问是 0 次。
“谁清理参数栈”是 cdecl 和 stdcall 的分界:cdecl 由调用者清(add esp, N),所以支持可变参数;stdcall 由被调用者清(ret N),代码更小但参数个数必须固定。Windows API 用 stdcall,C 标准库用 cdecl——这就是同一颗 CPU 上两套 ABI 不能混用的具体样子。
递归
递归没有任何特殊机制,它能工作只是因为每次调用都新开一个栈帧:
int fact(int n) { return n <= 1 ? 1 : n * fact(n-1); }调用 fact(5) 时栈上同时存在 5 个栈帧,每个都有自己的 n(在各自的 [ebp+8])和自己的返回地址。递归深度直接等于栈帧层数,所以:
- 递归太深 → 栈溢出,在 OS 里表现为栈段越界(见 OS 3.1.2)
- 尾递归可被编译器优化成循环(复用同一个栈帧),此时递归深度不再增长
边界
call 压入的是下一条指令的地址。 若 call 指令本身占 5 字节、地址为 0x1000,压入的是 0x1005。这是计算题的直接考点。
ret 和 IRET 不能互换(4.1.5)。ret 只弹返回地址;IRET 还要恢复 PSW 和特权级。中断的”现场”比过程调用的”现场”多两样东西:标志寄存器和特权状态——因为中断可能来自任意时刻、任意特权级,而 call 是程序自愿的、同一特权级内的。完整对照见 5.6 异常和中断(本地资料)。
不是每个函数都有栈帧。 叶子函数(不再调用别人)、没有局部变量的函数,编译器在 -O2 下常常省略 EBP,直接用 ESP 加偏移访问。此时 [ebp+8] = 第一个参数 那套完全失效,读汇编时看到函数开头没有 push ebp; mov ebp,esp,就要改用 ESP 口径重新数偏移。
参数区属于调用者的栈帧,不属于被调用者。 所以被调用者能读参数([ebp+8] 往上),但那块空间由调用者分配、由调用者(cdecl)释放。画栈帧图时把参数画在调用者一侧才是准确的,教材有时为了直观画在被调用者帧里,读题时以偏移量为准。
栈向低地址生长,所以”栈顶”在图的下方。 push 使 ESP 减小,sub esp, N 是分配空间不是释放。这个方向感反过来就全错了。
除 call/ret 的语义外,本节全部内容都是 ABI,不是 ISA。 参数放哪、返回值放哪、哪些寄存器要保存、谁清栈——换个操作系统就可能全变,但机器指令的编码一位都不变。判断题问”下列哪项由 ISA 规定”,本节的东西除了 call/ret 一律选”不是”。
返回地址与局部变量在同一块可写内存里相邻。 局部数组越界写就能改掉返回地址,这是缓冲区溢出的机理。统考不考攻击手法,但”返回地址存在栈上”这个事实要清楚——它同时解释了递归为什么可行和溢出为什么危险。
对照速查
| 位置 | 内容 | 谁压的 |
|---|---|---|
[ebp+8] 及以上 | 参数(第一个参数在 +8) | 调用者 |
[ebp+4] | 返回地址 | call 指令 |
[ebp] | 旧 EBP | 被调用者(prologue) |
[ebp-4] 及以下 | 被保存的寄存器、局部变量 | 被调用者 |
| 四件事 | 谁管 |
|---|---|
| 控制转移 | ISA:call / ret |
| 参数与返回值 | ABI |
| 局部存储(栈帧布局) | ABI |
| 寄存器保存责任划分 | ABI |
| 判断 | 对错 |
|---|---|
call 压入 call 指令自身的地址 | ❌(下一条指令的地址) |
| 返回地址存在专用寄存器里 | ❌(存在栈里,否则递归失效) |
ret 与 IRET 等价 | ❌ |
| EBP 在函数执行期间会变 | ❌(固定,变的是 ESP) |
push 使 ESP 增大 | ❌(减小,栈向低地址长) |
| 每个函数都必然有栈帧 | ❌(叶子函数常被省略) |
| 参数区属于被调用者的栈帧 | ❌(属于调用者) |
| 调用约定由 ISA 规定 | ❌(ABI) |
| 递归需要专门的硬件支持 | ❌(只需要栈) |
| 调用者保存寄存器在调用后值仍可靠 | ❌(正是”不保证”的那一类) |
考点
- 四件事里只有控制转移由 ISA 管(
call/ret),其余三件全是 ABI call= 压下一条指令地址 + 跳转;ret= 弹栈送 PC- 返回地址存在栈上 → 递归可行、深度只受栈限制、可被溢出改写
- ESP 变、EBP 不变;
[ebp+8]=第一参数、[ebp+4]=返回地址、[ebp-4]=第一局部变量 [ebp+偏移]是基址寻址的标准实战:寄存器常量、形式地址变量- prologue:
push ebp; mov ebp,esp; sub esp,N;epilogue:leave; ret - 调用者保存(
EAX ECX EDX)/ 被调用者保存(EBX ESI EDI EBP),判据是”调用后还要不要用” - 参数从右往左压栈,所以第一参数固定在
[ebp+8]→ 可变参数函数可行 - cdecl 调用者清栈(支持可变参数),stdcall 被调用者清栈
- 栈向低地址生长,
push使 ESP 减小
链接
- 🏠 返回总览:计算机组成原理第 4 章:指令系统总览
- ⬅️ 上一节:4.3.3 循环语句的机器级表示
- ➡️ 下一节:4.4.1 复杂指令系统计算机(CISC)
- 🔗 4.1.1 指令集体系结构(ABI 与 ISA 的分层)
- 🔗 4.2.2 常见的数据寻址方式(基址寻址、堆栈寻址)
- 🔗 5.6 异常和中断(本地资料)(
ret与IRET保存的现场不同) - 🔗 OS 3.1.2 连续分配管理方式(栈在进程地址空间中的位置)
- 📖 名词库:第 4 章名词库