这节课的目标是让你们熟悉RISC-V处理器,汇编语言,以及RISC-V的calling convention。对于这周要发布的traps lab来说,这些内容至关重要,因为在这个实验中你们将会频繁用到 trapframe 和栈
存在零扩展和符号扩展现象。符合这些标准的数据结构在内存中将自然对齐
最多8个整数寄存器和8个浮点数寄存器,更多的参数需要通过堆栈
小于指针字的参数通过最低有效位传递给寄存器,在堆栈上为小端存储
两倍指针字长:自然对齐,它们驻留在对齐的偶数-奇数寄存器对中,偶数寄存器放最低有效位
例如,在 RV32 中,函数 void foo(int, long long) 的第一个参数通过 a0 传递,第二个参数通过 a2 和 a3 传递。a1 中不会传递任何内容。
整数寄存器 a0 和 a1 以及浮点寄存器 fa0 和 fa1
更大的值分配内存(堆还是栈?返回指针?)
七个整数寄存器 t0
到 t6
和十二个浮点寄存器 ft0
到 ft11
是临时寄存器,在函数调用之间是易失的,需要由 调用者保存
十二个整数寄存器 s0
到 s11
和十二个浮点寄存器 fs0
到 fs11
是在函数调用之间 保持不变 的,如果被调用者需要使用这些寄存器,必须由 被调用者保存
对于 没有浮点硬件 的机器,将使用软浮点调用约定,避免使用任何 F, D, and Q standard extensions,也不使用 f 寄存器
整数参数的传递方式与 RVG 调用约定相同,浮点参数则使用整数寄存器传递和返回,遵循与相同大小的整数参数相同的规则
例如,在 RV32 中,函数
double foo(int, double, long double)
的第一个参数通过a0
传递,第二个参数通过a2
和a3
传递,第三个参数通过a4
以引用方式传递;返回值在a0
和a1
中返回。在 RV64 中,参数通过a0
、a1
和a2-a3
对传递,返回值在a0
中返回。
浮点数的动态舍入模式、异常 flag 根据 fenv.h
头文件中的例程来进行访问和管理
高级语言–汇编语言–二进制指令
一个处理器可以理解特定的二进制指令,将这些指令的集合称为指令集,任何一个处理器都有一个关联的 ISA(Instruction Sets Architecture)。每一条指令都有其关联的二进制编码,或 Opcode。
要让处理器理解你的语言,必须将它翻译为二进制指令
C经过编译、汇编、链接会变为 .obj或者.o文件。
不具备C语言的组织结构,在汇编语言中你只能看到一行行的指令,比如add,mult等等
没有很好的控制流程,没有循环,但是有基于lable的跳转
汇编语言中的函数是以label的形式存在而不是真正的函数定义
不同机器的指令集不同,对应的汇编语言也就不同。
编译器通常是第三方开发的,而非指令集的创建者
RISC-V 机器对应的汇编语言为 RISC-V汇编。
如果你使用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中,很多指令都做了不止一件事情。但是RISC-V不会这样做,RISC-V的指令趋向于完成更简单的工作,相应的也消耗更少的CPU执行时间。
开源。这是来自于UC-Berkly的一个研究项目,是市场上唯一的一款开源指令集,这意味着任何人都可以为RISC-V开发主板。
在你们的日常生活中,你们可能已经在完全不知情的情况下使用了精简指令集。比如说ARM也是一个精简指令集,高通的Snapdragon处理器就是基于ARM。对于Mac,苹果公司也在尝试向ARM做迁移。如果你想在现实世界中找到RISC-V处理器,你可以在一些嵌入式设备中找到。所以RISC-V也是有应用的,当然它可能没有x86那么流行。
Intel的指令集之所以这么大,是因为Intel对于向后兼容非常看重。
同时,Intel在它的处理器里面做了一些有意思的事情,例如安全相关的enclave,这是Intel最近加到处理器中来提升安全性的功能。
此外,Intel还实现了一些非常具体的指令,这些指令可以非常高效的进行一些特定的运算。通常来说对于一个场景都会有一个完美的指令。
如果查看RISC-V的文档,可以发现 RISC-V的特殊之处在于:它区分了Base Integer Instruction Set和Standard Extension Instruction Set。
这种模式使得RISC-V更容易支持向后兼容。 每一个RISC-V处理器可以声明支持了哪些扩展指令集,然后编译器可以根据支持的指令集来编译代码。
如果你在你自己的计算机编写同样的C代码并编译,你得到的极有可能是差别较大的汇编代码。这里有很多原因,有一些原因我们之后会讲,有一些原因是因为编译器。
当将C代码编译成汇编代码时,现代的 编译器会执行各种各样的优化,编译器可能决定不使用代码中的某个变量,或执行顺序不同,但保证最终的效果是相同的
上面的 global表示你可以在其他文件中调用这个函数。
text表明这里的是代码。
文件中每一行左边的数字表明的是这条指令会在内存中的哪个位置,这个信息非常有用。
在汇编代码中还可以看到函数对应的label,以及它们是在哪里定义的。如:
我并不是百分百确定。这两类文件都是汇编代码,.asm文件中包含大量额外的标注,而.s文件中没有。所以通常来说当你编译你的C代码,你得到的是.s文件。如果你好奇我们是如何得到.asm文件,makefile里面包含了具体的步骤。
1 | make qemu-gdb |
1 | riscv64-unknown-elf-gdb |
我的 tui、layout 无法使用,还是只看源码吧。打一个断点:
根据这个地址可以从反汇编文件找到 main 函数的第一条指令:
其他略过
即课前准备的内容:
汇编代码并不是在内存上执行,而是在寄存器上执行。寄存器是用来进行任何运算和数据读取的最快的方式
通常我们在谈到寄存器的时候,我们会用它们的ABI名字。不仅是因为这样描述更清晰和标准,同时也因为在写汇编代码的时候使用的也是ABI名字。
第一列中的寄存器名字并不是超级重要,它唯一重要的场景是在RISC-V的Compressed Instruction中。
我们通过load将数据存放在寄存器中,这里的数据源可以是来自内存,也可以来自另一个寄存器。
之后我们在寄存器上执行一些操作。
如果我们对操作的结果关心的话,我们会将操作的结果store在某个地方。这里的目的地可能是内存中的某个地址,也可能是另一个寄存器。
基本上来说,RISC-V中通常的指令是64bit,但是在Compressed Instruction中指令是16bit。
在Compressed Instruction中我们 使用更少的寄存器,也就是x8 - x15寄存器。
我猜你们可能会有疑问,为什么s1寄存器和其他的s寄存器是分开的,因为s1在 Compressed Instruction 是有效的,而s2-11却不是。
a0到a7寄存器是用来作为函数的参数。如果一个函数有超过8个参数,我们就需要用内存了
从这里也可以看出,当可以使用寄存器的时候,我们不会使用内存,我们只在不得不使用内存的场景才使用它。
更多的内容请见课前准备
基本上来说,任何一个Caller Saved寄存器,作为调用方的函数要小心可能的数据可能的变化;任何一个Callee Saved寄存器,作为被调用方的函数要小心寄存器的值不会相应的变化。
学生提问:除了Stack Pointer和Frame Pointer,我不认为我们需要更多的Callee Saved寄存器。
TA:s0 - s11都是Callee寄存器,我认为它们是 提供给编译器 而不是程序员使用。在一些特定的场景下,你会想要确保一些数据在函数调用之后仍然能够保存,这个时候编译器可以选择使用s寄存器。
大小小于寄存器大小的数据,基于它有符号或无符号,进行相应的扩展
接下来我们讨论一下栈,stack。栈之所以很重要的原因是,它使得我们的函数变得有组织,且能够正常返回。
每次函数调用都会产生一个 Stack Frame,并且只给函数自己使用。
通过移动Stack Pointer来完成Stack Frame的 空间分配。
从高地址开始向低地址使用。所以栈总是向下增长。空间分配也就得做减法
一个函数的Stack Frame包含了保存的寄存器,本地变量,并且,如果函数的 参数 多于8个,额外的参数会出现在Stack中。
Stack Frame大小并不总是一样,但是有关Stack Frame有两件事情是确定的:
第一个是SP(Stack Pointer),它指向Stack的底部并代表了当前Stack Frame的位置。第二个是FP(Frame Pointer),它指向当前Stack Frame的顶部。
我们可以通过 FP 找到指令的 Return address 和上一个 Stack Frame 的指针
所以当前函数返回时,我们需要将前一个Frame Pointer存储到FP寄存器中
Stack Frame必须要被汇编代码创建。编译器生成了汇编代码,进而创建了Stack Frame。
所以通常,在汇编代码中,函数的最开始你们可以看到 Function prologue,之后是函数的本体,最后是Epilogue。这就是一个汇编函数通常的样子。
有些足够简单的函数不需要用到栈,不调用别的函数,它只有函数主体,并没有Stack Frame的内容。
它不调用别的函数,所以 不需要保存自己的 Return address 或者任何其他的Caller Saved寄存器,但可能仍需保存 Callee Saved寄存器
叶函数必定返回到上一个 Stack Frame,所以 FP 寄存器不会变化,也无需保存。RA 是调用者保存的,叶函数也无需保存
这个函数调用了其他函数,需要 prologue
可以看到在 sum_then_double 的 prologue 中,我们为它的 Stack Frame 分配了空间,并在 sp 处保存了自己的返回地址
在 epilogue 中,我们加载了返回地址,并释放了栈空间。最后 ret
如果我们删除掉Prologue 和 Epilogue,sum_then_double 将不知道自己该返回到哪里。
call 会把 sum_to 的返回地址加载到ra中,所以会覆盖掉 sum_then_double 的 ra。
在 sum_then_double 返回时,他将陷入一个无限循环,始终返回到 li t0, 2
可以通过 info frame 查看一些信息,包含 ra、fp、pc 等:
如果过对调用栈中的某个 stack frame 感兴趣,可以先定位到那个frame再输入info frame: