RISC-V from Scratch 5

RISC-V from scratch 5

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

简介

欢迎再次来到 RISC-V from scratch ,先快速回顾一下我们之前做过的内容,我们之前已经探索了很多与 RISC-V 及其生态相关的底层概念(例如编译、链接、原语运行时、汇编等)。具体来说,在上一篇文章中,我们完成了一个简陋的 UART 驱动程序,并利用该驱动程序完成了打印字符的任务,今天,我们紧接着上一篇的实验内容,继续深入探讨 RISC-V,完善咱们的小内核。

本篇博客中,我们将会:

  • 写一个自己的链接器脚本
  • 使用 RISC-V 提供的机器模式特权寄存器
  • 初始化 bss 数据段

搭建环境

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

往期回顾

RISC-V from scratch 4 中,我们实现了一个简单的 UART 驱动程序和一个简单的 C 运行时 crt0.s,并达到了预期效果,见下图:

考虑到 crt0.s 的实现已经是第二章的事情了,比较久远,因此在这里贴出代码,也是为了方便下一步的讨论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.section .init, "ax"
.global _start
_start:
.cfi_startproc
.cfi_undefined ra
.option push
.option norelax
la gp, __global_pointer$
.option pop
la sp, __stack_top
add s0, sp, zero
jal zero, main
.cfi_endproc
.end

然而,我们为了打出字符串 “hi” ,将主程序写成了这样:

1
2
3
4
5
6
7
int main() {
char p = 'h';
uartinit();
uartputc(p);
p++;
uartputc(p);
}

难道不能把它放入一个字符串,循环打印出来么?

1
2
3
4
5
6
int main() {
char p[] = "hello";
uartinit();
for (int i =0; i < 5; i++)
uartputc(p[i]);
}

你会发现,这样的尝试失败了,你会得到一个很奇怪的链接错误:

1
relocation truncated to fit: R_RISCV_HI20 against `p'

这是为什么呢?在本系列的第二篇博客中,我们使用如下方法得到了一个链接器脚本:

1
2
3
# In the `riscv-from-scratch/work` directory...
# Copy the default linker script into riscv64-virt.ld
riscv64-unknown-elf-ld --verbose > riscv64-virt.ld

该脚本中的内容非常复杂,说实话,大部分内容我至今都没有看懂,但这并不妨碍我们以后的实验,我们只需要知道,这个舶来品现在水土不服了。

再回链接器脚本

什么是链接器脚本,具体来说,链接器控制了各个程序中 section 的合并以及摆放位置,在一般情况下,我们写程序时完全不需要关心程序放在内存的哪个位置,因为我们平时写的代码都是 PIC (Position-Independent Code),它们可以运行在内存中的任意位置。但现在,我们要完成一个 RISC-V 内核,程序摆放的位置就值得考究了,要是你随意摆放的话,那机器怎么知道你要把代码放在哪里,从哪里开始运行呢?

为了编写一个自定义的链接器脚本,我们必然要先学它的基本语法。这里我推荐一个博主的教程 Linker Script File,里面介绍了非常基础的语法知识,非常适合初学者。在这里我就不详细解释链接器脚本的语法了。

学好链接器脚本的语法后,就可以动手写了!

首先,创建文件 my-virt.ld ,加入入口 _start 以及第二章中我们已经加的 MEMORY 指令。

1
2
3
4
5
6
ENTRY(_start)
MEMORY
{
/* qemu-system-riscv64 virt machine */
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
}

然后就是 SECTION 语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SECTION 
{
PROVIDE(__stack_top = 0x88000000);
PROVIDE(__uart_base_addr = 0x10000000);

. = 0x80000000;
.text : {
* (.text);
}

.data : {
__global_pointer$ = .;
* (.data);
}
}

将代码 text 放到了内存起始点,数据段紧跟其后。那么问题来了,__global_pointer$ 这个值,该指向哪里呢?指向数据段的中部?尾部?顶部?常量静态数据在前在后?全局数据与局部静态数据又如何?那么大家问问自己的直觉,__global_pointer$ 最有可能在哪里?顶部?没错,博主本人在做实验时就放在了顶部,还就真的对了(当然博主讲话是负责任的,证据在后面)。

那么现在问题解决了么?还没有!因为我们还没有确定 .init section 的位置,往前翻翻,注意到 crt0.s 里面的代码,可是放在 .init section 里面的(没错,就是因为这个,我才把它贴在这里)。在本系列的第二篇博客中,我们已经详细说明了 C Runtime 的重要性,不把它放在 0x80000000 ,程序一定跑不了。

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
SECTION 
{
PROVIDE(__stack_top = 0x88000000);
PROVIDE(__uart_base_addr = 0x10000000);

. = 0x80000000;
.init : {
* (.init);
}
.text : {
* (.text);
}

.data : {
__global_pointer$ = .;
* (.data);
}
.rodata : {
* (.rodata);
}

__bss_start = .;
.bss : {
* (.bss);
}
__bss_end = .;
}

为了节省篇幅,把后面的东西都加了进来,事实上也没啥,就是 .rodata.bss 段,分别存放只读数据(常量数据)和未初始化数据,如果不太清楚这些段名的作用,可以参考 C 语言内存分布

那么到此为止,能不能实现我们预期的结果呢?按一下方式编译运行程序,若不出意外,就可以看到字符串 “hello” 了。

1
2
3
4
5
6
> riscv64-unknown-elf-gcc -c kernel/main.c -o build/main.o
> riscv64-unknown-elf-gcc -c kernel/ns16550a.c -o build/ns16550a.o
> riscv64-unknown-elf-as kernel/crt0.s -o build/crt0.o

> riscv64-unknown-elf-ld build/crt0.o build/main.o build/ns16550a.o -T kernel/my-virt.ld
> qemu-system-riscv64 -machine virt -m 128M -kernel a.out -bios none -nographic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CC=riscv64-unknown-elf-gcc
AS=riscv64-unknown-elf-as
LD=riscv64-unknown-elf-ld

TARGET=build/a.out
CFLAG= -c
VIRTLD=-T [your-src-directory]/my-virt.ld

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

build/%.o: [your-src-directory]/%.s
$(AS) -g $^ -o $@

build/%.o: [your-src-directory]/%.c
$(CC) $(CFLAG) $^ -o $@

如果大家看过第四章博客,一定对 Makefile 的简洁方便印象深刻,所以我也提供了 Makefile 的编写,也会发现我们这次编译程序的方式不太相同(两次编译都能过)。如果你不喜欢这种方式,可以尝试一下这个 riscv64-unknown-elf-gcc -g -ffreestanding -O0 -Wl,--gc-sections -nostartfiles -nostdlib -nodefaultlibs -Wl,-T,my-virt.ld crt0.s ns16550a.c main.c 冗长繁杂的命令😅。

机器模式

我们要想实现一个可以工作的内核,crt0.s 中那几行简短的代码肯定是不够的,因为 crt0.s 中只是建立的栈,找到了数据段位置,然后就跳转到 main 函数了。然而一个可以工作的内核需要处理中断控制、中断处理、状态指示、权限设置甚至多 CPU 处理等多方面问题。考虑到 crt0.s 是机器启动后,最先开始执行的代码,一定处在机器模式——权限最高的模式,那我们必然要给它塞一些更加繁重的任务。

可能有些读者对 RISC-V 的特权架构不是很熟,建议看一下 RISC-V 特权架构RISC-V 中文手册RISC-V privileged manual 。由于篇幅关系,我就不在这里展开介绍了。

首先,我们要给 crt0.s 文件正式升级,将它改名为 boot.s ,或者随便什么你喜欢的名字(start.sentry.shead.S 之类的)。

mhartid 寄存器

考虑到 QEMU virt 机器可以使用多个处理器,那么我们就需要防止多个 hart 执行 boot.s ,在机器刚开始运行(以及我们刚开始编写代码时),一哄而上可不是什么好的选择。

1
2
3
4
5
6
7
8
9
_start:
# read our hart identifier into t0
# see if it is 0, if not to busy loop
csrr t0, mhartid
bnez t0, 4f
...
4:
wfi
j 4b

因此这里我们首先使用 mhartid 寄存器获取 hart 的 ID ,csrr 是一个伪指令,它读取一个 CSR 寄存器。让非 0 的 hart 全部跳转到死循环里,并将它们 stall 住,死循环中 wfi 指令在这方面是专家:

The Wait for Interrupt instruction (WFI) provides a hint to the implementation that the current hart can be stalled until an interrupt might need servicing.

satp 寄存器

现在,我们就要开始设置一些寄存器了以及一些初始化任务。

我们需要取消内存分页机制,这样我们就可以完全控制 MMU (Memory Management Unit) ,只不过控制的方式就是让 virtual memory = physical memory 。参考 RISC-V 特权架构RISC-V 中文手册,使用 csrw 语句,向 satp 寄存器写 0 即可。

1
2
3
# SATP should be 0 
# Supervisor Address Translation and Protection
csrw satp, zero

mstatus 寄存器

刚开始执行代码一定是机器模式,但是我们总不能一直让 hart 在机器模式下运行;此外,全局中断使能位也需要我们控制。这些都可以在 mstatus 寄存器上找到,关于 mstatus 寄存器,RISC-V 特权架构RISC-V 中文手册上都有详细介绍。在此就略写几句。

当进入 main 函数时,hart 最好要进入监管者模式。因为 main 函数事实上是我们操作系统内核最主要的函数之一,此外,我们也希望中断能被打开。对照 mstatus 寄存器的位图,我们可以在对应位域置 1 ,来打开中断或者记录信息等。

![](/img/mstatus.png)

比如,我们想先打开机器模式的中断使能,那么我们需要:

  • mstatus.MIE 位置为 1 ,因为它代表机器模式全局下的中断使能
  • mstatus.MPIE 位置为 1 ,它代表了在中断/异常发生前,机器模式全局下的中断使能(我们肯定不想在中断/异常发生一次后,使能就失效了吧)

我们还要将 mstatus.MPP 位置为 01,它代表了中断/异常发生前,代码运行的模式。之所以置为 01(监管者模式),是为了在执行 mret 的时候进入监管者模式。结合之前所说的,写下如下代码:

1
2
li   t0, (0b01 << 11) | (1 << 7) | (1 << 3)
csrw mstatus, t0

初始化 BSS 数据段

如果你了解了 C 语言内存分布,你就会知道全局未初始化变量都会放在 BSS 段中,即我们在链接器文件里描述的 .bss section 。这里不得不说一句,写 C/C++ 未在定义时初始化是非常危险的😅,因为这会导致不确定行为。那么,作为操作系统的开发人员,初始化 BSS 数据段的责任就担在我们身上了。

还记得我们之前定义的 __bss_start__bss_end 吧,它们一个在 .bss 数据段前面,一个在后面,这两个符号是为方便数据初始化而设定的,那么目前,我们先把 .bss 段全部初始化为 0 。

1
2
3
4
5
6
7
    la  a0, __bss_start
la a1, __bss_end
bgeu a0, a1, 2f
l1:
sd zero, (a0)
addi a0, a0, 8
bltu a0, a1, l1

只要你熟悉了 RISC-V 汇编语言,你肯定不难看懂上面的代码。它的作用就是从 __bss_start 开始循环,每次将 0 存放到目标地址,直到 __bss_end 为止。

mie 寄存器

mie 寄存器包含了中断使能位,用于控制中断是否有效。其位域如下图:

我们除了要打开机器模式下的全局中断使能,还需要打开软件、时钟、外部这三部分子中断使能,参照 mie 寄存器的位域图,我们可以写出下面的代码,打开所有机器模式下的中断使能。

1
2
li   t3, (1 << 3) | (1 << 7) | (1 << 11)
csrw mie, t3

mtvec 寄存器

mtvec 寄存器又是什么?该寄存器全名 Machine Trap-Vector Base-Address Register,它存放了 trap vector 信息,包括了基地址和模式位。换句话说,当中断/异常发生时,PC 值肯定需要跳转到中断/异常处理程序,该寄存器就保存了这些处理程序的地址。对 mtvec 寄存器的详细介绍,还是要参考 RISC-V 特权架构RISC-V 中文手册RISC-V privileged manual 资料。

我们先不考虑中断/异常处理程序,先定义一个符号 mtrap_vector ,把它当作处理程序的开始点,然后,把它放入到 mtvec 寄存器中。

1
2
la   t2, mtrap_vector
csrw mtvec, t2

转到 main

看起来我们快要写完了。到这时候,大家可能会变得不耐烦且急躁。于是,写出了最后一句指令 mret

哦不,等等,mret 指令会把我们带到哪里?回顾一下 crt0.s ,在快结束的时候,我们使用指令 jal zero, main 跳转到了 main 函数里,我们的 boot.s 当然也需要跳转到 main 函数。但是,我们还可以用 jal zero, main 指令跳转吗?不行。这样跳转的话,我们仍在机器模式下。为了使 hart 跑在监管者模式下,我们必须使用 mret

所以,mret 指令会把我们带到哪里?参考 RISC-V 的相关资料,在处理 mret 指令时,PC 值会从 mepc 寄存器取得。因此,我们必须将 main 函数的地址存入 mepc 寄存器。

1
2
3
4
la   t1, main
csrw mepc, t1
...
mret

我们把 main 函数的地址写入到了 mepc 寄存器中,回想一下 mepc 寄存器,它是用来存放中断处理完后的恢复执行的地址。那么在执行了 mret 之后, PC 值会被置为 mepc 寄存器中的数值,程序就会自己跳转到 main 函数里面了。

接下来

今天,我们一口气介绍了好多寄存器以及中断相关的内容!这可能相当令人烦躁,但也请静下心来,多多尝试,仔细理解每一步。由于这次我将代码进行了拆分介绍,虽然每个地方更加清楚,但缺乏整体观念,因此,在这里将完整代码列出:

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
# boot.s

.section .init,"ax"
.global _start
_start:
# read our hart identifier into t0
# see if it is 0 if not to busy loop
csrr t0, mhartid
bnez t0, 4f

# SATP should be 0 Supervisor Address Translation and Protection
csrw satp, zero
.option push
.option norelax
la gp, __global_pointer$
.option pop

# BSS section expected to be 0
la a0, __bss_start
la a1, __bss_end
bgeu a0, a1, 2f
1:
sd zero, (a0)
addi a0, a0, 8
bltu a0, a1, 1b
2:
la sp, __stack_top
li t0, (0b01 << 11) | (1 << 7) | (1 << 3)
csrw mstatus, t0
la t1, main
csrw mepc, t1
la t2, mtrap_vector
csrw mtvec, t2
li t3, (1 << 3) | (1 << 7) | (1 << 11)
csrw mie, t3
la ra, 4f
mret
4:
wfi
j 4b

在本篇博客,我们重点讲述了 boot.s 的代码的功能,并详尽地介绍了 RISC-V 特权模式以及一部分寄存器的功能。这全是在为之后的工作铺路,接下来我们就要实现时钟中断了,时钟中断十分重要,没有它,我们就无法切换进程,实现进程间的调度。

附:数据的分布

为了理清 RISC-V 中数据到底如何分布,特做以下实验,一方面是为了验证自己的理论知识,另一方面,也是为了检查程序是否有 bug 。话不多说,直接开始实验。

该实验只需要 crt0.smain.c 文件即可,本章中的内容可以不用增加到 crt0.s 文件中。而 main.c 文件,我增加了不少内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static char sg[] = "hello world!";
char g[] = "hi RISC-V";
int ga;

int main() {
int l = 102;
static int sl = 105;
uartinit();
for(int i = 0; i < 12; i++)
uartputc(sg[i]);
uartputc(l);
uartputc(sl);
for(int i = 0; i < 9; i++)
uartputc(g[i]);
if (ga == 0)
uartputc('g');
return 0;
}

先来看看各种类型的数据:

  • sg 静态全局已初始化

  • g 全局已初始化

  • ga 全局未初始化

  • l局部已初始化

  • sl 静态局部已初始化

然后,我们可以按照之前给过的 Makefile 文件编译生成 a.out,我们首先看看运行结果:

令人欣慰的是,我们的程序没啥错误(至少到目前为止是这样),为了更好地明确各个数据的位置,我们需要用到 objdump 工具,反编译生成的文件 a.out。在 shell 中输入:riscv64-unknown-elf-objdump -d a.out,就可以得到反编译生成的 RISC-V 汇编语言。为了方便大家看得更加清楚,我单独将 main 的部分取出,并省略了一些对我们不重要的代码:

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
0000000080000018 <main>:
# allocate stack space
# ...
80000020: 06600793 li a5,102 # 局部变量 102 直接当作立即数
80000024: fef42223 sw a5,-28(s0) # 存入到 -28(s0)
80000028: 180000ef jal ra,800001a8 <uartinit>
8000002c: fe042623 sw zero,-20(s0)
80000030: a00d j 80000052 <main+0x3a>
80000032: 00018713 mv a4,gp # 会发现 a4 就在 gp 上
80000036: fec42783 lw a5,-20(s0) # 即 gp 就是数据段的开头
8000003a: 97ba add a5,a5,a4 # 从 gp 中取出数据 sg
8000003c: 0007c783 lbu a5,0(a5)
80000040: 2781 sext.w a5,a5
80000042: 853e mv a0,a5 # 全局静态变量 sg
80000044: 1b8000ef jal ra,800001fc <uartputc>
# loop var increasement
# ...
80000060: fe442783 lw a5,-28(s0)
80000064: 853e mv a0,a5 # 从 -28(s0) 中取出 是局部变量 102
80000066: 196000ef jal ra,800001fc <uartputc>
8000006a: 01c1a783 lw a5,28(gp) # 8000101c <sl.1504> # 静态变量 sl 从 gp 中取出
8000006e: 853e mv a0,a5
80000070: 18c000ef jal ra,800001fc <uartputc>
80000074: fe042423 sw zero,-24(s0)
80000078: a00d j 8000009a <main+0x82>
8000007a: 01018713 addi a4,gp,16 # 80001010 <g>
8000007e: fe842783 lw a5,-24(s0)
80000082: 97ba add a5,a5,a4 # 从 gp 中取出 全局变量 g
80000084: 0007c783 lbu a5,0(a5)
80000088: 2781 sext.w a5,a5
8000008a: 853e mv a0,a5
8000008c: 170000ef jal ra,800001fc <uartputc>
# loop var increasement
# ...
800000a8: 05c1a783 lw a5,92(gp) # 8000105c <ga> # 从 gp 中取出 未初始化的全局变量 ga
800000ac: e789 bnez a5,800000b6 <main+0x9e>
800000ae: 06700513 li a0,103
800000b2: 14a000ef jal ra,800001fc <uartputc>
# deallocate stack space and return
# ...
800000c0: 8082 ret


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