RISC-V裸机编程指南(Bare metal programming with RISC-V guide)
原文链接: https://popovicu.com/posts/bare-metal-programming-risc-v/
今天,我们将探讨如何为RISC-V架构的机器编写一个裸机程序。为了确保可复现,目标平台选择为QEMU riscv64 virt
虚拟机。
我们将简要介绍RISC-V机器启动过程的初始阶段,并且说明你可以在何处插入自己的代码来对裸机进行编程!
在本文结束时,我们将为RISC-V机器编写一个裸机程序,该程序能够在不依赖操作系统和其它库函数的情况下,向用户发送字符串'hello'。
机器启动以及运行初始化软件(Machine bootup and running the initial software)
总体概念(General concepts)
如果您熟悉计算机启动过程,您可以选择跳过这部分内容。
当一台真实的计算机开机时,硬件首先执行健康检查,然后将要运行的第一条指令加载到内存中。一旦指令加载完毕,处理器核心初始化其寄存器,其中程序计数器会指向第一条指令所在的地址。从那一刻起,软件就可以开始运行了。
在像小型微控制器这样的简单设备中,初始化软件只是一块单一的二进制指令块,处理器接下来将只执行这些指令。而在像笔记本电脑或手机这样的更复杂的设备中,启动过程有更多的阶段。
在这些更复杂的设备中,首先执行的程序是BIOS(基本输入输出系统),BIOS通常会完成硬件自检、设备初始化等一系列启动步骤,最后会将引导程序载入内存,并将控制权交给引导程序。引导程序通常体积小,易于加载到内存中,处理器可以轻松运行其代码。引导程序会将操作系统内核加载到内存中(不过,实现引导程序本身就是一门学问)。
每台机器都有自己加载初始软件的独特方式。例如,BIOS可以被存储在一块独立的存储芯片上,当机器通电启动时,存储芯片中的内容会被直接填充到内存中的某个固定地址处,然后处理器就会从那个地址处开始执行指令。
QEMU 启动(QEMU bootup)
即使riscv64 virt
虚拟机是虚拟化的,仍然有其自己的启动顺序。它会经历多个阶段,目前我们并不会全部深入探究。敬请期待后续详细介绍这些细节的文章。
显然,riscv64 virt
虚拟机无法从物理意义上的芯片中读取初始化软件(它是虚拟的),所以是QEMU以某种方式模拟了这一点。你可能在之前的QEMU示例中见过-bios
标志,现在你可能对其作用有了深刻的理解。如果你猜测这正是在虚拟RISC-V核心启动时执行的第一批指令,那么你的答案几乎是正确的。
零阶段引导加载器(The Zero Stage Bootloader(ZSBL))
当你启动这款虚拟机时,QEMU会在0x1000
地址处填充一些指令,并将程序计数器的值设置为该地址。这相当于真实机器在主板上有一些硬编码的ROM固件(隐藏在某个芯片中),并在启动时将固件内容复制到RAM中。你无法控制这些指令,即它们不属于你的软件镜像,通常情况下,我也找不到你需要覆盖这些指令的理由,而且实际上它们对于更复杂的设置非常有用(我保证我们将在后续文章中详细讲解)。对于好奇的人来说,这些少量指令就是零阶段引导加载器(ZSBL)。ZSBL会设置几个寄存器(现在你基本可以忽略这些寄存器设置),然后跳转到地址0x80000000
,真正有意义的操作便从此处开始!
QEMU-bios
标志(QEMU -bios
flag)
# 启动qemu示例
qemu-system-riscv64 -machine virt -bios hello
在启动QEMU时,如果通过-bios
标志指定了自己的程序,虚拟机启动后会将指定的程序加载到0x80000000
处,故0x80000000
是QEMU首次执行用户提供指令的地方。如果没有提供任何自定义程序,QEMU将会使用默认设置并加载一个名为OpenSBI的软件。本博客的下一篇文章将详细介绍RISC-V架构中SBI的概念以及OpenSBI究竟是什么。值得注意的是,RISC-V上的SBI并不完全等同于BIOS,但二者功能相似。我的个人猜测是,QEMU的作者只是重新利用了在其他架构(如x86)上表示BIOS的可用标志。无论如何,请记住SBI在功能上与BIOS非常相似,更重要的是,它是可以定制的。
-bios
标志的参数是一个ELF
文件,ELF
文件中包含指令,同时也可能包含一些数据,这些数据以段(section)为单位进行组织。 ELF
是Linux的标准二进制格式,而ELF
文件格式的详细信息已经超出了本文的范畴,但是这里应有的基本认识是ELF其实就是一个键值映射,其中键是段(section)的起始地址,值是需要被加载到该内存地址中的一系列字节。因此,我们提供给-bios
标志的ELF
文件应该从0x80000000开始填充内存(QEMU默认的OpenSBI image就是这样做的)。
关于-kernel
标志的说明(A note on the -kernel
flag)
如果你之前用QEMU启动过操作系统(例如Linux),你可能使用过-kernel
标志。它基本上与-bios
标志是相同的:你可以传递给它一个ELF镜像,该镜像覆盖某些其他内存区域,从概念上讲,它将直接将字节转储到内存中。我们今天不会使用这个标志,我们将在接下来的文章中介绍它的使用方法。
在启动过程中,ELF文件如何被使用?(How can ELF
files be used during the bootup?)
从概念上看,ELF文件只是填充内存的一种方式,但这个过程确实不简单,你不太可能一个下午就写出一个解析器来。有些细心的读者可能会想,我们的机器是如何知道从ELF文件中解析出映射到某个地址0x12345678
的内容,并用这些东西去填充内存的呢?这的确是一个很好的观察。在我们这个例子中,我们使用的是虚拟机,基本上是在模拟一个从概念上就拥有智能数字电路或者复杂的初始软件引导程序的机器,这种程序在开机时就预装在机器的内存中。当然,真实的机器并不会这样操作。真实的机器在开机时加载的软件是作为一个平坦的二进制大块存储在机器存储设备中的,开机时这些数据就直接被转储到内存里,实际上并没有进行任何解析。但是,由于我们现在处理的是虚拟机,所以我们可以尽情发挥,我们并不受到制造执行此类操作的硬件复杂性的约束。
为RISC-V编写一个自定义的"BIOS"(Writing a custom “BIOS” for RISC-V)
我们已经知道了0x80000000
是机器执行的第一条用户指定指令所在的位置。我提到这一点作为一个既定事实,如果你想了解为什么是这样的,可以从这里了解更多细节。简而言之,DRAM
在地址空间中被映射至从0x80000000
开始(如果你不清楚这是什么意思,不必担心,这在本文剩余部分不太重要)。
让我们首先创建一个ELF
文件,该文件将从地址0x80000000
开始放置一些指令,用于向用户显示消息“hello”!
通过 UART 与用户交互(Interacting with the user via UART)
过去从事过嵌入式系统编程的人肯定对UART
(通用异步收发传输器)这一概念非常熟悉。UART
是一种极其简单的设备,用于最基本的输入/输出:它有一根用于输入(接收,称为RX
)的线和一根用于输出(发送,称为TX
)的线,每次只有一位数据在电线上传输。如果你要将两台设备通过UART
连接起来进行通信,那么一台设备的TX
端口就是另一台设备的RX
端口,反之亦然。如果您正在阅读本文且之前未曾接触过UART
,我强烈建议您至少购买一个最便宜的Arduino
,并让它通过USB-to-UART线缆与您的计算机进行通信。这里的概念与我们正在做的事情完全相同,但您是在真实环境中操作,因此会更有意义,因为我们当前所讨论的场景完全是虚拟化的。
QEMU在虚拟机中模拟了一个UART
设备,我们的软件可以访问它。当你打开QEMU的串行端口(UART)时,大体会发生如下情况是:当你按下键盘上的某个键时,该键的编码会从主机的TX
端口发送到VM的RX
端口;而当VM在其TX
端口输出数据时,这些数据会被图形化地呈现在终端中(这样你就无需解析从模拟板上接收到的电信号),例如,如果VM发送出代表数字65的8位数据,QEMU会将其渲染为字符'a',因为这是该字符的ASCII码。
QEMU将UART
映射到地址0x10000000
处(你可以在其源代码中了解到这一信息),在此虚拟化的设备是NS16550A
,具体细节在这里并不重要。对于本文而言,如果你在程序中向地址0x10000000
发送一个8位的数据,该数据将通过虚拟UART
设备的TX
线发送出去。实际上,这意味着如果你打开QEMU的串行端口,你写入到0x10000000
的字符将会在你的控制台中显示。
将所有内容编码在一起!(Coding it all together!)
基于上述所有知识,我们现在可以编写代码了。我们将要构建的ELF文件将在0x80000000
处放置一些指令,依次将字符'h'、'e'、'l'、'l'和'o'打印到地址0x10000000
。最后,代码应该无限循环(以防止QEMU因任何不明原因崩溃,同时我们可以查看输出)。
# 指示_start为全局符号
.global _start
# 标识代码段
.section .text.bios
_start:
# 把一个立即数移动到a1寄存器
# 0x10000000是串口的映射地址,在该地址写入8个字节数据将会被发送到串口
li a1, 0x10000000
# addi: 立即数加法指令, a0: 通用寄存器 x0: 0寄存器,其值总是0
# 1. 把0x68移动到x0寄存器,然后把x0寄存器的值移动到a0寄存器
addi a0, x0, 0x68
# sb store byte: 从a0寄存器移动一个字节的内容到a1寄存器中存储的内存地址中
# 即将0x68移动到内存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在不同的操作系统上可能不同,这取决于具体安装的riscv工具链
riscv64-linux-gnu-as -march=rv64i -mabi=lp64 -o hello.o -c hello.s
具体的命令可能会根据您所使用的riscv64汇编器有所不同,以下是我通过Debian
系统包管理器获取到的工具。我留给读者自行获取适用于构建riscv64软件的正确工具链,通常只需从互联网上获取相应的软件包即可。
现在,代码仅完成了汇编,意味着我们已将汇编文件转换为机器码格式,但该二进制文件尚未准备好作为我们的"BIOS"
使用。我们需要使用链接器,并通过链接器脚本来控制其行为,以确保生成的指令按照预期布局在0x80000000
处。下面来编写链接器脚本。
MEMORY {
/*
1. 定义一块内存区域,权限为读写执行
2. 区域起始地址为0x80000000
3. 大小为128K
*/
dram_space (rwx) : ORIGIN = 0x80000000, LENGTH = 128
}
SECTIONS {
/*
定义text段的布局规则, 把hello.o中.text.bios段的内容放到.text段中
将text段中的内容定位到dram_space区域中,即0x80000000中
这段链接脚本的作用是配置链接器将程序的代码段(.text)定位到一个具有读写执行权限的、从0x80000000开始的128KB DRAM区域中。
*/
.text : {
hello.o(.text.bios)
} > dram_space
}
将上述内容保存到hello.ld
文件中,然后运行下方的命令,会生成最终的可执行文件。
# riscv64-linux-gnu-ld在不同的操作系统上可能不同,这取决于具体安装的riscv工具链
riscv64-linux-gnu-ld -T hello.ld --no-dynamic-linker -m elf64lriscv -static -nostdlib -s -o hello hello.o
我们不会详细介绍这些内容的具体含义,简而言之,我们现在有了将这些指令精确放置到所需位置的方法。接下来,我们用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中运行这个"fake BIOS"(Running the “fake BIOS” on QEMU)
现在可以通过运行以下命令启动QEMU:
qemu-system-riscv64 -machine virt -bios hello
要查看UART
上的情况,请点击顶部菜单中的view
按钮,然后点击serial0
选项。输出应如下所示:
我只想运行代码(I just want to run the code!)
前往本文的GitHub仓库,运行make
命令后,会生成hello
文件,即本文创建的"fake BIOS"。然后,你可以运行qemu-system-riscv64 -machine virt -bios hello
启动QEMU。
注:
本文所需要使用的工具: qemu-system-riscv64
和RISC-V Compiler Toolchain
带中文注释的示例程序仓库: https://github.com/wfenfeng/riscv-bare-metal-fake-bios-with-comments
标签:riscv64,bios,programming,RISC,裸机,a0,QEMU,hello From: https://www.cnblogs.com/fenfeng9/p/18158918