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_vcpucreate_vcpu_task(name, vm->entry_point, vm->vcpu_affinity[vcpu_id], ...)virt/vm.c:320-361),任务创建时标记为 TASK_FLAGS_VCPUcore/task.c:376-382)。
  • 任务切换时,hypervisor 要为”即将失去 CPU 的 guest”保存虚拟化状态:arch_task_sched_out 里调用 vcpu_context_savearch/aarch64/core/arch.c:140-151)。
  • 调度的”入/出 guest”由钩子完成:vcpu_return_to_user/vcpu_exit_from_uservirt/vm.c:294-318)在异常返回路径里切换 vcpu->mode 并触发中断注入钩子。

1.2 vCPU 任务的特殊性

普通任务有 task_func_t func(任务函数),vCPU 任务没有 EL2 侧的函数体:它第一次被调度时,直接从异常返回路径 eret 进 guest 的入口地址。所以 vCPU 任务的特殊之处在两点:

  1. 入口不是函数,而是 guest 入口 PCarch_vcpu_init 把 guest 入口地址填进 gp_regs->pcarch/aarch64/virt/arch_virt.c:108-129),任务栈顶向下挂一张 gp_regs,首次调度后异常返回直接进 guest。
  2. 切换时多保存一层虚拟化上下文。普通任务切换只保存通用寄存器(cpu_context + fpsimd),vCPU 任务还要保存 GIC 的 LR、vtimer 的 CNTV 寄存器、EL1 系统寄存器等——这正是第 5 章 vmodule 机制要解决的。

“vCPU = 任务”意味着系统里其实有两套调度在同时工作:guest 的 Linux 调度器在自己的虚拟 CPU 之间分派任务,minos 的调度器在物理核上分派 vCPU。两套调度操作的是不同的 CPU 集合(虚拟 vs 物理),呈嵌套关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  Linux 侧(虚拟 CPU = vCPU)          hypervisor 侧(物理核)    
┌──────────────────────────┐ ┌──────────────────────────┐
│ tasks: A B C │ │ pcpu 0 │
│ | │ │ pcpu 1 │
│ v │ │ ^ │
│ vCPU0 vCPU1 │ │ | │
│ ^ ^ │ │ | │
└──────────────────────────┘ └──────────────────────────┘
Linux 只改就绪链表 + 发虚拟 IPI
(任务放哪个 vCPU 由 Linux 定,可任意迁移)
任务从 CPU0 迁到 CPU1 只是换链表

minos 决定:vCPU 钉在哪个物理核、何时跑
两套调度嵌套,互不竞争

Linux 的”跨 CPU 迁移”只是把它自己的任务从 vCPU0 的链表挪到 vCPU1 的链表,并发一个 SGI 请 vCPU1 重新调度;真正在物理核上执行什么、执行多久,由 minos 调度器决定。

既然 vCPU 是一个普通任务,它就必须遵循这套任务与调度规则。下一章先补上这些基础,后面看 vCPU 时才能对上”它是在什么时候、以什么状态被调度的”。


2. 任务与调度器基础

2.1 任务的数据结构

struct taskinclude/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 任务的 pdatastruct 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 pcpuinclude/minos/percpu.h:18-54)。这些成员的关系可以用一张结构图概括(任务通过 task->state_list 挂到某条链表上;new_list/stop_list 是独立于 ready_list 的旁路):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──────────────────────────────────────────────────────────────────┐
│struct pcpu (one per physical cpu) │
│running_task idle_task │
│ │
│local_rdy_grp : bit7..bit0 │
│ │
│ready_list[0] -> [task B] -> [task C] -> NULL │
│ready_list[1] -> NULL │
│ready_list[2..6] ... │
│ready_list[7] -> [idle] -> NULL │
│ │
│new_list -> [task X] -> NULL │
│stop_list -> [task Y] -> NULL │
└──────────────────────────────────────────────────────────────────┘
  • 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_RECYCLEsched.c:242-244),本核的 kworker 任务(kworker_taskcore/idle.c:94-115)收 flag 后调用 pcpu_release_taskcore/idle.c:78-92)回收。
  • sched_timer:同级任务多于 1 个时启用时间片轮转。

队列结构定义好了,那任务怎么进队列?这由两个入口完成:task_ready(新任务/被唤醒的任务入队)和 __wake_up(唤醒睡眠中的任务)。

2.3 入队与唤醒

入队

task_ready 的两条路径(本核直接入队 / 跨核投递 + SGI 通知):

phase7_taskready

  • 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_irqwork SGI,由目标核 irqwork_handler(sched.c:425-469)取走真正入队。

关键点:跨核路径不直接操作目标核的就绪队列,而是放进 new_list(只有它需要自旋锁保护,percpu.h:36-37),再用 SGI 让目标核自己取走。这样目标核的就绪链表永远只被本核修改,无需全局锁。

唤醒

wait_eventinclude/minos/event.h:51-78)与 wakecore/event.c:132-154)是一对:任务自己 wait_event 睡眠,别人 wake 把它拉起。事件对象 event->data 存着等待它的任务,wake 取出该任务调 __wake_up

1
2
3
4
5
6
7
8
 睡眠侧(任务自己)                    唤醒侧(别的代码/别的核)
wait_event(&ev, cond, to) wake(&ev)
├─ __wait_event() ├─ get ev->data (task)
│ state -> WAIT_EVENT ├─ __wake_up(task, ...)
│ add to ev->wait_list │ ├─ task == current?
│ sched() sleep │ │ └─ wake_up_interrupted()
└─ continue after wake │ └─ wake_up_common()
└─ task_ready() re-enqueue

(睡眠侧:置 WAIT_EVENT → 挂等待链表 → sched() 让出;唤醒侧:wake 取任务调 __wake_up,中断里唤醒自己走 wake_up_interrupted,跨核唤醒走 wake_up_common。)

__wake_upsched.c:638-644)按”唤醒谁”分两条路:

  • 唤醒自己task == current,中断里发生)→ wake_up_interruptedsched.c:516):spin_trylock 试锁,直接把自己从 WAIT_EVENT 置回 RUNNING,不入队、就地继续。
  • 唤醒其他任务wake_up_commonsched.c:573-636):spin_lock 加锁,**自旋等 task->cpu == -1**(确保任务真的被 sched out,switch_to_taskcur->cpu = -1sched.c:300),状态置 WAKING,最后 task_ready 重新入队。

在 vCPU 场景,这个”事件”就是 vcpu->vcpu_eventvm.c:354 初始化):睡眠侧是 vcpu_idlevm.c:144wait_event,第 3.4 节讲);唤醒侧有三处——**kick_vcpuvm.c:212,给 vCPU 发 virq 时先唤醒)、vtimer 定时器到期arch/aarch64/virt/vtimer.c:94)、VM 电源恢复**(virt/vm_pm.c:57)。

唤醒流程(__wake_up 双分支 + wake_up_common 跨核同步):

phase7_wakeup

任务入队只是第一步——队列里有多个任务时,谁在什么时候被选中运行?这就是”调度点”要回答的:在哪几个时机触发选队,以及选中后如何切换。

2.4 调度的执行点

switch_to_tasksched.c:267-319)是上下文切换的核心,顺序固定:

  1. arch_task_sched_out(cur):保存当前任务(vCPU 任务在此触发 vmodule save,见第 5 章)。
  2. 处理当前任务状态:WAIT_EVENT 且有 delay 则启动延迟定时器;RUNNING 则置回 READY。
  3. cur->cpu = -1,通知其他核可以安全唤醒它。
  4. 切换到 next:置 RUNNING、set_current_taskarch_task_sched_in(next)(触发 vmodule restore)。

UML 时序图(从调度入口到 vmodule save/restore 的完整调用序列):

phase7_switch

真正的调度点有两个:

  • **sched()**(sched.c:339-350):主动让出,如 task_sleep/task_suspend/cond_resched
  • 异常返回路径__exception_return_handlersched.c:381-415)在 eret 前检查 __TIF_NEED_RESCHED,需要就 pick_next_task + switch_to_task

pick_next_tasksched.c:226-265)用 ffs_one_table[local_rdy_grp] 找最高非空优先级,取出队首任务并轮转(队首移到队尾)。

pick_next_task 选出了 next,接下来 switch_to_task 把当前任务换下来。但上文 2.4 的四步里看不到任何寄存器保存——这就引出下一节:真正的寄存器级切换其实不在这四步里,而在异常入口/返回的汇编层。

2.5 寄存器级上下文切换

细看 switch_to_tasksched.c:267-319)会发现一个容易困惑的点:它从头到尾没有保存/恢复任何通用寄存器(x0-x30),也没有切换栈指针。它只做了 set_current_task(next)——即 mov x18, nextinclude/minos/task_info.h:55-58x18 恒为 current task 指针)。

那寄存器级切换在哪完成?答案在异常入口与异常返回switch_to_task 只是”决定下一个是谁、改指针”,实际的换栈/恢复现场全在汇编层。完整时序:

phase7_ctxswitch

上面是”怎么切”的调用时序;下面看栈视角——被切走的任务,它的栈里到底压了什么,切换后如何换到新任务的栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
task A stack: saved by SAVE_GP_REGS when exception enters EL2
┌────────────────────────────────────────┐
│ stack_top (high addr) │
│ free area (sp was here) │
├────────────────────────────────────────┤
│ ELR_EL2 = guest pc (return addr) │
│ SPSR_EL2 = pstate before exception │
│ SP_EL0 = guest stack pointer │
│ x0 .. x29 = general purpose regs │
│ lr │
├────────────────────────────────────────┤
│ stack_base = sp after SAVE_GP_REGS │
│ (= TASK_STACK_OFFSET) │
│ stack_bottom (low addr) │
└────────────────────────────────────────┘
  • 任务栈从高地址向下生长;SAVE_GP_REGS(vector.S:55-83)把 ELR/SPSR/SP_EL0/x0-x29/lr 从低到高压到栈顶。
  • 压完后 sp 指向 stack_baseexception_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_statearch/aarch64/include/asm/tcb.h:47-59),arch_task_sched_in 再恢复。这是所有任务(含普通内核任务)都有的第二层,存在 struct task 结构体里(不是栈上):

1
2
3
4
5
6
7
8
9
10
struct task  (allocated via zalloc, NOT on the task stack)
┌────────────────────────────────────────────────────┐
│ ... │
│ union { pdata / vcpu } │
│ struct cpu_context cpu_context │
│ └─ struct fpsimd_context fpsimd_state │
│ regs[64] (q0..q31, 128-bit each) │
│ fpsr / fpcr │
│ fpexc32_el2 (32-bit vCPU only) │
└────────────────────────────────────────────────────┘

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vCPU extra state: vmodule save/restore, stored in vcpu->context[]
order = registration order (gicv3 first, then vtimer, then linker modules)
┌──────────────────────────────────────────────────────────┐
│ vcpu->context[0] = gicv3-vmodule │
│ ICH_LR0..15 / APR / VMCR │
│ gicv3_state_save/restore (vgicv3.c:1043-1072) │
│ │
│ vcpu->context[1] = vtimer_module │
│ CNTV_CVAL_EL0 / CNTV_CTL_EL0 │
│ vtimer_state_save/restore (vtimer.c:110/97) │
│ │
│ vcpu->context[2] = aarch64 vcpu context │
│ vbar_el1/sctlr_el1/tcr_el1/ttbr0/1_el1 │
│ hcr_el2/vttbr_el2/vtcr_el2/sp_el1 ... │
│ arch_vcpu_state_save/restore (arch_virt.c:239/284) │
│ │
│ vcpu->context[3] = vfp │
│ VFP / NEON registers │
│ vfp_state_save/restore (vfp.c:44/77) │
└──────────────────────────────────────────────────────────┘

这些模块在开机时注册(gicv3-vmodule 经 gicv3 驱动、vtimer_module 经定时器驱动、链接期 aarch64 vcpu context/vfpvmodules_init,见 5.2 节);save_vcpu_vmodule_state 的具体实现见 5.4 节。

三个关键理解:

  1. stack_base 就是”异常返回帧”的位置。每次异常/sched() 进 EL2,现场被压到当前任务栈顶,sp 存入 task->stack_baseasm-offset.c:31TASK_STACK_OFFSET 就是这个字段)。换栈的本质就是换一个 stack_base

  2. switch_to_task 不碰寄存器是设计使然。它运行在异常处理上下文里,当前任务的现场已经在栈上了(由 SAVE_GP_REGS 压好);它只需要把 x18 指向下一个任务,返回后汇编层读新任务的 stack_base 换栈即可。这样上下文切换的”脏活”集中在一处汇编,C 层调度器保持简洁。

  3. 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_vcpuvm->vcpu_affinity[vcpu_id] 传给 create_vcpu_task。亲和性决定三件事:任务在哪个核就绪、kick_vcpu 往哪个核发 SGI、task_ready 走本核还是跨核路径(sched.c:179-185)。

VM 的亲和性来源:

  1. 静态配置:VM 的 dts/vmtag 里写死 vcpu_affinity[VM_MAX_VCPU]__create_vmmemcpy(vm->vcpu_affinity, vme->vcpu_affinity, ...)vm.c:865-866),vm_check_vcpu_affinity 校验不重复、不越界(vm.c:164-180)。
  2. 动态分配VM_FLAGS_DYNAMIC_AFF 置位时,vmtag_check_and_configget_vcpu_affinityvm.c:466-469),从全局位图 vcpu_aff_bitmap 上挑空闲核。vcpu_affinity_initvm.c:381-403)在开机时把已声明 VM 占用的核记进位图,并记录已占用核数 native_vcpus(空闲核数 = NR_CPUS - native_vcpusget_vcpu_affinity 正是用这个差值判断剩余核是否够用,vm.c:415)。

2.7 跨核打断

  • pcpu_resched(pcpu_id)sched.c:120-123):给目标核发 CONFIG_MINOS_RESCHED_IRQ SGI,让目标核从 guest/异常返回路径检查重调度。
  • pcpu_irqwork(pcpu_id)sched.c:125-128):发 CONFIG_MINOS_IRQWORK_IRQ,目标核的 irqwork_handler 处理 new_list

两个 SGI handler 在 local_sched_initsched.c:477-490)注册。

这两个 SGI 是第 2.3 节跨核入队(smp_percpu_task_readypcpu_irqwork)和后面第 4 章 kick_vcpu 跨核唤醒的底层通道:**目标核收到 SGI 后,在异常返回路径检查 resched 标志或处理 new_list**。


3. vCPU 的创建与生命周期

有了任务与调度器的基础,就可以看 vCPU 这一特殊任务是如何被创建、唤醒,以及它的状态如何随 guest 进出而流转的。先看创建流程。

3.1 创建流程

create_vmvirt/vm.c:885-937)→ create_vcpusvm.c:786-807)→ 逐个 create_vcpuvm.c:320-361):

1
2
3
4
task = create_vcpu_task(name, vm->entry_point,
vm->vcpu_affinity[vcpu_id], 0, NULL);
task->return_to_user = vcpu_return_to_user;
task->exit_from_user = vcpu_exit_from_user;

create_vcpu_taskcore/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_MODEvm.c:347),vcpu_event 初始化为 OS_EVENT_TYPE_NORMALvm.c:354)。

UML 时序图(create_vm → create_vcpu 的逐层调用):

phase7_create

注意创建阶段 vCPU 任务尚未进入就绪队列NO_AUTO_START 让它只是”存在”,真正上线要等第 3.2 节的 vcpu_online

也就是说,创建只是把 vCPU 任务”造出来”,还躺在一边;让它进入就绪队列、真正可被调度,是”上线”这一步做的事。

3.2 任务上线与启动时机

vcpu_onlinevm.c:63-67):

1
2
ASSERT(vcpu_is_offline(vcpu));
task_ready(vcpu->task, 0);

vcpu_is_offline 检查 VCPU_STATE_SUSPEND。启动路径:do_start_vmvm.c:519-537)只对 vcpu0 调 vcpu_online;多 vCPU 的后续启动靠 guest 内部通过 SGI/psci 触发 vcpu_power_onvm.c:90-116)。

上线后 vCPU 任务开始在调度器里运行,但它会反复在”执行 guest 代码”和”陷入 EL2 处理事务”之间切换。vCPU 当前处于哪一侧、切换进行到哪一步,用一个字段就能表达——这就是 mode 状态机。它对第 4 章 kick_vcpu 的跨核判断也至关重要。

3.3 mode 四态状态机

struct vcpumode 字段(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void vcpu_return_to_user(struct task *task, gp_regs *regs)
{
struct vcpu *vcpu = (struct vcpu *)task->pdata;

vcpu->mode = OUTSIDE_ROOT_MODE;
smp_wmb();

do_hooks(vcpu, (void *)regs, OS_HOOK_ENTER_TO_GUEST);

smp_wmb();
vcpu->mode = IN_GUEST_MODE;
}

static void vcpu_exit_from_user(struct task *task, gp_regs *regs)
{
struct vcpu *vcpu = (struct vcpu *)task->pdata;

vcpu->mode = OUTSIDE_GUEST_MODE;
smp_wmb();

do_hooks(vcpu, (void *)regs, OS_HOOK_EXIT_FROM_GUEST);

smp_wmb();
vcpu->mode = IN_ROOT_MODE;
}

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
2
eret 进 guest 前  → task_return_to_user(core/task.c:175-196)→ vcpu_return_to_user
guest 异常进入 EL2 → task_exit_from_user(core/task.c:166-173)→ vcpu_exit_from_user

UML 时序图(进出 guest 时 mode 的完整切换调用序列,含中断注入/回收钩子):

phase7_modecall

两张 mermaid 状态图概括:

phase7_mode

3.4 guest 睡眠与 vcpu_idle

guest 执行 WFI 会被 trap 到 EL2(arch_vcpu_state_initHCR_EL2.TWI 默认置位,arch_virt.c:198-199),进入 wfi_wfe_handlerarch/aarch64/virt/trap.c:47-51):

1
2
3
4
5
static int wfi_wfe_handler(gp_regs *reg, int ec, uint32_t esr_value)
{
vcpu_idle(get_current_vcpu());
return 0;
}

vcpu_idlevm.c:142-145):

1
2
3
4
int vcpu_idle(struct vcpu *vcpu)
{
return wait_event(&vcpu->vcpu_event, vcpu_can_idle(vcpu), 0);
}

wait_eventinclude/minos/event.h:51-78)把当前任务状态置 WAIT_EVENT 并挂到 vcpu_event 的等待链表,然后 sched() 让出 CPU。vcpu_can_idlevm.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_tasksched.c:226)会在本核就绪队列里选下一个最高优先级任务接着跑——可能是另一个 vCPU、VM0 的内核任务,或 idle 任务(第 2.4 节)。所以:

1
2
3
4
guest 执行 WFI → trap 到 EL2 → vcpu_idle → wait_event
└─ sched() 让出 vCPU 任务(状态置 WAIT_EVENT,从就绪队列移除)
└─ pick_next_task 选出下一个任务,物理核继续跑别人
└─ 直到 kick_vcpu 唤醒该 vCPU,它重新入队、被调度回来

vCPU 睡眠只是”这个 vCPU 任务不占 CPU 了”,物理核不会闲着——它被调度器分给其他任务。这正是”vCPU = 任务”的意义:guest 的空闲(WFI)通过 sched() 转化为本核 CPU 的高效复用。

睡眠与唤醒的完整环路:

phase7_idle

WFI 被 trap 的场景VM_FLAGS_NATIVE_WFI 置位时(arch_virt.c:198TWI 不置位,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_vcpuvirt/vm.c:201-246)要保证:目标 vCPU 尽快回到 guest 里响应中断。对于不在本核的 vCPU,可能还要发一个物理 SGI 去打断它。

调用入口(virt/virq.c:51-56):

1
2
3
4
5
static void inline virq_kick_vcpu(struct vcpu *vcpu, struct virq_desc *desc)
{
kick_vcpu(vcpu, virq_is_hw(desc) ?
VCPU_KICK_REASON_HIRQ: VCPU_KICK_REASON_VIRQ);
}

send_virqvirq.c:80-118)在把 virq 置为 pending 后调用 virq_kick_vcpu

上文是调用入口;下面贴出 kick_vcpu 完整代码,拆开它的四个分支,看它如何根据”同核/跨核 × ret × mode”决定要不要发 SGI。

4.2 四种情况

kick_vcpu 的核心逻辑(virt/vm.c:201-246):

1
2
3
4
5
6
7
8
9
10
11
12
ret = wake(&vcpu->vcpu_event);        // ① 先尝试唤醒事件

if (smp_processor_id() == vcpu_affinity(vcpu))
return ret; // ② 同核:只唤醒即可

mode = vcpu->mode; // ③ 读目标 vCPU 状态
smp_rmb();

if ((ret < 0) && (mode != IN_ROOT_MODE)) // ④ 分支 A
pcpu_resched(vcpu_affinity(vcpu));
else if ((ret < 0) && (vcpu->vm->flags & VM_FLAGS_NATIVE_WFI)) // ⑤ 分支 B
pcpu_resched(vcpu_affinity(vcpu));

wakecore/event.c:132-154)返回 0(唤醒成功)或负值(唤醒失败)——负值说明任务不在等事件,即已在 ready/running 状态。四个分支:

# 条件 处理
1 同核(smp_processor_id() == vcpu_affinity wake,不打断(同核无需 SGI,返回路径会自查 resched)
2 跨核,ret < 0mode != IN_ROOT_MODE vCPU 不在 EL2(在 guest 中或切换途中):发 resched SGI 打断
3 跨核,ret < 0mode == IN_ROOT_MODE,非 NATIVE_WFI vCPU 正在 EL2 处理事务,返回路径会自查,无需 SGI
4 跨核,ret < 0mode == 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 vmoduleinclude/virt/vmodule.h:12-39):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct vmodule {
char name[32]; /* 模块名 */
int id; /* 模块 id,即 vcpu->context[] 的下标 */
struct list_head list; /* 挂到全局 vmodule_list */
uint32_t context_size; /* 每个 vCPU 独享的上下文大小 */

/* 9 个状态回调,由各模块实现 */
void (*state_save)(struct vcpu *vcpu, void *context);
void (*state_restore)(struct vcpu *vcpu, void *context);
void (*state_init)(struct vcpu *vcpu, void *context);
void (*state_deinit)(struct vcpu *vcpu, void *context);
void (*state_reset)(struct vcpu *vcpu, void *context);
void (*state_stop)(struct vcpu *vcpu, void *context);
void (*state_suspend)(struct vcpu *vcpu, void *context);
void (*state_resume)(struct vcpu *vcpu, void *context);
void (*state_dump)(struct vcpu *vcpu, void *context);
};

关键成员:idvcpu->context[] 下标一一对应(见 5.3);state_save/restore 是每次任务切换都要调的;state_init 是 VM 上线时初始化一次;context_size 决定给每个 vCPU 分配多大的上下文内存。

注册方式有两种:

  1. 链接期声明MINOS_MODULE_DECLAREinclude/minos/device_id.h:55-61)把 module_id 放进 __vmodule section,开机时由 vmodules_initvirt/vmodule.c:158-170)遍历 section 统一创建。已有:aarch64 vcpu contextarch_virt.c:361-362)、vfparch/aarch64/virt/vfp.c:107-116)。
  2. 运行期注册register_vcpu_vmodule(name, init_fn)vmodule.c:62-74)。已有:gicv3-vmodulevirt/virq_chips/vgicv3.c:1072)、vgicv2vgicv2.c:778)、vtimer_modulearch/aarch64/virt/vtimer.c:301-304)。

两种最终都走 create_vmodulevmodule.c:30-60):分配 struct vmoduleid = vmodule_class_nr++、调用 init_fn 填充回调。

注意两点:

  • 代码里想用 strcmp(name, "vcpu_core") 把 CPU 上下文模块强制插到链表头(vmodule.c:54-57),但**当前没有任何模块叫 vcpu_core**(arch 模块真名 aarch64 vcpu contextarch_virt.c:361),该分支是死代码,意图未生效。
  • 实际注册顺序由注册时机决定:gicv3-vmodule 最早(irq_init()core/minos.c:68)、vtimer_module 次之、链接期的 aarch64 vcpu context/vfp 最后,链表序为 [gicv3, vtimer, aarch64 vcpu context, vfp]

注册顺序决定了 vmodule->idvcpu->context[] 的下标,也决定 save/restore 的遍历顺序(VCPU_VMODULE_ACTION 按链表头到尾遍历,save 与 restore 同序)。

以上注册的都是”模块类型”(全局一份链表);但状态是每个 vCPU 各自一份的——同一个 gicv3 模块,vcpu0 和 vcpu1 各需要一块自己的上下文内存。下面看这块内存怎么分配、何时初始化。

5.3 每 vCPU 的上下文分配

vcpu_vmodules_initvmodule.c:81-116)在 vm_vcpus_initvm.c:759-784)里被调用:

  • vmodule_class_nr 分配 vcpu->context 指针数组(vm.h:77)。
  • 遍历链表,为每个有 context_size 的 vmodule malloc 一块独享内存,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.carch.c 之间:

1
2
3
4
5
6
7
8
9
void vcpu_context_save(struct task *task)    /* vm.c:118-121 */
{
save_vcpu_vmodule_state(task_to_vcpu(task));
}

void vcpu_context_restore(struct task *task) /* vm.c:123-126 */
{
restore_vcpu_vmodule_state(task_to_vcpu(task));
}

这两个函数被 arch_task_sched_out/arch_task_sched_in 调用(arch/aarch64/core/arch.c:140-164),而它们又由 switch_to_tasksched.c:267-319)调用。于是每次任务切换必然走 vmodule 遍历。

遍历本身由宏生成(vmodule.c:138-156):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define VCPU_VMODULE_ACTION(action)				\
void action##_vcpu_vmodule_state(struct vcpu *vcpu) \
{ \
struct vmodule *vmodule; \
list_for_each_entry(vmodule, &vmodule_list, list) {\
if (vmodule->state_##action) { \
vmodule->state_##action(vcpu, \
vcpu->context[vmodule->id]);\
} \
} \
}

VCPU_VMODULE_ACTION(save)
VCPU_VMODULE_ACTION(restore)
VCPU_VMODULE_ACTION(reset)
...

一次生成 save/restore/reset/stop/suspend/resume/dump 七个遍历函数。

save_vcpu_vmodule_state 为例,它没有手写函数体——是 VCPU_VMODULE_ACTION(save)(vmodule.c:150)在编译期展开生成的。展开后等价于:

1
2
3
4
5
6
7
8
9
10
void save_vcpu_vmodule_state(struct vcpu *vcpu)   /* 由宏生成,vmodule.c:150 */
{
struct vmodule *vmodule;
list_for_each_entry(vmodule, &vmodule_list, list) {
if (vmodule->state_save) {
vmodule->state_save(vcpu,
vcpu->context[vmodule->id]);
}
}
}

逻辑:遍历全局 vmodule_list(注册时建的链表),对每个模块若有 state_save 回调,就用 vcpu->context[vmodule->id] 作为该模块的私有上下文调用它。vmodule->id 正是 vcpu->context[] 的下标(见 5.2 注册顺序),所以每个模块从自己的 context 槽位存取状态。

完整切换时序:

phase7_vmodule

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_initvcpu_vmodules_init 在 VM 上线时调用一次;state_save/restore 每次任务切换都调用;state_stoprelease_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 vmcsinclude/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_vmcsvmcs.c:154-187)分配共享内存并映射进 VM0(create_hvm_shmem_map);vm_create_vmcs_irqvmcs.c:189-205)给 vCPU 分配一个 hvm virq,用来通知 VM0”有新请求”。

6.3 请求-应答主流程

__vcpu_trapvmcs.c:24-129)在 guest 需要 VM0 服务时被调用(MMIO trap 的 trap_mmio_* 等封装见 vmcs.h:58-84):

  1. 等上一笔请求完成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),注意实现用的是 < 比较而非 == 同核判断。
  2. 写请求trap_type/reason/data 写入共享结构,vmcs->host_index++smp_mb()vmcs.c:68-79)。
  3. 通知 VM0send_virq_to_vm(vm0, vcpu->vmcs_irq)vmcs.c:82),VM0 的驱动收中断、读 vmcs 处理。
  4. 等待应答:若 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 有机会运行”。
  5. 回传结果*result = vmcs->trap_result,返回 trap_ret

握手协议:host_index 是 guest 侧的”提交计数”(guest 每提交一笔 +1),guest_index 是 VM0 侧的”完成计数”(VM0 每处理完一笔 +1)。两者相等表示 guest 提交的请求都已处理完,即空闲、可发下一笔。SGI/sched 的引入是为了避免”guest 与 VM0 绑在同一物理核上”时的互相等待死锁。

一次请求-应答的完整时序:

phase7_vmcs


7. 关键知识点

  1. vCPU = 任务:vCPU 没有 EL2 函数体,首调由 arch_vcpu_init 设置 guest 入口 PC,经异常返回路径 eret 进 EL1;调度、睡眠、唤醒全部复用 core/task.c + core/sched.c
  2. mode 四态是锁步协议vcpu_return_to_user/vcpu_exit_from_user 先置中间态 → do_hooks → 置最终态,配合 smp_wmb/smp_rmb 保证顺序;安全性来自 kick_vcpu 对任何 mode != IN_ROOT_MODE 都发 resched,天然保守。
  3. kick_vcpu 的分支:同核只唤醒;ret < 0 && mode != IN_ROOT_MODE 发 resched SGI;ret < 0 && mode == IN_ROOT_MODE 时仅 native WFI 场景额外补发一次 SGI,否则靠返回路径自查。
  4. vmodule 统一状态机:每种虚拟化状态一个模块,调度器只遍历回调,加新状态不改调度器;名为 vcpu_core 的模块本应最先处理,但该名字当前无模块注册,实际注册序是 gicv3-vmodule 最先、CPU 上下文排第三,save/restore 按链表序同向遍历。
  5. 寄存器级上下文切换在异常帧完成switch_to_task 不保存/恢复通用寄存器,只 mov x18, next;真正换栈靠 exception_returnnext->stack_base + LOAD_GP_REGS。vCPU↔vCPU 切换与普通任务同机制,vmodule 只额外处理虚拟化状态(见 2.5)。
  6. 调度器用 cpu == -1 做跨核同步:唤醒任务前自旋等 task->cpu == -1,保证任务确实已 sched out。
  7. 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_uppick_next_taskswitch_to_task sched.c:267/163/226/638
core/task.c 任务创建与进出 guest 钩子:create_vcpu_tasktask_return/exit_from_user task.c:376/175/166
virt/vm.c vCPU 生命周期主线create_vcpuvcpu_onlinekick_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 等)在正文引用处标注了行号,需要时按引用回查即可。