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)值。
我们从第一条指令开始分析。
-
现在我们跑的是一个裸机程序,没有 dtb ,因此 auipc t0, 0x0 这条指令最终将当前 PC 地址 0x1000 写入 t0 寄存器;
-
接着将 t0 加上 32 偏移,得到地址 0x1020, 写入 a1 寄存器;
-
通过 csrr 指令将 mhartid 寄存器的值读入 a0 寄存器;
-
将 t0 偏移 24,得到地址 0x1018,从这地址读取客户机程序起始地址,写入 t0寄存器;
-
通过 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 了,流程如下:
- 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
- 查看 GD32VF103_MFOL 对应 mr 的 ram_block,读取 HVA 基地址:
(gdb) p s->internal_rom.ram_block->host $5 = (uint8_t *) 0x7ffff4400000 ""
- 计算 GPA 地址 0x1018对应的 HVA , 也就是 0x7ffff4401018,对其下监视点:
(gdb) watch *(0x7ffff4400000 + 0x1018)Hardware watchpoint 3: *0x7ffff4401018
- 继续执行,直到达到监视点,我们读取 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
- 查看调用栈:
(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 数据到内存中。下面是对其逻辑的详细分析:
-
循环遍历 ROM 列表: 函数通过遍历一个名为 roms 的链表来处理每一个 ROM 实例。这个链表包含了虚拟机中所有 ROM 的信息。
-
检查 ROM 文件: 对于每一个 ROM 实例 rom,函数首先检查是否有对应的固件文件 (rom->fw_file)。如果有,那么跳过此 ROM,因为它可能已经被固件加载器处理过了。
-
迁移状态检查: 接下来,函数检查虚拟机是否正处于迁移过程中 (runstate_check(RUN_STATE_INMIGRATE)): 如果是迁移状态,那么 ROM 数据将在下次迁移完成后填充,因此当前不需要做任何事情。 如果 ROM 数据已经存在并且标记为只读 (rom->isrom 为真),那么释放已有的数据以防止覆盖可能被客户机修改的数据。
-
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 将剩余的空间清零。
-
释放 ROM 数据: 如果 ROM 数据被标记为只读 (rom->isrom 为真),那么数据只需要写入一次,之后可以释放 ROM 数据缓冲区以节省内存。
-
刷新指令缓存: 为了确保 CPU 能够从新的 ROM 数据中正确地取指令执行,函数调用 cpu_flush_icache_range 来清除对应内存范围的指令缓存。 跟踪记录: 最后,函数调用 trace_loader_write_rom 来记录 ROM 加载的信息,这可能用于调试或性能分析。
到这里,我们大概了解它是怎么将客户机程序起始地址,写入对应的位置了。
但是我们还不清楚,是怎么从 -kernel 参数解析客户机程序的。
碍于篇幅,今天先到这里,下篇文章我们继续探究。