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 |
|
然而,我们为了打出字符串 “hi” ,将主程序写成了这样:
1 |
|
难道不能把它放入一个字符串,循环打印出来么?
1 |
|
你会发现,这样的尝试失败了,你会得到一个很奇怪的链接错误:
1 |
|
这是为什么呢?在本系列的第二篇博客中,我们使用如下方法得到了一个链接器脚本:
1 |
|
该脚本中的内容非常复杂,说实话,大部分内容我至今都没有看懂,但这并不妨碍我们以后的实验,我们只需要知道,这个舶来品现在水土不服了。
再回链接器脚本
什么是链接器脚本,具体来说,链接器控制了各个程序中 section 的合并以及摆放位置,在一般情况下,我们写程序时完全不需要关心程序放在内存的哪个位置,因为我们平时写的代码都是 PIC (Position-Independent Code),它们可以运行在内存中的任意位置。但现在,我们要完成一个 RISC-V 内核,程序摆放的位置就值得考究了,要是你随意摆放的话,那机器怎么知道你要把代码放在哪里,从哪里开始运行呢?
为了编写一个自定义的链接器脚本,我们必然要先学它的基本语法。这里我推荐一个博主的教程 Linker Script File,里面介绍了非常基础的语法知识,非常适合初学者。在这里我就不详细解释链接器脚本的语法了。
学好链接器脚本的语法后,就可以动手写了!
首先,创建文件 my-virt.ld
,加入入口 _start
以及第二章中我们已经加的 MEMORY 指令。
1 |
|
然后就是 SECTION 语句:
1 |
|
将代码 text 放到了内存起始点,数据段紧跟其后。那么问题来了,__global_pointer$
这个值,该指向哪里呢?指向数据段的中部?尾部?顶部?常量静态数据在前在后?全局数据与局部静态数据又如何?那么大家问问自己的直觉,__global_pointer$
最有可能在哪里?顶部?没错,博主本人在做实验时就放在了顶部,还就真的对了(当然博主讲话是负责任的,证据在后面)。
那么现在问题解决了么?还没有!因为我们还没有确定 .init
section 的位置,往前翻翻,注意到 crt0.s
里面的代码,可是放在 .init
section 里面的(没错,就是因为这个,我才把它贴在这里)。在本系列的第二篇博客中,我们已经详细说明了 C Runtime 的重要性,不把它放在 0x80000000
,程序一定跑不了。
1 |
|
为了节省篇幅,把后面的东西都加了进来,事实上也没啥,就是 .rodata
和 .bss
段,分别存放只读数据(常量数据)和未初始化数据,如果不太清楚这些段名的作用,可以参考 C 语言内存分布。
那么到此为止,能不能实现我们预期的结果呢?按一下方式编译运行程序,若不出意外,就可以看到字符串 “hello” 了。
1 |
|
1 |
|
如果大家看过第四章博客,一定对 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 |
|
因此这里我们首先使用 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 |
|
mstatus 寄存器
刚开始执行代码一定是机器模式,但是我们总不能一直让 hart 在机器模式下运行;此外,全局中断使能位也需要我们控制。这些都可以在 mstatus
寄存器上找到,关于 mstatus
寄存器,RISC-V 特权架构 和 RISC-V 中文手册上都有详细介绍。在此就略写几句。
当进入 main
函数时,hart 最好要进入监管者模式。因为 main
函数事实上是我们操作系统内核最主要的函数之一,此外,我们也希望中断能被打开。对照 mstatus
寄存器的位图,我们可以在对应位域置 1 ,来打开中断或者记录信息等。
比如,我们想先打开机器模式的中断使能,那么我们需要:
- 将
mstatus.MIE
位置为 1 ,因为它代表机器模式全局下的中断使能 - 将
mstatus.MPIE
位置为 1 ,它代表了在中断/异常发生前,机器模式全局下的中断使能(我们肯定不想在中断/异常发生一次后,使能就失效了吧)
我们还要将 mstatus.MPP
位置为 01,它代表了中断/异常发生前,代码运行的模式。之所以置为 01(监管者模式),是为了在执行 mret
的时候进入监管者模式。结合之前所说的,写下如下代码:
1 |
|
初始化 BSS 数据段
如果你了解了 C 语言内存分布,你就会知道全局未初始化变量都会放在 BSS 段中,即我们在链接器文件里描述的 .bss
section 。这里不得不说一句,写 C/C++ 未在定义时初始化是非常危险的😅,因为这会导致不确定行为。那么,作为操作系统的开发人员,初始化 BSS 数据段的责任就担在我们身上了。
还记得我们之前定义的 __bss_start
和 __bss_end
吧,它们一个在 .bss
数据段前面,一个在后面,这两个符号是为方便数据初始化而设定的,那么目前,我们先把 .bss
段全部初始化为 0 。
1 |
|
只要你熟悉了 RISC-V 汇编语言,你肯定不难看懂上面的代码。它的作用就是从 __bss_start
开始循环,每次将 0 存放到目标地址,直到 __bss_end
为止。
mie 寄存器
mie
寄存器包含了中断使能位,用于控制中断是否有效。其位域如下图:
我们除了要打开机器模式下的全局中断使能,还需要打开软件、时钟、外部这三部分子中断使能,参照 mie
寄存器的位域图,我们可以写出下面的代码,打开所有机器模式下的中断使能。
1 |
|
mtvec 寄存器
mtvec
寄存器又是什么?该寄存器全名 Machine Trap-Vector Base-Address Register,它存放了 trap vector 信息,包括了基地址和模式位。换句话说,当中断/异常发生时,PC 值肯定需要跳转到中断/异常处理程序,该寄存器就保存了这些处理程序的地址。对 mtvec
寄存器的详细介绍,还是要参考 RISC-V 特权架构 和 RISC-V 中文手册 和 RISC-V privileged manual 资料。
我们先不考虑中断/异常处理程序,先定义一个符号 mtrap_vector
,把它当作处理程序的开始点,然后,把它放入到 mtvec
寄存器中。
1 |
|
转到 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 |
|
我们把 main
函数的地址写入到了 mepc
寄存器中,回想一下 mepc
寄存器,它是用来存放中断处理完后的恢复执行的地址。那么在执行了 mret
之后, PC 值会被置为 mepc
寄存器中的数值,程序就会自己跳转到 main
函数里面了。
接下来
今天,我们一口气介绍了好多寄存器以及中断相关的内容!这可能相当令人烦躁,但也请静下心来,多多尝试,仔细理解每一步。由于这次我将代码进行了拆分介绍,虽然每个地方更加清楚,但缺乏整体观念,因此,在这里将完整代码列出:
1 |
|
在本篇博客,我们重点讲述了 boot.s
的代码的功能,并详尽地介绍了 RISC-V 特权模式以及一部分寄存器的功能。这全是在为之后的工作铺路,接下来我们就要实现时钟中断了,时钟中断十分重要,没有它,我们就无法切换进程,实现进程间的调度。
附:数据的分布
为了理清 RISC-V 中数据到底如何分布,特做以下实验,一方面是为了验证自己的理论知识,另一方面,也是为了检查程序是否有 bug 。话不多说,直接开始实验。
该实验只需要 crt0.s
和 main.c
文件即可,本章中的内容可以不用增加到 crt0.s
文件中。而 main.c
文件,我增加了不少内容:
1 |
|
先来看看各种类型的数据:
-
sg
静态全局已初始化 -
g
全局已初始化 -
ga
全局未初始化 -
l
局部已初始化 -
sl
静态局部已初始化
然后,我们可以按照之前给过的 Makefile
文件编译生成 a.out
,我们首先看看运行结果:
令人欣慰的是,我们的程序没啥错误(至少到目前为止是这样),为了更好地明确各个数据的位置,我们需要用到 objdump
工具,反编译生成的文件 a.out
。在 shell 中输入:riscv64-unknown-elf-objdump -d a.out
,就可以得到反编译生成的 RISC-V 汇编语言。为了方便大家看得更加清楚,我单独将 main
的部分取出,并省略了一些对我们不重要的代码:
1 |
|