Lecture 5: 机器级编程 III——过程、栈帧与调用约定 (Machine Programming III: Procedures, Stack Frames, and Calling Convention)

目录 · ← l4 · l6 →

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(过程、数组、异质数据结构) 关联 LabL3 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 Labelret 组成最基本的调用/返回对。
  • 直观解释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 放进 %rdxcall 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%rsp 16 字节对齐——这正是 -Ogmain 出现 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 中弃用(链接时警告),新代码用 fgetsstrcpy 仍在,但容量必须由程序员保证。
  • 缓和措施(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;
}

【代码做什么?】maind 在栈上留 8 字节并用 leaq 8(%rsp), %rdx 取地址,15213%edi2%esicall multstore;② multstorepushq %rbx 保住 dest%rdx 是调用者保存,跨 call 会丢)后 call mult2;③ mult2 相乘并把结果留在 %raxmultstore 写回 (%rbx) 并恢复。

【底层机制透视】 mult2 完全没有栈帧(只用寄存器,ret 前无任何 subq/pushq),印证”x86-64 只用必需的机制”。multstorepushq %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) = 30426objdump -d 显示 mult2 位于 0x401126multstore 中为 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 = 0x7fffffffb6b0x/6gx $rsp 首行为返回地址 0x40114c,反查得 call_incr+28: add 0x8(%rsp),%raxx/1gd $rdi15213。输出 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 $8pushq $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 = 36objdump -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, %rbxn 是本层私有状态,必须活过 call fact%rdi调用者保存的,递归调用有权覆写它,所以 n 必须搬到被调用者保存%rbx(并配合 pushq %rbx/popq %rbx 保全调用者的 %rbx)。若把 n 留在 %rdifact(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 的全部理论前提立刻动手做的部分

  1. 先做课堂活动 machine-procedures:在 GDB 里跑 callslocals,亲手确认 call 压入返回地址、ret 弹回。做完这四道题,你就具备了攻击所需的全部观察手段。
  2. Phase 1(CTARGET,代码注入 CI):用 objdump -d ctargettouch1 地址与 buf 到返回地址的偏移,再用 hex2raw 构造”填充 + 小端返回地址”——本讲的栈帧图与 call/ret 语义直接给出答案。
  3. Phase 2/3(CI 传参):注入代码把 cookie 或其字符串地址放进 %rdi,再 rettouch2/touch3(第一个参数用 %rdi,见 5.2.4)。
  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 做内部跳转:讲义建议 “use ret instructions 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;GDB break *addrinfo registers 对比。
  • 忘记恢复 %rbxpushq %rbx 漏了 popq %rbx(或提前 ret),破坏常出现在离现场很远的地方。调试gcc -S 统计 pushq/popq 配对数;GDB break funcfinish,比较入口与返回前的 info registers rbx r12 r13
  • 误解栈参数偏移:以为第 7 个参数在 (%rsp),实际那里是返回地址、arg7 在 8(%rsp)调试gdb --args ./prog,被调用者入口 x/4gx $rsp,再 p *(long*)($rsp+8) 与实参对比。
  • %rsp 16 字节对齐被破坏:调用 printf/SSE 时 SIGSEGVmovaps 要求 16 字节对齐),源于 push 了奇数个 8 字节未配平。调试break *funcp/x $rsp & 0xf 应为 0
  • 递归导致栈溢出Segmentation faultbt 显示成千上万层同样帧。调试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 已不可用:链接期报 “the gets' 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 + yB = x - yC = 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 全程零保存、零访存)。