Lecture 8: C 函数的实现:运行时栈与栈帧 (Implementing Functions in C, Run-Time Stack)

目录 · ← l7 · l9 →

Lecture 8: C 函数的实现:运行时栈与栈帧 (Implementing Functions in C, Run-Time Stack)

概述

本讲回答”编译器究竟怎样把函数调用变成机器码”这一问题,核心是运行时栈 (run-time stack) 与栈帧 (stack frame) 这两个抽象;我们引入活动记录 (activation record)、LC-3 调用约定(R0–R3 caller-saved、R4 全局数据指针、R5 帧指针、R6 栈指针、R7 返回地址)、 实参自右向左压栈的理由、完整的调用与返回序列,并手工把一段 C 代码翻译成 LC-3 汇编。 这一讲是 ECE 220 的枢纽:上一讲的”按值传递”在这里得到机器级解释(副本就在栈上), 而后面要学的数组、指针、递归、动态内存分配,全部建立在”谁能看见谁的栈帧、谁能改谁的副本”这一套约定之上。

核心概念与底层机制图解

  • 活动记录 / 栈帧 (Activation Record / Stack Frame):一次函数调用在栈上占用的那一整块内存,装着这次调用所需的全部”私人数据”。
    • 直观解释:像去餐厅吃饭时服务员给你划出的那块桌面:你的餐盘、账单、别人给你的便条都放这里;吃完走人,桌面立刻被下一位客人使用。
    • 底层机制图解:栈在 LC-3 中向低地址增长;压栈 (push) 是先把 R6 减 1,再 STR;弹栈 (pop) 是 ADD R6,R6,#1。 每个函数调用都会在栈顶长出一个新帧,返回时整个帧被”弹掉”——所谓弹掉,只是把 R6 抬回去,内存里的旧位还在,但逻辑上已经无效
      栈(自高地址向低地址生长,R6 指向栈顶)
      +--------------------------------+
      \|  调用者的栈帧                  \|   高地址
      +--------------------------------+
      \|  被调用者的栈帧(本次调用)    \|   ← R6 与 R5 都在这一块里
      +--------------------------------+
      \|  ...(更深的调用继续向下)     \|   低地址
      
    • 作用域与存储期:帧内所有 automatic 变量的生命周期 = 这次调用的生命周期;return(或 RET)之后它们立即失效,内存会被下一次调用复用。
  • 调用约定 (Calling Convention):寄存器分工与栈帧格式的固定约定,让不同的人(甚至不同的编译器)写出的代码能互相调用。
    • 直观解释:像机场的行李转运规则:哪件行李放哪个传送带、标签怎么写、谁负责搬——所有航站楼都照同一份规则办,行李才不会丢。
    • 底层机制图解:ECE 220 使用的 LC-3 约定:

      寄存器角色谁负责保存
      R0–R3传递参数与返回值用的通用寄存器caller-saved(调用者保存)
      R4全局数据指针 (global data pointer),指向全局数据区全程序公用
      R5帧指针 (frame pointer),指向当前帧的局部变量区被调用者保存进自己的帧
      R6栈指针 (stack pointer),指向栈顶由调用/返回序列维护
      R7返回地址 (return address),JSR 写入、RET 使用被调用者保存进自己的帧

      为什么要”约定”而不是”随便”:① 编译器本身是个程序,只能按固定规则生成代码;② 不同编译器(或手写汇编)产生的子程序必须能互相调用,所以对调用接口的选择必须一致。编译器可以在这个约定之内自由优化(见后文),因为帧的内部布局不是接口的一部分。

    • 作用域与存储期:约定决定了”跨函数可见”的东西——只有栈上的实参、返回值槽与 R0 的返回值能跨越函数边界;帧内部的局部变量对外完全不可见。
  • 栈帧布局 (Stack Frame Layout):帧被分成”linkage(链接信息)+ 局部变量 + 参数”三部分。
    • 直观解释:像一份三明治:最下面(地址最低)是别人递进来的原料(参数),中间是保存回去的路(linkage),最上面是你自己加工出来的东西(局部变量)。
    • 底层机制图解R5 指向局部变量区的,也就是该帧的第一个局部变量(R5+0);其余局部变量依次落在 R5-1R5-2…,R6 是栈顶:
      高地址  +---------------------------+  ← 调用者的栈帧
              \|  caller's stack frame     \|
              +---------------------------+
              \|  参数(实参的副本)       \|  R5+4, R5+5, R5+6, ...   ← 第一个实参在 R5+4
              +---------------------------+
              \|  返回值槽                 \|  R5+3
              +---------------------------+
              \|  返回地址 (R7)            \|  R5+2
              +---------------------------+
              \|  上一个帧指针 (调用者的 R5)\|  R5+1
              +---------------------------+
              \|  局部变量                 \|  R5+0, R5-1, R5-2, ...  ← R5 指向第一个局部变量(R5+0)
      低地址  +---------------------------+  ← R6(执行中会因压栈而暂时更低)
              R5+1/R5+2/R5+3 这三格就是 linkage:把它们连起来,就能从任意深度的帧一路"走"回 `main`。
      

      偏移是编译器算好的常量numR5+4abs_valueR5+0,于是 C 语句被翻译成 LDR R0,R5,#4STR R0,R5,#0 这样的定长指令。 这个顺序就是课程的真实约定(已对照 lc3code/translate.asmFIND_ABSMAIN、以及 538-mt1-review 讲义核实):R5+0(及 R5-1R5-2…)= 局部变量,R5+1 = 旧帧指针,R5+2 = 返回地址,R5+3 = 返回值R5+4 起 = 参数为什么返回值必须在 R5+3(紧贴参数区之下):被调用者返回后,调用者要用一条 ADD R6,R6,#(nparams+1) 同时弹出参数返回值(538-mt1-review 的原话是 “Pop parameters and return value (destroy the params)”);只有返回值紧邻第一个参数之下,这一条指令才成立。 没有局部变量的 MAIN 也满足同一组偏移:ADD R6,R6,#-3 / STR R5,R6,#0(即 R5+1)/ ADD R5,R6,#-1(使 R5+1 = R6+0),于是 R5+1 旧帧指针、R5+2 返回地址、R5+3 返回值、R5+4 第一个参数,与 FIND_ABS 完全一致。

    • 作用域与存储期:参数与局部变量都是 automatic;linkage 的作用域是”这次调用的整个生命周期”,它在 RET 之前被用掉,随后随帧一起失效。
  • 为什么实参自右向左压栈 (right-to-left):调用 f (A, B, C) 时,编译器先压 C、再压 B、最后压 A,于是 A 落在地址最低处,也就是 R5+4
    • 直观解释:想象给收银员一叠订单:最上面那张必须永远是”总单”(第一个参数),后面的明细按顺序往下排。这样收银员不必知道一共几张,就能先看总单再往下翻。
    • 底层机制图解:决定性理由是 C 允许可变参数函数 (variable-argument function)printf 就是典型:
      printf ("%d and %f\n", i, x);   /* 参数个数由格式串决定,编译器不告诉 printf */
      

      printf 必须能”先拿到第一个参数(格式串),再按格式串里的说明符个数去取后面的参数”。若第一个参数总在固定的 R5+4,它就能顺序读到 R5+5R5+6……;若顺序反过来(第一个参数在最高的地址),printf 连”一共几个参数”都不知道,就无法定位起点:

      压栈顺序:C(最高地址)→ B → A(最低地址 = R5+4)
      R5+6: C   R5+5: B   R5+4: A   ← 第一个实参永远是 R5+4,与参数个数无关
      
    • 作用域与存储期:参数副本属于被调用者的帧;被调用者可以随意修改它们,返回时调用者把这些字全部弹掉(对可变参数函数也一样,只是弹出长度由调用者决定)。
  • 调用序列 (Call Sequence):调用者做的四件事 + 被调用者做的三件事。
    • 直观解释:像寄快递:先装箱(压参数)→ 下单(JSR)→ 取回执(读返回值)→ 清理包装(弹栈);收件方则负责开箱、干活、贴回执。
    • 底层机制图解
      调用者:                                    被调用者:
      ① 求值实参,自右向左压栈                    ① 为 linkage + 局部变量腾出空间(ADD R6,R6,#-N)
      ② JSR(R7 ← 下一条指令地址,PC ← 函数入口)  ② 保存调用者的 R5:STR R5,R6,#k
      ③ 从栈顶读返回值:LDR R0,R6,#0              ③ R5 ← 局部变量底部:ADD R5,R6,#(n-1)
      ④ 弹出返回值与所有实参:ADD R6,R6,#(n+1)    ④ 保存返回地址:STR R7,R5,#2
                                                 ⑤ 执行语句;返回值写入 R5+3
                                                 ⑥ 拆帧:LDR R7,R5,#2 / LDR R5,R5,#1 / ADD R6,R6,#(n+2)
                                                 ⑦ RET(PC ← R7)
      

      具体指令:LDR R0,R5,#0(求值)→ ADD R6,R6,#-1 + STR R0,R6,#0(压栈:必须先移动 R6 再写)→ JSR MY_FUNCLDR R0,R6,#0(读栈顶返回值)→ ADD R6,R6,#2(弹掉返回值与实参)。

    • 作用域与存储期:调用序列决定了一次调用的”内存波纹”:先长高(压参数)、再长出一个完整的帧、最后全部收回,栈指针必须回到平衡位置——不平衡就会慢慢耗尽栈空间。
  • 返回值与返回值槽 (Return Value Slot):返回值放在帧里的固定位置 R5+3,返回后它就是栈顶。
    • 直观解释:像交作业时把作业本放在桌子最上面——老师(调用者)一伸手就能拿到,不需要问”你放哪了”。
    • 底层机制图解return <expr>; 的四步:
              LDR     R0,R5,#0        ; ① 求值   ② STR R0,R5,#3:写进返回值槽
              LDR     R7,R5,#2        ; ③ 恢复 R7 与 R5(此时 R6 仍指向帧内)
              LDR     R5,R5,#1
              ADD     R6,R6,#3        ; ④ 弹出局部变量与 linkage,只留下返回值
              RET
      

      被调用者弹出 n+2 个字(n 个局部变量 + linkage 中除返回值外的两格)之后,R6 正好指向返回值槽,调用者一条 LDR R0,R6,#0 就读到返回值;紧接着调用者再用一条 ADD R6,R6,#(nparams+1) 把返回值与所有参数一起弹掉。返回值必须落在参数区正下方(R5+3),这两条指令才成立——这正是”linkage 三格顺序不能随便换”的根本原因。

    • 作用域与存储期:返回值是调用者帧里的一段临时空间,由调用者负责弹出;被调用者的局部变量在 RET 之后已全部失效。
  • 谁保存什么:caller-saved 与 callee-savedR0–R3 是 caller-saved,R5/R6/R7 的状态由栈帧机制维护。
    • 直观解释:会议室里的白板(R0–R3)谁用谁擦;会议室本身的结构(R5/R6/R7)由每次开会的人负责恢复原样。
    • 底层机制图解:LC-3 上的实际做法是:
      • R0–R3调用者保存——调用者若在调用之后还需要这些寄存器里的值,必须自己在调用前压栈保存(编译器在寄存器不够用时才这么做);
      • R5R7被调用者保存——但保存位置不是别的寄存器,而是自己栈帧里的两个槽R5+1R5+2),这样天然支持嵌套调用;
      • R6:由”压栈/弹栈”的算术共同维护,返回时必须回到调用者期望的位置;
      • R4:全局数据指针,通常整个程序共用,不随调用改变。
        ; 如果调用者需要保留 R1,就自己压栈保护(caller-saved 的含义)
              ADD     R6,R6,#-1
              STR     R1,R6,#0
              JSR     SOME_FUNC
              LDR     R1,R6,#0        ; 读完返回值后恢复 R1
              ADD     R6,R6,#1
        
    • 作用域与存储期R0–R3 的内容只保证”到下一次调用之前”有效;跨调用存活的数据必须放进栈帧(automatic)或全局数据区(static)。
  • 编译器可以优化(所以编译出的帧与你的心理模型可能不同):栈帧的内部结构不是接口,编译器有充分的自由。
    • 直观解释:约定只规定了”行李怎么交接”,没规定”你在自己房间里怎么摆”;有人把衣服挂起来(寄存器),有人干脆不用箱子(省掉帧)。
    • 底层机制图解:常见的优化包括:
      • 把变量放进寄存器:局部变量只在函数内使用,不必真的分配栈槽;不保存 R7:函数体内不再调用别的子程序时可以省掉 STR R7,R5,#2
      • 完全不建立栈帧:叶子函数 (leaf function) 可能直接算完就返回;省略帧指针(x86-64 在 -O2 下把 RBP 当普通寄存器,导致调试器难以还原调用栈)。 实测证据:同一份 l08_add3.c-O0 下被调用者的参数副本位于调用者局部变量的更低地址-O2&a < &b < &c&total 的相对位置整体反转—— 同一份 C 代码,帧内布局可以完全不同;不变的是接口层面(实参顺序、返回值位置、寄存器角色),否则不同模块无法链接在一起。
    • 作用域与存储期:优化改变的是”变量住在哪”(寄存器、栈槽、甚至被消除),但 C 语言层面automatic 变量的可见性与生命周期不变——语言语义不因优化而改变。
  • 返回局部变量地址的危险 (Dangling Pointer):函数返回后,它的栈帧已经失效,任何指向帧内的指针都变成悬空指针 (dangling pointer)。
    • 直观解释:像把”我家冰箱第二层”这个地址告诉别人,然后你就搬走了;下一位住户往那一层放什么,你完全无法预料。
    • 底层机制图解
      int32_t *bad (void)
      {
          int32_t local = 1234;
          return &local;          /* 帧一拆,local 的槽就"归下一位调用者所有" */
      }
      

      返回时 ADD R6,R6,#n 只是抬高了栈指针,内存里的 1234 还在——所以紧接着读可能”看起来正确”,但任何一次新的调用(甚至 printf 内部的调用)都会复用这块内存,把 1234 覆盖成别的位。 这正是”未定义行为 (undefined behavior, UB)”的典型:结果依编译器与调用时序而定

    • 作用域与存储期:这就是”作用域 ≠ 存储期”的报应:变量的名字只在该函数里可见,而它的存储也在函数返回时结束了;指针却把两个边界都带了出去。

代码示例与底层机制分析

代码 (C) — 小函数与其调用者,用地址揭示帧的边界

/*
 * ECE220 Lecture 8 demo -- a small function and its caller, with the
 * addresses of every variable printed so that the frame layout is visible.
 * Build: gcc -g -std=c99 -Wall -Werror l08_add3.c -o l08_add3
 */
#include <stdint.h>
#include <stdio.h>

/*
 * Function: add3
 * Description: adds three integers
 * Parameters: a, b, c -- the values to add
 * Return Value: a + b + c
 */
static int32_t
add3 (int32_t a, int32_t b, int32_t c)
{
    int32_t total;      /* the only local variable of add3 */

    printf ("  add3 frame: &a=%p &b=%p &c=%p\n",
            (void *)&a, (void *)&b, (void *)&c);
    printf ("  add3 frame: &total=%p\n", (void *)&total);
    printf ("  add3 frame: (intptr_t)&b - (intptr_t)&c = %ld\n",
            (long)((intptr_t)&b - (intptr_t)&c));
    printf ("  add3 frame: (intptr_t)&a - (intptr_t)&b = %ld\n",
            (long)((intptr_t)&a - (intptr_t)&b));

    total = a + b + c;

    return total;
}

int
main ()
{
    int32_t x = 7;
    int32_t y = -2;
    int32_t z = 10;
    int32_t answer;

    printf ("main frame: &x=%p &y=%p &z=%p &answer=%p\n",
            (void *)&x, (void *)&y, (void *)&z, (void *)&answer);

    answer = add3 (x, y, z);

    printf ("add3(7, -2, 10) = %d\n", (int)answer);

    /* Call by value: the caller's variables are untouched. */
    printf ("main still holds x=%d y=%d z=%d\n", (int)x, (int)y, (int)z);

    return 0;
}

实际编译运行结果./l08_add3,gcc 12.2.0 -O0;地址每次运行都变):

main frame: &x=0x7ffc6edef64c &y=0x7ffc6edef648 &z=0x7ffc6edef644 &answer=0x7ffc6edef640
  add3 frame: &a=0x7ffc6edef61c &b=0x7ffc6edef618 &c=0x7ffc6edef614
  add3 frame: &total=0x7ffc6edef62c
  add3 frame: (intptr_t)&b - (intptr_t)&c = 4
  add3 frame: (intptr_t)&a - (intptr_t)&b = 4
add3(7, -2, 10) = 15
main still holds x=7 y=-2 z=10

【代码做什么?】 main 声明四个 int32_t 并打印它们的地址;调用 add3 (x, y, z)add3 打印自己三个参数与局部变量 total 的地址, 并打印参数之间的地址差;计算 a + b + c 返回后,main 打印结果,并确认 xyz 没有被改动。

【底层机制透视】

  • 地址差 4 说明参数是各自独立的槽&b - &c = 4&a - &b = 4,正好是一个 int32_t 的宽度,说明 abca(最低)、bc(最高)的顺序连续摆放——与 LC-3 上”自右向左压栈、第一个实参地址最低”完全一致
  • 参数在被调用者的帧里&a = 0x...61cmain&x = 0x...64c 低 48 字节,属于新长出来的那一段栈;这就是”按值传递”的物理形态——副本在栈上
  • main 的值不变add3 改不改自己的 a 都无所谓,x 所在的槽根本没被写过。所以函数若想改变调用者的数据,只能接收一个指向它的地址。
  • 优化会改变布局:同一份代码用 gcc -O2 编译后,&a < &b < &c&total 的相对位置整体反转(实测),提醒我们帧的内部布局不是接口

【内存布局图解】

x86-64,-O0 实测(地址为一次真实运行的输出):
高地址  +----------------------------+ 0x7ffc6edef64c   ← main 的栈帧(地址更高)
        |  int32_t x = 7             |
        +----------------------------+ 0x7ffc6edef648
        |  int32_t y = -2            |
        +----------------------------+ 0x7ffc6edef644
        |  int32_t z = 10            |
        +----------------------------+ 0x7ffc6edef640
        |  int32_t answer            |
        +----------------------------+ 0x7ffc6edef62c
        |  add3 的局部变量 total     |   ← add3 的栈帧:整体在 main 之下
        +----------------------------+ 0x7ffc6edef61c
        |  参数副本 a = 7            |   ← 最低地址 = 第一个实参
        +----------------------------+ 0x7ffc6edef618
        |  参数副本 b = -2           |
        +----------------------------+ 0x7ffc6edef614
        |  参数副本 c = 10           |   ← 最高地址 = 最后一个实参
低地址  +----------------------------+
main 的 x 在 0x...64c,add3 的 a 在 0x...61c,相差 48 字节:两个完全不同的世界。

【与汇编的对应】(LC-3:调用 add3 (x, y, z) 的压栈顺序)

; 自右向左压栈:先 z(c),再 y(b),最后 x(a)
        LDR     R0,R5,#-2       ; 第三个实参 z
        ADD     R6,R6,#-1
        STR     R0,R6,#0        ; z 落在最高地址
        LDR     R0,R5,#-1       ; 第二个实参 y
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        LDR     R0,R5,#0        ; 第一个实参 x(最后压 → 地址最低)
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        JSR     ADD3
        LDR     R0,R6,#0        ; 返回值位于栈顶
        ADD     R6,R6,#4        ; 弹掉返回值 + 3 个实参
; 进入 ADD3 后:R6 指向 a;被调用者建好帧之后
;   a = R5+4、b = R5+5、c = R5+6(第一个参数永远在 R5+4)

代码 (C) 与手工翻译的 LC-3 汇编 — 完整的 main + add3

待翻译的 C 代码

int32_t add3 (int32_t a, int32_t b, int32_t c)
{
    int32_t total;              /* one local variable */
    total = a + b + c;
    return total;
}

int main (void)
{
    int32_t x = 7, y = -2, z = 10;
    int32_t answer;
    answer = add3 (x, y, z);
    return answer;
}

手工翻译的 LC-3 汇编(与课程示例 translate.asm 风格一致;用一个小型 LC-3 解释器实际执行过):

        .ORIG   x3000
        LEA     R4,GLOBAL_DATA
        LD      R6,STACK_TOP    ; R6 <- xFE00,栈基址
        JSR     MAIN            ; 调用 main(R7 <- 下一条指令)
        LDR     R0,R6,#0        ; 读 main 的返回值
        ADD     R6,R6,#1        ; 弹掉返回值
        HALT

;-------------------------------------------------------------------
; int main (void)
;   locals: x at R5+0, y at R5-1, z at R5-2, answer at R5-3
;-------------------------------------------------------------------
MAIN
        ADD     R6,R6,#-7       ; 4 个局部变量 + 3 个 linkage 字
        STR     R5,R6,#4        ; 保存调用者的帧指针(R6+4 = R5+1)
        ADD     R5,R6,#3        ; R5 -> 局部变量底部(x)
        STR     R7,R5,#2        ; 保存返回地址
        AND     R0,R0,#0        ; x = 7
        ADD     R0,R0,#7
        STR     R0,R5,#0
        AND     R0,R0,#0        ; y = -2
        ADD     R0,R0,#-2
        STR     R0,R5,#-1
        AND     R0,R0,#0        ; z = 10
        ADD     R0,R0,#10
        STR     R0,R5,#-2
        LDR     R0,R5,#-2       ; 自右向左压入实参:先 z
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        LDR     R0,R5,#-1       ; 再 y
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        LDR     R0,R5,#0        ; 最后 x(第一个实参,地址最低)
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        JSR     ADD3            ; 调用
        LDR     R0,R6,#0        ; 读返回值
        ADD     R6,R6,#4        ; 弹出返回值与 3 个实参
        STR     R0,R5,#-3       ; answer = add3 (x, y, z)
        LDR     R0,R5,#-3       ; return answer
        STR     R0,R5,#3        ; 写返回值槽
        LDR     R7,R5,#2        ; 拆栈帧
        LDR     R5,R5,#1
        ADD     R6,R6,#6        ; 弹出局部变量与 linkage(留下返回值)
        RET

;-------------------------------------------------------------------
; int32_t add3 (int32_t a, int32_t b, int32_t c)
;   a at R5+4, b at R5+5, c at R5+6;  local total at R5+0
;-------------------------------------------------------------------
ADD3
        ADD     R6,R6,#-4       ; 1 个局部变量 + 3 个 linkage 字
        STR     R5,R6,#1        ; 保存调用者的帧指针
        ADD     R5,R6,#0        ; R5 -> 局部变量(total)
        STR     R7,R5,#2        ; 保存返回地址
        LDR     R0,R5,#4        ; R0 <- a
        LDR     R1,R5,#5        ; R1 <- b
        ADD     R0,R0,R1        ; a + b
        LDR     R1,R5,#6        ; R1 <- c
        ADD     R0,R0,R1        ; a + b + c
        STR     R0,R5,#0        ; total = a + b + c
        LDR     R0,R5,#0        ; return total
        STR     R0,R5,#3        ; 写返回值槽
        LDR     R7,R5,#2        ; 拆栈帧:恢复返回地址
        LDR     R5,R5,#1        ; 恢复调用者的帧指针
        ADD     R6,R6,#3        ; 弹出局部变量与 linkage(留下返回值)
        RET

STACK_TOP       .FILL   xFE00
GLOBAL_DATA
        .END

实际执行结果(用一个小型 LC-3 解释器运行上面的汇编;栈基址 xFE00):

instructions executed: 54
R0 (return value of main) = 15
R6 after returning to the caller = xFE00      ← 栈指针回到初始位置:完全平衡

执行结束时留在栈上的字(地址: 十进制值)

xFDF2:   15        ← add3 的局部变量 total(R5_add3+0)
xFDF3:  65020      ← add3 保存的调用者帧指针 = xFDFC = main 的 R5(R5+1)
xFDF4:  12317      ← add3 的返回地址 = x301D(JSR ADD3 的下一条指令)(R5+2)
xFDF5:   15        ← add3 的返回值槽(R5+3)——RET 后它就是栈顶
xFDF6:    7        ← 实参 a = x(第一个实参,地址最低)(R5+4)
xFDF7:  65534      ← 实参 b = y = -2(用 16 位 2 的补码表示)
xFDF8:   10        ← 实参 c = z(最后一个实参,地址最高)(R5+6)
xFDF9:   15        ← main 的局部变量 answer(R5_main-3)
xFDFA:   10        ← main 的局部变量 z(R5_main-2)
xFDFB:  65534      ← main 的局部变量 y = -2(R5_main-1)
xFDFC:    7        ← main 的局部变量 x(R5_main+0)= xFDFC 正是 main 的 R5
xFDFD:    0        ← main 保存的"上一个帧指针"(最外层没有调用者,置 0)(R5+1)
xFDFE:  12291      ← main 的返回地址 = x3003(JSR MAIN 的下一条指令)(R5+2)
xFDFF:   15        ← main 的返回值槽(R5+3);最外层用 LDR R0,R6,#0 读它

【代码做什么?】 最外层先设置 R4(全局数据指针)与 R6 = xFE00(栈基址),JSR MAIN 进入 C 的 mainMAIN 建立 7 个字的帧(4 个局部变量 + 3 个 linkage),给 xyz 赋值,自右向左压入三个实参,JSR ADD3ADD3 建立 4 个字的帧(1 个局部变量 + 3 个 linkage),从 R5+4R5+5R5+6 取三个参数相加,写入局部变量 total, 把结果写进返回值槽(R5+3),拆帧后 RETMAIN 从栈顶取回 15 存进 answer,最后按同样的方式返回给自己的调用者。

【底层机制透视】

  • 参数偏移固定ADD3aR5+4bR5+5cR5+6,始终不变——因为 ADD3 只有 1 个局部变量,R5 = R6 + 0,被调用时的 R6(指向 a)正好是 R5+4。 n 个局部变量时建帧要写 ADD R6,R6,#-(n+3)STR R5,R6,#nADD R5,R6,#(n-1)增量随局部变量个数变化,但 linkage 相对 R5 的偏移(+1/+2/+3)永远不变,参数一律从 R5+4 起。
  • linkage 是”回家的路”R5+1 存调用者的 R5R5+2 存返回地址 R7;递归或深层调用时每个帧都存着自己那一层的这两个值,于是可以逐层 LDR R5,R5,#1 走回去(调试器打印调用栈 backtrace 的原理)。
  • 栈指针必须平衡main 的帧 7 个字 + add3 的帧 4 个字 + 3 个实参,最终 R6 回到 xFE00,说明”压多少、弹多少”完全对上;不平衡是隐蔽 bug 的常见来源
  • 实测的地址关系xFDF6 < xFDF7 < xFDF8 对应 a < b < c,证明实参自右向左压栈;xFDF3 = xFDFC 说明 add3 保存的正是 main 的帧指针。
  • 与 C 语言级的对应total = a + b + c; 被翻译成 6 条指令(2 条 LDR + 2 条 ADD + 1 条 LDR + 1 条 STR), 完全没有”优化”;真实编译器会做出与这里不同的选择(见”编译器可以优化”一节)。

【内存布局图解】

执行到 ADD3 内部时(R5_main = xFDFC,R5_add3 = xFDF2),栈的实际内容:
高地址  +-------------------------------+ xFE00  ← 栈基址(初始 R6)
        +-------------------------------+ xFDFF   (以下每格 = 1 个 LC-3 字)
        |  main 的返回值槽         = 15 | R5_main+3
        +-------------------------------+ xFDFE
        |  main 的返回地址     = x3003  | R5_main+2   ┐
        +-------------------------------+ xFDFD        │ main 的 linkage
        |  main 保存的上个帧指针   = 0  | R5_main+1   ┘  ← xFDFC = main 的 R5
        |  main 的局部变量 x        = 7 | R5_main+0  ← R5_main
        +-------------------------------+ xFDFB
        |  main 的局部变量 y      = -2  | R5_main-1
        |  main 的局部变量 z       = 10 | R5_main-2
        |  main 的局部变量 answer  = 15 | R5_main-3
        +-------------------------------+ xFDF8
        |  实参 c = z              = 10 | R5_add3+6   ┐
        +-------------------------------+ xFDF7        │ 参数区:第一个实参
        |  实参 b = y             = -2  | R5_add3+5   │ 地址最低(R5+4)
        +-------------------------------+ xFDF6        ┘
        |  实参 a = x              = 7  | R5_add3+4
        +-------------------------------+ xFDF5
        |  add3 的返回值槽         = 15 | R5_add3+3
        +-------------------------------+ xFDF4
        |  add3 的返回地址     = x301D  | R5_add3+2   ┐
        +-------------------------------+ xFDF3        │ add3 的 linkage
        |  add3 保存的帧指针 = xFDFC    | R5_add3+1   ┘
        +-------------------------------+ xFDF2
        |  add3 的局部变量 total   = 15 | R5_add3+0  ← R5_add3(也是 R6)
低地址  +-------------------------------+

【与汇编的对应】 上面整段汇编就是本例的”C → 机器码”翻译,三条关键规律可以单独记住:

; ① 访问局部变量:R5 + 编译期算好的偏移
        LDR     R0,R5,#0        ; total(局部变量在 R5+0 及以下)
        STR     R0,R5,#0
; ② 访问参数:R5 + (3 + 参数序号)
        LDR     R0,R5,#4        ; 第一个参数 a
        LDR     R1,R5,#6        ; 第三个参数 c
; ③ 返回值:永远写 R5+3,返回前把 R6 抬到 R5+3
        STR     R0,R5,#3        ; 返回值槽
        LDR     R7,R5,#2        ; 拆 linkage:R7 ← 返回地址
        LDR     R5,R5,#1        ;             R5 ← 调用者的帧指针
        ADD     R6,R6,#3        ;             R6 指向返回值槽
        RET

代码 (C) — 危险示例:返回/保存指向局部变量的地址(仅供演示,请勿模仿)

/*
 * DANGER: demonstration only, do not imitate.
 *
 * gcc 12 在 ECE 220 的警告级别下就能抓住这两种写法:
 *   -Werror=return-local-addr       (直接返回局部变量地址)
 *   -Werror=dangling-pointer=       (把局部变量地址存进全局指针)
 * 所以下面这两份程序必须去掉 -Werror 才能编译、运行:
 *   gcc -g -std=c99 -Wall l08_dangling2.c -o l08_dangling2
 */
#include <stdint.h>
#include <stdio.h>

static int32_t *dangling;          /* points into a frame that no longer exists */
static volatile int32_t sink;      /* keeps the "reuser" from being optimized away */

static void
keep_a_local (void)
{
    int32_t local = 1234;

    dangling = &local;             /* the slot dies when keep_a_local returns */
}

static void
reuse_the_stack (void)
{
    volatile int32_t junk[8];
    int32_t i;

    for (i = 0; 8 > i; i++) {
        junk[i] = 0x5A5A;
        sink = junk[i];
    }
}

int
main ()
{
    keep_a_local ();
    printf ("right after the call:  *dangling = %d\n", (int)*dangling);

    reuse_the_stack ();
    printf ("after the second call: *dangling = %d\n", (int)*dangling);

    return 0;
}

实际编译与运行结果

$ gcc -g -std=c99 -Wall l08_dangling2.c -o l08_dangling2
l08_dangling2.c: In function ‘keep_a_local’:
l08_dangling2.c:21:14: warning: storing the address of local variable ‘local’ in ‘dangling’ [-Wdangling-pointer=]
$ ./l08_dangling2
right after the call:  *dangling = 1234
after the second call: *dangling = 8        (未定义行为:结果依编译器/时序而定)

$ gcc -g -std=c99 -Wall -Werror l08_dangling2.c -o l08_dangling2
cc1: all warnings being treated as errors        ← ECE 220 的编译选项会直接拦下它

$ gcc -g -std=c99 -Wall -Werror dangling_min.c -o dangling_min     # 直接 return &local
dangling_min.c:3:56: error: function returns address of local variable [-Werror=return-local-addr]

【代码做什么?】 keep_a_local 把一个局部变量的地址存进全局指针 dangling 后返回;main 立刻读该地址,得到 1234; 接着调用 reuse_the_stack(它在自己的帧里写 8 个 0x5A5A),再读同一个地址,值已经被覆盖成 8。整个过程没有任何编译错误(只去掉 -Werror 时)。

【底层机制透视】

  • “帧被弹掉”不等于”内存被清空”RET 只把 R6 抬高,1234 仍然躺在原来的地址上,所以第一次读”看起来是对的”——这是陷阱中最危险的部分:程序可能在测试时一直正常,直到某次调用把它踩坏
  • 下一位调用者会复用这段内存reuse_the_stackjunk[8] 正好覆盖了 local 的槽,于是读出来变成 8(0x5A5A 循环计数器的残留)。缓存、寄存器分配、优化等级都会改变实际结果。
  • 编译器能抓住它-Wreturn-local-addr-Wdangling-pointer= 都在 -Wall 里,配合 ECE 220 的 -Werror直接变成编译错误——这是把 UB 挡在门外的第一道防线;用 -fsanitize=address 还能在运行时精确报出”访问已释放栈内存”。
  • 正确的替代做法:要么让调用者传入存放结果的地址(void f (int32_t *out)),要么把结果作为返回值交回(return local; 返回的是值的拷贝),要么在需要长期存活时用动态分配(后面的讲次)——三者都不会把”已失效的帧”带出去。

【内存布局图解】

keep_a_local 返回前后,同一段栈内存的命运:
高地址  +---------------------------+
        |  main / 其它帧            |
        +---------------------------+ ← keep_a_local 调用期间的帧
        |  int32_t local = 1234     | ← dangling 指向这里(0x...6a0 之类)
        +---------------------------+ ← keep_a_local 返回后 R6 抬高,这段内存"逻辑上无效"
        |                           |   但 1234 的位还在
        +---------------------------+
        |                           | ← reuse_the_stack 的 junk[8] 恰好覆盖同一段
        |  0x5A5A 0x5A5A ...        |   (写 8 个 int32_t,其中最后一个写进原 local 的槽)
低地址  +---------------------------+
读 *dangling:第一次 1234(残留),第二次 8(被复用后的位)——两次都能"读到数",却都不是被承诺的语义。

【与汇编的对应】(LC-3:keep_a_local 返回时做了什么,以及为什么地址会失效)

KEEP_A_LOCAL
        ADD     R6,R6,#-4       ; 1 个局部变量(local)+ 3 个 linkage 字
        STR     R5,R6,#1
        ADD     R5,R6,#0        ; R5 -> local
        STR     R7,R5,#2
        AND     R0,R0,#0        ; local = 1234
        LD      R1,C1234        ; (1234 超过 ADD 的 imm5 范围,从字面量池取)
        ADD     R0,R0,R1
        STR     R0,R5,#0
        STR     R5,R4,#2        ; dangling = &local(全局数据区 R4+2)
        LDR     R7,R5,#2        ; 拆帧:R7、R5 恢复,R6 抬高
        LDR     R5,R5,#1
        ADD     R6,R6,#3
        RET
        ; 注意:R6 抬高之后,[旧 R5+0] 这个地址仍然写着 1234,
        ; 但它已经不在"活的栈"里了;下一个被调用者会用 ADD R6,R6,#-n 把同样的地址划进自己的帧。
C1234   .FILL   #1234

常见错误与调试技巧

  • 忘记保存/恢复 R7(手写汇编时):函数里再调用别的子程序后,RET 就跳不回来了。调试gdbinfo registers r7(LC-3 用 lc3simprint R7)看返回值是否被覆盖;在函数入口/出口分别打印 R7;规则是”只要函数体内有 JSR/JSRR/陷阱指令,建帧时必须 STR R7,R5,#2“。
  • 栈指针不平衡:压了 3 个实参却只 ADD R6,R6,#2,栈会逐次下沉,最终撞上堆或越界。调试:在函数入口与 RET 前各打印一次 R6(或用 gdbp $rsp),两者必须相等;x86-64 上还可以用 -fsanitize=address 直接报出栈越界。 与之相关,以为”编译出的帧长什么样”是固定的**
  • 返回或保存局部变量地址(悬空指针):函数返回后指针仍被使用,读到的值随调用时序变化。调试gcc -g -std=c99 -Wall -Werror 会在编译期报 -Wreturn-local-addr / -Wdangling-pointer=;运行时用 gcc -fsanitize=address -gvalgrind --track-origins=yes ./prog 精确指出”使用了已失效的栈内存”;gdbbt 看当前帧是否还有效。
  • 局部变量未初始化,误以为”新帧里是 0”:帧只是把 R6 挪了位置,里面的位是上一次调用留下的垃圾。调试gcc -O2 -Wmaybe-uninitializedvalgrindConditional jump or move depends on uninitialised value(s);养成声明即初始化的习惯。
  • 传结构与数组时想当然”传的是本体”:数组会退化为指针(传地址),结构体按值传递则整个拷进参数区,帧会突然变大。调试:打印 sizeof&param;用 gcc -S 观察建帧语句(ADD R6,R6,#-N 里的 N 会明显增大)。
  • printf 里混用错误的类型/个数导致读到”栈上的位”:可变参数函数靠格式串决定读几个参数,写错就会读到别的槽。调试gcc -std=c99 -Wall -Werror-Wformat 会检查参数与说明符;gdb 在该 printf 前中断后用 x/8xw $rsp(LC-3 用 x/8xw R6)查看即将被读走的那些字。

关键要点

  • 一次调用 = 一个栈帧:帧里依次是参数副本、返回值槽、返回地址、上一个帧指针、局部变量;R6 指向栈顶,R5 指向局部变量底部,R5+4 起是第一个参数。
  • 实参自右向左压栈,因此第一个实参永远位于 R5+4;这是 printf 这类可变参数函数能”先拿到格式串、再依次取参”的前提,也是所有调用者/被调用者能对上的唯一方式。
  • 调用与返回是两个对称的序列:压参 → JSR → 读返回值 → 一条 ADD R6,R6,#(nparams+1) 弹栈;建帧(保存 R5/R7、分配局部变量)→ 执行 → 写返回值槽(R5+3)→ 拆帧 → RET栈指针必须平衡。
  • 接口固定、内部自由:寄存器分工与参数顺序是跨模块的契约,而”变量放寄存器还是栈槽、要不要帧指针”完全由编译器决定——编译出来的帧可能与你心里的模型很不一样。 同样地,帧一失效,指向它的指针就是悬空指针**:return 只是抬高栈指针,旧内存会被下一位调用者复用;-Werror-fsanitize=address 是发现这类未定义行为最有效的工具。

思考题(带答案)

  1. 一个函数有 3 个参数、2 个局部变量。它建立栈帧时 R6 要减多少?参数分别对应哪个偏移?返回到调用者之前 R6 又要加多少? 答案:帧 = 2 个局部变量 + 3 个 linkage 字 = 5 个字,所以 ADD R6,R6,#-5538-mt1-reviewFOO 正是这个例子,注释写着 “three for linkage, two for local vars”)。 接着 ADD R5,R6,#1R5 指向两个局部变量中地址较高的那个,于是 STR R5,R6,#2 恰好把调用者的帧指针存到 R5+1,再 STR R7,R5,#2 存返回地址;参数依次在 R5+4R5+5R5+6,返回值槽是 R5+3。返回前 ADD R6,R6,#4(弹出 2 个局部变量与 linkage 中的两格)使 R6 正好指向返回值槽;调用者读完返回值后再用一条 ADD R6,R6,#(3+1) 同时弹出返回值与三个参数。 三个参数依次在 R5+4R5+5R5+6。返回前 ADD R6,R6,#4(弹出 2 个局部变量 + linkage 中的两格,留下返回值槽),此时 R6 正好指向返回值。

  2. 为什么 printf 要求实参自右向左压栈?如果改成从左往右,printf 会遇到什么问题? 答案printf 的参数个数由第一个参数(格式串)决定,编译器不会告诉它一共传了几个。自右向左压栈让第一个实参固定落在 R5+4,于是 printf 可以先取格式串、再按其中的说明符个数依次读 R5+5R5+6……。若从左往右压栈,第一个实参会在最高的地址上,而 printf 既不知道总共有几个参数,也就无法算出”第一个参数在哪里”——它连起点都找不到。

  3. 下面的函数为什么危险?在实际系统上它可能”看起来正常”,为什么?给出两种正确的替代写法。

     int32_t *make (void) { int32_t local = 100; return &local; }
    

    答案local 是 automatic 变量,函数返回时它的栈帧被拆掉,返回的指针成为悬空指针。之所以”看起来正常”,是因为 RET/拆帧只是把栈指针抬高,并没有清空内存,紧接着读往往还能读到 100;但任何一次新的调用都会复用这段内存并把它覆盖,于是程序会在毫无规律的时刻给出错误结果(还可能被编译器直接优化掉,见 -Wreturn-local-addr)。 正确写法:① 让调用者传入存放结果的地址——void make (int32_t *out) { *out = 100; };② 直接返回值本身——int32_t make (void) { return 100; }(返回的是值的拷贝,与帧无关);若结果必须长期存活,则使用动态分配。