Appearance
函数调用约定与栈帧
概念
前三篇我们已经能写"顺序 + 分支 + 循环"的程序了——也就是一个 main 函数能做的全部事情。
但真实程序不是一个函数。一旦你写下:
c
int main(void) {
int x = add3(1, 2, 3); /* 调用 */
printf("%d\n", x);
}四个立刻冒出来的问题:
| # | 问题 | 谁来回答 |
|---|---|---|
| 1 | 跳过去以后,怎么回来? | jal 自动把返回地址存进 $ra;jr $ra 跳回 |
| 2 | 参数放在哪? | 前 4 个放 $a0~$a3,多的压栈 |
| 3 | 返回值放在哪? | 放 $v0 |
| 4 | 局部变量放哪?调用完还要用的东西放哪? | 栈(stack)+ 栈帧(stack frame) |
这四个问题的答案合起来,就是"调用约定(calling convention)"——它是 ISA 之上的一层约定(ABI 的一部分):
"约定"这个词的含义很重要:
两个函数可以互不认识、分别由不同的人写、甚至用不同语言编译——只要双方都守这套规则,就能正确协作。
而约定的核心难点,是一个"资源不够"的问题:
这个地方就是栈。
本篇在主线上的位置:L4 的操作系统会讲"进程地址空间""用户栈与内核栈""栈溢出攻击";L2 的流水线会讲"
jal为什么在流水线里要特别处理"。 但它们都建立在同一件事上:一个函数调用在内存里到底长什么样。 这一篇就是那块地基。 同时,这一篇也是"汇编 → C"的桥:讲完.stack frame,lang/13-callstack.md(C 的运行时栈)几乎就是把同一张图换个角度看。
原理
一、调用与返回:jal 与 jr $ra
MIPS 的调用只用了两条指令:
| 指令 | 作用 |
|---|---|
jal target | ① $ra = PC + 4 ② 跳到 target |
jr $ra | 跳到 $ra 里的地址(返回) |
注意 jal 存的是 PC+4——也就是**"jal 的下一条指令"的地址**。这是返回位置的正确选择(返回到调用点的下一句,而不是重做 jal)。
但这里有个致命的问题:
asm
main:
jal funcA # $ra = main 里的返回地址
funcA:
jal funcB # $ra 被覆盖成 funcB 的返回地址!
...
funcB:
...
jr $ra # 跳回 funcA 里那条正确的
funcA:
...
jr $ra # ✗ 现在跳回哪?—— 已经变成 funcA 内部的地址了$ra 只有一份,jal 会覆盖它。所以"非叶子函数"(还会调别人的函数)必须在调用之前把 $ra 存起来,返回前再取回。
存哪?栈。 这就是栈帧存在的第一个理由。
二、参数与返回值:$a0~$a3 与 $v0
调用约定规定:
| 用途 | 寄存器 | 位置 |
|---|---|---|
| 第 1~4 个参数 | $a0 $a1 $a2 $a3 | 寄存器 |
| 第 5 个起 | — | 压到栈上(偏移 16、20、24…) |
| 返回值 | $v0(第二个返回值用 $v1) | 寄存器 |
为什么只有 4 个参数寄存器? 两个考量:
- 寄存器总共只有 32 个(
,呼应第一篇),不能全用来传参; - 绝大多数函数的参数不超过 4 个——4 个够覆盖绝大多数情况,再多就访存。
注意第 5 个参数的偏移是 16,不是 0。原因有点绕,但值得说清:
0($sp)~15($sp)这 16 字节是"预留给$a0~$a3的槽位"(所谓 argument save area / 参数溢出槽)。 被调用者可以偷懒:不想把参数挪到别处,就直接把$a0~$a3存到0($sp)~12($sp)里,于是"栈上的参数"就变成了一段连续的内存(偏移 0、4、8、12、16、20…)。 这是 MIPS o32 ABI 的规定——为的是让"可变参数函数"(如printf)能把所有参数当成一个数组来遍历。
用一张图把"7 个参数"的位置排清楚:
寄存器: $a0 $a1 $a2 $a3 栈(高地址 → 低地址)
┌────┬────┬────┬────┐ ┌──────────────────────────┐
│ 第1│ 第2│ 第3│ 第4│ │ 仅供 spill 的 16 字节 │ 0($sp)
└────┴────┴────┴────┘ │ 第5个参数 → 16($sp) │
│ 第6个参数 → 20($sp) │
│ 第7个参数 → 24($sp) │
└──────────────────────────┘ ← $sp返回值为什么要在寄存器里? 因为返回值几乎总会被立刻用到(x = f() 里的 x =)。放寄存器可以直接接上下一条指令;放栈就要多一次访存。
三、$t 与 $s:谁负责保存
这是调用约定里最需要动脑子的一条。先看规定:
| 寄存器 | 分类 | 规则 | 记忆 |
|---|---|---|---|
$t0~$t9 | 调用者保存(caller-saved) | 被调用者可以随便改;调用者若还想用,自己先存 | "临时的,用完就没了" |
$s0~$s7 | 被调用者保存(callee-saved) | 被调用者若要用,必须先存、返回前必须恢复 | "保存的,跨调用还在" |
$ra、$sp、$fp、$gp | 特殊 | 有各自规矩 | — |
$v0/$v1、$a0~$a3 | 调用者保存 | 被调用者可以改(本来就是它要用的) | — |
为什么要有两种?直接全"被调用者保存"不行吗?
不行,因为两边都有成本。用两个极端例子看清楚:
| 场景 | 全用 $s(被调用者保存) | 全用 $t(调用者保存) |
|---|---|---|
叶子函数 int add(int a,int b){return a+b;} | 必须开栈帧保存它用到的每个 $s,返回前恢复 | 什么都不用保存,直接算完 jr $ra → 极快 |
| 循环里的变量(跨很多次函数调用还要用) | 放在 $s 里,一次保存管到底 | 每次调用前都要存、回来再取 → 慢 |
所以分工的本质是:
- 短期临时值(表达式中间结果):放
$t——叶子函数因此几乎零开销(这是 RISC 性能的关键之一); - 长期变量(循环计数器、外层函数的局部量):放
$s——一次保存,多次受益。
一条可以直接用起来的判据:
写"非叶子"汇编函数时,如果它用到了
$s寄存器,就一定得保存;如果只用$t,就不必(但它自己调用别人时,$t里的值可能被清掉,所以要看清楚用途)。
四、栈帧的结构
栈的两个基本事实:
- 栈向"低地址"方向增长——
addiu $sp,$sp,-32是"开栈帧",addiu $sp,$sp,32是"关栈帧"; $sp永远指向当前栈帧的"顶部"(最低地址)。
一个标准的栈帧由五部分构成(从低地址到高地址):
高地址
┌──────────────────────────┐ ← 调用者的栈帧
│ … │
├──────────────────────────┤
│ 参数溢出区(>4 个参数) │ 16($sp)、20($sp)…
├──────────────────────────┤
│ 保存的 $ra │ ← ★ 非叶子函数必须有
├──────────────────────────┤
│ 保存的 $fp(旧帧指针) │ ← 用了 $fp 才有
├──────────────────────────┤
│ 保存的 $s0 … $sN │ ← 用到几个存几个
├──────────────────────────┤
│ 局部变量 / 临时数组 │
├──────────────────────────┤ ← $fp(帧基址)指向这里(或高一点)
│ (对齐填充) │
└──────────────────────────┘ ← $sp(栈顶)
低地址两个指针的分工:
| 指针 | 指向 | 变化 | 用途 |
|---|---|---|---|
$sp | 栈顶(帧的最低地址) | 开栈/关栈时变 | 分配与释放 |
$fp | 帧基址(帧内一个稳定位置) | 整个函数执行期间不变 | 访问局部变量的锚点 |
为什么要有 $fp? 因为 $sp 在函数执行中可能变:
- 动态分配栈上的空间(C 的
alloca、变长数组 VLA)会让$sp移动; - 调用别的函数时
$sp也一直在动。
如果只用 $sp 做基准,偏移量就是"浮动"的——这很麻烦。$fp 提供一个固定的锚点:lw $t0, -4($fp) 永远指向同一个局部变量。
注意:简单函数常常省略
$fp(gcc -fomit-frame-pointer就是干这个的),直接用$sp加固定偏移访问。只要能保证$sp在函数内不变,就不需要$fp——这就是"帧指针省略"优化的原理。
对齐铁律:$sp 必须保持 8 字节对齐(MIPS o32;某些 ABI 要 16 字节)。
为什么? 因为 double、long long 这些类型需要 8 字节对齐(上一篇的"对齐"规则),如果 $sp 只是 4 字节对齐,一个 8 字节局部变量就可能落在未对齐地址上 → lw 直接抛 Address Error。
所以你会看到"帧大小是 8 的倍数":哪怕只用 4 字节,也要填 4 字节的"对齐填充"。
五、一次调用的完整过程(五个步骤)
把上面所有规则串起来,"调用"这件事展开成五步:
| 步骤 | 谁在做 | 做什么 |
|---|---|---|
| ① | 调用者 | 把参数放进 $a0~$a3(多出的压栈) |
| ② | 调用者 | jal 函数(自动写 $ra = PC+4,并跳转) |
| ③ | 被调用者 | addiu $sp,$sp,-N 开栈帧;保存 $ra 和用到的 $s |
| ④ | 被调用者 | 执行函数体;结果放 $v0 |
| ⑤ | 被调用者 | 恢复 $s 和 $ra;addiu $sp,$sp,N 关栈帧;jr $ra |
五个步骤里,第 ③ 和第 ⑤ 步要有对称性:
开了多少、存了什么,就必须关多少、恢复什么——顺序反过来(后进先出)。 "栈帧不对称"是手写汇编最常见的 bug:少恢复一个
$s,调用者的循环变量就悄悄被改坏了,而且症状离现场很远(典型表现:循环跑飞、返回值莫名奇妙)。
用代码看一遍(一个用了 $s0 的非叶子函数):
asm
myfunc:
addiu $sp, $sp, -8 # ③ 开栈帧:8 字节
sw $ra, 4($sp) # 保存返回地址
sw $s0, 0($sp) # 保存要用的 $s0
...
move $s0, $t0 # 用 $s0 存一个"跨调用要活"的值
jal other # 调别人(这会清掉所有 $t!)
...
lw $s0, 0($sp) # ⑤ 恢复 $s0(顺序与保存相反)
lw $ra, 4($sp) # 恢复 $ra
addiu $sp, $sp, 8 # 关栈帧
jr $ra # 返回注意 jal other 那一行:它之后,所有 $t 寄存器的值都不再可信——因为 $t 是 caller-saved。这就是为什么"跨调用要活的值"必须放 $s。 而 $s0 之所以能活下来,是因为**"保存 $s0"这个动作是 other 自己做的**(它若用 $s0 就得保存并恢复)——约定就是这样互相兜底的。
六、递归:栈的存在意义
递归把"栈"的必要性逼到明面上:
每进入一层递归,就有一个新的
n、一个新的返回地址。它们必须各自独立地活着。
$a0 和 $ra 都只有一份——所以每一层都必须把自己的那份存到栈上。于是:
这就是"递归太深会栈溢出(Segmentation fault)"的算术根据。不是"递归慢",而是"栈是有限的"。
递归函数的标准写法(四个动作缺一不可):
- 进函数就开栈帧、保存
$ra和$a0($a0是参数,递归调用会改它); - 判终止条件(base case);
- 递归调用(
jal自己); - 调用返回后,从栈里取回
$ra和$a0,再算、再恢复、再返回。
第 4 步是最容易漏的:$a0 在递归调用后已经被改掉了(被调用者可能改 $a0,因为它是 caller-saved),如果不用栈取回,n 就丢了。这是递归汇编最经典的错误。
示例
例 1:叶子函数 —— 可以完全不要栈帧
任务:写 int add3(int a, int b, int c) { return a+b+c; } 的汇编,并说明为什么它不需要栈帧。
asm
add3:
addu $v0, $a0, $a1 # v0 = a + b
addu $v0, $v0, $a2 # v0 = (a+b) + c
jr $ra # 直接返回三条指令,$sp 一直没动。 为什么可以这样?
| 理由 | 说明 |
|---|---|
| 它不再调用别人(叶子函数) | 没有第二次 jal,所以 $ra 不会被覆盖 → 不用保存 |
它只用 $a0~$a2 和 $v0 | 这些都是"它自己要用"的寄存器,本来就是它有权改的 |
一个 $s 都没用 | 没有"跨调用要活"的值 → 不用保存任何东西 |
这就是"调用约定分工"的红利:最底层的那些小函数(加法、比较、取字段)几乎零开销。
对照 Python 的"函数调用开销":
python
# 用 Python 感受"调用开销"的存在(每次调用都要建栈帧、压参数)
import sys
def add3(a, b, c):
return a + b + c
# 递归深度与帧大小:Python 默认递归上限 1000 层
print("Python 默认递归上限 =", sys.getrecursionlimit())
print("→ 每层调用都要一个栈帧;栈帧太大或太深 → RecursionError")
# 用"递归求和"看层数
def rsum(n):
return 0 if n == 0 else n + rsum(n - 1)
for n in (5, 100):
print(f"rsum({n}) = {rsum(n)} 递归深度 = {n + 1} 层")预期输出:
Python 默认递归上限 = 1000
→ 每层调用都要一个栈帧;栈帧太大或太深 → RecursionError
rsum(5) = 15 递归深度 = 6 层
rsum(100) = 5050 递归深度 = 101 层结论:汇编里"要不要开栈帧"是一个可以自己决定的优化决策;编译器的"叶函数优化"(leaf function optimization)就是这个——一个叶子函数根本不碰 $sp,比开了帧再关快得多。
例 2:非叶子函数 —— 到底要保存什么
任务:列出三种函数"必须保存什么",并算出帧大小(含对齐填充)。
python
import unicodedata
def w(s):
"""按显示宽度算长度:中文/全角按 2 列计"""
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
def frame_of(need_ra, need_s):
"""按 o32 约定算帧大小:$ra(4) + 每个 $s(4),最后补齐到 8 的倍数。"""
size, parts = 0, []
if need_ra:
size += 4; parts.append("$ra(4)")
for i in range(need_s):
size += 4; parts.append(f"$s{i}(4)")
if size and size % 8: # 栈必须 8 字节对齐
p = 8 - size % 8
size += p; parts.append(f"对齐填充({p})")
return size, parts
CASES = [
("叶子函数 add3(a,b,c)", False, 0),
("普通非叶子 f()", True, 0),
("用了 $s0 的 g()", True, 1),
("用了 $s0,$s1 的 h()", True, 2),
("用了 $s0..$s3 的 k()", True, 4),
]
print(" " + pad("函数", 24) + pad("帧大小", 10) + "内容")
for name, ra, ns in CASES:
size, parts = frame_of(ra, ns)
print(" " + pad(name, 24) + pad(f"{size} 字节", 10)
+ (", ".join(parts) if parts else "无(不建帧)"))
print("\n h():$ra(4) + 2 个 $s(8) = 12 → 补 4 → 16")
print(" k():$ra(4) + 4 个 $s(16) = 20 → 补 4 → 24")
print(" ★ 帧大小总是 8 的倍数:因为 $sp 必须 8 字节对齐,")
print(" 否则 8 字节的局部变量(double / long long)会落在未对齐地址上 → Address Error")预期输出:
函数 帧大小 内容
叶子函数 add3(a,b,c) 0 字节 无(不建帧)
普通非叶子 f() 8 字节 $ra(4), 对齐填充(4)
用了 $s0 的 g() 8 字节 $ra(4), $s0(4)
用了 $s0,$s1 的 h() 16 字节 $ra(4), $s0(4), $s1(4), 对齐填充(4)
用了 $s0..$s3 的 k() 24 字节 $ra(4), $s0(4), $s1(4), $s2(4), $s3(4), 对齐填充(4)
h():$ra(4) + 2 个 $s(8) = 12 → 补 4 → 16
k():$ra(4) + 4 个 $s(16) = 20 → 补 4 → 24
★ 帧大小总是 8 的倍数:因为 $sp 必须 8 字节对齐,
否则 8 字节的局部变量(double / long long)会落在未对齐地址上 → Address Error两条结论:
| 结论 | 说明 |
|---|---|
帧大小 = 4($ra)+ 4×用到的 $s 数 + 局部变量,再补齐到 8 的倍数 | 没用到 $s、又是非叶子 → 也要 8 字节(4 字节 $ra + 4 字节填充) |
| 叶子函数帧大小为 0 | 这就是"叶函数优化"能省下的开销——不碰 $sp、不访存 |
例 3:递归 factorial —— 逐层栈帧的完整推导
任务:写 factorial(n) 的汇编,并把 n=5 时每一层的栈帧地址、返回值算清楚。
asm
fact:
addiu $sp, $sp, -8 # 开帧(8 字节)
sw $ra, 4($sp) # 保存返回地址(递归会覆盖它!)
sw $a0, 0($sp) # 保存 n(递归会改 $a0!)
slti $t0, $a0, 1 # n < 1 ?
beq $t0, $zero, recur # 不是 → 去递归
addiu $v0, $zero, 1 # base case: 0! = 1
j done
recur:
addiu $a0, $a0, -1 # 参数 n-1
jal fact # 递归调用($ra 被覆盖)
lw $a0, 0($sp) # ★ 取回自己的 n(否则丢了!)
mul $v0, $a0, $v0 # v0 = n * fact(n-1)
done:
lw $ra, 4($sp) # 恢复返回地址
addiu $sp, $sp, 8 # 关帧
jr $ra两个"★"是递归的命门:不保存 $ra → 返回时跳到递归调用点、死循环;不保存 $a0 → n 变成 0,结果永远为 0。
python
SP_TOP, FRAME = 0x7FFFEFFC, 8
print(f"初始 $sp = 0x{SP_TOP:08X},每帧 {FRAME} 字节(4($sp)=$ra,0($sp)=$a0)")
print(f"\n {'lvl':>3} {'call':>8} {'$sp':>12} {'$ra slot':>12} {'$a0 slot':>12}")
sp, frames = SP_TOP, []
for k in range(5, -1, -1): # fact(5) → fact(0),共 6 层
sp -= FRAME
frames.append((k, sp))
print(f" {len(frames):>3} {'fact(%d)' % k:>8} 0x{sp:08X} 0x{sp+4:08X} 0x{sp:08X}")
print(f"\n n=5:{len(frames)} 层 × {FRAME} 字节 = {len(frames)*FRAME} 字节"
f",$sp 从 0x{SP_TOP:08X} 降到 0x{sp:08X}")
print("\n —— 回溯(每层从栈取回 $a0,乘上子调用的 $v0)——")
v = 1
for k, _ in reversed(frames): # fact(0) → fact(5)
if k == 0:
print(" fact(0) 命中 base case → $v0 = 1")
else:
v = k * v
print(f" fact({k}) 取回 $a0={k} → $v0 = {k} × {v // k} = {v}")
import math
print(f" 校验:5! = {math.factorial(5)},程序得 {v},一致 = {v == math.factorial(5)}")
print("\n=== 帧大小 × 深度 = 栈消耗 ===")
CAP = 8 * 1024 * 1024
for fsz in (8, 16, 48, 256):
print(f" 帧 {fsz:>3} 字节 → 8 MB 栈可支持约 {CAP // fsz:>9} 层递归")
print(f"\n 本例帧 {FRAME} 字节 → 上限约 {CAP // FRAME} 层")
print(" → '递归太深 → 栈溢出(Segmentation fault)' 就是这么算出来的")预期输出:
初始 $sp = 0x7FFFEFFC,每帧 8 字节(4($sp)=$ra,0($sp)=$a0)
lvl call $sp $ra slot $a0 slot
1 fact(5) 0x7FFFEFF4 0x7FFFEFF8 0x7FFFEFF4
2 fact(4) 0x7FFFEFEC 0x7FFFEFF0 0x7FFFEFEC
3 fact(3) 0x7FFFEFE4 0x7FFFEFE8 0x7FFFEFE4
4 fact(2) 0x7FFFEFDC 0x7FFFEFE0 0x7FFFEFDC
5 fact(1) 0x7FFFEFD4 0x7FFFEFD8 0x7FFFEFD4
6 fact(0) 0x7FFFEFCC 0x7FFFEFD0 0x7FFFEFCC
n=5:6 层 × 8 字节 = 48 字节,$sp 从 0x7FFFEFFC 降到 0x7FFFEFCC
—— 回溯(每层从栈取回 $a0,乘上子调用的 $v0)——
fact(0) 命中 base case → $v0 = 1
fact(1) 取回 $a0=1 → $v0 = 1 × 1 = 1
fact(2) 取回 $a0=2 → $v0 = 2 × 1 = 2
fact(3) 取回 $a0=3 → $v0 = 3 × 2 = 6
fact(4) 取回 $a0=4 → $v0 = 4 × 6 = 24
fact(5) 取回 $a0=5 → $v0 = 5 × 24 = 120
校验:5! = 120,程序得 120,一致 = True
=== 帧大小 × 深度 = 栈消耗 ===
帧 8 字节 → 8 MB 栈可支持约 1048576 层递归
帧 16 字节 → 8 MB 栈可支持约 524288 层递归
帧 48 字节 → 8 MB 栈可支持约 174762 层递归
帧 256 字节 → 8 MB 栈可支持约 32768 层递归
本例帧 8 字节 → 上限约 1048576 层
→ '递归太深 → 栈溢出(Segmentation fault)' 就是这么算出来的四条结论:
| 结论 | 说明 |
|---|---|
每层递归有自己的 $ra 槽和 $a0 槽 | 这就是"必须用栈"的直接原因——$ra 和 $a0 都只有一份 |
不保存 $a0 → 结果永远为 0 | 因为递归返回后 $a0 已被改成 0(caller-saved 规则) |
不保存 $ra → 死循环 | 因为返回地址被下一层覆盖了 |
| 栈消耗 = 帧大小 × 深度 | 8 字节/帧 → 8 MB 栈能撑约 105 万层;256 字节/帧就只能 3 万层 |
例 4:超过 4 个参数 —— 栈传递与"16 字节保留槽"
任务:int f(a,b,c,d,e,f,g) 的 7 个参数分别放在哪。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
ARGS = ["a", "b", "c", "d", "e", "f", "g"]
print(f" 参数共 {len(ARGS)} 个:{ARGS}\n")
print(" " + pad("参数", 8) + pad("位置", 12) + "说明")
for i, name in enumerate(ARGS):
if i < 4:
loc, note = "$a%d" % i, "前 4 个走寄存器"
else:
loc, note = "%d($sp)" % (16 + (i - 4) * 4), "第 %d 个 → 溢到栈上" % (i + 1)
print(" " + pad(name, 8) + pad(loc, 12) + note)
print("\n ★ 为什么从 16 开始而不是从 0?")
print(" 0($sp)..15($sp) 是 a0-a3 的'保留溢出槽'(argument save area)")
print(" → 被调用者可以把 a0-a3 直接存入 0..12($sp),")
print(" 于是'所有参数'就连成一段连续内存:0,4,8,12,16,20,24")
print(" → 可变参数函数(printf)因此能像遍历数组一样遍历参数")
print("\n 调用者开栈时要注意:这些参数槽 + 自己的帧,一起要 8 字节对齐")
base = 16
extra = (len(ARGS) - 4) * 4
print(f" 本例溢出参数占 {extra} 字节,加上 16 字节保留槽 = "
f"{base + extra} 字节的参数区")预期输出:
参数共 7 个:['a', 'b', 'c', 'd', 'e', 'f', 'g']
参数 位置 说明
a $a0 前 4 个走寄存器
b $a1 前 4 个走寄存器
c $a2 前 4 个走寄存器
d $a3 前 4 个走寄存器
e 16($sp) 第 5 个 → 溢到栈上
f 20($sp) 第 6 个 → 溢到栈上
g 24($sp) 第 7 个 → 溢到栈上
★ 为什么从 16 开始而不是从 0?
0($sp)..15($sp) 是 a0-a3 的'保留溢出槽'(argument save area)
→ 被调用者可以把 a0-a3 直接存入 0..12($sp),
于是'所有参数'就连成一段连续内存:0,4,8,12,16,20,24
→ 可变参数函数(printf)因此能像遍历数组一样遍历参数
调用者开栈时要注意:这些参数槽 + 自己的帧,一起要 8 字节对齐
本例溢出参数占 12 字节,加上 16 字节保留槽 = 28 字节的参数区两条结论:
| 结论 | 说明 |
|---|---|
| 前 4 个参数走寄存器,第 5 个起压栈,偏移从 16 开始 | 16 这个"奇怪"的起始偏移是 ABI 规定的保留槽 |
| "参数在内存里是连续的"这个性质支撑了可变参数函数 | printf("%d %d", a, b) 靠它能"数着取参数"——这也是 C 可变参数必须至少有一个具名参数的原因 |
考点
考点
1. 调用与返回的机制
jal做两件事:$ra = PC+4(存的是下一条指令的地址)并跳转;jr $ra返回;$ra只有一份 → 嵌套调用必被覆盖 → 非叶子函数必须保存$ra;- 叶子函数(不再调别人)可以不保存
$ra,连栈帧都不用建。
2. 参数与返回值
- 前 4 个参数 →
$a0~$a3;第 5 个起 → 栈上,偏移 16、20、24…; 0($sp)~15($sp)是$a0~$a3的保留溢出槽(所以第 5 个参数从 16 开始);- 返回值 →
$v0(第二个用$v1)。
3. $t 与 $s(必考)
| 分类 | 寄存器 | 规则 |
|---|---|---|
| 调用者保存 | $t0~$t9、$a0~$a3、$v0/$v1 | 被调用者可随便改;调用者要用就自己先存 |
| 被调用者保存 | $s0~$s7、$fp、部分 $ra 场景 | 被调用者要用就必须存、返回前必须恢复 |
jal之后,所有$t的值都不可信——跨调用要活的值必须放$s;- 分工的理由:叶子函数只碰
$t→ 零保存开销;长命变量放$s→ 一次保存管到底。
4. 栈帧结构与两个指针
- 栈向低地址增长:
addiu $sp,$sp,-N开帧,addiu $sp,$sp,N关帧; - 帧的五部分:参数溢出区 / 保存的
$ra/ 保存的$fp/ 保存的$s/ 局部变量; $sp= 帧的最低地址(会变);$fp= 帧基址(函数内不变)——$fp是"局部变量的稳定锚点";- 简单函数可省略
$fp(用$sp+ 固定偏移),前提是$sp在函数内不变; $sp必须 8 字节对齐 → 帧大小总是 8 的倍数(少了要填充),否则 8 字节局部变量会未对齐 →Address Error。
5. 调用的五步(务必能默写)
①调用者放参数 → ②jal → ③被调用者开帧并保存 $ra/$s → ④执行、结果放 $v0 → ⑤恢复 $s/$ra、关帧、jr $ra。
- ③ 与 ⑤ 必须对称(关帧时按相反顺序恢复);
- 栈帧不对称是手写汇编最常见的 bug,症状离现场远(循环跑飞、返回值莫名)。
6. 递归(必考)
- 每层递归一个栈帧,各自保存 自己的
$ra和自己的参数; - 栈空间 = 帧大小 × 深度 → 递归太深 → 栈溢出;
- 递归函数两步不能少:保存
$ra(否则返回地址被覆盖)、保存/取回$a0(否则n被清零); - 算例:帧 8 字节 → 8 MB 栈约支撑 105 万层;帧 256 字节 → 只剩 约 3.3 万层。
小结
- 调用约定是"调用者与被调用者的分账规则":①怎么回来(
$ra)②参数放哪($a0~$a3+ 栈)③返回值放哪($v0)④局部变量放哪(栈帧)。 jal自动写$ra = PC+4,jr $ra返回;$ra只有一份,所以非叶子函数必须把它存栈。- 前 4 个参数走
$a0~$a3,第 5 个起压栈(从 16( v0`。 $t是调用者保存、$s是被调用者保存——分工是为了"谁更需要谁付账":叶子函数因此零保存开销,长命变量放$s一次存到底。- 栈向低地址增长;栈帧五部分:参数区 /
$ra/$fp/$s/ 局部变量;$sp会变,$fp不变(所以$fp是局部变量的锚点,简单函数可省)。 $sp必须 8 字节对齐 → 帧大小是 8 的倍数(不够要填)。- 调用五步:放参数 →
jal→ 开帧存$ra/$s→ 执行出$v0→ 恢复关帧jr $ra;存与恢复必须对称。 - 递归 = 每层一个栈帧;栈空间 = 帧大小 × 深度;帧 8 字节 → 约 105 万层,帧 256 字节 → 约 3.3 万层。
回到主线:现在函数能调了。但最后一步跨不过去——函数只能在自己的世界里转,碰不到"外面"。
想读文件、想打印到屏幕、想申请内存——怎么办?为什么"读一个文件"不能由用户程序自己干?
syscall和jal有什么本质区别?为什么前者会"进入另一个世界"?用户态和内核态是怎么分的?权限是怎么拦住的?
这就是下一篇:系统调用 —— 请求操作系统做事。
下一篇:系统调用:请求操作系统做事
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。