ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

QMP 协议实战:从零构建 QEMU 虚拟机可编程管理接口

QMP 协议实战:从零构建 QEMU 虚拟机可编程管理接口 1. QMP 到底是什么从“黑盒虚拟机”到“可编程机器”很多人第一次接触 QEMU都是从命令行敲下qemu-system-aarch64 -M virt -cpu cortex-a72 ...这一长串参数开始的。虚拟机跑起来了能登录、能编译、能跑测试一切看起来都挺好。但当你想要在虚拟机运行过程中动态插拔一块磁盘、热添加一个网卡、查询当前所有块设备的 IO 状态或者写个脚本自动感知客户机是否崩溃时命令行参数就显得力不从心了——因为命令行只能管“开机那一刻”管不了“运行中的每一秒”。QMP全称QEMU Machine Protocol就是为解决这个问题而生的。它是 QEMU 对外暴露的一套基于JSON的文本协议运行在 QEMU 进程内部通过一个 socketUnix domain socket 或 TCP与外部程序通信。你可以把它理解成虚拟机的“遥控器接口”外部程序发送一条 JSON 指令QEMU 执行后回一条 JSON 结果中间还可能异步推送事件通知。它和 QAPIQEMU API是配套的——QAPI 是 QEMU 内部用一套 schema 语言定义接口的框架QMP 则是这套框架在运行时的 JSON 表现形式。一句话概括QMP 让 QEMU 从“只能靠命令行启动的黑盒”变成了“可以被程序实时观测和操控的机器”。它解决的核心问题是虚拟机的可管理性、可观测性和可自动化。适合谁来学三类人最需要它一是做虚拟化平台或云管平台的开发者需要对接底层 QEMU二是做自动化测试的工程师需要脚本控制虚拟机生命周期三是折腾嵌入式、ARM64 模拟、国产系统镜像安装的爱好者想搞清楚virsh、libvirt这些上层工具底下到底在干什么。需要先说明一点QMP 本身不负责“怎么装系统”“怎么配网络”它只负责“发指令、收结果、收事件”。真正的业务逻辑在上层工具里。所以学 QMP重点不是背命令而是理解它的通信模型、消息格式和典型交互套路。2. 协议设计拆解为什么是 JSON为什么是 socket2.1 通信模型一条 socket 上的请求-响应-事件三件套QMP 的通信模型非常朴素就是一条全双工的字节流通道。QEMU 启动时通过-qmp参数指定监听地址外部程序连上来之后双方用一行一个 JSON 对象的方式对话。注意这个“一行一个”很关键QMP 不使用长度前缀也不使用分隔符嵌套而是靠换行符\n来切分消息。这意味着你发送的 JSON 里不能有裸换行字符串内部的换行必须转义成\n否则解析会错位。消息分三类命令command客户端发给 QEMU形如{execute: query-status, arguments: {...}, id: req-1}。成功响应success responseQEMU 回给客户端形如{return: {...}, id: req-1}。错误响应error response形如{error: {class: GenericError, desc: ...}, id: req-1}。异步事件eventQEMU 主动推送形如{event: RESET, data: {...}, timestamp: {...}}。这里的id字段是客户端自己生成的用来把响应和请求对应起来。因为 QMP 允许流水线pipeline发送多条命令响应不一定按顺序回来所以id是必须的。很多人写脚本时偷懒不加id单条命令测试没问题一旦并发就抓瞎。2.2 为什么选 JSON 而不是二进制协议这是个值得展开的问题。QEMU 早期其实有过别的管理接口尝试但最终 QMP 选择了 JSON 文本协议原因有几层第一可调试性压倒一切。虚拟化排障场景里工程师经常需要手动连上去敲命令看状态。JSON 是纯文本socat、nc、Python 的 socket 都能直接交互肉眼可读抓包可看。二进制协议虽然省带宽但在这种“人机混合调试”的场景里是灾难。第二schema 驱动带来的强类型。QAPI 用一套.json的 schema 文件定义所有命令、参数、返回类型和事件。QEMU 构建时会根据 schema 自动生成 C 代码和文档。这意味着 QMP 的接口是“有契约”的不是随手拼的字符串。你查query-block返回什么字段是有权威定义的不会今天有inserted明天变device。第三跨语言友好。任何语言都有 JSON 库Python、Go、Rust、C# 都能几行代码接上 QMP。相比之下如果 QMP 用自定义二进制格式每种语言都得写一套编解码生态就起不来。代价当然也有JSON 文本比二进制大高频查询时带宽和解析开销更高。但对于管理面control plane这种低频、低带宽的场景这点开销完全可以接受。真正的高频数据面比如块设备 IO走的是别的通道不走 QMP。2.3 握手qmp_capabilities这道门槛连上 QMP socket 后你不能立刻发命令。QEMU 会先推一条greeting消息里面包含 QEMU 版本、支持的 capability 列表等信息。你必须先发一条{execute: qmp_capabilities}收到{return: {}}之后才进入命令模式。这个设计是为了兼容性老版本客户端连上新版本 QEMU 时可以通过 greeting 里的信息判断对方能力再决定怎么交互。如果你跳过这一步直接发query-status会收到CommandNotFound或者GenericError提示你还没进入命令模式。提示有些封装库比如 Python 的qmp包会自动帮你完成握手但如果你手写 socket 交互这一步千万别漏。3. 实操上手从零连上 QMP 并跑通第一条命令3.1 启动一个带 QMP 的 QEMU 实例假设你手头有一个 ARM64 的虚拟磁盘镜像想启动 QEMU 并开放 QMP。最简命令大概长这样以 aarch64 virt 机器为例qemu-system-aarch64 \ -M virt \ -cpu cortex-a72 \ -smp 2 \ -m 2048 \ -drive filedisk.qcow2,ifnone,iddrive0 \ -device virtio-blk-pci,drivedrive0 \ -qmp unix:/tmp/qmp-demo.sock,serveron,waitoff \ -nographic关键在-qmp这一项。它的语法是-qmp 地址,serveron|off,waiton|off。几个参数的含义unix:/tmp/qmp-demo.sock监听 Unix domain socket。也可以用tcp:127.0.0.1:4444走 TCP但生产环境更推荐 Unix socket因为文件权限天然做了访问控制不占端口也不怕被外部扫到。serveronQEMU 作为服务端等待连接。如果设成offQEMU 会主动去连你指定的地址适合“QEMU 被管理程序拉起”的场景。waitoff不阻塞启动。如果设成waitonQEMU 会停在启动阶段等你连上来这在调试早期启动流程时有用但正常使用一般设off。如果你还想同时保留一个人类可读的 monitorHMP可以再加一个-monitor但注意 QMP 和 HMP 是两个不同的接口别混用。3.2 用 socat 手动对话最原始也最直观的方式是用socat连上去socat - UNIX-CONNECT:/tmp/qmp-demo.sock连上后你会先看到 greeting一行 JSON然后手动输入{execute: qmp_capabilities}回车收到{return: {}}。接着试{execute: query-status}返回类似{return: {status: running, singlestep: false, running: true}}再试一个稍微复杂点的{execute: query-block}这个返回会很长包含所有块设备的详细信息设备名、插入的镜像文件、读写统计、是否只读、后端节点等。如果你之前用-drive挂过盘这里就能看到对应条目。注意手动输入 JSON 时一定要保证是单行。如果你在编辑器里格式化成多行再粘贴QMP 会因为换行符而解析失败。这是新手最常踩的坑之一。3.3 用 Python 写一个最小客户端手动敲只能应急真正干活还得靠脚本。下面是一个不依赖第三方库的最小 Python 客户端直接操作 socketimport 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._read_message() # greeting self.execute(qmp_capabilities) def _read_message(self): while b\n not in self.buf: chunk self.sock.recv(4096) if not chunk: raise ConnectionError(QMP connection closed) self.buf chunk line, self.buf self.buf.split(b\n, 1) return json.loads(line.decode(utf-8)) def execute(self, cmd, argsNone): msg {execute: cmd} if args: msg[arguments] args self.sock.sendall((json.dumps(msg) \n).encode(utf-8)) while True: resp self._read_message() if event in resp: continue # 先忽略异步事件 if error in resp: raise RuntimeError(resp[error]) return resp[return] if __name__ __main__: c QMPClient(/tmp/qmp-demo.sock) print(c.execute(query-status)) print(c.execute(query-cpus-fast))这段代码有几个细节值得说_read_message里用了一个缓冲区self.buf因为 socket 的recv不保证一次收到完整的一行。必须循环读到出现\n为止。这是所有手写 QMP 客户端都绕不开的。execute里遇到event就continue因为异步事件可能在任何时刻插进来。真实项目里应该把事件分发到单独的处理逻辑而不是简单丢弃。没有加id因为这里是同步单条发送。如果要并发必须加id并维护一个 pending 字典。跑通之后你可以试着调用query-block、query-pci、query-memory-size-summary等命令感受一下 QMP 能拿到多少运行时信息。4. 核心命令与事件真正干活时用到的那些4.1 查询类命令把虚拟机状态“读”出来QMP 的查询命令是使用频率最高的一类。它们不改状态只返回信息相对安全。常用的有命令作用典型用途query-status查询虚拟机运行状态判断是否 running/paused/shutdownquery-cpus-fast查询 vCPU 列表及线程 ID做 CPU 亲和性绑定、性能分析query-block查询块设备及镜像信息监控磁盘、确认热插成功query-blockstats查询块设备 IO 统计采集读写延迟、IOPSquery-pci查询 PCI 设备树确认设备是否挂上、地址分配query-memory-size-summary查询内存总量与已用内存监控query-version查询 QEMU 版本兼容性判断query-commands列出所有支持的命令能力探测这里重点说query-blockstats。它返回的字段里有rd_operations、wr_operations、rd_bytes、wr_bytes、flush_operations等累计值。做监控时你需要自己算两次采样之间的差值再除以时间间隔才能得到速率。QMP 本身不给你算好的速率这是设计上的取舍——它只提供原始数据计算交给上层。query-cpus-fast返回的thread-id是宿主机上的线程 ID这个非常有用。你可以拿它去/proc/pid/task/tid里看调度信息或者用taskset把 vCPU 绑到特定物理核上。做低延迟场景时这一步是标配。4.2 控制类命令热插拔与生命周期管理控制类命令会改变虚拟机状态用的时候要小心。几个典型system_powerdown给客户机发 ACPI 关机信号。注意这只是“请求”客户机如果不响应比如没装 ACPI 驱动虚拟机不会真的关。system_reset硬复位相当于按复位键。stop/cont暂停和恢复虚拟机。做快照、迁移前通常先stop。device_add/device_del热插拔设备。比如热添加一块 virtio 磁盘{execute: device_add, arguments: { driver: virtio-blk-pci, drive: drive1, id: disk1 }}但注意device_add之前你得先用blockdev-add或drive_add把后端存储准备好。QEMU 的块设备模型分“前端”guest 看到的设备和“后端”宿主机上的镜像文件两者要分别配置再关联。这是新手最容易搞混的地方。blockdev-add添加后端块设备节点。blockdev-del删除后端节点前提是没有前端在用。netdev_add/netdev_del网络后端。热插拔的顺序很重要先加后端再加前端删除时先删前端再删后端。顺序反了会报DeviceInUse之类的错误。4.3 异步事件让程序“感知”而不是“轮询”QMP 的事件机制是它区别于简单 RPC 的关键。事件由 QEMU 主动推送常见的有RESET虚拟机复位。SHUTDOWN客户机发起关机。POWERDOWN收到电源按钮事件。STOP虚拟机被暂停。RESUME虚拟机恢复。BLOCK_IO_ERROR块设备 IO 出错。DEVICE_DELETED设备删除完成。GUEST_PANICKED客户机内核 panic。有了事件你就不用轮询query-status了。比如监控客户机是否崩溃只需监听GUEST_PANICKED收到就告警。这比每秒查一次状态高效得多也更及时。事件消息里带timestamp格式是{seconds: ..., microseconds: ...}。做日志关联时可以用它对齐时间线。但要注意这个时间戳是 QEMU 进程所在宿主机的时钟不是客户机的。实操心得事件是“尽力而为”的QMP 不保证事件不丢。如果你的业务对事件可靠性要求极高得在收到事件后再用查询命令做一次状态确认形成“事件触发 查询兜底”的双保险。5. 常见问题与排查技巧实录5.1 连不上、握手失败、命令报错怎么办下面这张表是我在实际项目里整理出来的高频问题速查现象可能原因排查与解决Connection refusedsocket 文件不存在或 QEMU 没起来检查-qmp参数、确认 QEMU 进程存活连上后发命令无响应忘了发qmp_capabilities先握手再发命令CommandNotFound命令名拼错或该版本不支持用query-commands列出实际支持的命令JSON 解析错误消息里有裸换行或多余空格确保单行、用标准 JSON 库序列化响应和请求对不上没加id或并发处理有 bug每条命令带唯一id用字典匹配DeviceInUse删除时前端还在用后端先device_del再blockdev-del事件收不到客户端读取逻辑把事件丢了单独线程读 socket事件和响应分流中文乱码编码不一致统一用 UTF-85.2 几个只有踩过才知道的坑坑一query-block的返回结构随版本变化。早期版本里插入的镜像信息在inserted字段下后来引入了qdev节点图结构变成inserted和device并存再后来又推荐用query-named-block-nodes。如果你写的解析代码硬编码了字段路径升级 QEMU 后可能直接崩。稳妥做法是先query-version判断版本或者用query-named-block-nodes这种更稳定的接口。坑二Unix socket 的权限。QEMU 创建的 socket 文件权限默认受 umask 影响。如果管理程序和 QEMU 不在同一个用户下可能连不上。可以在-qmp里用unix:/path,serveron之后手动chmod或者干脆让两者同用户运行。TCP 方式虽然方便但一定要绑127.0.0.1别绑0.0.0.0否则等于把虚拟机控制权开放给整个网络。坑三system_powerdown不等于关机完成。很多人以为发了system_powerdown虚拟机就关了其实它只是模拟按电源键。客户机可能因为没装 ACPI、或者卡在某个进程而不响应。正确做法是发完之后监听SHUTDOWN事件超时后再考虑quit强制退出 QEMU 进程。quit是最后手段会直接杀掉 QEMU客户机来不及刷盘有数据丢失风险。坑四事件和响应混在一条流里。如果你用单线程“发一条读一条”的简单模型遇到 QEMU 在两条命令之间推了个事件你的读取逻辑就会把事件当成响应导致解析错位。正确架构是一个线程专门读 socket读到消息后判断是event还是return/error分别投递到事件队列和响应队列。这是写生产级 QMP 客户端的必修课。坑五device_del是异步的。发完device_del收到{return: {}}只代表“删除请求已接受”不代表设备已经删干净。真正的完成信号是DEVICE_DELETED事件。如果你紧接着就blockdev-del后端可能因为前端还没完全移除而失败。稳妥做法是等DEVICE_DELETED事件到了再删后端。5.3 调试技巧把 QMP 流量抓下来看排查协议问题时最有效的办法是把原始流量抓下来。Unix socket 可以用socat做中间人转发socat -v UNIX-LISTEN:/tmp/qmp-proxy.sock,fork UNIX-CONNECT:/tmp/qmp-demo.sock-v会把双向流量打印到 stderr带方向标记。然后让客户端连/tmp/qmp-proxy.sock你就能看到每一条 JSON 的原文。这个方法在排查“为什么这条命令报错”时特别管用因为你能确认自己发出去的到底是什么。另一个技巧是用query-commands做能力探测。不同 QEMU 版本支持的命令集不一样尤其是涉及块设备和显示的部分。写跨版本兼容的客户端时启动后先拉一遍命令列表把不支持的功能降级处理比硬编码假设要稳得多。6. 从 QMP 到上层生态它在你熟悉工具里的位置6.1 libvirt、virsh 与 QMP 的关系很多人用virsh管理虚拟机觉得它和 QMP 是两套东西。其实virsh底下走的是 libvirtlibvirt 再往下对接 QEMU 时用的正是 QMP。libvirt 把 QMP 的原始命令封装成了更高层的抽象domain、device、storage pool 等概念。你执行virsh attach-disklibvirt 内部会翻译成一串 QMP 命令blockdev-adddevice_add并处理事件等待和错误回滚。理解这层关系的好处是当virsh报错信息含糊时你可以直接连上 QMP 看底层到底发生了什么。比如virsh说“attach disk failed”但没说为什么你连 QMP 手动执行同样的命令就能看到具体的error.desc定位快很多。6.2 自动化测试与 CI 中的 QMP在自动化测试场景里QMP 常被用来做“测试夹具”fixture。典型流程是测试框架启动 QEMU带 QMP通过 QMP 等待客户机启动完成监听事件或轮询状态注入测试负载采集query-blockstats等指标最后system_powerdown并等待SHUTDOWN。整个过程无需人工干预也不需要 SSH 进客户机。这里有个经验等待客户机启动完成不要用固定 sleep。不同镜像、不同硬件配置启动时间差异很大sleep 短了会失败长了浪费时间。更好的做法是监听客户机内部的就绪信号比如通过串口输出特定字符串或者用 QEMU Guest AgentQMP 这边配合事件做同步。6.3 与 QEMU Guest Agent 的分工QMP 是宿主机侧的管理接口QEMU Guest AgentGA是客户机侧的代理两者通过 virtio-serial 通信。QMP 管“虚拟机这台机器”GA 管“客户机操作系统内部”。比如想在客户机里执行命令、读写文件、获取客户机 IP用 GA。想热插拔设备、查询块设备、控制虚拟机生命周期用 QMP。两者经常配合使用。比如做文件级备份先用 QMP 的guest-fsfreeze-freeze这个命令其实是 QMP 转发给 GA 的冻结客户机文件系统再用 QMP 做块设备快照最后guest-fsfreeze-thaw解冻。理解这个分工才能设计出正确的备份和迁移流程。7. 我个人的几条实操建议第一永远先握手再干活。不管用什么库确认qmp_capabilities返回成功再发业务命令。我见过太多“命令发了没反应”的问题最后都是握手漏了。第二给每条命令加id。哪怕你现在是同步调用加上id也不亏。将来改成并发时这个id就是你的救命稻草。生成id用递增整数或 UUID 都行关键是唯一。第三事件处理要独立。不要把事件处理和命令响应混在一个循环里。用一个专门的读取线程把消息按类型分流。这是从“能跑”到“稳定”的分水岭。第四热插拔严格按顺序。后端先于前端创建前端先于后端删除。删除时等DEVICE_DELETED事件再动后端。这个顺序错了错误信息往往很隐晦排查起来费时。第五用query-commands做兼容性探测。跨版本部署时启动后先拉命令列表不支持的功能优雅降级比运行时崩溃强。第六抓包是终极武器。协议层的问题看原始 JSON 流量比看任何日志都快。socat -v做代理转发几秒钟就能定位问题。最后分享一个小技巧如果你只是想快速看看某个 QEMU 实例支持哪些命令、返回什么结构可以用query-commands配合query-command-line-options部分版本支持来探索。把返回的 JSON 存下来用jq过滤比翻文档快得多。比如jq .return[].name就能列出所有命令名再针对感兴趣的用jq看参数结构。这套“探索式调试”在对接新版本 QEMU 时特别高效。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进