【翻译】Bare metal programming with RISC-V guide

原文作者@popovicu

原文链接Bare metal programming with RISC-V guide

版权声明: 本文为 @popovicu 的原创作品。本文的翻译仅供学习参考,更多详细信息请访问原文链接。

RISC-V 裸机编程指南

发表于:2023年9月9日 | 上午12:05

@popovicu94

今天我们将探讨如何为 RISC-V 机器编写一个裸机程序。为了确保可重复性,我们将目标设定为 QEMU 的 riscv64 virt 虚拟机。

我们将简要介绍 RISC-V 机器启动的初始阶段,以及如何插入你自己的定制软件来进行裸机编程!

在本文的结尾,我们将为我们的 RISC-V 机器编写一个裸机程序,并向用户发送字符串“hello”,而不依赖于运行机器上的任何支持软件(操作系统内核、库等)。

机器启动和初始软件运行

一般概念

如果你熟悉计算机如何启动,可以跳过这一部分。

当一台真实的机器启动时,硬件首先运行健康检查,然后将要运行的第一批指令加载到内存中。一旦指令加载完成,处理器核心初始化其寄存器,程序计数器指向第一条指令。从那时起,软件便可以运行。

在像小型微控制器这样的简单系统中,所有的软件都包含在一个单一的二进制指令块中。处理器接下来将只执行这些指令。在像笔记本电脑或手机这样的更复杂的系统中,启动有更多阶段。

在这些复杂系统中,传统上,第一批指令是 BIOS,它的任务是将引导加载程序加载到内存中并将控制权移交给它。引导加载程序通常很小,便于加载到运行内存中,处理器可以轻松开始执行它的代码。然后它将操作系统内核加载到内存中(实现引导加载程序本身就是一门学问)。

每台机器以自己的方式加载初始软件。例如,BIOS 可以存储在一个独立的存储芯片上,通电后,存储的内容将被加载到内存中的固定地址,然后处理器从该地址开始执行。

QEMU 启动

即使 riscv64 virt 机器是虚拟的,它仍然有自己的启动顺序。它会经历多个阶段,目前我们不会探索所有细节。请关注后续文章中的更多详细介绍。

理解这个虚拟机的关键在于,显然它没有连接读取软件的芯片(它是虚拟的),所以 QEMU 通过某种方式模拟这一过程。你可能在之前的 QEMU 示例中见过 -bios 标志,现在你应该能对它有一个强烈的直觉。如果你猜测这是传递你的虚拟 RISC-V 核心在启动时执行的第一批指令,你是几乎正确的*

零阶段引导加载程序 (ZSBL)

一旦你启动了这个虚拟机,QEMU 就会在 0x1000 地址填充一些指令,并将程序计数器设置为该地址。这相当于一台真实的机器在板上有一些硬编码的 ROM 固件(存放在某个芯片中),并在启动时将其内容转储到 RAM 中。你无法控制这些指令,它们不是你软件镜像的一部分,通常来说,我认为你没有理由想要覆盖这些指令,而且它们实际上对于更复杂的设置非常有用(我保证我们会在后续文章中讨论它们)。对于好奇的人来说,这几条指令就是零阶段引导加载程序(ZSBL)。ZSBL 设置了一些寄存器,具体原因我们将在未来探索(现在,你可以基本忽略这些寄存器的设置),并跳转到 0x80000000 地址,这才是真正的“战斗”开始的地方!

QEMU 的 -bios 标志

0x80000000 是 QEMU 开始运行用户提供的第一条指令的地址,这些指令在虚拟机启动时立即加载。如果你不传递任何东西,QEMU 将使用默认设置,并加载一个名为 OpenSBI 的软件。下一篇文章将详细介绍 RISC-V 中 SBI 的概念,以及 OpenSBI 究竟是什么。需要注意的是,RISC-V 中的 SBI 并不是真正的 BIOS,而是非常类似的东西。我的个人猜测是,QEMU 作者简单地复用了在其他架构(如 x86)上代表 BIOS 的标志。不过需要记住的是,SBI 在功能上与 BIOS 非常相似,更重要的是,它是可以定制的。

-bios 标志接受一个 ELF 二进制文件,包含指令和其他数据,按段组织。ELF 是 Linux 的标准二进制格式,虽然 ELF 文件格式的详细内容超出了本文的讨论范围,但我们可以简化理解为它是一个键值对映射,其中键是段的起始地址,值是需要加载到该地址的字节序列。因此,传递给 -bios 标志的 ELF 文件会填充从 0x80000000 开始的内存(这正是 QEMU 默认的 OpenSBI 镜像所做的事情)。

关于 -kernel 标志的说明

如果你之前用 QEMU 启动过操作系统(例如 Linux),你可能用过 -kernel 标志。它的作用基本与 -bios 标志相同:你可以传递一个 ELF 映像,覆盖其他内存区域,从概念上讲,它只是将字节转储到内存中。我们今天不会使用这个标志,但会在后续文章中详细介绍它的用法。

ELF 文件如何在启动过程中使用?

虽然概念上 ELF 文件只是一种填充内存的方法,但它们绝对不简单,你无法在一个下午编写一个简单的解析器。细心的读者可能会想:机器如何知道从 ELF 文件中解析出映射到某个地址(如 0x12345678)的内容并将其加载到内存中?这是个很好的问题——在我们的案例中,我们使用的是虚拟机,基本上是在模拟一台拥有智能数字电路或极其复杂的初始软件引导程序的机器,而这些内容在机器上电时已经准备好存储在内存中。当然,这不是在真实机器中发生的事情。真实机器上电时加载的软件是存储在机器存储器中的平坦二进制块,上电时它会直接被转储到内存中,实际上并没有任何解析过程。但由于我们在这里处理的是虚拟机器,几乎没有限制,我们无需受到制造硬件复杂性的约束。

为 RISC-V 编写自定义 “BIOS”

我们已经确定 0x80000000 是机器执行的第一条用户提供指令的位置。我只是将其作为一个事实给出,如果你想了解更多背景,可以从这里开始。基本上,我们看到的是,DRAM 在地址空间中映射为从 0x80000000 开始(如果你不理解这是什么意思,不用担心,本文接下来的内容对这个概念的依赖不大)。

让我们开始构建一个 ELF 文件,它将在地址 0x80000000 处布置一些处理器指令,以向用户显示消息“hello”!

通过 UART 与用户交互

曾经做过嵌入式系统编程的人肯定熟悉 UART 的概念。UART 是一种非常简单的设备,用于最基本的输入/输出形式:它有一根输入线(接收,称为 RX)和一根输出线(发送,称为 TX),每次传输一位。如果你要连接两台设备通过 UART 通信,一台设备的 TX 连接到另一台设备的 RX,反之亦然。如果你阅读本文时从未接触过 UART,我强烈建议你至少购买最便宜的 Arduino,并通过 USB 转 UART 电缆与计算机通信。这个概念与我们在这里做的完全相同,但你将真正动手操作,理解会更加深刻,因为这里的场景是完全虚拟化的。

QEMU 虚拟化了虚拟机上的 UART 设备,我们的软件可以访问它。当你打开 QEMU 的串行端口(UART)部分时,基本上当你按下键盘上的按钮时,按键的代码会从主机的 TX 发送到虚拟机的 RX,而当虚拟机在其 TX 上输出某些内容时,它会在终端中图形化显示给你(因此你不必解码来自模拟板的电信号 :)),例如,如果虚拟机发送 8 位代表 65 的数据,你的 QEMU 将显示字符 a,因为这是它的 ASCII 代码。

我们知道 QEMU 将 UART 映射到地址 0x10000000(可以在 QEMU 的源代码中查看),这里虚拟化的设备是 NS16550A。具体细节不重要:对于本文的目的来说,这意味着如果你从软件中向该地址发送一个 8 位值,它将通过虚拟化的 UART 设备的 TX 线路发送出去。实际上,这意味着如果你打开 QEMU 的串行端口,你写入 0x10000000 的字符将显示在你的控制台中。

将代码整合在一起

了解了所有这些知识后,我们现在可以编写代码。我们即将构建的 ELF 文件会在地址 0x80000000 上布局一些指令,这些指令依次将字符 ‘h’、‘e’、‘l’、‘l’ 和 ‘o’ 发送到地址 0x10000000。最后,代码会进入一个无限循环(这样 QEMU 不会由于某些奇怪的原因崩溃,并且我们可以检查输出)。

 .global _start
 .section .text.bios

_start: addi a0, x0, 0x68
 li a1, 0x10000000
 sb a0, (a1) # 'h'

 addi a0, x0, 0x65
 sb a0, (a1) # 'e'

 addi a0, x0, 0x6C
 sb a0, (a1) # 'l'

 addi a0, x0, 0x6C
 sb a0, (a1) # 'l'

 addi a0, x0, 0x6F
 sb a0, (a1) # 'o'

loop: j loop

将此文件保存为 hello.s。让我们将这个文件汇编为机器码。在我的情况下(可能也是你的情况),我使用了跨平台的工具链,这意味着我在与目标平台不同的平台上进行开发。具体来说,我在 x86 机器上开发此软件,并为 riscv64 机器构建。

要汇编此文件,我运行以下命令:

riscv64-linux-gnu-as -march=rv64i -mabi=lp64 -o hello.o -c hello.s

具体命令可能会根据你使用的 riscv64 汇编器有所不同,这是我通过 Debian 系统的包管理器获得的工具。我建议你通过互联网获取构建 riscv64 软件的正确工具链,通常只需获取正确的软件包。

现在,代码只是被汇编了,这意味着我们已经将软件指令转换为机器码格式,但这个二进制文件还不能作为我们的伪 BIOS 使用。我们需要使用 链接器 并通过 链接脚本 来确保生成的指令按预期布局在 0x80000000 地址。让我们编写链接脚本。

MEMORY {
  dram_space (rwx) : ORIGIN = 0x80000000, LENGTH = 128
}

SECTIONS {
  .text : {
    hello.o(.text.bios)
  } > dram_space
}

我们不会深入解释这些内容的含义,但简而言之,现在我们有一种方法可以将这些指令准确地放置在我们想要的位置。让我们用 objdump 验证一下。

riscv64-linux-gnu-objdump -D hello

输出应该类似如下:

Disassembly of section .text:

0000000080000000 <.text>:
    80000000: 06800513           li a0,104
    80000004: 100005b7           lui a1,0x10000
    80000008: 00a58023           sb a0,0(a1) # 0x10000000
    8000000c: 06500513           li a0,101
    80000010: 00a58023           sb a0,0(a1)
    80000014: 06c00513           li a0,108
    80000018: 00a58023           sb a0,0(a1)
    8000001c: 06c00513           li a0,108
    80000020: 00a58023           sb a0,0(a1)
    80000024: 06f00513           li a0,111
    80000028: 00a58023           sb a0,0(a1)
    8000002c: 0000006f           j 0x8000002c

在 QEMU 上运行“伪 BIOS”

现在可以通过运行以下命令启动 QEMU:

qemu-system-riscv64 -machine virt -bios hello

要查看 UART 上发生的情况,请点击顶部菜单中的 View 按钮并切换到串行端口视图。输出应该如下图所示:

Bare metal QEMU 'fake BIOS'

我只想运行代码

访问本文的 GitHub 仓库,运行 make 命令,它将执行我们上述的所有步骤。然后你可以启动 QEMU。