RISCV Kernel
本文记述一些我在实现 RISCV 内核过程中感兴趣的问题和解决方案。
Kernel Relocation
重定位内核到高地址。
在 qemu 中,内核被加载到 0x80200000 地址。 在 linux 内核中,常见的做法是将内核加载到 0xffffffff_xxxxxxxx 的随机高位地址中。这样可以增强内核的安全性。
对于一般的函数、符号都比较好解决,只需要通过一些参数使得编译出来的代码通过 PC 相对寻址即可。 最关键的问题是,当我们在 rust 中使用了 dyn trait 或者在 C++ 中使用了虚函数之后,编译器会生成一些对 vtable 的访问,这些访问是通过绝对地址来实现的。 如果是 C++,那么还相对方便规避虚表;但是在 rust 中,Display、Debug 等常用 trait 都是 dyn trait,无法规避。
在大二时我探索了很久这个问题。但由于当时缺少资料,我没有了解到正确的解决方案。 大四寒假时,感谢 LLM 技术的蓬勃发展,我终于找到了解决方法。
linux 关键代码位于 https://github.com/torvalds/linux/blob/master/arch/riscv/kernel/vmlinux.lds.S#L109。
.rela.dyn : ALIGN(8) {
__rela_dyn_start = .;
*(.rela .rela*)
__rela_dyn_end = .;
}
这个代码段定义了一个名为 .rela.dyn 的 section,其中包含了所有的重定位信息。 具体来说,每 24 字节为一个重定位项,包含了重定位类型、符号位置、数据值等信息。 每一个诸如虚表等存储绝对地址的指针都会在编译时生成一个重定位项与之对应,记录了这个指针自己的地址和指针未经重定位时的值。
因此,内核只需要在启动时,首先用位置无关的代码来解析这些重定位项,并依此修改自身的代码中的绝对地址即可。
具体来说:
- 链接脚本将
.rela.dynsection 包含在内核中。 - RUST 编译参数指定
-Ccode-model=medium, 使得编译器生成的代码使用 PC 相对寻址;这里一定不能额外添加-Crelocation-model=pie, 否则会生成 GOT 表,反而导致任意符号都需要查表访问。 - 链接参数指定
-pie --emit-relocs。 - 内核启动时,在调用任意 dyn trait 函数之前,根据
.rela.dyn修改自身。我自己的实现是在链接脚本中指定了起始物理地址为0x0,然后在运行期通过 PC 相对寻址得到内核的实际加载地址。 - 执行初始化,创建虚拟内存映射等。
- 重新按照新地址修复内核代码中的绝对地址。
- 退出 rust 到汇编,应用虚拟内存。
- 从汇编再次进入 rust,此时内核已经被重定位到高地址了。
多目标支持
我购置了一台 RISC-V 开发板,因为做了一些适配工作。
目标平台为 StarFive VisionFive2 v1.3b。
Boot
VisionFive2 在 opensbi 之后还会运行一个 uboot 引导程序。 为了和 linux 保持兼容性,我们需要将内核正确打包为一个 Image。
详情请参考 https://docs.linuxkernel.org.cn/arch/riscv/boot-image-header.html。
Image Header 直接嵌在代码段的开头即可。其中前 8 个字节可以是指令,因此我直接放置了跳转指令,调准到 Header 之后实际的代码。
TEXT_OFFSET 是内核代码相对于物理地址起始点的偏移。在 qemu 中,物理地址起始点是 0x80000000,而在 visionfive2 上是 0x40000000。 而内核代码的起始地址分别(一般)是 0x80200000 和 0x40200000,因此 TEXT_OFFSET 均为 0x200000。
当然,这里也可以修改 offset 为其他值,uboot 会根据 Image Header 中的 text_offset 来加载内核到指定的位置(phy_addr+text_offset)。
_start:
# Image Header
j .L_start
j _start
.balign 8
.quad TEXT_OFFSET /* text_offset */
.quad ekernel - skernel /* image_size */
.quad 0 /* flags */
.word 2 /* version */
.word 0 /* res1 */
.quad 0 /* res2 */
.quad 0x5643534952 /* magic "RISCV" */
.word 0x05435352 /* magic2 "RSC\x05" */
.word 0 /* res3 */
.L_start:
# actual codes
UART
UART 是最基本的外设,是所有调试的基础。 其实适配起来非常简单,而且初始化 UART 其实可以省略,因为 opensbi 已经初始化好了 UART。
以下是一些对比:
- 基地址:均为
0x10000000。 - 寄存器布局:qemu上每个寄存器只占 1 字节,而 VisionFive2 上每个寄存器占 4 字节。
- IO位宽:qemu上写入使用
sb(8位)指令,而 VisionFive2 上需要使用sw(32位)指令。但数据均只有一个字节有效。
FDT
有时需要经常在 qemu 和实际开发板之间切换,如果每次都修改一些常量的定义未免太麻烦,而专门为某个平台写一个适配层又感觉有些不优雅。 因此我添加了设备树支持。
内核在启动时, a1 寄存器中存储着扁平设备树(FDT)的内存地址。 设备树中存储着所有设备的相关信息,比如 UART 的地址、位宽等。
我直接使用 fdt crate 对设备树进行解析,运行时获取 UART 的相关信息,从而实现了在 qemu 和 VisionFive2 之间切换无需修改配置、重新编译。
具体来说,/chosen 节点的 stdout-path 会定义使用哪个设备作为标准输出。在 visionfive2 上和 qemu 上均为 /soc/serial@10000000。
虚拟内存
在 RISCV 规范中,每一个叶页表项都包含 A 位和 D 位,分别表示该页是否被访问过和是否被修改过。 他们将在访问/写入对应页面时由硬件自动设置为 1。 部分芯片并没有实现这个功能,此时规定的行为是,如果 A 位和 D 位不是 1,则访问/写入对应页面时会触发异常。
因此我们需要在创建页表时,直接设置 A 位和 D 位为 1。
在线上板测试
我编写了一个框架,部署在连接开发板的计算机上,可以为网络上的用户提供测试 kernel 的能力,并通过冷启动的方式保证测试之间无干扰。
- 用户通过 POST 请求上传内核镜像
- 经过排队后,服务器会启动开发板的电源(通过串口继电器控制)
- 开发板通过DHCP+TFTP的方式从服务器下载内核镜像并启动
- 用户在线获取开发板的输出
- 运行结束后,关闭开发板电源
目前还很简陋,只包含最基本的功能。未来计划优先完善实时监看的功能。