深度探索Linux系统虚拟化:原理与实现
上QQ阅读APP看书,第一时间看更新

1.3.2 特殊指令

有一些指令从机制上可以直接在Guest模式下本地运行,但是其在虚拟化上下文的语义与非虚拟化下完全不同。比如cpuid指令,在虚拟化上下文运行这条指令时,其本质上并不是获取物理CPU的特性,而是获取VCPU的特性;再比如hlt指令,在虚拟化上下文运行这条指令时,其本质上并不是停止物理CPU的运行,而是停止VCPU的运行。所以,这种指令需要陷入KVM进行模拟,而不能在Guest模式下本地运行。在这一节,我们以这两个指令为例,讨论这两个指令的模拟。

1.cpuid指令模拟

cpuid指令会返回CPU的特性信息,如果直接在Guest模式下运行,获取的将是宿主机物理CPU的各种特性,但是实际上,通过一个线程模拟的CPU的特性与物理CPU可能会有很大差别。比如,因为KVM在指令、设备层面通过软件方式进行了模拟,所以这个模拟的CPU可能要比物理CPU支持更多的特性。再比如,对于虚拟机而言,其可能在不同宿主机、不同集群之间迁移,因此也需要从虚拟化层面给出一个一致的CPU特性,所以cpuid指令需要陷入VMM特殊处理。

Intel手册中对cpuid指令的描述如表1-2所示。

表1-2 cpuid指令

cpuid指令使用eax寄存器作为输入参数,有些情况也需要使用ecx寄存器作为输入参数。比如,当eax为0时,在执行完cpuid指令后,eax中包含的是支持最大的功能(function)号,ebx、ecx、edx中是CPU制造商的ID;当eax值为2时,执行cpuid指令后,将在寄存器eax、ebx、ecx、edx中返回包括TLB、Cache、Prefetch的信息;再比如,当eax值为7,ecx值为0时,将在寄存器eax、ebx、ecx、edx中返回处理器扩展特性。

起初,KVM的用户空间通过cpuid指令获取Host的CPU特征,加上用户空间的配置,定义好VCPU支持的CPU特性,传递给KVM内核模块。KVM模块在内核中定义了接收来自用户空间定义的CPU特性的结构体:


commit 06465c5a3aa9948a7b00af49cd22ed8f235cdb0f
KVM: Handle cpuid in the kernel instead of punting to userspace
linux.git/include/linux/kvm.h
struct kvm_cpuid_entry {
    __u32 function;
    __u32 eax;
    __u32 ebx;
    __u32 ecx;
    __u32 edx;
    __u32 padding;
};

用户空间按照如下结构体kvm_cpuid的格式组织好CPU特性后,通过如下KVM模块提供的接口传递给KVM内核模块:


commit 06465c5a3aa9948a7b00af49cd22ed8f235cdb0f
KVM: Handle cpuid in the kernel instead of punting to userspace
 linux.git/include/linux/kvm.h
/* for KVM_SET_CPUID */
struct kvm_cpuid {
    __u32 nent;
    __u32 padding;
    struct kvm_cpuid_entry entries[0];
};

linux.git/drivers/kvm/kvm_main.c
static long kvm_vcpu_ioctl(struct file *filp,
               unsigned int ioctl, unsigned long arg)
{
    …
    case KVM_SET_CPUID: {
        struct kvm_cpuid __user *cpuid_arg = argp;
        struct kvm_cpuid cpuid;
        …
        if (copy_from_user(&cpuid, cpuid_arg, sizeof cpuid))
            goto out;
        r = kvm_vcpu_ioctl_set_cpuid(vcpu, &cpuid, 
cpuid_arg->entries);
    …
}

static int kvm_vcpu_ioctl_set_cpuid(struct kvm_vcpu *vcpu,
                    struct kvm_cpuid *cpuid,
                    struct kvm_cpuid_entry __user *entries)
{
    …
    if (copy_from_user(&vcpu->cpuid_entries, entries,
               cpuid->nent * sizeof(struct kvm_cpuid_entry)))
    …
}

KVM内核模块将用户空间组织的结构体kvm_cpuid复制到内核的结构体kvm_cpuid_entry实例中。首次读取时并不确定entry的数量,所以第1次读取结构体kvm_cpuid,其中的字段nent包含了entry的数量,类似读消息头。获取了entry的数量后,再读结构体中包含的entry。所以从用户空间到内核空间的复制执行了两次。

事实上,除了硬件支持的CPU特性外,KVM内核模块还提供了一些软件方式模拟的特性,所以用户空间仅从硬件CPU读取特性是不够的。为此,KVM后来实现了2.0版本的cpuid指令的模拟,即cpuid2,在这个版本中,KVM内核模块为用户空间提供了接口,用户空间可以通过这个接口获取KVM可以支持的CPU特性,其中包括硬件CPU本身支持的特性,也包括KVM内核模块通过软件方式模拟的特性,用户空间基于这个信息构造VCPU的特征。具体内容我们就不展开介绍了。

在Guest执行cpuid指令发生VM exit时,KVM会根据eax中的功能号以及ecx中的子功能号,从kvm_cpuid_entry实例中索引到相应的entry,使用entry中的eax、ebx、ecx、edx覆盖结构体vcpu中的数组regs中相应的字段。当再次切入Guest时,KVM会将它们加载到物理CPU的通用寄存器,这样在进入Guest后,Guest就可以从这几个寄存器读取CPU相关信息和特性。相关代码如下:


commit 06465c5a3aa9948a7b00af49cd22ed8f235cdb0f
KVM: Handle cpuid in the kernel instead of punting to userspace
void kvm_emulate_cpuid(struct kvm_vcpu *vcpu)
{
    int i;
    u32 function;
    struct kvm_cpuid_entry *e, *best;
    …
    function = vcpu->regs[VCPU_REGS_RAX];
    …
    for (i = 0; i < vcpu->cpuid_nent; ++i) {
        e = &vcpu->cpuid_entries[i];
        if (e->function == function) {
            best = e;
            break;
        }
        …
    }
    if (best) {
        vcpu->regs[VCPU_REGS_RAX] = best->eax;
        vcpu->regs[VCPU_REGS_RBX] = best->ebx;
        vcpu->regs[VCPU_REGS_RCX] = best->ecx;
        vcpu->regs[VCPU_REGS_RDX] = best->edx;
    }
    …
    kvm_arch_ops->skip_emulated_instruction(vcpu);
}

最后,我们以一段用户空间处理cpuid的过程为例结束本节。假设我们虚拟机所在的集群由小部分支持AVX2的和大部分不支持AVX2的机器混合组成,为了可以在不同类型的Host之间迁移虚拟机,我们计划CPU的特征不支持AVX2指令。我们首先从KVM内核模块获取其可以支持的CPU特征,然后清除AVX2指令的支持,代码大致如下:


struct kvm_cpuid2 *kvm_cpuid;

kvm_cpuid = (struct kvm_cpuid2 *)malloc(sizeof(*kvm_cpuid) +
      CPUID_ENTRIES * sizeof(*kvm_cpuid->entries));
kvm_cpuid->nent = CPUID_ENTRIES;
ioctl(vcpu_fd, KVM_GET_SUPPORTED_CPUID, kvm_cpuid);

for (i = 0; i < kvm_cpuid->nent; i++) {
  struct kvm_cpuid_entry2 *entry = &kvm_cpuid->entries[i];

  if (entry->function == 7) {
    /* Clear AVX2 */
    entry->ebx &= ~(1 << 6);
    break;
  };
}

ioctl(vcpu_fd, KVM_SET_CPUID2, kvm_cpuid);

2.hlt指令模拟

当处理器执行hlt指令后,将处于停机状态(Halt)。对于开启了超线程的处理器,hlt指令是停止的逻辑核。之后如果收到NMI、SMI中断,或者reset信号等,则恢复运行。但是,对于虚拟机而言,如果任凭Guest的某个核本地执行hlt,将导致物理CPU停止运行,然而我们需要停止的只是Host中用于模拟CPU的线程。因此,Guest执行hlt指令时需要陷入KVM中,由KVM挂起VCPU对应的线程,而不是停止物理CPU:


commit b6958ce44a11a9e9425d2b67a653b1ca2a27796f
KVM: Emulate hlt in the kernel
linux.git/drivers/kvm/vmx.c
static int handle_halt(struct kvm_vcpu *vcpu, …)
{
    skip_emulated_instruction(vcpu);
    return kvm_emulate_halt(vcpu);
}

linux.git/drivers/kvm/kvm_main.c
int kvm_emulate_halt(struct kvm_vcpu *vcpu)
{
    …
        kvm_vcpu_kernel_halt(vcpu);
    …
}

static void kvm_vcpu_kernel_halt(struct kvm_vcpu *vcpu)
{
    …
    while(!(irqchip_in_kernel(vcpu->kvm) &&
          kvm_cpu_has_interrupt(vcpu))
          && !vcpu->irq_summary
          && !signal_pending(current)) {
        set_current_state(TASK_INTERRUPTIBLE);
        …
        schedule();
        …
    }
    …
    set_current_state(TASK_RUNNING);
}

VCPU对应的线程将自己设置为可被中断的状态(TASK_INTERRUPTIBLE),然后主动调用内核的调度函数schedule()将自己挂起,让物理处理器运行其他就绪任务。当挂起的VCPU线程被其他任务唤醒后,将从schedule()后面的一条语句继续运行。当准备进入下一次循环时,因为有中断需要处理,则跳出循环,将自己设置为就绪状态,接下来VCPU线程则再次进入Guest模式。