引言
RISC-V的作者们旨在提供数种可以在 BSD 许可证之下自由使用的 CPU 设计;该许可证允许像是 RISC-V 芯片设计等派生作品可以像 RISC-V 本身一样是公开且自由发行,也可以是闭源或者是专有财产。
RISC-V – Wikipedia
在过去几十年的开源软件运动中,以 GNU/Linux 等为代表的一系列开源软件成为了科技行业内不可或缺的基础设施,平等的让每一个使用开源软件的个人与团体受益。除了开源操作系统,开源运动在其他方面也并未停下过。各种软件开发框架和系统软件都在拥抱开源(比如,曾将 Linux 称为「癌症」的微软现在也积极的在开源社区中贡献)。
然而,计算机除了软件,还有硬件。开源运动也同样「蔓延」到了硬件。通过开源 PCB 设计图、CAD 文件和芯片设计文件等的开源硬件运动也在同样进行。CPU 是计算机的核心部件,自然也会在开源运动的「射程范围」之内。然而,流行的 CPU 的指令集(ISA)要么商业封闭(x86_64, ARM)、要么显得陈旧(MIPS)。在这样的背景下,开源的指令集 RISC-V 呼之欲出。
[关于 RISC-V 历史的更多阅读:RISC-V – Wikipedia]
相比于传统的 x86_64 指令集,RISC-V 有诸多优点:
- RISC-V 汇编的书写更加自然。
- RISC-V 设计于过去十年,在指令集层面集成了更多现代处理器的特征,更加利于学习与开发;x86_64 的历史包袱太重,对指令集的学习和开发比较复杂。
- 学术界和工业界对 RISC-V 很感兴趣。
然而,RISC-V 毕竟还是一个比较年轻的指令集标准,不足之处也是非常多的:
- 缺少高性能处理器的实现。
- 现行 RISC-V 平台开发更多面向 QEMU,实机开发仍不多。
本文以及未来可能发布的文章将形成一个系列,记录我在 RISC-V 平台上开发的一些经验和感悟。
本文内容
在本文中,我会介绍如何让一个 C 程序跑在 QEMU 的 RISC-V 模拟器上。在这个过程中,我会提到以下内容:
- RISC-V 引导流程
- ELF 格式
- 链接(Linking)
运行环境
编译工具链
GNU 工具链(toolchain)在日常开发中被广泛使用,我们可以选择 GNU 在 RISC-V 平台下的工具链(请选择生成 ELF 文件的工具链版本)。[RISC-V GNU 工具链在 GitHub 的链接](内附工具链的编译与安装步骤)
QEMU 模拟器
QEMU 模拟器支持多种指令集的模拟(emulation),安装指南在官方文档中。[QEMU 官方网站]
程序从哪运行?
我们现在在做一件非常酷的事情:抛弃掉操作系统、直接在平台上跑我们自己的程序。绕过操作系统听上去真的很棒:当我在服务器上进行长时间计算任务时,我总是希望能把操作系统拿掉,让任务可以无损地利用计算资源。但是,如果真的把操作系统拿掉了,我们可能会遇到很麻烦的事情。我们在编程的时候依托了很多「中间程序」的帮忙:比如,我们程序的 I/O 均由操作系统代理进行。没有了操作系统,我们需要做许多额外的工作。
我们以 QEMU 为例,介绍 RISC-V 平台通电后的行为。如果要开启一个 QEMU 模拟实例:
qemu-system-riscv64 \
-machine virt \ # 模拟器类型;
-bios none \ # BIOS 类型;
-m 128M \ # 内存大小;
-nographic # 禁用图像,在这里只需要让 QEMU 在控制台中与我们交互即可;
执行之后应该啥都没有。为什么呢?QEMU 通电之后,QEMU 会进行准备工作,一旦进行准备完毕,Program Counter 就会跳转到内存中 0x80000000
的位置,以 Machine Mode
的权限执行代码;接下来,程序要干什么,机器也就跟着干什么。QEMU 开始之后,内存一片空白,自然也就没有任何输出。
所以,如果我们想要运行一段程序,那么我们就需要把程序的第一条指令放到 0x80000000
的位置,然后让机器一直往下取指令并且执行指令。
如何做到呢?一种想法是,把程序写好、编译成二进制文件,然后一个一个字节透过 GDB 修改内存注入到 QEMU 中(这个当然是开玩笑,不要当真)。这样比较麻烦,而且不是程序员应该负责的工作,那么我们该如何解决这个问题呢?
思考一下我们平常电脑的启动流程:进入 BIOS/EFI Firmware 之后,可以透过 BIOS/EFI Firmware 来选择需要从哪一个介质(硬盘、CD/DVD、U 盘等)启动;选择介质之后,以常见的硬盘启动为例,BIOS/EFI Firmware 会从硬盘中读取启动项信息(MBR/GPT 中提供了相关信息),然后根据硬盘中的内容把操作系统或 Bootloader(例如 GRUB, bootmgr)加载到内存,然后就完成了引导;接下来,操作系统会继续初始化,或者由 Bootloader 进行相关引导。
有点麻烦,有没有什么快速的方法直接把程序注入到 QEMU 的内存中,让 QEMU 直接跑我们放在 0x80000000
上的代码?
其实是有的,只不过这是 QEMU 提供的便捷方式。-kernel
选项就可以把我们文件系统中的文件载入到 QEMU 的内存中。听上去真的很棒,看样子曙光要来临了。
程序加载
可是 QEMU 怎么知道如何将文件载入内存呢?这个听上去有点蠢,因为文件里都是二进制,本能的想法是:就把文件里所有的内容一个一个字节放到内存里就好了,实在需要额外信息的话,那么就给 QEMU 一个地址,让它把文件都写到这个基地址(base address)上即可。然而,在实际操作中,这种方式实在是太不实用了:
- 在常见的运行环境中,操作系统常常会开启内存分页(paging),有时我们需要将程序中不同的内容(可执行的二进制、数据)放在相同/不同的页面、或者是不同的地址,则此时如何将程序的不同部分加载到对应的地址就非常有必要了。
- 有时,我们编写的 C 程序中有比较大的静态数组(例如:
int buf[10000000]
),这时如果编译时我们在文件中预留10000000
个int
空白位,这样空间利用效率极低。如果这时,加载文件的程序可以在内存中帮我们开辟10000000
个int
空白位、而不是把文件里10000000
个int
空白位全部复制到内存里的话,整个系统的效率会极大提高。
所以,粗暴的将文件内容直接抄到对应内存块上是不可取的。我们需要按照一定的计划,将文件中的不同部分加载到对应位置。Linux 是怎么做的呢?假设我们要运行 riscv-test
这个文件:首先,应该由文件本身提供加载到内存的方式;Linux 先读取 riscv-test
里预先写好的内存加载计划,然后按照计划将 riscv-test
中的指定块放到内存中,并且根据计划给定的程序入口点(entry point)开始运行 riscv-test
里的代码。
Linux 和 riscv-test
约定好使用 ELF 格式。这个格式的文件里,不仅包含文件内容(可执行的二进制、数据等等),并且包含如何将数据加载到内存的规定。由于 ELF 格式的细则比较复杂,所以我在这里节选一些内容帮助读者理解。
Executable and Linkable Format – ELF
上方这张示意图展示了 ELF 文件的大概结构。数据以段(section)的形式存在文件中,例如 .text
、.rodata
、.data
等。这些段的地址(address)和大小(size)被定义在底部的 Section Header Table 中。
我们通过解剖一个可执行文件的方式来了解 ELF 格式。我们写一个 test_elf.c
的程序:
// test_elf.c
#include <stdio.h>
int main()
{
int a = 10, b = 20;
printf("%d\n", a + b);
return 0;
}
使用 RISC-V GNU 工具链的编译器进行编译(工具链前缀可能会因运行平台而异,例如,在 Ubuntu 22.04 上编译的工具链,其前缀为 riscv64-unknown-linux-gnu-gcc
):
riscv64-unknown-linux-gnu-gcc test_elf.c -o test_elf
我们现在得到了 test_elf
,现在我们使用一个叫做 readelf
的工具(请自行安装)来读取 test_elf
中的 ELF 信息:
readelf -a test_elf > elf_report
[elf_report 文件 – elf_report on GitHub Gist]
打开 elf_report
,文件大纲大概为:
- ELF Header
- Section Headers
- Program Headers
- Dynamic Section
- Relocation Section
- Symbol Table
- …
我们首先看看 ELF Header:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x10450
Start of program headers: 64 (bytes into file)
Start of section headers: 12152 (bytes into file)
Flags: 0x5, RVC, double-float ABI
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 10
Size of section headers: 64 (bytes)
Number of section headers: 35
Section header string table index: 34
ELF Header 记录了程序的一些基本信息,比如 ELF 格式、OS/ABI、体系结构等信息。
再来看看 Section Headers(文本宽度较长,建议在宽屏设备上全屏阅读):
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000010270 00000270
0000000000000021 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000010294 00000294
0000000000000020 0000000000000000 A 0 0 4
[ 3] .hash HASH 00000000000102b8 000002b8
0000000000000024 0000000000000004 A 5 0 8
[ 4] .gnu.hash GNU_HASH 00000000000102e0 000002e0
0000000000000030 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 0000000000010310 00000310
0000000000000060 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000010370 00000370
0000000000000041 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 00000000000103b2 000003b2
0000000000000008 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 00000000000103c0 000003c0
0000000000000020 0000000000000000 A 6 1 8
[ 9] .rela.plt RELA 00000000000103e0 000003e0
0000000000000030 0000000000000018 AI 5 19 8
[10] .plt PROGBITS 0000000000010410 00000410
0000000000000040 0000000000000010 AX 0 0 16
[11] .text PROGBITS 0000000000010450 00000450
0000000000000134 0000000000000000 AX 0 0 4
[12] .rodata PROGBITS 0000000000010588 00000588
0000000000000004 0000000000000000 A 0 0 8
[13] .eh_frame_hdr PROGBITS 000000000001058c 0000058c
0000000000000014 0000000000000000 A 0 0 4
[14] .eh_frame PROGBITS 00000000000105a0 000005a0
000000000000002c 0000000000000000 A 0 0 8
[15] .preinit_array PREINIT_ARRAY 0000000000011e08 00000e08
0000000000000008 0000000000000008 WA 0 0 1
[16] .init_array INIT_ARRAY 0000000000011e10 00000e10
0000000000000008 0000000000000008 WA 0 0 8
[17] .fini_array FINI_ARRAY 0000000000011e18 00000e18
0000000000000008 0000000000000008 WA 0 0 8
[18] .dynamic DYNAMIC 0000000000011e20 00000e20
00000000000001e0 0000000000000010 WA 6 0 8
[19] .got PROGBITS 0000000000012000 00001000
0000000000000028 0000000000000008 WA 0 0 8
[20] .sdata PROGBITS 0000000000012028 00001028
0000000000000010 0000000000000000 WA 0 0 8
[21] .bss NOBITS 0000000000012038 00001038
0000000000000008 0000000000000000 WA 0 0 1
[22] .comment PROGBITS 0000000000000000 00001038
000000000000002d 0000000000000001 MS 0 0 1
[23] .riscv.attributes RISCV_ATTRIBUTE 0000000000000000 00001065
0000000000000035 0000000000000000 0 0 1
[24] .debug_aranges PROGBITS 0000000000000000 000010a0
00000000000000a0 0000000000000000 0 0 16
[25] .debug_info PROGBITS 0000000000000000 00001140
0000000000000683 0000000000000000 0 0 1
[26] .debug_abbrev PROGBITS 0000000000000000 000017c3
000000000000027b 0000000000000000 0 0 1
[27] .debug_line PROGBITS 0000000000000000 00001a3e
0000000000000244 0000000000000000 0 0 1
[28] .debug_frame PROGBITS 0000000000000000 00001c88
0000000000000068 0000000000000000 0 0 8
[29] .debug_str PROGBITS 0000000000000000 00001cf0
00000000000004e0 0000000000000001 MS 0 0 1
[30] .debug_line_str PROGBITS 0000000000000000 000021d0
0000000000000174 0000000000000001 MS 0 0 1
[31] .debug_loclists PROGBITS 0000000000000000 00002344
000000000000012b 0000000000000000 0 0 1
[32] .symtab SYMTAB 0000000000000000 00002470
0000000000000768 0000000000000018 33 61 8
[33] .strtab STRTAB 0000000000000000 00002bd8
0000000000000239 0000000000000000 0 0 1
[34] .shstrtab STRTAB 0000000000000000 00002e11
0000000000000160 0000000000000000 0 0 1
我们观察这里的表头:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
按照这个表头,我们来看 .text
段的信息:
[11] .text PROGBITS 0000000000010450 00000450
0000000000000134 0000000000000000 AX 0 0 4
.text
的数据类型被定义为 PROGBITS
,要求加载器将这段数据加载到内存的 0000000000010450
位置。这段数据位于文件的 00000450
位置,大小为 0000000000000134
,以 4
字节的方式在内存中对齐,在运行期间需要占用这一段内存(A),并且这段数据是可执行的(X)。
[补充阅读:Executable and Linkable Format – Wikipedia][补充阅读:elf(5) – Linux manual page]
所以,如果我们需要把程序放在内存中,并且让 QEMU 模拟器运行我们的程序,我们得用我们的代码生成一个正确的 ELF 文件,并且让 QEMU 按照 ELF 格式进行内容的加载。
链接(Linking)
我相信各位学 C 或者其他语言的时候,都扫过一眼「编译流程」的介绍,其中会有一个叫做链接(Linking)的过程。大部分人可能不会去深究(我本人也是很晚才接触到这些细节),但是这个对我们程序的正确运行有很大的帮助。
[更多阅读:10 分钟看懂 C++ 编译过程|坎德人的小包包]
一个 C 程序的编译通常从 C 文件的预编译(Preprocessing)开始。预编译时,编译器会把头文件中的内容全部拷贝到源文件中,并且根据文本中的宏(macro)定义将对应的宏展开。之后,编译器会将 C 程序编译成汇编代码(assembly),然后由汇编器(assembler)进行汇编,生成目标文件(object file)。
接下来就是链接了。链接将目标文件中的可执行二进制内容放到对应的位置,并且重新整理内容的位置和指向。
[更多阅读:Linking – Computer Systems A Programmers Perspective – 3rd]
帮助我们将目标文件组装成 ELF 格式文件的工具是 GNU Binutils 中的 ld 链接器。ld 处理的方式是:根据一个链接器脚本(linker script),将目标文件放置到对应的文件位置、标明希望加载到内存的位置、组织 ELF 头等元信息(metadata),最后输出标准 ELF 文件。
对于 C 程序而言,要正确运行还需要一个额外的东西:C Runtime(简称为 CRT)。虽然 C 程序编译之后就直接以可执行二进制的方式运行,但是它的运行依托了 C Runtime。GCC 在编译一个 C 程序时,除了把 C 代码编译成目标文件之外,还会将 C Runtime 的目标文件加入链接,最后形成 ELF 格式的文件。而 Linux 在读取、运行 ELF 格式的 C 程序时,会先进入 C Runtime 的入口,然后再由 C Runtime 跳转至 main。
为什么需要这样的设计呢?尽管我们说 C 语言是汇编语言的高级抽象,C 语言的行为可以被映射为汇编代码,但是 C 语言所需要的栈结构并没有先天存在于内存中。C Runtime 的一大工作就是在运行 main 之前,在内存中设置好栈结构,并且设置好栈指针寄存器(stack pointer register)。所以,我们需要另外写一段汇编代码,将栈设置好,然后再将它们链接起来。我们把 test_elf.c
重写为:
// test_elf.c
char stack[4096];
void main()
{
int a = 10, b = 20, c = 0;
for (;;)
c += a + b;
}
由于我们是在裸机上跑,所以我们并没有「标准输出流」(stdout)这种东西,自然 printf
也是用不了的。那我们如何验证我们的程序在运行呢?我们可以使用 QEMU 的调试端口,让 GDB 连接到虚拟机,然后就可以侦测到 CPU 和内存的动向。
然后,我们用汇编写一个引导程序 entry.S
:
# entry.S
# To create a stack for C-compiled program and handover control to C world.
# Useful in linking;
.section .text
.global __entry
__entry:
# Create the stack for C world;
la a0, stack
# Stack pointer decreases gradually with the more data loaded into the stack;
li a1, 4096
add a0, a0, a1
mv sp, a0
# Go!
call main
entry.S
的作用是建立栈空间并且跳转到 main
。这里需要注意的是,.section .text
的含义是:「以下可执行代码应被放入 .text
区域」,作用是让链接器将这部分代码放到对应的 ELF 段(section)中;.global __entry
定义了一个全局符号,将这个指令的地址分配给 __entry
这个符号。这个符号会在之后的脚本中用到。同时,我们在 test_elf.c
里定义了一块内存空间 stack
,所以我们在这里把 sp
设置为 stack
的底部。
我们还需要链接脚本将程序整合在一起:
/*
linking.ld
*/
OUTPUT_ARCH( "riscv" ) /* 设置目标指令集 */
ENTRY( __entry ) /* 设置程序入口点 */
/* 段定义信息 */
SECTIONS
{
/* "." 是内存指针,它的作用是定位内容、将内容放置于指定位置 */
. = 0x80000000;
/* 我们把内存指针设置为 0x80000000,然后在 0x80000000 的位置上定义 .text 段 */
.text : {
/* 放置内容 */
*(.text .text.*)
/* 内容放置完毕之后,为了内存对齐,我们还需要留一部分的白,这里我们使用 ALIGN 来计算对齐之后的内存位置 */
. = ALIGN(0x1000);
}
/* 以下三个段都存储了程序所需要的数据,就不再细讲 */
.rodata : {
. = ALIGN(16);
*(.srodata .srodata.*)
. = ALIGN(16);
*(.rodata .rodata.*)
. = ALIGN(16);
}
.data : {
*(.sdata .sdata.*)
. = ALIGN(16);
*(.data .data.*)
. = ALIGN(16);
}
.bss : {
*(.sbss .sbss.*)
. = ALIGN(16);
*(.bss .bss.*)
. = ALIGN(16);
}
}
然后,我们写一个脚本 make.sh
进行编译:
riscv64-unknown-linux-gnu-gcc test_elf.c \
-Wall -O0 -ggdb -gdwarf-2 \
-ffreestanding -fno-common -nostdlib -MD \
-I. -mno-relax -fno-pie -no-pie -mcmodel=medany \
-c -o test_elf.o
riscv64-unknown-linux-gnu-gcc entry.S -c -o entry.o
riscv64-unknown-linux-gnu-ld -z max-page-size=4096 -T linking.ld -o test_elf entry.o test_elf.o
首先需要注意的是 C 源文件的编译方式。我们需要用 -c -o test_elf.o
来告诉编译器输出名为 test_elf.o
的目标文件。除此之外,还有很多其他的编译选项,其中 -O0
关闭了编译器优化、-ggdb -gdwarf-2
保留了调试信息、-ffreestanding -fno-common -nostdlib
关闭了对标准库的引用和链接。
接下来,加载 ELF 文件到 QEMU,在 TCP 10024 端口开放调试端口,在虚拟机中启动我们的程序:
qemu-system-riscv64 \
-machine virt \
-bios none \
-m 128M \
-nographic \
-kernel test_elf \
-gdb tcp::10024 -S
由于是调试模式,所以 QEMU 在通电之后暂停,等待 GDB 的操作。使用 GDB 进入调试:
gdb-multiarch
在 GDB 控制台中,设置目标架构、符号文件、开启自动反编译并且连接到 QEMU:
(gdb) set architecture riscv:rv64
The target architecture is set to "riscv:rv64".
(gdb) symbol-file test_elf
Reading symbols from test_elf...
(gdb) set disassemble-next-line auto
(gdb) target remote 127.0.0.1:10024
Remote debugging using 127.0.0.1:10024
连接之后,会显示机器当前运行的位置(program counter):
0x0000000000001000 in ?? ()
我们先在入口点打上断点:
(gdb) b *0x80000000
Breakpoint 1 at 0x80000000
(gdb) c
Continuing.
来到 __entry
:
Breakpoint 1, 0x0000000080000000 in __entry ()
=> 0x0000000080000000 <__entry+0>: 17 15 00 00 auipc a0,0x1
(gdb) si
0x0000000080000004 in __entry ()
=> 0x0000000080000004 <__entry+4>: 13 05 05 00 mv a0,a0
(gdb) si
0x0000000080000008 in __entry ()
=> 0x0000000080000008 <__entry+8>: 85 65 lui a1,0x1
(gdb) si
0x000000008000000a in __entry ()
=> 0x000000008000000a <__entry+10>: 2e 95 add a0,a0,a1
(gdb) si
0x000000008000000c in __entry ()
=> 0x000000008000000c <__entry+12>: 2a 81 mv sp,a0
(gdb) si
0x000000008000000e in __entry ()
=> 0x000000008000000e <__entry+14>: ef 00 40 00 jal ra,0x80000012 <main>
进入 main
:
(gdb) si
main () at test_elf.c:5
5 {
(gdb) si
0x0000000080000014 5 {
(gdb) si
0x0000000080000016 5 {
(gdb) si
6 int a = 10, b = 20, c = 0;
(gdb) si
0x000000008000001a 6 int a = 10, b = 20, c = 0;
(gdb) si
6 int a = 10, b = 20, c = 0;
(gdb) si
0x0000000080000020 6 int a = 10, b = 20, c = 0;
(gdb) si
6 int a = 10, b = 20, c = 0;
(gdb) si
8 c += a + b;
结语
我们在这篇文章中将一个程序放在了内存上,并且将所有的计算资源都分配给了它,非常的酷。在这个基础上,我们可以完成一些嵌入式的编程,甚至是实现很多系统软件。
如果本文有纰漏、缺陷,还劳烦各位指出,谢谢。
Reference
[1]Wikipedia, “RISC-V – Wikipedia,” retrieved 16 March 2023, https://en.wikipedia.org/wiki/RISC-V
[2]riscv-collab, “GNU toolchain for RISC-V, including GCC,” retrieved 16 March 2023, https://github.com/riscv-collab/riscv-gnu-toolchain
[3]QEMU, “QEMU,” retrieved 16 March 2023, https://www.qemu.org
[4]twilco, “RISC-V from scratch 2: Hardware layouts, linker scripts, and C runtimes, ” retrieved 16 March 2023, https://twilco.github.io/riscv-from-scratch/2019/04/27/riscv-from-scratch-2.html
[5]Massachusetts Institute of Technology, “xv6-riscv,” retrieved 16 March 2023, https://pdos.csail.mit.edu/6.828/2022/xv6.html
[6]Editors Andrew Waterman and Krste Asanovi ́c, “The RISC-V Instruction Set Manual, Volume I: User-Level ISA, Document Version 20191213,” RISC-V Foundation, December 2019
[7]Editors Andrew Waterman, Krste Asanovi ́c, and John Hauser, “The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Docu- ment Version 20211203,” RISC-V International, December 2021
[8]Wikipedia, “Executable and Linkable Format – Wikipedia,” retrieved 16 March 2023, https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[9]Atish Patra, Anup Patel, “An Introduction to RISC-V Boot Flow”
[10]Jagan Teki, “An Introduction to RISC-V Boot flow: Overview,
Blob vs Blobfree standards”