过程调用的机器级表示

一次函数调用要解决四件事,而 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 在取指阶段就已经增量过了。

返回地址存在栈里,这一个设计决定推出了三件事:

  1. 递归可行——每层调用有自己的返回地址,互不覆盖。若返回地址存在一个固定寄存器里,递归第二层就会把第一层的覆盖掉。
  2. 调用深度只受栈空间限制,不受硬件限制。
  3. 返回地址是可以被写坏的数据——缓冲区溢出攻击改的就是它。“控制流信息和数据放在同一块可写内存里”是这个设计的代价。

栈帧:EBP 与 ESP 的分工

        高地址
      ┌─────────────────┐
      │   调用者的栈帧    │
      ├─────────────────┤
      │     参数 n       │
      │      ⋮          │  ← 调用者压入(IA-32)
      │     参数 1       │  ← [ebp+8]
      ├─────────────────┤
      │    返回地址      │  ← call 压入,[ebp+4]
      ├─────────────────┤ ←──── EBP(帧基址,函数内不变)
      │    旧 EBP       │  ← [ebp]
      ├─────────────────┤
      │ 被保存的寄存器    │
      ├─────────────────┤
      │    局部变量      │  ← [ebp-4], [ebp-8], …
      ├─────────────────┤ ←──── ESP(栈顶,随压弹变动)
        低地址

两个指针分工明确,这是整个栈帧机制的关键:

ESPEBP
指向栈顶,随每次 push/pop 变动帧基址,函数执行期间固定不变
用来分配和释放空间以固定偏移访问参数和局部变量

关联对照: [ebp+8] 是基址寻址最标准的一次实战

回看 4.2.2 那条判据——基址寻址里寄存器是常量、形式地址是变量——在栈帧上字面成立:

对应物变不变
基址寄存器 BREBP整个函数执行期间不变
形式地址 +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 减小

链接