AkiraZheng's Time.

虚拟化:热迁移脏页跟踪:dirty bitmap模式详解

Word count: 9.6kReading time: 40 min
2026/09/16

热迁移(Live Migration,在线迁移)要把一台运行中的虚机从源服务器搬到目的服务器:内存、vCPU 状态、设备状态一样不落,业务还几乎不中断。同时满足这两条的可行策略只有"先全量、后增量":虚机继续跑,QEMU 先把整个内存搬一遍,之后只补变化过的页;等变化量小到一次短暂停机能发完,才让虚机停机,补上最后一轮脏页和设备状态,再在对端拉起。

整个策略成立的前提,是源端能准确回答一个问题:上一轮拷贝之后,虚机又改了哪些页?把"页被写过"这个事件记录下来的机制叫脏页跟踪(dirty logging),arm64 KVM 上的经典实现就是本文的主角 dirty bitmap(脏页位图)模式:KVM 给每个 memslot(内存插槽)维护一张"每 4KB 页一个 bit"的位图,靠 stage-2(第二阶段翻译表)写保护把 guest 的写变成 Permission Fault(权限错误),在 fault 处理里往位图置位,QEMU 周期性把位图同步回用户态、发送对应脏页、再让 KVM 清位并重新写保护,循环直到收敛。

一句话讲清楚为什么需要标脏:热迁移的停机窗口由"停机那一刻还剩多少脏页"决定,而"还剩多少脏页"只能靠持续标脏来知道。

各章知识点的依赖关系如下,全文沿这张地图展开:

plantuml diagram

开篇思考题,全文读完应能回答:

  1. 虚机全程不关机,QEMU 凭什么知道某个页在上一轮发送之后又被 guest 改写了?
  2. 开启脏页跟踪的那一次 KVM_SET_USER_MEMORY_REGION,KVM 为什么可以不把整个内存一次性写保护?不写保护怎么保证不漏记?
  3. 同样是 stage-2 只读的页,KVM 怎么区分"我为标脏写保护的页"和"本来就只读的页"?guest 写这两种页,后续路径有什么不同?
  4. stage-2 写保护只能拦住 guest 发起的写,QEMU 自己(设备模拟 DMA)往虚机内存写数据时,谁来标脏?

代码取自主线 Linux(约 7.3)与 openEuler QEMU 8.2。写 fault 到 stage-2 权限的微观机制(relax_perms、BBM)在读写权限篇里已拆过,本文直接引用结论;写权限的事实源(VMA(虚拟内存区域)与 memslot)在写权限下传篇;VFIO 设备自身的迁移在VFIO 热迁移篇。

1. 热迁移三阶段:全量、增量、停机收尾

先看迁移对象。QEMU 把要搬的东西注册成一串 SaveStateEntry(保存状态项),分两类:

  • RAM:注册名 "ram" 的迭代式 handler,占迁移量的大头,有 save_setup / save_live_iterate / save_live_complete_precopy 三个阶段回调
  • 设备状态(VM State):每个设备一份 VMStateDescription(设备状态描述符),只在停机后一次性序列化。几个真实的例子:
    • GIC(Generic Interrupt Controller,通用中断控制器)的 vmstate_gic,含 ctlr、每中断的 enabled/pending/active/level、irq_target、priority 等
    • vCPU 的 vmstate_arm_cpu,含 xregs[32]、pc、cpsr、elr_el[4]、系统寄存器对、generic timer
    • passthrough 设备的内部状态走 VFIO 迁移协议,MMIO(Memory-Mapped I/O,内存映射 IO)BAR 空间本身不参与脏页跟踪(详见 VFIO 篇)

vCPU 寄存器平时在 KVM 里,停机后由 cpu_synchronize_all_states() 从 KVM 拉回 QEMU,再随 vmstate 一起发。

再看流程。qmp_migrate 解析 URI 后 migrate_init 复位状态,异步连上目的端,连接成功回调 migrate_fd_connect 里创建名为 "live_migration" 的迁移线程:

1
2
3
/* qemu/migration/migration.c */
migrate_fd_connect()
+-> qemu_thread_create(&s->thread, "live_migration", migration_thread, ...)

整个热迁移由 migration_thread 驱动,分三段(图1):

  • setup 段:发流头(MAGIC/VERSION),调 qemu_savevm_state_setup(),然后状态从 SETUP 切到 ACTIVE。对 RAM 来说这一步做三件事:
    • 初始化 QEMU 侧位图
    • 通知 KVM 开启脏页跟踪(第 3 节)
    • 做首次位图同步(第 5 节)
  • 迭代段:主循环每轮执行 migration_iteration_run(),先估剩余量,够小就转停机,否则 qemu_savevm_state_iterate() 发一轮脏页
  • 收尾段:migration_completion() 停机并发出剩余数据,migration_iteration_finish() 处理源端虚机的最终状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* qemu/migration/migration.c */
migration_thread()
+-> qemu_savevm_state_header() /* MAGIC/VERSION */
+-> qemu_savevm_state_setup() /* ram_save_setup: 开脏页跟踪, 第 3 节 */
+-> qemu_savevm_wait_unplug() /* SETUP -> ACTIVE */
+-> while (migration_is_active(s)) /* 迭代主循环, 每轮 */
| +-> migration_iteration_run()
| | +-> qemu_savevm_state_pending_estimate() /* 估值: 不碰 KVM */
| | +-> qemu_savevm_state_pending_exact() /* 逼近阈值才调: 触发位图同步 */
| | +-> migration_completion() /* pending < 阈值: 转停机, 出循环 */
| | +-> qemu_savevm_state_iterate() /* 否则: 发一轮脏页 */
| +-> migration_detect_error() /* cancel/IO错误: 退出循环 */
| +-> migration_rate_limit() /* 100ms 限速周期 */
+-> migration_iteration_finish() /* 成功->POSTMIGRATE; 失败->vm_start */

停机切换的判定条件是"剩余量小于阈值"。阈值不是写死的字节数,而是带宽乘停机预算:

1
2
3
4
5
6
/* qemu/migration/migration.c, migration_update_counters(): 每 100ms 周期执行 */
s->threshold_size = expected_bw_per_ms * migrate_downtime_limit();

/* expected_downtime 的估计: 上次同步时刻的剩余脏字节 / 带宽 */
s->expected_downtime =
stat64_get(&mig_stats.dirty_bytes_last_sync) / expected_bw_per_ms;

downtime_limit(停机时间上限)默认 300ms。也就是说:当"剩余脏页量"小到按当前带宽 300ms 内能发完时,虚机才停机,进入最后的停机拷贝。migration_completion_precopy() 里 migration_stop_vm(RUN_STATE_FINISH_MIGRATE) 让所有 vCPU 停下,业务中断从这里开始,到目的端 vm_start() 结束,这段时长就是迁移的 downtime(停机时间)。

如果脏页产生速度持续高于发送带宽,迭代永远收敛不了。precopy(预拷贝,即先全量后增量的模式)的对策不是强制停机,而是压脏页速率:migration_bitmap_sync 里每隔 1 秒评估一次,连续两个周期脏量超发送量就触发 auto-converge(自动收敛,动态限制 vCPU 运行时间比例,mig_throttle_guest_down() 周期性让 vCPU 睡觉截流)或 dirty-limit;仍然不收敛就等用户 cancel 或切 postcopy(后拷贝,先停机切换、缺页再按需拉取的模式)。

plantuml diagram

迭代阶段的四个动作(同步位图、发送脏页、清除已发送块、写 fault 标脏)覆盖了 dirty bitmap 模式迭代期的全部环节。下面按生命周期逐个拆开。

2. dirty bitmap 模式:一句话与三层位图

一句话定义:QEMU 给 memslot 打上 KVM_MEM_LOG_DIRTY_PAGES 标记,KVM 为它维护一张每 4KB 页一个 bit 的位图;页面被写保护后,guest 一写就 Permission Fault 陷到 EL2,KVM 在 fault 处理里给位图置位;QEMU 周期性用 ioctl 把位图同步回用户态,发送对应脏页,再让 KVM 清掉这些位并重新写保护,如此循环直到收敛。

这里要分清两种陷入,触发机制完全不同:

  • guest 的写触发 Permission Fault,是硬件异常:CPU 退出 guest 进入 KVM 的异常处理路径(VHE 布局下宿主内核本身就运行在 EL2),一次标脏就是一次这样的异常陷入加处理
  • QEMU 同步位图走 KVM_GET_DIRTY_LOG / KVM_CLEAR_DIRTY_LOG,是宿主机系统调用:陷入宿主内核里的 KVM 模块,与 guest 异常无关

所以"trap 到 EL2 获取脏页位图"的说法不准确:位图的读写都走宿主内核的 ioctl 路径,只有置位动作走 guest 异常退出的 fault 路径。

位图在系统中实际存在三层(图2),职责各不相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
图2: 三层位图与单向数据流

guest writes (stage-2 protected page)
| Permission Fault -> mark_page_dirty_in_slot -> set bit
v
+----------------------------------+
| kvm_memory_slot.dirty_bitmap | <- 内核侧, 脏页记录的唯一来源
| (kernel, 1 bit per 4K page) |
+----------------------------------+
|
| KVM_GET_DIRTY_LOG (ioctl: whole-slot copy-out)
v
+----------------------------------+
| KVMSlot.dirty_bmap | <- QEMU 用户态缓存, 每次 GET 整块覆盖
+----------------------------------+
|
| cpu_physical_memory_set_dirty_lebitmap (OR into global)
v
+----------------------------------+
| ram_list.dirty_memory | <- QEMU 全局位图, 汇合 KVM 同步来的位
| [DIRTY_MEMORY_MIGRATION] | 与 QEMU 自身 DMA 写置的位 (第 7 节)
+----------------------------------+
|
| qatomic_xchg at sync (move & clear)
v
+----------------------------------+
| RAMBlock.bmap | <- 迁移消费视图: find_next_bit 扫描,
| (per RAMBlock) | 发一页清一位
+----------------------------------+
|
| KVM_CLEAR_DIRTY_LOG (after send: clear bits
| + re-write-protect, see ch.6)
+---------------------------> back to the top bitmap
  • KVM memslot 位图是脏页记录的唯一来源,只有写 fault 和 KVM 内部写 guest 内存的路径会置位
  • QEMU 全局位图 ram_list.dirty_memory[] 按 ram_addr 编址,有 VGA/CODE/MIGRATION 三种 client,KVM 同步来的位和 QEMU 设备模拟 DMA 写置的位(第 7 节)都汇到这里
  • RAMBlock.bmap 是迁移专用消费视图,同步时从全局位图搬运而来,发送线程用它找脏页,发一页清一位

数据单向流动,不回流。看内核侧的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* linux/include/linux/kvm_host.h */
struct kvm_memory_slot {
struct hlist_node id_node[2];
struct interval_tree_node hva_node[2];
struct rb_node gfn_node[2];
gfn_t base_gfn; /* slot 起始 guest frame number */
unsigned long npages; /* 页数 = 位图 bit 数 */
unsigned long *dirty_bitmap; /* 脏位图: 每页 1 bit */
struct kvm_arch_memory_slot arch;
unsigned long userspace_addr; /* host 虚拟地址起始 */
u32 flags; /* KVM_MEM_LOG_DIRTY_PAGES / READONLY */
short id;
u16 as_id;
//...
};

static inline unsigned long kvm_dirty_bitmap_bytes(struct kvm_memory_slot *memslot)
{
return ALIGN(memslot->npages, BITS_PER_LONG) / 8; /* long 对齐 */
}

位图的定位规则极简:bit 下标 = gfn - memslot->base_gfn。64GB 内存的虚机是 16M 个页,位图 2MB。分配有个历史包袱,kvm_alloc_dirty_bitmap() 一次 __vcalloc(2, dirty_bytes, ...) 分配两倍长度:第二份是 legacy 模式 KVM_GET_DIRTY_LOG 做"快照+清零"时的暂存缓冲,避免在 ioctl 里 vmalloc。manual-protect 模式(下文)用不到第二份,但分配照旧。

置位函数只有一处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* linux/virt/kvm/kvm_main.c */
void mark_page_dirty_in_slot(struct kvm *kvm,
const struct kvm_memory_slot *memslot, gfn_t gfn)
{
struct kvm_vcpu *vcpu = kvm_get_running_vcpu();
//...
if (memslot && kvm_slot_dirty_track_enabled(memslot)) {
unsigned long rel_gfn = gfn - memslot->base_gfn; /* bit 下标 */

if (kvm->dirty_ring_size && vcpu)
kvm_dirty_ring_push(vcpu, slot, rel_gfn); /* dirty ring 模式 */
else if (memslot->dirty_bitmap)
set_bit_le(rel_gfn, memslot->dirty_bitmap); /* bitmap 模式: 原子置位 */
}
}

set_bit_le 在小端 arm64 上就是原子 set_bit,无锁,位级原子操作天然并发安全,不需要 mmu_lock。三个入口汇聚到这个函数:

  • 写 fault 路径(kvm_s2_fault_map 尾部,第 4 节)
  • KVM 内核自己写 guest 内存,例如 vgic/its 保存中断表(kvm_write_guest → __kvm_write_guest_page)
  • v7.3 新增的 HDBSS 硬件脏位缓冲排空路径(第 9 节)

用户态拿位图的 uapi(内核暴露给用户态的接口)长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* linux/include/uapi/linux/kvm.h */
struct kvm_dirty_log {
__u32 slot; /* slot id | as_id<<16 */
__u32 padding1;
union {
void *dirty_bitmap; /* 出参: 内核整块拷出, 每页 1 bit */
__u64 padding2;
};
};

/* for KVM_CLEAR_DIRTY_LOG */
struct kvm_clear_dirty_log {
__u32 slot;
__u32 num_pages; /* 要清的页数 */
__u64 first_page; /* 起始页号, 必须 64 页对齐 */
union {
void *dirty_bitmap; /* 入参: 为 1 的位才被清 */
__u64 padding2;
};
};

注意方向差异:GET 是出参(内核把整张位图拷给用户);CLEAR 是入参(用户告诉内核要清哪些位,传进来的位图里为 1 的位才被清)。这个差异是第 6 节"不丢页"论证的核心。

3. 初始化:打标记与渐进写保护

迁移线程 setup 阶段调 qemu_savevm_state_setup(),对 RAM 落到 ram_save_setup → ram_init_all → ram_init_bitmaps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* qemu/migration/ram.c */
static void ram_init_bitmaps(RAMState *rs)
{
qemu_mutex_lock_ramlist();
WITH_RCU_READ_LOCK_GUARD() {
ram_list_init_bitmaps();
/* We don't use dirty log with background snapshots */
if (!migrate_background_snapshot()) {
#ifdef TARGET_AARCH64
if (kvm_enabled()) {
kvm_update_hdbss_cap(true, migrate_hdbss_buffer_size()); /* fork 特有: HDBSS 硬件脏位, 第 9 节 */
}
#endif
memory_global_dirty_log_start(GLOBAL_DIRTY_MIGRATION); /* 开启脏页跟踪 */
migration_bitmap_sync_precopy(rs, false); /* 首次同步 */
}
}
qemu_mutex_unlock_ramlist();
migration_bitmap_clear_discarded_pages(rs);
}

ram_list_init_bitmaps() 把每个 RAMBlock 的 bmap 全置 1,首轮全量发送的依据就来自这里,不是靠 KVM 报脏。memory_global_dirty_log_start(GLOBAL_DIRTY_MIGRATION) 是通知 KVM 的核心动作,它不直接发 ioctl,而是走 QEMU 内存模型的事务机制:

  • 置全局跟踪标志 global_dirty_tracking
  • 提交一次内存事务,FlatView(平面视图,AddressSpace 的展开结果)重建,每个 RAM 区域的 dirty_log_mask 多出 MIGRATION 位
  • 对每个 mask 增长的 FlatRange 回调 listener 的 log_start,KVM 的 listener 收到后更新 memslot flags
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* qemu/accel/kvm/kvm-all.c */
static int kvm_mem_flags(MemoryRegion *mr)
{
bool readonly = mr->readonly || memory_region_is_romd(mr);
int flags = 0;

if (memory_region_get_dirty_log_mask(mr) != 0) {
flags |= KVM_MEM_LOG_DIRTY_PAGES; /* 打上脏页跟踪标记 */
}
if (readonly && kvm_readonly_mem_allowed) {
flags |= KVM_MEM_READONLY; /* 只读 MR: 另一个 flag */
}
//...
}

static int kvm_slot_update_flags(KVMMemoryListener *kml, KVMSlot *mem,
MemoryRegion *mr)
{
mem->flags = kvm_mem_flags(mr);
if (mem->flags == mem->old_flags) {
return 0; /* flags 没变, 不发 ioctl */
}
kvm_slot_init_dirty_bitmap(mem); /* 用户态缓存位图分配 */
return kvm_set_user_memory_region(kml, mem, false);
}

完整开启链,从迁移线程到内核:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[QEMU] migration_thread                                        /* qemu/migration/migration.c */
+-> qemu_savevm_state_setup /* qemu/migration/savevm.c */
+-> ram_save_setup /* qemu/migration/ram.c */
+-> ram_init_all
+-> ram_state_init /* migration_dirty_pages = 全部页数 */
+-> ram_init_bitmaps
+-> ram_list_init_bitmaps /* rb->bmap 全 1, clear_bmap 分配 */
+-> memory_global_dirty_log_start(GLOBAL_DIRTY_MIGRATION)
| /* qemu/system/memory.c */
+-> global_dirty_tracking |= GLOBAL_DIRTY_MIGRATION
+-> memory_region_transaction_commit /* 内存事务提交 */
+-> memory_region_commit
+-> flatviews_reset /* FlatView 重建, mask 多出 MIGRATION 位 */
+-> address_space_update_topology_pass(adding=true)
+-> MEMORY_LISTENER_UPDATE_REGION(log_start) /* mask 增长时 */
+-> kvm_log_start /* qemu/accel/kvm/kvm-all.c */
+-> kvm_section_update_flags
+-> kvm_slot_update_flags
+-> kvm_mem_flags /* 置 LOG_DIRTY_PAGES */
+-> kvm_slot_init_dirty_bitmap
+-> kvm_set_user_memory_region
+-> ioctl(KVM_SET_USER_MEMORY_REGION)
+-> migration_bitmap_sync_precopy /* 首次位图同步, 第 5 节 */

ioctl 进入内核后,V1 版只允许两个 flag(KVM_SET_USER_MEMORY_REGION_V1_FLAGS = LOG_DIRTY_PAGES | READONLY),既有 memslot 只改 flags 的场景分类为 KVM_MR_FLAGS_ONLY,位图分配与页表动作分别在 prepare 和 commit 两个钩子里:

1
2
3
4
5
6
7
8
9
10
[KVM] kvm_vm_ioctl(KVM_SET_USER_MEMORY_REGION)                 /* linux/virt/kvm/kvm_main.c */
+-> kvm_vm_ioctl_set_memory_region
+-> kvm_set_memory_region /* flags 校验, change 分类 */
+-> kvm_set_memslot
+-> kvm_prepare_memory_region /* 先于激活 */
+-> kvm_alloc_dirty_bitmap /* __vcalloc(2, bytes) */
+-> bitmap_set(new->dirty_bitmap, 0, new->npages) /* initially-set 模式: 初始全 1 */
+-> kvm_update_flags_memslot /* RCU 换入新 memslot, 对 vCPU 生效 */
+-> kvm_commit_memory_region
+-> kvm_arch_commit_memory_region /* linux/arch/arm64/kvm/mmu.c */

kvm_arch_commit_memory_region() 是 arm64 侧的关键分叉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* linux/arch/arm64/kvm/mmu.c */
//...
if (log_dirty_pages) {

if (change == KVM_MR_DELETE)
return;

/*
* Huge and normal pages are write-protected and split
* on either of these two cases:
*
* 1. with initial-all-set: gradually with CLEAR ioctls,
*/
if (kvm_dirty_log_manual_protect_and_init_set(kvm))
return;
/*
* or
* 2. without initial-all-set: all in one shot when
* enabling dirty logging.
*/
kvm_mmu_wp_memory_region(kvm, new->id);
kvm_mmu_split_memory_region(kvm, new->id);
} else {
kvm_mmu_free_memory_cache(&kvm->arch.mmu.split_page_cache);
}

两种模式,取决于 kvm->manual_dirty_log_protect 里有没有 KVM_DIRTY_LOG_INITIALLY_SET 位。这个位由 QEMU 在 kvm_init() 时(VM 创建早期,早于任何 memslot)探测并使能:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* qemu/accel/kvm/kvm-all.c, kvm_init() */
if (!s->kvm_dirty_ring_size) {
dirty_log_manual_caps =
kvm_check_extension(s, KVM_CAP_MANUAL_DIRTY_LOG_PROTECT2);
dirty_log_manual_caps &= (KVM_DIRTY_LOG_MANUAL_PROTECT_ENABLE |
KVM_DIRTY_LOG_INITIALLY_SET);
s->manual_dirty_log_protect = dirty_log_manual_caps;
if (dirty_log_manual_caps) {
ret = kvm_vm_enable_cap(s, KVM_CAP_MANUAL_DIRTY_LOG_PROTECT2, 0,
dirty_log_manual_caps);
/* 失败则回退 legacy 模式 */
}
}

arm64 的 KVM_DIRTY_LOG_MANUAL_CAPS 两个位都声明(arch/arm64/include/asm/kvm_host.h),所以 QEMU 8.2 + arm64 实际走的是 manual-protect + initially-all-set 模式。两种模式的对比:

legacy 一步到位模式 initially-all-set 渐进模式
开启时动作 kvm_mmu_wp_memory_region 整槽清 S2AP_W,kvm_mmu_split_memory_region 拆全部 block,再 kvm_flush_remote_tlbs_memslot 什么都不做,只把位图初始全 1("所有页都算脏")
代价 大内存 VM 一次 ioctl 里写保护加拆分全部页表,vCPU 停顿显著 零停顿;代价转嫁给首轮全量发送,发送时逐块 CLEAR 才写保护(第 6 节)
正确性 开启即全 RO,写 fault 置位 未写保护的页被写不置位,但位本来就是 1,不漏记;CLEAR 之后才靠写 fault 置位

渐进模式的正确性依赖一个不变量:位图里的 1 是"至少这么脏"的下界。开启时全 1 是最保守的估计(全部重发,冗余但不丢);之后每次 CLEAR 把"已确认发送"的位清零并写保护,未发送的位保持 1。这个设计来自 2020 年的 arm64 补丁 "KVM: arm64: Support enabling dirty log gradually in small chunks"。

两个边角问题。KVM_MR_CREATE(新建 slot 时就带 LOG_DIRTY)为什么不写保护:新 slot 的 stage-2 映射必然为空(映射只由 slot 内的 fault 按需建立),wp 对空区间是 no-op,真正的保证来自 fault 路径,即 logging 下强制 4K 粒度、读 fault 不给写权限(第 4 节)。迁移结束关闭 LOG_DIRTY 时走 else 分支,只释放 eager split 缓存,没有任何 unprotect 动作,原因见第 8 节。

4. 写 fault 标脏:权限真值表与三层判定

迭代期里 guest 的写随时可能撞上写保护。以渐进模式为例,某个页被 CLEAR 写保护后,guest 再写它:stage-2 PTE(Page Table Entry,页表项)的 S2AP_W 为 0,MMU 报 Permission Fault,FSC(Fault Status Code,错误状态码)为 0x0C|level,硬件把异常路由到 EL2,KVM 接手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[KVM] guest 写 RO 页, Permission Fault 退出 guest              /* linux/arch/arm64/kvm */
+-> kvm_arch_vcpu_ioctl_run /* arm.c, run 循环 */
+-> handle_exit
+-> kvm_get_exit_handler /* handle_exit.c */
+-> kvm_handle_guest_abort /* arch/arm64/kvm/mmu.c */
+-> gfn_to_hva_memslot_prot(memslot, gfn, &writable)
| /* writable = !(slot flags & KVM_MEM_READONLY) */
+-> io_mem_abort /* hva 无效或真只读: MMIO exit, 不进标脏 */
+-> user_mem_abort
+-> kvm_s2_fault_pin_pfn /* get_user_pages(GUP) pin 页, 取 VMA 信息 */
+-> kvm_s2_resolve_vma_size
| /* logging 时: max_map_size = PAGE_SIZE, 强制 4K 粒度 */
+-> kvm_s2_fault_compute_prot /* 现算权限, 见真值表 */
+-> kvm_s2_fault_map
+-> kvm_pgtable_stage2_relax_perms /* 粒度相等: 原子加回 S2AP_W */
+-> kvm_pgtable_stage2_map /* 粒度不等: 换粒度重建, 含 lazy split */
+-> mark_page_dirty_in_slot /* writable && !ret 才调用 */
+-> set_bit_le(rel_gfn, memslot->dirty_bitmap)

权限怎么算?写权限篇讲过事实源有两层(VMA 与 memslot),fault 时刻现算成枚举。v7.3 的实现在 kvm_s2_fault_compute_prot(),核心就一行条件:

1
2
3
4
5
6
7
/* linux/arch/arm64/kvm/mmu.c */
*prot = KVM_PGTABLE_PROT_R;

if (s2vi->map_writable && (s2vi->device ||
!memslot_is_logging(s2fd->memslot) ||
kvm_is_write_fault(s2fd->vcpu)))
*prot |= KVM_PGTABLE_PROT_W;

memslot_is_logging() 的定义是 memslot->dirty_bitmap && !(flags & KVM_MEM_READONLY)。展开成真值表:

  • 不在 logging(没开迁移):写权限恒给,正常 RW 映射
  • logging + 读 fault:给 RO。这一行决定了标脏如何触发:读 fault 建的映射不带写权限,guest 随后的写必然再 fault 一次,那次 fault 才拿到 W 并标脏
  • logging + 写 fault:给 RW 并标脏。kvm_s2_fault_map() 尾部 if (writable && !ret) mark_page_dirty_in_slot(...) 完成这一次标脏
  • device(VM_PFNMAP 且非内存 PFN)旁路 logging 检查直接给 W,避免设备映射反复 fault;不过这类 VMA 在 kvm_arch_prepare_memory_region() 里直接拒绝开 LOG_DIRTY(-EINVAL),所以正常配置下走不到

怎么区分"本来就只读的页"和"为标脏写保护的页"? 这是同一张 PTE 上的两种 RO,硬件位(S2AP_W=0)完全同形,区分全部在软件层,共三层判定:

  • QEMU 侧:只读 MemoryRegion(如 ROM)注册 memslot 时同时带 KVM_MEM_READONLY 与 KVM_MEM_LOG_DIRTY_PAGES 两个 flag(kvm_mem_flags() 两个判断互不干扰),位图照常分配
  • 内核 logging 判定:memslot_is_logging() 把 READONLY slot 排除在脏页跟踪外,位图虽在,fault 路径不再据它收权,也就不标脏
  • fault 入口分流:kvm_handle_guest_abort() 里 gfn_to_hva_memslot_prot() 返回的 writable 只看 READONLY flag。写 fault 落在真只读 slot 上时 write_fault && !writable 成立,走 io_mem_abort() 以 KVM_EXIT_MMIO 退出给 QEMU 模拟,根本进不了标脏路径;写 fault 落在写保护页上时 writable 为真,进 user_mem_abort() 放权加标脏

所以两类只读页的命运分岔在 fault 入口之前:真只读页的写 fault 永远出不来(每次都当 MMIO 丢给用户态),写保护页的写 fault 在 KVM 内闭环。这个区分是软件意图,不是硬件属性,PTE 不需要也不能承载它。

写 fault 闭环的另一半是粒度。stage-2 平时为了性能用 2M/1G block 映射,而位图是 4K 粒度:一个 2M block 只有一位可记,block 里任意一页写过整块都得算脏,跟踪粒度被放大 512 倍。所以 logging 下 kvm_s2_resolve_vma_size() 强制 max_map_size = PAGE_SIZE,block 必须拆:

  • eager split(预拆分):KVM_CAP_ARM_EAGER_SPLIT_CHUNK_SIZE 显式开启(默认 0 关闭),开启时在 CLEAR 的批处理路径 kvm_mmu_split_huge_pages() 里提前把 block 拆完
  • lazy split(惰性拆分):不开启时,写 fault 落在 2M RO block 上,mapping_size(4K) 不等于硬件报的 perm_fault_granule(2M),走 kvm_pgtable_stage2_map() 换粒度重建,fault 的那个 4K 以 RW 落位并标脏,其余 511 个子页留 invalid 等各自的 fault

最后补一个 v7.3 的新变化:stage2_set_prot_attr() 现在对可写 PTE 无条件置上 DBM(Dirty Bit Modification,bit 51)。这个位与 S2AP_W 并存,但只有在 VTCR_EL2.HD=1(FEAT_HDBSS 硬件脏位,第 9 节)时才参与硬件行为;经典 bitmap 模式下 HD=0,DBM 是惰性位,权限判定只看 S2AP。也就是说,没有 HDBSS 硬件时,arm64 的标脏就是纯软件的"写保护 + Permission Fault + fault 路径置位",stage-2 没有软件可直接读取的硬件脏位。relax_perms 恢复写权限的原子修改与 BBM 细节,见读写权限篇第 7、8 节。

5. 位图同步:QEMU 拉取 KVM 位图

标脏发生在 KVM,消费发生在 QEMU,中间需要周期性搬运。QEMU 8.2 的 precopy 路径里,同步(sync)只发生在三个时机(postcopy 切换前的 ram_postcopy_send_discard_bitmap 另有一次,不在本文范围):

  • 首次:ram_init_bitmaps() 开启跟踪后立即一次
  • 迭代中:每轮 migration_iteration_run() 先调 qemu_savevm_state_pending_estimate() 做估值,只用上次同步留下的计数,零开销;估值降到 threshold_size 以下才调 qemu_savevm_state_pending_exact(),RAM 的 ram_state_pending_exact() 在此刻真正做一次同步:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* qemu/migration/ram.c */
static void ram_state_pending_exact(void *opaque, uint64_t *must_precopy,
uint64_t *can_postcopy)
{
//...
uint64_t remaining_size = rs->migration_dirty_pages * TARGET_PAGE_SIZE;

if (!migration_in_postcopy() && remaining_size < s->threshold_size) {
qemu_mutex_lock_iothread();
WITH_RCU_READ_LOCK_GUARD() {
migration_bitmap_sync_precopy(rs, false); /* 只有逼近阈值才同步 */
}
qemu_mutex_unlock_iothread();
remaining_size = rs->migration_dirty_pages * TARGET_PAGE_SIZE;
}
//...
}
  • 停机后:ram_save_complete() 里 migration_bitmap_sync_precopy(rs, true)(last_stage 模式),随后把剩余脏页一次发完

这个两级设计(estimate/exact)是 8.2 相对老版本的变化:

  • 老版本每轮 ram_save_iterate() 开头都同步一次
  • 新版本把同步挪到收敛判定处,全量发送(bulk)阶段(剩余量远大于阈值)根本不同步
  • 原因是同步不便宜:每次都要对每个 memslot 做一次 GET ioctl,legacy 模式下还伴随整槽清零、重新写保护与 TLB flush,而积压很大时同步结果不改变任何决策

ram_save_iterate() 里那句 "in case it was the 1st time and we had to sync the dirty bitmap" 的旧注释是老设计的残迹。

同步的完整链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[QEMU] migration_iteration_run                                 /* qemu/migration/migration.c */
+-> qemu_savevm_state_pending_exact /* qemu/migration/savevm.c */
+-> ram_state_pending_exact /* qemu/migration/ram.c */
+-> migration_bitmap_sync_precopy
+-> migration_bitmap_sync /* 剩余量逼近阈值才走到这里 */
+-> memory_global_dirty_log_sync /* qemu/system/memory.c */
+-> memory_region_sync_dirty_bitmap(NULL) /* mr=NULL: 遍历 listener 与 FlatRange */
+-> kvm_log_sync /* qemu/accel/kvm/kvm-all.c */
+-> kvm_physical_sync_dirty_bitmap /* 按 memslot 切分 */
+-> kvm_slot_get_dirty_log
+-> ioctl(KVM_GET_DIRTY_LOG)
| /* 出参整块覆盖 KVMSlot.dirty_bmap 缓存 */
+-> kvm_slot_sync_dirty_pages
+-> cpu_physical_memory_set_dirty_lebitmap
| /* OR 进 ram_list.dirty_memory[MIGRATION/VGA/CODE] */
+-> ramblock_sync_dirty_bitmap /* 每个 RAMBlock */
+-> cpu_physical_memory_sync_dirty_bitmap /* qemu/include/exec/ram_addr.h */
+-> qatomic_xchg(全局 MIGRATION 位, 0) /* 取走并清零全局位 */
+-> rb->bmap |= bits /* OR 进消费位图 */
+-> clear_bmap_set(...) /* 标记待 CLEAR 的块, 第 6 节 */
+-> stat64_set(&mig_stats.dirty_bytes_last_sync, ram_bytes_remaining())
+-> migration_trigger_throttle() /* >1s 周期: auto-converge 判定 */

内核侧 GET ioctl 的行为按模式分岔:

1
2
3
4
5
6
7
8
9
10
11
[KVM] kvm_vm_ioctl(KVM_GET_DIRTY_LOG)                          /* linux/virt/kvm/kvm_main.c */
+-> kvm_vm_ioctl_get_dirty_log
+-> kvm_get_dirty_log_protect
+-> kvm_arch_sync_dirty_log /* arm64: 平时 no-op */
+-> (manual 模式) copy_to_user 原样整块拷出
| /* 不清零, 不重保护, 不 flush */
+-> (legacy 模式) 逐个非零 word 走四步
| +-> mask = xchg(&dirty_bitmap[i], 0) /* 步骤1: 快照并清零 */
| +-> kvm_arch_mmu_enable_log_dirty_pt_masked /* 步骤2: 重新写保护 */
| +-> copy_to_user(log->dirty_bitmap, buffer) /* 步骤3: 拷给用户 */
| +-> kvm_flush_remote_tlbs_memslot /* 步骤4: flush TLB */

kvm_arch_sync_dirty_log() 在 arm64 平时是空函数(HDBSS 开启时 kick 所有 vCPU 逼它们退出 guest 排空硬件脏位缓冲,第 9 节)。

legacy 模式那四步的顺序不是随手排的,内核注释写明了正确性论证:

  1. Take a snapshot of the bit and clear it if needed.
  2. Write protect the corresponding page.
  3. Copy the snapshot to the userspace.
  4. Upon return caller flushes TLB's if needed.

Between 2 and 4, the guest may write to the page using the remaining TLB entry. This is not a problem because the page is reported dirty using the snapshot taken before and step 4 ensures that writes done after exiting to userspace will be logged for the next call.

翻译过来:步骤 2 清了 PTE 的写权限,但各 pCPU 的 TLB(Translation Lookaside Buffer,地址翻译缓存)里还缓存着"可写"的旧表项,flush 生效之前 guest 的写不会 fault,这些写不进位图。这段窗口的写由步骤 1 的快照兜底(先快照后写保护,窗口内的写在快照里已经是 1);flush 之后的写必然 fault,记入下一轮。flush 是"脏页不丢失"的时间边界。

arm64 的这个 flush 不需要向各 vCPU 发 IPI(核间中断)请求,kvm_arch_flush_remote_tlbs_range() 由调用 CPU 同步执行范围 TLBI(TLB Invalidation,TLB 失效指令):

1
2
3
4
5
6
7
8
9
10
11
12
/* linux/arch/arm64/kvm/mmu.c */
int kvm_arch_flush_remote_tlbs_range(struct kvm *kvm, gfn_t gfn, u64 nr_pages)
{
u64 size = nr_pages << PAGE_SHIFT;
u64 addr = gfn << PAGE_SHIFT;

if (is_protected_kvm_enabled())
kvm_call_hyp_nvhe(__pkvm_tlb_flush_vmid, kvm->arch.pkvm.handle);
else
kvm_tlb_flush_vmid_range(&kvm->arch.mmu, addr, size);
return 0;
}

底层是 tlbi ipas2e1is,按 IPA(中间物理地址,即 guest 物理地址)范围失效 stage-2 表项,超出 MAX_TLBI_RANGE_PAGES 的分块执行,每次配 dsb(ish)。is 后缀(Inner-Shareable)意味着指令本身硬件广播到所有核,返回 0 让通用层的 KVM_REQ_TLB_FLUSH 请求路径短路,所以 arm64 全树 grep 不到这个 request 的使用者。

manual 模式把 GET 简化成"纯读",清零、重保护、flush 三个动作全部转嫁给 CLEAR ioctl,也就是发送路径。这正是第 6 节的内容。

6. 先清后发:发送脏页与 CLEAR 重保护

发送侧的循环在 ram_save_iterate() 里,每轮在限速预算内反复调 ram_find_and_save_block():

1
2
3
4
5
6
7
8
9
10
11
12
13
[QEMU] qemu_savevm_state_iterate                               /* qemu/migration/savevm.c */
+-> ram_save_iterate /* qemu/migration/ram.c */
+-> ram_find_and_save_block
+-> find_dirty_block /* find_next_bit 扫 bmap; 绕一圈无脏页则本轮结束 */
+-> pss_find_next_dirty
+-> find_next_bit(rb->bmap, size, pss->page) /* 顺序扫消费位图 */
+-> ram_save_host_page /* host page 为单位 */
+-> migration_bitmap_clear_dirty /* 先清 */
+-> migration_clear_memory_region_dirty_bitmap /* 触发远端 CLEAR */
+-> test_and_clear_bit(page, rb->bmap) /* 清本地消费位图 */
+-> ram_save_target_page /* 后发 */
+-> save_normal_page
+-> qemu_put_buffer /* 页内容进迁移流 */

发送以 host page(宿主机页,THP(Transparent Huge Page,透明大页)大页时一页 = 512 个 target page(目标架构页,虚机视角的页))为单位:pss_host_page_prepare() 圈定大页边界,循环把页内所有脏 target page 逐个清位发送。原因在 CLEAR 的代价:一次 CLEAR 会把范围内的大页全部拆成 4K 重新写保护(见下),整页发送让一次拆分服务整页的所有子页,避免对同一大页反复拆分。

核心是"先清后发"的顺序,migration_bitmap_clear_dirty() 的注释直说了理由:

This must be called before we send any of the page in the chunk because we need to make sure we can capture further page content changes when we sync dirty log the next time.

展开讲,"清"有两层,顺序都是先于发送:

第一层:清 QEMU 消费位图(test_and_clear_bit(page, rb->bmap))。先清再发,发送之后发生的重新标脏才能在下一轮 sync 把这一位重新点亮,页被重发;如果发完才清,就会把"发送窗口内新产生的脏"连同旧位一起抹掉。

第二层:清 KVM 位图并重新写保护(这就是"拷贝到对端之后如何写保护"的答案)。不直接逐页发 ioctl,而是用 clear_bmap 做"推迟+分块":sync 搬运全局位图进 rb->bmap 时,clear_bmap_set() 对整个 RAMBlock 的所有块置标记(默认 2^18 个 target page 一块,即 1GB,一块一位);标记不区分块里有没有脏页,真正的触发条件是发送,发送块内第一个脏页时 migration_clear_memory_region_dirty_bitmap() 测试并清除该标记,命中才对整块发起远端清除:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* qemu/migration/ram.c */
static void migration_clear_memory_region_dirty_bitmap(RAMBlock *rb,
unsigned long page)
{
//...
if (!rb->clear_bmap || !clear_bmap_test_and_clear(rb, page)) {
return; /* 本块已清过/无标记: 不重复清 */
}
shift = rb->clear_bmap_shift;
size = 1ULL << (TARGET_PAGE_BITS + shift); /* 块大小, 默认 1GB */
start = QEMU_ALIGN_DOWN((ram_addr_t)page << TARGET_PAGE_BITS, size);
memory_region_clear_dirty_bitmap(rb->mr, start, size);
}

推迟到发送时刻、切成 GB 级块,是把 CLEAR(伴随写保护、拆大页、TLB flush 的昂贵操作)从 sync 时点分摊到发送过程中。继续往下到 KVM:

1
2
3
4
5
[QEMU] memory_region_clear_dirty_bitmap                        /* qemu/system/memory.c */
+-> kvm_log_clear /* qemu/accel/kvm/kvm-all.c */
+-> kvm_physical_log_clear /* manual 未启用时直接 return */
+-> kvm_log_clear_one_slot /* 64 host page 对齐; 临时位图防误清 */
+-> ioctl(KVM_CLEAR_DIRTY_LOG)

kvm_physical_log_clear() 开头有个重要分支:manual protect 未启用时直接 no-op 返回,legacy 模式下 GET 已经"快照+清零"过整张位图,用户态不需要再显式清。kvm_log_clear_one_slot() 传给内核的位图有讲究:对齐扩展出的首尾空洞必须清零,慢路径干脆构造一张临时位图,只回放已从内核同步到 QEMU 的脏位。内核注释警告了不这么做的后果:把尚未同步的位也清掉,guest 的修改就会从位图里丢失,迁移后目的端直接数据损坏或 panic。

内核侧 CLEAR 的处理:

1
2
3
4
5
6
7
8
9
10
[KVM] kvm_vm_ioctl(KVM_CLEAR_DIRTY_LOG)                        /* linux/virt/kvm/kvm_main.c */
+-> kvm_vm_ioctl_clear_dirty_log
+-> kvm_clear_dirty_log_protect
+-> copy_from_user(第二份位图) /* 用户要清的位图 */
+-> mask &= atomic_long_fetch_andnot(mask, p) /* 原子清位, 只留真正清掉的 */
+-> kvm_arch_mmu_enable_log_dirty_pt_masked /* linux/arch/arm64/kvm/mmu.c */
+-> kvm_stage2_wp_range /* 重新写保护该范围 */
+-> kvm_mmu_split_huge_pages /* initially-set 模式: 拆大页 */
+-> kvm_nested_s2_wp
+-> kvm_flush_remote_tlbs_memslot /* TLBI ipas2e1is */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* linux/arch/arm64/kvm/mmu.c */
void kvm_arch_mmu_enable_log_dirty_pt_masked(struct kvm *kvm,
struct kvm_memory_slot *slot,
gfn_t gfn_offset, unsigned long mask)
{
phys_addr_t base_gfn = slot->base_gfn + gfn_offset;
phys_addr_t start = (base_gfn + __ffs(mask)) << PAGE_SHIFT;
phys_addr_t end = (base_gfn + __fls(mask) + 1) << PAGE_SHIFT;

lockdep_assert_held_write(&kvm->mmu_lock);

kvm_stage2_wp_range(&kvm->arch.mmu, start, end); /* 写保护 */

if (kvm_dirty_log_manual_protect_and_init_set(kvm))
kvm_mmu_split_huge_pages(kvm, start, end); /* 渐进模式下大页还没拆过 */

kvm_nested_s2_wp(kvm);
}

atomic_long_fetch_andnot 的语义保证正确性:只清用户点名、且确实为 1 的位。GET 之后 fault 新置的位若不在用户同步过的快照里,CLEAR 的 mask 不含它,位保持 1,留给下一轮 GET。

把一页的完整生命周期串起来(渐进模式),状态随时间的演变如下表:

时刻 发生了什么 KVM 位图的位 stage-2 权限 QEMU bmap 的位
t0 开启跟踪 1(初始全 1) RW(渐进模式不整体写保护) 1(初始全 1)
t1 bulk 发送推进到 P 所在块 1 RW 1
t2 发送 P:先 CLEAR 再读页内容 0(CLEAR 清位) RO(CLEAR 写保护) 0(发送前清位)
t3 guest 写 P 1(fault 置位) RO,触发 fault 0
t4 fault 处理完成 1 RW(relax_perms 放权) 0
t5 下一轮 sync 1(GET 拷出) RW 1(重新点亮,等待重发)

t2 到 t3 之间就是"先清后发"要保护的窗口:CLEAR 先把 P 写保护,QEMU 随后读页内容发送。这期间 guest 的写要么发生在读之前(内容已带上),要么触发 fault 重新置位,下一轮 sync 后重发。两个顺序(KVM 侧"先清位再写保护最后 flush TLB",QEMU 侧"先 CLEAR 再发送")共同保证了任何时刻的写都不会丢失记录。

7. 旁路:QEMU 自身写虚机内存的标脏路径

第 4 节的写 fault 路径有个盲区:stage-2 只约束 guest 经 IPA 发起的访问。QEMU 用户态(设备模拟)直接经 HVA(Host Virtual Address,宿主机虚拟地址)写 guest RAM 时根本不经过 stage-2 翻译,KVM 的写保护形同虚设,位图也看不到这些写。迁移期间这类写真实存在:e1000/virtio 设备模型把网络包 DMA 进 guest 内存、VGA 刷新显存,都是 QEMU 进程在写自己的 mmap。

谁来标脏?QEMU 自己的地址空间层。所有设备模型写 guest 内存都走 address_space_write(),写完顺手置位:

1
2
3
4
5
6
7
8
9
[QEMU] 设备模拟写 guest RAM (网卡 DMA / VGA 等)
+-> address_space_write /* qemu/system/physmem.c */
+-> flatview_write_continue
+-> qemu_ram_ptr_length /* 拿到目的 RAM 的 host 指针 */
+-> memmove /* 直接写 */
+-> invalidate_and_set_dirty
+-> cpu_physical_memory_range_includes_clean
+-> cpu_physical_memory_set_dirty_range /* qemu/include/exec/ram_addr.h */
+-> bitmap_set_atomic(ram_list.dirty_memory[MIGRATION/VGA], ...)

invalidate_and_set_dirty() 先用 memory_region_get_dirty_log_mask() 确认当前有哪些 client 在跟踪(迁移期间含 MIGRATION 位),再按 mask 置全局位图。这些位与 KVM 同步来的位在第 5 节的 sync 处汇合:ramblock_sync_dirty_bitmap() 的 qatomic_xchg 把全局位图整体搬进 rb->bmap,不区分来源。

另外两条 QEMU 视角的写路径:

  • vhost(内核态数据面):vhost 的写发生在内核,不经 QEMU 地址空间,靠 vhost 自己的 log 记录;vhost 的 MemoryListener 注册了 .log_sync = vhost_log_sync,在同一轮 sync 里把 log 搬回 QEMU 全局位图
  • TCG 加速(无 KVM 时):QEMU 的 SoftTLB 对 clean 页的写走 notdirty_write() 标脏,与 KVM 无关,一笔带过

这一节回答了思考题 4,也解释了三层位图里中间那层存在的意义:KVM 位图只覆盖 guest 的写,QEMU 全局位图是 guest 写与 VMM 写的汇合点。

8. 停机切换与两端收尾

迭代收敛后(pending_size < threshold_size),migration_iteration_run() 返回 BREAK,migration_thread 跳出循环进入 migration_completion():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[QEMU] migration_completion                                    /* qemu/migration/migration.c */
+-> migration_completion_precopy
+-> migration_downtime_start /* 记录停机起点 */
+-> migration_stop_vm(RUN_STATE_FINISH_MIGRATE) /* vCPU 全停, 业务中断开始 */
+-> vm_stop_force_state
+-> qemu_savevm_state_complete_precopy /* qemu/migration/savevm.c */
+-> cpu_synchronize_all_states /* vCPU 寄存器从 KVM 拉回 QEMU */
+-> qemu_savevm_state_complete_precopy_iterable
+-> ram_save_complete /* qemu/migration/ram.c */
+-> migration_bitmap_sync_precopy(rs, true) /* 最后一次同步 */
+-> ram_find_and_save_block /* 循环到发完: 无限速发剩余脏页 */
+-> qemu_put_be64(f, RAM_SAVE_FLAG_EOS)
+-> qemu_savevm_state_complete_precopy_non_iterable
+-> vmstate_save /* 逐设备: vmstate_gic, vmstate_arm_cpu... */
+-> qemu_put_byte(f, QEMU_VM_EOF) /* 流结束标记 */

停机阶段发的东西:剩余脏页(RAM)加上全部设备状态(vmstate)。QEMU_VM_EOF 是给目的端的结束信号,源端随后置 COMPLETED,migration_iteration_finish() 把源端虚机置为 RUN_STATE_POSTMIGRATE(不再运行,进程通常随后退出)。

目的端在迁移开始前就以 -incoming tcp:0:4444 之类的方式起好了进程,等连接进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[QEMU-dst] socket_accept_incoming_migration                    /* qemu/migration/socket.c */
+-> migration_channel_process_incoming
+-> migration_ioc_process_incoming /* qemu/migration/migration.c */
+-> migration_incoming_process
+-> process_incoming_migration_co /* 协程 */
+-> qemu_loadvm_state /* qemu/migration/savevm.c */
+-> qemu_loadvm_state_main /* section 主循环, 读到 QEMU_VM_EOF 结束 */
+-> (QEMU_VM_SECTION_PART) qemu_loadvm_section_part_end
+-> vmstate_load
+-> ram_load /* qemu/migration/ram.c */
+-> ram_load_precopy
+-> qemu_get_buffer(f, host, TARGET_PAGE_SIZE)
| /* RAM_SAVE_FLAG_PAGE: 直写 block->host + offset */
+-> ramblock_recv_bitmap_set /* 记录接收位图 */
+-> (QEMU_VM_SECTION_START/FULL) qemu_loadvm_section_start_full
+-> vmstate_load
+-> vmstate_load_state /* 设备状态 */
+-> process_incoming_migration_bh /* 主循环 BH 收尾 */
+-> bdrv_activate_all /* 接管磁盘 */
+-> qemu_announce_self /* 免费ARP, 通告新位置 */
+-> vm_start /* vCPU 恢复, 业务恢复 */

两个要点:

  • 目的端写页不走 address_space 层,qemu_get_buffer 直接 memcpy 进 block->host + offset,所以不产生 QEMU 侧标脏(也不需要)
  • 位图和页权限都不迁移。目的端从头开跑,没有脏页跟踪;rb->receivedmap 只是收页记录,供 postcopy/resume 场景回传源端重建状态用。源端页表里那些 RO 映射随源 VM 一起销毁,谈不上恢复

真正需要"恢复"的是迁移失败或被取消的场景:源端虚机要继续跑,得停跟踪、还性能。cancel 路径 migrate_fd_cancel() 置 CANCELLING 并 shutdown 迁移流,migration_thread 的 migration_detect_error() 返回致命错误跳出循环,migration_iteration_finish() 对 FAILED/CANCELLED 分支调 vm_start() 恢复源端业务,随后 cleanup 链关跟踪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[QEMU] migration_iteration_finish                              /* qemu/migration/migration.c */
+-> vm_start() /* FAILED/CANCELLED 时: 源端虚机恢复运行 */
+-> migrate_fd_cleanup_schedule
+-> migrate_fd_cleanup_bh
+-> migrate_fd_cleanup
+-> qemu_savevm_state_cleanup /* qemu/migration/savevm.c */
+-> ram_save_cleanup /* qemu/migration/ram.c */
+-> memory_global_dirty_log_stop(GLOBAL_DIRTY_MIGRATION)
+-> global_dirty_tracking &= ~GLOBAL_DIRTY_MIGRATION
+-> memory_region_transaction_commit /* FlatView 重建, mask 去掉 MIGRATION 位 */
+-> address_space_update_topology_pass /* log_stop (Reverse) */
+-> kvm_log_stop /* qemu/accel/kvm/kvm-all.c */
+-> kvm_section_update_flags
+-> kvm_slot_update_flags
+-> kvm_mem_flags /* 无 KVM_MEM_LOG_DIRTY_PAGES */
+-> kvm_set_user_memory_region
+-> ioctl(KVM_SET_USER_MEMORY_REGION)
+-> g_free(block->bmap / clear_bmap) /* 释放迁移位图 */

内核侧收到 flags 变更(KVM_MR_FLAGS_ONLY,LOG_DIRTY 从有到无)后做两件事:kvm_arch_commit_memory_region() 走 else 分支,只释放 eager split 缓存;位图由 kvm_commit_memory_region() 的 FLAGS_ONLY 分支 kvm_destroy_dirty_bitmap() 释放。除此之外全目录搜不到任何 unprotect 动作,页表里残留的 RO 映射靠惰性恢复:下次 guest 写它触发 Permission Fault,user_mem_abort 里 memslot_is_logging() 已经为假,compute_prot 因 !memslot_is_logging(...) 给回 W,relax 放权;此后新 fault 还能按 VMA 重建 2M 大页。这与姊妹篇的结论一致:写保护只改 PTE 硬件位,从不改软件事实源,所以权限随时可以从事实源重算回来。

全周期时间线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
迁移开始 (setup):
QEMU: ram_init_bitmaps -> bmap 全 1, 开启全局脏跟踪
KVM: SET_USER_MEMORY_REGION(LOG_DIRTY) -> 位图初始全 1, 不整体写保护
VM: 运行中

迭代期 (每轮):
QEMU: 逼近阈值时 sync: GET -> OR 进全局位图 -> 搬进 rb->bmap
发送: find_next_bit 扫 bmap, 先清后发, 按块 CLEAR
KVM: CLEAR: 清位 + 重新写保护 + 拆大页 + TLB flush
写 fault: relax_perms 恢复 RW + 位图置位
VM: 运行中

停机切换 (收敛后):
QEMU: vm_stop(FINISH_MIGRATE) -> 最后一次 sync -> 发完剩余脏页
-> 设备状态(vmstate) -> QEMU_VM_EOF
VM: 业务中断开始

对端恢复:
dst: ram_load 直写内存 -> vmstate_load 恢复设备 -> 免费ARP -> vm_start
VM: 业务在目的端恢复

源端收尾:
成功: RUN_STATE_POSTMIGRATE, 虚机不再运行
失败/取消: vm_start 恢复业务 + log_stop 去掉 LOG_DIRTY
-> 残留 RO 映射由后续写 fault 惰性恢复

9. 模式谱系:bitmap、dirty ring 与 HDBSS

把本文的 dirty bitmap 模式放回谱系里看:

模式 标脏动作 同步方式 arm64 状态
legacy bitmap 写 fault 置位 GET 顺带清零+重保护 可用,老 VMM 路径
manual+initially-set bitmap 写 fault 置位 GET 纯读,CLEAR 清零+重保护 本文主述,QEMU 8.2 默认
dirty ring(脏页环) 写 fault 推环形队列 用户态按 vCPU 读取环,RESET 重保护 7.3 支持
HDBSS 硬件置 DBM+缓冲 IPA VM exit 排空缓冲,sync 前 kick vCPU 7.3 新增,依赖硬件

bitmap 模式的开销瓶颈在同步成本:GET/CLEAR 都按 memslot 整体或大块工作,QEMU 找脏页要 find_next_bit 扫描全内存的位图,开销与脏页数量无关、与内存总量正相关。dirty ring 反过来:每次写 fault 顺带把 {slot, offset} 推进 per-vCPU 的环形队列(kvm_dirty_ring_push()),用户态按队列读取,成本与脏页数成正比。arm64 在 7.3 的支持由两个 Kconfig 组成:

  • HAVE_KVM_DIRTY_RING_ACQ_REL:非 TSO(Total Store Order,全序内存模型)架构用 acquire/release 语义的版本
  • NEED_KVM_DIRTY_RING_WITH_BITMAP:ring 与备份位图混合,因为 vgic/its 这类没有 vCPU 上下文的内核写路径没法推 ring,必须落回位图

QEMU 侧 listener 也二选一:ring 模式注册 log_sync_global(先 kvm_dirty_ring_flush() 再处理),bitmap 模式注册 log_sync。

HDBSS(Hardware Dirty Bit State Save,硬件脏位状态保存)是 v7.3 加入的硬件加速:

  • 开启脏页跟踪时 KVM 自动置 VTCR_EL2.HD,可写映射处于 writable-clean 状态
  • 硬件在写时直接把它提升为 writable-dirty 并把 IPA 记进 per-vCPU 的 HDBSS 缓冲,写不再 fault
  • VM exit 时 kvm_flush_hdbss_buffer() 逐条 kvm_vcpu_mark_page_dirty() 汇入同一套位图

这也是第 4 节 DBM 位被无条件置上的原因:没有 HD=1 时它是惰性位,硬件就绪后同一批 PTE 直接获得硬件标脏能力。对本文流程的影响仅一处:kvm_arch_sync_dirty_log() 在 HDBSS 开启时 kick 所有 vCPU 逼缓冲排空,保证 GET 快照之前硬件脏位都已写入位图。

总结

  1. dirty bitmap 模式的本质是用写权限换脏页记录:KVM 写保护 stage-2 PTE,把 guest 的写变成 Permission Fault,在 fault 处理里给 memslot 位图(每 4KB 页一个 bit,下标 = gfn - base_gfn)原子置位一次。位图是脏页记录的唯一载体,停机窗口由它算出的剩余脏页量决定。
  2. 位图分三层、单向流动:KVM memslot 位图是记录来源(只有 fault 置位),QEMU 全局位图汇合 guest 写与 QEMU 自身 DMA 写(stage-2 管不到后者,invalidate_and_set_dirty 补位),rb->bmap 是发送消费视图(find_next_bit 扫描、发一页清一位)。
  3. arm64 + QEMU 8.2 实际走 manual-protect + initially-all-set 渐进模式:开启跟踪的那次 ioctl 不做整体写保护(位图初始全 1,未保护页的写不置位但位本来就是 1),写保护推迟到发送时逐块 KVM_CLEAR_DIRTY_LOG 完成,配合拆大页把开启停顿摊进首轮全量发送。
  4. 两个顺序保证不丢脏页:内核侧"先快照/清位,再写保护,最后 flush TLB",写保护与 flush 之间的窗口由快照兜底,flush 之后的写必然 fault 入下一轮;QEMU 侧"先 CLEAR(重保护)后发送",发送期间的并发写要么进内容要么重新标脏。
  5. 只读页与写保护页在 PTE 上同形,区分全在软件:READONLY memslot 被 memslot_is_logging() 排除在跟踪外,其写 fault 在入口就被分流到 MMIO exit;权限收放都由 fault 时刻从 memslot/VMA 事实源现算,因此关闭跟踪后无需主动恢复写权限,惰性放权即可。
CATALOG
  1. 1. 热迁移三阶段:全量、增量、停机收尾
  2. 2. dirty bitmap 模式:一句话与三层位图
  3. 3. 初始化:打标记与渐进写保护
  4. 4. 写 fault 标脏:权限真值表与三层判定
  5. 5. 位图同步:QEMU 拉取 KVM 位图
  6. 6. 先清后发:发送脏页与 CLEAR 重保护
  7. 7. 旁路:QEMU 自身写虚机内存的标脏路径
  8. 8. 停机切换与两端收尾
  9. 9. 模式谱系:bitmap、dirty ring 与 HDBSS
  10. 总结