文章目录
前言
- X86平台内核对QEMU下发的中断处理大致分三部分:查路由表,递交IO到中断控制器直至LAPIC,寄存器注入。第一部分路由中断在前一章已经介绍,中断向量的传递涉及到8259中断控制器的模拟,IOAPIC中断控制器模拟和LAPIC控制器的模拟,本文首先根据控制器手册分析其工作原理,然后介绍其软件模拟的实现,和中断流程的模拟
中断信号处理路径
- X86平台处理中断信号分为四个阶段:
- 根据中断的gsi号查询中断路由表,找到对应的表项,取出handler,投递中断到IOAPIC
- IOAPIC根据输入的中断引脚索引查询重定向表,取出中断信号的目的cpu id和vector,投递中断到LAPIC
- LAPIC判断中断类型和向量号,将其写入vcpu中的IRR(Interrupt Request Register)寄存器
- 下一次VM-Entry进入时,检查IRR寄存器是否有中断请求,如果有,将IRR对应的bit转化为vector,写入vmcs区域的VM-Entry interrupt information区域

IOAPIC递交中断
- IOAPIC的中断流程处理,开始于
kvm_set_ioapic_irq,整个流程如下
kvm_set_ioapic_irq
kvm_ioapic_set_irq
ioapic_set_irq
ioapic_service
kvm_irq_delivery_to_apic
kvm_apic_set_irq
- 中断信息最终要传递lapic,这中间就是ioapic的处理流程,其中主要的工作在
ioapic_service完成
ioapic_service
kvm_ioapic_redirect_entry *entry = &ioapic->redirtbl[irq]
irqe.dest_id = entry->fields.dest_id;
irqe.vector = entry->fields.vector;
kvm_irq_delivery_to_apic(ioapic->kvm, NULL, &irqe, NULL)
kvm_for_each_vcpu(i, vcpu, kvm) {
if (!kvm_apic_match_dest(vcpu, src, irq->shorthand, irq->dest_id, irq->dest_mode))
continue;
if (!kvm_lowest_prio_delivery(irq)) {
if (r < 0)
r = 0;
r += kvm_apic_set_irq(vcpu, irq, dest_map)
__apic_accept_irq
首先将中断向量对应的重定向表项取出,重要的两个数据是目的cpu的id和向量号。然后调用继续传递中断信息,kvm_irq_delivery_to_apic中遍历每个vcpu,查看其apic id和重定向表中取出的id是否一样,如果一样,调用kvm_apic_set_irq继续传递中断信息,走到__apic_accept_irq,就是lapic负责的流程了
LAPIC处理
Make Request
- KVM LAPIC处理中断信号的流程开始于
__apic_accept_irq,该函数收到来自其它CPU或IOAPIC发来的中断向量。我们讨论普通的情况,收到来自IOAPIC的中断
static int __apic_accept_irq(struct kvm_lapic *apic, int delivery_mode,
int vector, int level, int trig_mode,
struct dest_map *dest_map)
switch (delivery_mode)
case APIC_DM_FIXED:
if (apic_test_vector(vector, apic->regs + APIC_TMR) != !!trig_mode) {
if (trig_mode)
kvm_lapic_set_vector(vector, apic->regs + APIC_TMR);
else
apic_clear_vector(vector, apic->regs + APIC_TMR);
}
if (vcpu->arch.apicv_active)
kvm_x86_ops->deliver_posted_interrupt(vcpu, vector);
else {
kvm_lapic_set_irr(vector, apic);
kvm_make_request(KVM_REQ_EVENT, vcpu);
kvm_vcpu_kick(vcpu);
}
- lapic递交中断信号的主要动作就是往它模拟的lapic的寄存器地址上,IRR对应的地方写1,之后标记vcpu上的request的对应bit,这里是event。注意,除了这种方式,如果硬件支持posted-interrupt方式deliver中断,需要优先考虑使用。lapic将内存中IRR上中断向量对应bit置1后,make request就完成了。之后就是唤醒vcpu或者将其从guest态踢出,让其再次进入guest态前能够处理中断,这个动作被称为Kick

Kick vCPU
- 被Kick的vCPU进程可能处于以下两种状态,Kick要分别进行处理:
- vCPU进程在睡眠状态,Kick函数需要将它唤醒,投入到vCPU所在物理CPU的运行队列中,等待调度,当vCPU被调度运行后进入guest态,响应注入的中断
- vCPU进程在运行状态,正在guest态运行,Kick函数发送核间中断触发VM-Exit,使vCPU退出guest态,同样被投入到运行队列中,等待调度,后面的流程同上
- 第二种情况需要利用LAPIC发送核间中断,这是LAPIC的另一个功能,这个功能主要是向其它cpu或自己发送中断,这种中断称为核间中断——IPI(Interprocessor Interrupt)
- 接下来介绍核间中断的工作方式,然后是如何Kick一个睡眠的vCPU进程,最后是如何Kick一个正在guest态运行的vCPU进程
核间中断
- 核间中断的发起最重要的一个寄存器叫ICR(interrupt command register),软件按照寄存器的使用规则往该寄存器中写信息,就可以发出IPI,ICR格式如下,截图来自Intel手册vol3-10.6

ICR中有几个重要的字段如下:
- Vector,IPI要发送的中断向量号
- Delivery Mode,描述发送中断的类型,以下中断类型可供选择
000 (Fixed):固定向量号的中断,这种就是普通的中断
001 (Lowest Priority):固定向量号的中断,和Fixed一样,但这种中断被硬件控制到优先级最低
010 (SMI):System Managment Interrupt,发送系统管理中断,这种中断要求Vector字段必须位0
100 (NMI):None-Maskable Interrupt,不可屏蔽中断,不可屏蔽中断的向量号是2,因此这里的Vector会被忽略
110 (Start Up):“Start-up” IPI,一种特殊的核间中断。该中断对应的ISR在BIOS启动的时候就被创建好了,这种投递方式硬件不保证信号准确送达到目的CPU,失败后不会重试,需要靠软件保证,kick vcpu通过这种方式实现 - Destination:存放中断要发往目的CPU或者CPU组,共8bit可以发往所有256个处理器
- 当ICR内容准备好之后,往其低16个字节写入内容,就会触发发送IPI的动作。
Kick Sleep vCPU
/*
* Kick a sleeping VCPU, or a guest VCPU in guest mode, into host kernel mode.
*/
void kvm_vcpu_kick(struct kvm_vcpu *vcpu)
{
int me;
int cpu = vcpu->cpu;
/* vcpu线程处于睡眠状态,唤醒 */
if (kvm_vcpu_wake_up(vcpu))
return;
/* 获取当前进程所在cpu的id,启动阶段由操作系统从lapic 的ID寄存器中读取 */
me = get_cpu();
/* 目标cpu不是当前进程所在cpu并且在线,满足条件后执行kick动作 */
if (cpu != me && (unsigned)cpu < nr_cpu_ids && cpu_online(cpu))
if (kvm_arch_vcpu_should_kick(vcpu))
smp_send_reschedule(cpu);
put_cpu();
}
kvm_vcpu_kick的核心功能就是让睡眠的,或者处在Guest态的vCPU进程,重新投入到运行队列中等待调度,如果目标vCPU在睡眠,那么它必然处在内核态,因此将其唤醒之后直接返回,唤醒函数的主要流程如下:
bool kvm_vcpu_wake_up(struct kvm_vcpu *vcpu)
{
......
wqp = kvm_arch_vcpu_wq(vcpu); /* 1 */
swake_up(wqp); /* 2 */
......
}
1. 获取当前vcpu的等待队列
2. 如果等待队列中有正在睡眠的进程,将队列中的第一个task唤醒
- 唤醒一个进程的实质就是将它的状态设置为
TASK_RUNNING并投入到运行队列中,等待调度器的调度。swake_up最终调用到wake_up_process函数,如下:
int wake_up_process(struct task_struct *p)
{
return try_to_wake_up(p, TASK_NORMAL, 0); /* 3 */
}
3. 将处于TASK_NORMAL状态的进程唤醒,TASK_NORMAL状态包括了可中断睡眠和不可中断睡眠
- 分析
try_to_wake_up的实现:
static int
try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
......
cpu = task_cpu(p); /* 4 */
cpu = select_task_rq(p, p->wake_cpu, SD_BALANCE_WAKE, wake_flags); /* 5 */
if (task_cpu(p) != cpu) { /* 6 */
wake_flags |= WF_MIGRATED;
set_task_cpu(p, cpu);
}
......
4. 获取进程上一次运行所在的cpu id
5. 选择进程要投入到哪个cpu上的运行队列,这里会调用具体的调度器select_task_rq方法进行选择,完成后,进程在运行队列中等待调度
6. 将上一次进程运行的cpu与这一次选择的cpu做比较,不同表示进程发生了迁移,标记以下,最后更新进程运行的cpu
- 到这里,原来处于睡眠中的vCPU进程就被放到了运行队列中,接下来就是等待调度时机的出现,将vCPU投入运行了,整个Kick过程结束
Kick in-guest vCPU
- 除了Kick睡眠的vCPU,还有一种情况是Kick运行的vCPU。整个过程由
smp_send_reschedule函数实现,其中会涉及到核间中断的发送,所以是体系结构相关的,每个体系结构定义了一套多核操作,X86上smp_send_reschedule由native_smp_send_reschedule函数,如下:
static inline void smp_send_reschedule(int cpu)
{
smp_ops.smp_send_reschedule(cpu);
}
struct smp_ops smp_ops = {
......
.smp_send_reschedule = native_smp_send_reschedule,
......
};
- 看名字,这个操作是通用接口,让目标物理CPU重新调度进程,为什么这个接口就能实现Kick guest vCPU的功能?看看里面的具体实现:
/*
* this function sends a 'reschedule' IPI to another CPU.
* it goes straight through and wastes no time serializing
* anything. Worst case is that we lose a reschedule ...
*/
static void native_smp_send_reschedule(int cpu)
{
......
apic->send_IPI_mask(cpumask_of(cpu), RESCHEDULE_VECTOR); /* 1 */
......
}
注释:该函数向其它CPU发送一个'重调度'的核间中断,这个中断不保证串行化,就是说,如果在这个中断到达目标CPU之前,已经有一个中断存在,这个中断可能会丢失
1. 调用本地APIC接口,发送'重调度'核间中断,它的向量号是RESCHEDULE_VECTOR(253)。说明,核间中断内核和IOAPIC发送的中断内容一样,有两个基本的组成:目标CPU的ID,中断向量号。当这个IPI成功发送到目标CPU之后,硬件会跳转到中断向量表,转入中断处理流程
- 本地APIC接口是一个静态的全局变量,它是一组APIC操作的驱动,系统在上电时根据平台不同进行初始化,操作中定义了发送核间中断的操作接口,如下:
struct apic {
......
/* ipi */
void (*send_IPI)(int cpu, int vector);
void (*send_IPI_mask)(const struct cpumask *mask, int vector);
......
}
- 一个具体的APIC操作驱动实现如下,我们选取
NumaConnect system举例,numachip_send_IPI_mask最终操作具体的LAPIC硬件寄存器ICR,完成核间中断的发送,之后就是目标CPU上的中断处理了
static const struct apic apic_numachip __refconst = {
.name = "NumaConnect system",
.send_IPI_mask = numachip_send_IPI_mask,
......
}
- 内核中针对APIC核间中断的中断向量表定义如下:
/*
* The APIC and SMP idt entries
*/
static const __initconst struct idt_data apic_idts[] = {
......
INTG(RESCHEDULE_VECTOR, reschedule_interrupt), /* 2 */
INTG(CALL_FUNCTION_VECTOR, call_function_interrupt),
......
};
2. 重调度中断向量为RESCHEDULE_VECTOR,中断处理函数是smp_reschedule_interrupt,它的主要功能就是让目标CPU上正在运行的进程调度出去,然后让调度器重新选择新进程投入运行
RESCHEDULE_VECTOR核间中断实际上被很多内核模块调用,而Kick函数之所以也用这个接口,是看中了它的两个功能,一是它发送了一条中断信息,二是这个中断的处理流程是让CPU上的调度器重新调度,首先第一个功能,就能让guest态的CPU退出,VMCS中的Pin-based VM-execution control字段的最低位可以设置外部中断请求到达时是否触发VM-exit,这个字段被称为external-interrupt exiting。通常,这个字段会被设置为1。参考Intel手册volume 3 24.6.1部分。有了这个硬件机制,就能让guest CPU退出。再说重调度的第二个功能,它会让目标CPU上的调度器从运行队列中重新选择进程,投入运行,这个进程,可能还是之前刚退出的vCPU进程,也可能是其它进程,这都不重要,我们的目的是让vCPU进程退出guest后再进入,从而发现被注入的中断。- 最后给出’重调度’中断处理例程,这里涉及内核调度器的机制,有空继续分析
- TODO
/*
* Reschedule call back. KVM uses this interrupt to force a cpu out of
* guest mode
*/
__visible void __irq_entry smp_reschedule_interrupt(struct pt_regs *regs)
{
ack_APIC_irq();
inc_irq_stat(irq_resched_count);
kvm_set_cpu_l1tf_flush_l1d();
if (trace_resched_ipi_enabled()) {
/*
* scheduler_ipi() might call irq_enter() as well, but
* nested calls are fine.
*/
irq_enter();
trace_reschedule_entry(RESCHEDULE_VECTOR);
scheduler_ipi();
trace_reschedule_exit(RESCHEDULE_VECTOR);
irq_exit();
return;
}
scheduler_ipi();
}
VM-Entry
硬件基础
- 中断注入最底层要依靠硬件实现,VMCS中VM-entry控制类字段,interruption-information是实现中断注入的基础,这个字段32bit,各字段含义如下,截图来自Intel手册vol3-24.8.3

- CPU进入客户态时,中断注入区域VM-entry interruption-information的加载,是在guest state区域加载之后——跳转到guest state中RIP指向的地址之前。实际上,中断注入的本质就是在硬件上deliver中断信息到CPU,而deliver的时机必须在CPU VM-entry建立好guest上下文,但还没开始运行的时间窗口里,这个时候deliver的中断信息,硬件查到的IDT就是guest的IDT,因为IDTR的加载是在guest state区域加载过程中完成的。
Request检查
- qemu通过vm ioctl命令字创建vcpu之后,获得了vcpu的fops,通过下发vcpu ioctl的KVM_RUN命令字,让内核运行该vcpu
kvm_vm_ioctl
switch (ioctl) {
case KVM_CREATE_VCPU:
kvm_vm_ioctl_create_vcpu(kvm, arg)
static struct file_operations kvm_vcpu_fops = {
.release = kvm_vcpu_release,
.unlocked_ioctl = kvm_vcpu_ioctl,
......
}
kvm_vcpu_ioctl
switch (ioctl) {
case KVM_RUN:
kvm_arch_vcpu_ioctl_run(vcpu, vcpu->run)
vcpu_run(vcpu)
for (;;) {
if (kvm_vcpu_running(vcpu)) {
r = vcpu_enter_guest(vcpu);
......
}
上面是一个vcpu从创建到使用的过程,进入vcpu_run之后,调用vcpu_enter_guest进入客户态,如果没有特殊的处理,vcpu就会一直在客户态待下去
vcpu_enter_guest在进入客户态前,会对当前的vcpu->requests做检查,看是否在上一轮进入客户态的时候,有别的cpu向自己发出了request,如果有,需要做相应的处理,vcpu_enter_guest中有这么一段代码
if (kvm_check_request(KVM_REQ_EVENT, vcpu) || req_int_win) {
++vcpu->stat.req_event;
kvm_apic_accept_events(vcpu);
if (vcpu->arch.mp_state == KVM_MP_STATE_INIT_RECEIVED) {
r = 1;
goto out;
}
/* 将request中等待的中断注入 */
if (inject_pending_event(vcpu, req_int_win) != 0)
硬件注入
在inject_pending_event中有一个vm_cpu_has_injectable_intr判断当前是否有可注入的中断,里面通过kvm_apic_has_interrupt检查当前vcpu上的irr中最高优先级的中断向量,判断是否可以注入
inject_pending_event
kvm_apic_has_interrupt
__apic_update_ppr
apic_has_interrupt_for_ppr
highest_irr = apic_find_highest_irr(apic)
__apic_update_ppr先根据TPR和ISRV更新PPR,apic_has_interrupt_for_ppr中寻找优先级最高的IRR,如果能找到,表示有中断需要注入,调用中断注入的实现
if (kvm_x86_ops->interrupt_allowed(vcpu)) {
kvm_queue_interrupt(vcpu, kvm_cpu_get_interrupt(vcpu), false);
kvm_x86_ops->set_irq(vcpu);
}
vmx_inject_irq
irq = vcpu->arch.interrupt.nr
intr = irq | INTR_INFO_VALID_MASK
intr |= INTR_TYPE_EXT_INTR
vmcs_write32(VM_ENTRY_INTR_INFO_FIELD, intr)
最终将中断信息写入vmcs对应区域,中断信息需要按照前面介绍的格式来组装,将interruption-information区域的32bit内容填满,首先是最高位的有效位,必须置1,然后是中断的类型写到[10-8],这里是外部中断,最后是中断向量本身。组装完成后,就可以往interruption-information区域写入了。VM_ENTRY_INTR_INFO_FIELD是interruption-information区域在VMCS整个区域的偏移
Q&A
Q:为什么Posted-Interrupt类型的中断注入,不需要CPU退出,就能达到中断退出的目的?
A:首先思考下为什么中断注入一定要让CPU退出,这是因为intel硬件设计使然。因为只有在VM-Entry这个时间硬件才检查VMCS域中的VM-entry interruption-information的字段,所以要让虚机先退出,然后复现一个VM-Entry的时机。
现在Intel提供了另外一种中断检查机制,通过VMCS域中的 Posted-Interrupt Descriptor字段就可以让Guest态的CPU感知到中断,并且这个字段Host可以直接写,不需要在VM-Entry时通过写VMCS间接设置。

1281

被折叠的 条评论
为什么被折叠?



