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.s 、entry.s 、head.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.s 和 main.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日
许可协议