QEMU RISC-V virt 平台分析

3,114 阅读6分钟

注:本文章的内容尚未完成,已完成的部分请参见页面侧边的目录。

RISC-V ISA 规范中给不同的平台实现留下了许多可自定义的地方。作为操作系统设计者,有必要了解目标平台的实现细节,并对自己的操作系统源代码进行针对性的调整。但 QEMU 的 RISC-V 平台的相关文档较少,如果不借助 QEMU 的源代码,操作系统设计者将很难获知 QEMU RISC-V 平台的设计细节。

本文将借助 QEMU 的源代码,分析 QEMU 的 RISC-V virt 平台的实现细节,以帮助想要理解 virt 实现细节的操作系统设计者。

下文中的所有源代码均以 QEMU v5.1.0 为准。

QEMU virt 平台介绍

virt 平台是 QEMU 上的 RISC-V 虚拟硬件平台之一。其源代码文件 hw/riscv/virt.c 开头的版权声明如下:

/*
 * QEMU RISC-V VirtIO Board
 *
 * Copyright (c) 2017 SiFive, Inc.
 *
 * RISC-V machine with 16550a UART and VirtIO MMIO
 *
 ...
 */

从声明中可以看出,virt 平台的全称是 QEMU RISC-V VirtIO Board,由 SiFive 公司设计,并且包含 16550a UART 和 VirtIO MMIO 作为外设和 I/O 接口。

使用 -machine virt 参数启动 QEMU RISC-V 即可使用 virt 平台。

与 virt 平台类似,QEMU 中还有 spike 平台,由伯克利大学博士生 Sagar Karandikar 和 SiFive 联合设计。源代码文件为 hw/riscv/spike.c。spike 的设计相比 virt 要简单很多,但本文不会对 spike 平台进行详细介绍。

virt 平台的内存空间布局

RISC-V 规范中声明,每个平台的内存空间布局可以由不同平台自己定义。virt 平台的 M-mode 内存布局在 hw/riscv/virt.c 文件中定义,名为 virt_memmap,代码如下:

static const struct MemmapEntry {
    hwaddr base;
    hwaddr size;
} virt_memmap[] = {
    [VIRT_DEBUG] =       {        0x0,         0x100 },
    [VIRT_MROM] =        {     0x1000,        0xf000 },
    [VIRT_TEST] =        {   0x100000,        0x1000 },
    [VIRT_RTC] =         {   0x101000,        0x1000 },
    [VIRT_CLINT] =       {  0x2000000,       0x10000 },
    [VIRT_PCIE_PIO] =    {  0x3000000,       0x10000 },
    [VIRT_PLIC] =        {  0xc000000,     0x4000000 },
    [VIRT_UART0] =       { 0x10000000,         0x100 },
    [VIRT_VIRTIO] =      { 0x10001000,        0x1000 },
    [VIRT_FLASH] =       { 0x20000000,     0x4000000 },
    [VIRT_PCIE_ECAM] =   { 0x30000000,    0x10000000 },
    [VIRT_PCIE_MMIO] =   { 0x40000000,    0x40000000 },
    [VIRT_DRAM] =        { 0x80000000,           0x0 },
};

其中,对于操作系统设计者来说,比较重要的内存块有:

  • 启动 ROM:VIRT_MROM
  • 设备内存:VIRT_DRAM
  • UART 串口:VIRT_UART0

virt 平台启动流程

通过调试模式启动 QEMU 可以发现,virt 平台在 reset 时,会将 PC 置为 0x10000x1000 处的内存块名为 VIRT_MROM。配置这块内存块的 代码 如下:

    /* boot rom */
    memory_region_init_rom(mask_rom, NULL, "riscv_virt_board.mrom",
                           memmap[VIRT_MROM].size, &error_fatal);
    memory_region_add_subregion(system_memory, memmap[VIRT_MROM].base,
                                mask_rom);

可以看出,MROM 是 mask ROM 的简写(mask 是 ROM 的一种制造工艺)。注意,源代码中的 riscv_virt_board.mrom 实际上并不对应 host OS 上的文件名,而只是这个内存区域的一个名称。

真正 向这个内存块中载入数据 的代码如下:

    /* load the reset vector */
    riscv_setup_rom_reset_vec(start_addr, virt_memmap[VIRT_MROM].base,
                              virt_memmap[VIRT_MROM].size, kernel_entry,
                              fdt_load_addr, s->fdt);

可以看出,这块内存的内容名为 reset vector。riscv_setup_rom_reset_vec 的代码位于 hw/riscv/boot.c 文件中,如下:

    uint32_t reset_vec[10] = {
        0x00000297,                  /* 1:  auipc  t0, %pcrel_hi(fw_dyn) */
        0x02828613,                  /*     addi   a2, t0, %pcrel_lo(1b) */
        0xf1402573,                  /*     csrr   a0, mhartid  */
#if defined(TARGET_RISCV32)
        0x0202a583,                  /*     lw     a1, 32(t0) */
        0x0182a283,                  /*     lw     t0, 24(t0) */
#elif defined(TARGET_RISCV64)
        0x0202b583,                  /*     ld     a1, 32(t0) */
        0x0182b283,                  /*     ld     t0, 24(t0) */
#endif
        0x00028067,                  /*     jr     t0 */
        start_addr,                  /* start: .dword */
        start_addr_hi32,
        fdt_load_addr,               /* fdt_laddr: .dword */
        0x00000000,
                                     /* fw_dyn: */
    };

QEMU 会将这一段汇编代码写入内存地址 0x1000 处,并在虚拟 CPU 启动时执行。这段代码做的事情主要有:

  • 将这段代码的基地址(即 0x1000)载入 a2 寄存器中;
  • 将当前的 mhartid 载入 a0 寄存器中;
  • 将一个名为 fdt_load_addr 的值载入 a1 寄存器中(FDT 可能是某种 device tree 的名称);
  • 跳转到 start_addr,将控制权交给 SBI 或用户的操作系统。

在 hw/riscv/virt.c 中,start_addr 的决定规则如下:

  • 如果虚拟机器上有安装 Pflash,则 start_addr 将设为 VIRT_FLASH 内存块的基址(对应代码);
  • 否则,start_addr 将设为 VIRT_DRAM 内存块的基址。

通常,QEMU 会将 固件 或 操作系统内核 加载到 VIRT_DRAM 的偏移 0 位置。因此跳转到 start_addr 相当于将控制权交给 SBI 或用户的操作系统。

固件与内核的加载

在 hw/riscv/virt.c 中,触发固件加载的 代码 如下:

    riscv_find_and_load_firmware(machine, BIOS_FILENAME,
                                 memmap[VIRT_DRAM].base, NULL);

riscv_find_and_load_firmware 函数的实现在 hw/riscv/boot.c 文件中。其中,决定固件文件名的代码如下:

    if ((!machine->firmware) || (!strcmp(machine->firmware, "default"))) {
        /*
         * The user didn't specify -bios, or has specified "-bios default".
         * That means we are going to load the OpenSBI binary included in
         * the QEMU source.
         */
        firmware_filename = riscv_find_firmware(default_machine_firmware);
    } else if (strcmp(machine->firmware, "none")) {
        firmware_filename = riscv_find_firmware(machine->firmware);
    }

总共分为 3 种情况:

  • 若在 QEMU 命令行参数中没有指定 -bios,或指定了 -bios default,则会加载 QEMU 自带的 OpenSBI 作为固件。
  • 若指定了 -bios none,则不加载固件,即 firmware_filename = NULL
  • 否则,用户可以通过 -bios 指定一个固件文件名。

默认的 OpenSBI 固件的文件名在 hw/riscv/virt.c 中定义,名为 BIOS_FILENAME。对于 RISC-V 64 平台,该文件名为 opensbi-riscv64-virt-fw_jump.bin

加载固件时的 逻辑 为:

  1. 首先尝试将文件作为 ELF 格式加载;
  2. 若不成功,则再作为普通二进制格式加载,若仍不成功,则启动失败。

加载操作系统内核时的 逻辑 稍有不同,为:

  1. 首先尝试将文件作为 ELF 格式加载;
  2. 若不成功,则再尝试作为 uImage 格式加载
  3. 若不成功,则再作为普通二进制格式加载,若仍不成功,则启动失败。

固件加载的基地址:从本节开始处的 hw/riscv/virt.c 的 代码 中可以看出,固件加载的基地址就是 VIRT_DRAM 内存块的基地址。但在实际加载过程中,若固件是 ELF 格式,则基地址由 ELF 的节表信息决定,不一定是 VIRT_DRAM 内存块的基地址。(当加载 ELF 格式遇到内存布局冲突时,QEMU 将输出错误信息并退出。)

内核加载的基地址:加载内核时,若内核是 ELF 格式,则基地址由 ELF 的节表信息决定。若内核是二进制格式,则基地址由 KERNEL_BOOT_ADDRESS 定义;对于 RISC-V 64 平台,该值0x80200000