序
我曾写过我更偏好于进入函数后入栈,离开函数前出栈,但这样我一道题都做不对。
宏
MIPS 中,可以通过这样的方式来定义宏:
1 | name(%param1, %param2, ...) |
命名的时候也可以不要参数,这样使用的时候也不用传入参数。
在我看来宏其实和函数区别并不明显。但是宏比较适合定义一些反复使用,同时简约的函数,比如结束进程:
1 | end |
非要说这样可以节约很多行代码,其实好像也没有,但是看起来就是舒服一点(
以下是一些很常用的宏:
1 | # exit program |
注意:两个宏可以取同一个名字,编译器通过传入的参数来判断展开为哪一段代码,比如:
1 |
|
两次输出分别为 is a number. 和 114514 is a number.。
函数与栈
MIPS 中被称之为“函数”的东西,是比宏复杂得多了。一般来说,都需要用一个标签名来隔离开,并用 jal 指令进入函数体,执行完再 jr $ra 回来。比如说:
1 |
|
这就不是像宏一样展开了。函数体独立于主程序外(其实可以给主程序也加个标签叫做 main:),要用的时候相对跳转过去,用完了还得返回之前的位置执行下一行指令。其实宏和函数并没有严格界限,还得视情况而定。不过某种情况下我们还是得用这种标签法,那就是函数涉及递归时。
考虑翻译递归函数的 C 语言代码为 MIPS:
1 | void dfs(int i) |
要进入 dfs,我们使用 jal,return 或执行到函数尾时,我们用 jr $ra 结束这一层函数调用,回到上一层函数调用。执行 dfs(i + 1) 时,我们用 jal dfs 调用子函数。
然而,在讨论出入栈问题时,我们必须明确:
-
要出入栈的内容包括:
$ra,调用子函数时会改变、从子函数回来时要恢复的元素(在这里,比如说i)。 -
调用子函数后,栈顶保存的是上一层函数中第一条指定的内容。我们通过例子来看:
首先 i 肯定需要用一个寄存器保存,我们就假设是 $t0。
1 | main: |
我们把第 n 层 dfs 称作 dfsn,从 dfs1 开始。
-
首先,
main调用dfs1,假设进入第二个条件判断并满足条件,那么$ra和$t0入栈。入栈的$ra指向main的end,$t0为 0。 -
$t0 + 1变成 1。jal跳转dfs2。此时新的$ra指向dfs1的pop($t0),$t0为 1。 -
假设
dfs2仍然进入第二个条件判断并满足条件,那么入栈的$ra指向dfs1的pop($t0),$t0为 1。 -
$t0 + 1变成 2。jal跳转dfs3。此时新的$ra指向dfs2的pop($t0),$t0为 2。 -
让
dfs3满足第一个条件,使用jr回到dfs2的pop($ra)吧。经过两个pop后,$ra指向了dfs1的pop($ra),$t0为 1。 -
到
dfs2函数尾,jr回到dfs1的pop($ra),经过两个pop后,$ra指向了main的end,$t0为 0。 -
到
dfs1函数尾,jr回到main的end,运行end结束。
显然这样是可以的,不会弄乱每层 i 的值,也能正常回到主函数。
另一种出入栈方式是:
1 | main: |
同样的:
-
main调用dfs1,入栈$ra指向main的end,$t0 = 0。 -
满足第二个条件,
$t0 = 1,jal跳转dfs2后$ra指向dfs1的# some code use $t0,$t0 = 1。 -
入栈
$ra指向dfs1的# some code use $t0,$t0 = 1。 -
满足第二个条件,
$t0 = 2,jal跳转dfs3后$ra指向dfs2的# some code use $t0,$t0 = 2。 -
dfs3满足第一个条件,jr回到dfs2的# some code use $t0,这时候,问题出现了。
什么问题呢?回到 dfs2 的时候,$t0 是 2,下文使用 $t0 的时候,都是用 2 来运行的。可是 dfs2 对应的 $t0 应该是 1(调用 dfs1 时 $t0 为 0,dfs2 时为 1)。这是为什么呢?
原因出在 addi $t0, $t0, 1 上。这是因为我们需要 dfs(i + 1)。按理说,是将 i + 1 传入下一层函数,不应该改变本层 i 的值。但是 MIPS 并不如 C 语言那样智能,给你创建 i 的副本来保存不同层的值。所有层的 i 都存放在 $t0 这个寄存器中。这就是为什么我们需要手动出入栈。在第一种写法,我们是先入 dfsn 的栈,再跳转 dfsn+1,而第二种写法是 先跳转 dfsn+1,再入 dfsn 的栈。在 $ra 上面,这两种是等效的,均可以实现递归-回溯。但是要用到 $t0,则第二种需要手动减去 $t0 的增加值:
1 | main: |