函数调用与栈帧
函数把复杂任务分解为可复用的模块,调用则是把这些模块拼接起来的机制。函数调用不是一条跳转指令就能完成的操作:调用者要把实参交给被调用者,被调用者要能分配局部空间、保存必要状态,并在结束时把结果和控制权交回。MIPS用一块向低地址生长的栈、以及每次调用新建的栈帧来实现这套约定。本页的寄存器名沿用MIPS;指令格式见Simple-MIPS教学指令集,PC与寄存器组的分工见CPU的核心部件。
定义
函数调用要完成的六件事
设发起调用的函数为调用者,被调用的函数为被调用者。一次调用需要依次完成:
- 传递参数:调用者把实参放在被调用者能访问的地方,通常是参数寄存器;寄存器放不下时放到栈上。
- 转移控制:执行流程从调用者跳转到被调用者的入口。
- 获得存储空间:被调用者在栈上开辟自己的栈帧,用于局部变量和临时数据。
- 执行任务:执行函数体内的指令。
- 放置结果:被调用者把返回值放在调用者能访问的地方,通常是返回值寄存器。
- 返回控制:被调用者释放自己的栈帧,把执行流程交还调用者,调用者从调用点之后继续执行。
这六件事里,第1、2、5、6步需要调用者与被调用者共同遵守约定,否则各自独立编译的函数无法正确拼接。
栈
栈是内存中用于存放临时数据的一段区域,按后进先出(LIFO)的方式工作:最后压入的数据最先弹出。可以把栈想成一摞盘子,只能从最上面放取。MIPS的栈向低地址方向生长,压栈让地址变小,出栈让地址变大,因此“栈顶”是当前占用区域中地址最低的一端。
$sp是栈顶指针,始终指向栈顶。压入一个32位字时,先把 $sp减4取得空间,再把数据写到 $sp指向的地址;弹出一个字时,先读出 $sp处的数据,再把 $sp加4。这里的4来自MIPS的字长:每个字占4字节。
$fp是帧指针,指向当前栈帧的固定位置,通常取帧的基址。函数执行过程中 $sp会随临时数据的压入和弹出而变化,$fp保持不变,所以用 $fp加固定偏移访问局部变量和保存的寄存器,比用不断变化的 $sp更稳定。
栈帧
每次函数调用都会在栈上分配一段属于这次调用的空间,称为栈帧,也叫活动记录。一个典型的栈帧保存以下内容:
| 内容 | 作用 |
|---|---|
| 返回地址 | 当前函数执行完后回到调用者的位置。函数体内若还要调用其他函数,jal会覆盖 $ra,所以必须先在序言中把自己的 $ra存入栈帧。 |
| 放不进参数寄存器的实参 | 前4个参数走 $a0–$a3,更多参数由调用者放在栈上。 |
| 需要保留的寄存器旧值 | 被调用者若使用约定由它保存的非易失性寄存器,要先存旧值,返回前恢复。 |
| 局部变量 | 数组、结构体等不适合放在寄存器中的局部数据。 |
$fp标记帧的基址,$sp标记当前栈顶,两者共同界定当前帧的范围。函数返回时先恢复保存的寄存器和返回地址,再把 $sp恢复到进入函数时的位置,整帧占用的空间就被释放。
调用机制
寄存器分工
MIPS的调用约定把一部分寄存器固定用于传递信息和维护栈:
| 寄存器 | 用途 |
|---|---|
$a0–$a3 | 传递前4个参数 |
$v0–$v1 | 存放返回值 |
$ra | 保存返回地址 |
$sp | 栈顶指针 |
$fp | 帧指针 |
参数超过4个时,多出的参数由调用者放在栈上;调用结束后,调用者负责清理这部分参数空间。$ra、$sp、$fp都是单个寄存器,函数体内的一次新调用就可能改写 $ra,这正是函数体内还会发起调用的函数必须保存返回地址的原因。
调用者与被调用者
调用者把参数送入 $a0–$a3(或栈上),然后执行跳转链接指令jal。jal做两件事:把下一条指令的地址(PC+4)写入 $ra,再跳转到被调用者的入口。返回地址是调用点之后的指令,而不是jal本身。
被调用者分三个阶段工作:
-
序言:保存现场并开辟栈帧
序言要保存调用者的
$fp、设置新的$fp、调整$sp开辟局部空间,并把$ra和需要保留的寄存器存入栈帧;具体顺序由栈帧布局决定。叶子函数(不调用其他函数的函数)可以不保存$ra。 -
执行函数体
用
$fp加偏移访问局部变量和保存的寄存器;函数体内如有新的调用,新的被调用者会建立更低的栈帧。 -
尾声:放置结果并恢复现场
把结果写入
$v0或$v1,恢复保存的寄存器(包括调用者的$fp)和$ra,把$sp恢复到进入函数时的位置以释放本帧,最后用jr $ra跳回调用者。
序言和尾声必须成对:序言保存了哪些寄存器,尾声就要按相反的顺序恢复哪些;序言开辟了多大空间,尾声就要释放多大空间。
递归与栈
栈是递归能够正确回溯的基础。递归调用与普通调用没有本质区别,只是被调用者又是同一个函数:
- 独立栈帧:每进入一层递归,都会新建一个栈帧;不同层的同名参数和局部变量各自占用不同地址,互不覆盖。
- 状态保存:每层栈帧都保存该层自己的参数、局部变量和返回地址。返回地址指向调用它的上一层,而不是最初的调用点。
- 逐层回溯:到达递归基后开始返回,栈帧按后进先出的顺序逐个弹出,程序能回到上一层的调用位置继续执行。
如果没有栈,递归的每一层会覆盖上一层的返回地址和局部变量,回到上一层时就失去了继续执行所需的状态。
例子
嵌套调用时栈帧如何变化
假设main调用A,A调用B,三个函数都使用栈帧。
main运行:栈上只有main的栈帧。main调用A:在main栈帧的下方(更低地址)建立A的栈帧,$sp移到A帧的栈顶。A调用B:在A栈帧的下方建立B的栈帧,$sp继续下移。B返回:B的栈帧被弹出,$sp回到A帧的栈顶,A的局部变量和保存的返回地址仍然完好;A继续执行jal B之后的指令。
可以看到,一次返回只销毁最上面的一层栈帧,不会清空整个栈。若B返回后A又调用C,C会复用B刚释放的那段空间。
返回地址为什么必须入栈
设main在地址0x00400020处执行jal A,$ra因此保存0x00400024。A的序言把0x00400024存入自己的栈帧;随后A在0x00400100处执行jal B,$ra被改写为0x00400104。
B返回时用jr $ra回到0x00400104,A继续执行。等A结束时,它从自己的栈帧取回0x00400024写入 $ra,再用jr $ra回到main。如果A没有保存最初的 $ra,0x00400024会被jal B覆盖,A就找不到回main的路。
练习
依次把1、2、3压入栈,再连续弹出两次,第一次弹出的值是 。
参考解答
栈按后进先出工作,最后压入的3最先弹出;第二次弹出的是2,1仍留在栈中。
MIPS的栈向低地址方向生长。压入一个32位字后,
$sp的值如何变化?参考解答
压栈先让
$sp减4取得空间,再写入一个字;减32是把32位错当成32字节。一个典型的栈帧可能保存下列哪些内容?
参考解答
栈帧保存与本次调用有关的状态:返回地址、放不下的参数、需要恢复的寄存器和局部变量。全局变量不在栈帧里,它们有独立的存储区域。
帧指针
$fp的主要作用是什么?参考解答
$sp会随压栈和出栈变化,$fp在函数执行期间保持不动,所以用$fp加偏移访问帧内数据更稳定。返回地址由$ra保存。MIPS用固定的寄存器承担调用中的不同角色。
$ra保存的是什么?参考解答
jal把下一条指令的地址写入$ra,被调用者返回时用jr $ra跳回这个地址。调用有5个参数的MIPS函数时,第5个参数通常放在哪里?
参考解答
$a0–$a3只能容纳前4个参数,第5个及之后的参数由调用者放在栈上。
递归函数每进入一层新的调用时,栈会发生什么变化?
参考解答
每层递归都有独立的栈帧,才能同时保留各层的状态;返回时按后进先出的顺序逐层弹出。
main调用A,A调用B。B执行jr $ra返回后,$sp指向哪里?参考解答
B的尾声只释放B自己的栈帧,$sp回到A帧的栈顶;A的栈帧和main的栈帧都还在。
交互探索
调用如何展开,又如何返回?
逐步查看阶乘的调用栈,区分等待中的栈帧与正在执行的调用。
正在准备演示…