Lecture 5: 机器级编程 III——过程、栈帧与调用约定 (Machine Programming III: Procedures, Stack Frames, and Calling Convention)
Lecture 5: 机器级编程 III——过程、栈帧与调用约定 (Machine Programming III: Procedures, Stack Frames, and Calling Convention)
讲义对应:CMU 15-213 Lecture 5 — Machine Programming (Part III)(素材:
F25-05-machine-gamma.txt) 教材对应:CS:APP3e 第 3 章 3.7–3.9(过程、数组、异质数据结构) 关联 Lab:L3 Attack Lab(栈帧与调用约定是全部攻击的物理基础)
5.1 概述
前两讲解决了”一条指令怎么算”(算术/逻辑)与”下一条指令在哪”(控制流),本讲回答第三个问题:一个过程(procedure)如何调用另一个过程并在返回后继续正确执行,需要同时解决控制传递(control transfer)、数据传递(data passing)与内存管理(memory management,局部存储的分配与释放)。x86-64 的答案是”寄存器传参 + 运行时栈 + 寄存器保存约定”,全部由普通指令实现,没有专用硬件。
本讲把”栈”从课本抽象变成真实的、位于高地址段、向低地址增长的内存区域:承接 Lecture 4 的分支跳转,通向 Lecture 6 的数组/结构体,并为 L3 Attack Lab 提供全部原理——攻击者的工作本质就是”用越界写覆盖别人栈帧里的返回地址”。
5.2 核心概念与底层机制图解
5.2.1 过程的三大机制(Mechanisms in Procedures)
- 定义与目的:保证 $P$ 调用 $Q$($y = Q(x)$)后 $P$ 的现场完好恢复,$Q$ 的结果与副作用如期交付。
- 直观解释:像读一本有注释的书:读到”参见第 7 章”(调用)就夹一张书签(返回地址)再去,读完凭书签回到原处;书签、要问的问题、草稿纸分别对应对三大机制。
- 三个问题的具体分解:① 控制传递——跳转到被调用者开头,返回时跳回调用点的下一条指令;② 数据传递——参数传入、返回值传出;③ 内存管理——执行期间分配局部存储,返回时释放。
- 与机器码/硬件的对应:
call/ret负责控制传递,寄存器 + 栈负责数据传递,subq/addq调整%rsp负责内存管理。x86-64 只使用实际需要的机制——mult2只用寄存器,连栈都不碰。 - 为什么需要”栈”这种结构:支持递归的语言其过程代码必须可重入(reentrant)——同一过程可同时有多个活动实例(instantiation),各需自己的参数、局部变量与返回地址。栈的”后进先出”恰好匹配”被调用者必先于调用者返回”这一调用纪律(stack discipline);每个实例在栈上占用的连续区域即一个栈帧(Stack Frame)。
5.2.2 运行时栈与 pushq/popq
- 定义与目的:运行时栈是进程地址空间中由
%rsp(stack pointer)管理的一段连续区域,存放过程调用的瞬时状态。 - 直观解释:栈像一摞盘子,只能从最顶上放和拿;而”顶端”在内存里的地址是最小的。
高地址 (0x7fffffffb6e8)
+--------------------------------+ 已映射栈区的高地址端
| 调用者较早的帧 (older frames) | 高地址一侧,先入栈
+--------------------------------+
| 当前帧的数据 | 局部变量 / 保存的寄存器
%rsp ->+--------------------------------+ 栈顶 (Top of Stack)
低地址 (0x7fffffffb6a0) ↑ 栈向低地址方向增长 ↑
pushq 一次:%rsp -= 8,再向 (%rsp) 写入 8 字节
popq 一次:先读出 (%rsp) 的 8 字节,再 %rsp += 8
- pushq/popq 的语义与等价形式(讲义中给出的标准改写):
pushq %rbp ≡ subq $8, %rsp popq %rbp ≡ movq (%rsp), %rbp
movq %rbp, (%rsp) addq $8, %rsp
push/pop 只是”改指针 + 搬数据”的编码紧凑版(编码仅 1–2 字节)。栈指针的移动本身就代表分配与释放:subq $16, %rsp 即”分配 16 字节”,addq $16, %rsp 即”释放”,不必清零。
- 与机器码/硬件的对应:
push/pop由地址生成部件与存储部件完成;前提是该栈区已映射且可写。
5.2.3 控制传递:call 与 ret
- 定义与目的:
call Label与ret组成最基本的调用/返回对。 - 直观解释:
call= “把回来的路写在便签上贴到栈顶,再跳走”;ret= “撕下栈顶便签照着跳回去”。 - 底层机制图解(讲义课堂活动
machine-procedures第 1–4 题的结论):
| 调用者的其他数据 |
+--------------------------------+
| 返回地址 (8 字节) | <== %rsp 指向这里(被调用者入口)
%rsp ->+--------------------------------+
call Label ≡ pushq $next ; jmp Label # next = call 的下一条指令的地址
ret ≡ popq %rip # ≡ movq (%rsp),%rip ; addq $8,%rsp
- 与机器码/硬件的对应:讲义课堂活动用 GDB 验证:
0x15213上方紧邻的值0x00000040117a就是返回地址;ret把它弹入%rip并使%rsp加 8。 - ⚠️ 这就是 Attack Lab 的全部原理:返回地址在栈上、且位于被调用者局部缓冲区的高地址一侧;一次越界写即可改写这 8 字节,让
ret跳到touch1、注入代码或 gadget(详细手法见 Lecture 6)。
5.2.4 数据传递:参数寄存器与返回值
- 定义与图解(讲义 Procedure Data Flow):
%rdi %rsi %rdx %rcx %r8 %r9依次接第 1–6 个整型/指针参数,第 7 个起走栈;返回值在%rax(128 位用%rdx:%rax),浮点参数走%xmm0-%xmm7、浮点返回在%xmm0。 直观解释:这是 ABI(Application Binary Interface)层面的”外交礼节”——双方遵守同一份礼节,各自内部怎么实现都无所谓。
- 与机器码/硬件的对应:
multstore(x, y, dest)三个参数落在%rdi、%rsi、%rdx,结果由%rax带回;只在实际需要时才分配栈空间——前 6 个参数完全不占栈。 - 常见陷阱:第 7 个及以后的参数从右往左压栈(见 5.3.3 实测),故
arg7在最低地址、离返回地址最近;被调用者用8(%rsp)、16(%rsp)读取(栈顶是返回地址)。
5.2.5 栈帧(Stack Frame)结构
- 定义与目的:栈帧是单个过程实例在栈上占用的区域,容纳返回信息、局部存储与临时空间。
- 直观解释:一次调用占用栈上一段”格子间”,进门时搭起(set-up code,含
call压返回地址),出门时拆掉(finish code,含ret弹返回地址);%rsp是抽屉底边,%rbp是上一层分界线。 - 完整的 x86-64/Linux 栈帧布局图(高地址在上、低地址在下):
高地址 (High Address)
| 调用者的局部变量 ... |
+--------------------------------+
| 参数 7、参数 8、... | 调用者的参数构造区
| (Argument Build Area) | 仅当整型参数 > 6 个时存在
+--------------------------------+ <-- 调用者帧 / 被调用者帧的分界
| 返回地址 (Return Address) | call 压入;ret 弹出并跳转
%rbp+8 ->+--------------------------------+
| (返回地址的位置) |
%rbp+0 ->+--------------------------------+
| 保存的 %rbp (Old %rbp) | 被调用者 pushq %rbp(可选)
+--------------------------------+ <== %rbp 指向这里(帧指针,可选)
| 被调用者保存的寄存器 | %rbx、%r12-%r15(用到的才保存)
| (Saved Registers) |
+--------------------------------+
| 局部变量 (Local Variables) | 取地址的变量、数组、寄存器装不下的
+--------------------------------+
| 本函数的参数构造区 | 调用别人时构造第 7+ 个参数
| (Argument Build, 可选) |
%rsp ->+--------------------------------+ <== 栈顶指针 (Stack Pointer)
低地址 (Low Address) 栈向这个方向增长 ↓
说明:%rbp 是可选帧指针,”Saved Registers/Local Variables” 与 “Argument Build” 的先后由编译器决定;无论怎么排,%rsp 永远指向当前帧的最低有效地址。典型序言是 pushq %rbp; movq %rsp,%rbp; subq $N,%rsp,收尾是 movq %rbp,%rsp; popq %rbp; ret(-O0 下可见,见 5.3.3)。
5.2.6 寄存器保存约定(Register Saving Conventions)
- 定义与目的:
yoo把$15213放进%rdx后call who,而who内部subq $18213, %rdx会毁掉它——若无人协调,addq %rdx, %rax就会算错。约定据此把寄存器分为两类,明确”谁负责保存”。 - 直观解释:调用者保存(caller-saved,又名 call-clobbered) 像公共白板:你离开自己办公室前要先把内容抄下来,因为对方会随手擦。被调用者保存(callee-saved,又名 call-preserved) 像带锁的抽屉:对方要用就得先清空、用完恢复,你放在里面的东西一定还在。
- 寄存器使用约定速查表(摘自课堂活动附录 x86-64 ELF Calling Convention Summary):
| 寄存器 | 保存约定 | 传统用途 |
|---|---|---|
%rax | 调用者保存(Clobbered) | 返回值;也常作临时寄存器 |
%rbx | 被调用者保存(Preserved) | 被调用者的临时存储 |
%rcx | 调用者保存 | 第 4 个整型参数 |
%rdx | 调用者保存 | 第 3 个整型参数;128 位返回值高半部 |
%rsi | 调用者保存 | 第 2 个整型参数 |
%rdi | 调用者保存 | 第 1 个整型参数 |
%rbp | 被调用者保存 | 可作帧指针,也可当通用寄存器 |
%rsp | 特殊形式的被调用者保存 | 栈指针;返回时必须恢复原值 |
%r8 | 调用者保存 | 第 5 个整型参数 |
%r9 | 调用者保存 | 第 6 个整型参数 |
%r10 | 调用者保存 | 临时寄存器 |
%r11 | 调用者保存 | 临时寄存器 |
%r12 | 被调用者保存 | 被调用者的临时存储 |
%r13 | 被调用者保存 | 被调用者的临时存储 |
%r14 | 被调用者保存 | 被调用者的临时存储 |
%r15 | 被调用者保存 | 被调用者的临时存储 |
一句话记忆:调用者保存的是 %rax %rcx %rdx %rsi %rdi %r8 %r9 %r10 %r11;被调用者保存的是 %rbx %rbp %r12 %r13 %r14 %r15 加特殊的 %rsp。
- 为什么这样分(设计权衡):① 减少调用开销——若所有寄存器都由被调用者保存,”什么都没干”的小函数(如
incr)每次调用也要先 push 再 pop 一堆寄存器;把易失寄存器划给调用者后,调用者只在真的跨调用存活时才保存。② 便于被调用者做临时存储——%rax %rcx %rdx等可当草稿纸随便涂写,无需任何保存/恢复代码。③ 长寿命变量有稳定去处——跨调用存活的局部变量放%rbx/%r12-%r15或栈上,由被调用者保全(递归中”本层的n“就在%rbx,见 5.3.4)。 - 与机器码/硬件的对应:没有任何硬件强制这些约定;违反它不会立刻崩溃,只会产生极难调试的随机错误。
%rsp必须恢复则被ret强制——否则返回地址就取错了。
5.2.7 局部变量的存放
- 定义与目的:决定局部变量住寄存器还是住栈。
- 三条规则:① 优先放寄存器——编译器尽量为每个局部变量分配一个寄存器,读写零访存;② 必须放栈上——取过地址(
&x传给别人)、是数组或结构体(需连续编址、可下标访问)、寄存器不够用(变量太多或跨调用存活);③ 取地址用leaq——leaq 8(%rsp), %rdi算的是地址而非内容,正是call_incr中&v1的实现(5.3.2)。 - 直观解释:寄存器像桌上便签(快,但数量有限、没法告诉别人在哪);栈像带门牌号的储物柜(慢些,但有地址、能按序号排成数组)。
- 与机器码/硬件的对应:
movq %rax, -40(%rsp)是”变量落户栈上”,leaq -40(%rsp), %rax是”取变量地址”。注意 x86-64 中%rsp相对寻址即可完成一切,无需%rbp。
5.2.8 递归过程与栈溢出
- 定义与目的:递归是”同一过程有多个存活实例”的极端情形。
- 机制:递归无需任何特殊处理——每次递归调用产生全新栈帧,其中的局部变量与保存的寄存器互相隔离;后进先出纪律恰好匹配”调用未返回则被调用者先返回”,互递归($P$ 调 $Q$、$Q$ 调 $P$)同样适用。
- 直观看法:每深入一层就像叠一张透明幻灯片,各写着自己的
n,返回时从上往下逐张抽走。讲义的yoo→who→amI→amI→amI调用链演示了%rsp/%rbp随调用链节节下移、又逐节回弹。 - 栈溢出(stack overflow):递归深度不受语言限制,只受栈空间限制(Linux 默认约 8 MB,
ulimit -s可查);层越大越早触底,写入低于栈底映射区的地址即触发 SIGSEGV——”段错误”最常见的来源之一。 - 与机器码/硬件的对应:
call本身只压 8 字节,深度消耗主要来自序言中的subq $N, %rsp。(”栈溢出”在安全语境下另有含义,见 5.2.10。)
5.2.9 x86-64 相对 IA32 的变化(补充说明)
- 帧指针不再必需:IA32 的
%ebp是标准帧指针、连成链表供调试器回溯;x86-64 允许%rbp当通用寄存器(默认-fomit-frame-pointer),代价是回溯要靠.eh_frame/CFI(gcc -S里的.cfi_*即为此生成)。 - 参数不再全部走栈:IA32 把所有参数压栈、被调用者用
8(%ebp)/12(%ebp)取;x86-64 前 6 个走寄存器,故小函数几乎不触碰内存。 - 栈帧大小编译期确定、
%rsp相对寻址:IA32 以%ebp为基准,x86-64 用%rsp加常量偏移,因为一帧多大在编译期已定(变长数组/alloca除外)。 - 更严格的栈对齐:System V AMD64 ABI 要求
call前%rsp16 字节对齐——这正是-Og下main出现subq $8, %rsp以及 5.3.2 中subq $16, %rsp的原因。
5.2.10 缓冲区溢出(Buffer Overflow):Attack Lab 的伏笔
- 定义与目的:C 语言的数组不做边界检查,
gets/strcpy/strcat/sprintf等函数无法知道目标缓冲区有多大,会一直拷贝直到遇到'\0',从而越界覆盖相邻内存。 - 直观解释:缓冲区像”往小杯子里倒水”,
strcpy不管杯子多大,多出的水漫到旁边——而旁边恰恰是返回地址。
高地址 (0x7fffffffb6f8)
| 调用者 main 的帧 |
+--------------------------------+
| 填充 (8 字节) | 对齐用
+--------------------------------+
| 返回地址 (8 字节) | <== 越界写到这里 = 控制流被劫持
+--------------------------------+
| 填充 (8 字节) |
+--------------------------------+
| char buf[16] | <== strcpy 从这里开始写,不检查长度
%rsp ->+--------------------------------+ 低地址 0x7fffffffb6c8
- 与机器码/硬件的对应:
gets因无法安全使用已在 C11 中移除、glibc 中弃用(链接时警告),新代码用fgets;strcpy仍在,但容量必须由程序员保证。 - 缓和措施(modern defenses):栈不可执行(NX)、地址随机化(ASLR/PIE)、栈保护(stack canary,
-fstack-protector)。Attack Lab 中 CTARGET 故意关闭这些保护以演示代码注入(code injection, CI);RTARGET 打开 NX + ASLR,逼迫使用返回导向编程(return-oriented programming, ROP)——攻击者不再注入代码,而是把栈上的一串地址排成”gadget 链”,每个 gadget 都是现有程序里”若干指令 +ret“的片段(0xc3就是ret的编码)。 - 本节只铺垫原理:如何用
%rdi传 cookie、如何用hex2raw处理小端字节序、如何选 gadget,详见 Lecture 6。
5.3 代码示例与底层机制分析
以下所有 C 代码均在 /tmp/ch05/ 下用 gcc (GCC) 12.2.0 -Og -Wall -std=c11 实际编译、运行,并用 gcc -Og -S 生成汇编逐一核对。
5.3.1 示例 1:多参数调用与返回值(讲义 mult2 / multstore)
代码 (C):
/* c05_ex1.c —— 编译:gcc -Og -Wall -std=c11 c05_ex1.c -o c05_ex1 */
#include <stdio.h>
long mult2(long a, long b) {
long s = a * b;
return s;
}
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}
int main(void) {
long d = 0;
multstore(15213, 2, &d);
printf("multstore(15213,2) = %ld\n", d);
return 0;
}
【代码做什么?】 ① main 给 d 在栈上留 8 字节并用 leaq 8(%rsp), %rdx 取地址,15213→%edi、2→%esi,call multstore;② multstore 用 pushq %rbx 保住 dest(%rdx 是调用者保存,跨 call 会丢)后 call mult2;③ mult2 相乘并把结果留在 %rax,multstore 写回 (%rbx) 并恢复。
【底层机制透视】 mult2 完全没有栈帧(只用寄存器,ret 前无任何 subq/pushq),印证”x86-64 只用必需的机制”。multstore 的 pushq %rbx 是为让 dest 活过 call mult2——留在 %rdx 里会被 mult2 覆写。
【与汇编 / 硬件的对应】 gcc -Og -S 实测输出:
# long mult2(long a, long b) # a in %rdi, b in %rsi
mult2:
movq %rdi, %rax # s = a
imulq %rsi, %rax # s *= b -> 结果留在 %rax
ret
# void multstore(long x, long y, long *dest) # x:%rdi y:%rsi dest:%rdx
multstore:
pushq %rbx # 保存被调用者保存寄存器
movq %rdx, %rbx # 把 dest 挪进 %rbx,活过下面的 call
call mult2
movq %rax, (%rbx) # *dest = t
popq %rbx # 恢复
ret
【实测验证】 输出 multstore(15213,2) = 30426;objdump -d 显示 mult2 位于 0x401126,multstore 中为 callq 401126 <mult2>。
5.3.2 示例 2:取地址的局部变量必须放栈上(call_incr)
代码 (C):
/* c05_ex4.c */
#include <stdio.h>
long incr(long *p, long val) {
long x = *p;
long y = x + val;
*p = y;
return x;
}
long call_incr(void) {
long v1 = 15213; /* 因为要取地址 &v1 -> 必须放在栈上 */
long v2 = incr(&v1, 3000);
return v1 + v2;
}
int main(void) {
printf("call_incr() = %ld\n", call_incr());
return 0;
}
【底层机制透视】 v1 本身只需 8 字节和一个寄存器,但 &v1 被传了出去,编译器没有选择余地:必须给它内存地址,而没有任何 x86-64 寄存器与地址绑定,唯一去处就是栈(5.2.7 的”取地址 ⇒ 必须放栈”)。相对地,incr 全程在寄存器里完成。
【内存布局 / 数据结构图解】(GDB 实测地址,x86-64 Linux)
+------------------------------+ 0x7fffffffb6d0
| main 帧的返回地址 / 填充 |
+------------------------------+ 0x7fffffffb6c8
| 填充(对齐, 8 字节) |
+------------------------------+ 0x7fffffffb6c0 <== &v1 (即 %rdi)
| v1 = 15213 = 0x3b6d |
+------------------------------+ 0x7fffffffb6b8
| 填充 8 字节 |
+------------------------------+ 0x7fffffffb6b0 <== %rsp (call 之前)
| 返回地址 0x40114c | <== call 压入,incr 入口处 %rsp
+------------------------------+ 0x7fffffffb6a8
| ... incr 帧 (无局部变量) ... |
【与汇编 / 硬件的对应】 gcc -Og -S 实测输出(与讲义幻灯片逐条一致):
incr: # p in %rdi, val in %rsi
movq (%rdi), %rax # x = *p
addq %rax, %rsi # y = x + val (%rsi = val, y 复用)
movq %rsi, (%rdi) # *p = y
ret # 返回值 x 已在 %rax
call_incr:
subq $16, %rsp # 分配 16 字节:v1 与对齐填充
movq $15213, 8(%rsp) # v1 = 15213
movl $3000, %esi # 用 movl 省 1 字节;写 %esi 自动清零高 32 位
leaq 8(%rsp), %rdi # &v1 —— leaq 计算地址而非取值
call incr
addq 8(%rsp), %rax # v1 + v2,直接从栈上重读 v1
addq $16, %rsp # 释放
ret
【实测验证】 GDB 断在 incr 入口:%rsp = 0x7fffffffb6b0,x/6gx $rsp 首行为返回地址 0x40114c,反查得 call_incr+28: add 0x8(%rsp),%rax;x/1gd $rdi 为 15213。输出 call_incr() = 33426(=15213+18213),与讲义一致。
5.3.3 示例 3:8 个参数——第 7、8 个走栈传递
代码 (C):
/* c05_ex3.c */
#include <stdio.h>
long sum8(long a1, long a2, long a3, long a4,
long a5, long a6, long a7, long a8) {
return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8;
}
int main(void) {
long r = sum8(1, 2, 3, 4, 5, 6, 7, 8);
printf("sum8 = %ld\n", r);
return 0;
}
【底层机制透视】 前 6 个参数住寄存器,第 7、8 个”溢出”到调用者的参数构造区。为了让这 16 字节在 call 之后仍位于被调用者所期望的偏移,编译器采用”从右向左压栈“:先 pushq $8 再 pushq $7,于是 7 在最低地址((%rsp) 位置即被调用者的 8(%rsp))。
【内存布局 / 数据结构图解】(call sum8 执行后的瞬间)
+---------------------------+ 高地址
| 调用者 main 的其他数据 |
+---------------------------+ <== 调用前的 %rsp
| arg8 = 8 | <-- pushq $8 压入(先压)
+---------------------------+
| arg7 = 7 | <-- pushq $7 压入(后压)
+---------------------------+ <-- call 前的 %rsp
| 返回地址 (main 中 call |
| 的下一条指令) |
+---------------------------+ <== sum8 入口处的 %rsp
| ... sum8 帧(本函数无局部)|
所以被调用者读参数用的是 8(%rsp) = arg7、16(%rsp) = arg8:“跳过栈顶那 8 字节返回地址” 是这里最容易记错的地方。
【与汇编 / 硬件的对应】 gcc -Og -S 实测:
# long sum8(long a1, ..., long a8)
# a1:%rdi a2:%rsi a3:%rdx a4:%rcx a5:%r8 a6:%r9 a7:8(%rsp) a8:16(%rsp)
sum8:
addq %rsi, %rdi # a1 += a2
addq %rdx, %rdi # a1 += a3
addq %rcx, %rdi # a1 += a4
addq %r8, %rdi # a1 += a5
leaq (%rdi,%r9), %rax # rax = a1 + a6
addq 8(%rsp), %rax # += arg7 —— 来自栈
addq 16(%rsp), %rax # += arg8 —— 来自栈
ret
main:
subq $8, %rsp # 仅为 16 字节对齐
pushq $8 # arg8 先入栈(高地址)
pushq $7 # arg7 后入栈(低地址)
movl $6, %r9d # arg6
movl $5, %r8d # arg5
movl $4, %ecx # arg4
movl $3, %edx # arg3
movl $2, %esi # arg2
movl $1, %edi # arg1
call sum8
addq $16, %rsp # 调用者负责释放自己压入的参数
-O0 下更能看出被调用者的读法:序言是 pushq %rbp; movq %rsp,%rbp;,六个寄存器参数被搬到 -8(%rbp)…-48(%rbp),随后用 movq 16(%rbp), %rax(arg7)与 movq 24(%rbp), %rax(arg8)——16(%rbp) 恰好”跳过 saved %rbp(8 字节)+ 返回地址(8 字节)”。
编译器也可先把 %rsp 整体下移、再往参数槽写内存(等价形式):
subq $16, %rsp # 一次性开辟参数构造区
movq %rdx, (%rsp) # arg7 写到最低地址
movq %rax, 8(%rsp) # arg8
我在 /tmp/ch05 编译的 caller3(long *v)(-Og -S)中真实观测到 movq %r9, (%rsp) / movq %r8, 8(%rsp) 与 pushq %r10 / pushq %r8 并存;objdump -d 量出 pushq $8 编码为 6a 08(2 字节),而等价的 movq $8, (%rsp) 要 8 字节——这就是 GCC 偏爱 push 的直接原因。
【实测验证】 输出 sum8 = 36;objdump -d 显示 pushq $0x8 编码为 6a 08。关键观察:栈参数的释放由调用者负责(addq $16, %rsp),这与被调用者保存寄存器的恢复由被调用者负责形成对照。
5.3.4 示例 4:递归阶乘——每次调用生成新栈帧
代码 (C):
/* c05_ex2.c */
#include <stdio.h>
long fact(long n) {
if (n <= 1)
return 1;
else
return n * fact(n - 1);
}
int main(void) {
for (long i = 0; i <= 10; i++)
printf("fact(%ld) = %ld\n", i, fact(i));
return 0;
}
【代码做什么?】 ① n <= 1 直接返回 1(终止情形,无栈帧开销);② 否则保存本层 n、把 n-1 放进 %rdi 递归调用;③ 用保存的 n 乘 %rax 后恢复返回。
【底层机制透视】 关键在 movq %rdi, %rbx。n 是本层私有状态,必须活过 call fact;%rdi 是调用者保存的,递归调用有权覆写它,所以 n 必须搬到被调用者保存的 %rbx(并配合 pushq %rbx/popq %rbx 保全调用者的 %rbx)。若把 n 留在 %rdi,fact(10) 会算出荒谬的结果——这是初学者最容易犯的错。
【内存布局 / 数据结构图解】 fact(4) 最深处的栈:
高地址
+------------------------------------------+
| main 的帧 |
+------------------------------------------+ <-- main 调用 fact(4) 时 call 压入
| fact(4) 帧: 返回地址 (回到 main) |
+------------------------------------------+
| saved %rbx (main 的旧值) | 被调用者保存约定
| %rbx = 4 | <== 本层私有 n
+------------------------------------------+ <-- fact(4) 调用 fact(3) 时 call 压入
| fact(3) 帧: 返回地址 (回到 fact(4)) |
+------------------------------------------+
| saved %rbx (= 4) |
| %rbx = 3 | <== 本层私有 n
+------------------------------------------+ <-- fact(3) 调用 fact(2) 时 call 压入
| fact(2) 帧: 返回地址 (回到 fact(3)) |
+------------------------------------------+
| saved %rbx (= 3) |
| %rbx = 2 | <== 本层私有 n
+------------------------------------------+ <-- fact(2) 调用 fact(1) 时 call 压入
| fact(1) 帧: 返回地址 (回到 fact(2)) | n<=1 直接 ret,不再 pushq %rbx
%rsp ->+------------------------------------------+ 低地址(栈向此方向增长)
每层恰好 16 字节(返回地址 + saved %rbx),fact(10) 只耗 160 字节;若每层加 1 KB 局部数组,同深度就要 10 KB——帧大小决定递归深度。
【与汇编 / 硬件的对应】 gcc -Og -S 实测:
fact: # n in %rdi
cmpq $1, %rdi
jg .L8 # if (n > 1) goto .L8
movl $1, %eax # return 1
ret
.L8:
pushq %rbx # 保存调用者的 %rbx(被调用者保存约定)
movq %rdi, %rbx # 本层私有 n 存进 %rbx
leaq -1(%rdi), %rdi # 递归参数 n-1
call fact
imulq %rbx, %rax # n * fact(n-1)
popq %rbx # 恢复
ret
【实测验证】 输出 fact(10) = 3628800。讲义附加幻灯片中的递归 pcount_r 用同一套模式(pushq %rbx; movq %rdi,%rbx; andl $1,%ebx; shrq %rdi; call pcount_r; addq %rbx,%rax; popq %rbx);讲义还指出尾递归 popcount_t 因”调用后无任何计算”被优化成 jne .L15 纯循环,不消耗任何栈帧。
5.3.5 示例 5:栈缓冲区溢出演示
⚠️ 仅供演示,请勿模仿。以下代码故意制造越界写,仅用于理解 Attack Lab 的物理基础。
代码 (C):
/* overflow.c —— 编译:gcc -Og -g -fno-stack-protector overflow.c -o overflow */
#include <stdio.h>
#include <string.h>
__attribute__((noinline)) void vulnerable(const char *src) {
char buf[16];
strcpy(buf, src); /* ⚠️ 无边界检查:src 长于 16 字节即越界 */
printf("buf = %s\n", buf);
}
int main(int argc, char **argv) {
if (argc > 1) vulnerable(argv[1]);
printf("returns normally\n");
return 0;
}
【底层机制透视】 strcpy 只拿到一个目标指针,没有任何长度信息,必然写满整串字符加一个 '\0';buf 与返回地址同处一帧、返回地址在更高地址,长输入必然踩到它。
【内存布局 / 数据结构图解】(GDB 实测,-Og -fno-stack-protector)
+-------------------------------+ 高地址 0x7fffffffb6f8
| main 的帧 |
+-------------------------------+ 0x7fffffffb6e0 <== 返回地址所在
| 返回地址 (8 字节) | 「越界 24 字节即改到这里」
+-------------------------------+ 0x7fffffffb6d8
| 填充 (8 字节) |
+-------------------------------+ 0x7fffffffb6d0
| char buf[16] | <== strcpy 从这里开始写
%rsp -> +-------------------------------+ 0x7fffffffb6c8 低地址
【实测验证】
$ ./overflow short-ok
buf = short-ok
returns normally # 未越界,正常返回
$ ./overflow AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault (core dumped) # exit=139=SIGSEGV:返回地址被 0x41414141… 覆盖
24 字节的偏移由 BUFFER_SIZE、对齐与编译器版本共同决定。Attack Lab 中必须用 objdump -d ctarget 亲自量出 buf 与返回地址的偏移,不能照搬任何数字(讲义明确提醒该位置取决于 BUFFER_SIZE 与 GCC 的分配策略)。
5.4 实验关联:L3 Attack Lab
本讲是 L3 Attack Lab 的全部理论前提。立刻动手做的部分:
- 先做课堂活动
machine-procedures:在 GDB 里跑calls与locals,亲手确认call压入返回地址、ret弹回。做完这四道题,你就具备了攻击所需的全部观察手段。 - Phase 1(CTARGET,代码注入 CI):用
objdump -d ctarget找touch1地址与buf到返回地址的偏移,再用hex2raw构造”填充 + 小端返回地址”——本讲的栈帧图与call/ret语义直接给出答案。 - Phase 2/3(CI 传参):注入代码把 cookie 或其字符串地址放进
%rdi,再ret进touch2/touch3(第一个参数用%rdi,见 5.2.4)。 - Phase 4/5(RTARGET,ROP):栈不可执行 + ASLR,只能把
farm.c的 gadget 地址排成链;gadget 就是”指令序列 +ret“,靠的仍是 5.2.3 的语义。
Lab 中会遇到的坑:
- 字节序:
hex2raw按小端写地址,0x0040117a要写成7a 11 40 00。 0x0a是换行:exploit 串中间不能出现0x0a,否则Gets提前终止。- 偏移量必须实测:它由
BUFFER_SIZE与 GCC 分配策略决定,是每个 target 独有的编译期常量。 - 不要用
jmp/call做内部跳转:讲义建议 “useretinstructions for all transfers of control”,因为call的相对偏移编码难以构造。 - Phase 3 的字符串位置:
hexmatch/strncmp会压栈并覆盖getbuf的缓冲区,cookie 字符串要放在不会被覆盖的高地址处。
5.5 常见错误与调试技巧
- 把
%rdi当本地变量存放处:递归或嵌套调用后参数”莫名其妙变了”(%rdi是调用者保存)。调试:objdump -d -M intel a.out看跨call的寄存器是否都是%rbx/%r12-%r15;GDBbreak *addr后info registers对比。 - 忘记恢复
%rbx:pushq %rbx漏了popq %rbx(或提前ret),破坏常出现在离现场很远的地方。调试:gcc -S统计pushq/popq配对数;GDBbreak func、finish,比较入口与返回前的info registers rbx r12 r13。 - 误解栈参数偏移:以为第 7 个参数在
(%rsp),实际那里是返回地址、arg7 在8(%rsp)。调试:gdb --args ./prog,被调用者入口x/4gx $rsp,再p *(long*)($rsp+8)与实参对比。 %rsp16 字节对齐被破坏:调用printf/SSE 时SIGSEGV(movaps要求 16 字节对齐),源于push了奇数个 8 字节未配平。调试:break *func,p/x $rsp & 0xf应为0。- 递归导致栈溢出:
Segmentation fault且bt显示成千上万层同样帧。调试:ulimit -s查栈上限(通常 8192 KB);gdb -ex run -ex 'bt 30'数帧。 - 缓冲区越界但”看起来没事”:覆盖到未使用的填充字节时反而正常返回。调试:
gcc -fsanitize=address -g prog.c运行,ASan 直接报stack-buffer-overflow与越界字节数。 - 以为
&x一定存在:GDB 里p &x报无此变量(-O2下已进寄存器或被消除)。调试:用-Og编译,或gdb -ex 'info locals'、-ex 'info address x'。 gets已不可用:链接期报 “thegets' function is dangerous and should not be used.",因为 C11 已移除gets。**替代**:fgets(buf, sizeof buf, stdin)后自行去掉换行,或getline`。
5.6 关键要点
- 栈是过程机制的正确答案:”若 $P$ 调用 $Q$ 则 $Q$ 先返回”与后进先出天然契合,每个实例获得私有存储,递归与互递归都无需特殊处理。
call= 压返回地址 + 跳转;ret= 弹栈顶到%rip。返回地址在栈上、位于局部缓冲区的高地址侧——这就是 Attack Lab 全部攻击的物理基础。- 数据传递规则:前 6 个整型/指针参数进
%rdi %rsi %rdx %rcx %r8 %r9,第 7 个起从右向左压栈(被调用者读8(%rsp)、16(%rsp));返回值在%rax,浮点在%xmm0。 - 寄存器保存约定:调用者保存
%rax %rcx %rdx %rsi %rdi %r8 %r9 %r10 %r11,被调用者保存%rbx %rbp %r12-%r15,%rsp必须恢复;跨调用存活的值放被调用者保存寄存器或栈上。 %rsp的移动就是分配/释放:subq $N, %rsp分配、addq $N, %rsp释放;x86-64 的%rbp可选,栈帧大小在编译期定死。- 安全第一原则:
strcpy/strcat/sprintf/已废弃的gets都要求程序员自己保证容量;栈越界写能劫持控制流,防御靠 NX、ASLR、canary 与安全编码。
5.7 思考题(带答案)
题 1(栈帧推演题) 某 x86-64 函数(-O0 编译)汇编如下,请画出栈帧并推断其 C 原型与局部变量:
foo:
pushq %rbp
movq %rsp, %rbp
subq $48, %rsp
movq %rdi, -8(%rbp) # 参数 1 落栈
movq %rsi, -16(%rbp) # 参数 2 落栈
movq -8(%rbp), %rax
addq -16(%rbp), %rax
movq %rax, -40(%rbp) # 局部变量 A
movq -8(%rbp), %rax
subq -16(%rbp), %rax
movq %rax, -32(%rbp) # 局部变量 B
movq -40(%rbp), %rax
imulq -32(%rbp), %rax
movq %rax, -24(%rbp) # 局部变量 C
movq -24(%rbp), %rax
addq -32(%rbp), %rax
leave
ret
答案:原型 long foo(long x, long y);三个 long 局部变量 A = x + y、B = x - y、C = A * B,返回 C + B。
高地址
+----------------------------+
| arg7+(本题无) |
+----------------------------+
| 返回地址 |
+----------------------------+ <== %rbp+8
| saved %rbp(调用者的) | <== %rbp 指向此处
+----------------------------+
| -8(%rbp): 参数 x | \
| -16(%rbp): 参数 y | | 编译器把两个入口参数
| -24(%rbp): 局部 C | | 从寄存器搬到栈上
| -32(%rbp): 局部 B | |
| -40(%rbp): 局部 A | /
| -48(%rbp): 填充/对齐 |
+----------------------------+ <== %rsp = %rbp-48
低地址
推演要点:参数先经寄存器到达、再由序言搬到栈上;subq $48 说明帧内 48 字节;负偏移都是本函数私有存储。(我在 /tmp/ch05 用 -Og -S 编译的对应函数 puzzle(5,3) 中,三个局部变量正落在 -40(%rsp)、-32(%rsp)、-24(%rsp)。)
题 2(计算题) 某递归函数每层栈帧 48 字节(含返回地址、saved %rbp 与 32 字节局部数组),Linux 默认栈上限 8 MB。若还要调用 printf(内部一次性再耗 1 KB 栈),最深处约在第几层?
答案:可用栈 $\approx 8 \times 2^{20} = 8388608$ 字节,扣掉 printf 的 1 KB 余量后 $\approx 8387584$ 字节;每层 48 字节,故 $n \approx 8387584/48 \approx 174741$ 层(工程上按 100000 层估算更稳妥)。把每层数组从 32 字节改成 320 字节,最大深度立刻缩成十分之一——递归深度由栈空间而非语言决定。
题 3(”想当然”辨析) 有同学说:”%rax 是返回值寄存器,所以我把它当中间结果,调用 who 之后它肯定还在。”错在哪?
答案:混淆了”用途”与”保存约定”。%rax 的传统用途是返回值,但它的保存约定是调用者保存(call-clobbered),被调用者有权覆写。它”能”作返回值,是因为约定要求调用者主动去读它,而非要求被调用者保全它。跨 call 存活的中间结果必须放 %rbx/%r12-%r15(被调用者保全)或调用者自己的栈帧。同理,%rdi 是”第 1 个参数寄存器”,也不代表调用后仍保留原值。
题 4(设计权衡题) 为什么只让 %rbx、%rbp、%r12-%r15 这 6 个寄存器由被调用者保存,而不让全部 16 个都这样?
答案:这是调用开销与寄存器可用性的折中:全部由被调用者保存,则小函数(如 incr)即使一个寄存器都不用也得 push/pop 一堆,开销加在所有调用上;全部由调用者保存,则被调用者没有”便宜”的私有寄存器,稍复杂的函数就要频繁访存。当前划分下,调用者只为真正跨调用存活的少数值付费(multstore 付一次 pushq %rbx),被调用者把 %rax %rcx %rdx 等当免保存草稿纸用(incr 全程零保存、零访存)。