1. 从一个被忽视的调试入口说起
很多人第一次接触 QEMU,都是从命令行参数开始的。敲一行qemu-system-aarch64 -M virt -cpu cortex-a57 ...,虚拟机就跑起来了。用久了会发现一个尴尬的事:虚拟机跑起来之后,想动态改点东西——比如热插拔一块盘、临时挂起、查一下当前块设备的实际状态——命令行参数是静态的,改不了。这时候就得靠 QMP,也就是QEMU Machine Protocol。
QMP 是 QEMU 对外暴露的一套基于 JSON 的控制协议。它跑在一个 socket 或者标准输入输出上,客户端发 JSON 命令,QEMU 回 JSON 响应,中间还可能穿插异步事件。你可以把它理解成 QEMU 的"遥控器接口":命令行是开机前设定好的剧本,QMP 是运行中随时能按的按钮。libvirt、virt-manager 这些上层管理工具,底层跟 QEMU 对话靠的就是它。热词里那一堆qemu模拟arm64、termux安装qemu、windows使用qemu安装openeular arm虚拟机,只要涉及"运行中动态操作",最后都会落到 QMP 上。
这篇东西写给两类人:一类是已经在用 QEMU 跑虚拟机、但只会改命令行参数的;另一类是想自己写个脚本或小工具去管理 QEMU 实例的。我会把 QMP 的协议结构、握手流程、命令分类、事件机制、实操脚本、以及踩过的坑,按我自己的理解顺序讲一遍。看完你应该能自己写一个能连上 QEMU、查状态、做热插拔的小工具。
2. QMP 到底是什么,为什么不是 QAPI
2.1 QMP 和 QAPI 的关系,别搞混
热词里同时出现了 QMP 和 QAPI,这俩经常被混着说,但其实是两层东西。QAPI 是 QEMU 内部的 schema 定义框架,它用一套自定义的 DSL 描述所有命令、事件、类型,然后通过代码生成器生成 C 结构体、JSON 序列化/反序列化代码、以及文档。QMP 是这套 schema 对外暴露的运行时协议,也就是 QAPI 定义出来的东西,通过 JSON 报文在 socket 上跑起来的那一层。
打个比方:QAPI 是接口定义文件(类似.proto或者 OpenAPI spec),QMP 是真正跑在网线上的 HTTP/JSON 流量。你写客户端的时候面对的是 QMP,但你想知道某个命令有哪些参数、返回什么结构,得去翻 QAPI 生成的文档,也就是qemu-qmp-ref那份东西。
这个区分很重要,因为很多人搜"QMP 命令列表"搜不到,其实应该搜 QAPI 的 reference。QEMU 源码里qapi/目录下那一堆.json文件,就是所有命令的源头定义。
2.2 为什么用 JSON 而不是自定义二进制协议
QEMU 早期其实有过别的监控接口,比如 HMP(Human Monitor Protocol),就是你在 QEMU 窗口里敲info block、quit那种。HMP 是给人看的,输出是格式化文本,解析起来极其痛苦——不同版本输出格式还不一样,写脚本的人被坑得死去活来。
QMP 换成 JSON 有几个直接好处。第一,结构化,字段名和类型明确,不用正则去抠文本。第二,跨语言,任何语言都有 JSON 库,Python、Go、Rust 都能几行代码接上。第三,可扩展,加字段不影响老客户端,客户端忽略不认识的字段就行。第四,请求响应和事件能统一在一个通道里,异步事件和同步响应用同一套 JSON 对象表达,靠字段区分。
代价是 QMP 比 HMP 啰嗦,人肉敲命令不现实。但 QMP 本来就不是给人敲的,是给程序用的。真要人肉调试,QEMU 还留了 HMP 作为 QMP 的一个子命令(human-monitor-command),可以两全。
2.3 QMP 能干什么,一张能力清单
不夸张地说,QEMU 运行时的几乎所有可动态调整的东西都在 QMP 里。我按用途分几类,方便你对号入座:
| 类别 | 典型命令 | 用途 |
|---|---|---|
| 生命周期 | stop、cont、system_reset、quit | 暂停、恢复、重启、退出 |
| 块设备 | blockdev-add、device_add、blockdev-snapshot | 热插拔盘、打快照 |
| 网络 | netdev_add、netdev_del | 动态加网卡后端 |
| 设备 | device_add、device_del、device-list-properties | 热插拔设备、查属性 |
| 迁移 | migrate、migrate-incoming、query-migrate | 在线迁移 |
| 查询 | query-status、query-block、query-cpus-fast | 查各种运行时状态 |
| 内存 | query-memory-size-summary、balloon | 内存气球、容量查询 |
| 事件 | SHUTDOWN、RESET、STOP、BLOCK_IO_ERROR | 异步通知 |
这张表不是全部,QAPI 里命令有几百个,但日常用得最多的就是上面这些。你如果只是想让脚本能查状态、能优雅关机、能热插一块盘,掌握这四类就够了。
3. 协议结构:握手、命令、事件三件套
3.1 连接建立后的第一件事:greeting
QMP 不是连上就能发命令的,有个握手过程。客户端连上 socket 之后,QEMU 会先主动推一条greeting消息过来,长这样:
{ "QMP": { "version": { "qemu": {"major": 8, "minor": 2, "micro": 0}, "package": "" }, "capabilities": ["oob"] } }这条消息告诉你三件事:QEMU 版本号、支持的 capabilities 列表、以及协议版本。capabilities里如果有oob,说明支持 out-of-band 命令,也就是某些命令可以插队执行,不被主循环阻塞——这个后面讲。
收到 greeting 之后,客户端必须发一条qmp_capabilities命令,才能进入正常命令模式:
{"execute": "qmp_capabilities"}QEMU 回:
{"return": {}}到这一步,握手完成。没发qmp_capabilities之前,除了少数几个命令,其他一律报错。这是新手最常踩的坑之一,连上了、发了命令、收到CommandNotFound或者GenericError,八成是忘了握手。
3.2 命令报文的两种形态
QMP 命令报文有两种写法,取决于有没有参数。
无参数命令,直接给execute字段:
{"execute": "query-status"}有参数命令,参数放在arguments里:
{ "execute": "device_add", "arguments": { "driver": "virtio-blk-pci", "drive": "disk1", "id": "mydisk" } }响应统一是{"return": ...}或者{"error": ...}。成功时return里是命令的返回值,可能是空对象、字符串、数组、复杂结构。失败时error里是:
{ "error": { "class": "GenericError", "desc": "Device 'mydisk' is already in use" } }class是错误分类,desc是给人看的描述。写脚本的时候,判断成功失败要看有没有error字段,而不是看return是不是空——有些命令成功也返回空对象。
3.3 事件:异步消息怎么混进来
QMP 是单通道的,同步响应和异步事件都从同一个 socket 出来。客户端怎么区分?看字段。有event字段的是事件,有return或error字段的是命令响应。
一个典型事件:
{ "event": "RESET", "data": {"guest": true}, "timestamp": {"seconds": 1700000000, "microseconds": 123456} }timestamp是 QEMU 加上去的,方便你记录事件发生时间。data是事件携带的负载,不同事件结构不同,有的没有data。
这里有个实操上的大坑:如果你的客户端是"发一条命令、读一条响应"的简单模型,遇到事件就会错位。比如你发了query-status,结果先收到一个RESET事件,你的代码把事件当成响应解析,直接崩。正确做法是循环读,直到读到带return/error的那条,把中间的事件缓存或分发出去。这个逻辑后面给代码。
3.4 out-of-band 命令:为什么需要插队
默认情况下,QMP 命令是在 QEMU 主循环里串行执行的。如果某条命令卡住了(比如查询一个响应很慢的块设备),后面的命令全得排队。oobcapability 就是解决这个的:开启后,标记为 oob 的命令可以在单独的线程里执行,不被主循环阻塞。
开启方式是在握手时带上:
{"execute": "qmp_capabilities", "arguments": {"enable": ["oob"]}}然后发 oob 命令时加个标记:
{"execute": "query-status", "id": "req1", "control": {"run-oob": true}}注意id字段,这是客户端自己生成的请求 ID,响应里会原样带回,用来匹配请求和响应。在 oob 场景下id几乎是必须的,因为响应顺序不再保证和请求顺序一致。普通场景下id可选,但建议都带上,方便调试。
4. 动手:从零写一个 QMP 客户端
4.1 启动一个带 QMP 的 QEMU 实例
先得有个 QEMU 在跑,并且开了 QMP。最简方式是用-qmp参数:
qemu-system-aarch64 \ -M virt -cpu cortex-a57 -m 1024 \ -qmp unix:/tmp/qmp.sock,server=on,wait=off \ -nographic-qmp后面跟的是 QMP 的监听地址。unix:/tmp/qmp.sock表示用 Unix domain socket,server=on表示 QEMU 作为服务端监听,wait=off表示不阻塞等待客户端连接。也可以用 TCP:
-qmp tcp:127.0.0.1:4444,server=on,wait=off注意:QMP 默认没有任何认证。Unix socket 靠文件权限保护,TCP 则完全裸奔。千万不要把 QMP 监听到公网地址上,任何能连上的人都能控制你的虚拟机,包括执行
quit直接关掉它。生产环境要么用 Unix socket,要么套一层访问控制。
如果你已经有 QEMU 在跑但没开 QMP,那就得重启加参数。QMP 没法在运行时动态开启,这是设计上的取舍——控制通道必须在启动时确定。
4.2 用 Python 写一个最小可用客户端
Python 标准库自带socket和json,不用装任何东西。下面这个类我用了很久,够稳:
import socket import json class QMPClient: def __init__(self, path): self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(path) self.buf = b"" self.events = [] # 读 greeting self._read_message() # 握手 self.execute("qmp_capabilities") def _read_message(self): while True: # 先看缓冲区里有没有完整的一行 if b"\n" in self.buf: line, self.buf = self.buf.split(b"\n", 1) if line.strip(): return json.loads(line) chunk = self.sock.recv(4096) if not chunk: raise ConnectionError("QMP connection closed") self.buf += chunk def execute(self, cmd, **args): msg = {"execute": cmd} if args: msg["arguments"] = args self.sock.sendall((json.dumps(msg) + "\n").encode()) # 循环读,跳过事件 while True: resp = self._read_message() if "event" in resp: self.events.append(resp) continue if "error" in resp: raise RuntimeError(resp["error"]["desc"]) return resp.get("return") def close(self): self.sock.close()用起来:
q = QMPClient("/tmp/qmp.sock") print(q.execute("query-status")) print(q.execute("query-cpus-fast")) q.close()这段代码的关键点在于execute里的while True循环:它把事件和响应分开处理,事件存起来,响应才返回。这就是 3.3 节说的那个坑的解法。很多人图省事写recv一次就json.loads,遇到事件必崩。
4.3 一个容易忽略的细节:消息边界
QMP 用换行符\n分隔消息,但一次recv不保证读到完整消息。TCP 是字节流,Unix socket 也是,一条 JSON 可能被拆成多个包,也可能多条消息挤在一个包里。所以必须自己维护缓冲区,按\n切分。
上面代码里self.buf就是干这个的。每次recv追加到缓冲区,然后检查有没有\n,有就切出一条完整消息。这个模式是所有基于行分隔协议的客户端都要写的,不写迟早出问题——本地测试可能一直正常,一上负载就偶发解析失败。
实操心得:调试 QMP 的时候,我习惯在
_read_message里加一行print(line),把原始报文打出来。JSON 解析失败时能立刻看到到底收到了什么,比猜快得多。
5. 常用命令实战:查询、热插拔、迁移
5.1 查询类命令:先看清楚现状再动手
动手改之前先查,这是铁律。几个最常用的查询命令:
query-status返回虚拟机运行状态:
{"return": {"status": "running", "singlestep": false, "running": true}}status可能是running、paused、shutdown、prelaunch等。写脚本做优雅关机时,先查状态,如果是paused得先cont再发关机。
query-block返回所有块设备信息,包括插入的介质、读写统计、是否可写:
{ "return": [ { "device": "disk1", "inserted": { "file": "/var/lib/vm/disk1.qcow2", "drv": "qcow2", "ro": false }, "stats": {"rd_bytes": 12345, "wr_bytes": 67890} } ] }query-cpus-fast比老的query-cpus快,因为它不触发 CPU 状态同步,返回每个 vCPU 的线程 ID、是否在线:
{ "return": [ {"cpu-index": 0, "thread-id": 12345, "props": {"core-id": 0}} ] }thread-id很有用,你可以拿它去taskset绑核,或者用perf单独分析某个 vCPU 的性能。
5.2 热插拔块设备:blockdev-add 加 device_add 两步走
热插一块盘是 QMP 最经典的用法,但很多人第一次做会失败,因为它需要两步,而且顺序不能反。
第一步,把后端存储加进来,用blockdev-add:
{ "execute": "blockdev-add", "arguments": { "driver": "qcow2", "node-name": "disk2", "file": { "driver": "file", "filename": "/var/lib/vm/disk2.qcow2" } } }这里node-name是给这个块节点起的名字,后面device_add要引用它。driver是格式层(qcow2、raw),file是协议层(file、nbd、rbd 等)。这种分层结构是 QEMU 块设备模型的核心,理解了这个,各种存储后端就都能拼出来。
第二步,把前端设备挂上去,用device_add:
{ "execute": "device_add", "arguments": { "driver": "virtio-blk-pci", "drive": "disk2", "id": "disk2-dev" } }drive字段引用第一步的node-name,id是这个设备的唯一标识,卸载时要用。
卸载反过来:先device_del删前端,再blockdev-del删后端。
{"execute": "device_del", "arguments": {"id": "disk2-dev"}} {"execute": "blockdev-del", "arguments": {"node-name": "disk2"}}注意:
device_del是异步的,命令返回不代表设备已经删干净。设备真正移除后会发一个DEVICE_DELETED事件。如果你紧接着就blockdev-del,可能报"节点仍在使用"。稳妥做法是等DEVICE_DELETED事件到了再删后端。这个坑我踩过不止一次,尤其在脚本里连续操作时。
5.3 在线迁移:migrate 命令族
迁移是 QMP 里比较重的一块,命令有好几个。最简的用法:
{ "execute": "migrate", "arguments": { "uri": "tcp:192.168.1.100:4444", "channels": [] } }发出去之后迁移在后台跑,你得用query-migrate轮询进度:
{ "return": { "status": "active", "ram": {"total": 1073741824, "transferred": 536870912, "remaining": 536870912}, "total-time": 5000 } }status从setup到active到completed,中间可能进postcopy-active。ram里的remaining是还没传的内存字节数,可以用来估算剩余时间。
迁移过程中如果源端内存变化太快,remaining一直不降,说明脏页产生速度超过了传输速度,会进入dirty-limit或者干脆卡住。这时候要么降负载,要么开auto-converge:
{"execute": "migrate-set-parameters", "arguments": {"auto-converge": true}}auto-converge会自动给 vCPU 降频,牺牲一点性能换迁移能收敛。这是生产环境迁移大内存虚拟机的常用手段。
6. 事件机制与错误处理:让脚本真正可靠
6.1 值得关注的事件清单
QMP 事件有几十种,日常需要处理的主要是这几类:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
SHUTDOWN | 客户机发起关机 | 感知虚拟机要关了 |
RESET | 虚拟机复位 | 记录重启次数 |
STOP/RESUME | 暂停/恢复 | 同步状态 |
DEVICE_DELETED | 设备移除完成 | 确认热拔完成 |
BLOCK_IO_ERROR | 块设备 IO 出错 | 告警、切盘 |
MIGRATION | 迁移状态变化 | 跟踪迁移进度 |
GUEST_PANICKED | 客户机内核 panic | 自动重启或告警 |
SHUTDOWN事件带一个guest字段,true表示是客户机主动发起的(比如poweroff),false表示是 QEMU 层面触发的。这个区分在自动化运维里很有用:客户机主动关机可以放心回收资源,QEMU 层面关机可能是异常,得告警。
6.2 事件驱动的脚本骨架
一个健壮的 QMP 客户端应该是事件驱动的,而不是纯轮询。骨架大概这样:
def run_forever(qmp): while True: msg = qmp._read_message() if "event" in msg: handle_event(msg) elif "return" in msg or "error" in msg: handle_response(msg) def handle_event(msg): ev = msg["event"] if ev == "SHUTDOWN": print("guest shutdown, guest-initiated:", msg["data"]["guest"]) elif ev == "BLOCK_IO_ERROR": print("IO error on", msg["data"]["device"]) elif ev == "DEVICE_DELETED": print("device removed:", msg["data"]["device"])这个模型下,命令响应和事件在同一个循环里处理,不会错位。如果你用异步框架(asyncio、gevent),思路一样,只是把阻塞读换成异步读。
6.3 错误分类与重试策略
QMP 的error.class有几个常见值,处理策略不同:
GenericError:通用错误,通常是参数不对或状态不允许,重试没用,得改逻辑。CommandNotFound:命令不存在,可能是 QEMU 版本太老,或者拼错了。DeviceNotFound:设备不存在,检查id或node-name。GenericError里带 "is already in use":资源冲突,先清理再重试。
重试策略上,我的经验是:只有明确是瞬态错误的才重试,比如迁移过程中的临时失败。参数错误、状态错误重试一万次也没用,只会刷屏。判断瞬态还是永久,看desc里的关键词,或者干脆维护一个白名单。
实操心得:写自动化脚本时,我给每个 QMP 命令都包一层重试装饰器,但重试条件写得很严——只对
GenericError且desc包含 "try again" 或 "busy" 的重试,最多三次,间隔指数退避。这样既覆盖了瞬态问题,又不会在真错误上死循环。
7. 常见问题排查速查表
实际用 QMP 遇到的问题,八成集中在下面这几类。我整理成表,方便对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
连上后发命令报CommandNotFound | 没发qmp_capabilities | 先握手 |
| 响应和预期对不上 | 把事件当响应解析了 | 循环读到return/error |
| JSON 解析偶发失败 | 没处理消息分片 | 维护缓冲区按\n切 |
device_del后blockdev-del报占用 | 设备还没删完 | 等DEVICE_DELETED事件 |
迁移卡在active不收敛 | 脏页产生太快 | 开auto-converge或降负载 |
| TCP 连不上 | 监听地址或防火墙 | 检查-qmp参数和端口 |
| 命令执行很慢 | 主循环被阻塞 | 考虑开oobcapability |
收到一堆BLOCK_IO_ERROR | 底层存储出问题 | 查宿主机磁盘和网络存储 |
再补几个不那么常见但很坑的:
QEMU 版本差异。QMP 命令在不同 QEMU 版本间有增删改。比如query-cpus在新版本里被标记为 deprecated,推荐用query-cpus-fast。写跨版本脚本时,先query-qmp-schema拿到当前 QEMU 支持的完整 schema,再决定用哪些命令。这个命令返回一大坨 JSON,描述了所有命令、事件、类型的定义,是自省的利器。
id字段冲突。device_add的id必须全局唯一,重复会报错。脚本里最好用带前缀的命名,比如disk-<uuid>,避免和 QEMU 内部生成的 id 撞车。
热插拔 CPU 的限制。不是所有架构和机器类型都支持 CPU 热插拔。x86 上一般可以,arm64 的virt机器类型支持有限。动手前先query-hotpluggable-cpus查一下当前配置支持哪些 CPU 槽位,别盲目device_add。
内存气球和 KSM 的交互。用balloon命令回收内存时,如果宿主机开了 KSM,回收效果可能和预期不符,因为 KSM 已经把相同页合并了。排查内存问题时要把这层考虑进去。
8. 我踩过的几个真实坑
说几个文档里不会写、但实际会遇到的。
第一个是握手时机。有次写脚本,连上 socket 后立刻发query-status,结果报错。原因是 greeting 还没读完就发了命令,QEMU 那边状态机还没准备好。正确做法是连上后先阻塞读一条消息(就是 greeting),再发qmp_capabilities,再发业务命令。顺序错一步都不行。
第二个是事件积压。长时间运行的客户端如果不及时读 socket,QEMU 那边的事件会堆在缓冲区里,堆满了可能阻塞 QEMU 主循环。我见过一个监控脚本,只在需要查状态时才读 socket,结果跑了一天把 QEMU 卡住了。QMP 连接必须持续读,哪怕你暂时不关心事件,也得读出来丢掉,不能让它积压。
第三个是优雅关机的正确姿势。直接发quit是硬关,相当于拔电源,客户机文件系统可能损坏。优雅关机应该发system_powerdown,这会模拟按电源键,让客户机自己走关机流程。然后监听SHUTDOWN事件,等到了再quit。这个流程在自动化里特别重要,我见过太多脚本直接quit导致磁盘镜像损坏的案例。
第四个是迁移后的连接处理。迁移完成后,源端的 QEMU 进程还在,QMP 连接也还在,但虚拟机已经跑到目标端了。这时候你在源端发命令,行为可能很怪。正确做法是迁移完成后主动断开源端连接,去连目标端的 QMP。目标端的 QMP 地址在启动时用-qmp指定,或者迁移时通过migrate的uri参数约定。
第五个是schema 自省的价值。刚开始我总去网上搜命令用法,后来发现query-qmp-schema才是权威。它返回的 schema 里,每个命令的参数类型、是否可选、返回结构都写得清清楚楚。写脚本前先拉一份 schema 存下来,比翻文档快得多。而且 schema 是运行时拿的,和当前 QEMU 版本完全匹配,不会有版本对不上的问题。
9. 把 QMP 用起来的几个方向
QMP 本身是个协议,价值在于它能撑起什么。我见过和做过的几个方向:
自定义监控面板。用 QMP 定期query-block、query-cpus-fast、query-memory-size-summary,把数据推到自己的监控系统。比走 libvirt 轻量,适合嵌入式或者资源受限的场景。
自动化测试编排。测试用例里需要动态插盘、拔盘、模拟 IO 错误,QMP 的blockdev-add、device_del、block_set_io_throttle正好覆盖。配合事件监听,能写出很精细的测试流程。
故障注入。QMP 有一些命令专门用来模拟异常,比如blockdev-snapshot配合blockdev-del可以模拟存储故障,stop/cont可以模拟卡顿。做高可用测试时很有用。
轻量管理工具。不想引入 libvirt 那一整套依赖时,直接用 QMP 写个几十行的管理脚本,能启动、查询、关机、迁移,够用。热词里那些termux安装qemu、windows使用qemu安装openeular arm虚拟机的场景,往往就是这种轻量需求,QMP 脚本比装 libvirt 合适得多。
和上层工具集成。很多编排系统需要和 QEMU 对话,QMP 是标准入口。理解 QMP 之后,看 libvirt 的源码或者调试 libvirt 和 QEMU 之间的问题,会清晰很多——你知道 libvirt 发的每条命令到底在干什么。
最后分享一个小技巧:调试 QMP 时,可以用socat把 Unix socket 转成 TCP,然后用nc或者浏览器插件手动发 JSON,比写代码快。命令是socat UNIX-CONNECT:/tmp/qmp.sock TCP-LISTEN:5555,reuseaddr,然后nc localhost 5555就能手敲命令了。不过记得手敲时每条 JSON 后面要跟换行,否则 QEMU 不认。这个方式适合快速验证某个命令的参数格式,验证完再写进脚本。