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。