Lecture 4: 栈抽象、栈帧与用栈做算术 (The Stack Abstraction, Stack Frames, and Arithmetic Using a Stack)

目录 · ← l3 · l5 →

Lecture 4: 栈抽象、栈帧与用栈做算术 (The Stack Abstraction, Stack Frames, and Arithmetic Using a Stack)

概述

第 3 讲给出了调用约定,但没有回答”参数、返回值、返回地址、局部变量究竟放在哪里”——本讲给出答案: 放在栈 (stack) 上的一块连续区域里,这块区域叫栈帧 (stack frame / activation record)。 本讲引入 LIFO 语义、两条指令的 push/pop、R6 栈指针与 R5 帧指针的分工,以及完整的 LC-3 栈帧布局; 并用两个真实例子把它落地:用栈计算后缀表达式 1 2 + 3 4 - *(即 (1+2)*(3-4)),以及一个带完整帧的加法子程序。 这套机制正是 C 中 automatic 变量、递归与”栈溢出”的物理基础,也是后续数组、指针与动态数据结构的前提。

核心概念与底层机制图解

  • 栈抽象 (Stack Abstraction):栈是一种只能在顶端进出的数据结构,提供 LIFO(后进先出) 语义——像一摞餐盘,只能把新盘子放在最上面、也只能从最上面取走。
    • 底层机制图解

      操作    含义                与队列 (queue, FIFO) 的对比(BFS 用队列,见第 1 讲)
      PUSH    把数据放到栈顶        队列:从尾部加入
      POP     取走栈顶数据          队列:从头部取出
      
      栈的生长方向(关键约定):
          高地址 +----------+  ← 栈底 (base):一开始 R6 指向这里,此时栈为空
          低地址 +----------+  ← 栈顶 (top):R6 指向最后一个被压入的字;压栈时地址减小
      

      内存里”栈顶以上(更高地址)”的内容是旧数据:还在内存里但已不属于栈——弹出并不擦除数据。

    • 作用域与存储期:栈上的数据是 automatic storage duration:进入函数/块时创建、离开时销毁,生命周期与”活跃的调用”严格对应;C 中函数内定义的普通变量就在这类存储里。

  • PUSH / POP 的两条指令实现:LC-3 没有 PUSH/POP 指令,但每个操作恰好用两条指令完成——压栈 = 先把手指往下挪一格再放东西,弹栈 = 先拿起东西再把手指往上挪一格。
    • 底层机制图解

      压栈(push R0)                    弹栈(pop 到 R0)
      ADD  R6,R6,#-1   ; 先腾出空间      LDR  R0,R6,#0   ; 先取数据
      STR  R0,R6,#0    ; 再存入数据      ADD  R6,R6,#1   ; 再收回空间
      

      顺序不能颠倒ADD R6,R6,#-1 放到后面就会写错格子、甚至覆盖别的帧;两条移动指令一条 #-1、一条 #1,对称且容易检查。

    • 作用域与存储期:被压入的值活到它被弹出为止;一旦弹出,它对应的存储期就结束了(比特还在内存里,但读取它是未定义行为——这正是”返回局部变量地址”错误的根源)。

  • R6 栈指针与 R5 帧指针 (Stack Pointer and Frame Pointer)R6 指向栈顶(一直在动,像”当前手的位置”),R5 指向当前帧的基准(本层内固定不动,像”本层书本的封面标签”)。
    • 底层机制图解:本课与 MP 使用的完整帧布局(高地址在上、低地址在下):

      高地址  +--------------------------+
              \|  caller 的栈帧            \|   调用者的帧
              +--------------------------+
              \|  parameters              \| ← R5+4, R5+5, ...(第一个参数固定在 R5+4)
              +--------------------------+
              \|  return value            | ← R5+3   ┐
              |  return address (R7)     | ← R5+2   ├ 这三格是 linkage(连接信息)
              |  previous frame pointer  | ← R5+1   ┘
              +--------------------------+
              \|  local variable 0        | ← R5+0   ← R5 指向局部变量底部
              |  local variable 1, ...   | ← R5-1, R5-2, ...
      低地址  +--------------------------+   ← R6 指向栈顶(最低的活动字)
      

      关键顺序(课程真实约定,见 translate.asmFIND_ABSR5+0(及 R5-1R5-2、…)= 局部变量;R5+1 = 旧帧指针;R5+2 = 返回地址;R5+3 = 返回值R5+4R5+5、… = 参数。返回值必须在 R5+3,因为它要紧贴在第一个参数 R5+4 之下,调用者才能用一条 ADD R6,R6,#(nparams+1) 同时弹掉参数返回值(538-mt1-review:”Pop parameters and return value (destroy the params)”)。

    • 作用域与存储期R5/R6 在子程序入口保存、出口恢复,因此每个活跃的调用都有自己的一对值;配合”每次调用都有自己的帧”,就得到 C 中局部变量互不干扰递归可行的保证。

  • 为什么两者缺一不可:R6 在函数体内不断移动,R5 不动——R6 像电梯楼层号(一直在变),R5 像”我家在 5 楼”,用它来算相对位置。
    • 底层机制图解

      时刻             R6       R5     R5-R6
      刚建好帧         R5       R5       0     ← 两者相同
      压 2 个参数后    R5-2     R5       2     ← R6 变了;参数仍在 R5+4、R5+5
      调用返回后       R5       R5       0
      又压 1 个临时值  R5-1     R5       1
      用 R6 定位参数:LDR R0,R6,#2 的偏移每次都不同(无法使用)
      用 R5 定位参数:LDR R0,R5,#4 永远成立
      

      x86-64 的调用约定(rbp 帧指针 + rsp 栈指针)与 LC-3 完全同构;高优化下编译器会”省略帧指针”,代价是调试器无法可靠地还原调用栈。

    • 作用域与存储期R5 让”帧内偏移”成为编译期常量(编译器符号表里 find_abs/num → R5+4 那一列),R6 只负责空间的分配与回收。

  • 建立与拆除栈帧 (Prologue / Epilogue):调用者压参数,被调用者建帧,返回时按相反顺序拆帧——像进房间先挂外套摆好工具(prologue),离开前收好工具取回外套(epilogue)。
    • 底层机制图解

      ① 调用者压参数(右到左):先压 p2(在更高地址),再压 p1(落在栈顶 = R6,它就是新帧的 R5+4)
      ② 调用者 JSR 子程序                       ;R7 ← 返回地址
      ③ 被调用者 prologue(FIND_ABS 的写法:k 个局部变量就压 3+k 格)
           ADD  R6,R6,#-(3+k)  ;3 格 linkage + k 个局部变量
           STR  R5,R6,#1       ;R5+1 ← 旧帧指针(此时 R6+1 正是将来的 R5+1)
           ADD  R5,R6,#0       ;R5 = R6;R5+0 就是第一个局部变量
           STR  R7,R5,#2       ;R5+2 ← 返回地址
      ④ 函数体:局部变量用 R5+0、R5-1、…,参数用 R5+4、R5+5、…
      ⑤ 被调用者 epilogue
           STR  R0,R5,#3       ;返回值写进 R5+3(紧贴第一个参数之下)
           LDR  R7,R5,#2       ;取回返回地址
           LDR  R5,R5,#1       ;恢复调用者的帧指针
           ADD  R6,R6,#(k+2)   ;弹出局部变量与 linkage,只把 R5+3 的返回值留在栈顶
           RET                 ;JMP R7
      ⑥ 调用者:LDR R0,R6,#0 读返回值,再用一条 ADD R6,R6,#(nparams+1) 弹掉参数与返回值
      

      FIND_ABS 有 1 个局部变量:压 3+1=4 格、epilogue ADD R6,R6,#3FOO 有 2 个局部变量:压 5 格、ADD R6,R6,#4。 没有局部变量时(translate.asmMAIN)只压 3 格并令 R5 = R6-1R5+1R5+3 的语义完全不变。)

    • 作用域与存储期:帧的存活期 = 一次调用;R5 的恢复使调用者的作用域不受影响,这就是”两个函数里的同名变量互不干扰”的机制来源。

  • 用栈做算术 (Arithmetic Using a Stack):后缀(postfix)表达式天然对应”遇到数就压栈、遇到运算符就弹两次再压结果”;中缀 1 + 2 × 3 有歧义、需要优先级规则,而后缀 1 2 3 × + 无歧义,连括号都不用。
    • 底层机制图解:把 (1+2)*(3-4) 写成后缀 1 2 + 3 4 - * 并用 R6 执行:

      输入记号   动作                       栈内容(栈顶在左)        R6
      1          push 1                    [1]                      base-1
      2          push 2                    [2, 1]                   base-2
      +          pop 2 和 1,push 1+2=3    [3]                      base-1
      3 与 4     各压一次栈                 [4, 3, 3]                base-3
      -          pop 4 和 3,push 3-4=-1   [-1, 3]                  base-2
      *          pop -1 和 3,push 3×(-1)  [-3]                     base-1
      结束        结果在栈顶                结果 = -3
      

      实现上只需要”弹两个、算、压一个”的子程序,每个都恰好是 LDR R1,R6,#1(更深的操作数)+ LDR R0,R6,#0(栈顶)+ ADD R6,R6,#2 + 计算 + ADD R6,R6,#-1 + STR

    • 作用域与存储期:栈上每个操作数的存储期就是它参与运算的那一瞬间;这种数据流驱动的求值方式也是 JVM 字节码与许多解释器的模型。

  • 栈与堆相向增长 (Stack and Heap Grow Toward Each Other):两个区域从内存两端出发,中间是自由空间——栈像从天花板往下挂的盘子,堆像从地面往上堆的箱子。
    • 底层机制图解

      高地址  +--------------------+
              \| 系统空间 / I/O      |  xFE00 起;栈 stack 从上方往下增长 ↓(R6/R5)
              |      ↓             |
              |      ↑             |
              | 堆 heap            |  向上增长 ↑   malloc 从低处往高处要空间
              | 全局数据 global data|  R4 指向这里(本课约定 x4000 起)
              | 代码 code          |  从 x3000 开始
      低地址  +--------------------+
      

      malloc 与函数调用在同一个地址空间的两端抢空间,这是”栈溢出”与”内存耗尽”在系统层面的真实图景。

    • 作用域与存储期:栈对应 automatic、堆对应 allocated(mallocfree)、全局数据对应 static;三种存储期在内存图上就是三段不同的区域。

  • 栈溢出与下溢 (Overflow / Underflow):LC-3 的栈没有任何检查——一摞盘子堆到顶会塌,抽空了还继续抽也会塌,机器不会报警,只会悄悄写坏别人的数据。
    • 底层机制图解:溢出(压栈过多,R6 撞上堆或全局数据)在 LC-3/嵌入式/内核里造成静默的数据破坏 (silent data corruption),是最难查的一类 bug;在桌面/手机上则由硬件检测越界页、由操作系统让程序崩溃(segmentation fault)。下溢(弹出过多,R6 越过栈底往上跑)会读到别的帧的内容。递归深度过大是溢出的最常见原因。
    • 作用域与存储期:栈的检查是运行期问题,与语言层无关——C 不检查,LC-3 不检查,只有操作系统 + 硬件通过”页保护”近似兜底。
  • 与 C 的连接 (Connection to C):C 的每一次函数调用都是本讲这套动作的翻译结果。写 C 时你只管定义变量、调用函数,编译器负责建帧、算偏移、拆帧。
    • 底层机制图解

      C 语言                      LC-3 机制(本讲)
      函数参数                     调用者压栈;第一个参数固定在 R5+4
      局部变量 (automatic)         R5+0, R5-1, ...(帧内偏移是编译期常量)
      return 值                    写进 R5+3,调用者用 LDR R0,R6,#0 读回
      递归                         每层调用都有自己的帧(R5/R6 各不一样)
      变量的作用域 (scope)          由符号表(R5+偏移)实现,与运行期无关
      

      教学目标 2/3/4/7(作用域与存储、调用约定、数组与指针、递归)都直接建立在这张帧图上。

    • 作用域与存储期:C 的 storage duration 分类(automatic / static / allocated)与本讲的内存图一一对应;而 scope 是编译期概念,它决定编译器把名字翻译成哪个 R5+偏移

代码示例与底层机制分析

示例 1:用栈执行后缀程序 1 2 + 3 4 - *

代码 (LC-3 assembly):

; 计算 (1+2)*(3-4),即后缀式 1 2 + 3 4 - *
; 寄存器用途
;   R6 : 栈指针(指向栈顶,栈向低地址增长)
;   R0 : 操作数 / 返回值 / 算术临时
;   R1 : 第二个操作数(更深的那个)与乘法计数器
;   R2 : MULT 的累加器
        .ORIG x3000
        LEA  R6,STK_BASE     ; x3000: R6 = x4000,栈基址(空栈)

        ; ---- 执行 "1" 与 "2":压栈 ----
        AND  R0,R0,#0        ; x3001
        ADD  R0,R0,#1        ; x3002
        ADD  R6,R6,#-1       ; x3003  压栈的固定两步
        STR  R0,R6,#0        ; x3004
        AND  R0,R0,#0        ; x3005
        ADD  R0,R0,#2        ; x3006
        ADD  R6,R6,#-1       ; x3007
        STR  R0,R6,#0        ; x3008

        ; ---- 执行 "+" ----
        JSR  STACKADD        ; x3009: 弹出 2 与 1,压回 3

        ; ---- 执行 "3" 与 "4" ----
        AND  R0,R0,#0        ; x300A
        ADD  R0,R0,#3        ; x300B
        ADD  R6,R6,#-1       ; x300C
        STR  R0,R6,#0        ; x300D
        AND  R0,R0,#0        ; x300E
        ADD  R0,R0,#4        ; x300F
        ADD  R6,R6,#-1       ; x3010
        STR  R0,R6,#0        ; x3011

        ; ---- 执行 "-" 与 "*" ----
        JSR  STACKSUB        ; x3012: 弹出 4 与 3,压回 -1
        JSR  STACKMUL        ; x3013: 弹出 -1 与 3,压回 -3

        LDR  R0,R6,#0        ; x3014: 结果出栈
        ADD  R6,R6,#1        ; x3015
        HALT                 ; x3016

; STACKADD -- 弹出两个操作数,压回它们的和
;   栈顶是 rhs,其下是 lhs;出口 R0 = lhs + rhs;改变 R0、R1
STACKADD
        LDR  R1,R6,#1        ; x3017: R1 ← lhs(更深的一个)
        LDR  R0,R6,#0        ; x3018: R0 ← rhs(栈顶)
        ADD  R6,R6,#2        ; x3019: 一次弹出两个
        ADD  R0,R0,R1        ; x301A: R0 ← lhs + rhs
        ADD  R6,R6,#-1       ; x301B: 压回结果
        STR  R0,R6,#0        ; x301C
        RET                  ; x301D

; STACKSUB -- 弹出两个操作数,压回 lhs - rhs
STACKSUB
        LDR  R1,R6,#1        ; x301E: R1 ← lhs
        LDR  R0,R6,#0        ; x301F: R0 ← rhs
        ADD  R6,R6,#2        ; x3020: 弹出两个
        NOT  R0,R0           ; x3021: 取 rhs 的补码
        ADD  R0,R0,#1        ; x3022: R0 ← -rhs
        ADD  R0,R1,R0        ; x3023: R0 ← lhs - rhs
        ADD  R6,R6,#-1       ; x3024: 压回结果
        STR  R0,R6,#0        ; x3025
        RET                  ; x3026

; STACKMUL -- 弹出两个操作数,压回 lhs * rhs
;   它要调用 MULT,所以必须先把 R7 保存到栈上(第 3 讲的 R7 纪律)
STACKMUL
        ADD  R6,R6,#-1       ; x3027: 在栈上腾一格保存 R7
        STR  R7,R6,#0        ; x3028: 现在栈布局是 [R7, rhs, lhs]
        LDR  R1,R6,#2        ; x3029: R1 ← lhs(跳过 R7 与 rhs)
        LDR  R0,R6,#1        ; x302A: R0 ← rhs
        JSR  MULT            ; x302B: R0 ← lhs * rhs
        LDR  R7,R6,#0        ; x302C: 恢复返回地址
        ADD  R6,R6,#1        ; x302D: 丢掉保存的 R7
        ADD  R6,R6,#2        ; x302E: 弹出两个操作数
        ADD  R6,R6,#-1       ; x302F: 压回乘积
        STR  R0,R6,#0        ; x3030
        RET                  ; x3031

; MULT -- 用重复加法计算 R0 * R1(假设 R1 >= 0),结果放 R0;改变 R1、R2
MULT    AND  R2,R2,#0        ; x3032: R2 ← 0
        ADD  R1,R1,#0        ; x3033: 测试乘数
        BRz  MULT_DONE       ; x3034
MULT_L  ADD  R2,R2,R0        ; x3035: 累加
        ADD  R1,R1,#-1       ; x3036
        BRnp MULT_L          ; x3037
MULT_DONE
        ADD  R0,R2,#0        ; x3038
        RET                  ; x3039

        .BLKW #64            ; x303A..x3079: 给栈留出空间
STK_BASE                     ; x307A: 空栈时 R6 指向这里
        .END

【代码做什么?】

  1. LEA R6,STK_BASE 把栈指针指到栈底;此时”栈是空的”(没有任何字低于 R6 属于栈)。
  2. 每个数字记号(1、2、3、4)都用固定两条指令压栈:ADD R6,R6,#-1 然后 STR R0,R6,#0
  3. 每遇到运算符就 JSR 对应子程序:取两个操作数、一次弹出、算出结果、再压回;STACKMUL 在调用 MULT 前把 R7 压栈保存,返回前恢复。
  4. 最后 LDR R0,R6,#0 取回栈顶结果并弹出:R0 = -3(= xFFFD),即 (1+2)*(3-4)

【底层机制透视】

  • 栈的”弹出”只是移动指针ADD R6,R6,#2 之后两个操作数仍在内存里,但已经不属于栈;再压一个新值就会覆盖它们。”一次弹两个”之所以安全,是因为栈是连续内存(等价于两次 ADD R6,R6,#1);而任何”部分弹出”都会让栈失去平衡。
  • MULT 的循环次数取决于操作数的值R1 是乘数(此处 lhs = 3),每次减一,内层 ADD R2,R2,R0rhs 累加进去。负的 rhs 能工作(累加负数),但负的 R1 会死循环——这就是”接口假设必须写进 CIS”的例子。
  • R7 保存在栈上(比第 3 讲的固定 .BLKW 槽更安全),因此 STACKMUL 即使被再次进入也不会互相覆盖。

【内存布局图解】(STK_BASE = x4000,逐步执行)

初始:     R6 = x4000(空栈)
push 1/2: x3FFF = 1、x3FFE = 2      R6 = x3FFE    栈(顶在左): [2, 1]
STACKADD: 读 M[R6+1] = 1 (lhs)、M[R6+0] = 2 (rhs) → R6 = x4000 → 压回 3 于 x3FFF
push 3/4: x3FFE = 3、x3FFD = 4      R6 = x3FFD    栈: [4, 3, 3]
STACKSUB: 读 3 (lhs) 与 4 (rhs) → 3-4 = -1 → R6 = x3FFE,M[x3FFE] = -1    栈: [-1, 3]
STACKMUL: 压 R7 到 x3FFD → 读 3 (lhs) 与 -1 (rhs) → JSR MULT(累加 3 次 -1)→ R0 = -3
          → 弹两格、压回 → R6 = x3FFF,M[x3FFF] = -3                      栈: [-3]
结束:     LDR R0,R6,#0 → R0 = xFFFD = -3;R6 = x4000(栈恢复为空)

内存快照: x3FFF = xFFFD(-3) 仍在栈上;x3FFE = xFFFD(-1)、x3FFD = R7、x3FFC = 3 都已弹出(属"旧数据")

【与汇编的对应】:这段程序与它的”高级语言版本”(示例 3)逐句对应——C 的 push(x)/pop() 就是那两条指令,sp 就是 R6,stack[] 就是 x4000 以下的 LC-3 内存;能手工完成的”栈机器求值”是理解所有表达式求值的通用语言。

示例 2:一个带完整栈帧的子程序 SUM2

代码 (LC-3 assembly):

; SUM2 -- 把栈上的两个参数相加,返回值留在栈顶
; 调用接口 (CIS)
;   输入  : 两个参数由调用者压栈(右到左:先压 p2,再压 p1,所以 p1 在栈顶 = R5+4)
;   输出  : 返回值在 M[R6](即帧内的 R5+3);调用者用一条 ADD 同时弹掉参数与返回值
;   改变  : R0、R1(caller-saved)
;   副作用: 无(只读写自己的帧)
; 帧布局(R5 相对,与 translate.asm 的 FIND_ABS 完全一致):
;   R5+5 = p2   R5+4 = p1   R5+3 = 返回值   R5+2 = 返回地址
;   R5+1 = 旧帧指针        R5+0 = 局部变量
        .ORIG x3000
        LEA  R6,STK_BASE     ; x3000: R6 = x305A
        LD   R0,VAL_B        ; x3001: p2 先压(右到左 → p2 在更高地址)
        ADD  R6,R6,#-1       ; x3002
        STR  R0,R6,#0        ; x3003
        LD   R0,VAL_A        ; x3004: p1 后压 → 落在栈顶,成为新帧的 R5+4
        ADD  R6,R6,#-1       ; x3005
        STR  R0,R6,#0        ; x3006
        JSR  SUM2            ; x3007: R7 ← x3008
        LDR  R0,R6,#0        ; x3008: 读返回值(= 35)——R6 正指向它
        ADD  R6,R6,#3        ; x3009: 一条 ADD 弹掉 2 个参数 + 1 个返回值
        HALT                 ; x300A

SUM2                         ; x300B: ---- 被调用者 ----
        ADD  R6,R6,#-4       ; x300B: prologue:3 格 linkage + 1 个局部变量
        STR  R5,R6,#1        ; x300C: R5+1 ← 调用者的帧指针(必须在改 R5 之前)
        ADD  R5,R6,#0        ; x300D: R5 = R6,R5+0 就是第一个局部变量
        STR  R7,R5,#2        ; x300E: R5+2 ← 返回地址

        LDR  R1,R5,#4        ; x300F: R1 ← p1(第一个参数固定在 R5+4)
        LDR  R0,R5,#5        ; x3010: R0 ← p2
        ADD  R1,R1,R0        ; x3011: R1 ← p1 + p2
        STR  R1,R5,#0        ; x3012: 局部变量 ← 和

        STR  R1,R5,#3        ; x3013: epilogue:返回值写进 R5+3
        LDR  R7,R5,#2        ; x3014: 取回返回地址
        LDR  R5,R5,#1        ; x3015: 恢复调用者的帧指针
        ADD  R6,R6,#3        ; x3016: 弹出局部变量与 linkage(返回值除外)→ R6 = R5+3
        RET                  ; x3017

VAL_A   .FILL #42            ; x3018
VAL_B   .FILL #-7            ; x3019
        .BLKW #64            ; x301A..x3059: 栈空间
STK_BASE                     ; x305A: 空栈位置
        .END

【代码做什么?】

  1. 调用者把参数从右向左压栈:先 p2 = -7(落在更高地址),再 p1 = 42(落在栈顶 = 将来的 R5+4);JSR 把返回地址放进 R7。
  2. prologue 压 3+1=4 格:把旧帧指针存到 R6+1R5 = R6 之后它就是 R5+1),令 R5 = R6,再把返回地址存到 R5+2
  3. 函数体用 LDR R1,R5,#4LDR R0,R5,#5固定偏移取参数,相加后写进局部变量 R5+0
  4. epilogue 把结果写进 R5+3、恢复 R7 与 R5、用 ADD R6,R6,#3 让 R6 落在返回值上;调用者读结果(35)后用一条 ADD R6,R6,#3 弹掉 2 个参数与返回值。

【底层机制透视】

  • 参数在 R5+4 而不是 R5+0:因为被调用者的 linkage 与局部变量都压在参数的下面(更低地址);第一个参数是最后一个被压入的,所以位置固定。
  • STR R5,R6,#1 必须在 ADD R5,R6,#0 之前:一旦 R5 被覆盖,调用者的帧指针就永远丢失。此时还没设 R5,所以用 R6+1 作基址——而 R5 = R6 之后 R6+1 正是 R5+1
  • 返回值的位置决定调用者如何弹栈R5+3 紧贴在第一个参数 R5+4 之下,所以 RET 之后 R6 指向返回值、参数就在它上面,调用者于是能用一条 ADD R6,R6,#(nparams+1) 同时弹掉两者。若返回值放在别处(例如 R5+1),调用者就得先弹掉连接残留、再单独弹参数,一条指令完不成——这正是课程规定”返回值必须在 R5+3”的原因。
  • epilogue 的两条 LDR 不能交换LDR R7,R5,#2 必须用旧 R5 作基址;先恢复 R5 再去读 R5+2 只会读到调用者帧里的垃圾值。

【内存布局图解】(prologue 完成后,R5 = x3054)

        地址     内容                      R5 相对      说明
        x3059    xFFF9 (-7)               R5+5        参数 p2(先压 → 高地址)
        x3058    x002A (42)               R5+4        参数 p1(后压 → 栈顶方向)
        x3057    x0023 (35)               R5+3        返回值(紧贴第一个参数之下)
        x3056    x3008                    R5+2        返回地址(JSR 写入的 R7)
        x3055    x0000                    R5+1        旧帧指针(调用者的 R5)
        x3054    x0023 (35)               R5+0        局部变量(和)  ← R5 = R6 = x3054
        x305A                             STK_BASE    调用前的 R6(空栈位置;x3053 以下是 SUM2 的帧)

函数体执行时 R5 = R6 = x3054;若又压了两个临时值,R6 = x3052(R5−R6 = 2),
但 LDR R1,R5,#4 仍正确取到 p1 —— 这就是"为什么要留着 R5"。
epilogue 之后 R6 = R5+3 = x3057(指向返回值)、R5 = x0000;
调用者 ADD R6,R6,#3 后 R6 = x305A,与调用前完全一致(栈平衡)。

【与汇编的对应】(逐条手算执行结果)

时刻R0R1R5R6内存变化
LD R0,VAL_B#−7x0000x305A
压 p2 后#−7x0000x3059M[x3059] = −7
LD R0,VAL_A 后 / 压 p1 后#42x0000x3058M[x3058] = 42(p1 成为 R5+4)
JSR 后(prologue 前)#42x0000x3058R7 = x3008
ADD R6,R6,#-4#42x0000x3054
STR R5,R6,#1 后(R5 还是旧值)#42x0000x3054M[x3055] = 0(旧帧指针 → R5+1)
ADD R5,R6,#0#42x3054x3054R5 = R6
STR R7,R5,#2#42x3054x3054M[x3056] = x3008(返回地址)
求和并存储后#−7#35x3054x3054M[x3054]=35(局部变量)
epilogue 后#−7#35x0000x3057M[x3057]=35(返回值 → R5+3)
RET 后 / ADD R6,R6,#3#35#35x0000x305AR6 先指向返回值,读回后用一条 ADD 弹掉参数与返回值,栈完全恢复

R0 = 35,正是 42 + (−7)

示例 3:C 版本的后缀求值器(同一算法的”高级语言形态”)

代码 (C):

/* ECE 220 -- Lecture 4 example: arithmetic on a stack.
 *
 * Running the postfix program "1 2 + 3 4 - *", which is (1 + 2) * (3 - 4).
 * The array below plays the role of LC-3 memory, sp plays the role of R6,
 * and push/pop are exactly ADD R6,R6,#-1 + STR and LDR + ADD R6,R6,#1.
 */
#include <stdio.h>
#include <stdint.h>

#define STK_SIZE 16

static int32_t stack[STK_SIZE];   /* LC-3 memory used as the stack */
static int32_t sp = STK_SIZE;     /* R6: index of the top element  */

static void push(int32_t value)
{
    sp = sp - 1;            /* ADD R6,R6,#-1 : make space first */
    stack[sp] = value;      /* STR R0,R6,#0  : then store       */
}

static int32_t pop(void)
{
    int32_t value = stack[sp];   /* LDR R0,R6,#0 */
    sp = sp + 1;                 /* ADD R6,R6,#1 : remove space */
    return value;
}

static void show(const char *label)
{
    int32_t i;

    printf("%-12s stack (top first):", label);
    if (sp == STK_SIZE) {
        printf(" <empty>");
    }
    for (i = sp; i < STK_SIZE; i = i + 1) {
        printf(" %d", (int)stack[i]);
    }
    printf("   [R6 offset = %d]\n", (int)(sp - STK_SIZE));
}

int main(void)
{
    const char *program[] = { "1", "2", "+", "3", "4", "-", "*" };
    int32_t i;

    show("start");
    for (i = 0; i < 7; i = i + 1) {
        const char *token = program[i];

        if (token[0] >= '0' && token[0] <= '9') {
            push((int32_t)(token[0] - '0'));
            printf("push %s\n", token);
        } else {
            int32_t rhs = pop();
            int32_t lhs = pop();
            int32_t result = 0;

            if (token[0] == '+') {
                result = lhs + rhs;
            } else if (token[0] == '-') {
                result = lhs - rhs;
            } else {
                result = lhs * rhs;
            }
            push(result);
            printf("%s: %d %c %d = %d\n", token, (int)lhs, token[0], (int)rhs,
                   (int)result);
        }
        show("  after");
    }

    printf("result = %d\n", (int)pop());
    return 0;
}

编译与运行:

$ gcc -g -std=c99 -Wall -Werror ece220_l04_rpn.c -o ece220_l04_rpn
$ ./ece220_l04_rpn
start        stack (top first): <empty>   [R6 offset = 0]
push 1
  after      stack (top first): 1   [R6 offset = -1]
push 2
  after      stack (top first): 2 1   [R6 offset = -2]
+: 1 + 2 = 3
  after      stack (top first): 3   [R6 offset = -1]
push 3
  after      stack (top first): 3 3   [R6 offset = -2]
push 4
  after      stack (top first): 4 3 3   [R6 offset = -3]
-: 3 - 4 = -1
  after      stack (top first): -1 3   [R6 offset = -2]
*: 3 * -1 = -3
  after      stack (top first): -3   [R6 offset = -1]
result = -3

【代码做什么?】

  1. spSTK_SIZE 出发表示”空栈”;push 先减 sp 再写入,pop 先读出再增 sp——与 ADD R6,R6,#-1+STR / LDR+ADD R6,R6,#1 一一对应。
  2. 主循环遍历记号数组:数字解析为整数后压栈;运算符先弹 rhs、再弹 lhs,按 lhs op rhs 计算后压回。
  3. show 每步打印栈内容与”R6 偏移”,便于与手算的 LC-3 栈图对照;最后输出 result = -3,与示例 1 的 R0 = xFFFD 一致。

【底层机制透视】

  • sp 用的是数组下标而非地址,sp - STK_SIZE 就是”R6 相对栈底移动了多少个字”。
  • stack[sp] 的”先减指针、再写入”顺序保证了栈向低地址生长;反过来写就与 LC-3 语义相反。
  • pop() 不会擦除 stack[sp]——与 LC-3 一样,”弹出”只是移动指针,旧值仍在内存里。
  • 运算符分派用 if/else 链实现,对应 LC-3 的比较与分支;真实编译器会把它变成跳转表 (jump table)。

【内存布局图解】

全局数据区(static storage duration)
+-----------------------------+  0x4040a0 附近
| stack[0] ... stack[15]      |  32 字节;sp 从 16 开始,向索引 0 方向"生长"
| sp = 0                      |  执行到最后:栈里只剩 1 个元素
+-----------------------------+

栈内容(打印中"-"一步之后):
        +---+---+---+---+---+
        | 4 | 3 | 3 | ? | ? |  ...      打印时从 sp 到 15 依次输出 → "4 3 3"
        +---+---+---+---+---+
          ↑
          sp(栈顶);索引 13、14 的旧值已不在"栈上"

【与汇编的对应】

; C: sp = sp - 1;  stack[sp] = value;
        ADD  R6,R6,#-1         ; sp--
        STR  R0,R6,#0          ; stack[sp] = value
; C: value = stack[sp];  sp = sp + 1;
        LDR  R0,R6,#0          ; value = stack[sp]
        ADD  R6,R6,#1          ; sp++
; C: rhs = pop(); lhs = pop(); result = lhs - rhs; push(result);
        JSR  POP_R0            ; rhs 在 R0
        ADD  R3,R0,#0          ; 暂存 rhs(R3 是 caller-saved)
        JSR  POP_R0            ; lhs 在 R0
        NOT  R3,R3             ; -rhs
        ADD  R3,R3,#1
        ADD  R0,R0,R3          ; lhs - rhs
        JSR  PUSH_R0

一次调用一层帧:递归的前奏

SUM2 的 prologue/epilogue 放在一个自我调用的子程序里,就得到递归:每层调用都在更低地址压出自己的一份完整帧(自己的 R5R7、局部变量),同一段代码于是可以有任意多份互不干扰的数据。在真实机器上可直接观察(本机实测每层相差 0x30 = 48 字节):

$ gcc -g -std=c99 -Wall -Werror ece220_l04_frames.c -o ece220_l04_frames && ./ece220_l04_frames
main     : &automatic = 0x7ffe86cb5c68
frame 3: &local = 0x7ffe86cb5c4c   local = 30      (完整程序见 ece220_l04_frames.c)
frame 2: &local = 0x7ffe86cb5c1c   local = 20      地址随 ASLR 变化,但逐层下降
frame 1: &local = 0x7ffe86cb5bec   local = 10
frame 0: &local = 0x7ffe86cb5bbc   local = 0
sum of the four locals = 60

在 LC-3 上重复同样的实验,就是观察 DEPTH_REPORT 每次 prologue 之后的 R5:每递归一层就减小固定的字节数, 直到 R6 撞上代码或全局数据——那就是前面说的”栈溢出”。

常见错误与调试技巧

  • R6 失去平衡(弹栈数量算错):调用者忘记执行 ADD R6,R6,#(nparams+1),或弹多了把调用者的数据当参数弹掉;现象是程序”跑一会儿就错”,多个函数间互相踩数据。调试:在每个调用点前后比较 R6;gdb 中用 p $rsp(对应 R6)核对;lc3simprint R6 并用 dump 观察栈区。
  • prologue 顺序写错:先 ADD R5,R6,#0STR R5,R6,#1,把帧指针当成旧帧指针存进 R5+1;或者把偏移写成 #3(那是返回值的位置)。现象是调用者返回后局部变量与 R5 全部错乱。调试:逐条单步 prologue,检查 M[R5+1] 是否等于调用前的 R5,M[R5+2] 是否等于 JSR 的下一条指令地址。
  • 用 R6 而不是 R5 访问参数:写成 LDR R0,R6,#2,一旦函数体内压过临时值,偏移就失效,现象是”参数偶尔读错”。调试:检查所有访问参数/局部变量的指令是否都以 R5 为基址;在 gdb 中打印 $rbp/$rsp 的差,确认帧内偏移恒定。
  • 返回局部变量的地址int32_t *f(void) { int32_t x = 1; return &x; }——帧一拆,x 就没有存储期了,现象是调用后读到的值随机变化。调试-Wall -Werror 会给出 -Wreturn-local-addrvalgrind --tool=memcheck ./proggcc -fsanitize=address -g 能直接指出”使用了已释放的栈内存”。
  • 把”弹出”误解为”擦除”:弹出后仍去读 M[R6] 之上的旧值,误以为那是”栈上的数据”,于是读到残留值。调试:用 x/8xw $rsp(gdb)或 dump(lc3sim)观察栈区,对照”R6 以上不属于栈”逐字检查。
  • 栈溢出:无限递归或每层帧过大(例如把大数组声明为局部变量)。LC-3 上是静默数据破坏,Linux 上是 Segmentation fault调试gdbbt 50 看递归深度;ulimit -s 查栈上限;把递归改成用显式栈的迭代(如同示例 1)。

关键要点

  • 栈提供 LIFO 语义;在 LC-3 上压栈是 ADD R6,R6,#-1 + STR R0,R6,#0,弹栈是 LDR R0,R6,#0 + ADD R6,R6,#1顺序不可颠倒,而且”弹出”只移动指针、不擦除数据。
  • R6 是栈指针(一直移动),R5 是帧指针(本帧内固定);没有 R5,函数体一旦压入临时值,就无法再用固定偏移访问参数与局部变量。C 的自动变量、递归、作用域隔离全部依赖这一点。
  • 栈帧布局(课程真实约定):R5+0/R5-1/… 局部变量、R5+1 旧帧指针、R5+2 返回地址、R5+3 返回值R5+4/R5+5/… 参数;返回值必须在 R5+3,这样调用者才能用一条 ADD R6,R6,#(nparams+1) 同时弹掉参数与返回值。
  • 用栈求值是表达式求值的通用模型(遇到数压栈、遇到运算符就”弹两次、算、压一次”):1 2 + 3 4 - * 最终得到 -3,与 C 版本一致;而栈与堆从地址空间两端相向增长,相遇即内存耗尽,栈的溢出/下溢在 LC-3 上没有任何检查,只会造成静默的数据破坏。

思考题(带答案)

问题 1:为什么 LC-3 的帧布局把返回值放在 R5+3(紧贴第一个参数 R5+4 之下),而不是像局部变量那样放在 R5 以下?如果 C 规定实参从左向右压栈,R5+4 还会是第一个参数吗?

答案:因为调用者返回后要用一条 ADD R6,R6,#(nparams+1) 同时弹掉参数返回值;由于参数在 R5+4 及以上连续排列,只有把返回值放在它们正下方的 R5+3,这一条指令才能一次清掉两者(538-mt1-review 的原话是 “Pop parameters and return value”)。那些”连接信息”(旧帧指针 R5+1、返回地址 R5+2)由被调用者的 epilogue 自己弹掉,调用者不必关心。如果改成从左向右压栈,最后一个参数会落在 R5+4、第一个参数跑到更远处;对固定参数个数的函数两者都可行,但变参函数(如 printf)就无法知道第一个参数在哪里,所以 C 选择右到左压栈。

问题 2:某个 LC-3 子程序的 epilogue 写成下面这样,哪里有问题?会造成什么后果?

        STR  R0,R5,#3        ; 返回值 → R5+3
        LDR  R5,R5,#1        ; 先恢复调用者的帧指针
        LDR  R7,R5,#2        ; 再想用 R5+2 取回返回地址
        ADD  R6,R6,#3
        RET

答案LDR R5,R5,#1LDR R7,R5,#2 的顺序反了R5+1 是旧帧指针、R5+2 才是返回地址,一旦先用 LDR R5,R5,#1 恢复 R5,R5+2 就变成调用者帧里的某个字,LDR R7,R5,#2 取到垃圾值,RET 跳向随机位置(ADD R6,R6,#3 也会因为基准不清而算错栈顶)。正确顺序是先取回返回地址、再恢复帧指针、最后调 R6——即 STR R0,R5,#3 / LDR R7,R5,#2 / LDR R5,R5,#1 / ADD R6,R6,#3 / RET

问题 3:下面的 C 函数返回了局部变量的地址。请解释为什么它危险,并给出两种修法。

int32_t *make_value(int32_t v)
{
    int32_t local = v * 2;
    return &local;          /* 危险 */
}

答案local 具有 automatic storage duration,家在 make_value 的栈帧里;函数一返回帧就被拆除(LC-3 上就是 R5/R6 被恢复),那块空间随时会被下一次调用覆盖,因此返回的指针指向已经结束存储期的对象,读取它是未定义行为。两种修法:(1) 让调用者提供存储 void make_value(int32_t v, int32_t *out) { *out = v * 2; },或直接返回值 int32_t make_value(int32_t v) { return v * 2; };(2) 改用 allocated storage:int32_t *p = malloc(sizeof(int32_t)); *p = v * 2; return p;(调用者负责 free)。编译器在 -Wall 下会给出 -Wreturn-local-addr 警告——让工具替你发现存储期错误。