Lecture 8: C 函数的实现:运行时栈与栈帧 (Implementing Functions in C, Run-Time Stack)
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-1、R5-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`。偏移是编译器算好的常量:
num在R5+4、abs_value在R5+0,于是 C 语句被翻译成LDR R0,R5,#4、STR R0,R5,#0这样的定长指令。 这个顺序就是课程的真实约定(已对照lc3code/translate.asm的FIND_ABS与MAIN、以及538-mt1-review讲义核实):R5+0(及R5-1、R5-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+5、R5+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_FUNC→LDR 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-saved:
R0–R3是 caller-saved,R5/R6/R7的状态由栈帧机制维护。- 直观解释:会议室里的白板(
R0–R3)谁用谁擦;会议室本身的结构(R5/R6/R7)由每次开会的人负责恢复原样。 - 底层机制图解:LC-3 上的实际做法是:
R0–R3:调用者保存——调用者若在调用之后还需要这些寄存器里的值,必须自己在调用前压栈保存(编译器在寄存器不够用时才这么做);R5、R7:被调用者保存——但保存位置不是别的寄存器,而是自己栈帧里的两个槽(R5+1与R5+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 打印结果,并确认 x、y、z 没有被改动。
【底层机制透视】
- 地址差 4 说明参数是各自独立的槽:
&b - &c = 4、&a - &b = 4,正好是一个int32_t的宽度,说明a、b、c按a(最低)、b、c(最高)的顺序连续摆放——与 LC-3 上”自右向左压栈、第一个实参地址最低”完全一致。 - 参数在被调用者的帧里:
&a = 0x...61c比main的&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 的 main; MAIN 建立 7 个字的帧(4 个局部变量 + 3 个 linkage),给 x、y、z 赋值,自右向左压入三个实参,JSR ADD3; ADD3 建立 4 个字的帧(1 个局部变量 + 3 个 linkage),从 R5+4、R5+5、R5+6 取三个参数相加,写入局部变量 total, 把结果写进返回值槽(R5+3),拆帧后 RET;MAIN 从栈顶取回 15 存进 answer,最后按同样的方式返回给自己的调用者。
【底层机制透视】
- 参数偏移固定:
ADD3里a在R5+4、b在R5+5、c在R5+6,始终不变——因为ADD3只有 1 个局部变量,R5 = R6 + 0,被调用时的R6(指向a)正好是R5+4。 n 个局部变量时建帧要写ADD R6,R6,#-(n+3)、STR R5,R6,#n、ADD R5,R6,#(n-1):增量随局部变量个数变化,但 linkage 相对R5的偏移(+1/+2/+3)永远不变,参数一律从R5+4起。 - linkage 是”回家的路”:
R5+1存调用者的R5、R5+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_stack的junk[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就跳不回来了。调试:gdb里info registers r7(LC-3 用lc3sim的print R7)看返回值是否被覆盖;在函数入口/出口分别打印R7;规则是”只要函数体内有JSR/JSRR/陷阱指令,建帧时必须STR R7,R5,#2“。 - 栈指针不平衡:压了 3 个实参却只
ADD R6,R6,#2,栈会逐次下沉,最终撞上堆或越界。调试:在函数入口与RET前各打印一次R6(或用gdb的p $rsp),两者必须相等;x86-64 上还可以用-fsanitize=address直接报出栈越界。 与之相关,以为”编译出的帧长什么样”是固定的** - 返回或保存局部变量地址(悬空指针):函数返回后指针仍被使用,读到的值随调用时序变化。调试:
gcc -g -std=c99 -Wall -Werror会在编译期报-Wreturn-local-addr/-Wdangling-pointer=;运行时用gcc -fsanitize=address -g或valgrind --track-origins=yes ./prog精确指出”使用了已失效的栈内存”;gdb里bt看当前帧是否还有效。 - 局部变量未初始化,误以为”新帧里是 0”:帧只是把
R6挪了位置,里面的位是上一次调用留下的垃圾。调试:gcc -O2 -Wmaybe-uninitialized、valgrind的Conditional jump or move depends on uninitialised value(s);养成声明即初始化的习惯。 - 传结构与数组时想当然”传的是本体”:数组会退化为指针(传地址),结构体按值传递则整个拷进参数区,帧会突然变大。调试:打印
sizeof与¶m;用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是发现这类未定义行为最有效的工具。
思考题(带答案)
一个函数有 3 个参数、2 个局部变量。它建立栈帧时
R6要减多少?参数分别对应哪个偏移?返回到调用者之前R6又要加多少? 答案:帧 = 2 个局部变量 + 3 个 linkage 字 = 5 个字,所以ADD R6,R6,#-5(538-mt1-review的FOO正是这个例子,注释写着 “three for linkage, two for local vars”)。 接着ADD R5,R6,#1让R5指向两个局部变量中地址较高的那个,于是STR R5,R6,#2恰好把调用者的帧指针存到R5+1,再STR R7,R5,#2存返回地址;参数依次在R5+4、R5+5、R5+6,返回值槽是R5+3。返回前ADD R6,R6,#4(弹出 2 个局部变量与 linkage 中的两格)使R6正好指向返回值槽;调用者读完返回值后再用一条ADD R6,R6,#(3+1)同时弹出返回值与三个参数。 三个参数依次在R5+4、R5+5、R5+6。返回前ADD R6,R6,#4(弹出 2 个局部变量 + linkage 中的两格,留下返回值槽),此时R6正好指向返回值。为什么
printf要求实参自右向左压栈?如果改成从左往右,printf会遇到什么问题? 答案:printf的参数个数由第一个参数(格式串)决定,编译器不会告诉它一共传了几个。自右向左压栈让第一个实参固定落在R5+4,于是printf可以先取格式串、再按其中的说明符个数依次读R5+5、R5+6……。若从左往右压栈,第一个实参会在最高的地址上,而printf既不知道总共有几个参数,也就无法算出”第一个参数在哪里”——它连起点都找不到。下面的函数为什么危险?在实际系统上它可能”看起来正常”,为什么?给出两种正确的替代写法。
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; }(返回的是值的拷贝,与帧无关);若结果必须长期存活,则使用动态分配。
