ARMv8/v9 Generic Timer虚拟化架构拆解
做虚拟化平台的老哥们应该都有同感:时间不对,一切白给。无论是虚拟机的时钟漂移、线程调度延迟,还是容器网络的超时重传,底层都是定时器在撑着。而ARM平台上这个"定时器地基",就是Generic Timer。今天这篇不聊泛泛的架构概念,直接进到ARMv8/v9 Generic Timer在虚拟化场景下的内部机制,从硬件视图、软件分层到KVM落地实现,一整套拆开揉碎讲清楚。
先说清楚这文章解决什么问题:如果你正在做KVM虚拟化开发、BSP适配、或者排查虚拟机时钟异常类问题,这篇文章能帮你建立起从硬件定时器到KVM虚拟中断的完整链路认知。如果你只是听说过Generic Timer想入门,跟着走一遍也能搞清楚它的核心机制,后面遇到问题至少知道去哪一层排查。
标题里"V-15"和"A-40"是我们内部项目对虚拟化和架构两块内容的编号,不必纠结具体含义,下面直接进正题。
1. 定时器虚拟化的三个核心难题
1.1 时间在虚拟化世界里为什么这么难
虚拟化最容易被低估的复杂度,就是时间。CPU、内存、设备都可以通过硬件虚拟化扩展来加速,唯独时间这个东西,它的"设备"是每颗CPU核心自带的,而且它的读取频率极高——操作系统的时钟节拍、调度器的tick、协议栈的超时计算,全都在高频读时间。
想象一下:你有一个物理闹钟,现在要把它分给10个人用,每个人都想在自己的时间线上设置闹铃、读取当前时间,而且互相不能干扰。更麻烦的是,每个"人"(虚拟机)还希望自己可以随意拨快拨慢手表(修改系统时间),但不能影响别人。这就是定时器虚拟化的本质难题。
具体拆解成三个问题来看:
- 时间来源一致性:所有虚拟机看到的"当前时间"必须有一个统一的基准,不能每颗CPU各说各话,否则迁移和多核场景直接崩。
- 虚拟时间隔离:每台虚拟机需要自己的时间线,这个时间线的起点可以不同(比如虚拟机刚启动时时间是2010年,宿主机已经是2024年),而且虚拟机修改自己的时间不能影响宿主机和其他虚拟机。
- 定时中断路由:每台虚拟机要能设置自己的闹钟,时间到了要正确触发对应的虚拟中断,并且要精确到微秒级别,不能靠软件轮询。
这三个问题,ARM Generic Timer的虚拟化架构就是专门为它们设计的。
1.2 ARM没有给定时器单独开一条虚拟化捷径
这里要说一个容易误解的点。ARM在虚拟化上做了很多硬件加速,比如GIC (Generic Interrupt Controller) 对虚拟中断的直接注入,MMU有Stage-2地址转换。但定时器这个模块,ARM并没有提供一个"虚拟定时器设备"让你直接操作。它采用的方式是:提供一组精心设计的硬件机制,让Hypervisor用trap-and-emulate的方式来实现虚拟定时器,但是把这个过程的开销压缩到极低。
为什么不像PCIe直通那样直接把定时器直通给虚拟机?因为定时器不是独立设备,它是CPU核心的一部分,而且多个虚拟机共享同一颗物理CPU。直通等于让一个虚拟机霸占硬件资源,其他虚拟机就没法用定时器了。
所以说,ARM Generic Timer的虚拟化架构,本质上是一个"硬件辅助的软件虚拟化"方案。理解这一点,后面看KVM的代码逻辑就会顺很多。
2. Generic Timer硬件机制全景拆解
2.1 系统计数器:整个时间世界的基准
ARM Generic Timer的顶层是一套系统级的计数器,叫System Counter。这个计数器是唯一的、全局的、单调递增的,整个SoC上所有核心看到的值都是一样的。它通常由主板上的晶振驱动,频率一般在1MHz到50MHz之间。
System Counter在软件侧体现为两个寄存器视图:CNTPCT(物理计数器)和CNTVCT(虚拟计数器)。这两个寄存器都是64位的,单位是tick。但是注意,ARM的spec规定软件不能直接读System Counter本身,必须通过每个核心上的CNTPCT/CNTVCT寄存器来读。
为什么要区分物理和虚拟两套计数器视图?答案很简单:物理计数器给宿主机用,虚拟计数器给虚拟机用。虚拟计数器 = 物理计数器 - Offset,这个Offset由Hypervisor设置。这就是前面说的"虚拟时间线"的实现基础。
2.2 每个核心上的定时器组件结构
每个ARM核心上有两组重要的定时器组件,一组是给非安全世界的,一组是给安全世界的。我们做虚拟化主要关注非安全世界,也就是EL1和EL2这两层,下面的结构以这套为主。
每个核心上有四种定时器:
- 物理定时器 (Physical Timer):通常用于EL1/EL0非安全世界,中断号是PPI 13。
- 虚拟定时器 (Virtual Timer):通常用于给虚拟机提供定时中断,中断号是PPI 14。
- EL2物理定时器 (EL2 Physical Timer):给Hypervisor自己用的定时器,中断是PPI 26。
- 安全物理定时器 (Secure Physical Timer):给TrustZone安全世界用的,虚拟化场景一般用不到。
每一种定时器都有自己的一组寄存器:CompareValue(比较值)、Control(控制)、Status(状态)。定时器的原理很简单:当前计数值 >= CompareValue 的时候,触发一次中断。
2.3 关键寄存器与作用域划分
把寄存器按访问权限和虚拟化角色拆开看,落实到代码和调试上会更有操作性:
| 寄存器 | 访问层级 | 虚拟化角色 |
|---|---|---|
| CNTPCT_EL0 | EL0/EL1可读 | 物理计数器,Hypervisor读物理时间用 |
| CNTVCT_EL0 | EL0/EL1可读 | 虚拟计数器,虚拟机读当前时间 |
| CNTVOFF_EL2 | 仅EL2可写 | 虚拟计数器偏移量,虚拟机时间线原点 |
| CNTP_TVAL_EL0 | EL0/EL1可写 | 物理定时器的递减计数值 |
| CNTP_CTL_EL0 | EL0/EL1可写 | 物理定时器控制:使能、屏蔽、状态 |
| CNTP_CVAL_EL0 | EL0/EL1可写 | 物理定时器比较值 |
| CNTV_TVAL_EL0 | EL0/EL1可写 | 虚拟定时器的递减计数值 |
| CNTV_CTL_EL0 | EL0/EL1可写 | 虚拟定时器控制 |
| CNTV_CVAL_EL0 | EL0/EL1可写 | 虚拟定时器比较值 |
| CNTHP_TVAL_EL2 | 仅EL2 | EL2物理定时器 |
| CNTHP_CTL_EL2 | 仅EL2 | EL2物理定时器控制 |
| CNTHP_CVAL_EL2 | 仅EL2 | EL2物理定时器比较值 |
| CNTHCTL_EL2 | 仅EL2 | 控制EL0对计数器和定时器的访问权限 |
| CNTKCTL_EL1 | EL1可写 | 控制EL0对内核定时器寄存器的访问 |
这张表建议存一下,排问题的时候会反复用到。特别是CNTVOFF_EL2和CNTHCTL_EL2这两个,是整个虚拟化的枢纽。
2.4 计数器读路径:为什么虚拟机读时间也要被拦截
这里有一个很多初学者没注意到的设计点:虚拟机执行CNTVCT_EL0读取时间,在KVM的默认配置下是不需要trap到EL2的。ARM通过硬件机制让CNTVCT_EL0直接读出来一个已经减去了CNTVOFF_EL2的值,整个过程发生在硬件层面,虚拟机根本不知道偏移的存在。
但是有一个例外:当虚拟机运行在32位模式,而Hypervisor需要读取CNTPCT的时候,情况就变得复杂了。32位guest访问CNTPCT会拆成两次32位load(高32位和低32位),这中间可能发生高低位不一致的问题。KVM内核对这个问题有专门的patch处理路径,后面在常见问题部分会讲到。
3. KVM定时器虚拟化架构设计
3.1 分层模型:Host Timer和Guest Timer
KVM在arch/arm64/kvm/arch_timer.c中实现了完整的定时器虚拟化逻辑。整套设计围绕两条时间线展开:
- Host时间线:基于CNTPCT物理计数器,KVM用它来调度vCPU的执行、计算虚拟机的运行时间。
- Guest时间线:基于CNTVCT虚拟计数器,虚拟机里的操作系统看到的时间。
两条时间线之间的桥梁就是CNTVOFF_EL2。KVM在加载vCPU到物理CPU上的时候写入这个寄存器,vCPU切走的时候不需要恢复(因为下个vCPU加载时会重新写),这个操作在关键路径上只有一次MSR写,开销极低。
定时器的虚拟化用到的核心结构体在KVM中分成两层:
struct arch_timer_cpu { struct arch_timer_context timers[NR_KTIMERS]; struct hrtimer hrtimer; bool is_timer_running; }; struct arch_timer_context { struct kvm_vcpu *vcpu; enum kvm_arch_timers timer; u64 cnt_cval; u64 cnt_ctl; bool loaded; bool ready; };第一层是per-CPU结构的,每个vCPU一组;第二层是具体的物理定时器和虚拟定时器各自独立的上下文。从结构体上就能看出设计意图:物理和虚拟两个定时器分别模拟,各自维护比较值和控制位,kvm根据guest的配置决定用哪个。
3.2 Virtual Timer和Physical Timer的分工策略
这是KVM定时器虚拟化设计里最精彩的部分。为什么要有两套定时器给guest用?表面上看,有虚拟定时器就够了,guest设闹钟就用CNTV_CVAL_EL0,时间到了触发PPI 14中断,不就行了吗?
真实原因是:某些guest操作系统会直接操作物理定时器而不是虚拟定时器。比如Linux内核早期的arch timer驱动,它在某些配置下直接使用物理timer。还有32位的ARM guest内核,某些版本的代码路径上会读取CNTPCT来校准时间。如果guest运行在EL1(虚拟机里),它访问CNTP_TVAL_EL0和CNTP_CTL_EL0是会直接透过到硬件物理定时器的(在trap没有配置的情况下),它会在没有Hypervisor掌控的情况下直接产生物理中断,这个中断会直接送进guest却没有任何虚拟化层的管理,成为一颗无法关闭的定时炸弹。
所以KVM的做法是分两层:guest的"物理定时器"和"虚拟定时器"操作都必须经过KVM的接管和控制。它最终映射到Host侧的实现是:用Host的一个真正的物理定时器来backing guest的某个定时器,再通过vGIC把中断以虚拟中断的形式注入回guest。
具体到实现上,KVM的策略是:
- 虚拟定时器:用虚拟计数器CNTVCT作为时间基准,backing hrtimer基于host的CLOCK_MONOTONIC(实际读取cntpct换算),中断走PPI 14,通过vgic注入。
- 物理定时器:用物理计数器CNTPCT作为时间基准,backing hrtimer同样基于host时间,中断走PPI 13,通过vgic注入。
两个timer的backing hrtimer在host上共用同一个hrtimer实体(arch_timer_cpu->hrtimer),通过hrtimer_start重新指定到期时间来实现两个timer的切换和共享。
3.3 中断的虚拟化路径:从硬件中断到Guest IRQ
定时器中断是整个虚拟化链路里最长的路径之一,值得完整走一遍:
- Host物理定时器超时,触发host的timer中断handler。
- KVM的timer handler(kvm_timer_irq_handler)被调用。这个handler识别中断来源是backing timer到期。
- KVM检查对应的arch_timer_context,确认是否应该向guest注入中断。
- 如果要注入,调用kvm_timer_update_irq,通过irqchip(vgic)设置对应的虚拟中断为pending状态。
- vCPU在下一次进入guest模式时,vgic会把pending中断通过list register注入,guest在EL1收到中断。
这里面有一个容易被忽略的关键细节:KVM在判断"是否应该向guest注入中断"的时候,不是简单看hrtimer到期就注入,而是要对比当前的虚拟计数器值和guest设置的定时器比较值。因为hrtimer的精度和实际timer的精度存在微小偏差,KVM需要做一次校准:
static bool kvm_timer_irq_can_fire(struct arch_timer_context *timer_ctx) { u64 cnt; u64 cval, ctl; cval = timer_ctx->cnt_cval; ctl = timer_ctx->cnt_ctl; if (kvm_timer_should_fire(timer_ctx)) return false; cnt = kvm_phys_timer_read(); if (cnt < cval) return false; return (ctl & ARCH_TIMER_CTRL_ENABLE) && !(ctl & ARCH_TIMER_CTRL_IT_MASK); }注意最后两个条件:ENABLE位和IT_MASK位。这说明KVM严格遵循ARM定时器的硬件语义:即使hrtimer到点了,如果guest屏蔽了中断或者禁用了定时器,也不能硬注入。
3.4 KVM如何处理Guest屏蔽定时器中断
这个场景在真实运行中经常出现,而且坑特别多。Guest的Linux内核在处理定时器中断时,会先屏蔽中断(设置IMASK位),清掉pending状态,处理完再重新使能。如果KVM在guest屏蔽中断期间直接把hrtimer停了,guest重新使能时会发现定时器已经没有在走,时间直接卡住。
KVM的处理方式是:即使guest屏蔽了中断,hrtimer也要继续跑,因为hrtimer的到期只是一个信号,真正的"是否注入中断"还要看guest的屏蔽状态。而且KVM在hrtimer到期时如果发现guest屏蔽了中断,会通过编程硬件定时器的比较值,让它在下一个预期的到期点再次触发,形成"轮询等待"的效果。
这个"继续跑、不注入"的策略,保证了guest重新使能定时器后,马上就能收到一个pending的中断,时间线不会断。但是代价是host侧会多次触发定时器中断,增加一定的CPU开销。这块在性能敏感场景值得做profile,看看是不是有大量的spurious wakeup。
4. 定时器的生命周期管理与vCPU调度联动
4.1 vCPU加载与卸载时定时器状态保存
KVM在vCPU切入和切出时,对定时器有明确的状态管理。切出的场景是vCPU被抢占、运行时间片到期或者需要退出到用户空间处理IO。
切入的时候,KVM做这么几件事:
void kvm_timer_vcpu_load(struct kvm_vcpu *vcpu) { struct arch_timer_cpu *timer = vcpu_to_timer(vcpu); struct arch_timer_context *vtimer = vcpu_vtimer(vcpu); struct arch_timer_context *ptimer = vcpu_ptimer(vcpu); kvm_timer_update_state(vcpu); if (timer->is_timer_running) return; timer->is_timer_running = true; /* Set the offset for the virtual timer */ timer_set_voffset(vcpu->kvm, vcpu->arch.timer_irq.offset); kvm_timer_vcpu_load_nogic(vcpu); kvm_timer_unblock(vcpu); }关键在于:
- 写入CNTVOFF_EL2,把这条vCPU的虚拟时间线切到自己的坐标。
- 更新vtimer和ptimer的硬件寄存器,让guest看到的定时器状态连续。
- 如果backing hrtimer已经启动过,切回来时不需要重新启动hrtimer,它一直在跑,只是到期事件可能因为vCPU不在而错过了。这里有一个"补课"机制,后面讲。
切出的时候逻辑对称,但要额外注意:
void kvm_timer_vcpu_put(struct kvm_vcpu *vcpu) { struct arch_timer_cpu *timer = vcpu_to_timer(vcpu); struct arch_timer_context *vtimer = vcpu_vtimer(vcpu); /* If the timer has expired, inject the interrupt */ if (kvm_timer_should_fire(vtimer)) kvm_timer_update_irq(vcpu, true, vtimer); if (!timer->is_timer_running) return; timer->is_timer_running = false; timer_save_state(vcpu); timer->hrtimer.cancel(cancel_phys_timer); }这里有一个设计亮点:即使vCPU被切出,hrtimer也不是被cancel掉(取消),而是保留。如果guest设置的定时器到期时间还没到,hrtimer留在host的timer wheel里,到期时照样触发;如果guest设置的到期时间已经过了,那么在切出的时候,KVM会立刻把虚拟中断的pending状态置上,等vCPU下一次进入时直接处理。
这就是为什么虚拟机的定时器即使在高负载宿主上也不会出现明显漂移——因为KVM用的是host的硬件定时器来保证到期精度,而不是依赖vCPU的调度。
4.2 "补课"机制:Guest错过定时器中断怎么办
展开讲一下上面提到的补课机制。场景是这样的:guest的定时器在10ms后到期,但是host负载很高,vCPU在8ms的时候被切出了物理CPU,直到20ms后才重新被调度回来。
如果没有补课机制,这10ms的定时器事件就丢了,guest醒来后发现时间已经过去了20ms,但它的定时器中断一个都没收到,所有依赖定时器的逻辑全都错乱。
KVM的补课逻辑在kvm_timer_vcpu_load里:
static void kvm_timer_update_state(struct kvm_vcpu *vcpu) { struct arch_timer_cpu *timer = vcpu_to_timer(vcpu); struct arch_timer_context *vtimer = vcpu_vtimer(vcpu); struct arch_timer_context *ptimer = vcpu_ptimer(vcpu); if (kvm_timer_should_fire(vtimer) != vtimer->irq.level) kvm_timer_update_irq(vcpu, !vtimer->irq.level, vtimer); if (kvm_timer_should_fire(ptimer) != ptimer->irq.level) kvm_timer_update_irq(vcpu, !ptimer->irq.level, ptimer); timer->hrtimer_active = false; }kvm_timer_should_fire会做一次当前虚拟计数器和比较值的大小判断。如果vCPU回来时发现当前时间已经超过了guest设置的值,就把中断状态更新为pending。因为这段时间guest的定时器中断本质上"一直在pending",KVM通过这此判断来模拟这个状态。
这个机制背后反映的设计哲学是:定时器中断本质上是一个"事件携带"的机制,只要最终状态正确,中间的丢失可以用状态同步来弥补,不需要逐个事件追溯。这与中断控制器虚拟化中level trigger的处理思路一脉相承。
4.3 32位Guest的特殊处理与CNTPCT陷阱
谈32位guest之前先解释一下为什么会有trap问题。当虚拟机运行在32位模式(AArch32),ARM架构规定访问某些64位寄存器的行为会primary变化。比如32位guest访问CNTPCT_EL0,会被trap到EL2。为什么?因为32位guest无法一次完成64位load,只能拆成两次32位load,两次之间的高32位值可能已经变了。如果不做trap处理,guest会读到撕裂的时间值。
KVM的处理是接受这个trap,然后模拟读取:用一次原子的64位读拿到CNTPCT,再拆成高32位和低32位返回给guest。所以这里多出来的开销是必然的,不是KVM实现的问题。但这里有一个严重的性能隐患:如果guest的Linux内核在时钟校准里频繁读CNTPCT,每次都会有trap,整个系统时间校准路径会变得特别慢。
实际的KVM在arch/arm64/kvm/hyp/hyp-entry.S中有对应的handlertable:
el0_32_pc: ... el0_32_cntpct: ...它做了这样的处理:在hyp阶段直接读出CNTPCT,拆成两个32位值,设置好返回寄存器后直接eret回guest,全程不退出到host kernel。这个路径把多次trap的开销降到了单次trap+一次内存读,算是在架构限制下的最优解。
我记得还有一处是CNTVCT的访问,在ARMv8.0的某个勘误表里有提到,32位guest读CNTVCT_EL0也可能产生诡异的撕裂值,个别内核版本会在head.S里做一次重读校准,这里就不再展开了。
5. KVM定时器实验复现:搭建一个最小观测环境
5.1 环境准备与内核配置要点
纸上谈兵没用。我自己的调试环境是QEMU + KVM跑ARM64 guest,host是树莓派的Ubuntu Server(内核5.15+)和另外一台鲲鹏920服务器,两边逻辑一致,只是性能差很多。
要复现本文讲的定时器虚拟化行为,需要确认以下配置项:
- CONFIG_KVM=y,CONFIG_KVM_ARM_HOST=y,这个不用多说。
- CONFIG_ARM_ARCH_TIMER=y,ARM的arch timer驱动。
- CONFIG_ARM_GIC_V3=y,中断控制器的v3版本。
- CONFIG_HZ_250或者CONFIG_HZ_1000,guest里配置的时钟频率会影响定时器中断频率,方便观察差异。
调试输出方面,建议打开tracefs的timer相关事件:
mount -t tracefs tracefs /sys/kernel/tracing echo 1 > /sys/kernel/tracing/events/kvm/kvm_timer_update_irq/enable echo 1 > /sys/kernel/tracing/events/kvm/kvm_hrtimer_start/enable echo 1 > /sys/kernel/tracing/events/kvm/kvm_hrtimer_cancel/enable cat /sys/kernel/tracing/trace_pipe这三个trace event能完整还原KVM定时器的生命周期全貌,从hrtimer启动到中断注入全链路可见。实测里面kvm_timer_update_irq的触发频率和guest的HZ配置强相关,可以直观看到虚拟定时器的tick节奏。
5.2 一个观测脚本:确认CNTVOFF在不同VM间隔离
要验证CNTVOFF_EL2确实起到了虚拟机时间隔离的作用,可以这样操作:
在宿主机写一个内核模块读取CNTVCT_EL0和CNTPCT_EL0的差值(不过这需要root权限和内核模块,不是每个环境都方便),更轻量的方式是跑一个只读虚拟计数器的小程序:
#include <stdint.h> #include <stdio.h> #include <time.h> static inline uint64_t cntvct_read(void) { uint64_t val; asm volatile("mrs %0, cntvct_el0" : "=r" (val)); return val; } static inline uint64_t cntpct_read(void) { uint64_t val; asm volatile("mrs %0, cntpct_el0" : "=r" (val)); return val; } int main(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); uint64_t v = cntvct_read(); uint64_t p = cntpct_read(); printf("CNTVCT=%lu CNTPCT=%lu diff=%ld tick\n", v, p, p - v); return 0; }在宿主机上编译运行,diff会是一个固定值(0或者某个常数,取决于kernel启动时是否设置了CNTVOFF为0);在虚拟机里编译运行同一个程序,diff就是KVM设置的CNTVOFF_EL2值。两个VM看到的diff不同,这就能非常直观地验证虚拟时间线的隔离。实测中,diff值通常是一个非常大的随机偏移,这就是KVM为每个VM分配的独立时间原点。
5.3 用ftrace验证hrtimer与注入的因果关系
更深入地验证中断注入路径,可以同时打开kvm和timer的trace事件:
echo 1 > /sys/kernel/tracing/events/kvm/enable echo 1 > /sys/kernel/tracing/events/timer/enable cat /sys/kernel/tracing/trace在guest里跑一个高频率的定时器程序(比如nanosleep 1ms),guest产生的定时器中断会导致host侧对应的backing hrtimer反复到期。trace里可以看到hrtimer_expire_entry到kvm_timer_update_irq之间通常只有几微秒的延迟,这说明中断注入路径是硬实时链路,没有经过workqueue之类的延迟机制。
如果发现hrtimer到期到中断注入之间出现了超过100微秒的间隔,那就要怀疑是不是host上有其他高优先级中断抢占了CPU,或者vCPU没有在物理CPU上运行导致hrtimer回调无法及时执行。这个时候要改成per-cpu的hrtimer(HRTIMER_MODE_ABS_HARD)来保证硬中断上下文中处理,后面调优部分会讲怎么改。
6. 高频问题排查与性能调优实录
6.1 虚拟机时钟漂移最快排查清单
虚拟机上最常被人吐槽的问题就是时钟漂移,本来以为是网络问题,查半天发现是时间不对导致TCP时间戳错乱。遇到时钟漂移,按以下顺序排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| guest时间比host慢 | CNTVOFF配置异常 | host读CNTVCT与guest读CNTVCT对比 |
| guest时间跳变 | 迁移后CNTVOFF未正确恢复 | 检查migration代码路径,确认写入CNTVOFF_EL2 |
| guest时间完全不动 | CNTFRQ未设置或异常 | dmesg看arch_timer驱动初始化日志 |
| 定时中断抖动大 | hrtimer模式不是HARD | 查/sys/kernel/debug/tracing看hrtimer回调上下文 |
| 32位guest时间异常 | CNTPCT trap路径问题 | strace guest内读时间函数,看是否有频繁trap |
最坑的一次经历是某个guest内核版本里CNTKCTL_EL1的配置有bug,导致guest无法在EL0访问CNTVCT_EL0,结果guest的vDSO里的时钟读取全部走了系统调用,性能掉了一个量级。这种问题靠监测是看不出来的,要学会用perf trace去看guest内时间系统调用的频率是否异常。
6.2 中断风暴问题:hrtimer空转与RESCHED
经常有老哥反馈,宿主机负载不高,但CPU0的中断占用率很高,irqtop里看arch_timer中断每秒触发几万次。大概率是guest里的定时器配置了过高的频率(比如1000Hz),而且guest处于idle状态不断进入WFI。
KVM对guest的WFI处理会直接退出到host,让vCPU线程睡在hrtimer上,等到下次虚拟定时器到期再唤醒。如果guest设置了非常短的定时器间隔,vCPU就会不停地醒来再睡,每次醒来都要走一遍完整的vCPU加载流程,开销非常大。
调优方向有两个:
- 在guest里调低HZ,从1000降到250,能减少3/4的中断触发频率,绝大多数服务器场景完全够用。
- 在host侧确认hrtimer的HRTIMER_MODE_ABS_HARD配置是否正确。KVM默认对timer hrtimer用的是hard模式(在硬中断上下文执行hrtimer回调),如果降级成了soft模式,回调会进softirq,延迟会明显增加。
同时要检查KVM是否开启了irqchip in-kernel模式。如果使用userspace irqchip(比如老的kvmtool或者某些嵌入式场景),虚拟中断要通过eventfd通知到用户空间再写回,每次注入都是两次ioctl的开销,性能会差几十倍。
6.3 迁移场景下的定时器连续性保障
vCPU热迁移时,定时器状态迁移是一块特别容易出bug的地方。KVM的迁移相关代码在arch/arm64/kvm/arch_timer.c里有如下关键函数:
int kvm_timer_get_state(struct kvm_vcpu *vcpu) int kvm_timer_set_state(struct kvm_vcpu *vcpu)这两个函数负责把当前vCPU的定时器状态(比较值、控制位)保存到kvm_timer_context里,然后在目标机上恢复。
正常情况下迁移流程是:
- 源端暂停vCPU。
- kvm_timer_get_state保存vtimer和ptimer的cnt_cval、cnt_ctl。
- 用户空间(QEMU/kvmtool)把这些状态传给目标端。
- 目标端kvm_timer_set_state恢复这些状态。
- 目标端vCPU启动后,在kvm_timer_vcpu_load时把cval写入硬件寄存器。
这里的坑在于:目标机的CNTVCT值和源机的CNTVCT值不一样,因为两台机器的CNTVOFF_EL2不同。恢复状态时必须把源机的cval换算成目标机的cval,换算公式是:new_cval = old_cval + (new_vo - old_vo)。KVM在kvm_timer_set_state里会调用timer_set_voffset重新设置新VM的偏移,然后根据偏移差调整cval。如果这一步做错了,guest迁移后定时器要么立即触发一次虚假中断,要么延后好久才触发。
调试这个问题的办法是,在迁移前后各打印一下kvm_timer_get_state和kvm_timer_set_state出的cval和offset:
echo 1 > /sys/kernel/tracing/events/kvm/kvm_timer_get_state/enable echo 1 > /sys/kernel/tracing/events/kvm/kvm_timer_set_state/enable对比两份日志里的cnt_cval差值,应该和两边的CNTVOFF差值完全一致,如果不一致就是换算逻辑出了问题。
6.4 从KVM向pKVM/枪口式虚拟化演进的影响
ARMv9开始力推的CCA(Confidential Compute Architecture)和pKVM(protected KVM)对定时器虚拟化的架构影响值得提前关注。
pKVM里,Hypervisor本身被拆成了两个世界:root world(高特权,控制所有硬件)和protected world(受限的VMM运行环境)。这意味着原来运行在EL2的KVM代码被一分为二,其中一部分降级到EL1。定时器虚拟化里最重要的CNTVOFF_EL2写入操作,在pKVM下由root world完成,protected world的VMM不能再直接操作这个寄存器。整个中断注入路径也变了,定时器中断需要经过root world的中转才能到达protected world的VMM再到guest。
这对系统软件开发者意味着什么?以前在KVM里直接操作timer寄存器做优化的代码路径需要重新审视。比如原来用户态通过KVM_CAP_ARM_TIMER控制定时器行为的方式,在pKVM里可能不再可用,因为那个状态不在protected world的掌控范围内。
好在ARM在硬件层面考虑了这一点,EL2物理定时器(PPI 26)就是给hypervisor自己用的,它不参与guest的定时器虚拟化,所以hypervisor可以在不干扰guest虚拟定时器的情况下做自己的调度计时。pKVM也延续了这套设计,用EL2物理定时器做自己的时间基准,不和guest的时间线混在一起。
7. 写在最后的个人经验
定时器虚拟化这块,看起来只是虚拟化体系里的一小块,但它牵扯到硬件架构、中断子系统、调度器、迁移等多个方向的交叉知识。串起来理解之后,再去看其他设备的虚拟化(比如vgic中断虚拟化、PMU虚拟化),很多设计思路都是相通的。
我个人在做这套东西的过程中的几个体会:
第一,别上来就看KVM源码,先把ARM ARM(ARM Architecture Reference Manual)里Generic Timer那章过一遍。很多KVM代码里的常量和不直观的位操作,都是直接从寄存器语义翻译过来的。
第二,排查时间相关问题的时候,建立"时间线世界观"特别有用。时刻问自己:我现在看的是host时间线还是guest时间线?CNTVOFF在中间扮演了什么角色?一旦把坐标系分清楚,至少70%的问题都能定位到正确方向。
第三,性能调优不要凭空想。装好ftrace和perf,实测几组数据再动代码。很多时候你以为瓶颈在中断注入的路径上,实际测出来的结果可能完全相反。没有测量就没有优化。
最后说一个调试小技巧:如果你需要快速确认一个guest当前用的是vtimer还是ptimer,只需要在guest里执行cat /proc/interrupts | grep -E "arch_timer|timer"看中断号。如果你看到的是一个外设中断号(非13/14),说明guest代码里做了自定义处理,这时候就要怀疑它是不是在EL1直接操作物理定时器,绕过KVM的虚拟化了。这种情况在改装过内核的安卓环境里尤其常见,值得多留个心眼。