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

1.4.2 处理器启动过程

对于SMP系统,在正常运转时每个核的地位都是同等的,但是在系统启动时,需要准备环境,包括从BIOS获取系统各种信息,然后解压内核,跳转到解压的内核处并初始化必要的系统资源、数据结构以及各子系统等。这些准备工作如果由多个处理器不加保护地并发执行,将会带来灾难,因此只能由一个处理器执行,其他处理器必须处于停止状态,这就是操作系统的Boostrap过程,因此执行这些操作的处理器被称为Boostrap Processor,简称BSP。

当操作系统的初始化过程完成后,BSP需要通知其他处理器启动。相对于BSP,其他处理器被称为Application Processor,简称AP。AP需要略过解压内核、内核初始化等相关代码,跳转到一段为其准备的特殊代码,进行处理器自身相关的初始化,包括设置相关的寄存器、切换到保护模式等,然后运行0号任务,等待其他就绪任务到来。

MP Spec1.4定义的BSP通知AP启动的逻辑如下:


BSP sends AP an INIT IPI
BSP DELAYs (10mSec)
If (APIC_VERSION is not an 82489DX) {
    BSP sends AP a STARTUP IPI
    BSP DELAYs (200μSEC)
    BSP sends AP a STARTUP IPI
    BSP DELAYs (200μSEC)
}
BSP verifies synchronization with executing AP

不同系列的处理器,其启动逻辑有所不同。对于80486这种使用独立LAPIC(型号为82489DX)的CPU,BSP只需要发送1个INIT IPI即可,独立LAPIC不支持STARTUP IPI。在INIT IPI方式下,BSP不能设置AP的起始运行地址,AP固定从BIOS中开始运行,然后跳转到一个固定位置,操作系统只能将AP起始运行的代码放置在这个固定的位置。

对于比较新的CPU,LAPIC被集成到CPU内部。这些较新的CPU支持STARTUP IPI,可以指定AP的起始运行地址。当处于INIT状态的CPU收到STARTUP IPI后,将从STARTUP IPI指定的位置开始运行。为了防止一些噪音导致STARTUP IPI信号丢失,较早的CPU约定发送两次STARTUP IPI,而对于较新的CPU,发送一次STARTUP IPI足矣。

1.VMM侧多处理器启动

通常多处理器系统都会将0号CPU作为BSP,kvmtool也不例外,其选择虚拟机的0号处理器作为BSP,将0号VCPU的状态设置为可以运行,而其他VCPU,即AP都被设置为未初始化。如果VCPU状态为未初始化,那么在尝试切入Guest时,VCPU对应的线程将被挂起。BSP准备好基础环境后,将向AP先后发送INIT IPI和STARTUP IPI,唤醒VCPU所在的线程。在收到STARTUP IPI后,VCPU的状态变更为VCPU_MP_STATE_SIPI_RECEIVED,处于此状态的VCPU再次尝试进入Guest时,将顺利进入Guest,不会再被挂起。相关代码如下:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/drivers/kvm/kvm_main.c
01 int kvm_vcpu_init(struct kvm_vcpu *vcpu, …, unsigned id)
02 {
03     …
04     if (!irqchip_in_kernel(kvm) || id == 0)
05         vcpu->mp_state = VCPU_MP_STATE_RUNNABLE;
06     else
07         vcpu->mp_state = VCPU_MP_STATE_UNINITIALIZED;
08     …
09 }

10 static int kvm_vcpu_ioctl_run(struct kvm_vcpu *vcpu, …)
11 {
12     …
13     if (unlikely(vcpu->mp_state == 
14                     VCPU_MP_STATE_UNINITIALIZED)) {
15         kvm_vcpu_block(vcpu);  
16         …
17         return -EAGAIN;
18     }
19     …
20 }

21 static void kvm_vcpu_block(struct kvm_vcpu *vcpu)
22 {
23     …
24     while (…&& vcpu->mp_state != VCPU_MP_STATE_SIPI_RECEIVED) {
25         set_current_state(TASK_INTERRUPTIBLE);
26         …
27         schedule();
28         …
29     }
30     …
31 }

根据第6、7行代码,kvmtool将AP的初始状态设置为VCPU_MP_STATE_UNINI-TIALIZED。那么,当VCPU尝试进入Guest模式时,根据第13~15行代码,其将进入函数kvm_vcpu_block。

函数kvm_vcpu_block将判断VCPU的状态。根据第24行代码,当VCPU尚不是VCPU_MP_STATE_SIPI_RECEIVED状态时,kvm_vcpu_block会将VCPU所在的线程设置为可中断状态,然后主动请求内核进行调度,VCPU所在的线程将被挂起。我们从状态VCPU_MP_STATE_SIPI_RECEIVED的名字就可以看出,这个状态表示VCPU收到SIPI(STARTUP IPI的简写)了,也就是说,只有在VCPU收到BSP发来的STARTUP IPI后,才可以开始运行。

当BSP向AP发送STARTUP IPI后,其他AP所在的线程将被唤醒,线程的状态将会流转为VCPU_MP_STATE_SIPI_RECEIVED,AP线程从上次挂起处,即第15行代码后继续执行。当执行到第17行代码时,将返回用户空间,用户空间通过ioctl发起KVM_RUN命令以再次发起进入虚拟机操作,这次VCPU所在线程将不会再进入第13、14行代码所在的if分支了,而是会顺利进入Guest。

根据第4、5行代码,kvmtool将BSP的状态设置为VCPU_MP_STATE_RUNNABLE,因此当BSP所在的线程首次尝试进入Guest时,不会进入第13、14行代码所在的if分支,而是顺利进入Guest,开启系统Bootstrap过程。

2.Guest侧多处理器启动

BSP准备好环境后,通过向AP发送核间中断的方式启动AP。BSP除了告知LAPIC核间中段的目的CPU等常规信息外,还有两个特殊的字段需要注意。一个是Delivery Mode,对于INIT IPI,Delivery Mode对应的值为INIT;对于STARTUP IPI,Delivery Mode对应的值为start up。AP通过Delivery Mode字段的值判断INIT IPI和STARTUP IPI。另外一个值得注意的字段是STARTUP IPI指定的AP的起始运行地址,其占用的是中断控制寄存器中的vector字段(0~7字节)。LAPIC的中断控制寄存器的具体格式如图1-8所示。

图1-8 中断控制寄存器格式

BSP准备好基础环境后,调用函数smp_boot_cpus启动其他AP:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/arch/x86/kernel/smpboot_32.c
static void __init smp_boot_cpus(unsigned int max_cpus)
{
    …
    for (bit = 0; kicked < NR_CPUS && bit < MAX_APICS; bit++) {
        apicid = cpu_present_to_apicid(bit);
        …
        if (!check_apicid_present(bit))
            continue;
        …
        if (… || do_boot_cpu(apicid, cpu))
        …
    }
    …
}

在前面讨论MP Table时,我们提到过,在启动时,操作系统会扫描MP Table,在全局变量phys_cpu_present_map中标记存在的CPU,比如如果0号CPU存在,那么phys_cpu_present_map的位0将被置为1。这里函数smp_boot_cpus就是检查phys_cpu_present_map中的每一位,如果置位了,则调用函数do_boot_cpu以启动相应的处理器:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/arch/x86/kernel/smpboot_32.c
01 static int __cpuinit do_boot_cpu(int apicid, int cpu)
02 {
03     …
04     boot_error = wakeup_secondary_cpu(apicid, start_eip);
05     …
06 }

07 static int __devinit
08 wakeup_secondary_cpu(int phys_apicid, unsigned long start_eip)
09 {
10     …
11     apic_write_around(APIC_ICR2,
12         SET_APIC_DEST_FIELD(phys_apicid));
13     …
14     apic_write_around(APIC_ICR, APIC_INT_LEVELTRIG |
15         APIC_INT_ASSERT | APIC_DM_INIT);
16     …
17     apic_write_around(APIC_ICR2, 
18         SET_APIC_DEST_FIELD(phys_apicid)) ;
19     …
20     apic_write_around(APIC_ICR, APIC_INT_LEVELTRIG | 
21         APIC_DM_INIT);
22     …
23     if (APIC_INTEGRATED(apic_version[phys_apicid]))
24         num_starts = 2;
25     else
26         num_starts = 0;
27     …
28     for (j = 1; j <= num_starts; j++) {
29         …
30         apic_write_around(APIC_ICR2, 
31             SET_APIC_DEST_FIELD(phys_apicid));
32         …
33         apic_write_around(APIC_ICR, APIC_DM_STARTUP
34                     | (start_eip >> 12));
35         …
36     }
37     …
38 }

MP Spec规定INIT IPI使用水平触发模式,第1次使引脚有效,第2次使引脚无效。第11~15行代码就是发送第1次INIT IPI,即assert INIT,其中第11、12行代码是设置中断控制寄存器的目的CPU字段;第14~15行代码按照MP Spec要求设置LAPIC为水平触发,并设置引脚有效(assert);第15行代码设置了中断控制寄存器的Delivery Mode字段的值APIC_DM_INIT,即设置了这个核间中断是一个INIT IPI。第17~21行代码是发送第2次INIT IPI,即de-assert INIT。

第23行代码判断LAPIC是集成到CPU内部的还是独立的。集成LAPIC支持STARTUP IPI,MP Spec约定需要发送两次STARTUP IPI,所以变量num_starts被赋值为2,即循环两次,发送两次STARTUP IPI。独立的LAPIC不支持STARTUP IPI,所以变量num_starts被赋值为0,即不执行循环,所以不会发送STARTUP IPI。

第30~34行代码是发送STARTUP IPI。第33行代码设置了中断控制寄存器的Delivery Mode字段的值为APIC_DM_STARTUP,即设置了这是STARTUP IPI。STARTUP IPI支持设置AP的起始运行地址,其使用中断控制寄存器中的vector字段(0~7字节)存储AP开始运行的地址。该地址要求4KB页面对齐,即假设字段vector的值为VV,当CPU收到STARTUP IPI后,其从0xVV0000处开始运行。

根据第34行代码,AP启动运行的位置为start_eip,我们看到start_eip按照页面对齐的要求右移了12位。start_eip指向的代码片段是专门为AP启动准备的入口,这段代码被称为trampoline,以32位系统为例,这段代码在文件arch/x86/kernel/trampoline_32.S中。BSP向AP发送核间中断启动AP前,在低端内存申请了一块内存,将trampoline代码片段复制到这块区域,并将start_eip指向这块内存区,相关代码如下:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/arch/x86/kernel/smpboot_32.c
01 static int __cpuinit do_boot_cpu(int apicid, int cpu)
02 {
03     …
04     start_eip = setup_trampoline();
05     …
06 }

07 static unsigned long __devinit setup_trampoline(void)
08 {
09     memcpy(trampoline_base, trampoline_data, 
10            trampoline_end - trampoline_data);
11     return virt_to_phys(trampoline_base);
12 }

linux.git/arch/x86/kernel/trampoline_32.S
13 ENTRY(trampoline_data)
14     …
15     ljmpl   $__BOOT_CS, $(startup_32_smp-__PAGE_OFFSET)

linux.git/arch/x86/kernel/head_32.S
16 ENTRY(startup_32)
17     …
18 ENTRY(startup_32_smp)
19     …
20     movb ready, %cl
21     movb $1, ready
22     cmpb $0,%cl     # the first CPU calls start_kernel
23     je   1f
24     …
25     jmp initialize_secondary # all other CPUs call …
26 1:
27 #endif /* CONFIG_SMP */
28     jmp start_kernel
29     …
30 ready:  .byte 0

linux.git/arch/x86/kernel/smpboot_32.c
31 void __devinit initialize_secondary(void)
32 {
33     …
34     asm volatile(
35         "movl %0,%%esp\n\t"
36         "jmp *%1"
37         :
38         :"m" (current->thread.esp),"m" (current->thread.eip));
39 }

第4行代码就是在启动AP前,BSP调用函数setup_trampoline为AP准备启动代码片段。trampoline这段代码将AP从实模式切换到保护模式后,跳转到了解压后的内核的头部,但是并不是从头部(startup_32)开始执行,而是跳过了需要BSP执行的如复制引导参数、准备内核页表等部分,从标号startup_32_smp处开始执行。

从startup_32_smp开始,AP进行了自身相关必需的初始化。接下来后续又开始分化了,BSP需要跳转到函数start_kernel执行,而AP则跳转到函数initialize_secondary处执行。这个过程通过变量ready来控制,当CPU执行到第23行代码时,如果此时变量ready为0,则跳转到标号1处,即第26行代码处,进而在第28行代码处进入函数start_kernel。根据第30行代码,变量ready的初始值为0,那么当BSP执行第23行代码时,因为BSP是第一个执行这段代码的,所以BSP将跳转到函数start_kernel执行。在BSP使用完变量ready后,其马上会将该变量的值更新为1,见第21行代码,因此,AP在执行第23行代码时不会向前跳转,而是继续执行到第25行代码,进入函数initialize_secondary。

BSP将跳转到init/main.c中的start_kernel函数执行,这个函数初始化内核中各种数据结构以及子系统。显然,这些资源初始化一次即可,无须其他AP继续来初始化,所以要避免AP继续执行start_kernel函数。

而对于AP跳转到的函数initialize_secondary,根据第36、38行代码可见,AP最终将跳转到宏current指向的结构体thread中的字段eip处。thread.eip指向的是BSP为AP准备第1个任务的入口,这个任务就是CPU闲时执行的idle任务,该任务在做了简短的准备后,随即调用cpu_idle将AP暂停,等待执行其他就绪任务:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/arch/x86/kernel/smpboot_32.c
static int __cpuinit do_boot_cpu(int apicid, int cpu)
{
    …
    per_cpu(current_task, cpu) = idle;
    …
    idle->thread.eip = (unsigned long) start_secondary;
    …
}

static void __cpuinit start_secondary(void *unused)
{
    …
    cpu_idle();
}

3.LAPIC发送核间中断

在上一节中,我们看到了Guest内核通过写LAPIC的控制寄存器来发送核间中断,但是核间中断终究是需要LAPIC来发送的,因此,在这一节中我们探讨KVM中的虚拟LAPIC是如何发送核间中断的。

LAPIC采用一个页面存放各寄存器的值,中断控制寄存器也在这个页面中,操作系统会将这个页面映射到进程的地址空间,通过MMIO的方式访问这些寄存器。当Guest访问这些寄存器时,将从Guest陷入KVM。后来,为了减少VM退出的次数,Intel从硬件层面对中断进行了支持,如果只是读寄存器的值,那么将不再触发VM退出,只有写寄存器时才会触发VM退出,具体内容我们将在“中断虚拟化”一章中继续讨论。从Guest陷入KVM后,将进入函数apic_mmio_write,该函数读取icr寄存器中的目的CPU字段,向目的CPU发送核间中断:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/drivers/kvm/lapic.c
01 static void apic_mmio_write(struct kvm_io_device *this,…)
02 {
03     …
04     case APIC_ICR:
05         …
06         apic_send_ipi(apic);
07         break;
08    
09     case APIC_ICR2:
10         apic_set_reg(apic, APIC_ICR2, val & 0xff000000);
11         break;
12     …
13 }

14 static void apic_send_ipi(struct kvm_lapic *apic)
15 {
16     …
17     for (i = 0; i < KVM_MAX_VCPUS; i++) {
18         vcpu = apic->vcpu->kvm->vcpus[i];
19         …
20         if (vcpu->apic &&
21             apic_match_dest(vcpu, apic, short_hand, dest,…)) {
22             …
23                 __apic_accept_irq(vcpu->apic, …, vector, …);
24         }
25     }
26     …
27 }

28 static int __apic_accept_irq(struct kvm_lapic *apic, …)
29 {
30     …
31     case APIC_DM_STARTUP:
32         …
33             vcpu->sipi_vector = vector;
34             …
35                 wake_up_interruptible(&vcpu->wq);
36         }
37         break;
38     …
39 }

第9、10行代码是处理Guest写中断控制寄存器高32位的情况,即将Guest设置目的CPU对应的LAPIC的ID记录在虚拟LAPIC中。第4~7行代码处理Guest写中断控制寄存器低32位的情况,其中第6行代码调用函数apic_send_ipi向目的CPU发起了IPI中断。函数apic_send_ipi遍历所有的CPU,调用apic_match_dest尝试匹配目的CPU,一旦匹配成功,则调用__apic_accept_irq以完成向目的CPU发送核间中断。根据第31、35代码,当BSP向AP发送的是STARTUP IPI时,KVM将唤醒AP开始运行Guest。

Guest运行的起始地址记录在数据结构vcpu的变量sipi_vector中,见第33行代码。在AP准备切入Guest前,KVM将使用变量sipi_vector来设置AP对应的VMCS中Guest的cs和rip,见如下代码:


commit c5ec153402b6d276fe20029da1059ba42a4b55e5
KVM: enable in-kernel APIC INIT/SIPI handling
linux.git/drivers/kvm/kvm_main.c
static int vmx_vcpu_setup(struct vcpu_vmx *vmx)
{
    …
    if (vmx->vcpu.vcpu_id == 0) {
        …
    } else {
        vmcs_write16(GUEST_CS_SELECTOR, 
vmx->vcpu.sipi_vector << 8);
        vmcs_writel(GUEST_CS_BASE, vmx->vcpu.sipi_vector << 12);
    }
    …
    if (vmx->vcpu.vcpu_id == 0)
        …
    else
        vmcs_writel(GUEST_RIP, 0);
    …
}

函数vmx_vcpu_setup是负责切入Guest前初始化VCPU的,其中vcpu_id非0的分支是处理AP的。代码中sipi_vector是BSP向AP发送START IPI时传递的AP的起始运行地址。MP Spec确定AP的起始地址为4KB页面对齐,即假设中断控制寄存器中字段vector的值为VV,那么AP的起始地址为0xVV0000,这就是为什么代码中将sipi_vector左移12位作为代码段cs寄存器的值,同时用于页内偏移的rip寄存器设置为0。