![6cf3bf585a1eaa6b06b8a6906ac80d2d.png](https://i-blog.csdnimg.cn/blog_migrate/bd47b33a714b4897bc51e5f7257d89a2.jpeg)
调用约定
调用约定(Calling Convention)是规定子过程如何获取参数以及如何返回的方案,其通常与架构、编译器等相关。具体来说,调用约定一般规定了
- 参数、返回值、返回地址等放置的位置(寄存器、栈或存储器等)
- 如何将调用子过程的准备工作与恢复现场的工作划分到调用者(Caller)与被调用者(Callee)身上
调用约定虽然叫“Convention",但并不像如“CamelNaming”一样只是一种”习惯“。调用约定是不同人编写的汇编程序能正常配合工作的保证。
MIPS 寄存器约定
![70092e88093038d5d619302df337a725.png](https://i-blog.csdnimg.cn/blog_migrate/948cf1ff199d1cc93c295477790ca103.jpeg)
![ab1b2567ed61c46c1120ed2bb68c42c1.png](https://i-blog.csdnimg.cn/blog_migrate/8fdbfba44137537e37d70435f921c240.jpeg)
RISCV 寄存器约定
![dc9b38be22253a19889abe4712b8255f.png](https://i-blog.csdnimg.cn/blog_migrate/49802510adba984f0bb8f41c9e6554e7.jpeg)
![0925f9f8a55ab29d403c5808aa4e282b.png](https://i-blog.csdnimg.cn/blog_migrate/1f781330a9d2ee807ee5f911e7163a4c.jpeg)
这里有几点容易产生疑惑的地方:
- 这些表的说法为什么这么混乱?其中有些指出寄存器是由"caller"还是"callee"保存,有些指出"callee must preserve",还有些指出"preserve on call"(这种说法有时也叫"preserved across call")?
- 这些说法不同,但其实想表达的都是一个意思。其中,“preserve across call”个人认为是最清晰的说法:寄存器约定描述的实际上就是在子过程之间到底哪些寄存器的值需要保存起来,哪些不需要,这里举一个例子:
- 对于$ra,考虑函数A调用函数B:进入A中后,ra中保存了A的返回地址,但在A中使用
jal B
后,ra中的值就变成了从B返回A的地址,那这样A就永远无法正确返回了。因此,ra是属于“在调用之间必须保存”的寄存器,但是其并不是callee-saved,而是caller-saved:因为一旦进入callee的过程内,之前的ra就已经被覆盖了。
- 怎么区分caller与callee? 如果一个函数A调用函数B,函数B又调用函数C,那怎么划定这个概念?
- 如果我们只看字面意思,那函数B又是一个caller,又是一个callee,我们就没法描述这个规则了。事实上,caller和callee是一组相对的概念,不能从嵌套的角度去考虑。这是什么意思?比如,我们可以说,对于A调用B,A是caller,B是callee;B与C同理。对于一个caller-callee对,上面的表描述的规则都是适用的。
- 但如果从函数嵌套着互相调用,我们应该讲函数区分为“叶过程(leaf routine):不再调用其他过程的过程,与 非叶过程:还会调用其他过程的过程(non-leaf routine)”。
参数传递
1.都是caller通过sp为参数在栈顶分配空间,如果参数数量多,则多出的参数caller为callee复制好,
寄存器数目内的caller只分配,不复制
2.如果callee想,可以将在寄存器内的参数也复制到栈里,这样所有参数在栈中就是一个数组
3.RISCV和MIPS的栈帧(stack frame)安排的不同
MIPS中,当前子过程(subroutine)的参数在上一个子过程的栈帧里,当前过程的栈帧顶端(sp所指的位置)是下一个子过程(如果有)的参数。如果当前过程想获得更多参数,就要通过sp(或fp)越过当前子过程的栈帧去取参数。
![cbba91c3cf44915f38fc15af471bd12b.png](https://i-blog.csdnimg.cn/blog_migrate/ab41d16f635dd2d993266fd09426adf4.jpeg)
在RISCV的约定中,当前子过程的参数在当前子过程的的帧底,而为下一个子过程准备的参数在sp的更低位置处。这里需要注意的问题是:如果当前过程需要为下一个过程准备参数,虽然看起来下一个过程的参数在当前栈帧的外面(因为下一个子过程的参数被放在sp的更低位),这部分多出来的参数需要的空间也需要考虑,防止栈溢出。
![3237190eea7e14f257b19543951a08e1.png](https://i-blog.csdnimg.cn/blog_migrate/fd4d3ab10b09e4f85de56b143f835c0e.png)
关于fp (frame pointer)
“帧指针fp($30)并不是必须的,而且实际应用中其实很少使用,除了栈指针sp会在运行时变化,如调用alloca()”——wiki page on "calling convention".
为什么要使用帧指针fp
通常情况下只需要sp来管理栈即可,但有些情况下sp在子过程运行时可能会改变,比如如下的函数中,alloca()
函数会改变sp指针,在栈中开放新的空间为函数使用,但其并不总是会被调用,且sp改变的量也不能确定,程序不能预测sp的变化。当这一点发生时,就非常危险了:我们已经不知道sp跑到哪里去了,那怎么去将栈中保存的关键内容弹出呢?
int add_chaos(int go,int n){ //a subroutine that could change sp at runtime
int* pointer;
int ret;
if (go==1) {
pointer=alloca(4*n); //allocate n new words in the stack
}
ret=foo();
return ret;
}
这种情况下必须要使用帧指针fp。帧指针在调用过程中起着锚定的作用,在子过程被调用时,把旧的fp压栈,再将fp指向新产生的栈帧的一个固定位置,这样当前栈帧中就有了fp当做一个哨兵,其承担两个关键任务:
- 不管sp怎么变,我们只要知道栈中保存的关键量(如ra,fp,s0...等等)相对于fp的偏移,就可以顺利将他们弹出,保证了程序的安全。
- 如果sp在子过程为自己分配了栈空间后又发生了变化(如使用
allca()
),那么在子过程返回前,fp还需要帮sp恢复原值(sp恢复原值也就是释放了当前子过程占用的栈空间)
帧指针fp的位置
前面大概说明了为什么要使用fp,现在来关注子过程为自己分配栈空间时(during prologue of the subroutine),fp应该怎么设置,这里结合一个实例来说明.
;一个mips叶子过程(leaf routine)的汇编
main:
addi $sp,$sp,-16 ;栈指针减16,指向帧顶,分配4个word的空间
;因为是叶过程,我们调整sp时没有给传出参数(outgoing args)留空间
sw $ra,12($sp) ;旧的$ra放在帧底
;尽管是叶过程,我们仍然保存$ra以更好的展示...
sw $fp, 8($sp) ;旧的$fp放在$ra上面
;a'ight ya know what im gonna say...
sw $s2, 4($sp) ;保存s2
sw $s1, 0($sp) ;...
addi $fp, $sp, 12 ;调整$fp指向帧底
...
...
lw $ra, 12($sp) ;$ra弹出
lw $fp, 8($sp) ;$fp弹出
lw $s2, 4($sp) ;...
lw $s1, 0($sp) ;...
addi $sp, $sp, 16 ;$sp复原
jr $ra
以上的实例是一个按照大部分mips资料中关于$fp设置方法的内容 (包括参考[2],[3])写出的一个非常基本的mips子过程,关键点在注释中给出. 可以看到,按照大多数资料描述的方式,$fp
指向帧底,$sp
指向帧顶(即栈顶),这这样的描述很自然,其栈布局(stack layout)如下图所示。
![75fd97d54efaf65b2e79e609f306e137.gif](https://i-blog.csdnimg.cn/blog_migrate/a156144cd67ec00e4deb04987cd56a3a.gif)
MIPS Stack Frame
但是并非所有编译器都采用这种形式,比如GCC编译器会生成如下的汇编代码(mips):
main:
addi $sp,$sp,-16
sw $ra,12($sp)
sw $fp,8($sp)
sw $s2, 4($sp)
sw $s1, 0($sp)
move $fp,$sp ;$fp指向$sp第一次调整后的位置
注意到GCC会把$fp
指向$sp
第一次调整后的位置,$sp
和之前一样指向栈顶,GCC的栈布局如下图所示。事实上具体的栈布局的区别并不会带来很大的影响,只要保证该保存的寄存器都按照调用约定保存,参数按照上文所述的约定传递,不同的子过程间的调用就不会出现问题。
![79d9418c7444b1672f78363a8f08b36b.png](https://i-blog.csdnimg.cn/blog_migrate/310c6935114fe9ff239a1a45c5708fb8.png)
GCC stack frame
关于帧指针fp的总结
- fp是可选的,我们编写子过程不一定要使用它,实际上,许多程序都将其优化掉了(使用fp会付出额外的代价)
- 在一个子过程的栈帧中fp到底指向哪/用不用,并不影响我们的“调用约定”。子过程需要保证的只是,在自己退出后,fp和该子过程被调用前没有区别。
- 注意MIPS和RISCV中,fp都是callee-saved register,所以子过程无论如何都有义务保证fp对caller不变.两种方法可以保证这一点:1. 不使用fp 2. 改变fp之前将其压栈
参考:
- Cornell CS3410 slide10:Calling Conventionshttp://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/slides/10-calling-notes-bw.pdf
- UCSB CS64 resourceshttps://sites.cs.ucsb.edu/~franklin/64/resources/spim/BookCallConvention.htm
- Wiki page on "Calling convention"https://en.wikipedia.org/wiki/Calling_convention
- Washington University CSE410 slidehttps://courses.cs.washington.edu/courses/cse410/09sp/examples/MIPSCallingConventionsSummary.pdf