利用 GDB 巧妙分析 QEMU 的 GD32VF103 Machine 启动流程

源码仓库:qemu-nuclei-gd32vf103

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

6 个赞