QEMU qcow2 的地址映射与写时复制
一、qcow2 簇映射与局部写入
raw 镜像的磁盘偏移通常直接对应文件偏移;qcow2 在两者之间增加映射表,使尚未使用的磁盘区域可以不分配数据簇,也使多个视图能够共享已有内容。代价是每次读写需要识别映射状态,写入时还可能分配空间、复制旧内容并更新元数据。
本文始终使用同一笔请求:向磁盘偏移 0x20031000 写入 4 KiB。若目标簇独占,可以原位写;若它被共享,需要换一个簇来保护旧内容;若本层未分配,还要从 backing 或零数据补齐未覆盖的范围。这三种结果来自不同的初始映射,后面的地址计算和交互逐一展示其处理。
qcow2 驱动在宿主 QEMU 进程的块层运行,客户机驱动只提交磁盘扇区读写。L1、L2 和 refcount 的持久内容存在镜像文件中,QEMU 在内存中缓存并更新它们,再通过文件或协议节点访问实际存储。
本文沿着地址查找、读取分流和局部写入展开。示例使用普通 64 位 L2 表项、64 KiB 簇和 16 位 refcount,不启用压缩、加密、外置数据文件或 lazy refcounts。设备如何把请求送入块层可接着阅读 virtio-blk 的请求处理与写入完成;这里从 qcow2 驱动收到磁盘字节偏移开始。
源码版本:QEMU 10.2.0。
二、L1、L2 映射与 refcount 引用计数
1. 从客户机磁盘偏移查到数据簇
qcow2 以 cluster 为文件空间的分配单位。L1 指向 L2 表,L2 表项再描述数据簇;它们按客户机磁盘偏移查找。refcount table 指向 refcount block,记录的是当前 qcow2 文件中各个宿主簇的引用数,数据和元数据簇都受它管理。backing file 属于另一个块节点,不计入当前文件的这套引用计数。
一个普通 L2 表占一簇,每项 8 字节。64 KiB 簇可容纳 8192 项,因此一个 L1 表项覆盖 512 MiB 客户机地址。对地址 0x20031000,整数除法与取余给出:
cluster_size = 0x10000,L2 每表 8192 项
guest_cluster = 0x20031000 / 0x10000 = 8195
L1 index = 8195 / 8192 = 1
L2 index = 8195 % 8192 = 3
簇内偏移 = 0x1000
L1[1] → L2 表 @ 0x40000
L2[3] → 数据簇 @ 0x90000
实际数据位置 = 0x90000 + 0x1000 = 0x91000
这里的箭头表示去掉标志位后的文件偏移。L1、L2 的最高位 QCOW_OFLAG_COPIED 在活动映射中表示所指簇的 refcount 为 1,写路径据此判断是否需要另行分配;非活动表中的这一位不承担同样的有效性保证。映射与表项布局
2. 用宿主簇编号查引用计数
反查数据簇的归属时,起点换成宿主文件偏移。一个 64 KiB refcount block 包含 32768 个 16 位计数;0x90000 对应宿主簇 9,因此取 refcount table[0] 指向的 block[9]。0 表示空闲,1 表示独占,2 或更大表示共享。L2 表位于宿主簇 4,其计数在 block[4],与数据簇的 block[9] 是两个独立条目。宿主簇计数索引
因此两套表的索引方向不同:L1/L2 从客户机磁盘偏移找数据位置,refcount 从镜像文件的簇号查共享状态。这里的宿主簇仍是文件中的逻辑区间;底层文件系统怎样安排物理存储,由下一层负责。
三、示例:偏移 0x20031000 的读写
L1 / L2 映射与一次局部写入
输入客户机磁盘偏移,选择初态与读写方向,比较映射和两类引用计数。每次推演都从所选初态开始,不累计修改。
地址拆分
当前客户机簇 · 每格 4 KiB
本:本层旧数据;底:backing 数据;零:逻辑零;新:本次写入。方框表示请求范围,格子表示操作完成后的可见内容。
| 簇用途 / 文件偏移 | 之前 | 之后 |
|---|
默认读取 0x20031000:L1 索引 1、L2 索引 3、簇内偏移 0x1000;数据簇在文件 0x90000,读取位置为 0x91000。若写入且该数据簇 refcount=2,新分配 0x110000,保留其余 60 KiB 后更新 L2;旧计数 2→1,新计数 0→1。独占普通数据簇可直接原位写入。启用 JavaScript 后可比较其他情形。
这是单请求、成功路径的计算模型,固定普通 L2、64 KiB 簇与 16 位 refcount。每个地址按所选初态重新构造相关表项;文件偏移为示例分配结果,格子不是 extended L2 子簇。未模拟真实 I/O、缓存命中、并发、错误回滚、短 backing、缺失 L2 表、压缩或 lazy refcounts,也不把逻辑步骤当作实际刷盘次数。
“仅数据共享”允许观察数据 COW 而 L2 不搬迁;“L2 与数据均共享”同时展示两层改动。后一种状态可由内部快照产生,这里只借用其共享关系,不展开快照管理。复制 L2 表中的偏移值并不等于复制所有数据簇;本次写入只替换涉及的那个数据映射。
在宿主机对已停止使用、内容稳定的测试镜像执行以下只读查询,可核对簇大小、backing 链和逻辑区间的实际数据来源。map 的 start/length 是客户机磁盘范围,offset 在可直接映射时给出数据文件偏移;它不导出完整 refcount 表。示例地址由前文设定,实际文件的分配结果由其写入历史决定。qemu-img info/map
qemu-img info --backing-chain --output=json test.qcow2
qemu-img map --output=json --start-offset=0x20031000 --max-length=4096 test.qcow2
四、读取路径:本层数据、backing 与零数据
qcow2_get_host_offset() 取得 L1、加载 L2 缓存片段,并识别表项类型。普通已分配簇按宿主偏移读取;refcount 为 2 不改变读取位置。跨越类型、连续映射或缓存片段边界的请求可能被拆开,不能假定一次客户机读取只发出一个底层 I/O。查找与分类
| 当前映射 | 读取行为 |
|---|---|
| 普通已分配数据 | 读取当前文件的数据簇,加上簇内偏移 |
| 未分配,存在 backing | 向 backing 节点读取相同的客户机磁盘偏移 |
| 未分配,无 backing 或超出其长度 | 无 backing 数据覆盖的部分返回零 |
| 显式零簇 | 返回零,覆盖 backing 中原有内容 |
L1 指针为空时,整张缺失 L2 表所覆盖的区域都未分配;已有 L2 表也可包含空表项。未分配保留向下查找的含义,显式零则终止查找。零表项即使带有预分配的宿主偏移,也不会读取那里留下的字节。压缩簇另有描述符和解压分支,不能套用普通簇的“基址加偏移”读取公式。零与未分配语义、读取分支
backing 也可以是 qcow2,此时同一客户机偏移在下层重新经过它自己的映射表。链条上各文件的数据簇偏移无需相同,当前文件也不会将下层文件偏移直接写进自己的 L2。每次向下查找都可能增加元数据或数据访问;但热缓存中的表项并不需要每次重新读盘,不能用链深度简单推算实际 I/O 次数。
五、4 KiB 局部写入与簇内数据保留
Mermaid 源码
flowchart TB
accTitle: qcow2 地址映射与局部写入
accDescr: 本图限普通数据、未分配以及无预分配数据簇的显式零。客户机偏移拆成 L1 索引、L2 索引和簇内偏移。读取按表项返回本层数据、backing 数据或零。写入先准备可修改的 L2 表,独占普通数据簇可原位写入,共享或未分配数据需要新簇;新数据准备完成并设置写回依赖后更新 L2,最后减少旧数据引用。
address["客户机磁盘字节偏移<br/>普通数据 / 未分配 / 无预分配的显式零"] --> indices["L1 索引 → L2 表项<br/>保留簇内偏移"]
indices --> operation{"读取 / 写入"}
operation -->|读| kind{"L2 类型"}
kind -->|普通数据| local["本层宿主簇 + 簇内偏移"]
kind -->|未分配| backing["backing 的同一客户机偏移<br/>无覆盖部分返回零"]
kind -->|显式零| zero["直接返回零<br/>不读取 backing"]
operation -->|写| metadata["准备可修改的 L2<br/>共享时先复制,接入 L1 后减旧表引用"]
metadata --> private{"普通数据簇独占?"}
private -->|是| inplace["原位写入请求范围<br/>其余字节保持原位"]
private -->|否| allocate["分配新数据簇<br/>建立新引用计数"]
allocate --> prepare["写入客户机数据,补齐未覆盖部分<br/>旧本层 / backing / 零"]
prepare --> link["数据准备成功,设置写回依赖<br/>更新 L2 指针"]
link --> release["减少旧数据簇引用<br/>若原先没有数据簇则无需释放"]
classDef mapping fill:#e4f1ec,stroke:#438575,color:#173e34;
classDef read fill:#eaf0f6,stroke:#8198ac,color:#263c4c;
classDef write fill:#fff4db,stroke:#b89a55,color:#59491e;
class address,indices,operation,kind,private mapping;
class local,backing,zero read;
class metadata,inplace,allocate,prepare,link,release write;
1. 独占数据簇:原位覆盖请求范围
对于已分配、独占的普通数据簇,4 KiB 写入直接落到原宿主偏移,其他 60 KiB 留在原处,无须读回拼接。handle_copied() 识别可以保留的簇;普通已分配区间无需生成额外的 L2 修改记录。若需改写 L2 而该表共享,仍应先准备独占的元数据表。原位写入判断
预分配的独占零簇也可以复用已有宿主位置,但须处理未覆盖部分的零语义并更新 L2 状态。它与图中尚无宿主数据簇的显式零不同,也不能简单套用普通数据簇“只覆盖请求范围”的分支。是否需要新分配
2. 共享数据簇:分配新位置并补齐首尾
共享数据簇不能覆盖。示例旧数据在 0x90000、refcount 为 2,写入新分配的 0x110000 后,本次映射指向新簇,旧簇计数降为 1,另一份引用仍读到原内容。新簇中未被 4 KiB 请求覆盖的首尾区域,要从旧的逻辑视图补齐;若原映射未分配,这些字节来自 backing,或在没有 backing 数据时补零。显式零映射的保留部分始终补零。
QCowL2Meta 记录待接入映射及首尾 COW 区间。客户机数据可能先写,也可能与补齐数据合并写;perform_cow() 甚至会合并首尾读取,实际读取量未必恰好等于 60 KiB。因此可以用“保留 60 KiB 的旧语义”解释结果,却不能据此断言一定发生两次读或一次整簇拷贝。数据写入与合并、补齐首尾区间
3. 共享 L2 与元数据的提交顺序
若 L2 自身共享,get_cluster_table() 先分配新 L2,刷出其引用计数,复制旧表并刷出,然后更新 L1 指针,最后减少旧 L2 的引用。这个步骤保留原有数据指针;数据簇的替换是接下来的独立变化。新 L2 的准备和接入、旧 L2 引用释放
数据路径在写入及补齐成功后更新 L2,再减少旧数据簇的计数。常规 refcount 模式下,L2 写回依赖新簇计数的写回;COW 数据还有 flush 依赖,释放旧簇的计数更新也受元数据依赖约束。不能先让持久化指针指向尚未准备好的数据,也不能过早把仍被持久化映射引用的旧簇变为空闲。这里描述的是依赖关系,并不意味着每个小写请求都会同步刷盘;最终持久性还受 flush/FUA 和缓存模式影响。lazy refcounts 另有标脏与恢复规则。接入 L2、释放时的缓存依赖
完整覆盖一个普通簇时,没有首尾旧字节需要保留,共享数据仍须换到新位置,但可以省去这部分读取。反过来,跨簇的小请求可能牵涉两个数据簇,甚至两张 L2 表。判断开销时要看请求与簇边界的关系,而不只看请求总长度。新分配不等于读取整个旧簇,COW 也不等于所有写入必经的固定步骤。
出错路径也必须区分分配和接入。数据写入失败时,请求不会继续把新数据指针接入 L2;尚未接入的分配由 abort 路径释放。此前已经安全复制并发布的新 L2 表则可能保留,它仍描述原来的数据映射。这些清理保护格式状态,并不为跨多个簇的客户机写请求提供整体回滚:前面已经完成的部分仍可能可见。交互只展示成功请求,不能用它推导故障后的完整磁盘内容。写入错误收尾、取消未接入分配
六、Extended L2 的子簇映射与写放大
普通 L2 表项把整个簇看作一个数据来源。较大簇减少映射数量,却使首次局部写入可能需要带入大量 backing 数据。QEMU 5.2 加入 extended L2:格式说明提交定义子簇位图,功能启用提交加入创建选项与不兼容特性位。它是可选格式能力,普通 L2 并未因此消失。
扩展表项从 8 字节增加到 16 字节;后 64 位用两组 32 位分别表示分配状态和读零状态,一个普通数据簇分成 32 个连续子簇。取 128 KiB 簇,子簇就是 4 KiB:向原先未分配区域写入一个对齐的 4 KiB 块,可以仅把该子簇标为本层有效,其余子簇继续读取 backing,不必全部带入本层。零位与分配位不能同时置位。extended L2 格式
这仍保留簇级宿主分配与 refcount,32 个子簇没有各自独立的宿主基址;共享簇也不能因子簇位图存在就任意原位覆盖。代价包括更多状态组合,以及相同簇大小下翻倍的 L2 元数据:64 KiB 簇的 L2 表只剩 4096 项,覆盖 256 MiB;若要缓存相同磁盘范围,L2 缓存也需要约两倍容量,refcount block 不随之翻倍。选择较大簇可改善映射覆盖,但实际收益还取决于局部写入、共享关系和底层文件系统的分配方式。缓存容量影响
子簇边界同样重要:只覆盖半个子簇的首次写入,仍要保留该子簇内其余字节;已经由本层独占且有效的区域则可以继续原位更新。扩展表项提供的是更细的有效数据描述,收益主要体现在避免把仍可从 backing 读取的范围提前带入本层。评估时可分别观察元数据缓存、补齐读取与宿主分配,区分究竟省下了哪一类工作。
因此,同样一次 4 KiB 写入要分三类测量:独占有效簇主要承担数据覆盖;未分配簇增加空间分配与旧语义补齐;共享簇还要保护其他引用,可能连 L2 一起 COW。比较格式或簇大小时,应固定这些初始条件及缓存、刷新策略,再观察读取放大、写入延迟和镜像实际占用。把三类请求混成一个平均值,很难看出改进来自哪个环节。