Minos 虚拟化: vCPU 与调度
本文分析 Minos 如何复用自身的任务调度器来实现 vCPU 的调度:从任务数据结构与就绪队列讲起,再到 vCPU 的创建、mode 四态状态机与 vcpu_idle 睡眠,最后覆盖 kick_vcpu 跨核唤醒、vmodule 状态保存恢复,以及 guest 与 VM0 通信所用的 vmcs 协议。
1. 背景与设计选择
1.1 一个复用现有调度的选择
minos 本身就是一个运行在 EL2 的小型 OS:有任务、有调度器、有中断、有定时器。要让多个 guest 分时复用 CPU,一个自然的做法是把”每个 vCPU”当作调度器里的一个普通任务,由同一个调度器统一调度。这样 hypervisor 不需要维护第二套调度逻辑,guest 的 vCPU 与 VM0(host)自己的内核线程可以按优先级公平竞争。
这个设计在代码里有几个直接体现:
- vCPU 对应一个真实的任务:
create_vcpu里create_vcpu_task(name, vm->entry_point, vm->vcpu_affinity[vcpu_id], ...)(virt/vm.c:320-361),任务创建时标记为TASK_FLAGS_VCPU(core/task.c:376-382)。 - 任务切换时,hypervisor 要为”即将失去 CPU 的 guest”保存虚拟化状态:
arch_task_sched_out里调用vcpu_context_save(arch/aarch64/core/arch.c:140-151)。 - 调度的”入/出 guest”由钩子完成:
vcpu_return_to_user/vcpu_exit_from_user(virt/vm.c:294-318)在异常返回路径里切换vcpu->mode并触发中断注入钩子。
1.2 vCPU 任务的特殊性
普通任务有 task_func_t func(任务函数),vCPU 任务没有 EL2 侧的函数体:它第一次被调度时,直接从异常返回路径 eret 进 guest 的入口地址。所以 vCPU 任务的特殊之处在两点:
- 入口不是函数,而是 guest 入口 PC。
arch_vcpu_init把 guest 入口地址填进gp_regs->pc(arch/aarch64/virt/arch_virt.c:108-129),任务栈顶向下挂一张gp_regs,首次调度后异常返回直接进 guest。 - 切换时多保存一层虚拟化上下文。普通任务切换只保存通用寄存器(
cpu_context+ fpsimd),vCPU 任务还要保存 GIC 的 LR、vtimer 的 CNTV 寄存器、EL1 系统寄存器等——这正是第 5 章 vmodule 机制要解决的。
“vCPU = 任务”意味着系统里其实有两套调度在同时工作:guest 的 Linux 调度器在自己的虚拟 CPU 之间分派任务,minos 的调度器在物理核上分派 vCPU。两套调度操作的是不同的 CPU 集合(虚拟 vs 物理),呈嵌套关系:
1 | Linux 侧(虚拟 CPU = vCPU) hypervisor 侧(物理核) |
Linux 的”跨 CPU 迁移”只是把它自己的任务从 vCPU0 的链表挪到 vCPU1 的链表,并发一个 SGI 请 vCPU1 重新调度;真正在物理核上执行什么、执行多久,由 minos 调度器决定。
既然 vCPU 是一个普通任务,它就必须遵循这套任务与调度规则。下一章先补上这些基础,后面看 vCPU 时才能对上”它是在什么时候、以什么状态被调度的”。
2. 任务与调度器基础
2.1 任务的数据结构
struct task(include/minos/task_def.h:82-145)承载一个任务的全部信息,与 vCPU 直接相关的字段:
| 字段 | 含义 |
|---|---|
stack_base/top/bottom |
任务栈。stack_base 指向栈顶的 gp_regs(异常返回帧) |
state |
任务状态:RUNNING/READY/WAIT_EVENT/WAKING/SUSPEND/STOP(task_def.h:50-55) |
prio |
优先级 0-7,值越小优先级越高(task_def.h:23-38) |
affinity/cpu |
绑定的物理 CPU;cpu 为 -1 表示不在运行中(已 sched out) |
s_lock |
跨核保护 state/pend_state 的自旋锁 |
state_list |
挂到某个调度链表(ready list / new_list / stop_list) |
event_list |
挂到等待的事件对象上 |
return_to_user/exit_from_user |
进出 guest 的钩子函数指针,由 create_vcpu 赋值 |
pdata |
私有数据,vCPU 任务的 pdata 即 struct vcpu * |
cpu_context |
上下文切换用的寄存器保存区 |
优先级划分(task_def.h:33-38),数值越小优先级越高:OS_PRIO_VCPU = 4,其数值大于 OS_PRIO_SYSTEM(3)(故优先级低于系统任务)、小于 OS_PRIO_DEFAULT(5) 和 OS_PRIO_IDLE(7)(故优先级高于普通内核任务与 idle)。
任务结构只是”任务的静态描述”;要让它被调度,还必须知道它挂在哪个队列、在哪个核上排队。这就是每个物理 CPU 自己维护的就绪队列(struct pcpu)——任务真正”住”的地方。
2.2 每 CPU 的就绪队列
调度器为每个物理 CPU 维护一份独立的调度数据结构(struct pcpu,include/minos/percpu.h:18-54)。这些成员的关系可以用一张结构图概括(任务通过 task->state_list 挂到某条链表上;new_list/stop_list 是独立于 ready_list 的旁路):
1 | ┌──────────────────────────────────────────────────────────────────┐ |
running_task/idle_task:本核当前运行的任务、本核 idle 任务指针(percpu.h:40-41)。local_rdy_grp:8 位位图,bit N = 1表示ready_list[N]非空,pick_next_task用它 O(1) 找最高优先级(sched.c:251)。ready_list[8]:按优先级分桶的主就绪队列,任务用state_list挂入对应桶(数值越小优先级越高)。new_list:其他核投递过来的任务,暂存后由irqwork_handler批量取走——它是ready_list之外的旁路入口(需lock)。stop_list:已 STOP 任务,等本核 kworker 回收——同样是旁路,不进ready_list。回收链路:pick_next_task把 STOP 任务挂入stop_list并置KWORKER_TASK_RECYCLE(sched.c:242-244),本核的 kworker 任务(kworker_task,core/idle.c:94-115)收 flag 后调用pcpu_release_task(core/idle.c:78-92)回收。sched_timer:同级任务多于 1 个时启用时间片轮转。
队列结构定义好了,那任务怎么进队列?这由两个入口完成:task_ready(新任务/被唤醒的任务入队)和 __wake_up(唤醒睡眠中的任务)。
2.3 入队与唤醒
入队
task_ready 的两条路径(本核直接入队 / 跨核投递 + SGI 通知):
task_ready(task, preempt)(core/sched.c:163-190):把任务放入就绪队列。先定目标核(task->cpu = task->affinity,无绑定核则select_task_run_cpu()现挑,sched.c:169-171),再按目标核分派:- 本核(
pcpu->pcpu_id == task->cpu,sched.c:184):percpu_task_ready(sched.c:138-145)直接add_task_to_ready_list(sched.c:73-99)。入队时与当前任务同优先级则插到它前面(并触发sched_timer轮转),否则插队尾(sched.c:86-92);最后更新位图、必要时set_need_resched()(sched.c:94-98)。 - 跨核(sched.c:180-182):
get_per_cpu取目标核 pcpu 后smp_percpu_task_ready(sched.c:147-161)——加锁挂入目标核 **new_list**,发pcpu_irqworkSGI,由目标核irqwork_handler(sched.c:425-469)取走真正入队。
- 本核(
关键点:跨核路径不直接操作目标核的就绪队列,而是放进 new_list(只有它需要自旋锁保护,percpu.h:36-37),再用 SGI 让目标核自己取走。这样目标核的就绪链表永远只被本核修改,无需全局锁。
唤醒
wait_event(include/minos/event.h:51-78)与 wake(core/event.c:132-154)是一对:任务自己 wait_event 睡眠,别人 wake 把它拉起。事件对象 event->data 存着等待它的任务,wake 取出该任务调 __wake_up:
1 | 睡眠侧(任务自己) 唤醒侧(别的代码/别的核) |
(睡眠侧:置 WAIT_EVENT → 挂等待链表 → sched() 让出;唤醒侧:wake 取任务调 __wake_up,中断里唤醒自己走 wake_up_interrupted,跨核唤醒走 wake_up_common。)
__wake_up(sched.c:638-644)按”唤醒谁”分两条路:
- 唤醒自己(
task == current,中断里发生)→wake_up_interrupted(sched.c:516):spin_trylock试锁,直接把自己从 WAIT_EVENT 置回 RUNNING,不入队、就地继续。 - 唤醒其他任务 →
wake_up_common(sched.c:573-636):spin_lock加锁,**自旋等task->cpu == -1**(确保任务真的被 sched out,switch_to_task里cur->cpu = -1,sched.c:300),状态置 WAKING,最后task_ready重新入队。
在 vCPU 场景,这个”事件”就是 vcpu->vcpu_event(vm.c:354 初始化):睡眠侧是 vcpu_idle(vm.c:144 的 wait_event,第 3.4 节讲);唤醒侧有三处——**kick_vcpu(vm.c:212,给 vCPU 发 virq 时先唤醒)、vtimer 定时器到期(arch/aarch64/virt/vtimer.c:94)、VM 电源恢复**(virt/vm_pm.c:57)。
唤醒流程(__wake_up 双分支 + wake_up_common 跨核同步):
任务入队只是第一步——队列里有多个任务时,谁在什么时候被选中运行?这就是”调度点”要回答的:在哪几个时机触发选队,以及选中后如何切换。
2.4 调度的执行点
switch_to_task(sched.c:267-319)是上下文切换的核心,顺序固定:
arch_task_sched_out(cur):保存当前任务(vCPU 任务在此触发 vmodule save,见第 5 章)。- 处理当前任务状态:WAIT_EVENT 且有 delay 则启动延迟定时器;RUNNING 则置回 READY。
cur->cpu = -1,通知其他核可以安全唤醒它。- 切换到
next:置 RUNNING、set_current_task、arch_task_sched_in(next)(触发 vmodule restore)。
UML 时序图(从调度入口到 vmodule save/restore 的完整调用序列):
真正的调度点有两个:
- **
sched()**(sched.c:339-350):主动让出,如task_sleep/task_suspend/cond_resched。 - 异常返回路径:
__exception_return_handler(sched.c:381-415)在eret前检查__TIF_NEED_RESCHED,需要就pick_next_task+switch_to_task。
pick_next_task(sched.c:226-265)用 ffs_one_table[local_rdy_grp] 找最高非空优先级,取出队首任务并轮转(队首移到队尾)。
pick_next_task 选出了 next,接下来 switch_to_task 把当前任务换下来。但上文 2.4 的四步里看不到任何寄存器保存——这就引出下一节:真正的寄存器级切换其实不在这四步里,而在异常入口/返回的汇编层。
2.5 寄存器级上下文切换
细看 switch_to_task(sched.c:267-319)会发现一个容易困惑的点:它从头到尾没有保存/恢复任何通用寄存器(x0-x30),也没有切换栈指针。它只做了 set_current_task(next)——即 mov x18, next(include/minos/task_info.h:55-58,x18 恒为 current task 指针)。
那寄存器级切换在哪完成?答案在异常入口与异常返回,switch_to_task 只是”决定下一个是谁、改指针”,实际的换栈/恢复现场全在汇编层。完整时序:
上面是”怎么切”的调用时序;下面看栈视角——被切走的任务,它的栈里到底压了什么,切换后如何换到新任务的栈:
1 | task A stack: saved by SAVE_GP_REGS when exception enters EL2 |
- 任务栈从高地址向下生长;
SAVE_GP_REGS(vector.S:55-83)把ELR/SPSR/SP_EL0/x0-x29/lr从低到高压到栈顶。 - 压完后
sp指向stack_base,exception_return换栈时ldr sp ← next->stack_base(vector.S:125-126),再LOAD_GP_REGS从新栈弹出恢复。 - 这些都在任务自己的栈上,是”通用寄存器现场”。
除了异常帧(栈上),arch_task_sched_out(arch.c:140-151)还保存 fpsimd——fpsimd_state_save(arch.c:150)把 FPSR/FPCR 和 q0-q31(浮点/NEON 寄存器)存进 task->cpu_context.fpsimd_state(arch/aarch64/include/asm/tcb.h:47-59),arch_task_sched_in 再恢复。这是所有任务(含普通内核任务)都有的第二层,存在 struct task 结构体里(不是栈上):
1 | struct task (allocated via zalloc, NOT on the task stack) |
vCPU 任务比普通任务再多保存一层——EL1 系统寄存器不在任务栈上,而是由 vmodule 机制保存(第 5 章)。每个槽位 vcpu->context[i] 对应一个注册的 vmodule,各模块在注册时(第 5.2 节)提供自己的 state_save/restore 回调;arch_task_sched_out/in(arch.c:140/153)时,由宏生成的遍历函数 save/restore_vcpu_vmodule_state(vmodule.c:150-151)按注册顺序调用各模块的回调,每个模块从自己的 context[vmodule->id] 槽位存取状态。各槽位内容如下:
1 | vCPU extra state: vmodule save/restore, stored in vcpu->context[] |
这些模块在开机时注册(gicv3-vmodule 经 gicv3 驱动、vtimer_module 经定时器驱动、链接期 aarch64 vcpu context/vfp 经 vmodules_init,见 5.2 节);save_vcpu_vmodule_state 的具体实现见 5.4 节。
三个关键理解:
stack_base就是”异常返回帧”的位置。每次异常/sched()进 EL2,现场被压到当前任务栈顶,sp存入task->stack_base(asm-offset.c:31的TASK_STACK_OFFSET就是这个字段)。换栈的本质就是换一个stack_base。switch_to_task不碰寄存器是设计使然。它运行在异常处理上下文里,当前任务的现场已经在栈上了(由 SAVE_GP_REGS 压好);它只需要把x18指向下一个任务,返回后汇编层读新任务的stack_base换栈即可。这样上下文切换的”脏活”集中在一处汇编,C 层调度器保持简洁。vCPU 任务切换与普通任务完全同机制。vCPU 是任务,它被切走时同样是异常帧机制保存通用寄存器;区别只在
arch_task_sched_out/in额外触发 vmodule save/restore(第 5 章)。从一个 vCPU 切到另一个 vCPU,就是换栈 + eret 到下一个 vCPU 任务——若目标是进 guest 前,task_return_to_user还会先跑vcpu_return_to_user的 mode 钩子(第 3.3 节)。
2.6 任务亲和性(affinity)
每个任务绑定一个物理核(task->affinity),vCPU 任务也不例外——create_vcpu 把 vm->vcpu_affinity[vcpu_id] 传给 create_vcpu_task。亲和性决定三件事:任务在哪个核就绪、kick_vcpu 往哪个核发 SGI、task_ready 走本核还是跨核路径(sched.c:179-185)。
VM 的亲和性来源:
- 静态配置:VM 的 dts/vmtag 里写死
vcpu_affinity[VM_MAX_VCPU](__create_vm里memcpy(vm->vcpu_affinity, vme->vcpu_affinity, ...),vm.c:865-866),vm_check_vcpu_affinity校验不重复、不越界(vm.c:164-180)。 - 动态分配:
VM_FLAGS_DYNAMIC_AFF置位时,vmtag_check_and_config调get_vcpu_affinity(vm.c:466-469),从全局位图vcpu_aff_bitmap上挑空闲核。vcpu_affinity_init(vm.c:381-403)在开机时把已声明 VM 占用的核记进位图,并记录已占用核数native_vcpus(空闲核数 =NR_CPUS - native_vcpus,get_vcpu_affinity正是用这个差值判断剩余核是否够用,vm.c:415)。
2.7 跨核打断
pcpu_resched(pcpu_id)(sched.c:120-123):给目标核发CONFIG_MINOS_RESCHED_IRQSGI,让目标核从 guest/异常返回路径检查重调度。pcpu_irqwork(pcpu_id)(sched.c:125-128):发CONFIG_MINOS_IRQWORK_IRQ,目标核的irqwork_handler处理new_list。
两个 SGI handler 在 local_sched_init(sched.c:477-490)注册。
这两个 SGI 是第 2.3 节跨核入队(smp_percpu_task_ready → pcpu_irqwork)和后面第 4 章 kick_vcpu 跨核唤醒的底层通道:**目标核收到 SGI 后,在异常返回路径检查 resched 标志或处理 new_list**。
3. vCPU 的创建与生命周期
有了任务与调度器的基础,就可以看 vCPU 这一特殊任务是如何被创建、唤醒,以及它的状态如何随 guest 进出而流转的。先看创建流程。
3.1 创建流程
create_vm(virt/vm.c:885-937)→ create_vcpus(vm.c:786-807)→ 逐个 create_vcpu(vm.c:320-361):
1 | task = create_vcpu_task(name, vm->entry_point, |
create_vcpu_task(core/task.c:376-382)用 TASK_FLAGS_VCPU | TASK_FLAGS_NO_AUTO_START 创建任务:不自动启动(NO_AUTO_START),启动时机由 vcpu_online 决定。
注意普通任务创建时调 arch_init_task 初始化任务函数(task.c:254-255),而 vCPU 任务跳过这一步——它的”入口”由 arch_vcpu_init 单独设置(arch_virt.c:108-129)。
vcpu->mode 初始为 IN_ROOT_MODE(vm.c:347),vcpu_event 初始化为 OS_EVENT_TYPE_NORMAL(vm.c:354)。
UML 时序图(create_vm → create_vcpu 的逐层调用):
注意创建阶段 vCPU 任务尚未进入就绪队列:NO_AUTO_START 让它只是”存在”,真正上线要等第 3.2 节的 vcpu_online。
也就是说,创建只是把 vCPU 任务”造出来”,还躺在一边;让它进入就绪队列、真正可被调度,是”上线”这一步做的事。
3.2 任务上线与启动时机
vcpu_online(vm.c:63-67):
1 | ASSERT(vcpu_is_offline(vcpu)); |
vcpu_is_offline 检查 VCPU_STATE_SUSPEND。启动路径:do_start_vm(vm.c:519-537)只对 vcpu0 调 vcpu_online;多 vCPU 的后续启动靠 guest 内部通过 SGI/psci 触发 vcpu_power_on(vm.c:90-116)。
上线后 vCPU 任务开始在调度器里运行,但它会反复在”执行 guest 代码”和”陷入 EL2 处理事务”之间切换。vCPU 当前处于哪一侧、切换进行到哪一步,用一个字段就能表达——这就是 mode 状态机。它对第 4 章 kick_vcpu 的跨核判断也至关重要。
3.3 mode 四态状态机
struct vcpu 的 mode 字段(include/virt/vm.h:48-78):
| 状态 | 含义 |
|---|---|
IN_GUEST_MODE |
vCPU 正在执行 guest 代码(EL1) |
OUTSIDE_GUEST_MODE |
已从 guest 退出、尚未执行 OS_HOOK_EXIT_FROM_GUEST |
IN_ROOT_MODE |
运行在 hypervisor(EL2),如处理 trap |
OUTSIDE_ROOT_MODE |
即将进入 guest、尚未执行 OS_HOOK_ENTER_TO_GUEST |
两个切点函数(vm.c:294-318):
1 | static void vcpu_return_to_user(struct task *task, gp_regs *regs) |
(OS_HOOK_ENTER_TO_GUEST 触发中断注入,OS_HOOK_EXIT_FROM_GUEST 触发中断回收,分别对应 Phase 5 的 vgic_irq_enter_to_guest/exit_from_guest。)
关键点:先置中间态、再跑钩子、最后置最终态,中间用 smp_wmb 保证顺序。 这样设计是因为 kick_vcpu 需要读取 mode 判断是否发物理 SGI:smp_wmb/smp_rmb 只保证同一 vCPU 上的读写顺序,不保证远程读者一定采样到最终态——读者可能看到 OUTSIDE_* 中间态。安全性来自 kick_vcpu 对任何 mode != IN_ROOT_MODE 都发 resched(vm.c:240),天然保守,无论采到中间态还是 guest 态都不会漏发。这也是第 4 章 kick_vcpu 判断是否需要发物理 SGI 的依据。
调用链(由异常向量汇编触发):
1 | eret 进 guest 前 → task_return_to_user(core/task.c:175-196)→ vcpu_return_to_user |
UML 时序图(进出 guest 时 mode 的完整切换调用序列,含中断注入/回收钩子):
两张 mermaid 状态图概括:
3.4 guest 睡眠与 vcpu_idle
guest 执行 WFI 会被 trap 到 EL2(arch_vcpu_state_init 里 HCR_EL2.TWI 默认置位,arch_virt.c:198-199),进入 wfi_wfe_handler(arch/aarch64/virt/trap.c:47-51):
1 | static int wfi_wfe_handler(gp_regs *reg, int ec, uint32_t esr_value) |
vcpu_idle(vm.c:142-145):
1 | int vcpu_idle(struct vcpu *vcpu) |
wait_event(include/minos/event.h:51-78)把当前任务状态置 WAIT_EVENT 并挂到 vcpu_event 的等待链表,然后 sched() 让出 CPU。vcpu_can_idle(vm.c:128-140)是等待条件:VM 在线、任务无需停止、无 pending 也无 active 中断(vcpu_has_irq 检查 pending_bitmap 和 active_bitmap 两个位图,virt/virq.c:391-401)。当 kick_vcpu 唤醒该事件时,任务重新就绪、被调度回来,wait_event 返回,vCPU 再次进入 guest。
这里要澄清一个关键点:**sched() 让出的是”vCPU 任务”,不是”物理核”**。vCPU 进入 WAIT_EVENT 后,pick_next_task(sched.c:226)会在本核就绪队列里选下一个最高优先级任务接着跑——可能是另一个 vCPU、VM0 的内核任务,或 idle 任务(第 2.4 节)。所以:
1 | guest 执行 WFI → trap 到 EL2 → vcpu_idle → wait_event |
即vCPU 睡眠只是”这个 vCPU 任务不占 CPU 了”,物理核不会闲着——它被调度器分给其他任务。这正是”vCPU = 任务”的意义:guest 的空闲(WFI)通过 sched() 转化为本核 CPU 的高效复用。
睡眠与唤醒的完整环路:
WFI 被 trap 的场景:VM_FLAGS_NATIVE_WFI 置位时(arch_virt.c:198)TWI 不置位,guest 的 WFI 不 trap、由硬件直接让 CPU 休眠,此时 vCPU 任务保持在 EL1 的”运行中”状态——这影响 kick_vcpu 的判断(见 4.2 第 4 种情况)。
上面看到 vCPU 在 idle 时会 wait_event 睡眠,那么”谁把它唤醒、如何在另一个核上打断它”就成了下一个问题——这正是 kick_vcpu 的职责。
4. kick_vcpu:跨核唤醒
4.1 要解决的问题
当一个 vCPU 收到虚拟中断(例如另一个 guest 或定时器给它发 virq),它可能处于三种状态:正在本核运行、正在睡眠(WFI 后 idle)、或正在被调度。kick_vcpu(virt/vm.c:201-246)要保证:目标 vCPU 尽快回到 guest 里响应中断。对于不在本核的 vCPU,可能还要发一个物理 SGI 去打断它。
调用入口(virt/virq.c:51-56):
1 | static void inline virq_kick_vcpu(struct vcpu *vcpu, struct virq_desc *desc) |
send_virq(virq.c:80-118)在把 virq 置为 pending 后调用 virq_kick_vcpu。
上文是调用入口;下面贴出 kick_vcpu 完整代码,拆开它的四个分支,看它如何根据”同核/跨核 × ret × mode”决定要不要发 SGI。
4.2 四种情况
kick_vcpu 的核心逻辑(virt/vm.c:201-246):
1 | ret = wake(&vcpu->vcpu_event); // ① 先尝试唤醒事件 |
wake(core/event.c:132-154)返回 0(唤醒成功)或负值(唤醒失败)——负值说明任务不在等事件,即已在 ready/running 状态。四个分支:
| # | 条件 | 处理 |
|---|---|---|
| 1 | 同核(smp_processor_id() == vcpu_affinity) |
只 wake,不打断(同核无需 SGI,返回路径会自查 resched) |
| 2 | 跨核,ret < 0,mode != IN_ROOT_MODE |
vCPU 不在 EL2(在 guest 中或切换途中):发 resched SGI 打断 |
| 3 | 跨核,ret < 0,mode == IN_ROOT_MODE,非 NATIVE_WFI |
vCPU 正在 EL2 处理事务,返回路径会自查,无需 SGI |
| 4 | 跨核,ret < 0,mode == IN_ROOT_MODE,NATIVE_WFI |
额外补发一次 SGI,保证目标核能被物理中断唤醒 |
说明:第 2、3、4 种是互斥的(else if 只在前一条件为假时走到)。第 4 种只在本核真正是”root 模式 + native WFI”时才补发;若 vCPU 正在 EL1 执行 WFI,其 mode == IN_GUEST_MODE,会被第 2 种捕获,不走第 4 种。
以上是 vCPU 作为任务”何时被调度、如何被唤醒”的机制;接下来换到状态一侧——当它真的被切换走/切回时,GIC、vtimer、CPU 上下文这些虚拟化状态靠什么统一保存与恢复,这就是 vmodule。
5. vmodule 机制:虚拟化状态的统一保存/恢复
5.1 设计动机
一个 vCPU 在上下文切换时要保存/恢复多种互不相关的状态:CPU 的 EL1 系统寄存器、GICv3 的 List Register 与 VMCR、vtimer 的 CNTV 寄存器、VFP/NEON 寄存器……如果每种状态都往 switch_to_task 里塞一个 if 分支,代码会越来越乱、每加一个特性都要改调度器。
vmodule 把每种”虚拟化状态”抽象成一个模块,统一约定 state_save/restore/init/... 回调,调度器只调一个通用的遍历接口,加新状态只需新注册一个 vmodule。调度器与具体状态解耦。
5.2 结构定义与注册
struct vmodule(include/virt/vmodule.h:12-39):
1 | struct vmodule { |
关键成员:id 与 vcpu->context[] 下标一一对应(见 5.3);state_save/restore 是每次任务切换都要调的;state_init 是 VM 上线时初始化一次;context_size 决定给每个 vCPU 分配多大的上下文内存。
注册方式有两种:
- 链接期声明:
MINOS_MODULE_DECLARE(include/minos/device_id.h:55-61)把module_id放进__vmodulesection,开机时由vmodules_init(virt/vmodule.c:158-170)遍历 section 统一创建。已有:aarch64 vcpu context(arch_virt.c:361-362)、vfp(arch/aarch64/virt/vfp.c:107-116)。 - 运行期注册:
register_vcpu_vmodule(name, init_fn)(vmodule.c:62-74)。已有:gicv3-vmodule(virt/virq_chips/vgicv3.c:1072)、vgicv2(vgicv2.c:778)、vtimer_module(arch/aarch64/virt/vtimer.c:301-304)。
两种最终都走 create_vmodule(vmodule.c:30-60):分配 struct vmodule、id = vmodule_class_nr++、调用 init_fn 填充回调。
注意两点:
- 代码里想用
strcmp(name, "vcpu_core")把 CPU 上下文模块强制插到链表头(vmodule.c:54-57),但**当前没有任何模块叫vcpu_core**(arch 模块真名aarch64 vcpu context,arch_virt.c:361),该分支是死代码,意图未生效。 - 实际注册顺序由注册时机决定:
gicv3-vmodule最早(irq_init(),core/minos.c:68)、vtimer_module次之、链接期的aarch64 vcpu context/vfp最后,链表序为[gicv3, vtimer, aarch64 vcpu context, vfp]。
注册顺序决定了 vmodule->id 与 vcpu->context[] 的下标,也决定 save/restore 的遍历顺序(VCPU_VMODULE_ACTION 按链表头到尾遍历,save 与 restore 同序)。
以上注册的都是”模块类型”(全局一份链表);但状态是每个 vCPU 各自一份的——同一个 gicv3 模块,vcpu0 和 vcpu1 各需要一块自己的上下文内存。下面看这块内存怎么分配、何时初始化。
5.3 每 vCPU 的上下文分配
vcpu_vmodules_init(vmodule.c:81-116)在 vm_vcpus_init(vm.c:759-784)里被调用:
- 按
vmodule_class_nr分配vcpu->context指针数组(vm.h:77)。 - 遍历链表,为每个有
context_size的 vmodulemalloc一块独享内存,memset清零后调state_init(vcpu, data)。
之后任何地方都能用 get_vmodule_data_by_id(vcpu, id)(vmodule.c:76-79)取到某个模块的上下文——Phase 6 里 vtimer_handle_cntp_* 就是用它取 vtimer 上下文。
5.4 save/restore 的触发点
状态切换的桥接在 vm.c 与 arch.c 之间:
1 | void vcpu_context_save(struct task *task) /* vm.c:118-121 */ |
这两个函数被 arch_task_sched_out/arch_task_sched_in 调用(arch/aarch64/core/arch.c:140-164),而它们又由 switch_to_task(sched.c:267-319)调用。于是每次任务切换必然走 vmodule 遍历。
遍历本身由宏生成(vmodule.c:138-156):
1 |
|
一次生成 save/restore/reset/stop/suspend/resume/dump 七个遍历函数。
以 save_vcpu_vmodule_state 为例,它没有手写函数体——是 VCPU_VMODULE_ACTION(save)(vmodule.c:150)在编译期展开生成的。展开后等价于:
1 | void save_vcpu_vmodule_state(struct vcpu *vcpu) /* 由宏生成,vmodule.c:150 */ |
逻辑:遍历全局 vmodule_list(注册时建的链表),对每个模块若有 state_save 回调,就用 vcpu->context[vmodule->id] 作为该模块的私有上下文调用它。vmodule->id 正是 vcpu->context[] 的下标(见 5.2 注册顺序),所以每个模块从自己的 context 槽位存取状态。
完整切换时序:
5.5 各 vmodule 一览
| vmodule | 保存内容 | 位置 |
|---|---|---|
aarch64 vcpu context |
全部 EL1 系统寄存器 + HCR/VTCR/VTTBR 等 EL2 虚拟化寄存器 | arch_virt.c:239-349 |
gicv3-vmodule |
ICH_LR / APR / VMCR,进 guest 前填 LR | vgicv3.c:1043-1072 |
vtimer_module |
CNTV_CVAL/CTL,CNTVOFF_EL2 偏移 |
vtimer.c:97-130 |
vfp |
VFP/NEON 寄存器(state_init/save/restore) |
vfp.c:107-116 |
注意:state_init 由 vcpu_vmodules_init 在 VM 上线时调用一次;state_save/restore 每次任务切换都调用;state_stop 由 release_vcpu 在销毁时调用(vm.c:248-271)。
前面几章讲的都是单个 vCPU 在调度器里的行为;最后一个模块 vmcs 则是 vCPU 与 VM0 之间”跨 VM”的通信通道,回答”guest 如何请求 VM0 替它做事”。
6. vmcs:guest 与 VM0 的共享内存通信协议
6.1 为什么需要 vmcs
minos 采用”VM0 托管其他 VM”的模型:VM0(host Linux)通过 hypercall 创建/销毁 guest(virt/hypercall.c:28-107)。guest 运行中遇到”需要 VM0 协助”的事件(如访问由 VM0 模拟的设备寄存器、请求时间、请求重启),需要一种双向请求-应答机制:guest 把请求发给 VM0,VM0 处理后回传结果。这就是 vmcs(Virtual Machine Communication Structure)——一块同时映射到 guest 和 VM0 的共享内存。
6.2 vmcs 结构
struct vmcs(include/virt/vmcs.h:6-16),每个 vCPU 一块,__align(1024):
| 字段 | 作用 |
|---|---|
trap_type/reason/data |
请求的类别、原因、附加数据 |
trap_ret/result |
VM0 的回传结果 |
host_index |
guest 已提交的请求序号(guest 递增,vmcs.c:79) |
guest_index |
VM0 已处理完成的确认序号(VM0 递增,tools/mvm/main/mvm.c:424) |
data[0] |
请求携带的数据区(VMCS_DATA_SIZE = 1024-48) |
创建:vm_create_vmcs(vmcs.c:154-187)分配共享内存并映射进 VM0(create_hvm_shmem_map);vm_create_vmcs_irq(vmcs.c:189-205)给 vCPU 分配一个 hvm virq,用来通知 VM0”有新请求”。
6.3 请求-应答主流程
__vcpu_trap(vmcs.c:24-129)在 guest 需要 VM0 服务时被调用(MMIO trap 的 trap_mmio_* 等封装见 vmcs.h:58-84):
- 等上一笔请求完成:
while (vmcs->guest_index != vmcs->host_index)(vmcs.c:54-66)。循环内vcpu_affinity(vcpu) < vcpu_affinity(vm0->vcpus[0])时sched(),否则cpu_relax()。注释意图是”guest 与 VM0 vcpu0 绑在同一物理核时用sched()让 VM0 有机会运行、防死锁”(vmcs.c:49-53),注意实现用的是<比较而非==同核判断。 - 写请求:
trap_type/reason/data写入共享结构,vmcs->host_index++,smp_mb()(vmcs.c:68-79)。 - 通知 VM0:
send_virq_to_vm(vm0, vcpu->vmcs_irq)(vmcs.c:82),VM0 的驱动收中断、读 vmcs 处理。 - 等待应答:若
nonblock为 0,while (vmcs->guest_index != vmcs->host_index)等 VM0 处理完——VM0 侧的vmcs_ack每处理完一笔就guest_index++(tools/mvm/main/mvm.c:418-425),两者相等即本笔完成。真正判断”guest 与 VM0 vcpu0 同核”的是vmcs.c:97-98:相等时把nonblock强制置 0(必须阻塞等应答)。第二个循环内用vcpu_affinity(vcpu) < vm0->vcpu_nr决定sched()还是cpu_relax()(vmcs.c:105-124)——用核号与 vCPU 数比较,疑似源码笔误,语义上仍是”让同核 VM0 有机会运行”。 - 回传结果:
*result = vmcs->trap_result,返回trap_ret。
握手协议:host_index 是 guest 侧的”提交计数”(guest 每提交一笔 +1),guest_index 是 VM0 侧的”完成计数”(VM0 每处理完一笔 +1)。两者相等表示 guest 提交的请求都已处理完,即空闲、可发下一笔。SGI/sched 的引入是为了避免”guest 与 VM0 绑在同一物理核上”时的互相等待死锁。
一次请求-应答的完整时序:
7. 关键知识点
- vCPU = 任务:vCPU 没有 EL2 函数体,首调由
arch_vcpu_init设置 guest 入口 PC,经异常返回路径eret进 EL1;调度、睡眠、唤醒全部复用core/task.c+core/sched.c。 - mode 四态是锁步协议:
vcpu_return_to_user/vcpu_exit_from_user先置中间态 →do_hooks→ 置最终态,配合smp_wmb/smp_rmb保证顺序;安全性来自kick_vcpu对任何mode != IN_ROOT_MODE都发 resched,天然保守。 - kick_vcpu 的分支:同核只唤醒;
ret < 0 && mode != IN_ROOT_MODE发 resched SGI;ret < 0 && mode == IN_ROOT_MODE时仅 native WFI 场景额外补发一次 SGI,否则靠返回路径自查。 - vmodule 统一状态机:每种虚拟化状态一个模块,调度器只遍历回调,加新状态不改调度器;名为
vcpu_core的模块本应最先处理,但该名字当前无模块注册,实际注册序是 gicv3-vmodule 最先、CPU 上下文排第三,save/restore 按链表序同向遍历。 - 寄存器级上下文切换在异常帧完成:
switch_to_task不保存/恢复通用寄存器,只mov x18, next;真正换栈靠exception_return读next->stack_base+LOAD_GP_REGS。vCPU↔vCPU 切换与普通任务同机制,vmodule 只额外处理虚拟化状态(见 2.5)。 - 调度器用
cpu == -1做跨核同步:唤醒任务前自旋等task->cpu == -1,保证任务确实已 sched out。 - vmcs 是共享内存请求-应答协议:guest 写请求并
host_index++,VM0 处理完guest_index++,两值相等即空闲;send_virq_to_vm通知 VM0,与 VM0 同核时靠sched()让 VM0 有机会运行以防死锁(实现用<比较判断,见 6.3)。
7.1 代码文件关联速查表
本文各章讲到的机制主要落在几个关键文件(行号对应正文引用的关键位置):
| 文件 | 在本文的作用 | 关键位置 |
|---|---|---|
core/sched.c |
调度器主体:task_ready/__wake_up、pick_next_task、switch_to_task |
sched.c:267/163/226/638 |
core/task.c |
任务创建与进出 guest 钩子:create_vcpu_task、task_return/exit_from_user |
task.c:376/175/166 |
virt/vm.c |
vCPU 生命周期主线:create_vcpu、vcpu_online、kick_vcpu、mode 切点、vcpu_idle、亲和性 |
vm.c:320/201/294/307 |
virt/vmodule.c |
vmodule 机制:注册、vcpu_vmodules_init 分配 context、save/restore 遍历 |
vmodule.c:81/138 |
virt/vmcs.c |
vmcs 通信协议:__vcpu_trap 请求-应答 |
vmcs.c:24 |
arch/aarch64/core/vector.S |
异常入口/返回:SAVE_GP_REGS 压栈、exception_return 换栈 + LOAD_GP_REGS |
vector.S:120/145 |
arch/aarch64/virt/arch_virt.c |
每 vCPU 的 EL1/EL2 系统寄存器上下文 | arch_virt.c:239/284 |
其余辅助文件(
event.c/idle.c/percpu.h/task_def.h/virq.c/trap.c等)在正文引用处标注了行号,需要时按引用回查即可。