You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

84 KiB

第一章RISC-V体系结构

目录

本章的1.1节将简单介绍RISC-V的诞生和发展历史其后将介绍和讨论RISC-V体系结构的各个方面如1.2节介绍汇编语言、1.3节讨论机器状态、1.4节分析中断和中断处理机制、1.5节讨论分页机制。最后在1.6节介绍PKE实验涉及的工具软件以及它们的使用方法。值得注意的是RISC-V的体系结构本身就是一个庞大的知识体系实际上是很难用一章的内容完全讲清楚的我们鼓励对该体系结构感兴趣的读者延申阅读参考文献开源指令集的指南以及RISC-V instruction set manual。然而不懂体系结构又无法理解操作系统和开展PKE的实验所以本章将重点阐述和讨论与操作系统设计有关的知识内容力求精简和突出重点。

1.1 RISC-V发展历史

RISC-V读做“risk-five”是一种典型的精简Reduced Instruction Set Computer简写为RISC指令集Instruction Set Architecture简写为ISA它是加利福尼亚大学伯克利分校的David Patterson教授与Krste Asanovic教授研究团队于2010年提出的一个开放指令集架构。名称中的“V”指的是该ISA的第五个版本有时“V”也被解读为向量Vector扩展。与之前的诸多商用指令集如x86、ARM以及MIPS等不同的是RISC-V是一个开放指令集它的提出受到了全球工业界和学术界的广泛关注。在我国RISC-V的发展、研究和应用同样得到了广泛关注在网信办、工信部、中科院等多个国家部委支持和指导下中国开放指令生态RISC-V联盟于2018年11月8日浙江乌镇举行的第五届互联网大会上正式宣布成立。

相比于传统的商用指令集架构RISC-V指令集以及其背后的计算机架构具有以下优点

后发优势。RISC-V指令集的提出是借鉴了之前的传统商业指令集通过取长补短而得到的。相比于之前的指令集RISC-V避免了很多前人踩过的非常多的坑。

开放架构。RISC-V指令集是一个开放的指令集意味着任何组织或个人都可以自由地设计硬件以支撑采用RISC-V指令所编写的代码而不会被起诉或收到法院传票。同时对该指令集的使用不受美国的贸易管制约束。需要指出的是RISC-V指令是开放的但采用该指令集的具体设计IP确是受到版权保护的这样做的目的是在开放的前提下鼓励创新和保护开发者的商业利益。

场景丰富。RISC-V指令集的设计综合考虑了多种应用场景涵盖了从嵌入式低功耗应用到高性能服务器场景为各种场景考虑并设计了多个不同但指令上接近的分支。在这一点上RISC-V跟ARM类似针对不同应用场景RISC-V也都有对应版本。

开源参考。自RISC-V提出以来工业界和学术界都积极开展了对其的应用和研究出现了大量的开源实现参见RISC-V开源项目列表。这些开源实现为具体应用领域的开发者提供了大量可借鉴的参考同时也极大地降低了CPU的设计门槛。可以预见的是随着大量基于该指令集的芯片开源项目的诞生RISC-V的发展将像开源软件生态中的Linux那样成为计算机芯片与系统领域创新的基石。

在本书所设计的实验中我们主要考虑通用计算场景所以选择RV64G作为我们目标计算机的指令集。其中RV64G中的“RV”代表RISC-V“64”代表所支持的指令是64位的实际上交叉编译器在生成代码时可能会采用32位指令减小生成的目标代码长度地址长度和寄存器长度都为64位而“G”代表通用general计算平台。实际上“G”等效于“IMAFD”其中“I”代表整数Integer计算指令、整数load、整数store以及控制流如分支跳转指令这些指令在任何RISC-V的实现中都是必须的“M”代表乘法Multiply即平台支持乘法和除法运算“A”代表原子Atomic扩展支持对寄存器进行的原子读、修改和原子写操作这些操作在多核设计中非常有用“F”代表单精度浮点Float运算支持“D”代表双精度浮点Double运算支持。

实际上按照RISC-V的指令集规范RISC-V的计算机可以设计为更低位数如32位甚至16位也可以设计为更高位数如128位这些都取决于所设计的计算机所面向的目标领域。一般说来如果计算机面向的目标领域是嵌入式领域32位甚至16位就已经足够如果目标领域为桌面的办公环境或者手机64位应已足够但如果目标领域为服务器则可能128位的指令架构才是合适的。更高的指令集位数往往意味着更大规模的应用程序更大的逻辑地址空间以及管理更大规模的内存注意并不意味着更快的速度。另外指令集本身也是可以裁剪的例如在为嵌入式环境设置的单核处理器上我们可以只保留“IMF”而不支持“AD”从而降低处理器设计的复杂度以及能耗。

考虑到今天64位电脑x86_64已广泛普及到桌面、服务器我们通常接触到的操作系统如Ubuntu、Windows10等都是内置的64位编译器编译出来的二进制代码特别是我们将要用到的RISC-V模拟器也都默认是64位我们将PKE实验的目标平台类型设定为桌面电脑采用通用版本RV64G

1.2 RISC-V汇编语言

关于RISC-V汇编语言较好的中文参考资料有《RISC-V手册——一本开源指令集的指南》参见开源指令集的指南里面完整列举和解释了RISC-V的所有汇编指令感兴趣的读者可以通过阅读该书了解RISC-V汇编。在这里我们不打算详细讨论RISC-V的汇编知识只重点讨论RISC-V汇编与8086汇编的不同以及PKE的代码涉及到的一些汇编语法和指令。

1.2.1 寄存器

表1.1列出了采用RV64G指令集的RISC-V计算机中的32个通用寄存器。

表1.1 RV64G的32个通用寄存器寄存器的宽度都是64位

寄存器 编程接口名称 ABI 描述 使用
x0 zero Hard-wired zero 硬件零
x1 ra Return address 常用于保存(函数的)返回地址
x2 sp Stack pointer 栈顶指针
x3 gp Global pointer
x4 tp Thread pointer
x5-7 t0-2 Temporary 临时寄存器
x8 s0/fp Saved Register/ Frame pointer (函数调用时)保存的寄存器和栈顶指针
x9 s1 Saved register (函数调用时)保存的寄存器
x10-11 a0-1 Function argument/ return value (函数调用时)的参数/函数的返回值
x12-17 a2-7 Function argument (函数调用时)的参数
x18-27 s2-11 Saved register (函数调用时)保存的寄存器
x28-31 t3-6 Temporary 临时寄存器

需要注意的是同一个通用寄存器可以用不同的名字来对它进行访问。例如x0和zero对应的是同一个寄存器且该寄存器是一个硬件零hardware zero也就是从该寄存器中读出来的数据永远是零。x8、s0、fp这些符号对应的也是同一个寄存器。一般来说汇编语言在书写时往往采用表1.1中的编程接口名称ABI来实现对寄存器的访问以提高汇编代码的可读性。

除了表1.1中的通用寄存器外RV64G处理器中还有一个重要的寄存器program counterpc。它也是一个64位寄存器但对它的读取需要采用特殊指令如auipc指令进行。另外RISC-V计算机为这些通用寄存器赋予了不同的用途。例如spstack pointer寄存器用于指向栈顶准确地讲是栈的低地址端。相应地我们将栈的高物理地址端称为栈底fpframe pointer寄存器则会在发生函数调用时被赋值为sp的内容并压栈字母a开始argument register的寄存器常用于函数调用时的参数以及返回值的传递字母s开始save register的寄存器用于在发生函数调用时保存重要计算结果字母t开始temporary register的寄存器则往往用于不重要的中间结果的保存。需要注意的是在RISC-V计算机中除了硬件零寄存器zero、栈顶指针sp、函数返回地址ra外其余寄存器的使用实际上取决于汇编程序员或者编译器如我们将要用到的RISC-V版本的GCC交叉编译器。例如对于s开始的寄存器虽然RISC-V规范要求在调用函数的时候由被调用的函数将它们都保存到栈中但实际的函数代码为了追求更高的速度往往只保存被函数使用过的寄存器。这一点我们将在1.2.5节的例子中给予展示。

不同于8086RISC-V中无法对“半个”寄存器进行直接访问例如AL之于AX指令始终需要访问完整的寄存器。另外对于栈的访问RISC-V处理器也并未提供类似push或者pop等指令来完成而是采用load/store来实现对内存中栈的访问。

1.2.2 指令格式

图1.1列出了RV64IRV64G的整数指令子集的常用基础指令。

fig1_1

图1.1 RV64I的常用基础指令带下划线的粗体字母从左到右连起来构成RV64I指令。

从图1.1中我们看到RV64I的常用基础指令并不多分为整数指令Integer computation、访存指令Load and Store和控制转移指令即跳转指令Control transfer每条指令的名称取图中下划线和标黑的字母构成。例如最基础的加法指令为add如果累加的其中一个操作数为立即数则指令为addi。实际上除了列出的指令外RV64I还包括很多其他的指令例如用于机器状态控制的指令、内存墙指令等。我们将在1.4节中讨论机器状态控制指令在1.5节讨论与虚拟存储相关的指令。

RISC-V汇编指令的格式与8086汇编是截然不同的。以最基础的加法指令为例它的指令格式以加法指令add为例

add rd, rs1, rs2

或者

addi rd, rs1, immediate

其中rd代表目标寄存器register destinationrs1和rs2分别代表源寄存器1register source和源寄存器2immediate代表立即数。前一条指令的执行效果是将两个源寄存器rs1和rs2中所存储的值进行求和并将结果写到rd寄存器后一条指令的执行将源寄存器1中的值与立即数相加结果写到rd寄存器忽略算术溢出。需要注意的是由于我们采用了64位指令集意味着以上指令中所有的操作数无论是rd还是rs中的值以及immediate都是64位的。如果immediate不足64位则对其进行符号位最高位扩展到64位。如果要操作更小一点的数则需要用到addw它最后的w符号表示操作数为一个字32位

以上我们只是以add汇编指令作为例子举例说明了RISC-V汇编语言。读者会发现跟8086汇编语言相比RISC-V汇编语言在语法上有着根本的区别源和目的寄存器的位置是不一样的。由于本书是操作系统课程的实验指导无法完整地列举和解释所有RISC-V汇编指令我们强烈建议读者继续阅读参考文献中的开源指令集的指南进一步了解RISC-V汇编语言。

1.2.3访存和寻址模式

在内存访问上RISC-V提供的接口相比于8086汇编极其简单读内存load和写内存store。而且所有其他指令都只与寄存器打交道不能使用任何间接形式对内存进行访问。这样设计的好处是避免了8086汇编里令人“眼花缭乱”的访存寻址模式这也是RISC-V的重要“后发优势”之一。

我们以ldload指令和swstore word指令为例来说明RISC-V的访存指令以下是ld指令的语法

ld rd, offset(rs1)

执行ld指令的动作是将立即数offset进行符号扩展与源寄存器rs1中的值进行求和得到地址A然后读出内存中A所对应地址的8个字节64位写到目的寄存器rd中。

以下是sw指令的语法

sw rs2, offset(rs1)

它的作用是将立即数offset进行符号扩展与源寄存器rs1中的值进行求和得到地址A然后将源寄存器rs2中的低位4字节注意不是8个字节因为操纵的是word即32位4字节写到内存中以地址A开始的4个字节中。当RISC-V处理器采用s开始的指令如例子中的sw、双字存储指令sd等向内存中写入数据的时候地址是从低端地址向高端地址发展的。同时采用了小端对齐little-endian的字节序当写入一个8字节的数值到内存中时将该数值的低位部分放在低地址端。

以上的例子中假设A=0x0000 0000 0000 0800rs2寄存器中的低位4字节数值为0x1234ABCD则sw指令执行完后内存中的数值为

[0x0000 0000 0000 0800] = 0xCD
[0x0000 0000 0000 0801] = 0xAB
[0x0000 0000 0000 0802] = 0x34
[0x0000 0000 0000 0803] = 0x12

1.2.4 C语言内嵌汇编

为实现对硬件的“包装”操作系统往往需要操纵硬件实现例如改变机器状态等动作这些功能高级语言如C并未提供往往需要汇编语言来完成。然而纯粹用汇编语言来实现操作系统又会导致代码量太大且代码的可读性不好。综合两者汇编语言和C语言长处的方案是在C语言中实现汇编的嵌入而PKE实验所选择的基于GCC的RISC-V交叉编译器就提供了两种形式的内嵌汇编的支持基本内联汇编和扩展内联汇编。

● 基本内联汇编语句

基本行内汇编很容易理解,一般是按照下面的格式:

asm(“statements”);

该语句中“asm”也可以由“asm”来代替。在“asm”后面有时也会加上“volatile”表示编译器不要对括弧内的汇编代码进行任何优化保持指令的原样。“asm”后面括号里面的便是汇编指令。例如

asm(“li x17,81”);      //将立即数81存入x17寄存器
asm(“ecall”);         //调用ecall指令作用类似8086上的int指令

编译器碰到以上语句后会将引号中的汇编语句直接翻译成对应的机器码放到所生成的目标代码中。这两行代码的作用是调用81号软中断即1.4节中的trap但因为它们是相邻的汇编语句所以可以用以下的形式书写

asm(“li x17,81\n\t”
  “ecall”);`

也就是采用分隔符“\n\t”隔开多条汇编指令。对于编译器而言以上两种写法是等效的。实际上GCC编译器在处理内联汇编语句时是要把asm(…)的内容“打印”到汇编文件中,所以格式控制字符是必要的。

● 扩展内联汇编

扩展内联汇编使得嵌入在C语言中的代码能够带输入、输出参数同时将被汇编代码块改变的寄存器“通知”给GCC编译器作为后者在调度寄存器时的参考。扩展内联汇编的格式为

asm volatile( 
"statements"(汇编语句模板): 
output_regs输出部分: 
input_regs输入部分:
clobbered_regs破坏描述部分
) 

其中asm 表示后面的代码为内嵌汇编也可以写作__asm__volatile 表示不想让编译器对里面的汇编代码进行优化也可以写作__volatile__ 。"statements"是汇编语句模板output_regs是内联汇编的输出部分可以理解为将要被汇编语句修改的寄存器组input_regs是内联汇编的输入部分即语句执行所需要的输入寄存器clobbered_regs是破坏描述部分代表将要被汇编语句改变破坏的内容。扩展内联汇编中汇编语句模版是必须要的其它3部分则都是可选内容以下对这些部分分别进行解释

汇编代码模板:汇编语句模板由汇编语句序列组成,语句之间使用“;”、“\n”或“\n\t”分开。指令中的操作数可以使用占位符引用C语言变量操作数占位符最多10个名称如下%0%1…%9。指令中使用上述占位符表示的操作数占位符从%0起依次表示输出操作数、输入操作数。

输出部分:描述输出操作数,可以有多个约束,不同的操作数描述符之间用逗号格开,每个约束用“=”开头,接着用字母表示操作数的类型。常用约束有:"=r",表示相应操作数可以使用一个通用寄存器,"=m",表示操作数存放在内存单元中。例如:"=m" (ret)在这里的ret是最终存放输出结果的C程序变量而“=m”则是限定字符串限定字符串表示了对它之后的变量的限制条件这样GCC就可以根据这些条件决定如何分配寄存器如何产生必要的代码处理指令以及如何处理操作数与C表达式或C变量之间的关系。“=”后面所跟的字母以及含义见表1.2。

输入部分:输入部分与输出部分相似,但是没有“=”符号。

破坏描述部分:破坏描述符用于通知编译器我们使用了哪些寄存器或内存, 可以防止内嵌汇编在使用某些寄存器时导致错误。修改描述符是由逗号隔开的字符串组成的每个字符串描述一种情况一般是寄存器有时也会有“memory”。具体的意思就是告诉编译器在编译内嵌汇编的时候不能使用某个寄存器或者不能使用内存的空间。

表1.2 内联汇编常用约束

字母 含义
m、o 内存单元
r 动态分配的寄存器
i 立即数
f 浮点寄存器

我们来看一个C语言内嵌汇编代码的例子

int dest=0;

int value=1;

asm volatile (

"lw t0,%1 \n\t"

"add t0,t0,t0 \n\t"

"sd t0,%0"

:"=m"(dest) //输出部分

:"m" (value) //输入部分

: "memory"); //破坏描述部分

在这个例子定义了两个局部变量dest和value它们的初值分别为0和1。接下来的扩展内联汇编代码将value的值读入t0寄存器作为输入接着使用add指令使t0中的值自我相加即t0=t0+t0最后再将结果写回dest局部变量。需要注意的是这里%0对应输出部分的dest而%1对应着输入部分的value因为dest在汇编代码模板之后先出现的是dest后出现的是value。

1.2.5 一个例子

我们再看一个稍微大一点的例子通过这个例子我们希望能尽量把以上的知识点串起来加深读者对RISC-V汇编语言的理解。假设有以下C语言程序test_asm.c其源代码如例1.1所示:

 1 #include <stdio.h>
 2
 3 void bar()
 4 {
 5   asm volatile( "li s5, 300" );
 6 }
 7
 8 int foo( int foo_arg )
 9 {
 10   int x;
 11   asm volatile( "li s5, 500" );
 12   bar();
 13   asm volatile (
 14      "sd s5,%0"
 15      :"=m"(x)
 16      :
 17      : "memory");
 18   printf( "x=%d\n", x );
 19   return 10;
 20 }
 21
 22 int main()
 23 {
 24   foo( 10 );
 25   return 0;
 26 }

例1.1 test_asm.c代码列表

例1.1中的代码除了main函数外还定义了另外两个函数foo(int)和bar()前者的返回值为整型而后者无返回值。主函数在执行过程中先调用foo(int)函数而foo(int)函数在执行过程中调用bar()函数。代码在foo(int)函数中内嵌了两段汇编代码第11行的基本汇编语句将寄存器s5的值赋值为500而第13行至17行的扩展内联汇编则将寄存器s5的值读出到局部变量x并在第18行打印出来。两端内嵌汇编之间被调用的bar()函数也执行了一个内嵌汇编语句作用是将寄存器s5的值赋值为300。以上过程的目的在于验证s5寄存器在调用子函数的过程中函数是否将寄存器的值进行了保留。按照RISC-V规范对于名字为s开始的寄存器在进行函数调用时应该对其值进行保留如果这一点被严格遵守的话例1.1在第18行的输出就应该输出500。

将例1.1采用RISC-V交叉编译器编译为二进制代码使用以下命令行

$riscv64-unknown-elf-gcc test_asm.c -march='rv64g' -o test

编译时我们通过GCC的“-march”开关指定目标指令集是RV64G。接下来采用以下命令执行例1.1中的代码其中spike为模拟器pke为我们的PKE内核test为例1.1所对应的二进制代码):

$ spike pke test

以上命令的执行结果为“x=300”。

这说明编译器为了代码的效率并未完全遵守RISC-V对寄存器使用的规范。我们可以对所生成的test文件进行反汇编

$riscv64-unknown-elf-objdump -D ./test | less

得到以下输出:

00000000000101a4 <bar>:
  101a4:    ff010113        addi  sp,sp,-16
  101a8:    00813423        sd   s0,8(sp)
  101ac:    01010413        addi  s0,sp,16
  101b0:    12c00a93        li   s5,300
  101b4:    00000013        nop
  101b8:    00813403        ld    s0,8(sp)
  101bc:    01010113        addi  sp,sp,16
  101c0:    00008067        ret
00000000000101c4 <foo>:
  101c4:    fd010113        addi  sp,sp,-48
  101c8:    02113423        sd   ra,40(sp)
  101cc:    02813023        sd   s0,32(sp)
  101d0:    03010413        addi  s0,sp,48
  101d4:    00050793        mv   a5,a0
  101d8:    fcf42e23        sw   a5,-36(s0)
  101dc:    1f400a93        li   s5,500
  101e0:    fc5ff0ef        jal   ra,101a4 <bar>
  101e4:    ff543623        sd   s5,-20(s0)
  101e8:    fec42783        lw   a5,-20(s0)
  101ec:    00078593        mv   a1,a5
  101f0:    0001c7b7        lui   a5,0x1c
  101f4:    f7078513        addi  a0,a5,-144 # 1bf70 <__clzdi2+0x2e>
  101f8:    1e2000ef        jal   ra,103da <printf>
  101fc:    00a00793        li    a5,10
  10200:    00078513        mv   a0,a5
  10204:    02813083        ld   ra,40(sp)
  10208:    02013403        ld   s0,32(sp)
  1020c:    03010113        addi  sp,sp,48
  10210:    00008067        ret
0000000000010214 <main>:
  10214:    ff010113        addi  sp,sp,-16
  10218:    00113423        sd   ra,8(sp)
  1021c:    00813023        sd   s0,0(sp)
  10220:    01010413         addi  s0,sp,16
  10224:    00a00513        li   a0,10
  10228:    f9dff0ef        jal   ra,101c4 <foo>
  1022c:    00000793        li   a5,0
  10230:    00078513        mv   a0,a5
  10234:    00813083        ld   ra,8(sp)
  10238:    00013403        ld   s0,0(sp)
  1023c:    01010113        addi  sp,sp,16
  10240:    00008067        ret

输出的第一列是我们的程序地址也称逻辑地址第二列是机器码我们看到RV64G的机器码都是规整的32位4个字节这符合RISC指令集的特点之后的列就是对应的汇编代码了。其中比较重要的指令是jaljump and link其作用是将返回地址pc+4写道ra寄存器中且将pc赋值为第二个参数即跳转到第二个参数所指向的逻辑地址。另一个重要的指令是ret其作用是将pc的赋值为ra寄存器的内容即跳转到函数被调用之前保存的函数返回地址。从反汇编的结果上来看test中并未对s开始的寄存器进行任何的保护动作这就是为什么我们得到“x=300”结果的原因。

我们仍然以例1.1为例分析RISC-V程序执行过程中栈的变化。RISC-V并未提供push和pop指令所以函数开始时对sp寄存器的操作都是RISC-V版本的入栈动作。例如main函数中的“addi sp,sp,-16”指令将sp减去16然后赋值给sp其实质是在栈顶“空出”16个字节的空间然后将必要的内容进行压栈。如后续的“sd ra,8(sp)”就是将返回地址ra寄存器中的内容压栈而“sd s0,0(sp)”是将fp寄存器中的内容压栈s0和fp是同一个寄存器参见表1.1。例1.1中程序从main函数到bar函数的函数调用过程中它的程序栈将形成图1.2中所示的结构:

fig1_2

图1.2 例1.1程序的函数调用栈结构从main函数到bar函数

我们看到不同的函数具有的栈帧stack frame的大小实际上是很不一样的一般来说简单的函数如main和bar的栈帧比较小都只有16个字节。这是因为对于函数来说栈的作用在于保存进入时的现场、函数中被使用的寄存器以及函数的局部变量。对于简单的函数而言其使用的寄存器较少同时也没啥局部变量这些因素就导致了较小的栈帧。另外这个函数调用过程中比较有趣的是ra和fp两个寄存器。ra寄存器指向函数的返回地址一般会在函数入口处入栈。但是对于bar函数来说因为它是叶子函数leaf function处于函数调用的最后一级所以就无需将ra寄存器入栈。fp寄存器则指向上一级函数的栈帧函数在入口处都需要将它存入栈中因为只有这样函数才能够找到来时的路在返回时做到有路可走。

1.3 机器的特权状态

采用RV64G指令集的处理器定义了三种特权模式运行在最高特权级的机器模式machine mode运行在次高特权级的监管者模式supervisor mode以及最低特权级的用户模式user mode。机器模式是启动后计算机所处的模式在该模式下能够运行所有特权指令访问所有内存空间。监管者模式所处的特权级略低于机器模式但仍能够运行部分特权指令以及访问机器模式所规定的所有内存空间。用户模式的特权级最低无法执行改变机器状态的特权指令和访问超过应用程序范围的内存空间。为后续讨论的方便我们有时会简称机器模式为M模式监管者模式为S模式用户模式为U模式。

实际上RISC-V指令集的设计综合考虑了从微型嵌入式设备到云服务器的多种应用场合根据应用场合的不同就出现了多种特权级的组合。例如对于简单的嵌入式设备例如我们的U盘控制器只设计机器模式M就足够了对于有安全性考虑的嵌入式设备例如车辆的中控、路由器等则需要搭配机器模式和用户模式的组合M+U对于通用计算机例如台式电脑、手机以及云服务器由于需要运行通用操作系统则需要机器模式、监管模式以及用户模式的组合M+S+U。由于本书的主题是操作系统在以后的实验中我们将假设我们的RISC-V计算机采用了M+S+U的组合。

fig1_3

图1.3 RISC-V机器的特权模式与特权级转换

通过《操作系统原理》课我们知道,现代的处理器定义不同特权级的根本原因,是为了对操作系统进行保护。例如让操作系统运行在较高的特权模式而用户代码则运行在较低的特权模式以防止用户态代码执行恶意的动作破坏操作系统。然而用户态的代码在它的生命周期里往往会要求做一些“合法”的特权模式行为例如进行I/O典型例子如常用的printf函数这就意味着处理器同时需要支持特权模式的转换control transfer。在RISC-V处理器中实现这种特权模式转换的工具就是中断当执行在低特权模式的代码被中断时处理器将进入更高特权模式执行中断处理例程来处理打断低特权代码执行的事件。中断处理完成后处理器将从高特权模式返回低特权模式。这里的中断有时被称为异常或系统调用我们将在1.4节详细讨论中断的分类、相关术语和处理过程。需要注意的是RISC-V处理器可以实现跨特权模式的转换例如从U模式直接进入M模式或者从M模式返回U模式这些转换的发生都取决于机器的状态寄存器的设置。

RISC-V为每一种特权模式定义了一组寄存器用于控制机器的状态status以及实现状态的转换。对于操作系统而言比较重要的是机器模式和监管模式的一组寄存器Control and Status Registers简写为CSRs下面我们将分别讨论这两种模式下的CSR以及状态转换的实现。需要说明的是关于RISC-V机器的特权状态RISC-V instruction set manual是一个比较好的文档,这里我们将重点讨论与操作系统相关的内容。

1.3.1 机器模式下的CSR

表1.3列出了在机器模式下RV64G处理中控制机器状态和与状态转换的主要寄存器

表1.3 RV64G机器模式下的CSR它们的长度都是64位

寄存器 作用
mscratch Machine Scratch。保存机器模式的栈顶指针这一点在离开机器模式进入低特权级模式如监管模式时非常重要因为一旦在低特权级模式发生异常将可能会回到机器模式处理这时机器模式需要有自己的栈来保存M模式下的执行所调用的函数参数和返回地址。
mstatus Machine Status。保存机器状态的寄存器。
mtvec Machine Trap Vector。指向中断处理函数的入口地址。
mepc Machine Exception PC。指向发生异常的那条指令的地址。
mcause Machine Cause。发生中断的原因如果发生的中断是异常其最高位为0低位为异常编号如果发生的是其他类型的中断则其最高位为1低位为中断编号。
mtval Machine Trap Value。异常发生时附带的参数值。例如当缺页异常发生时mtval的值就是程序想要访问的虚地址。
mie Machine Interrupt Enable。中断开启寄存器。
mip Machine Interrupt Pending。中断等待寄存器。
mideleg Machine Interrupt Delegation Registers。中断代理寄存器。
medeleg Machine Exception Delegation Registers。异常代理寄存器。

表1.3中的寄存器很多只是存放了一个指向内存地址的指针如mscratch、mtvec、mepc相对简单而mcause、mtval、mideleg以及medeleg都与中断处理紧密相关我们将在1.4节讨论中断时再对它们的作用进行讨论。M模式的CSR中比较特殊的是mstatus它存放了机器的状态其结构如下图所示

fig1_4

图1.4 mstatus寄存器结构

以下是对mstatus中各个位的说明

● MIESIEUIE: 中断使能位。只有当这个位为1时处理器才能够分别在机器模式、监管模式或者用户模式处理中断。实际上处理器在M模式下处理中断还要取决于对应特权模式的mie即Machine Interrupt Enable寄存器中对应的中断位是否设置以及是否有中断产生mip寄存器的值即对应位是否被设置。RISC-V的中断并不一定非要在机器模式下处理处理器可以将特定中断“代理”给其他特权模式如监管模式或用户模式处理我们将在1.4节讨论这种中断代理的方法和相关寄存器如表1.4中的mideleg和medeleg的设置。

● MPIESPIEUPIE中断使能保存位。这些位的作用是保留或暂存MIESIE或UIE的值。例如若中断发生且在机器模式处理中断发生时MIE的值将自动保存到MPIE中中断返回时处理器将利用MPIE位的值恢复MIE位。

● SPPMPP发生中断异常之前的机器模式。MPP有两位00表示用户模式01表示监管模式11则表示机器模式。这是因为机器模式具有最高特权级当中断返回时执行mret指令从机器模式可以返回所有可能的模式。然而具体返回到哪个模式将将参考MPP的取值。例如如果用户模式发生了中断且中断例程在机器模式下执行则进入中断时mstatus的MPP为00。在中断例程完成时mret指令将让处理器返回到用户模式目标模式保存在MPP中。而SPP只有1位因为从监管模式返回可能到监管模式本身或者用户模式不能返回机器模式

● FSXSSDFS和XS这两个位分别用于用户模式下的浮点扩展和内存页面标记扩展脏数据等。当处理器具备浮点运算单元以及对应的用户态扩展时这些位在mstatus中存在。根据它们的取值处理器可以帮助操作系统判断在用户进程切换时是否需要保存上下文。例如如果处理器认为操作系统在用户进程切换时需要保存更多的上下文如浮点协处理器上的上下文或内存中的页面它将把SD位设置为1否则为0。当然如果处理器不具备浮点运算单元且不支持用户态扩展则SD位的值恒定为0。

● MPRVMXRSUM这几个位都与访存控制有关关于RISC-V的页式内存管理机制将在1.5节讨论。MPRVModify PRiVilege位用于控制load和store的访问权限当MPRV=0时load与store指令按照当前的特权模式进行地址转换与内存保护当MPRV=1时load和store将按照MPP中存储的特权模式的权限进行内存保护检查。例如发生中断前处理器处于U模式中断后进入M模式处理这时MPP的值为00。若此时MPRV=1则在M模式的中断处理例程对内存访问的load和store依然按照U模式的权限进行。

MXRMake eXecutable Readable位用于修改load访存的权限。当MXR=0时只能从标记为可读页表项的R=1的内存页面中读取数据当MXR=1时可以从标记为可读页表项的R=1或者可执行页表项的X=1的内存页面中读取数据。

SUMpermit Supervisor User Memory access位用于控制S模式下的虚拟内存访问特权检查。当SUM=0时系统将不允许S模式的代码对U模式下允许访问的页面的访问当SUM = 1时则允许这些访问。

● TVMTrap Virtual MemoryTWTimeout WaitTSRTrap SRET这三个域用于RISC-V的虚拟化支持。虽然虚拟化和操作系统之间有着非常紧密的联系但由于属于更高级的话题在这里中不做详细论述。

● SXLUXL可以通过设置这两个域改变低特权级下CSR的长度如将它们设置为32位。为了简化讨论我们忽略这两个域认为CSR长度为机器的总线宽度全部为64位

1.3.2 监管模式下的CSR

与机器模式类似RISC-V处理器在监管模式也定义了一组CSR如表1.4所示。

表1.4 RV64G监管模式下的重要CSR

寄存器 作用
sscratch Supervisor Scratch。保存监管模式的栈顶指针这一点在离开机器模式进入低特权级模式如用户模式时非常重要因为一旦在低特权级模式发生异常将可能会回到监管模式处理假设已通过异常授权这时监管模式需要用自己的栈保存程序执行的返回地址等。
sstatus Supervisor Status。保存监管状态的寄存器。
stvec Supervisor Trap Vector。指向监管模式中断处理函数的入口地址。
sepc Supervisor Exception PC。指向发生异常的那条指令的地址。
scause Supervisor Cause。发生中断的原因如果发生的中断是异常其最高位为0低位为异常编号如果发生的是其他中断其最高位为1低位为中断编号。
stval Supervisor Trap Value。异常发生时附带的参数值。例如当缺页异常发生时mtval的值就是程序想要访问的虚地址。
sie Supervisor Interrupt Enable。中断开启寄存器。
sip Supervisor Interrupt Pending。中断等待寄存器。
sideleg Supervisor Interrupt Delegation Registers。中断代理寄存器。
sedeleg Supervisor Exception Delegation Registers。异常代理寄存器。

这些寄存器的长度实际上取决于M模式下mstatus寄存器中的SXL位的值。但为了简化后续讨论我们仍假设它们的长度为64位。

比较表1.3和1.4我们发现S模式下的CSR和M模式下的CSR是一一对应的。实际上它们的作用也是一一对应的关系不同的地方在于S模式的特权级别比M模式稍低这也体现在状态寄存器sstatus上它的结构如图1.5所示:

fig1_5

图1.5 sstatus寄存器

相比于图1.4中的mstatus图1.5中的sstatus像是一个“缩水”版的mstatus。sstatus中与mstatus相同的位具有与mstatus模式中相同的作用见1.3.1节中的讨论如UIE和SIE的作用仍然是控制是否允许在U模式或S模式处理中断UPIE和SPIE仍然是发挥保存UIE和SIE位的作用等等。除sstatus外scause、stval、sideleg以及sedeleg的作用也与M模式下对应的mcause、mtval、mideleg以及medeleg类似对它们的讨论也放在1.4节。

相应的RISC-V处理器在U模式也定义了如表1.3或表1.4的一套CSR。U模式下CSR的作用也跟M模式或S模式类似只是U模式是最低特权级模式它们的作用比M或S模式下对应的CSR更简单。我们的PKE实验并未用到U模式下的CSR所以我们不再对它们进行讨论。

1.3.3 CSR寄存器的读写指令

在RISC-V处理器上对于CSR寄存器的读写不能使用普通的load指令而应采用csr开头的汇编指令。表1.5列出了我们采用的RISC-V交叉汇编器riscv64-unknown-elf-as所支持的CSR读写指令。

表1.5 常用的CSR读写指令

指令 功能
csrr rd, csr Control and Status Register ReadCSR指令。 把csr寄存器中的值写入到rd寄存器中。
csrw csr, rs1 Control and Status Register WriteCSR指令。 把rs1的值写到csr寄存器中。
csrs csr, rs1 Control and Status Register Set设置CSR指令。 对于rs1中每一个为1的位将csr寄存器中对应位置位。
csrc csr, rs1 Control and Status Register Clear清除CSR指令。 对于rs1中每一个为1的位将csr寄存器中对应位清零。
csrrs rd, csr, rs1 Control and Status Register Read and Set读后置位CSR指令。 记控制寄存器中的值为t把t和寄存器rs1按位或的结果写入csr寄存器再把t写入rd寄存器。
csrrc rd, csr, rs1 Control and Status Register Read and Clear读后清除CSR指令。 记控制寄存器中的值为t把t和寄存器rs1中的值按位与的结果写入csr寄存器再把t写入rd寄存器。
csrrw rd, csr, rs1 Control and Status Register Read and Write读后写CSR指令。 记控制寄存器中的值为t把寄存器rs1的值写入csr寄存器再把t写入rd寄存器。

需要注意的是对于表1.5中的csrc、csrs、csrrs、csrrc以及csrrw指令都有其立即数版本。例如csrci就是csrc的立即数版本立即数版本将rs1替换为立即数并取立即数的低5位参与运算。立即数版本的功能跟非立即数版本一致但因为我们设计的实验PKE较少用到这类指令所以我们并未把它们列入表1.5中。

1.4 中断和中断处理

通过上一节的讨论,我们知道中断是处理器在实现特权级之间 “穿越”特别是从低特权模式向高特权模式进行控制转移的重要工具和手段。由于历史或翻译的原因各种文献特别是原理课的教材中与中断相关的名词多且繁杂然而对中断概念的理解又是操作系统中非常重要的一环。我们将在1.4.1节对中断的概念和分类进行讨论力求厘清与中断相关的各种表述帮助读者形成对中断的准确理解和表达。在1.4.3节我们将讨论RISC-V处理器的中断处理并在1.4.4节介绍RISC-V平台的中断代理机制。

1.4.1 中断的概念与分类

对于中断,我们必须认识到:它的本质是打断处理器上正在执行的程序,转而去执行另一段程序,并且在执行完这一段程序后,返回去执行原先在处理器上执行的之前被打断的程序的过程。例如,某程序在(低特权级模式的)处理器上执行时,由于某事件的发生,处理器不得不转而(进入更高特权级)执行该事件的处理程序,并在执行完处理程序后返回之前的(低特权级模式)程序继续执行。

在RISC-V处理器上中断并不一定意味着特权级的变迁RISC-V支持平级中断例如简单嵌入式设备上从M模式到M模式的中断但是对于传统意义的操作系统而言由于多个特权级例如我们考虑的M+S+U模式的RISC-V机器的存在中断处理例程往往在比用户模式也就是被中断的用户程序所运行的特权模式更高级别的特权模式中如监管模式或机器模式执行。对操作系统而言这样做也可以更好地对操作系统本身以及计算机的内存资源以及I/O资源进行保护。

实际系统中导致中断发生的事件往往是比较复杂的,它们的来源、处理时机和返回方式都不尽相同。为了便于读者对中断的理解以及表达的准确性,我们借鉴参考文献SiFive Interrupt的中断分类标准将系统中发生的可能中断当前执行程序的事件分为3类

Exception(异常):这类中断是处理器在执行某条指令时,由于条件不满足而产生的。典型的异常有除零错误、缺页、执行当前特权级不支持的指令等。相对于正在执行的程序而言exception是同步synchronous发生的。exception产生的时机是指令执行的过程中即处理器流水线的执行阶段在exception处理完毕后系统将返回发生exception的那条指令重新执行

Trap即我们通常理解的“系统调用”或者“软件中断”但是我们不建议把它翻译为“陷阱”因为“陷阱”这个词在中文语境的含义甚至和“中断”一样宽泛。RISC-V中trap等同于syscall这类中断是当前执行的程序主动发出的ecall指令类似8086中的int指令导致的。典型的trap有屏幕输出printf、磁盘文件读写read/write这些高级语言函数调用通过系统函数库libc的转换在RISC-V平台都会转换成ecall指令。与exception类似相对于正在执行的程序而言trap也是同步synchronous发生的。但与exception不同的地方在于trap在处理完成后返回的是下一条指令。

Interrupt我们不建议对它进行任何形式的翻译因为“中断”在中文语境中的含义过于宽泛这类中断一般是由外部设备产生的事件而导致的。在Intel的x86系列处理器中interrupt也被称为IRQInterrupt ReQuest。典型的interrupt有可编程时钟计时器PIT所产生的timer事件、DMA控制器发出的I/O完成事件、声卡发出的缓存空间用完事件等。相对于正在执行的程序而言interrupt是异步asynchronous发生的。另外对于处理器流水线而言interrupt的处理时机是指令的间隙。不同于exception但与trap类似interrupt在处理完成后返回的是下一条指令

表1.6 中断的分类

中断类型 产生时机 处理时机 返回地址
Exception 同步(于当前程序) 指令执行阶段 发生异常的指令
Trap 同步(于当前程序) - 下一条指令
Interrupt 异步(于当前程序) 指令执行间隙 下一条指令

表1.6对这3类中断进行了归纳和比较。需要注意的是不同文献特别是中文文献对于某个类型的中断可能用了不同的名字例如trap在很多文献和参考书中又被称为“陷阱”、“陷入”、“软件中断”或“系统调用”等等。

面对纷繁复杂的术语,我们给读者的建议是:描述某个确定的中断时,尽量用英文单词来表达(不要翻译成中文,特别是避免使用“中断”或“陷入”这类含义太宽泛的名词)。实际上,当需要描述某个新类型的中断时,可以试着根据这个中断的产生、处理的时机,以及返回的位置来对它进行分类和归纳,并翻译成它对应的类名。这样,听众一听就知道该中断的类型以及对应的处理方式了,避免了很多不必要、啰嗦且含糊的解释。

1.4.2中断向量表

系统发生中断我们用中文的“中断”这个名词来指代广义的中断并非以上的interrupt时执行的这段程序往往被称为中断例程interrupt routine。因为事件的多样性系统可能有多个这样的中断例程通常的做法是把这些例程的入口放在一张表中而这张表一般称为中断向量表interrupt table。RV64G处理器在发生中断后会将发生的中断类型、编号自动记录硬件完成到目标模式的CSR中。假设发生中断的目标模式为M模式则中断的这些信息会记录到mcause寄存器。表1.7列出了mcause的可能取值以及对应的中断信息为简化讨论我们只考虑为处理器配置本地中断控制器Core Local Interrupt即CLINT的情况SiFive Interrupt)。

表1.7 mcause寄存器的取值由Interrupt及Code字段拼接及值的含义

Interrupt Code 中断描述
1 0 User software interrupt
1 1 Superior software interrupt
1 2 保留
1 3 Machine software interrupt
1 4 User timer interrupt
1 5 Supervisor timer interrupt
1 6 保留
1 7 Machine timer interrupt
1 8 User external interrupt
1 9 Supervisor external interrupt
1 10 保留
1 11 Machine external interrupt
1 >=12 && <16 保留
1 >=16 Implementation defined local interrupts
0 0 Instruction address misaligned
0 1 Instruction access fault
0 2 Illegal Instruction
0 3 Breakpoint
0 4 Load address misaligned
0 5 Load access fault
0 6 Store/AMO address misaligned
0 7 Store/AMO access fault
0 8 Environment call from U-mode
0 9 Environment call from S-mode
0 10 保留
0 11 Environment call from M-mode
0 12 Instruction page fault
0 13 Load page fault
0 14 保留
0 15 Store/AMO page fault
0 >=16 保留

从表1.7中我们可以看到首先当发生的中断类型是interrupt时mcause的高位为1而如果发生的中断类型是exception或trapmcause的高位为0其次对于interrupt而言一个特权模式只有一些可能的取值。例如对于M模式interrupt的code的可能典型取值和含义为

● 3Machine software interrupt。这种类型的中断是由软件产生的但是却不是exception或者trap实际上这类interrupt主要是指在多核实际上RISC-V中的硬件处理单元称作硬件线程Hardware Threads简称Harts一般情况下它等同于“处理器核”的概念环境下的处理期间中断。为了实现这类中断RISC-V是通过让一个核直接写另一个核的本地中断控制器来实现的。需要注意的是这里的software interrupt并不是我们通常理解的“软件中断”更不是trap或syscall

● 7Machine timer interrupt。即时钟中断它也是由处理器核所带的本地中断控制器产生的。

● 11Machine external interrupt。即除了时钟中断之外的其他中断例如键盘、其他外设等。

● >=16Implementation defined local interrupts。与实现相关的其他中断源对于RV64G指令集而言这个数字可以到48RV32G是16。数字越大意味着可以接更多的外部中断源。

最后对于非interrupt即表1.7的Interrupt=0情况Code=8时表示发生的是一个来自U模式的trapEnvironment call from U-modeCode=9时表示发生的是一个来自S模式的trapEnvironment call from S-mode而Code=11时表示发生的是一个来自M模式的trapEnvironment call from M-mode。需要再次强调的是这里的trap就是我们平时所说的“系统调用”或者“syscall”在8086环境下它们由int指令产生而在RISC-V环境下它们由ecall指令产生。

除了这几个取值外表1.7中其他的Interrupt=0的情况就都是exception。例如当Code=13Load page fault就是常见的所谓“缺页中断实际上应该叫它缺页exception”了Code=15Store/AMO page fault就是访存Store或原子操作AMO异常这些异常在我们的PKE实验中都能接触到。

仍然以机器模式为例在RISC-V处理器中中断向量表的组织和实现有两种方式一种是直接模式Direct Mode另一种是向量模式Vectored Mode。前一种模式将CSR中的mtvec指向所有中断包括表1.7中所有的interruption、trap和exception的总入口函数然后由该函数根据mcause中具体的值调用对应的中断例程。向量模式则严格按照表1.7所示的顺序将所有中断例程的入口地址组织成一个向量类似8086中的中断向量表并将mtvec指向该表的首地址。当发生中断时根据mcause中的值计算向量中的偏移并调用对应例程。RISC-V是根据mtvec的最低位mtvec.mode来判断系统具体采用了哪种模式如果采用了直接模式其最低位为0如果采用了向量模式则其最低位为1。需要指出的是我们的PKE在中断向量上使用的是直接模式。

1.4.3中断处理例程

当发生一个中断假设其目标模式即执行中断例程的模式为机器模式RISC-V处理器硬件将执行以下动作

1保存发生中断前的pc如果是trap或者interrupt则保存下一条指令的pc到mepc寄存器

2发生中断前的特权级保存到mstatus寄存器的MPP字段

3将mstatus寄存器中的MIE字段保存到它自己的MPIE字段

4设置mcause其值与表1.6中的Interrupt和Exception code对应

5将pc设置为中断例程的入口如果为直接模式则设置为mtvec的值

6将mstatus寄存器的MIE字段清零转入机器模式。

在我们的PKE实验中系统的中断实际上是代理给监管模式S模式处理的在发生中断时处理器硬件的流程与以上的机器模式类似只是mepc、mstatus、mcause以及mtvec换成了sepc、sstatus、scause以及stvec。

典型的中断处理流程如以下代码:

.align 6
.global handler_interrupt
handler_interrupt:
addi sp, sp, -32*REGBYTES
STORE x1, 1* REGBYTES(sp)
STORE x2, 2* REGBYTES(sp)
...
STORE x31, 31* REGBYTES(sp)
//call C code handler
call software_handler
//finished interrupt handling, ready to return
LOAD x1, 1* REGBYTES(sp)
LOAD x2, 2* REGBYTES(sp)
...
LOAD x31, 31* REGBYTES(sp)
addi sp, sp, 32*REGBYTES
mret

在以上的代码中中断例程将首先对通用寄存器进行保存x0因为是硬件零没有保存的价值接下来调用由C语言编写的中断处理函数“software_handler”在处理函数执行完毕后恢复通用寄存器的值并调用mretS模式下对应sret指令返回。

处理器在执行mret指令时将执行以下动作

1将mstatus寄存器的MPIE字段恢复到该寄存器的MIE字段

2处理器转换到mstatus寄存器中MPP字段所对应的特权模式

3将mepc中的内容恢复到pc中。

从以上中断例程的处理流程可以看到,中断的处理实际上是硬件和软件共同完成的。硬件负责保存一部分的中断现场,而软件则主要根据所发生中断的类型选择适合的中断例程来进行处理,软件部分也需要负责保存一部分的现场,如通用寄存器部分。

1.4.4 RISC-V的中断代理机制

RISC-V在中断处理上有一个很有意思的设计就是可以将系统中的特定中断或者异常通过设置较高特权级的CSR寄存器“代理给”某个更低的特权级处理。例如我们可以设置机器模式的mideleg以及medeleg中的某些位将系统中的部分中断对应mideleg或异常对应medeleg“代理”给较低特权级的监管模式来处理同理我们也可以设置监管模式的sideleg以及sedeleg中的某些位将系统中的部分中断或异常“代理”给用户特权级的代码来处理。

例如我们的PKE代码中有以下的等效代码

csrw mideleg, 1<<1 | 1<<5 | 1<<9
csrw medeleg, 1<<0 | 1<< 3 | 1<<8 | 1<<12 | 1<<13 | 1<<15 

这段代码的作用是将M模式中interrupt中的1、5和9号分别对应Supervisor software interruptSupervisor timer interrupt和Supervisor external interrupt代理出去到S模式处理再将M模式中的exception或trap中的0、3、8、12、13和15号分别对应Instruction address misaligned调试中断Breakpoint3号用户态系统调用Environment call from U-mode8号缺页或访存异常12、13和15号代理出去到S模式处理。实际上将这些重要的中断代理出去后系统中产生的绝大部分中断事件将都在S模式处理。所以在其后的PKE实验中读者主要跟U模式以及S模式的代码打交道除启动过程和一些简单的设置如访存、中断代理等实验也基本不涉及M模式的代码。

1.5 页式虚存管理

我们知道程序中的代码对数据进行访问如使用load和store指令采用的是数据的逻辑地址即程序地址。然而将程序装入内存时装载器无法保证数据的逻辑地址和物理地址内存的编址之间有完全相等的关系。实际上由于操作系统往往是计算机装入物理内存的第一个程序如果仔细规划逻辑地址空间还能勉强建立操作系统程序内部逻辑地址到其所装入的物理内存的物理地址间的相等关系但是这一点对于后续装入的应用程序几乎是无法也不可能保证的。

为了实现程序逻辑地址到其装入物理内存后的物理地址的转换保证程序对数据的正确寻址采用RV64G指令集的RISC-V处理器在监管模式即S模式也就是我们的PKE操作系统代码运行的特权模式提供了三种逻辑地址到物理地址的转换方式BareSv39和Sv48。由于逻辑地址到物理地址的转换与物理内存的管理有着紧密的关联所以以上三种方式也被称为虚拟内存管理Virtual Memory Management简称VMM方式。其中Bare模式是最简单的VMM方式即寻址时不对虚拟地址进行任何转换所访问的物理内存地址就等于虚拟内存地址这种方式在操作系统启动和刚进入S模式时很有用单任务模式只执行一个应用下仍然可用但多任务模式启动多个进程下就没法用了Sv39和Sv48是页式虚拟内存的管理方式它们分别支持39位和48位的逻辑地址且将物理内存以页面page的粒度进行管理。Sv39和Sv48这两种VMM用得较多的是Sv39Sv48只是在Sv39上的一个简单扩展所以在后续讨论中我们将着重讨论Sv39。

1.5.1 Sv39中的物理地址与逻辑地址

首先我们来看物理地址。从RV64G的名字上来看采用该指令集的处理器的物理地址应该有64位但是目前的设计是不是把64个地址位都用了呢答案是其实并没有目前的设计只用到了其中的56个位。这意味着现有的设计能够支撑的物理内存空间大小已经高达2^56B=2^16TB即65536个TB这个数字在内存条仍然比较昂贵的今天已经是一个惊人的数字了因为即使对于今天的服务器而言物理内存达到1TB以上依旧是一个比较富豪的配置。

接下来我们来讨论逻辑地址。对于Sv39而言该虚存管理方案实际上只用到了64位中的39位是的也没有用到全部64位不过相信读者理解了VMM的原理就能明白为什么无论是物理地址还是逻辑地址都未用满64位了有了这个数字我们就可以计算出逻辑地址空间的大小为2^39B=512GB这意味着我们写的程序最大可以写到512GB当然这个数字对于我们在实验中所写的小程序没有什么意义我们写的hello world之类的小程序逻辑地址空间只有几个KB但是这个数字对于大型游戏软件、以及部分大数据处理软件是有足够吸引力的这其实也是为什么计算机工业“义无反顾”地抛弃32位去拥抱64位的根本原因通过以上的讨论我们发现一个有趣的现象那就是

逻辑地址空间大小 ≠ 物理地址空间大小

应该来说这个现象对于今天广泛存在的64位系统已经是一个司空见惯的现象了。同理对于Sv48而言其逻辑地址的长度是48位跟物理地址长度的56位也不等长。

1.5.2 Sv39中的页式地址空间管理与页表

我们来分析Sv39虚存管理方案中逻辑地址到物理地址的转换。由于Sv39是一个页式虚存管理方法我们首先需要搞清楚的问题就是页面包括虚页virtual page和实页physical page的大小。对于Sv39而言实际上包括Sv48其基础页面大小是4KB。实际上Sv39也支持更大的页面例如2MB的兆页megapage和1GB的吉页gigapage这些我们将在弄明白页式地址变换后讨论现阶段读者可以只考虑4KB的基础页。

接下来我们对39位的逻辑地址进行“切分”如图1.6所示

fig1_6

图1.6 Sv39中逻辑地址的结构

从图1.6中我们看到逻辑地址在切分后从左到右依次的分布是12位的page offset即页内偏移、9位的一级虚页号VPN[0]Virtual Page Number、9位的二级虚页号VPN[1]Virtual Page Number、9位的三级虚页号VPN[0]Virtual Page Number。其中的一级虚页号又常被称为页表Page Table简写为PT编号而二级和三级虚页号又常分别被称为页目录Page Directory简写为PD和根目录编号。

页内偏移为12位这很容易理解因为我们的基础页的大小是4KB的其地址长度就是12位。但是为什么我们的VPN都是9位的呢这是因为我们的页表或者页目录是需要保存在内存物理页面中的而4KB大小的基础页能够保存的页表项Page Table Entry简称为PTE或页目录项Page Directory Entry简称为PDE的个数是512=2^9为了讲清楚这个问题我们需要进一步观察图1.7中PTE或PDE的格式。

fig1_7

图1.7 Sv39中PDE/PTE格式

对图1.7中PDE/PTE格式的解释

● VValid位决定了该PDE/PTE是否有效V=1时有效即是否有对应的实页。

● RRead、WWrite和XeXecutable位分别表示此页对应的实页是否可读、可写和可执行。这3个位只对PTE有意义对于PDE而言这3个位都为0。

● UUser位表示该页是不是一个用户模式页。如果U=1表示用户模式下的代码可以访问该页否则就表示不能访问。S模式下的代码对U=1页面的访问取决于sstatus寄存器中的SUM字段取值。

● GGlobal位表示该PDE/PTE是不是全局的。我们可以把操作系统中运行的一个进程认为是一个独立的地址空间有时会希望某个虚地址空间转换可以在一组进程中共享这种情况下就可以将某个PDE的G位设置为1达到这种共享的效果。

● AAccess位表示该页是否被访问过。

● DDirty位表示该页的内容是否被修改。

● RSW位2位是保留位一般由运行在S模式的代码如操作系统来使用。

● PPN44位是物理页号Physical Page Number简写为PPN

图1.7的PDE/PTE格式中有一个很重要的问题为什么里面的PPN是44位的呢通过1.5.1小节的介绍我们知道目前阶段的RV64G处理器实际使用的物理内存地址是56个位而这个2^56B的物理地址空间可以看成是多少个4KB的基础物理页呢答案是2^(56-12)=2^44个也就是PPN实际上就是我们在操作系统原理课中所学习到的“物理块号”一个物理页编号而已但如果我们知道一个物理页的编号它的64位起始地址我们就可以通过将其低12位和高8位添加0而得到。

在知道Sv39的逻辑地址结构以及PDE/PTE格式后我们就能够理解Sv39的逻辑地址到物理地址的变换过程了这个过程如图1.8所示:

fig1_8

图1.8 Sv39中虚拟地址到物理地址的转换过程

地址变换机构首先获得逻辑地址va的VPN[2]在页目录的根目录根目录的地址由satp寄存器保存中查找对应的PDE依此得知以及页目录的PPN进而找到页目录实际存储的基础物理页再根据逻辑地址中的VPN[1]取得页目录内对应的PDE接着找到页表实际存储的基础物理页再根据逻辑地址中的VPN[0]取得页表内的PTE最后获得给定虚拟地址的物理地址对应的PPN再将PPN和虚拟地址中的page offset进行移位相加最终得到物理地址pa。

需要注意的是图1.8中所示的地址变换过程是由RISC-V处理器硬件完成的但是页表的构造却是操作系统完成的对于系统中运行的每个进程操作系统本身也可以看作是一个特殊的进程都应该有个一页表与其对应当处理器需要执行某个进程时就应该将satp指向想要执行的进程。另外对于一个进程而言例如我们的hello world程序它可能用不满全部虚拟地址空间Sv39的虚拟地址空间高达512GB这种情况下它的页表中可能只有非常少部分的PDE/PTE是有效的V位为1而其他PDE/PTE并不指向任何物理内存页面。

将一个进程的部分地址空间“共享”给另一个进程是操作系统中的常规操作例如将操作系统本身的地址空间部分开放给某用户进程。有了页表的帮助这种共享也非常便捷例如我们可以把某用户进程的PDE指向操作系统的PD或PT。但是这种共享必须考虑到权限问题例如将用户进程的PDE或PTE中的权限位进行相应的设置避免对操作系统代码可能的破坏如不允许写或执行。由于页面权限的限制用户进程的执行可能会碰到访存方面的exception如访问某个V=0的页面缺页异常或者执行X=0的页中的代码这些exception都会导致当前程序的中断并进入更高特权级如PKE操作系统运行的S模式中处理。

1.5.3 satp、Sv48、TLB和非基础页

在图1.8的虚拟地址变换中一个重要的寄存器是satp它的结构如图1.9所示。

fig1_9

图1.9 satp寄存器格式

satp寄存器包含一个44位的PPN它指向了一个页表的根目录所存储的基础页一个ASIDAddress Space IDentifier用于标识一个地址空间当发生地址空间的切换时系统将需要调用SFENCE.VMA指令来刷新地址变换机构如TLBTranslation Lookaside Buffer即原理课所讲的“快表”我们将在后续讨论中介绍TLB和SFENCE.VMA指令和一个4位的模式MODE表1.8列出了模式域可能的取值及含义。

表1.8 satp寄存器中MODE域的取值和含义

取值 虚存方案 说明
0 Bare 不对虚地址进行转换和内存保护
8 Sv39 采用Sv39的方案对虚地址进行转换
9 Sv48 采用Sv48的方案对虚地址进行转换

实际上除了Sv39和Sv48外系统在未来的扩展中还可能支持更多的扩展如Sv57以及Sv64RISC-V instruction set manual。从39、48以及57这几个数字我们可以发现的规律是它们之间两两的差是9结合图1.9中的地址翻译过程我们就很容易理解什么是Sv48了它在Sv39的基础上用上了虚地址中的39~47这9个位一共用了虚地址中的48个位作为第四级虚页号VPN[3]。这样做的结果是在进行地址翻译的时候新增了一级地址翻译。显然Sv48提供了比Sv39更大的虚拟地址空间2^48B=256TB>512GB从而能够支撑更大的应用程序的执行那么选择Sv48是不是比选择Sv39更具优势呢答案是取决于是否真的有必要支撑超过512GB虚地址空间的应用如果不需要则Sv39是更合理的选择。从图1.9中的地址翻译过程我们还可以看到一次虚地址va到实地址pa的转换涉及到4次根目录+页目录+页表+pa所在的页访问内存操作而相比于处理器内部进行的运算而言访存显然是更为耗时的操作。可以想象如果采用Sv48这个转换所需要的访存操作是5次比Sv39更多的。

为了加快虚地址到实地址的转换今天的处理器RISC-V处理器也不例外但取决于具体的设计一般都内置了TLBTranslation Lookaside Buffer单元其速度接近寄存器。在进行地址转换时将常用的PDE/PTE存储在TLB中以加速虚实地址的转换速度。然而这又带来一个新的问题即当发生进程切换时TLB中很有可能还保留了上一个进程的地址变换所涉及的PDE/PTE这时我们就需要借助satp寄存器中的ASIDApplication Specific ID字段来判断是否发生了进程切换如果发生了操作系统就需要调用SFENCE.VMA指令来将TLB中的内容刷新。

由于今天应用对逻辑地址空间需求的膨胀如逻辑地址空间达到了10GB且相对连续采用传统的4KB基础页来组织这类应用的虚地址空间显然会显得非常啰嗦大量的PDE和PTE且导致了地址翻译时的低效率。为了应对这类变化Sv39虚拟内存管理方案提供了2MB的兆页megapage和1GB的吉页gigapage来消减图1.9所示的逻辑地址翻译层数从而提高地址翻译的效率。实际上它们的原理非常简单对于2MB的兆页只需要将虚拟地址部分的VPN[0]部分作为页内偏移的一部分即可12+9=212^21B=2MB而对于1GB的吉页只需要将虚拟地址部分的VPN[0]和VPN[1]都当作页内偏移即可21+9=302^30B=1GB

实际上开启采用兆页或吉页的方法在RISC-V规范中并未提供应该是让各个设计RISC-V处理器的单位自行制定。考虑到我们的PKE实验并未涉及这些非基础页所以在这里我们不打算对这部分内容进一步讨论。

1.6 什么是代理内核

代理内核是一种轻量级的应用程序执行环境可以承载静态链接的RISC-V ELF文件其运行机制如下图所示。

fig1_11

图1.10 代理内核的概念

如图1.10所示代理内核的工作适配的场景是既有主机host又有目标机target的场景。代理内核并非独立存在的系统它不具有独自的IO实现而是将IO功能代理到Host主机上。它可以看成是操作系统的一个极小集为应用提供最基本的操作系统支撑使得应用可以在只具备核心资源包括处理器、内存的裸机上运行。为实现IO操作代理内核通过HTIF接口Host Target Interface的缩写同宿主机器交互完成大量IO相关的系调用其工作机制如下图所示。

fig1_12

图1.11 代理内核与主机内核的交互

图1.11中的Frontend Server可理解为在Host主机上运行的一个守护进程它将二进制文件如代理操作系统内核Proxy kernel加载到内存通过控制/状态寄存器使target机器复位并启动代理内核。后者和Frontend Server通过共享内存的HTIF接口通讯实现IO操作以及加载和执行目标应用程序的动作。当目标应用程序执行完毕后目标机器通过tohost CSR通知主机端的Frontend。

代理内核的设计,对于在类似于Zedboard开发板这样的环境下开发、验证RISC-V处理器以及面向所开发RISC-V处理器的应用有着极大的帮助。 Zedboard是一个搭载了ARM处理器核的FPGA系统其中ARM硬核部分一般被称为PS而FPGA部分一般被称为PLPS和PL端共享内存。PS端可以运行一个完整的ARM版Linux系统如ubuntu成为图1.10中的Host部分PL端的逻辑资源则可以用来编程形成一个RISC-V处理器软核。为了在RISC-V软核上运行目标应用就可以采用代理内核的思路而无需为RISC-V处理器去开发一个完整的操作系统让在RISC-V上运行的代理内核通过HTIF去“共享”访问PS端所控制的所有外部设备如终端、存储等。这样可以极大加速开发-验证的循环最终形成可靠的基于RISC-V的系统。

例如,我们可以采用伯克利大学的开源项目fpga-zynq,在Zedboard开发板上测试所开发的RISC-V处理器fpga-zynq就用到了代理内核Proxy Kernel来支撑目标应用。运行结果如图1.12所示:

fig1_13

图1.12 代理内核与主机内核的交互

我们的PKE内核实验中也存在两个并行的Host和Target系统Host就是我们的开发主机其上运行了一个类Linux的完整操作系统而Target就是我们用Spike模拟出来的RISC-V机器目标应用程序将在代理内核中执行。系统运行过程中代理内核借助Spike所提供的HTIF完成所有IO动作。

我们认为代理内核除了它的实用特性如以上讨论的对RISC-V的开发和验证具有很好的教学用途。在开发代理内核的过程中学生只需要将精力放在对计算机的“核心资产”即处理器和内存的管理和通过各种数据结构实现的逻辑到物理的映射上而无须过多考虑对IO设备控制的细节。这些都有助于降低操作系统开发的复杂度尽量降低学生开发的难度和复杂度。同时,代理内核的目标是支撑给定目标应用,无需搞个“大而全”的内核,去运行各种可能的应用

一个显而易见的事实是:比起复杂的多进程网络服务器(如Apache HTTP Server所需要的操作系统支持而言简单的Hello world!程序所需要的操作系统支持显然要少得多!例如PKE的第一个实验lab1_1所就采用了Hello world!程序作为目标应用而支撑该应用的PKE内核代码只有500多行这可能是世界上最小的“操作系统”了基于该事实采用代理内核思想设计操作系统实验的一个更Strong的理由是我们可以通过对输入的目标应用的迭代从易到难从简单到复杂反推代理操作系统内核本身的“进化”学生在进化代理内核的过程中完成对操作系统课程知识点的学习。

需要说明的是我们的PKE实验代码即可以满足操作系统课程实验的开发需求同时所开发的代理内核可以无缝地在Zedboard开发板上运行。这样PKE实验实际上是同时兼顾了教学用途采用Spike模拟RISC-V环境和实际工程开发在Zedboard开发板的PL上搭载需要验证的RISC-V处理器软核用途的。这样就为选择PKE实验的学生打下了一定的RISC-V软硬协同开发基础。

1.7 相关工具软件

PKE的实验将涉及Linux环境下较多工具软件的使用以下列出一些我们认为比较重要的工具软件并简要介绍其使用方法。详细的使用方法希望读者通过阅读其使用手册进一步掌握。

● git

git是一个开源的分布式版本控制系统对于一个本地仓库你可以使用如下命令创建

$git init

对已存在文件进行版本控制

$git add *.c

$git add LICENSE

$git commit -m 'Initial project version'

对于一个远程的git仓库你想要把代码克隆到本地需要使用git clone命令

$git clone url

当clone完成你就会有本地的工作目录进入该目录每个文件都有两种状态tracked or untracked。已跟踪的文件是指那些被纳入了版本控制的文件在上一次快照中有它们的记录在工作一段时间后它们的状态可能是未修改已修改或已放入暂存区。简而言之已跟踪的文件就是 git 已经知道的文件。其状态转换如图:

fig1_10

图1.13 git仓库中文件的状态转换

使用git status可以查看目录下的文件状态

$ git status

使用add命令可以递归地跟踪该目录下的所有文件、或暂存已修改的文件

$ git add fileName/directory

使用commit命令可以提交更新

$ git commit -m "commit information"

◇ 分支相关命令

查看分支:

$ git branch

本地创建远端分支devdev为分支名下同

$ git checkout -b dev origin/de

合并分支dev

$ git merge dev

本地删除分支dev

$ git branch -D/d dev

切换到已有分支dev

$ git checkout dev

本地创建并切换到分支dev

$ git checkout -b dev

推送分支dev到远端

$ git push origin

◇ 调试相关命令

$ git log

显示每次提交提交日期的差异,-2选项来只显示最近的2次提交

$ git log -p -2

每次提交的简略统计信息:

$ git log --stat

格式化log输出

$ git log --pretty=oneline [short、full、fuller]//用一行显示每一次提交

$ git log --pretty=format:"%h - %an, %ar : %s"

● riscv64-unknown-elf-gcc

命令格式riscv64-unknown-elf-gcc [options] file

常见参数:

-o Place the output into (指定输出文件的名称)

-E Preprocess only; do not compile, assemble or link预处理

-S Compile only; do not assemble or link编译

-c Compile and assemble, but do not link汇编

举例:

\1. -o选项指定输出文件名称为hello

$ riscv64-unknown-elf-gcc hello.cpp -o hello

\2. 使用-E选项输出预处理阶段的hello.i文件

$ riscv64-unknown-elf-gcc -E hello.cpp -o hello.i

\3. 使用-S选项输出编译阶段的hello.s文件

$ riscv64-unknown-elf-gcc -S hello.cpp -o hello.s

\4. 使用-c选项输出二进制文件hello.o

$ riscv64-unknown-elf-gcc -c hello.s -o hello.o

● riscv64-unknown-elf-objdump

命令格式riscv64-unknown-elf-objdump <option(s)> <file(s)>

常见参数:

-h, Display the contents of the section headers显示section header的内容

-f Display the contents of the overall file header显示整体file header的内容

举例:

\1. 查看对象文件所有的节sections.

$riscv64-unknown-elf-objdump -h elf_file

输出如下:

hello_elf:   file format elf64-littleriscv

Sections:
Idx Name     Size   VMA        LMA        File off Algn
 0 .text     000024cc 00000000000100b0 00000000000100b0 000000b0 2**1
         CONTENTS, ALLOC, LOAD, READONLY, CODE
 1 .rodata    0000000a 0000000000012580 0000000000012580 00002580 2**3
         CONTENTS, ALLOC, LOAD, READONLY, DATA
 2 .eh_frame   00000004 000000000001358c 000000000001358c 0000258c 2**2
         CONTENTS, ALLOC, LOAD, DATA
 3 .init_array  00000010 0000000000013590 0000000000013590 00002590 2**3
         CONTENTS, ALLOC, LOAD, DATA
 4 .fini_array  00000008 00000000000135a0 00000000000135a0 000025a0 2**3
         CONTENTS, ALLOC, LOAD, DATA
 5 .data     00000f58 00000000000135a8 00000000000135a8 000025a8 2**3
         CONTENTS, ALLOC, LOAD, DATA
 6 .sdata    00000040 0000000000014500 0000000000014500 00003500 2**3
         CONTENTS, ALLOC, LOAD, DATA
 7 .sbss     00000020 0000000000014540 0000000000014540 00003540 2**3
         ALLOC
 8 .bss     00000068 0000000000014560 0000000000014560 00003540 2**3
         ALLOC
 9 .comment   00000011 0000000000000000 0000000000000000 00003540 2**0
         CONTENTS, READONLY
 10 .riscv.attributes 00000035 0000000000000000 0000000000000000 00003551 2**0
         CONTENTS, READONLY

可以看得一个section在文件中对应的属性有六个其中name为section的名字size为section的大小VMA为该section期望从中执行的内存地址LAM为加载到内存的内存地址File off为该section在文件中的偏移量algn则为字节对齐。

\2. 显示文件的摘要信息包括start address

$riscv64-unknown-elf-objdump -f elf_file

例如我们使用riscv64-unknown-elf-objdump -f hello_elf输出如下

hello_elf:   file format elf64-littleriscv
architecture: riscv:rv64, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x00000000000100c2

● riscv64-unknown-elf-as

命令格式riscv64-unknown-elf-as [option...] [asmfile...]

编译汇编源文件到目标文件,例如:

$ riscv64-unknown-elf-as hello.S -o hello.o

● file

命令格式file [OPTION...] [FILE...]

file命令用于辨识文件类型例如

$ file app/elf/app1

我们将看到以下输出:

app/elf/app1: ELF 64-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, with debug_info, not stripped

表明app/elf/app1文件是一个可执行的二进制代码ELF文件采用了RISC-V指令集且为静态链接所生成。

● spike

命令格式spike [host options] [target options]

常见参数:

-m Provide MiB of target memory [default 2048]提供MiB内存

--isa= RISC-V ISA string [default RV64IMAFDC]设置ISA

spike是RISC-V仿真器我们将需要使用它结合pke运行我们的二进制程序例如

$ spike pke helloworld

得到输出如下:

Hello World! 

spike默认提供的内存为2048MiB使用-m选项可以指定内存大小单位是MiB例如

$ spike -m1024 pke helloworld

就可以将模拟出来的RISC-V机器的内存大小指定为1024MiB。

RISC-V是一个可扩展指令集spike默认支持的ISA为RV64IMAFDC我们可以通过--isa选项设置模拟出来的机器的ISA例如

$ spike --isa=RV64GCV pke helloworld

我们将目标机器的ISA指定为RV64GCV。

参考文献:

[RISC-V开源项目列表] RISC-V开源项目列表. https://riscv.org/risc-v-cores/

[开源指令集的指南] 勾凌睿、黄成、刘志刚《RISC-V手册——一本开源指令集的指南》. Avail at: http://crva.ict.ac.cn/documents/RISC-V-Reader-Chinese-v2p1.pdf

[RISC-V instruction set manual] A. Waterman, Y. Lee, and et al. The RISC-V Instruction Set Manual Volume II: Privileged Architecture (version 20191213). Avail at: https://content.riscv.org/wp-content/uploads/2019/12/riscv-spec-20191213.pdf

[RISC-V User-Level ISA] A. Waterman, K. Asanovi´c, and et al. The RISC-V Instruction Set Manual Volume I: Unprivileged ISA (version 20191213) Avail. at: https://content.riscv.org/wp-content/uploads/2019/06/riscv-spec.pdf

[SiFive Interrupt] SiFive, SiFive Interrupt Cookbook (version 1.0). Avail. at: https://sifive.cdn.prismic.io/sifive/0d163928-2128-42be-a75a-464df65e04e0_sifive-interrupt-cookbook.pdf

[Zedboard] Digilent Zedboard. https://reference.digilentinc.com/programmable-logic/zedboard/start

[Proxy Kernel] RISC-V Proxy Kernel and Boot Loader. https://github.com/riscv/riscv-pk

[fpga-zynq] Rocket Chip on Zynq FPGAs. https://github.com/ucb-bar/fpga-zynq

[Spike] Spike RISC-V ISA Simulator. https://github.com/riscv/riscv-isa-sim