QEMU user 态跨架构运行:x86 主机直接执行 RISC-V 程序
拿到一份为 RISC-V 编译的 ELF 可执行文件,手里却只有 x86_64 主机,第一反应通常是找一台 RISC-V 板子或者开一台虚拟机。但如果这份程序只是个用户态小程序、不依赖特殊设备,根本不必惊动整机模拟:QEMU 的 user 态组件 qemu-riscv64 本身就是一个用户态转译器,能把 RISC-V 指令块翻译成 x86 指令块就地执行,程序发起的系统调用则转交主机内核处理。再配合内核的 binfmt_misc 机制,外来架构的 ELF 甚至可以像本机程序一样直接敲路径运行,调用者完全感觉不到转译的存在。user 态与 system 态的分工边界在姊妹篇 qemu-system 与 qemu-user 的区别:整机模拟与单进程转译怎么选 里展开,本篇只盯住一个进程从「跑不了」到「透明跑通」的完整周期;对 QEMU 整机概念还陌生的读者,可先读 认识 QEMU:从零看懂虚拟机、镜像与快照 建立背景。
本文要回答的问题
- x86_64 内核凭什么判定一份 RISC-V ELF「不能执行」,
Exec format error和退出码 126 分别是谁给出的? qemu-riscv64 ./hello-riscv64这条显式转译命令里,转译器、外来程序、主机内核三者是什么关系?binfmt_misc注册项里的 magic、mask、interpreter、flags PF 各自起什么作用,内核怎么知道该找哪个解释器?- 把注册项临时注销后会发生什么,为什么
systemctl restart systemd-binfmt救不回来,正确的恢复命令是什么? - 动态链接的 RISC-V 程序为什么必须加
-L指定 sysroot,少了它报什么错?
一、先造一份 x86 内核不认识的 RISC-V ELF
实验用的程序小到只有一个 printf,但有意让它自报家门:打印自己的进程号,并通过编译期宏 __riscv 确认自己是按 RISC-V 目标编出来的。
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("hello from pid=%d on arch=", getpid());
fflush(stdout);
#ifdef __riscv
printf("riscv64 (uname machine follows)\n");
#else
printf("unknown\n");
#endif
return 0;
}在 x86_64 的 Ubuntu 22.04 上安装 gcc-riscv64-linux-gnu 交叉工具链后,用 riscv64-linux-gnu-gcc 加 -static 编出静态链接版本。交叉编译器本身是 x86 程序,只是它产出的机器码面向 RISC-V,file 命令一眼就能看穿这份产物的真实身份。下面的实录包含交叉编译、两次执行尝试与 binfmt 注册项的初始状态。
实录第 3 行的 file 输出值得逐段读:ELF 64-bit LSB executable 说明它是 64 位小端 ELF;关键在 UCB RISC-V——ELF 头里的 e_machine 字段被写成了 RISC-V 的架构编号,x86_64 内核的执行链路里没有任何处理程序认识这个编号;statically linked 表示它不依赖外部动态库,转译执行时少一个找库的麻烦,这也是本篇先用静态版、把动态版留到第六节的原因。
二、没有转译时会怎样:Exec format error 与退出码 126
先说清楚内核拒绝一份外来 ELF 的机理。shell 敲下 ./hello-riscv64 后,流程是 shell 调用 execve 陷入内核,内核依次尝试各种 binfmt 处理程序(脚本格式、原生 ELF 格式等)去认领这个文件。原生 ELF 处理程序读到 e_machine 字段发现与本机架构不符,返回 ENOEXEC;如果没有任何其他处理程序认领,execve 整体失败,bash 把它翻译成 cannot execute binary file: Exec format error,并以 126 作为退出码——126 是 bash 约定的「命令找到但不可执行」档位,与「命令不存在」的 127 相区分。
这台实验机上的小插曲恰好让机理暴露得更彻底。实录第 5 至 7 行显示,第一次直接 ./hello-riscv64 居然成功了(pid 12308,exit 0)——因为系统预装了 qemu-user-static,开机时已经把 RISC-V 的 binfmt 处理项注册进内核,外来 ELF 被悄悄接走了。想看到「裸奔」状态下的原生报错,必须先把这个钩子摘掉。第五节会完整演示注销动作,这里先看注销后的对照结果:同样的直接执行,立刻变成 Exec format error、退出码 126。同一个文件、同一条命令,差别只在内核里有没有那条注册项,这正是理解 binfmt_misc 作用的最好对照。
三、qemu-riscv64 显式转译:把外来 ELF 当参数交给转译器
不依赖任何内核钩子,最朴素的用法是把外来 ELF 作为参数显式递给 qemu-riscv64。实录第 9 至 11 行,qemu-riscv64 ./hello-riscv64 正常打印 hello from pid=12310 on arch=riscv64,退出码 0。注意这里的 pid 是转译器进程在主机命名空间里的真实进程号——user 态没有客户机内核、没有第二颗 CPU,qemu-riscv64 自己就是主机上的一个普通进程,它加载 ELF、把 RISC-V 指令块翻译成本机指令块执行,程序里的 getpid() 经过系统调用转译后,拿到的就是主机内核分配的 pid。
整条转译路径涉及五个角色,下图按角色语义着色:蓝色是执行请求的发起方,红色是内核里起分流作用的 binfmt 钩子,橙色是实际干活的转译器,灰色是被加载的外来文件,绿色是托底系统调用的主机内核。
读这张图抓住两条路径的区别。显式转译时根本不经过红色的 binfmt 钩子:是用户自己在命令行点名 qemu-riscv64,由橙色转译器直接打开灰色 ELF;而透明执行时,shell 的 execve 先进内核,红色钩子凭文件头 magic 认出这是 RISC-V ELF,代为启动橙色转译器,再把 ELF 路径作为参数传过去。两条路径殊途同归,后面的指令转译与系统调用透传完全一样。
四、binfmt_misc:让内核凭文件头自动找到解释器
binfmt_misc 是内核提供的一个可扩展执行格式框架,挂在 /proc/sys/fs/binfmt_misc/ 这个特殊文件系统上。每注册一种外来格式,目录下就出现一个同名文件,里面写清「认什么文件、交给谁执行」。实录第 13 至 21 行是本机 qemu-riscv64 注册项的完整内容,逐字段拆开看。
对照流程卡的第四步读注册项。magic 是一串十六进制字节序列,7f454c46 正是所有 ELF 文件共有的开头 \x7fELF,后面跟着的位数、字节序、e_machine 等字节把范围收窄到「64 位小端 RISC-V ELF」;mask 是与文件头按位与的掩码,掩码为 0 的字节表示「不关心」,这样同为 RISC-V ELF 但版本标志略有差异的文件也能被同一规则命中。offset 0 表示从文件第 0 字节开始匹配。interpreter /usr/libexec/qemu-binfmt/riscv64-binfmt-P 是匹配成功后内核要启动的解释器路径,也就是 qemu-riscv64 的注册副本。
flags: PF 两个字母各管一件事。P(preserve)表示注册项在内存中持久保留,并且解释器程序在注册时就被打开持有,不随某次执行结束而释放;F(fix-binary)更进一步,保证解释器文件在容器和不同挂载命名空间里始终指向同一个已打开对象——这正是 qemu-user-static 能在容器构建场景里透明转译外来架构程序的关键。如果没有 F,容器里挂不到主机的解释器路径,透明执行就会在容器内失效。
注册项生效后,实录第 23 至 25 行的直接执行与显式转译输出别无二致(pid 12316,exit 0),但发起方已经不再需要知道 qemu-riscv64 这个命令的存在:构建脚本、make 调用的外来编译器、包里自带的外来架构辅助工具,全都能不加修改地跑起来,这是 binfmt 方案相对手动转译最大的工程价值。
五、注销与恢复:把报错复现出来,再把钩子装回去
为了亲眼确认第二节的原生报错确实来自「没有处理程序认领」,实验用内核约定的注销方式向注册项写入 -1,随后立刻重试直接执行。下面的实录覆盖动态版对照、注销复现、一次无效恢复尝试与最终恢复的全过程。
实录第 31 至 34 行是注销后的第一组关键证据:提示 (qemu-riscv64 已注销) 之后,./hello-riscv64 立刻得到 cannot execute binary file: Exec format error,退出码 126,与第二节的机理预测完全一致。第 42 行顺带验证了注销的彻底性——连 /proc/sys/fs/binfmt_misc/qemu-riscv64 这个注册项文件本身都不存在了。
接下来是一个真实的坑。按直觉,systemctl restart systemd-binfmt 会重放 /usr/lib/binfmt.d/ 下的注册规则,应该能恢复;但实录第 36 至 38 行显示报错依旧。原因在于 Debian/Ubuntu 的 qemu-user-static 包没有把规则放进 systemd-binfmt 的目录,而是登记在 update-binfmts 自己的 /usr/share/binfmts/ 体系里,systemd 重放时根本看不到它。正确的恢复命令是 sudo update-binfmts --import qemu-riscv64,让 update-binfmts 按自家数据库重新写入注册项。实录第 43 至 49 行显示恢复后注册项重新出现、flags: PF 原样回来,再跑一次程序得到 pid 12618、exit 0,完整闭环。
上面的终端实录从上往下呈现了三个状态的切换:上半段是 binfmt 在册时的透明执行,RISC-V 程序直接敲路径就打印出 pid 与架构自报;中段注销注册项后,同一条命令原样返回 Exec format error 并以 126 退出,注册项文件也从 binfmt_misc 目录消失;下半段 update-binfmts 重新导入后,解释器路径与 PF 标志逐项复原,程序再次正常输出。三处 pid 各不相同,说明每次都是主机上全新的转译进程,而非缓存结果。
把常见故障按现象整理成表,排错时对号入座即可。
| 典型现象 | 根因 | 判定方法 | 处理 |
|---|---|---|---|
| Exec format error,退出码 126 | 无 binfmt 注册项或已被注销 | 查看 /proc/sys/fs/binfmt_misc/ 有无 qemu-riscv64 | 显式用 qemu-riscv64 跑,或 update-binfmts --import 恢复 |
| restart systemd-binfmt 后依旧报错 | qemu-user-static 规则不在 systemd 目录 | 查 /usr/share/binfmts/ 有无登记 | 改用 update-binfmts --import qemu-riscv64 |
| 容器内透明执行失效 | 注册项缺 F 标志,解释器未固定 | 注册项 flags 只有 P 没有 F | 安装 qemu-user-static 并重新注册,确认 flags 为 PF |
| 显式转译也报找不到文件 | 动态链接程序找不到 RISC-V 版加载器 | 见第六节,file 显示 dynamically linked | 加 -L /usr/riscv64-linux-gnu 指定 sysroot |
| 注册项开机后消失 | 注册时未带 P,或包未启用 | 重启后查注册项文件是否存在 | 用带 PF 的规则重新导入 |
六、动态链接版:少了 -L 就找不到外来架构的加载器
静态链接把所有库都打进了 ELF,转译器只管一个文件;真实工程里交叉编译默认产出动态链接程序,情况立刻复杂一层。实录第 28 行的 file 输出标明这是 dynamically linked 的 PIE 可执行文件,并且指定了外来架构的动态加载器 interpreter /lib/ld-linux-riscv64-lp64d.so.1。问题在于:主机的 /lib/ 下只有 x86_64 的加载器,RISC-V 版加载器随交叉工具链装在 /usr/riscv64-linux-gnu/ 这个 sysroot 里。
直接 qemu-riscv64 ./hello-riscv64-dyn 时,转译器会按 ELF 内嵌的解释器路径去找加载器,在主机根文件系统里扑空。正确写法是用 -L 告诉转译器「外来程序的根目录在哪」:
qemu-riscv64 -L /usr/riscv64-linux-gnu ./hello-riscv64-dyn-L 把转译器视角下的根目录重定向到 sysroot,于是 /lib/ld-linux-riscv64-lp64d.so.1 实际解析为 /usr/riscv64-linux-gnu/lib/ld-linux-riscv64-lp64d.so.1,后续所有 RISC-V 共享库也都在这个 sysroot 内查找。实录第 29 至 30 行显示带 -L 后动态版正常输出 pid 12324、exit 0。走 binfmt 透明执行时同理,发行版的注册规则通常已经把 -L /usr/riscv64-linux-gnu 预置进解释器参数,所以直接敲路径才能无感跑通;自己手工注册 binfmt 项时,这个参数必须写进注册行,否则动态程序会集体失败。
另外实录末尾还留了一个背景数字:本机的 qemu-user 套件一共注册了 28 个架构的处理项,RISC-V 只是其中之一,aarch64、arm、mips 等外来架构的转译与 binfmt 机理完全相同,把架构名换掉即可照搬本篇的全部命令。
实验环境
- 机器/系统:云主机 2C8G / Ubuntu 22.04.5(x86_64)
- 工具版本:gcc-riscv64-linux-gnu 11.4.0(Ubuntu 11.4.0-1ubuntu1~22.04)、qemu-user 与 qemu-user-static 套件(含 qemu-riscv64 与 binfmt 注册)
- 日期:2026-11-08
- 完整命令序列:交叉编译静态版与动态版 RISC-V ELF,
file确认架构;直接执行、qemu-riscv64显式转译、查询 binfmt 注册项;向注册项写入-1注销并复现 Exec format error(退出码 126);尝试重启 systemd-binfmt 后改用update-binfmts --import qemu-riscv64恢复;动态版以-L /usr/riscv64-linux-gnu指定 sysroot 跑通,输出即本篇引用的完整实录。
代码获取:本篇实验的源码与一键复现脚本见公开仓库 derekzhuo/geek-doc.cn 的
experiments/qemu-user-cross-arch-binary目录。克隆后进入该目录执行./run.sh,终端会依次打印交叉编译产物信息、显式转译与透明执行结果、注销复现的 126 报错以及 update-binfmts 恢复过程,与本篇实录逐条对应。
这一篇在知识体系里的位置
本篇是 SoC 与 QEMU 入门路线里的 user 态实操深文,与 qemu-system 与 qemu-user 的区别:整机模拟与单进程转译怎么选 互为表里:那篇建立「转译进程与整机模拟」的分界,本篇把单进程转译这条线从编译、执行到内核钩子全部走通。QEMU 的整体图景、镜像与快照等基础概念在 认识 QEMU:从零看懂虚拟机、镜像与快照。再往后,当程序开始依赖真实外设、内核模块或整套发行版环境,user 态的系统调用透传就不够用了,需要切换到 system 态启动带设备模型的虚拟整机,那是后续虚拟平台与外设模拟篇目的主题。
总结
qemu-user 跨架构执行的本质是用户态指令转译加系统调用透传:qemu-riscv64 把外来 ELF 的指令块翻译给主机 CPU 执行,程序拿到的 pid、文件与网络全部来自主机内核。没有任何处理程序认领先外来架构 ELF 时,execve 失败并表现为 Exec format error、bash 退出码 126;显式转译直接把 ELF 递给 qemu-riscv64 即可跑通,binfmt_misc 则凭 magic/mask 匹配文件头、按 interpreter 自动调起转译器,flags 中的 P 持久保留、F 固定解释器文件,二者支撑容器场景的透明执行。注销用向注册项写 -1,恢复在 Debian/Ubuntu 上必须走 update-binfmts --import 而非重启 systemd-binfmt;动态链接程序还要用 -L 指向包含外来架构加载器的 sysroot。掌握这条链路后,跨架构单程序验证、外来编译器引导与容器多架构构建都可以在不启动整机虚拟机的前提下完成。