之前排查一台虚拟机的迁移问题,对方发来一段 HMP 输出让我帮忙看,第一行就是info ramblock,后面跟着一大串块信息:pc.ram、pc.vga.vram、/objects/mem0,每个块都有各自的 size、host、offset。当时的第一反应是:guest 内存根本不是一整块,而是被这些 RAMBlock 拆成了十几个区间,每个区间还有独立的脏页表和管理状态。搞 QEMU/KVM 虚拟化的人迟早都会撞上 RAMBlock 这个词,它没有 MemoryRegion 那么直观(谁都知道那是地址空间里的一个区域),也不像 KVM slot 那样和硬件强相关,但它恰恰是 QEMU 管理真实物理内存的最小单元,贯穿内存分配、热插拔、迁移、脏页跟踪、用户态映射和设备 DMA 这些核心路径。
这篇文章写给两类读者:一类是天天在 KVM 平台上做迁移调优、排查内存占用异常的运维工程师,另一类是准备改 QEMU 源码、写内存相关模块的开发者。我会从结构、创建、迁移、诊断和常见坑五个方向把 RAMBlock 拆开讲,尽量把我在实际项目里踩过的坑、排查过的现象也一并写进去,希望能帮你少走几步弯路。
1. RAMBlock不是一块内存,而是一张“地契”
1.1 先从一个热插拔排查说起
有一次客户的虚拟机上热插拔了 8G 内存,插完之后宿主机的可用内存并没有像预期那样减少一大截,反而是 QEMU 进程的 RSS 涨得很诡异,guest 内部free -g也显示容量没完全识别。我在 QMP 里敲了query-memory-devices,又用 HMP 看了info ramblock,才发现热插拔创建的内存根本不在原来的pc.ram块里,而是新生成了一块名字类似/objects/dimm0的 RAMBlock,大家平铺在同一个全局地址空间里继续往后排。
这个现象很好地解释了 RAMBlock 的定位:它代表 QEMU 在自己进程地址空间里真正“拥有”的一块内存。无论 guest 看到的是多大的物理地址空间,最终都要落到一个个 RAMBlock 上,每个块要么是一段 mmap 出来的匿名内存,要么是一个 memfd 或普通文件映射,要么是一段显存。
理解这一点比背 API 重要得多,因为后面所有和内存相关的现象,比如迁移数据量异常、热插拔之后内存不回收、KVM slot 不够用,本质上都是 RAMBlock 的分配、挂在链表上的位置、bitmap 状态出了问题。
1.2 MemoryRegion、RAMBlock、KVM slot 的三级关系
QEMU 的内存模型在我心里一直是三层:
- MemoryRegion 描述 guest 地址空间里的逻辑区间,它可以是 RAM、ROM、MMIO 或者 alias;
- RAMBlock 描述宿主机上真正分配的物理内存集合,是 MR 的实体承载;
- KVM slot 是向 KVM 注册的映射单元,KVM 靠它完成虚拟地址到物理地址的转换和 dirty page 跟踪。
打个比方:MemoryRegion 是地图上的地块编号,RAMBlock 是这块地上实际盖起来的房子,KVM slot 则是房产交易中心里的登记记录。地图上可以画很多地块但没盖房子,对应 MR 只是 ROM/MMIO;也可以同一栋房子挂在多个地块上做 alias,但真正的产权证只有一张,也就是 RAMBlock 只有一份。memory_region_init_ram()在创建 MR 的时候会顺手把 RAMBlock 也建出来,然后mr->ram_block指向它。KVM 注册的时候再通过 MR 找到底下的 RAMBlock,把host指针和used_length填进KVM_SET_USER_MEMORY_REGION。
很多刚接触 QEMU 内存的人会问:为什么不能直接用 MemoryRegion 管理脏页和迁移?因为 MR 可以是 alias、可以重叠、可以只有地址没有实体映射,这些逻辑层的东西不适合承载物理内存的分配和同步。RAMBlock 这一层让 QEMU 有了一个干净的关键点:所有真实物理内存块都在一个全局链表里,遍历它就能做迁移、做 dirty log、做 UFFD 注册。
1.3 独立一层抽象真正解决的问题
如果没有 RAMBlock,QEMU 要管理的东西会非常散:匿名内存散落在malloc的地址区间,文件后端散落在 mmap 的文件区域,巨页散落在 hstate 分配的区间,这些没法统一编号、统一跟踪变化。RAMBlock 把这些全部拉齐成一个模型:
- 统一全局偏移(offset),每个块在整个可迁移内存空间里占一段不重叠的区间;
- 统一页大小(page_size),4K 也好、2M 巨页也好,bitmap 索引都按它来;
- 统一脏页跟踪(bmap),迁移引擎只需要按块扫 bitmap;
- 统一后端差异,fd 是 -1 就是匿名内存,fd 有效就是 memfd、普通文件、巨页文件。
所以在排查问题的时候,我的习惯是先问一句:这个内存是不是 RAMBlock?如果它不在全局链表里,那它就进不了迁移流,也不会被 KVM 注册,MIGRATION 阶段根本不会和它打交道。反过来,一旦确定某个内存区间确实落在一个 RAMBlock 内,那它一定在全局某个 offset 上有对应位置,迁移协议里就能编码它。
2. 拆解 struct RAMBlock:每个字段背后都有一段故事
2.1 身份信息:idstr、mr、offset
打开include/exec/ramlist.h或include/exec/memory.h附近的源码,能看到struct RAMBlock的第一组字段基本是身份类信息:
char idstr[256]:块的全局唯一名称,pc.ram、pc.vga.vram、/objects/mem0这类;struct MemoryRegion *mr:指向拥有它的 MemoryRegion;ram_addr_t offset:这个块在全局 RAM 空间里的起始偏移。
idstr的重要性远超想象。迁移协议的接收端要根据 idstr 把远端传来的块和本地块一一对应,如果名字对不上,迁移直接失败。创建 MR 的时候如果不给唯一名字,或者设备重复初始化,就会遇到 RAMBlock 重名冲突。
offset则直接决定迁移时每个页面在流里的地址编码,所有 RAMBlock 的 offset 加总起来就是整个 guest 可迁移内存的线性空间。块和块之间不能重叠,分配新块时要调用find_ram_offset()扫描空洞。当然还有一个特殊分支:如果你通过qemu_ram_alloc_from_ptr()传入了一个现成的 host 指针,QEMU 会直接用这个指针按页对齐作为 offset,这种情况下 QEMU 不再负责找空位。这个分支主要用于固件映射一类特殊场景,普通内存后端不会走。
2.2 空间信息:host、used_length、max_length、fd
RAMBlock 的第二组字段管的就是“这块内存实际长什么样”:
uint8_t *host:mmap 之后的用户态虚拟地址;ram_addr_t used_length:当前实际使用的字节数;ram_addr_t max_length:这块内存可能增长到的上限;int fd:文件后端的文件描述符,匿名内存是 -1。
host是 QEMU 访问 guest 内存的直接入口。virtio 设备读写 guest 数据、迁移线程把页面读走、VGA 刷新显存,全部是从 RAMBlock 的 host 指针做 memcpy。这也是为什么 RAMBlock 必须保证 host 指向的区域连续,不能是一堆散页拼起来的,否则memcpy没法做,UFFD 也没法一次性注册。
used_length和max_length的区分是 QEMU 支持内存在线扩容的关键。比如一个设备 MR 初始 4G,预留了 8G 的 max_length,后续 resize 时只调整 used_length,不重新做整块 mmap。迁移引擎读的是used_length,KVM 注册的也是used_length。排查内存占用时如果发现宿主机 RSS 比 guest 实际内存大很多,往往就是 max_length 远大于 used_length,或者退掉的块没有从全局链表移除。
2.3 迁移与脏页相关:bmap、clear_bmap、page_size
RAMBlock 里最烧钱的一组字段是脏页 bitmap:
unsigned long *bmap:每个物理页一个 bit,1 表示“待迁移/脏”。unsigned long *clear_bmap:配合 XBZRLE 压缩用,记录那些允许容忍少量变化的页面区间。size_t page_size:RAMBlock 的页粒度。
一个新创建的 RAMBlock,bmap 默认全部置 1,表示所有页都要迁移。迁移开始后,每次 KVM 同步 dirty log,QEMU 就会把 KVM 返回的脏页信息合并到 bmap 中,然后迁移线程按位扫描、发送页面、清掉对应 bit。page_size 决定帧的大小:普通 4K 页的 RAMBlock,每个脏 bit 对应 4K;如果是 1G 巨页的 RAMBlock,每个脏 bit 对应 1G。后者会在迁移时造成一种很微妙的问题,后面案例里细说。
2.4 链表组织:全局 RAMList 与 RCU
所有 RAMBlock 被挂在一个全局链表ram_list.blocks上,用双向链表组织。之所以用链表而不是数组,是因为内存热插拔时块要动态增删,插入头部是 O(1) 操作;另外迁移时还经常按 idstr 或者 offset 调整遍历顺序,链表约束也更少。
但链表遍历不是想怎么搞就怎么搞。ram_list有锁,遍历通常要跑在 RCU 读临界区里,qemu_ram_foreach_block()这个函数封装了这件事。如果你在回调函数里又去拿mmap_lock或者尝试修改链表,轻则死锁,重则 use-after-free,项目里踩过不只一次。
3. RAMBlock 的出生与死亡:从 MR 到全局链表
3.1 memory_region_init_ram 的调用链
普通内存后端的创建路径一般长这样:
MemoryRegion *mr = g_new0(MemoryRegion, 1); memory_region_init_ram(mr, NULL, "my.ram", size, &error_fatal);memory_region_init_ram()内部会把 MR 标记为 RAM 类型,然后调用qemu_ram_alloc(),最终进入qemu_ram_alloc_internal()。如果是文件后端,走的是memory_region_init_ram_from_fd(),对应qemu_ram_alloc_from_fd()。
这段链路的关键区别在于内存来源:
qemu_ram_alloc_from_ptr():外部传入现成 host 指针,QEMU 不做 mmap;qemu_ram_alloc_from_fd():基于 memfd 或普通文件做 mmap;qemu_ram_alloc():纯匿名 mmap。
-m 4G启动时,机器初始化代码里会创建系统主内存 MR,名字通常是pc.ram,对应的 RAMBlock 就是整个 guest 内存里最大的一块。-object memory-backend-file创建的后端对象,其 RAMBlock 名字通常是/objects/对象名,比如/objects/mem1,这在info ramblock里一眼能认出来。
3.2 ram_block_add 做了哪些事
真正把 RAMBlock 挂进全局链表的是ram_block_add()。它要干的事情我总结为四步:
- 检查 max_length 的对齐和合法性;
- 为新块分配全局 offset,如果 host 为空则调用
find_ram_offset()找一个足够大的空洞; - 初始化 dirty bitmap,全部位置 1;
- 加锁后把块插入
ram_list.blocks,更新 ram_list 的总长度。
这个阶段最容易踩的坑是 offset 分配。频繁插拔内存的设备会让全局内存空间出现很多碎片,虽然find_ram_offset()会尽力利用空洞,但反复热插拔之后,迁移数据里的地址编码可能不再连续,备份迁移设备时必须保证两端 RAMBlock 排列一致。
3.3 同名 RAMBlock 导致的 “already exists”
RAMBlock 的 idstr 必须是全局唯一的,同名注册会直接报错。我见过一个很典型的场景:某设备驱动在复位逻辑里反复调memory_region_init_ram(),每次都传同一个 name,第二次复位时 QEMU 直接报RAMBlock "xxx" already exists,整个设备初始化失败。
出现这种情况不要急着骂 QEMU,先查两件事:一是确认 MR 是否真的被销毁并 unparent 了,二是确认名字后缀是否足够唯一。给设备内存命名的时候,我习惯在名字里带上设备索引或者地址信息,比如net0.rxbuf、vhost0.vring0,避免设备重复实例化时撞名。
3.4 热插拔和热拔除:RAMBlock 没有被轻易销毁
内存热插拔时,QEMU 会创建一个新的 MemoryRegion 和对应的 RAMBlock,热拔除时按理说应该销毁。但实际运维中会发现宿主机的 RSS 并不一定立刻降下来,RAMBlock 的释放非常“收敛”:要等设备引用计数归零、迁移侧不再依赖、KVM slot 完全摘除之后才真正释放,而且不同 QEMU 版本策略还不一样。
所以排查“热拔除后内存不减少”的问题时,不要一上来就怪madvise,先用info ramblock看看目标块到底还在不在全局链表里。如果块还在且used_length不为 0,那就说明 QOM 对象还没释放干净,属于设备生命周期问题,不是 RAMBlock 本身的问题。
4. 迁移引擎里的 RAMBlock:脏页跟踪与迭代
4.1 迁移开始时的块排序与 MEM_SIZE 阶段
迁移初始化时,ram_save_setup()会把所有 RAMBlock 遍历一遍,按 idstr 排序后写入迁移流的初始化数据。之所以排序,是为了让 source 和 destination 在两端看到的块顺序一致,尤其面对热插拔过的 guest,块在链表里的插入顺序可能不同,但按名字排序后就能对齐。
迁移流的头部会先传一个 MEM_SIZE 阶段,里面包含每个 RAMBlock 的 idstr、used_length 和 flags。destination 在解析这个阶段时,会按名字逐个找本地对应的 RAMBlock,长度不一致或者名字找不到,就直接报迁移失败。
从这个机制也能理解为什么 RAMBlock 的 idstr 不能乱改:设备驱动或者后端对象在两端 QEMU 版本不一样时,名字生成规则可能变化,迁移就会在早期阶段挂掉,而不是跑到一半才报错。
4.2 dirty bitmap 的同步轮次
迁移开始后,迭代循环按轮次进行。每一轮迁移线程会做两件事:
- 调用
memory_global_dirty_log_sync(),把 KVM 返回的脏页信息合并到各个 RAMBlock 的 bmap 里; - 从 bmap 里找到脏页并发送,发送完成后把对应 bit 清零。
这里有一个容易误解的点:KVM 的 dirty log 是“增量”的,它只告诉 QEMU 从上次同步以来哪些页面被写过;而 RAMBlock 的 bmap 是“跨轮次”的,迁移线程负责把两边的信息合并。所以 bmap 一定要存在于 QEMU 用户态,不能直接拿 KVM 的 log 来用。
新版的 dirty ring 机制虽然改变了 KVM 上报脏页的方式,但最终数据仍然要落回到 RAMBlock 的 bmap 上。因此,迁移调优的时候,盯着 bmap 的大小和扫描算法看是没错的。
4.3 multifd、RDMA 与压缩都建立在 RAMBlock 上
multifd 多线程迁移,本质上是把 RAMBlock 里的页面按规则分发给多个 channel 并行发送。每个线程处理某个 RAMBlock 的连续区间,目标端再用同样的 idstr 和 offset 映射回去。RDMA 迁移则是把 RAMBlock 的 host 指针注册到 RDMA 内存区,远端可以直接用 offset 作为地址偏移做 one-sided write。
XBZRLE 压缩也离不开 RAMBlock。它针对的是那些“每次都有一点变化”的页面,通过 clear_bmap 记录允许变化的区域,在零重传、缓存旧页之间做一个折中。如果你看到迁移数据量不大但带宽打满,很有可能是压缩逻辑在反复比较同一批页面。
4.4 postcopy 阶段用到了 RAMBlock 的 fd 和 host
postcopy 迁移先把设备状态传完,让 destination 上的 guest 先跑起来,内存页面则在访问时通过 userfaultfd 按需从 source 拉取。QEMU 在进入 postcopy 时,会遍历所有 RAMBlock,把每个块的 host 区间注册到 UFFD,qemu_ram_get_fd(rb)拿到的 fd 直接传给UFFDIO_REGISTER。
这个环节很挑内存后端:文件后端、memfd 通常没问题,某些内核版本对匿名 memfd 支持也还行,但如果 RAMBlock 的 host 是从不可 mmap 的异常来源拿到的,UFFD 注册就可能失败。所以碰到 postcopy 无法启动的问题,先看看是不是某个 RAMBlock 的 fd 或者 host 来源异常。
5. 实战:用 info ramblock 和 gdb 诊断内存问题
5.1 HMP 命令 info ramblock 输出精读
在 QEMU monitor 里敲info ramblock,输出大致长这样:
| Block Name | size | offset | host | page_size | fd |
|---|---|---|---|---|---|
| pc.ram | 0x0000000200000000 | 0x0000000000000000 | 0x7f9b40000000 | 4096 | -1 |
| pc.vga.vram | 0x0000000001000000 | 0x0000000200000000 | 0x7f9b42000000 | 4096 | -1 |
| /objects/mem0 | 0x0000000200000000 | 0x0000000210000000 | 0x7f9b44000000 | 2097152 | 47 |
第一列是 idstr,第二列是 used_length,第三列是全局 offset,第四列是用户态 host 地址,后面的 page_size 和 fd 是新版本才有的增强信息。
拿到这张表,能直接回答几个高频问题:
- 显存有没有被错误地当成普通内存迁移?看
pc.vga.vram的 size。 - 巨页是否真的分配到了 guest 内存?看
pc.ram的 page_size 是否等于 2M 或 1G。 - 文件后端 fd 是否正确?看 fd 列是不是 -1。
- host 地址是不是 2M 对齐?直接对 host 做十六进制检查。
5.2 从 Host 虚拟地址反查 RAMBlock
调试时经常遇到一个场景:你从某个日志或者 crash 里拿到一个 QEMU 进程的 host 地址,想立刻知道它属于哪个 RAMBlock。最常用的函数是:
RAMBlock *qemu_ram_block_from_host(const void *ptr, bool round_offset, ram_addr_t *offset);它会遍历全局链表,判断 ptr 是否落在某个块的[host, host + used_length)区间内,命中后返回该块,并通过 offset 参数返回块内偏移。在 gdb 里可以直接手动调用:
(gdb) p qemu_ram_block_from_host((void*)0x7f9b44001000, 0, &off)这种反查方式在排查疑似内存越界、设备 DMA 写到错误地址、迁移页面校验失败的时候特别好用。一旦确定某个地址不属于任何 RAMBlock,而设备和 DMA 又在频繁访问它,那问题大概率是 MR 映射或者 DMA 地址翻译错了。
5.3 写一个遍历函数做统一内存审查
如果觉得info ramblock的信息不够用,或者想在代码里做自动化检查,直接用qemu_ram_foreach_block写一个回调即可,示意代码:
#include "exec/ramlist.h" #include "exec/ramblock.h" static void dump_one_block(RAMBlock *rb, void *opaque) { fprintf(stderr, "%-32s used=0x%016zx max=0x%016zx host=%p " "offset=0x%016" PRIx64 " page_size=%zu fd=%d\n", qemu_ram_get_idstr(rb), qemu_ram_get_used_length(rb), qemu_ram_get_max_length(rb), qemu_ram_get_host_ptr(rb), qemu_ram_get_offset(rb), qemu_ram_get_page_size(rb), qemu_ram_get_fd(rb)); } void dump_all(void) { qemu_ram_foreach_block(dump_one_block, NULL); }这个函数放在 monitor 命令实现或者临时调试代码里,都能快速拿到全量内存块的顶层视图。我一般还会在回调里统计 max_length 和 used_length 的差值总量,用来判断哪些内存被“预留但未使用”占着。
5.4 一个真实排查链:迁移带宽暴涨的根源
有一回做迁移压测,16G 的 guest 每轮 dirty log 同步的脏页数量很小,但迁移带宽高得离谱,几十 GB 的内存一会儿就迁完,而且重试率很高。一开始怀疑 guest 内部在疯狂刷内存,但dirty_pages_rate明明很低。
后来通过info ramblock发现pc.ram的 page_size 显示 1073741824,也就是 1G 巨页。问题立刻清晰了:每轮 dirty log 虽然只标脏了一两个 bit,但每个 bit 对应整整 1G,迁移线程按 1G 粒度读页面、压缩、发送,数据量自然爆炸。这是巨页迁移里一个很典型的副作用。
解决办法也很直接:对这种场景要么放弃巨页,改用 2M 或 4K 页的后端;要么在迁移配置里显式指定页面压缩策略,从根上避免“脏一个 bit,传一个巨页”的尴尬。那次之后,凡是上巨页的虚拟机,我都会先看一眼 RAMBlock 的 page_size,再决定迁移参数。
6. RAMBlock 相关的坑和边界
6.1 同名冲突:设备复位必踩
前面提过,RAMBlock 的 idstr 必须全局唯一。设备热插拔、复位、后端对象重建时,如果旧的 MR 没有彻底 unparent,名字会一直占用,新块创建必然失败。
我排查这类问题时,会先info ramblock看名字还在不在,再查 QOM 父子关系。如果确实存在遗留块,按流程释放 MR 后重新挂载即可;但要注意,某些版本在迁移过程中不允许释放 RAMBlock,所以必须在迁移窗口外做这类操作。
6.2 热插拔和迁移的顺序敏感
热插拔操作会改变 RAMBlock 在全局链表里的插入顺序,特别是当设备 hot-unplug 又 hotplug 之后,空出来的 offset 可能被后续块占用。迁移协议对 source/destination 两端块的顺序高度敏感,虽然新版本会按 idstr 排序,但如果两端各有不同的热插拔历史,某些块的 offset 布局会不一致,出现“找得到名字,但长度或偏移对不上”的迁移失败。
运维上有一个笨但有效的原则:迁移前尽量让两端内存拓扑一致,已经完成热插拔的虚拟机,迁移后保持相同的设备状态。不要一边迁移一边做内存热插拔,这属于自杀式操作,QEMU 在部分版本里会直接断言。
6.3 resize 期间禁止迁移
RAMBlock 支持used_length在线变化,比如 VGA 显存或巨页池 resize,但迁移期间调整 size 是大忌。因为迁移线程按块扫描 bmap 时,如果目标块突然变长或变短,bitmap 和 KVM slot 都会进入不一致状态,轻则迁出脏页错位,重则进程崩溃。
判断当前是否能安全 resize,看 RAMBlock 是否在迁移中是被注册过的。代码里通常有ram_block_resize()的入口,它会检查是否正在迁移,不满足就直接报错。外部操作者要做的就是:任何内存在线扩容动作开始前,先暂停或取消迁移,扩完再恢复。
6.4 KVM slot 数量和大量小 RAMBlock
每个需要交给 KVM 的 RAMBlock 通常要占一个 memory slot,而 x86 平台 KVM 的 slot 数量默认有限,一般在 512 左右。如果虚拟机里挂了一堆小内存设备,每个都创建一个独立 RAMBlock,slot 很容易被打爆。
遇到 slot 不够用,首先考虑合并小 MR:多个设备共享一个 MemoryRegion 的不同 offset,而不是各自建块。其次看这些设备是否真的需要全部注册到 KVM,一些纯软件模拟设备可以用 TCG 方式处理,不必占用 slot。这个问题在 vhost 设备多、VFIO 映射多、内存插槽配置多的时候会集中爆发。
6.5 别在遍历回调里动锁
最后一个坑是给改代码的人看的。qemu_ram_foreach_block()的回调运行在 RCU 读临界区里,此时你去获取ram_list.mmap_lock或者调用qemu_ram_free(),结果非常不确定:死锁、递归锁、悬垂指针都是常客。
如果你确实需要在遍历过程中修改链表,正确做法是先把需要的 RAMBlock 引用收集到一个临时数组里,离开 RCU 区域后再统一处理。这也是我每次 code review 里都会盯着看的一条红线:RAMBlock 链表是全局心脏,操作它必须遵守它的锁纪律。
从 RAMBlock 的角度回看 QEMU 整个内存子系统,会发现很多现象都变得解释清晰:为什么迁移要看 idstr、为什么热插拔要排顺序、为什么巨页会放大迁移数据量、为什么小内存设备会打爆 slot。理解了这张“地契”上写的每一栏字段,再遇到内存类问题,至少能知道第一步该翻哪张表、该看哪个函数。