Lec 5 Calling conventions and stack frames RISC-V (TA)
参考链接:
Calling Convention
在RV32和RV64 C语言编译器中,C语言类型int都是32位宽。longs 和 pointers,都和一整个整数寄存器一样大,所以在RV32是32位,在RV64是64位。
RV32采用的是ILP32整数模型,而RV64是LP64。
在RV32和RV64中, long long 是64位,float是一个32位的IEEE 754-2008浮点数,double是一个64位的IEEE 754-2008浮点数,long double是一个128位的IEEE浮点数
C类型的char和unsigned char是8位无符号整数,在RISC-V整数寄存器中存储时是零扩展的。unsigned short是16位无符号整数,在RISC-V整数寄存器中存储时是零扩展的。
在RV64中,32位类型,如int,被存储在整数寄存器中作为其32位值的适当符号扩展;也就是说,第63...31位都是相等的。这个限制甚至对无符号的32位类型也是如此。
RV32和RV64 C语言编译器和兼容软件在存储在内存中时保持所有上述数据类型的自然对齐。
RISC-V的调用惯例在可能的情况下用寄存器传递参数。最多八个整数寄存器(a0-a7)和最多八个浮点寄存器(fa0-fa7)用于此目的。
如果函数的参数被概念化为C struct的字段,每个字段都有指针对齐,那么参数寄存器就是该结构前八个指针字的影子。如果参数i < 8是一个浮点类型,它将被传递到浮点寄存器fai;否则,它将被传递到整数寄存器ai。然而,作为结构的联合或数组字段的一部分的浮点参数是在整数寄存器中传递的。此外,变量函数的浮点参数(除了那些在参数列表中明确命名的参数)是在整数寄存器中传递的。 小于一个指针字的参数在参数寄存器的最小有效位中传递。
相应地,在堆栈中传递的子指针字参数出现在指针字的低位地址中,因为RISC-V有一个小字节的内存系统。
当两倍于指针字大小的原始参数被传递到堆栈上时,它们自然被对齐。当它们被传递到整数寄存器中时,它们驻留在一个对齐的偶数寄存器对中,偶数寄存器持有最不重要的位。例如,在RV32中,函数void foo(int, long long)在a0中传递第一个参数,在a2和a3中传递第二个参数。在a1中没有任何东西被传递。
超过指针字大小两倍的参数是通过引用传递的。
conceptual struct中没有在参数寄存器中传递的部分被传递到堆栈中。堆栈指针sp指向未在寄存器中传递的第一个参数。
从函数中返回的值是整数寄存器a0和a1以及浮点寄存器fa0和fa1。只有当浮点值是基元或只由一个或两个浮点值组成的struct的成员时,才在浮点寄存器中返回。其他适合两个指针字的返回值会在a0和a1中返回。较大的返回值完全在内存中传递;调用者分配这个内存区域,并将它的指针作为隐含的第一个参数传递给被调用者。
在标准的RISC-V调用惯例中,堆栈是向下增长的,堆栈指针总是保持16字节对齐。
除了参数和返回值寄存器外,7个整数寄存器t0-t6和12个浮点寄存器ft0-ft11是临时寄存器,在调用过程中是不稳定的,如果以后使用,必须由调用者保存。
12个整数寄存器s0-s11和12个浮点寄存器fs0-fs11在不同的调用中保留,如果使用必须由被调用者保存。表18.2指出了每个整数和浮点寄存器在调用约定中的作用。
https://pdos.csail.mit.edu/6.828/2020/readings/riscv-calling.pdf
软浮点(soft-float)调用惯例用于缺乏浮点硬件的RV32和RV64实现。它避免使用F、D和Q标准扩展中的所有指令,因此也避免使用f寄存器。
整数参数的传递和返回方式与RVG惯例相同,堆栈纪律也是如此。浮点参数在整数寄存器中传递和返回,使用相同大小的整数参数的规则。例如,在RV32中,函数double foo(int, double, long double)的第一个参数在a0中传递,第二个参数在a2和a3中传递,第三个参数通过a4引用;其结果在a0和a1中返回。在RV64中,参数在a0、a1和a2-a3对中传递,结果在a0中返回。动态四舍五入模式和累积异常标志通过C99头文件fenv.h提供的例程访问。
5.1 C程序到汇编程序的转换
处理器并不能理解C语言。处理器能够理解的是汇编语言,或者更具体的说,处理器能够理解的是二进制编码之后的汇编代码。
ISA就是处理器能够理解的指令集。每一条指令都有一个对应的二进制编码或者一个Opcode。当处理器在运行时,如果看见了这些编码,那么处理器就知道该做什么样的操作。
当我们说到一个RISC-V处理器时,意味着这个处理器能够理解RISC-V的指令集。所以,任何一个处理器都有一个关联的ISA(Instruction Sets Architecture),ISA就是处理器能够理解的指令集。每一条指令都有一个对应的二进制编码或者一个Opcode。当处理器在运行时,如果看见了这些编码,那么处理器就知道该做什么样的操作。
所以通常来说,要让C语言能够运行在你的处理器之上。我们首先要写出C程序,之后这个C程序需要被编译成汇编语言。这个过程中有一些链接和其他的步骤,但是因为这门课不是一个编译器的课程,所以我们忽略这些步骤。之后汇编语言会被翻译成二进制文件也就是.obj或者.o文件。
如果你们曾经注意过你们的lab目录,在运行完make qemu之后你会看到一些.o文件,这些就是处理器能够理解的文件。虽然你还没有写任何汇编程序,你们也可以在目录中看到一些.asm文件,这是由C语言编译生成的。如果你们学过了6.004,那么你们必然已经看过一些汇编语言。
5.2 RISC-V vs x86
汇编语言有很多种(注,因为不同的处理器指令集不一样,而汇编语言中都是一条条指令,所以不同处理器对应的汇编语言必然不一样)。如果你使用RISC-V,你不太能将Linux运行在上面。相应的,大多数现代计算机都运行在x86和x86-64处理器上。x86拥有一套不同的指令集,看起来与RISC-V非常相似。通常你们的个人电脑上运行的处理器是x86,Intel和AMD的CPU都实现了x86。
RISC-V中的RISC是精简指令集(Reduced Instruction Set Computer)的意思,而x86通常被称为CISC,复杂指令集(Complex Instruction Set Computer)。这两者之间有一些关键的区别:
首先是指令的数量。实际上,创造RISC-V的一个非常大的初衷就是因为Intel手册中指令数量太多了。x86-64指令介绍由3个文档组成,并且新的指令以每个月3条的速度在增加。因为x86-64是在1970年代发布的,所以我认为现在有多于15000条指令。RISC-V指令介绍由两个文档组成。在这节课中,不需要你们记住每一个RISC-V指令,但是如果你感兴趣或者你发现你不能理解某个具体的指令的话,在课程网站的参考页面有RISC-V指令的两个文档链接。这两个文档包含了RISC-V的指令集的所有信息,分别是240页和135页,相比x86的指令集文档要小得多的多。这是有关RISC-V比较好的一个方面。所以在RISC-V中,我们有更少的指令数量。
除此之外,RISC-V指令也更加简单。在x86-64中,很多指令都做了不止一件事情。这些指令中的每一条都执行了一系列复杂的操作并返回结果。但是RISC-V不会这样做,RISC-V的指令趋向于完成更简单的工作,相应的也消耗更少的CPU执行时间。这其实是设计人员的在底层设计时的取舍。并没有一些非常确定的原因说RISC比CISC更好。它们各自有各自的使用场景。
相比x86来说,RISC另一件有意思的事情是它是开源的。这是市场上唯一的一款开源指令集,这意味着任何人都可以为RISC-V开发主板。RISC-V是来自于UC-Berkly的一个研究项目,之后被大量的公司选中并做了支持,网上有这些公司的名单,许多大公司对于支持一个开源指令集都感兴趣。
在最近几年,由于Intel的指令集是在是太大了,精简指令集的使用越来越多。Intel的指令集之所以这么大,是因为Intel对于向后兼容非常看重。所以一个现代的Intel处理器还可以运行30/40年前的指令。Intel并没有下线任何指令。而RISC-V提出的更晚,所以不存在历史包袱的问题。
5.3 RISC-V寄存器
这个表里面是RISC-V寄存器。寄存器是CPU或者处理器上,预先定义的可以用来存储数据的位置。寄存器之所以重要是因为汇编代码并不是在内存上执行,而是在寄存器上执行,也就是说,当我们在做add,sub时,我们是对寄存器进行操作。所以你们通常看到的汇编代码中的模式是,我们通过load将数据存放在寄存器中,这里的数据源可以是来自内存,也可以来自另一个寄存器。之后我们在寄存器上执行一些操作。如果我们对操作的结果关心的话,我们会将操作的结果store在某个地方。这里的目的地可能是内存中的某个地址,也可能是另一个寄存器。这就是通常使用寄存器的方法。
寄存器是用来进行任何运算和数据读取的最快的方式,这就是为什么使用它们很重要,也是为什么我们更喜欢使用寄存器而不是内存。当我们调用函数时,你可以看到这里有a0 - a7寄存器。通常我们在谈到寄存器的时候,我们会用它们的ABI名字。不仅是因为这样描述更清晰和标准,同时也因为在写汇编代码的时候使用的也是ABI名字。第一列中的寄存器名字并不是超级重要,它唯一重要的场景是在RISC-V的Compressed Instruction中。基本上来说,RISC-V中通常的指令是64bit,但是在Compressed Instruction(压缩指令)中指令是16bit。在Compressed Instruction中我们使用更少的寄存器,也就是x8 - x15寄存器。我猜你们可能会有疑问,为什么s1寄存器和其他的s寄存器是分开的,因为s1在Compressed Instruction是有效的,而s2-11却不是。除了Compressed Instruction,寄存器都是通过它们的ABI名字来引用。
a0到a7寄存器是用来作为函数的参数。如果一个函数有超过8个参数,我们就需要用内存了。从这里也可以看出,当可以使用寄存器的时候,我们不会使用内存,我们只在不得不使用内存的场景才使用它。
表单中的第4列,Saver列,当我们在讨论寄存器的时候也非常重要。它有两个可能的值Caller,Callee。最简单的记住它们的方法是:
Caller Saved寄存器在函数调用的时候不会保存
Callee Saved寄存器在函数调用的时候会保存
这里的意思是,一个Caller Saved寄存器可能被其他函数重写。假设我们在函数a中调用函数b,任何被函数a使用的并且是Caller Saved寄存器,调用函数b可能重写这些寄存器。我认为一个比较好的例子就是Return address寄存器(注,保存的是函数返回的地址),你可以看到ra寄存器是Caller Saved,这一点很重要,它导致了当函数a调用函数b的时侯,b会重写Return address。所以基本上来说,任何一个Caller Saved寄存器,作为调用方的函数要小心可能的数据可能的变化;任何一个Callee Saved寄存器,作为被调用方的函数要小心寄存器的值不会相应的变化。我经常会弄混这两者的区别,然后会到这张表来回顾它们。
如果你们还记得的话,所有的寄存器都是64bit,各种各样的数据类型都会被改造的可以放进这64bit中。比如说我们有一个32bit的整数,取决于整数是不是有符号的,会通过在前面补32个0或者1来使得这个整数变成64bit并存在这些寄存器中。
5.4 Stack
栈之所以很重要的原因是,它使得我们的函数变得有组织,且能够正常返回。
下面是一个非常简单的栈的结构图,其中每一个区域都是一个Stack Frame,每执行一次函数调用就会产生一个Stack Frame。
每一次我们调用一个函数,函数都会为自己创建一个Stack Frame,并且只给自己用。函数通过移动Stack Pointer来完成Stack Frame的空间分配。
对于Stack来说,是从高地址开始向低地址使用。所以栈总是向下增长。当我们想要创建一个新的Stack Frame的时候,总是对当前的Stack Pointer做减法。一个函数的Stack Frame包含了保存的寄存器,本地变量,并且,如果函数的参数多于8个,额外的参数会出现在Stack中。所以Stack Frame大小并不总是一样,即使在这个图里面看起来是一样大的。不同的函数有不同数量的本地变量,不同的寄存器,所以Stack Frame的大小是不一样的。但是有关Stack Frame有两件事情是确定的:
Return address总是会出现在Stack Frame的第一位
指向前一个Stack Frame的指针也会出现在栈中的固定位置
有关Stack Frame中有两个重要的寄存器,第一个是SP(Stack Pointer),它指向Stack的底部并代表了当前Stack Frame的位置。第二个是FP(Frame Pointer),它指向当前Stack Frame的顶部。因为Return address和指向前一个Stack Frame的的指针都在当前Stack Frame的固定位置,所以可以通过当前的FP寄存器寻址到这两个数据。
我们保存前一个Stack Frame的指针的原因是为了让我们能跳转回去。所以当前函数返回时,我们可以将前一个Frame Pointer存储到FP寄存器中。所以我们使用Frame Pointer来操纵我们的Stack Frames,并确保我们总是指向正确的函数。
5.5 struct
基本上来说,struct在内存中是一段连续的地址,如果我们有一个struct,并且有f1,f2,f3三个字段。
当我们创建这样一个struct时,内存中相应的字段会彼此相邻。你可以认为struct像是一个数组,但是里面的不同字段的类型可以不一样。
Last updated