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
(二级优化),然后看看汇编有什么变化。
汇编语言基础
汇编语言(几乎)是机器代码的直接表示。我们来看一个简单的例子:
addi a0, a1, 123
通常来说,每一行汇编代码表示一个单一指令。第一列(addi
)是指令名,也被叫做 操作码(opcode)。接下来的几列(a0, a1, 123
)被称作 操作数(oprands),是指令的参数。在这个例子中,addi
指令将值 123
与寄存器 a1
中的值相加,然后将结果保存到寄存器 a0
。
寄存器
寄存器类就像 CPU 中的临时变量,它们比内存快很多。CPU 将内存中的数据读取到寄存器,对寄存器做算术运算,然后将结果回写到内存或者寄存器。
下面是 RISC-V 中一些常见的寄存器:
寄存器 | ABI 名称 (别名) | 解释 |
---|---|---|
pc | pc | 程序计数器(下一条指令的位置) |
x0 | zero | 硬连线零(始终读为零) |
x1 | ra | 返回地址 |
x2 | sp | 栈指针 |
x5 - x7 | t0 - t2 | 临时寄存器 |
x8 | fp | 栈帧指针 |
x10 - x11 | a0 - a1 | 函数参数/返回值 |
x12 - x17 | a2 - a7 | 函数参数 |
x18 - x27 | s0 - s11 | 调用期间保存的临时寄存器 |
x28 - x31 | t3 - t6 | 临时寄存器 |
TIP
调用约定
通常来说,你可以按照你的喜好来使用寄存器,但为了和其他软件互通,寄存器的使用方式有明确的定义 —— 这被称为 调用约定。
例如,x10
- x11
寄存器被用作函数参数和返回值。为了可读性,它们在 ABI 中被赋予了像 a0
- a1
这样的别名。在规范可以看到更多细节。
内存访问
寄存器真的很快,但它们的数量有限。绝大部分的数据都被保存在内存中,程序从内存中读取数据或者往内存中写入数据是通过 lw
(load word) 指令和 sw
(store word) 指令来实现的:
lw a0, (a1) // 从 a1 寄存器中保存的地址中读取一个字(word,32 位)
// 然后保存到 a0 寄存器。在 C 语言就是: a0 = *a1;
sw a0, (a1) // 向 a1 保存的地址中写入一个字,这个字是保存在 a0 的。
// 在 C 语言中是: *a1 = a0;
你可以将 (...)
看作是 C 语言中的指针解引用。在这个例子里,a1
是一个指向 32 位宽值的指针。
分支指令
分支指令将会改变程序的控制流。它们被用于实现 if
、for
和 while
语句,
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,返回)指令被用作调用函数和返回:
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 操作):
addi sp, sp, -4 // 将栈指针向下移动 4 字节
// (即栈分配)
sw a0, (sp) // 将 a0 保存到栈
而从栈读取一个值,则是读取值然后增加栈指针(也被称作 pop 操作):
lw a0, (sp) // 从栈中读取到 a0
addi sp, sp, 4 // 将栈指针向上移动 4 字节
// (即栈释放)
TIP
在 C 语言里,栈操作是由编译器生成的,所以你不需要自己手写。
CPU 模式
CPU 有多种模式,每个有不同的特权。在 RISC-V 中有三种模式:
模式 | 概述 |
---|---|
M-mode | OpenSBI(即 BIOS)运行的模式。 |
S-mode | 内核运行的模式,又称“内核模式”。 |
U-mode | 应用运行的模式,又称“用户模式”。 |
特权指令
在 CPU 指令中,有一些被称为特权指令的是应用程序(用户模式)不能执行的,在本书中,我们将使用下列权限指令:
操作码和操作数 | 概述 | 伪代码 |
---|---|---|
csrr rd, csr | 读取 CSR | rd = csr; |
csrw csr, rs | 写入 CSR | csr = rs; |
csrrw rd, csr, rs | 同时读取和写入 CSR | tmp = 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 语言语法:
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));
这就是*“内联汇编”*,一种在 C 语言中嵌入汇编的语法。虽然你可以在一个单独的文件(.S
扩展名)中写汇编,但是更建议首选内联汇编,因为:
- 你可以在汇编中使用 C 变量。同样的,你也可以将汇编结果分配给 C 变量。
- 你可以将寄存器分配工作交给 C 编译器。这么做你就不需要手写汇编代码来处理寄存器的保存和恢复。
如何编写内联汇编代码
内联汇编代码的编码格式如下:
__asm__ __volatile__("assembly" : output operands : input operands : clobbered registers);
部分 | 释义 |
---|---|
__asm__ | 表示这是内联汇编 |
__volatile__ | 告诉编译器不要优化 "assembly" 这部分代码。 |
"assembly" | 以字符串字面量编写的汇编代码 |
output operands | 用来保存汇编结果的 C 变量 |
input operands | 在汇编中使用的 C 表达式(例如 123 、x )。 |
clobbered registers | 在汇编中会被破坏内容的寄存器。如果忘写,C 编译器将不会保留这些寄存器的内容,然后可能会引发 bug。 |
输出和输入操作数是用冒号隔开的,每个操作数都是按 约束 (C 表达式)
格式写的。约束被用来指定操作数类型,通常用 =r
(寄存器) 表示输出操作数,r
表示输入操作数。
在汇编中可以用 %0
、%1
、%2
等等(第一个是输出)来访问输出和输入操作数。
示例
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));
使用 xsrr
指令来读取 sepc
CSR 的值,然后将它分配给 value
变量。%0
对应到 value
变量。
__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));
使用 csrw
指令将 123
写入到 sscratch
CSR。%0
对应到包含 123
(r
约束) 的寄存器,它实际上像是:
li a0, 123 // 将 123 存到 a0 寄存器
csrw sscratch, a0 // 将 a0 寄存器的值写入到 sscratch 寄存器
虽然内联汇编中只有 csrw
指令,但是 li
指令会被编译器自动插入以满足 "r"
约束(寄存器中的值)。非常方便!