精准获取 QEMU 中 RISC-V 指令轨迹与执行频率
2025-04-24 20:50:12
如何在 QEMU 中获取 RISC-V 应用的动态指令执行轨迹或直方图?
问题来了:我想看看我的 RISC-V 程序到底执行了哪些指令
哥们儿,你是不是也遇到过这种情况:手头有一个编译好的 RISC-V Linux 程序,想搞清楚它在 QEMU 里跑起来的时候,实际执行了哪些指令?注意,是动态执行 的,不是 objdump -d
扒拉出来的静态代码。有时候只想知道个大概,比如哪些指令执行得最频繁(搞个直方图),有时候又想拿到完整的指令执行流水账(轨迹追踪)。
静态分析工具,像 objdump
这种,只能告诉你可执行文件里有哪些指令,但程序跑起来具体走了哪条分支、调用了哪个函数指针指向的地址、循环体执行了多少次……这些它可就无能为力了。我们需要的是一个能在运行时插一脚,实时记录信息的“探针”。
那用 QEMU 能不能办到这事儿呢?或者有没有其他工具可以帮忙?答案是肯定的,尤其是 QEMU,本身就提供了很强的观测能力。
为什么静态分析不够看?动态执行的奥秘
简单说,静态分析看到的是“地图”,而动态追踪看到的是“实际行车路线和堵车点”。为什么光看地图不够?
- 条件分支满天飞 :
if-else
、switch-case
,程序跑起来具体走哪条路,得看当时的输入和状态。静态分析只能把所有可能性都列出来。 - 函数指针和间接跳转 :C 语言里的函数指针、虚函数(在 C++ 里),或者手写汇编里的间接跳转 (
jalr
在 RISC-V 里很常见),静态分析很难确定它到底会跳到哪里。 - 动态链接库 (Shared Libraries) :程序运行时才加载的库函数,它们的指令自然不在主程序的静态代码里。
- 代码生成与修改 :虽然不常见,但 JIT (Just-In-Time) 编译或者某些刁钻的代码会动态生成或修改指令。
- 性能热点分析 :即使你知道所有可能执行的指令,但不知道哪些指令被反复执行了成千上万次。想优化性能,就得找到这些“热点”指令或代码块,这必须靠动态统计(直方图)。
所以,要想真正理解程序的运行时行为,动态指令追踪或统计是少不了的。
用 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-riscv64
的 virt
机器上运行它。
-
启动 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
,避免刷屏,方便后续分析。
-
分析日志文件 :
打开
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 也提供了交互式调试和记录指令轨迹的功能 (例如使用 +trace
或 log_commits
选项,具体请查阅 Spike 文档)。如果你的目标平台恰好是 Spike 支持的,并且对 QEMU 的系统模拟不是强依赖,Spike 也是一个可选的工具。
总结一下
要在 QEMU 中获取 RISC-V 应用的动态指令执行轨迹或直方图:
- 快速瞥一眼 :
qemu -d in_asm
可以看看大概执行了哪些代码块。 - 精准分析和统计 :QEMU TCG 插件 是王道。自己写 C 代码,利用插件 API 精确捕捉指令执行事件,实现轨迹记录或直方图统计。灵活、强大,性能开销可控。
选择哪种方法,取决于你的具体需求:要多精确?要什么样的数据?对性能有多敏感?多数情况下,花点时间学习和编写 TCG 插件是值得的投资。