知识点

函数调用与栈帧

函数把复杂任务分解为可复用的模块,调用则是把这些模块拼接起来的机制。函数调用不是一条跳转指令就能完成的操作:调用者要把实参交给被调用者,被调用者要能分配局部空间、保存必要状态,并在结束时把结果和控制权交回。MIPS用一块向低地址生长的栈、以及每次调用新建的栈帧来实现这套约定。本页的寄存器名沿用MIPS;指令格式见Simple-MIPS教学指令集,PC与寄存器组的分工见CPU的核心部件。

定义

函数调用要完成的六件事

设发起调用的函数为调用者,被调用的函数为被调用者。一次调用需要依次完成:

  1. 传递参数:调用者把实参放在被调用者能访问的地方,通常是参数寄存器;寄存器放不下时放到栈上。
  2. 转移控制:执行流程从调用者跳转到被调用者的入口。
  3. 获得存储空间:被调用者在栈上开辟自己的栈帧,用于局部变量和临时数据。
  4. 执行任务:执行函数体内的指令。
  5. 放置结果:被调用者把返回值放在调用者能访问的地方,通常是返回值寄存器。
  6. 返回控制:被调用者释放自己的栈帧,把执行流程交还调用者,调用者从调用点之后继续执行。

这六件事里,第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本身。

被调用者分三个阶段工作:

  1. 序言:保存现场并开辟栈帧

    序言要保存调用者的 $fp、设置新的 $fp、调整 $sp开辟局部空间,并把 $ra和需要保留的寄存器存入栈帧;具体顺序由栈帧布局决定。叶子函数(不调用其他函数的函数)可以不保存 $ra。

  2. 执行函数体

    用 $fp加偏移访问局部变量和保存的寄存器;函数体内如有新的调用,新的被调用者会建立更低的栈帧。

  3. 尾声:放置结果并恢复现场

    把结果写入 $v0或 $v1,恢复保存的寄存器(包括调用者的 $fp)和 $ra,把 $sp恢复到进入函数时的位置以释放本帧,最后用jr $ra跳回调用者。

序言和尾声必须成对:序言保存了哪些寄存器,尾声就要按相反的顺序恢复哪些;序言开辟了多大空间,尾声就要释放多大空间。

递归与栈

栈是递归能够正确回溯的基础。递归调用与普通调用没有本质区别,只是被调用者又是同一个函数:

  • 独立栈帧:每进入一层递归,都会新建一个栈帧;不同层的同名参数和局部变量各自占用不同地址,互不覆盖。
  • 状态保存:每层栈帧都保存该层自己的参数、局部变量和返回地址。返回地址指向调用它的上一层,而不是最初的调用点。
  • 逐层回溯:到达递归基后开始返回,栈帧按后进先出的顺序逐个弹出,程序能回到上一层的调用位置继续执行。

如果没有栈,递归的每一层会覆盖上一层的返回地址和局部变量,回到上一层时就失去了继续执行所需的状态。

例子

嵌套调用时栈帧如何变化

假设main调用A,A调用B,三个函数都使用栈帧。

  1. main运行:栈上只有main的栈帧。
  2. main调用A:在main栈帧的下方(更低地址)建立A的栈帧,$sp移到A帧的栈顶。
  3. A调用B:在A栈帧的下方建立B的栈帧,$sp继续下移。
  4. 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. 依次把1、2、3压入栈,再连续弹出两次,第一次弹出的值是 。

    参考解答

    栈按后进先出工作,最后压入的3最先弹出;第二次弹出的是2,1仍留在栈中。

  2. MIPS的栈向低地址方向生长。压入一个32位字后,$sp的值如何变化?

    选择答案
    参考解答

    压栈先让 $sp减4取得空间,再写入一个字;减32是把32位错当成32字节。

  3. 一个典型的栈帧可能保存下列哪些内容?

    题 frame-contents · choice
    参考解答

    栈帧保存与本次调用有关的状态:返回地址、放不下的参数、需要恢复的寄存器和局部变量。全局变量不在栈帧里,它们有独立的存储区域。

  4. 帧指针 $fp的主要作用是什么?

    选择答案
    参考解答

    $sp会随压栈和出栈变化,$fp在函数执行期间保持不动,所以用 $fp加偏移访问帧内数据更稳定。返回地址由 $ra保存。

  5. MIPS用固定的寄存器承担调用中的不同角色。

    1. $ra保存的是什么?

      选择答案
      参考解答

      jal把下一条指令的地址写入 $ra,被调用者返回时用jr $ra跳回这个地址。

    2. 调用有5个参数的MIPS函数时,第5个参数通常放在哪里?

      选择答案
      参考解答

      $a0–$a3只能容纳前4个参数,第5个及之后的参数由调用者放在栈上。

  6. 递归函数每进入一层新的调用时,栈会发生什么变化?

    选择答案
    参考解答

    每层递归都有独立的栈帧,才能同时保留各层的状态;返回时按后进先出的顺序逐层弹出。

  7. main调用A,A调用B。B执行jr $ra返回后,$sp指向哪里?

    选择答案
    参考解答

    B的尾声只释放B自己的栈帧,$sp回到A帧的栈顶;A的栈帧和main的栈帧都还在。

交互探索

调用如何展开,又如何返回?

逐步查看阶乘的调用栈,区分等待中的栈帧与正在执行的调用。

正在准备演示…