Skip to content

RISC-V

就像网页浏览器隐藏了 Windows/macOS/Linux 的差异,操作系统则是隐藏了不同 CPU 之间的差异。换句话说,操作系统是一个操控 CPU 并为应用程序提供抽象层的程序。

本书选择 RISC-V 作为目标 CPU 是因为:

  • 规范简单且适合新手。
  • 它是近年来流行的 ISA(指令集架构),与 x86 和 Arm 并驾齐驱。
  • 设计决策在整个规范中都有详细记录,并且读起来很有趣。

我们将会基于 32 位的 RISC-V 来编写操作系统。当然稍作改动你也可以让它适配 64 位的 RISC-V。但是,更宽的位宽会变得稍微复杂一些,而且更长的地址读起来可能很繁琐。

QEMU 虚拟机

计算机是由不同的设备组成的:CPU、内存、网卡、硬盘等等。例如,虽然 iPhone 和树莓派都使用 Arm CPUs,但显而易见它们是不一样的计算机。

本书支持 QEMU virt 虚拟机(文档)是因为:

  • 哪怕它不是真实存在于这个世界的,它也是简单的并且跟真实设备非常相似。
  • 你可以免费使用 QEMU 来模拟而不需要购买物理硬件。
  • 当你遇到调试问题,你可以阅读 QEMU 的代码、或者用调试器附加到 QEMU 进程来研究哪里出错了。

RISC-V assembly 101

RISC-V,或者说是 RISC-V ISA(指令集架构),定义了一些列 CPU 可以执行的指令。有点类似于 API 或者编程语言规范。当你写 C 程序时,编译器会将代码翻译成 RISC-V 汇编代码。不幸的是,在编写操作系统过程中你需要写一些汇编代码,不过不用担心!汇编没有你想象中那么难。

TIP

试试 Compiler Explorer!

Compiler Explorer是一个在线编译器,是帮助你学汇编的实用工具。当你写下 C 代码,它就会显示相应的汇编代码。

Compiler Explorer 默认使用 x86-64 CPU 汇编。可以通过在右边面板中指定 RISC-V rv32gc clang (trunk) 来输出 32 位 RISC-V 汇编。

这里还有个有趣的事,你可以试试在编译选项中指定优化选项,例如 -O0(关闭优化)或者 -O2 (二级优化),然后看看汇编有什么变化。

汇编语言基础

汇编语言(几乎)是机器代码的直接表示。我们来看一个简单的例子:

asm
addi a0, a1, 123

通常来说,每一行汇编代码表示一个单一指令。第一列(addi)是指令名,也被叫做 操作码(opcode)。接下来的几列(a0, a1, 123)被称作 操作数(oprands),是指令的参数。在这个例子中,addi 指令将值 123 与寄存器 a1 中的值相加,然后将结果保存到寄存器 a0

寄存器

寄存器类就像 CPU 中的临时变量,它们比内存快很多。CPU 将内存中的数据读取到寄存器,对寄存器做算术运算,然后将结果回写到内存或者寄存器。

下面是 RISC-V 中一些常见的寄存器:

寄存器ABI 名称 (别名)解释
pcpc程序计数器(下一条指令的位置)
x0zero硬连线零(始终读为零)
x1ra返回地址
x2sp栈指针
x5 - x7t0 - t2临时寄存器
x8fp栈帧指针
x10 - x11a0 - a1函数参数/返回值
x12 - x17a2 - a7函数参数
x18 - x27s0 - s11调用期间保存的临时寄存器
x28 - x31t3 - t6临时寄存器

TIP

调用约定

通常来说,你可以按照你的喜好来使用寄存器,但为了和其他软件互通,寄存器的使用方式有明确的定义 —— 这被称为 调用约定

例如,x10 - x11 寄存器被用作函数参数和返回值。为了可读性,它们在 ABI 中被赋予了像 a0 - a1 这样的别名。在规范可以看到更多细节。

内存访问

寄存器真的很快,但它们的数量有限。绝大部分的数据都被保存在内存中,程序从内存中读取数据或者往内存中写入数据是通过 lw (load word) 指令和 sw (store word) 指令来实现的:

asm
lw a0, (a1)  // 从 a1 寄存器中保存的地址中读取一个字(word32 位)
             // 然后保存到 a0 寄存器。在 C 语言就是: a0 = *a1;
asm
sw a0, (a1)  // 向 a1 保存的地址中写入一个字,这个字是保存在 a0 的。
             // 在 C 语言中是: *a1 = a0;

你可以将 (...) 看作是 C 语言中的指针解引用。在这个例子里,a1 是一个指向 32 位宽值的指针。

分支指令

分支指令将会改变程序的控制流。它们被用于实现 ifforwhile 语句,

asm
    bnez    a0, <label>   // 如果 a0 不为 0 则跳转到 <label>
    // 如果 a0 为 0 则从这里继续

<label>:
    // 如果 a0 不为 0 则从这里继续

bnez 表示 "branch if not equal to zero"(分支如果不等于 0)。其他常见的分支指令有 beq (branch if equal,分支如果等于)和 blt(分支如果小于)。它们跟 C 语言的 goto 很像,只是带有条件。

函数调用

jal (jump and link,跳转和链接)和 ret (return,返回)指令被用作调用函数和返回:

asm
    li  a0, 123      // 加载 123 到 a0 寄存器(函数参数)
    jal ra, <label>  // 跳转到 <label> 并且将返回地址保存到
                     // ra 寄存器中。

    // 函数调用结束后将会从这里继续...

// int func(int a) {
//   a += 1;
//   return a;
// }
<label>:
    addi a0, a0, 1    // 给 a0 (第一个参数) 加 1

    ret               // 返回到 ra 保存的地址。
                      // 返回值在 a0 寄存器。

按照调用约定,函数参数会通过 a0 - a7 寄存器传递,返回值则是保存在 a0 寄存器。

栈是一个被用于函数调用和局部变量的后进先出(LIFO)的内存空间。它是向下发展的,栈指针 sp 指向栈顶。

为了保存一个值到栈中,需要减小栈指针然后保存值(也被称作 push 操作):

asm
    addi sp, sp, -4  // 将栈指针向下移动 4 字节
                     // (即栈分配)

    sw   a0, (sp)    // 将 a0 保存到栈

而从栈读取一个值,则是读取值然后增加栈指针(也被称作 pop 操作):

asm
    lw   a0, (sp)    // 从栈中读取到 a0
    addi sp, sp, 4   // 将栈指针向上移动 4 字节
                     // (即栈释放)

TIP

在 C 语言里,栈操作是由编译器生成的,所以你不需要自己手写。

CPU 模式

CPU 有多种模式,每个有不同的特权。在 RISC-V 中有三种模式:

模式概述
M-modeOpenSBI(即 BIOS)运行的模式。
S-mode内核运行的模式,又称“内核模式”。
U-mode应用运行的模式,又称“用户模式”。

特权指令

在 CPU 指令中,有一些被称为特权指令的是应用程序(用户模式)不能执行的,在本书中,我们将使用下列权限指令:

操作码和操作数概述伪代码
csrr rd, csr读取 CSRrd = csr;
csrw csr, rs写入 CSRcsr = rs;
csrrw rd, csr, rs同时读取和写入 CSRtmp = csr; csr = rs; rd = tmp;
sret从 trap 处理程序返回(恢复程序计数器、操作模式等)
sfence.vma清除 TLB(Translation Lookaside Buffer)

CSR (Control and Status Register, 控制和状态寄存器) 是一个保存 CPU 设置的寄存器。CSR 列表可以在RISC-V Privileged Specification找到。

TIP

一些指令(例如 sret)会执行一些比较复杂的操作。阅读 RISC-V 模拟器代码或许能更好地理解到底发生了什么。特别是 rvemu,以一个直观且易于理解的方式编写的(例如 sret 的实现)。

内联汇编

在接下来的章节中,你会接触到如下特殊的 C 语言语法:

c
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));

这就是*“内联汇编”*,一种在 C 语言中嵌入汇编的语法。虽然你可以在一个单独的文件(.S 扩展名)中写汇编,但是更建议首选内联汇编,因为:

  • 你可以在汇编中使用 C 变量。同样的,你也可以将汇编结果分配给 C 变量。
  • 你可以将寄存器分配工作交给 C 编译器。这么做你就不需要手写汇编代码来处理寄存器的保存和恢复。

如何编写内联汇编代码

内联汇编代码的编码格式如下:

c
__asm__ __volatile__("assembly" : output operands : input operands : clobbered registers);
部分释义
__asm__表示这是内联汇编
__volatile__告诉编译器不要优化 "assembly" 这部分代码。
"assembly"以字符串字面量编写的汇编代码
output operands用来保存汇编结果的 C 变量
input operands在汇编中使用的 C 表达式(例如 123x)。
clobbered registers在汇编中会被破坏内容的寄存器。如果忘写,C 编译器将不会保留这些寄存器的内容,然后可能会引发 bug。

输出和输入操作数是用冒号隔开的,每个操作数都是按 约束 (C 表达式) 格式写的。约束被用来指定操作数类型,通常用 =r (寄存器) 表示输出操作数,r 表示输入操作数。

在汇编中可以用 %0%1%2 等等(第一个是输出)来访问输出和输入操作数。

示例

c
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));

使用 xsrr 指令来读取 sepc CSR 的值,然后将它分配给 value 变量。%0 对应到 value 变量。

c
__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));

使用 csrw 指令将 123 写入到 sscratch CSR。%0 对应到包含 123r 约束) 的寄存器,它实际上像是:

li    a0, 123        // 将 123 存到 a0 寄存器
csrw  sscratch, a0   // 将 a0 寄存器的值写入到 sscratch 寄存器

虽然内联汇编中只有 csrw 指令,但是 li 指令会被编译器自动插入以满足 "r" 约束(寄存器中的值)。非常方便!

TIP

内联汇编是一个编译器规范扩展,并不是 C 语言规范。你可以在 GCC 文档中查看详细用法。然而,由于约束语法因 CPU 架构而异,并且它具有许多复杂的功能,因此需要花费一些时间来理解。

对于新手而言,我建议寻找真实的例子。例如 HinaOSxv6-riscv 都是很好的参考。