ZEVORN.blog

July 30, 2024

分析 QEMU 的 GD32VF103 启动流程

noteqemu9.2 min to read

QEMU 启动以后,并没有立刻执行客户机程序的第一条指令,而是先执行 Machine 在初始化阶段设置的 reset vector 程序段,然后再跳转到客户机程序的第一条指令。

static const struct MemmapEntry{    hwaddr base;    hwaddr size;} gd32vf103_memmap[] = {    [GD32VF103_MFOL] = {0x0, 0x20000},};static void nuclei_board_init(MachineState *machine){    ...    /* reset vector */    uint32_t reset_vec[8] = {        0x00000297, /* 1:  auipc  t0, %pcrel_hi(dtb) */        0x02028593, /*     addi   a1, t0, %pcrel_lo(1b) */        0xf1402573, /*     csrr   a0, mhartid  */#if defined(TARGET_RISCV32)        0x0182a283, /*     lw     t0, 24(t0) */#elif defined(TARGET_RISCV64)        0x0182b283, /*     ld     t0, 24(t0) */#endif        0x00028067, /*     jr     t0 */        0x00000000,        memmap[GD32VF103_MAINFLASH].base, /* start: .dword */        0x00000000,        /* dtb: */    };    /* copy in the reset vector in little_endian byte order */    for (i = 0; i < sizeof(reset_vec) >> 2; i++)    {        reset_vec[i] = cpu_to_le32(reset_vec[i]);    }    rom_add_blob_fixed_as("mrom.reset", reset_vec, sizeof(reset_vec),                          memmap[GD32VF103_MFOL].base + 0x1000, &address_space_memory);...}

因此第一条指令的PC地址为 memmap[GD32VF103_MFOL].base + 0x1000,通过以上代码得知,memmap[GD32VF103_MFOL].base 为0,因此起始 PC 为 0x1000。

验证方法很简单,使用 riscv-gdb 远程 remote QEMU,命令如下:

打开第一个终端窗口,启动 QEMU:

qemu (nuclei_gd32vf103) $ ./build/qemu-system-riscv32 -M gd32vf103_rvstar -cpu nuclei-n205 -icount shift=0 -nodefaults -nographic -kernel ../nuclei-sdk/application/baremetal/helloworld/helloworld.elf -serial stdio -gdb tcp::1234 -S

打开第二个终端窗口,启动 gdb,可以看到第一条指令,反汇编和前面 reset vector 对比一下:

qemu (nuclei_gd32vf103) $ riscv-nuclei-linux-gnu-gdb ../nuclei-sdk/application/baremetal/helloworld/helloworld.elf (gdb) target remote localhost:1234Remote debugging using localhost:12340x00001000 in ?? ()(gdb) x /10i $pc=> 0x1000:      auipc   t0,0x0   0x1004:      addi    a1,t0,32   0x1008:      csrr    a0,mhartid   0x100c:      lw      t0,24(t0)   0x1010:      jr      t0   0x1014:      unimp   0x1016:      unimp   0x1018:      unimp   0x101a:      addi    s0,sp,16   0x101c:      unimp

这段启动程序主要做了两件事,通过 dtb 获取客户程序起始地址,读取 mhartid 寄存器(当前 hart (硬件线程) 的 ID)值。

我们从第一条指令开始分析。

  1. 现在我们跑的是一个裸机程序,没有 dtb ,因此 auipc t0, 0x0 这条指令最终将当前 PC 地址 0x1000 写入 t0 寄存器;

  2. 接着将 t0 加上 32 偏移,得到地址 0x1020, 写入 a1 寄存器;

  3. 通过 csrr 指令将 mhartid 寄存器的值读入 a0 寄存器;

  4. 将 t0 偏移 24,得到地址 0x1018,从这地址读取客户机程序起始地址,写入 t0寄存器;

  5. 通过 jr 指令,跳转到客户机程序起始地址。

使用 gdb 可以观察到 0x1018 地址内的数据为 0x8000000,这个地址正好在 reset vector 后面挨着。

我们知道,QEMU 启动的时候,使用 -kernel 加载的客户机程序 helloword.elf,势必有一个流程,将 0x8000000 写入 0x1018。

那么我们该如何快速定位到这个流程呢?

方法很简单,0x1018 地址是客户机的物理地址(Guest physical address,简称 GPA ),我们只需要计算出对应的 QEMU 进程的虚拟地址,即宿主机虚拟地址(Host virtual addree,简称 HVA ),使用 gdb 启动 qemu,对这个HVA下内存监视点,就可以了。

GPA 的 0 地址,对应 memmap[GD32VF103_MFOL].base 的 mr,通过 mr 的 ram 初始化过程,可以获取对应的 HVA 基地址,然后我们加上 0x1018 的 offset ,就获取到真正的 HVA 了,流程如下:

  1. gdb 启动 qemu,设置断点,在初始化 GD32VF103_MFOL 的时候停住:
qemu (nuclei_gd32vf103) $ gdb ./build/qemu-system-riscv32(gdb) set args -M gd32vf103_rvstar -cpu nuclei-n205 -icount shift=0 -nodefaults -nographic -kernel ../nuclei-sdk/application/baremetal/helloworld/helloworld.elf(gdb) b memory_region_init_romBreakpoint 1 at 0x6a7d60: file ../system/memory.c, line 3612.(gdb) run(gdb) finish
  1. 查看 GD32VF103_MFOL 对应 mr 的 ram_block,读取 HVA 基地址:
(gdb) p s->internal_rom.ram_block->host $5 = (uint8_t *) 0x7ffff4400000 ""
  1. 计算 GPA 地址 0x1018对应的 HVA , 也就是 0x7ffff4401018,对其下监视点:
(gdb) watch *(0x7ffff4400000 + 0x1018)Hardware watchpoint 3: *0x7ffff4401018
  1. 继续执行,直到达到监视点,我们读取 HVA 里的数据,看看是不是客户机程序的起始地址:
(gdb) cThread 1 "qemu-system-ris" hit Hardware watchpoint 3: *0x7ffff4401018Old value = 0New value = 1342177280x00007ffff696d565 in ?? () from /usr/lib/libc.so.6(gdb) x 0x7ffff44010180x7ffff4401018: 0x08000000
  1. 查看调用栈:
(gdb) bt#0  0x00007ffff696d565 in ?? () from /usr/lib/libc.so.6#1  0x0000555555c01c08 in memcpy (__dest=<optimized out>, __src=0x55555692f0e0, __len=<optimized out>)    at /usr/include/bits/string_fortified.h:29#2  address_space_write_rom_internal (as=0x55555648c460 <address_space_memory>, addr=4096, attrs=...,     ptr=<optimized out>, len=32, type=type@entry=WRITE_DATA) at ../system/physmem.c:2967#3  0x0000555555c02b1c in address_space_write_rom (as=<optimized out>, addr=<optimized out>, attrs=...,     attrs@entry=..., buf=<optimized out>, len=<optimized out>) at ../system/physmem.c:2987#4  0x00005555558e0f2e in rom_reset (unused=<optimized out>) at ../hw/core/loader.c:1282#5  0x0000555555c6af0a in resettable_phase_hold (obj=0x555556930350, opaque=<optimized out>, type=<optimized out>)    at ../hw/core/resettable.c:184#6  0x0000555555c6a4c1 in resettable_container_child_foreach (obj=<optimized out>,     cb=0x555555c6adc0 <resettable_phase_hold>, opaque=0x0, type=RESET_TYPE_COLD) at ../hw/core/resetcontainer.c:54#7  0x0000555555c6ae5a in resettable_child_foreach (rc=0x5555566fbaa0, obj=0x5555567393f0,     cb=0x555555c6adc0 <resettable_phase_hold>, opaque=0x0, type=RESET_TYPE_COLD) at ../hw/core/resettable.c:96#8  resettable_phase_hold (obj=obj@entry=0x5555567393f0, opaque=opaque@entry=0x0, type=type@entry=RESET_TYPE_COLD)    at ../hw/core/resettable.c:173#9  0x0000555555c6b290 in resettable_assert_reset (obj=obj@entry=0x5555567393f0, type=type@entry=RESET_TYPE_COLD)    at ../hw/core/resettable.c:60--Type <RET> for more, q to quit, c to continue without paging--#10 0x0000555555c6b651 in resettable_reset (obj=0x5555567393f0, type=RESET_TYPE_COLD) at ../hw/core/resettable.c:45#11 0x0000555555a6a3f4 in qemu_system_reset (reason=reason@entry=SHUTDOWN_CAUSE_NONE) at ../system/runstate.c:494#12 0x00005555558eaad3 in qdev_machine_creation_done () at ../hw/core/machine.c:1607#13 0x0000555555a6e043 in qemu_machine_creation_done (errp=0x5555564a0298 <error_fatal>) at ../system/vl.c:2677#14 qmp_x_exit_preconfig (errp=0x5555564a0298 <error_fatal>) at ../system/vl.c:2707#15 0x0000555555a717bb in qemu_init (argc=<optimized out>, argv=<optimized out>) at ../system/vl.c:3739#16 0x0000555555869ff9 in main (argc=<optimized out>, argv=<optimized out>) at ../system/main.c:47(gdb) 

这里我们重点关注 rom_reset() :

rom_reset (unused=<optimized out>) at ../hw/core/loader.c:1282

对应源代码:

static void rom_reset(void *unused){    Rom *rom;    QTAILQ_FOREACH(rom, &roms, next) {        if (rom->fw_file) {            continue;        }        /*         * We don't need to fill in the RAM with ROM data because we'll fill         * the data in during the next incoming migration in all cases.  Note         * that some of those RAMs can actually be modified by the guest.         */        if (runstate_check(RUN_STATE_INMIGRATE)) {            if (rom->data && rom->isrom) {                /*                 * Free it so that a rom_reset after migration doesn't                 * overwrite a potentially modified 'rom'.                 */                rom_free_data(rom);            }            continue;        }        if (rom->data == NULL) {            continue;        }        if (rom->mr) {            void *host = memory_region_get_ram_ptr(rom->mr);            memcpy(host, rom->data, rom->datasize);            memset(host + rom->datasize, 0, rom->romsize - rom->datasize);        } else {            address_space_write_rom(rom->as, rom->addr, MEMTXATTRS_UNSPECIFIED,                                    rom->data, rom->datasize);            address_space_set(rom->as, rom->addr + rom->datasize, 0,                              rom->romsize - rom->datasize,                              MEMTXATTRS_UNSPECIFIED);        }        if (rom->isrom) {            /* rom needs to be written only once */            rom_free_data(rom);        }        /*         * The rom loader is really on the same level as firmware in the guest         * shadowing a ROM into RAM. Such a shadowing mechanism needs to ensure         * that the instruction cache for that new region is clear, so that the         * CPU definitely fetches its instructions from the just written data.         */        cpu_flush_icache_range(rom->addr, rom->datasize);        trace_loader_write_rom(rom->name, rom->addr, rom->datasize, rom->isrom);    }}

它负责在虚拟机启动或迁移后重新加载 ROM 数据到内存中。下面是对其逻辑的详细分析:

  1. 循环遍历 ROM 列表: 函数通过遍历一个名为 roms 的链表来处理每一个 ROM 实例。这个链表包含了虚拟机中所有 ROM 的信息。

  2. 检查 ROM 文件: 对于每一个 ROM 实例 rom,函数首先检查是否有对应的固件文件 (rom->fw_file)。如果有,那么跳过此 ROM,因为它可能已经被固件加载器处理过了。

  3. 迁移状态检查: 接下来,函数检查虚拟机是否正处于迁移过程中 (runstate_check(RUN_STATE_INMIGRATE)): 如果是迁移状态,那么 ROM 数据将在下次迁移完成后填充,因此当前不需要做任何事情。 如果 ROM 数据已经存在并且标记为只读 (rom->isrom 为真),那么释放已有的数据以防止覆盖可能被客户机修改的数据。

  4. ROM 数据填充: 如果虚拟机不在迁移状态,并且 ROM 数据存在 (rom->data != NULL),函数会根据不同的情况将 ROM 数据写入内存: 如果 ROM 有一个关联的内存区域 (rom->mr),则获取该区域的物理内存指针 (memory_region_get_ram_ptr) 并将 ROM 数据复制到该内存区域。

如果 ROM 没有关联的内存区域,而是直接与地址空间关联 (rom->as),则使用 address_space_write_rom 将数据写入指定地址,并使用 address_space_set 将剩余的空间清零。

  1. 释放 ROM 数据: 如果 ROM 数据被标记为只读 (rom->isrom 为真),那么数据只需要写入一次,之后可以释放 ROM 数据缓冲区以节省内存。

  2. 刷新指令缓存: 为了确保 CPU 能够从新的 ROM 数据中正确地取指令执行,函数调用 cpu_flush_icache_range 来清除对应内存范围的指令缓存。 跟踪记录: 最后,函数调用 trace_loader_write_rom 来记录 ROM 加载的信息,这可能用于调试或性能分析。

到这里,我们大概了解它是怎么将客户机程序起始地址,写入对应的位置了。

但是我们还不清楚,是怎么从 -kernel 参数解析客户机程序的。

碍于篇幅,今天先到这里,下篇文章我们继续探究。