【firecracker】CPU与内存

运行原理

  firecracker虚拟机的CPU与内存功能依赖于硬件辅助虚拟化能力(如intel vt-x特性):

  • 硬件辅助虚拟化技术为物理CPU增加了一种新的执行模式(Guest Mode/None-Root Mode),在该模式下,CPU中执行的是虚拟机的指令,而非物理机指令。这是一种高效的虚拟指令执行方式,但是在该模式下并不能直接执行所有的虚拟机执令,某些特权指令(如IO指令)的执行会强制将CPU退出到普通模式(Root Mode)交由VMM程序(内核KVM模块/用户态firecracker)进行处理,处理完成后再重新返回到Guest模式执行。
  • 同时,MMU中增加了一级页表(X86架构下叫EPT),MMU通过该页表完成虚拟物理地址(Guest Physical Address,GPA)到主机物理地址(Host Physical Address, HPA)的转换。也就是说当CPU处于Guest模式时,CPU发出的内存访问请求,MMU需要经过两层转换:首先是完成由虚拟地址到虚拟机物理地址的转换,接着是完成由虚拟机物理地址到真实物理地址的转换。因此可以保证不同虚拟机在内存访问上的隔离性。

  关于硬件辅助虚拟化技术的详细描述可以参考intel手册和KVM内核模块代码,这里不对此进行深入讨论。firecracker的vCPU线程会对部分退出事件进行处理,我们可以结合代码来进一步讨论其实现原理:

firecracker/vmm/src/vstate.rs:

pub struct Vcpu {                       // Vcpu结构代表虚拟CPU                          
    …                                                                
    fd: VcpuFd,                         // fd是KVM内核模块为每个vCPU建立的文件句柄,通过该句柄可以向KVM发起vCPU相关的操作命令                    
    id: u8,                             // id是vCPU编号,从0开始            
    io_bus: devices::Bus,               // io_bus是端口总线,所有Legacy设备均挂在该总线下,CPU通过访问不同端口控制这些设备                          
    mmio_bus: Option<devices::Bus>,     // mmio_bus是mmio总线,所有mmio设备均挂在该总线下,CPU通过访问内存指令来控制这些设备,
                                        //     只不过mmio地址空间(0xD0000000~0xFFFFFFFF)与普通内存DRAM地址空间完全独立。这里Option
                                        //     类型是Rust标准库中定义的一种枚举类型,代表对象为空(None)或非空(Some),目的在于避免
                                        //     使用C语言中的空指针从而引发各种系统错误。每个vCPU对象对应的mmio_bus在初始时为None,
                                        //     后续在vCPU线程进入Guest模式前被设置为正确的mmio总线对象                                      
    …                                                      
}                                                                               

impl Vcpu {                                    // Rust语言中可以为结构体实现各种方法,类似面向对象语言中类的概念
    …
    pub fn run(                                // Vcpu结构中的run方法为每个vCPU线程的主函数                                       
            &mut self,                                                                                             
            …                                                                               
        ) {                                                                                                        
        …                                                                                                                
        while self.run_emulation().is_ok() {}  // 死循环结构调用run_emulation方法,  如果该方法执行出错则退出循环,线程退出                                                       
        …                                                                                                   
    }
    …

    fn run_emulation(&mut self) -> Result<()> {                                 
        match self.fd.run() {                                     // 通过vCPU句柄调用KVM内核暴露的ioctl接口(KVM_RUN)进入内核态,
                                                                  //     借助硬件辅助虚拟化能力进入Guest模式。通常情况下,物理CPU
                                                                  //     开始执行虚拟机指令,只有出现虚拟机指令无法执行或执行异常
                                                                  //     时,物理CPU才会退出到KVM模块进行处理(例如EPT缺页异常)。
                                                                  //     如果KVM模块也无法处 理,会进一步返回到用户态vmm程序,则
                                                                  //     下一条语句才开始执行
            Ok(run) => match run {                                // 如果执行到这里,说明出现了KVM内核也无法处理的退出事件。如果
                                                                  //     返回结果OK,则进一步判断退出事件类型
                VcpuExit::IoIn(addr, data) => {                   // 该分支处理vCPU的端口读操作(IoIn),addr代表端口地址,data代表将
                                                                  //     取的数据
                    self.io_bus.read(u64::from(addr), data);      // 将端口读操作转发到端口总线io_bus上,其内部根据端口地址搜索对
                                                                  //     应端口设备并调用设备的read接口      
                    …                             
                    Ok(())                                        // run_emulation返回处理成功,处层死循环将再次进入Guest模式
                }                                                               
                VcpuExit::IoOut(addr, data) => {                  // 该分支处理vCPU端口写操作(IoOut)
                    …                                
                    self.io_bus.write(u64::from(addr), data);                   
                    …                            
                    Ok(())                                                      
                }                                                               
                VcpuExit::MmioRead(addr, data) => {               // 该分支处理vCPU MMIO地址读操作(MmioRead)       
                    if let Some(ref mmio_bus) = self.mmio_bus {   // 如果vCPU可访问的mmio总线存在,            
                        mmio_bus.read(addr, data);                // 则将读操作转到到mmio总线上,其内部根据mmio地址搜索对应的mmio设备
                                                                  //         并调用设备的read接口 
                        …                      
                    }                                                           
                    Ok(())                                                      
                }                                                               
                VcpuExit::MmioWrite(addr, data) => {              // 该分支处理vCPU MMIO地址写操作(MmioWrite)        
                    if let Some(ref mmio_bus) = self.mmio_bus {                 
                        …                                                       
                        mmio_bus.write(addr, data);                              
                        …                      
                    }                                                           
                    Ok(())                                                      
                }                                                                
                VcpuExit::Hlt => {                                // 该分支处理Hlt指令,vCPU线程退出                         
                    info!("Received KVM_EXIT_HLT signal");                       
                    Err(Error::VcpuUnhandledKvmExit)                            
                }                                                               
                VcpuExit::Shutdown => {                           // 该分支处理shutdown命令,vCPU线程退出
                    info!("Received KVM_EXIT_SHUTDOWN signal");                 
                    Err(Error::VcpuUnhandledKvmExit)                            
                }  
                …
            }
            …
        }
    }                             
}

  通过上述代码可以看出,CPU与内存的虚拟化功能基本由硬件实现,而软件主要配合硬件完成对各种退出事件的处理:KVM内核模块实现了对EPT表缺页异常的处理;firecracker实现了对端口读写及mmio空间读写操作的处理。

初始化流程

  尽管虚拟CPU和内存在运行过程中无须firecracker有过多干涉,但是它们的初始化动作是完全由friecracker实现的,因此我们需要了解firecracker是如何实现CPU和内存初始化的。

一、简要KVM使用例程

  在我们正式分析firecracker的初始化代码之前,我们有必要先了解一下KVM模块的使用方法,因为firecracker是基于KVM模块构建的。Rust语言环境下有一个名叫kvm-ioctls的库,它实现了对KVM内核ioctl系统调用的封装,我们可以基于这个库实现一个简单的KVM虚拟机程序:启动一个单核,普通内存地址范围0x1000~0x5000的虚拟机,虚拟机进入Guest模式后执行一段简单的端口操作与MMIO空间操作指令后立刻停机。


fn main() {
    …
    let kvm = Kvm::new().unwrap();      // 第一步:创建一个kvm对象,对应字符设备/dev/kvm

    let vm = kvm.create_vm().unwrap();  // 第二步:通过kvm对象创建一个虚拟机对象vm
                                        // 第三步:初始化虚拟机普通内存
    let mem_size = 0x4000;              //      设置普通内存大小为0x4000                                                  
    let guest_addr: u64 = 0x1000;       //      设置普通内存起始地址为0x1000,意味在0x1000~0x5000之外均为MMIO地址空间 
    let load_addr: *mut u8 = unsafe {   //      通过mmap系统调用映射一段大小为0x4000的区间,虚拟起始地址记录到load_addr中 
        libc::mmap(                                                         
            null_mut(),                                                     
            mem_size,                                                       
            libc::PROT_READ | libc::PROT_WRITE,                             
            libc::MAP_ANONYMOUS | libc::MAP_SHARED
                | libc::MAP_NORESERVE,   
            -1,                                                             
            0,                                                               
        ) as *mut u8                                                        
    };                                                                      

    let slot = 0;                                  //     设置区间号为零,并将之前映射的区间信息传递给KVM        
    let mem_region = kvm_userspace_memory_region { //     该对象包含所有需要传递给KVM的信息                       
        slot,                                      //     当前区间编号,即0
        guest_phys_addr: guest_addr,               //     虚拟机起始物理地址,即0x1000        
        memory_size: mem_size as u64,              //     虚拟机普通内存区间大小,即0x4000        
        userspace_addr: load_addr as u64,          //     普通内存区间映射到主机的虚拟地址           
        flags: KVM_MEM_LOG_DIRTY_PAGES,            //     区间标志,通知KVM对该区间进行脏页跟踪                    
    };                                                                         
    unsafe { vm.set_user_memory_region(mem_region).unwrap() };    // 通过虚拟机对象vm将普通内存信息传递给KVM

    let x86_code = [                                              // 设置虚拟机内将执行的指令    
        0xba, 0xf8, 0x03, /* mov $0x3f8, %dx */                   //     端口寄存器dx设为0x3f8,对应串口设备
        0x00, 0xd8, /* add %bl, %al */                            //     将bl寄存器值 加到al寄存器中
        0x04, b'0', /* add $'0', %al */                           //     将字符'0'的值加到al寄存器中
        0xee, /* out %al, %dx */                                  //     向端口输出al寄存器的值
        0xec, /* in %dx, %al */                                   //     从端口读入值到al寄存器
        0xc6, 0x06, 0x00, 0x80, 0x00,                                                  
            /* movl $0, (0x8000); This generates a MMIO Write.*/  //     将零写到0x8000地址,位于MMIO地址空间
        0x8a, 0x16, 0x00, 0x80,
            /* movl (0x8000), %dl; This generates a MMIO Read.*/  //     读取0x8000地址值到dl寄存器
        0xf4, /* hlt */                                           //     执行停机指令
    ];    

    unsafe {                                                      // 将上述指令内容写入到虚拟机普通内存区间中
        let mut slice = slice::from_raw_parts_mut(load_addr, mem_size);     
        slice.write(&x86_code).unwrap();                                    
    }

    let vcpu_fd = vm.create_vcpu(0).unwrap();                     // 第四步:通过虚拟机对象创建一个vCPU对象
                                                                  // 第五步:初始化vCPU对象中各个寄存器的值
    let mut vcpu_sregs = vcpu_fd.get_sregs().unwrap();            //     获取各个段寄存器的默认值
    vcpu_sregs.cs.base = 0;                                       //     将代码段选择子cs对应的基地址设为0
    vcpu_sregs.cs.selector = 0;                                   //     将代码段选择子cs设为0
    vcpu_fd.set_sregs(&vcpu_sregs).unwrap();                      //     将设置后的段寄存器值传递给KVM

    let mut vcpu_regs = vcpu_fd.get_regs().unwrap();              //     获取通用寄存器的默认值              
    vcpu_regs.rip = guest_addr;                                   //     将指令指针IP设为0x1000
    vcpu_regs.rax = 2;                                            //     将rax寄存器初始化为2 
    vcpu_regs.rbx = 3;                                            //     将rbx寄存器初始化为3;对照上述指令,最后串口将输出字符'5'
    vcpu_regs.rflags = 2;                                         //     将标志寄存器设为2,因为位1为保留位,值须为1
    vcpu_fd.set_regs(&vcpu_regs).unwrap();                        //     将设置后的通用寄存器值传递给KVM

    loop {                                                        // 第六步:通过vCPU对象以死循环方式不断进入Guest模式
        match vcpu_fd.run().expect("run failed") {                          
            VcpuExit::IoIn(addr, data) => {                                 
                println!(                                                   
                    "Received an I/O in exit. Address: {:#x}. Data: {:#x}", 
                    addr,                                                   
                    data[0],                                                 
                );                                                          
            }
            VcpuExit::IoOut(addr, data) => {
                …
            }
            VcpuExit::MmioRead(addr, data) => {
                …
            }
            VcpuExit::MmioWrite(addr, data) => {
                …
            }
            VcpuExit::Hlt => {
                …
            }
            r => panic!("Unexpected exit reason: {:?}", r),
        } // end of match
    } // end of loop
}

  通过上述例程,我们看到只需要简单的六大步,便可以通过KVM模块创建一个虚拟机。在掌握了KVM的基础原理后,我们再来看看firecracker中的内存和CPU初始化流程。

二、虚拟机内存初始化init_guest_memory

  firecracker对虚拟机内存的初始化动作在init_guest_memory函数中完成,代码原理如下:

firecracker/vmm/src/lib.rs

struct Vmm {                            // vmm全局对象,包含当前虚拟机的所有全局信息
    kvm: KvmContext,                    // kvm上下文,包含kvm对象
    vm_config: VmConfig,                // 虚拟机配置,如CPU数和内存大小等
    guest_memory: Option<GuestMemory>,  // 虚拟机内存对象,初始时为None
    vm: Vm,                             // 虚拟机对象vm
    …
}

impl Vmm {
    fn init_guest_memory(&mut self) -> std::result::Result<(), StartMicrovmError> {
        let mem_size = self                                             // 从虚拟机配置vm_config中获取内存大小并转化为字节单位                                                         
            .vm_config                                                              
            .mem_size_mib                                                           
            .ok_or(StartMicrovmError::GuestMemory(                                   
                memory_model::GuestMemoryError::MemoryNotInitialized,               
            ))?                                                                     
            << 20;                                                                   
        let arch_mem_regions = arch::arch_memory_regions(mem_size);     // 根据不同体系架构,划分内存区间。以X86为例,虚拟机普通
                                                                        //     内存将分布在两个区间:0x0~0xD0000000和0xFFFFFFFF~X。
                                                                        //     地址区间0xD0000000~0xFFFFFFFF为MMIO空间。   
        self.guest_memory =                                                         
            Some(GuestMemory::new(&arch_mem_regions)                    // 通过mmap系统调用将各个区间映射到用户空间,参见KVM例程
            .map_err(StartMicrovmError::GuestMemory)?);
        self.vm                                                                     
            .memory_init(                                               // 通过vm对象将内存区间信息传递给KVM,参见KVM例程                                                 
                self.guest_memory                                                   
                    .clone()                                                         
                    .ok_or(StartMicrovmError::GuestMemory(                          
                        memory_model::GuestMemoryError::MemoryNotInitialized,   
                    ))?,                                                             
                &self.kvm,                                                          
            )                                                                       
            .map_err(StartMicrovmError::ConfigureVm)?;                              
        Ok(())                                                                      
    }
    …
}

三、虚拟机CPU初始化create_vcpus和start_vcpus

  firecracker通过create_vcpus创建虚拟CPU,并通过start_vcpus启动CPU,代码原理如下:

firecracker/vmm/src/lib.rs

struct Vmm {
    …
    kvm: KvmContext,                                // kvm上下文,包含kvm对象
    vcpus_handles: Vec<thread::JoinHandle<()>>,     // vCPU线程句柄数组,每个vCPU线程对应一个句柄
    vm: Vm,                                         // 虚拟机对象vm
    mmio_device_manager: Option<MMIODeviceManager>, // mmio设备管理器,包含mmio总线及其上的所有mmio设备
    legacy_device_manager: LegacyDeviceManager,     // legacy设备管理器,包含legacy总线及其上的所有legacy设备
    …
}

impl Vmm {
    …
    fn create_vcpus(                              // 创建所有的虚拟CPU                                                       
        &mut self,                                                              
        entry_addr: GuestAddress,                 // 参数entry_addr代表加载虚拟机内核的目的地址,
                                                  //      也是虚拟机进入Guest模式执行的第一条指令位置                                            
        request_ts: TimestampUs,                                                 
    ) -> std::result::Result<Vec<Vcpu>, StartMicrovmError> {                    
        let vcpu_count = self                                                      // 从虚拟机配置vm_config中读取虚拟CPU个数                                                  
            .vm_config                                                          
            .vcpu_count                                                         
            .ok_or(StartMicrovmError::VcpusNotConfigured)?;                     
        let mut vcpus = Vec::with_capacity(vcpu_count as usize);                   // 根据vCPU个数创建向量集vcpus           

        for cpu_id in 0..vcpu_count {                                              // 循环创建vcpu_count个vCPU                                           
            let io_bus = self.legacy_device_manager.io_bus.clone();                //     克隆legacy总线
            let mut vcpu = Vcpu::new(cpu_id, &self.vm, io_bus, request_ts.clone()) //     通过KVM创建一个vCPU
                .map_err(StartMicrovmError::Vcpu)?;                             
            vcpu.configure(&self.vm_config, entry_addr, &self.vm)                  //     配置vCPU寄存器信息,包括段寄存器、通用寄存器和系统寄存器
                                                                                   //           以及虚拟机内部页表。第一个vCPU(BSP)开始运行时即处于分
                                                                                   //           页模式下              
            .map_err(StartMicrovmError::VcpuConfigure)?;                           //  
            vcpus.push(vcpu);                                                      //      将vcpu添加到向量集中
        }                                                                        
        Ok(vcpus)                                                               
    }

    fn start_vcpus(&mut self, mut vcpus: Vec<Vcpu>)
            -> std::result::Result<(), StartMicrovmError> {                        // 启动所有的vCPU
        …                                                                                                     
        for cpu_id in (0..vcpu_count).rev() {                                      // 循环启动vCPU                                  
            …                                   
            let mut vcpu = vcpus.pop().unwrap();                                   //       依次取出vcpu                             

            if let Some(ref mmio_device_manager) = self.mmio_device_manager {      //       如果存在mmio总线,则克隆总线对象
                vcpu.set_mmio_bus(mmio_device_manager.bus.clone());             
            }                                                                   
            …                            
            self.vcpus_handles.push(                                            
                thread::Builder::new()                                             //       启动一个名为fc_vcpu的线程,执行vcpu的run函数,
                                                                                   //       参考运行原理代码分析                                    
                    .name(format!("fc_vcpu{}", cpu_id))                         
                    .spawn(move || {                                            
                        vcpu.run(…);
                    })                                                          
                    .map_err(StartMicrovmError::VcpuSpawn)?,                    
            );                                                                   
        }                                                                       
        …                                                                                                                
        Ok(())                                                                   
    }                                  
}

  至此,firecracker虚拟机CPU与内存子系统已经分析完成,简单总结一下:虚拟机CPU和内存的功能基本上是由硬件和KVM模块实现的,firecracker主要对端口和MMIO操作进行了处理。在后续的MMIO设备和legacy设备分析中,我们将深入讨论端口及MMIO空间的处理流程。


转载请注明:吴斌的博客 » 【firecracker】CPU与内存