RISC-V from Scratch 6

RISC-V from scratch 6

接上一篇博客,我今天继续写 RISC-V from scratch 系列博客。原本我打算将该英文系列全部翻译成中文,但原作者貌似没有把这一系列完成就咕咕了,因此本文的内容是我自己实践的内容,以及一些自己的想法,放在这里同大家探讨,算是狗尾续貂,弥补遗憾

简介

欢迎再次来到 RISC-V from scratch ,先快速回顾一下我们之前做过的内容,我们之前已经介绍了 RISC-V 的特权架构以及几个重要的寄存器,在更久以前,我们还介绍了一些相关底层概念(例如编译、链接、原语运行时、汇编等)。具体来说,在上一篇文章中,我们在 UART 驱动程序的基础上,写了一个自己的链接器脚本,将数据安放在了合适的位置,我们还完善了 boot.s 文件,为中断程序处理做好了准备。为了使我们更好地利用驱动程序,并进一步做到进程并发、调度等,在这篇博客中,我将介绍:

  • printf 等工具类函数的实现
  • 在机器模式中实现对时钟中断的处理
  • 将时钟中断处理程序放到监管者模式下

搭建环境

如果你还未看本系列博客的第一部分,没有安装 riscv-qemu 和 RISC-V 工具链,那么赶紧点击上面标题的链接,跳转到 “QEMU and RISC-V toolchain setup”

往期回顾

在正式内容开始前,我们先来看看上次实验进行到哪里了:

1
2
3
4
5
6
7
8
9
10
static char sg[] = "hello world!";

int main() {
int l = 102;
static int sl = 105;
uartinit();
for(int i = 0; i < 12; i++)
uartputc(sg[i]);
// ...
}

不错,我们已经可以熟练地使用驱动程序打印字符串了!但是,我们仍然不满足于此,我们最终的期望,一定是和其他机器一样,可以在 C 语言中使用 printf 等函数将字符打印出来。此外,实现了 printf 函数,也可以让我们调试更加方便(所谓的 printf 大法🤪),对中断处理程序的实现有一定帮助。

工具类函数库

虽然我们即将实现的这些工具类函数都可以在 C/C++ 的库函数里面找到,不需要自己重复造轮子,但既然 RISC-V from scratch 这一系列的初衷就是从零开始完成 RISC-V 内核,那么博主还是要头铁地试一试的。对这一部分不感兴趣的读者,可以直接跳过啦。

首先,新建一个文件取名 print.c ,再根据 C/C++ 中的规定,对 printf 函数定义:

1
2
3
4
5
6
/**
* @param fmt the string format
* @return return length of written chars if success
*/
int printf(const char *fmt, ...) {
}

如果大家对 C 语言中的可变参数语法不是很熟悉,那么建议看看菜鸟教程或者 GeeksforGeeks

考虑到今后的应用前景和代码结构,博主一并实现了 vsprintfsprintf 函数,以及strcpystrlen 等函数,但目前只实现了 %d %x %s 等。出于篇幅原因,只展示一些核心代码,其他的工具类函数实现都比较简单,就不罗嗦了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* @param s output string after replacement
* @param fmt input string format
* @param arg the variable arguments
* @return the length of output string s
*/
int vsprintf(char * s, const char * fmt, va_list arg) {
// ...
for(i = 0, j = 0; (fmt[i] & 0xff) != 0; i++) {
c = fmt[i];
// char put
if (c != '%') {
s[j++] = fmt[i]; continue;
} else {
// %d %x %s and %%
d = fmt[++i] & 0xff;
switch (d) {
case 'd':
tmp = va_arg(arg, int);
tmp = itoa(tmp, tmpstr, 10);
strcpy(&s[j], tmpstr);
j += tmp;
break;
case 'x':
tmp = va_arg(arg, int);
tmp = itoa(tmp, tmpstr, 16);
strcpy(&s[j], tmpstr);
j += tmp;
break;
case 's':
pt = va_arg(arg, char*);
strcpy(&s[j], pt);
j += strlen(pt);
break;
case '%': s[j++] = '%'; break;
default:
break;
}
}
}
// ...
}

说一个小细节,整数的打印有两种进制可供选择,将整数转为相应的字符串是通过 int itoa(int num, char *str, int base) 函数实现的。该函数也不难实现。printf 函数就很容易了,只要把参数转换为相应的类型,再调用 vsprintf 函数和 uartputc 函数就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @param fmt the string format
* @return return length of written chars if success
*/
int printf(const char *fmt, ...) {
va_list va;
int i, len;
char str[1024];
va_start(va, fmt);
len = vsprintf(str, fmt, va);
for(i = 0; i < len; i++)
uartputc(str[i]);
va_end(va);
return len;
}

时钟中断

知识预备

到这里,如果大家仍不熟悉 RISC-V 特权架构,特别是中断处理的相关知识的话,就要好好补补了:

其中,官方的 RISC-V 特权指令集手册解释最为详尽,推荐英文好的读者仔细读一下相关内容。

关于时钟中断,它是中断的三种来源之一,触发中断的条件非常简单:在相关中断使能全打开的情况下,当 mtime 寄存器中的值大于或等于 mtimecmp 寄存器中的值时,就会触发时钟中断。值得注意的是,在机器模式下,只有当 mtimecmp 寄存器被重新写入后,mip 寄存器中的时钟中断标志位才会被清除。因此,每次处理时钟中断,都不能忘记更新 mtimecmp

Platforms provide a 64-bit memory-mapped machine-mode timer compare register (mtimecmp), which causes a timer interrupt to be posted when the mtime register contains a value greater than or equal to the value in the mtimecmp register.

The MTIP bit is read-only and is cleared by writing to the memory-mapped machine-mode timer compare register (mtimecmp).

这里我要好好解释一下什么是 memory-mapped machine-mode 寄存器。如果你详细读过 RISC-V privileged ISA SpecificationRISC-V 特权架构,那么你一定发现上面引用中提到的寄存器 mtimemtimecmp 都不在 CSR 寄存器表中,它们是 memory-mapped(内存映射)寄存器,意味着他们存在于机器内存的某个位置。由网上的资料得知,在 QEMU 提供的 virt 机器中,时钟中断事实上是由一个叫 CLINT 的外部中断设别产生的,在 virt 机器中,mtime 的内存地址是 0x200bff8mtimecmp 的内存地址是 0x2004000

mtvec 寄存器

回顾一下 RISC-V from Scratch 5 RISC-V 特权架构,其中都提到了mtvec 寄存器,它的作用是存储处理程序的基址。很显然我们即将写的时钟中断处理程序就应当在此,注意,mtvec 寄存器要求中断处理程序地址 4 字节对齐。好,万事俱备,只欠东风。首先,新建一个文件取名 mtrap.s ,然后定义符号 mtrap_vector

1
2
3
4
.global mtrap_vector
.section .text
.align 4
mtrap_vector:

那么,我们该使用 mtvec 寄存器的哪一种寻址方式呢?其实都可以,简单点地,使用直接寻址,所有的中断/异常的处理都会跳转到这里;使用间接寻址,中断和异常就会分开,略显复杂但更易理解。

博主在实验时两个方式都用过了,我就介绍一下间接寻址吧。所有的中断处理程序的开始地址在 mtrap_vector 处形成数组,机器模式的时钟中断(中断编号为 7,因此在第 8 个)直接跳转到 mtimer 处。由于其他中断我们还没写,这就直接用 nop 语句代替了😅。

1
2
3
4
5
6
7
8
9
10
11
12
.global mtrap_vector
.section .text
.align 4
mtrap_vector:
nop # User Software Interrupt
nop # Supervisor software Interrupt
nop #
nop # Machine software Interrupt
nop # User timer Interrupt
nop # Supervisor timer Interrupt
nop #
j mtimer # Machine timer Interrupt

中断处理程序

RISC-V 特权架构中,我们已经知道了发生中断/异常时,机器的行动过程。那么在中断处理程序中,我们到底需要做什么呢?如果大家学过本科的计算机组成原理的话,那么应该知道这些:

  • 保存现场
  • 完成中断服务
  • 恢复现场
  • 返回主函数

保存现场,就是说当程序因为中断从正常运行的程序切换到中断处理程序后,为不打扰正常程序的运行,第一件事情就是将所有的寄存器值和之前的 PC 值都保存下来,以便日后恢复。RISC-V 在响应中断时就自动帮我们把 PC 值保存在了mepc 寄存器中,但其余寄存器的值是需要我们自己保存的。我们可以将所有的寄存器值都保存在栈中,也可以给 mscratch 寄存器分配一定空间,然后将值保存在那里。

1
2
3
4
5
6
7
mtimer:    
addi sp, sp, -32
sd a0, 0(sp)
sd a1, 8(sp)
sd a2, 16(sp)
sd a3, 24(sp)
# ...

中断服务,就是具体处理中断的程序啦。首先,我们可以通过 mcause 寄存器判断一下这是不是我们要处理的中断,然后就可以调用打印函数打印一句话,证明中断程序已经执行了。哦,不能忘记每次时钟中断处理都要更新 mtimecmp 寄存器,否则时钟中断信号就不会被清除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    # get the cause of interrupt or exception
# to make sure it is mtimer
csrr a1, mcause
andi a1, a1, 0x3f # get the last 7 bit
li a2, 7 # mtimer interrupt is 7
beq a1, a2, 1f
j 3f
1:
# handle timer interrupt
# add mtimecmp
la a1, 0x2004000
ld a2, 0(a1)
li a3, 2000000
add a2, a2, a3
sd a2, 0(a1)

call printime

恢复现场,到此为止中断程序就快做完了,马上要准备返回正常运行的程序了。我们需要把之前保存的寄存器值全部恢复过来。由于篇幅原因,我只写出了部分寄存器的保存和恢复,但保险起见,我们最好保存所有的寄存器。在程序的最后,使用 mret 语句将 mepc 寄存器中保存的 PC 值取出,恢复正常程序的执行。

1
2
3
4
5
6
7
3:
ld a0, 0(sp)
ld a1, 8(sp)
ld a2, 16(sp)
ld a3, 24(sp)
addi sp, sp, 32
mret

关于 printime 函数,我们使用 C 语言实现:

1
2
3
4
int timer = 0;
void printime() {
printf("Hello RISC-V %x\n", timer++);
}

现在就可以了么?还没有!我们必须在 boot.s 中先行更新一下 mtimecmp 寄存器,不让其为 0 ,否则系统会以为我们完全没有引入时钟中断!

1
2
3
li   t5, 0x2004000
li t4, 2000000
sw t4, 0(t5)

Makefile 编译

整个项目进展到这里,已经有很多文件需要我们去处理了。我们先停下脚步,整理一下我们的文件夹。

1
2
3
4
5
6
7
8
9
10
11
riscv-from-scratch
|--- build # 项目构建后生成的文件
|--- kernel # 整个项目的源代码
| |--- boot.s
| |--- main.c
| |--- print.c
| |--- ns16550a.c
| |--- ns16550a.h
| |--- mtrap.s
| |--- my-virt.ld
|--- Makefile

现在仍然按照 RISC-V from scratch 4 中使用的编译命令恐怕就很麻烦了,每次编译光写这么多文件名就足够令人头大,而且每次一点小小的更新都要将所有文件都重新编译链接也不是很划算。因此这里还是推荐使用 Makefile 帮助大家构建工程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CC=riscv64-unknown-elf-gcc
AS=riscv64-unknown-elf-as
LD=riscv64-unknown-elf-ld
TARGET=build/a.out
CFLAG= -c -ffreestanding
VIRTLD=-T kernel/my-virt.ld

all: build/boot.o build/main.o build/mtrap.o build/ns16550a.o build/print.o
$(LD) $(VIRTLD) $^ -o $(TARGET)

build/%.o: kernel/%.s
$(AS) -g $^ -o $@

build/%.o: kernel/%.c
$(CC) $(CFLAG) $^ -o $@

qemu: $(TARGET)
qemu-system-riscv64 -machine virt -m 128M -nographic \
-kernel build/a.out \
-bios none

clean:
rm build/*

编译运行,哇,它成功了!

监管者模式

什么是监管者模式?相比于机器模式的最高权限和强制手段,监管者模式没有这么高的权限。一般来说,监管者模式就是为对标现代操作系统而生的。监管者模式通常存在于复杂的 RISC-V 系统上,其核心功能就是支持内存分页、内存保护、中断/异常处理等,并且为用户模式提供隔离以及 SEE (Supervisor Execution Environment)。

回顾一下我们的程序,你会发现除了 main.c 函数的部分在监管者模式执行,其他代码几乎都在机器模式下执行。由于机器模式有近乎无限大的权限,让大量代码在该模式下运行是危险的,因此我们应当尽量用中断委托等手段,让更少的代码运行在机器模式下,还有一个重要的原因是,时钟中断处理程序往往与进程调度、切换有关,如果这些操作全在机器模式下完成,是十分不便且危险的。

痛苦的尝试

哈哈哈,接下来就是长时间的查阅资料与令人绝望的编写尝试,我受教颇多。我甚至发现很多实验情况与 RISC-V 官方文档描述的不符,可能是 virt 机器上对 RISC-V 机制的理解和实现不是很到位,也可能是我能力有限,没有领悟到关键。不想看这么多啰嗦和令人作呕的尝试的读者可直接跳过这部分。

这里,我尝试将时钟中断处理放在监管者模式下。在学习完 RISC-V 的中断委托机制后,我首先写出如下代码,将所有的中断/异常处理都委托给监管者模式。

1
2
3
li   t5, 0xffff
csrw medeleg, t5
csrw mideleg, t5

如果你使用 GDB 跑一下上面的代码,会发现 medelegmideleg 寄存器的值并不是 0xffff ,这是因为在 virt 机器上,有些中断/异常是不允许被委托给监管者模式的,因此这些位域会被硬接地。实践出真知,经过调试运行后,我发现 medelegmideleg 寄存器分别为 0xbfff0x666 。参考 mcause 寄存器对应事件表,这意味着,能被委托给监管者模式的中断事件只有 SEIPSTIPSSIP ;而所有的异常事件都能被委托给监管者模式。这真是令人奇怪,这意味着机器模式、用户模式下的中断处理程序无法被委托给监管者模式了。

然而实践告诉我,事实上监管者模式下的时钟中断处理也不是直接跳转到监管者模式的😅。它貌似无论如何都会跳转到机器模式下的处理程序,这可能是因为,mtimecmp 寄存器的更改只能在机器模式下完成。

第一种尝试,按照 RISC-V privileged ISA Specification 如下描述

The UTIP and STIP bits may be written by M-mode software to deliver timer interrupts to lower privilege levels. User and supervisor software may clear the UTIP and STIP bits with calls to the AEE and SEE respectively.

我们只需要在 mtrap.s 中将 mip 寄存器的 STIP 位置为 1 ,就可以将时钟中断传递给监管者模式了。但要注意到,进入监管者模式处理时钟中断后,就无法将 mip 寄存器的 STIP 位清除了(没错就是不行),可能因为这是机器模式才有的权限!意味着你的程序从此永远都在处理时钟中断的路上。为此,你必须在监管者模式处理时钟中断完成后,再跳入到机器模式下将 mip.STIP 位清除(见下左图)。

第二种尝试。我试着在机器模式下将 sip.STIP 位置为 1 ,然后监管者模式的时钟中断就可以启动了,再将 sip.STIP 位置为 0 ,但这个尝试不成功,因为 sip.STIP 在机器模式下居然无法被置为 1 (见上中图)。

第三种尝试。我发现 sip.STIP 位置为 1 不成功,于是直接打算将 sip.SSIP 置为 1 ,即将时钟中断转变成软件中断处理😅,这次,居然成功了(见上右图)。

事实上还有一条野路子,就是在进入机器模式的时钟中断处理程序后,强行更改 mepc 寄存器和 sepc 寄存器,让程序执行完机器模式的中断后,mret 地跳转到监管者模式下,然后 sret 地回到主程序中😅,这样做只能取得部分成功,中断处理程序在处理第二个时钟中断时会莫名卡一下。

经过这几天的尝试,我也是被这些奇怪的东西搞的晕头转向,之所以写这么详细,一方面是记录一下自己的实践过程,利于未来解决这一问题,另一方面也是提醒读者和我,要不断思考、尝试、总结,再次说明,读者没有必要细究这部分内容,保护头发,远离玄学。

最终

最终我的实验方式是,在机器模式下的处理程序中将 sip.SSIP 置为 1 ,即将时钟中断转变成软件中断处理😅,然后触发监管者模式的中断处理程序,进而打印出字符串以及清除中断信号。以下是详细介绍:

首先,因为我们是将时钟中断转变为监管者模式的软件中断,期望用监管者模式下的处理程序处理中断。因此,我们仍然需要用到中断委托机制,将软件中断委托给监管者模式。这里我偷个懒,直接把 0xffff 赋值给 midelegmedeleg 寄存器了。

1
2
3
li   t5, 0xffff
csrw medeleg, t5
csrw mideleg, t5

我们的时钟中断会首先触发机器模式下的时钟处理程序,而当且仅当在机器模式下,程序才能更新 mtimecmp 寄存器,因此,M-mode 下的时钟处理程序需要完成:

  • 读取 mtimecmp 寄存器,累加后在写入
  • 给出监管者模式的软件中断信号

特别注意:我们的代码中无需时刻比较 mtime 寄存器和 mtimecmp 寄存器的大小,我推测这是硬件帮我们做的事。我们只需要关心 mtimecmp 寄存器的值就行了。

1
2
3
4
5
6
7
8
9
10
1:
# handle timer interrupt add mtimecmp
la a1, 0x2004000
ld a2, 0(a1)
li a3, 2000000
add a2, a2, a3
sd a2, 0(a1)
# delegate to S-mode Software Interrupt
li a1, 1 << 1
csrw sip, a1

由于在这里我们将 sip.SSIP 位置为 1 ,因此会马上触发监管者模式下的中断程序。我将监管者模式的中断处理程序放在文件 strap.s 中,那么这中断处理程序该怎么写呢?其实还是老样子:

  • 保存现场
  • 完成中断服务
  • 恢复现场
  • 返回主函数

保存、恢复现场的部分就略过了,重点说一下中断服务,首先需要判断是什么中断/异常类型,再调用 printime 函数,最后清除软件中断信号。

1
2
3
4
5
6
7
8
9
10
11
12
strap_vector:
# save registers
# get cause of interrupt
csrr a1, scause
andi a1, a1, 0x3f
li a2, 1
bne a1, a2, 1f
call printime
li a1, 0
csrw sip, a1
# load registers
# sret

最后,大家修改一下 Makefile ,再进行编译运行,就会发现字符串非常有规律地被打印出来了!而且,打印函数还是运行在监管者模式下的。这为我们接下来的进程调度工作做了很好地铺垫。

接下来

呼,本博文一下子介绍了 printf 函数的实现和时钟中断处理。有没有感觉头晕脑涨了呢,千万不要灰心丧气,这也是我好几天的工作成果,遇到过不去的地方是很正常的。遗憾的是,我最终也没有弄明白如何”正确“地处理时钟中断委托,恳请网上的各位大佬们指点迷津啊🤷。

和前文讲的一样,接下来,我们开始准备攻克下一个难题——进程。首先需要明白进程的含义,并定义一个进程的结构体,最后,我们期望得到一个可多进程运行的小内核。除此之外,我们可能也需要弄明白如何把从键盘的输入字符打印在屏幕上。


RISC-V from Scratch 6
https://dingfen.github.io/2020/08/17/2020-8-17-riscv-from-scratch-6/
作者
Bill Ding
发布于
2020年8月17日
更新于
2024年4月9日
许可协议