QEMU 启动以后,并没有立刻执行客户机程序的第一条指令。
而是先执行 Machine 在初始化阶段,设置的 reset vector 程序段,然后再跳转到客户机程序的第一条指令。
接下来,我将讲述一些方法,带你快速浏览一遍 GD32VF103 客户机程序在 QEMU 上的启动流程。
先看一下 GD32VF103 初始化阶段的一些关键设置,代码如下:
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地址为: GD32VF103_MFOL.base + 0x1000
,
通过以上代码得知,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:1234
Remote debugging using localhost:1234
0x00001000 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 地址,对应 GD32VF103_MFOL.base
的 mr,追踪 mr 的 ram 初始化过程,获取对应的 HVA 基地址,然后加上偏移 0x1018 ,就获取到真正的 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_rom
Breakpoint 1 at 0x6a7d60: file ../system/memory.c, line 3612.
(gdb) run
(gdb) finish
2. 查看 mr 的 ram_block,读取 HVA 基地址:
(gdb) p s->internal_rom.ram_block->host
$5 = (uint8_t *) 0x7ffff4400000 ""
3. 计算 GPA 地址 0x1018对应的 HVA , 也就是 0x7ffff4401018,对其下监视点:
(gdb) watch *(0x7ffff4400000 + 0x1018)
Hardware watchpoint 3: *0x7ffff4401018
4. 继续执行,直到停在监视点,我们读取 HVA 里的数据,看看是不是客户机程序的起始地址:
(gdb) c
Thread 1 "qemu-system-ris" hit Hardware watchpoint 3: *0x7ffff4401018
Old value = 0
New value = 134217728
0x00007ffff696d565 in ?? () from /usr/lib/libc.so.6
(gdb) x 0x7ffff4401018
0x7ffff4401018: 0x08000000
5. 查看调用栈:
(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. 迁移状态检查: 接下来,函数检查虚拟机是否正处于迁移过程中:
如果是迁移状态,那么 ROM 数据将在下次迁移完成后填充,因此当前不需要做任何事情。
如果 ROM 数据已经存在并且标记为只读 (rom->isrom 为真),那么释放已有的数据以防止覆盖可能被客户机修改的数据。
4. ROM 数据填充: 如果虚拟机不在迁移状态,并且 ROM 数据存在 (rom->data != NULL),函数会根据不同的情况将 ROM 数据写入内存:
如果 ROM 有一个关联的内存区域 (rom->mr),则获取该区域的物理内存指针,并将 ROM 数据复制到该内存区域。
通过接口memory_region_get_ram_ptr()
,可以直接获取 mr 对应的 HVA。
如果 ROM 没有关联的内存区域,而是直接与地址空间关联 (rom->as),则使用 address_space_write_rom()
,将数据写入指定地址,并使用 address_space_set()
,将剩余的空间清零。
5. 释放 ROM 数据: 如果 ROM 数据被标记为只读 (rom->isrom 为真),那么数据只需要写入一次,之后可以释放 ROM 数据缓冲区以节省内存。
6. 刷新指令缓存: 为了确保 CPU 能够从新的 ROM 数据中正确地取指令执行,函数调用 cpu_flush_icache_range 来清除对应内存范围的指令缓存。跟踪记录:
最后,函数调用 trace_loader_write_rom 来记录 ROM 加载的信息,这可能用于调试或性能分析。
到这里,我们大概了解 QEMU 是怎么将客户机程序起始地址,写入对应的位置了。
本文首发于微信公众号:GTOC