返回

精准获取 QEMU 中 RISC-V 指令轨迹与执行频率

Linux

如何在 QEMU 中获取 RISC-V 应用的动态指令执行轨迹或直方图?

问题来了:我想看看我的 RISC-V 程序到底执行了哪些指令

哥们儿,你是不是也遇到过这种情况:手头有一个编译好的 RISC-V Linux 程序,想搞清楚它在 QEMU 里跑起来的时候,实际执行了哪些指令?注意,是动态执行 的,不是 objdump -d 扒拉出来的静态代码。有时候只想知道个大概,比如哪些指令执行得最频繁(搞个直方图),有时候又想拿到完整的指令执行流水账(轨迹追踪)。

静态分析工具,像 objdump 这种,只能告诉你可执行文件里有哪些指令,但程序跑起来具体走了哪条分支、调用了哪个函数指针指向的地址、循环体执行了多少次……这些它可就无能为力了。我们需要的是一个能在运行时插一脚,实时记录信息的“探针”。

那用 QEMU 能不能办到这事儿呢?或者有没有其他工具可以帮忙?答案是肯定的,尤其是 QEMU,本身就提供了很强的观测能力。

为什么静态分析不够看?动态执行的奥秘

简单说,静态分析看到的是“地图”,而动态追踪看到的是“实际行车路线和堵车点”。为什么光看地图不够?

  1. 条件分支满天飞if-elseswitch-case,程序跑起来具体走哪条路,得看当时的输入和状态。静态分析只能把所有可能性都列出来。
  2. 函数指针和间接跳转 :C 语言里的函数指针、虚函数(在 C++ 里),或者手写汇编里的间接跳转 (jalr 在 RISC-V 里很常见),静态分析很难确定它到底会跳到哪里。
  3. 动态链接库 (Shared Libraries) :程序运行时才加载的库函数,它们的指令自然不在主程序的静态代码里。
  4. 代码生成与修改 :虽然不常见,但 JIT (Just-In-Time) 编译或者某些刁钻的代码会动态生成或修改指令。
  5. 性能热点分析 :即使你知道所有可能执行的指令,但不知道哪些指令被反复执行了成千上万次。想优化性能,就得找到这些“热点”指令或代码块,这必须靠动态统计(直方图)。

所以,要想真正理解程序的运行时行为,动态指令追踪或统计是少不了的。

用 QEMU 捕捉动态指令:几种可行的方法

QEMU 作为模拟器,掌控着客户机 (Guest) 代码执行的方方面面。它的核心是 TCG (Tiny Code Generator),一个动态二进制翻译器。Guest 指令被翻译成 Host 能执行的代码块 (Translated Block, TB)。这就给咱们提供了天然的切入点。下面介绍几种在 QEMU 里获取动态指令信息的方法。

方法一:QEMU 内建的调试日志 (-d 参数)

QEMU 提供了一个 -d 参数,可以开启各种内部事件的日志记录,其中就有跟指令执行相关的。

原理与作用

qemu-system-riscv64 -d help 可以看到一堆可选的日志项。和咱们目标比较相关的是:

  • in_asm:记录进入 QEMU 的 Guest 汇编指令。它会在每个翻译块 (TB) 的开头打印这个块对应的所有 Guest 指令。
  • exec:记录执行的 TCG 操作码(中间表示)。更底层,信息量也更大,通常不是直接看 Guest 指令的最佳选择。
  • cpu:记录 CPU 状态的变化,比如寄存器写入。

in_asm 是最接近我们需求的。它会在 QEMU 准备执行一个翻译好的代码块 (TB) 之前,把这个 TB 对应的原始 Guest 指令打印出来。注意,它打印的是 即将执行的代码块,而不是 每一条指令执行时 都打印。如果一个代码块被反复执行,它可能只在第一次翻译或执行时打印一次(具体行为可能因 QEMU 版本和 TCG 缓存策略略有不同)。

操作步骤与示例

假设你的 RISC-V Linux 可执行文件叫 hello_riscv,你想在 qemu-system-riscv64virt 机器上运行它。

  1. 启动 QEMU 并开启日志

    # 假设你已经有 riscv64 的 Linux 环境镜像 (rootfs.img) 和内核 (kernel.img)
    # 需要将你的 hello_riscv 程序放入镜像中,或者通过网络、virtio-fs 等方式让 Guest 能访问到
    qemu-system-riscv64 \
        -machine virt \
        -m 2G \
        -nographic \
        -kernel path/to/kernel.img \
        -append "root=/dev/vda rw console=ttyS0" \
        -drive file=path/to/rootfs.img,format=raw,id=hd0 \
        -device virtio-blk-device,drive=hd0 \
        -d in_asm \
        -D qemu_in_asm.log \
        -- /path/inside/guest/hello_riscv
        # 或者不直接执行程序,启动后手动登录执行,日志同样会记录
    
    • -d in_asm: 开启输入汇编指令的日志。
    • -D qemu_in_asm.log: 将日志重定向到文件 qemu_in_asm.log,避免刷屏,方便后续分析。
  2. 分析日志文件

    打开 qemu_in_asm.log,你会看到类似下面的内容(具体指令和地址会不同):

    ----------------
    IN:
    0x00000000000104f0:  fe010113          addi    sp, sp, -32
    0x00000000000104f4:  00113c23          sd      ra, 24(sp)
    0x00000000000104f8:  00813823          sd      s0, 16(sp)
    0x00000000000104fc:  02010413          addi    s0, sp, 32
    ... (更多指令)
    
    ----------------
    IN:
    0x0000000000010510:  fa843023          sd      a0, -24(s0)
    0x0000000000010514:  fac43423          sd      a1, -32(s0)
    ...
    

    每一块 IN: 开头的就是一个翻译块 (TB) 包含的 Guest 指令。

局限性

  • 信息粒度 :它记录的是 代码块 的指令,而不是严格意义上的 每一条 执行。一个块执行 N 次,可能只记录一次块内容。所以,直接用它做精确的指令执行计数(直方图)很困难,做完整的轨迹追踪也很难保证顺序和完整性。
  • 性能影响 :开启日志,尤其是详细的日志,对 QEMU 性能影响很大。
  • 输出格式 :输出的是文本日志,需要自己写脚本解析才能得到结构化的数据,比如统计指令频率。
  • 可能不是“原生”指令 :极其罕见的情况下,TCG 的前端处理可能会对指令做微小的等效变换,但对于 in_asm 来说,通常显示的是比较准确的 Guest 指令。

总的来说,-d in_asm 是个快速查看 QEMU 大概执行了哪些代码块的方法,但对于精确追踪和统计,有点力不从心。

方法二:QEMU TCG 插件—— 精准打击的瑞士军刀

想在 QEMU 里搞点精细活儿,TCG 插件 (Plugin) 机制是官方推荐、功能也最强大的方式。它允许你编写 C/C++ 代码,挂载到 QEMU 的执行流程中,在特定事件发生时执行你的回调函数。

原理与作用

QEMU 插件提供了一套 API (在 qemu-plugin.h 头文件里定义),允许你在以下关键节点插入自定义逻辑:

  • 翻译块 (TB) 翻译时 :当 QEMU 翻译一段新的 Guest 代码块时,你可以检查这个块里的每一条指令 (QEMU_PLUGIN_TB_TRANS 事件,通过 qemu_plugin_register_vcpu_tb_trans_cb 注册回调)。
  • 指令执行时 :你可以在 TB 翻译阶段,为 某些或所有 指令插入一个“钩子”。当这条指令在之后实际被执行时,你的钩子函数会被调用 (QEMU_PLUGIN_INSN_EXEC 事件,通过 qemu_plugin_register_vcpu_insn_exec_cb 注册回调)。这对于指令轨迹追踪和精确计数至关重要。
  • 内存访问时 :可以监控特定内存地址的读写操作(QEMU_PLUGIN_MEM_RW 等)。
  • 系统调用时 :可以捕获 Guest 发起的系统调用。
  • 插件加载/卸载、vCPU 启动/停止时 :可以进行初始化和资源清理。

对于咱们的目标:

  • 指令轨迹追踪 :可以在 QEMU_PLUGIN_INSN_EXEC 的回调里,记录下当前执行指令的地址 (PC)。
  • 指令直方图 :同样在 QEMU_PLUGIN_INSN_EXEC 的回调里,用一个数据结构(比如 C++ 的 std::map<uint64_t, uint64_t> 或 C 的哈希表/数组)来统计每个指令地址的执行次数。

操作步骤与示例

咱们来写个简单的插件,先实现轨迹追踪,再改成直方图。

1. 编写插件 C 代码 (trace.c)

#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <glib.h> // QEMU 内部广泛使用 GLib

#define QEMU_PLUGIN_VERSION QEMU_PLUGIN_VERSION_MAJ_MIN(1, 0)
#include "qemu-plugin.h" // 这个头文件是关键

// 全局开关,可以通过参数控制是否开启追踪
static bool tracing_enabled = false;
// 用于存储直方图数据 <指令地址, 执行次数>
static GHashTable *insn_histogram = NULL;

// 每个 vCPU 的指令执行回调函数
static void vcpu_insn_exec_cb(unsigned int vcpu_index, void *userdata) {
    if (!tracing_enabled) return; // 如果没开启,直接返回

    struct qemu_plugin_insn *insn = (struct qemu_plugin_insn *)userdata;
    uint64_t pc = qemu_plugin_insn_vaddr(insn);

    // --- 指令轨迹追踪 ---
    // 直接打印 PC 值
    // fprintf(stderr, "TRACE: VCPU %d, PC=0x%" PRIx64 "\n", vcpu_index, pc);

    // --- 指令直方图统计 ---
    if (insn_histogram) {
        // 从哈希表中查找当前 PC
        gpointer count_ptr = g_hash_table_lookup(insn_histogram, GUINT_TO_POINTER(pc));
        uint64_t count = GPOINTER_TO_UINT(count_ptr); // GLib 指针转整数技巧
        count++; // 次数加 1
        // 更新哈希表
        g_hash_table_insert(insn_histogram, GUINT_TO_POINTER(pc), GUINT_TO_POINTER(count));
    }
}

// TB 翻译时的回调函数,在这里为每条指令注册执行回调
static void vcpu_tb_trans_cb(unsigned int vcpu_index, void *userdata) {
    struct qemu_plugin_tb *tb = (struct qemu_plugin_tb *)userdata;
    int n = qemu_plugin_tb_n_insns(tb); // 获取 TB 中的指令数量

    for (int i = 0; i < n; i++) {
        struct qemu_plugin_insn *insn = qemu_plugin_tb_get_insn(tb, i);
        // 为这条指令注册 vcpu_insn_exec_cb 回调,把指令信息本身 (insn) 作为 userdata 传过去
        qemu_plugin_register_vcpu_insn_exec_cb(insn, vcpu_insn_exec_cb, QEMU_PLUGIN_CB_NO_REGS, (void *)insn);
    }
}

// 插件退出时的回调函数,用来打印直方图结果
static void plugin_exit_cb(qemu_plugin_id_t id, void *p) {
    if (insn_histogram && tracing_enabled) {
        printf("--- Instruction Histogram ---\n");
        GHashTableIter iter;
        gpointer key, value;
        // 遍历哈希表
        g_hash_table_iter_init(&iter, insn_histogram);
        while (g_hash_table_iter_next(&iter, &key, &value)) {
            uint64_t pc = GPOINTER_TO_UINT(key);
            uint64_t count = GPOINTER_TO_UINT(value);
            // 可以考虑对结果排序后再输出,或者输出到文件
            printf("PC: 0x%" PRIx64 " Count: %" PRIu64 "\n", pc, count);
        }
        printf("--- End Histogram ---\n");
        // 清理哈希表
        g_hash_table_destroy(insn_histogram);
        insn_histogram = NULL;
    }
}

// 插件加载时的主入口函数
QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id,
                                         const qemu_info_t *info,
                                         int argc, char **argv) {
    printf("Plugin loading...\n");

    // 解析插件参数,例如 -plugin trace.so,enable=true
    for (int i = 0; i < argc; i++) {
        if (strcmp(argv[i], "enable=true") == 0) {
            tracing_enabled = true;
            printf("Instruction tracing/histogram enabled via argument.\n");
        }
    }

    if (tracing_enabled) {
        // 初始化哈希表 (用于直方图)
        insn_histogram = g_hash_table_new(g_direct_hash, g_direct_equal);
        if (!insn_histogram) {
            fprintf(stderr, "Failed to create histogram table!\n");
            return -1;
        }
    } else {
         printf("Tracing disabled by default. Use '-plugin your_plugin.so,enable=true' to enable.\n");
    }


    // 注册 TB 翻译回调
    qemu_plugin_register_vcpu_tb_trans_cb(id, vcpu_tb_trans_cb);
    // 注册插件退出回调
    qemu_plugin_register_atexit_cb(id, plugin_exit_cb, NULL);

    printf("Plugin loaded successfully.\n");
    return 0;
}

2. 编译插件

你需要 QEMU 的头文件。如果你是源码编译的 QEMU,头文件就在源码目录里。如果是通过包管理器安装的,可能需要安装对应的 -devel-dev 包。

# 假设 QEMU 头文件在 /path/to/qemu/include
# 需要链接 GLib,通常系统自带或需要安装 libglib2.0-dev (Debian/Ubuntu) 或 glib2-devel (Fedora/CentOS)
gcc -I/path/to/qemu/include -shared -fPIC -o trace.so trace.c $(pkg-config --cflags --libs glib-2.0)

-shared -fPIC 用来生成共享库。

3. 运行 QEMU 并加载插件

qemu-system-riscv64 \
    -machine virt \
    # ... 其他 QEMU 参数照旧 ...
    -plugin ./trace.so,enable=true \
    -- /path/inside/guest/hello_riscv
  • -plugin ./trace.so,enable=true: 加载我们编译好的 trace.so 插件,并传递 enable=true 参数给它。

运行效果

  • 轨迹追踪 (如果取消 vcpu_insn_exec_cb 中 histogram 代码的注释,并保留打印 PC 的代码):标准错误输出会疯狂刷屏,显示执行的每条指令的 PC 地址和 vCPU 索引。信息量巨大!

  • 指令直方图 (按示例代码原样):程序正常执行,结束后 QEMU 退出时,会调用 plugin_exit_cb,在标准输出打印出类似下面的直方图统计结果:

    --- Instruction Histogram ---
    PC: 0x104f0 Count: 1
    PC: 0x104f4 Count: 1
    PC: 0x104f8 Count: 1
    PC: 0x105a0 Count: 1001
    PC: 0x105a4 Count: 1000
    PC: 0x105a8 Count: 1000
    PC: 0x10510 Count: 1
    ... (大量指令及其执行次数)
    --- End Histogram ---
    

    这就清楚地显示了哪些地址的指令执行次数最多。

安全建议

  • 插件代码是直接在 QEMU 进程空间里运行的,权限很高。如果插件代码有 bug (比如内存访问越界),可能会搞崩 QEMU。
  • 如果插件需要与外部交互(如写文件、网络通信),务必小心处理,做好错误检查和资源管理。避免在频繁调用的回调(如指令执行回调)里做太重的 I/O 操作,影响性能。

进阶使用技巧

  • 过滤vcpu_tb_trans_cb 里可以检查指令的地址 (qemu_plugin_insn_vaddr) 或反汇编结果 (qemu_plugin_insn_disas),只为感兴趣的指令(比如特定函数内的、特定类型的指令)注册 exec 回调,减少开销。
  • 更丰富的指令信息qemu_plugin_insn_* 系列函数可以获取指令的字节码、操作数等。结合 Capstone 反汇编库 (需要在插件编译时链接 Capstone),可以获得非常详细的指令信息。
  • 条件触发 :可以在插件中设置更复杂的逻辑,比如只在特定条件满足时(如某个寄存器等于特定值)才开始记录。
  • 与 GDB 配合 :可以同时使用 -gdb 开启 GDB stub 和 -plugin 加载插件,方便在 GDB 中设置断点,然后观察插件输出或触发插件的特定行为。
  • 性能优化vcpu_insn_exec_cb 调用极其频繁,里面的逻辑一定要快!尽量把数据聚合、计算等放到 vcpu_tb_trans_cb 或者干脆在 plugin_exit_cb 里统一处理。使用高效的数据结构(例如 GHashTable)。对于直方图,如果内存足够,地址空间不太离散,用大数组直接映射 PC 可能比哈希表更快。

TCG 插件是 QEMU 进行动态分析的终极武器,灵活度和精度都最高,是解决这类问题的首选方案。

方法三:其他可能性(简述)

1. GDB 远程调试

理论上,你可以通过 QEMU 的 GDB Stub (-s-gdb tcp::...) 连接 GDB,然后用 GDB 的 si (step instruction) 命令单步执行,同时记录 PC 值。

  • 优点 :不需要写额外代码,利用现有调试工具。
  • 缺点极其 慢!GDB 和 QEMU 之间每次单步都要通信交互,跑完一个普通程序可能要等到天荒地老。并且自动化记录和统计比较麻烦。基本不适用于获取完整轨迹或大规模统计。

2. 特定架构模拟器

对于 RISC-V,除了 QEMU,还有一些其他的模拟器,比如官方的 Spike。Spike 也提供了交互式调试和记录指令轨迹的功能 (例如使用 +tracelog_commits 选项,具体请查阅 Spike 文档)。如果你的目标平台恰好是 Spike 支持的,并且对 QEMU 的系统模拟不是强依赖,Spike 也是一个可选的工具。

总结一下

要在 QEMU 中获取 RISC-V 应用的动态指令执行轨迹或直方图:

  • 快速瞥一眼qemu -d in_asm 可以看看大概执行了哪些代码块。
  • 精准分析和统计QEMU TCG 插件 是王道。自己写 C 代码,利用插件 API 精确捕捉指令执行事件,实现轨迹记录或直方图统计。灵活、强大,性能开销可控。

选择哪种方法,取决于你的具体需求:要多精确?要什么样的数据?对性能有多敏感?多数情况下,花点时间学习和编写 TCG 插件是值得的投资。