StratoVirt中MicroVM启动过程

0x01 microvm vs 标准vm

1.1 什么是MicroVM微虚拟机?

微虚拟机正是相对于标准虚拟机而言的。标准虚拟机是模拟真正的物理主机,乃至PC,所以它会拥有完整的外设(键盘、USB、串口、PCI设备、显示器等)。在云原生技术兴起之前,AWS已经在布局FaaS(函数即服务),也即serverless业务,AWS为了实现强隔离不同的函数实例,同时支持快速、低消耗启动多个实例,推出了Firecracker的微虚拟机技术。

microvm与传统的Qemu/KVM虚拟机的区别是,它只实现了用于服务端计算服务所需要的最小硬件集。按照文章[https://aws.amazon.com/cn/blogs/china/in-depth-analysis-of-aws-firecracker/]中所描述的,Firecracker只实现了虚拟网络、虚拟磁盘和虚拟sock、串口、和单个键的键盘。Firecracker的MicroVM可以在微秒级别内启动,而且内存底躁小于5MB(虚拟机运行时的需要总内存减去虚拟机配置内存)。以上两个参数都是传统虚拟机不能比拟的。

1.2 MicroVM为什么启动快?

传统虚拟机的外设比较多,外设在启动时都需要内核的处理,如加载驱动、硬件初始化等;存在多个网卡时,还需要为每个网卡配置网络;虚拟机内的操作系统没有经过专门处理,所以要启动很多服务。而MicroVM直接定制了虚拟机内核(Linux),最少的外设,抛弃传统的BIOS加载启动过程,所以它可以快速启动。

与Firecracker对应的国内项目就是openEuler社区的Stratovirt项目了。除了支持MicroVM,Stratovirt还支持标准虚拟机。对其系统的介绍见Rust社区文章 华为 | 基于Rust的下一代虚拟化平台-StratoVirt

下面跟随实例去探索下MicroVM启动过程,领略下其实现过程。

0x02 Stratovirt中MicroVM的启动过程

如下,我们按照文档中描述的步骤启动一个openEuler的MicroVM。

stratovirt -machine type=microvm -kernel /home/ubuntu/vmlinux.bin -smp 1 -m 1024 -append 'console=ttyS0 pci=off reboot=k quiet panic=1 root=/dev/vda' -drive file=/home/ubuntu/rootfs.img,id=rootfs,readonly=off,direct=off -device virtio-blk-device,drive=rootfs -qmp unix:/home/ubuntu/stratovirt.sock,server,nowait -serial stdio

逐个解释下参数的含义。

  • -machine是用来指定虚拟机的属性的。其中的type=microvm,指明我们要启动的是一个MicroVM。
  • -kernel用来指定内核二进制文件的。这个文件会由Stratvirt直接加载到虚拟机内存。
  • -smp 1 指定虚拟机的核心数为1。
  • -m 1024 指定的是虚拟机的内存,默认大小为MB。
  • –append 是传递给内核的参数。
  • -drive 指定根分区。内核启动后会挂载此分区,开启shell。
  • -device 指定虚拟设备列表。
  • -qmp qmp的监听socket地址,qmp是Qemu支持的虚拟机管理协议,Stratovirt兼容它。
  • -serial 指定串口是当前进程的标准输入。

熟悉Qemu的同学可以看出,这里面没有指定BIOS,是因为MicroVM不需要。

对比传统虚拟机,我们可以提出以下疑问:

Q1. 没有BIOS和GRUB引导,X86下如何从实模式跳转到保护模式呢?
Q2. 没有引导过程,内核是如何加载到内存的?

带着疑问去学习是个好习惯,我们就带着上面3个问题,看下StratoVirt代码和设计中是如何处理的。第一次过,主要是为了理清整体流程,不会拘泥于细节。

先根据自己之前的学习OS时写小操作系统myos的经验解答一下上面2个问题:

有GRUB时,BIOS只是加载了GRUB,内核的加载由GRUB完成;而guest机运行前的内存由VMM管理,可以直接将内核加载到guest机内存中。X86中内核的实模式启动地址是_start函数,初步猜测“只要将内核的起始位置加载到vcpu启动后访问的地址就行了”。同样,append的参数只要放到guest机存的指定位置,内核启动时就能找到它们了。

2.1 整体流程

入口位为src/main.rs中。目前只看x86_64的。但没有找到main函数,根据经验猜测应该是quick_main!宏,排查了main.rs中有引入宏的两个库,发现是error-chain,这个宏会自己打印错误日志。比较方便。找到入口是run函数。

run之后是调用real_main,真正的main函数。调用之前,要根据命令行参数,构建出虚拟机的配置信息。保存在vm_config中。流程图如下。

通过流程图并不能看出具体做了什么还要展开看。按照经验来判断,新建虚拟机实例时应该进行了一些操作,同时MachineOps的realize过程中也会有些操作。

2.2 新建虚拟机实例

machine/src/micro_vm/mod.rs中定义的LightMachine::new。
write_gdt_table

  1. 创建内存地址空间。AddressSpace。
  2. 创建IO内存地址空间。只有X86需要。
  3. 初始化free_irqs,mmio_region范围。
  4. 创建sysbus 系统总线。
  5. 设置vm_state
  6. 创建power_button 电源按键

新建虚拟机只是初始化一些对象,并没有看到对应的问题的答案。那么一定是在realize中了。

2.3 realize过程

realize的调用涉及到rust的一些知识。Trait T被struct S实现时,可以通过s.m方式调用,也可以通过T::m(s)来调用。所以代码中的MachineOps::realize其实就是调用的vm实例的realize方法,这里不能使用前一种方式调用,因为首个参数是&Arc<Mutex<Self>>类型的,也就是说不是&self类型,之所以用这种类型,是因为vm实例需要在不同线程之间共享,所以使用了Arc原子引用计数和Mutex锁。

传入的参数下面说下其具体做了什么操作:

  1. init_memory 初始化内存。
  2. 创建vCPU fd列表和初始化中断控制器。AARCH64和X86相同顺序不同。
  3. 创建各种device,下面会细说具体有什么设备。
  4. load_boot_source 加载启动文件。
  5. init_vcpu 初始化vCPU,传入的有boot_config,这里应该是要加载内核了。
  6. 针对AARC64,写入FDT(设备树表,Linux下Arm的设备管理方式)。
  7. 注册电源事件。

猜测内核的加载应该是在步骤4中。需要在看到内存是如何初始化的之后,再看第4步。

2.3.1 初始化内存

一开始没找到init_memory函数的定义,后来想到rust的trait中可以定义函数实现。

整个函数分以下步骤。

  1. 为sys_mem注册KVMMemory监听回调。处理添加移除region,添加移除ioeventfd。这两个概念具体是什么,暂时还不清楚。
  2. x86下添加sys_io内存的监听回调。
  3. 调用mmap,申请内存并初始化。
  4. 将sys_mem与虚拟机迁移关联。

重点看下第3步。arch_ram_ranges函数中,根据MEM_LAYOUT中定义的布局,为guest申请内存。0-4GB中间一段内存不能给guest机使用,是由VMM也就是StratoVirt来管理的。create_host_mmaps真正通过mmap分配内存,返回分配的内存区域列表。

初始化完成后,guest机内存已经准备好了。可以向其中写内容了。看下init_vcput和load_boot_source中是如何处理。

2.3.2 load_boot_source 加载引导源

其中调用load_linux加载内核。看到一个参数:prot64_mode=true,表示直接从64位保护模式启动。

load_linux的流程如下:

  1. 调用load_kernel_image,最终调用load_image加载内核到sys_mem,非压缩内核
    加载到0x100_0000的位置。也是在这里设置了boot_ip配置。sp固定是0x0000_8ff0,同时也返回了一个RealModeKernelHeader实例,只是默认参数没有使用。
  2. 之后惊奇地看到有设置page_table和gdt,有看过x86_64中内核实地址模式下的启动流程,实地址模式在进入保护模式前也做了这些工作,至此不用细看,也明白了核心操作是如下过程。

microVM正是通过VMM执行一些启动过程中要做的处理,提前准备内存和寄存器,直接在启动内核时进入保护模式,节省了内核做这些处理的时间,最大限度地提升内核的启动速度。至此也解答了问题Q1 Q2。

Q1 内核没有经过实模式,直接进入保护模式;
Q2 内核在load_linux时,以读取文件的方式加载到sys_mem中。

2.3.3 初始化vCPU

传入的cpu列表是fd列表,底层的vCPU,会在此过程中转换为封装的CPU实例。

  1. 转换为CPU实例列表。
  2. 调用各CPU的realize方法,底层是调用ArchCPU的set_boot_config方法,最终是设置了各个寄存器,上述的boot_ip被赋值给rip。

设置寄存器时,并没有通过ioctl将寄存器设置到vCPU中,只是保存到了ArchCPU实例中。以X86为例,设置eip等指令寄存器,同时设置了一系列的段寄存器。

Q3. 疑惑点是为什么没直接设置到内核的vCPU中,什么时候设置的?

既然还没有看到,还要继续看。下面唯一可能就是vCPU的启动过程了。

2.4 启动vCPU的过程

入口是vm实例的run方法。

vm.run
  -> vm.vm_start
    -> CPU::start
      -> CPUThreadWorker.handle // 每个CPU一个线程
        -> thread_cpu.reset // 重置CPU
          -> arch_cpu.reset_vcpu
            -> 真正设置sregs和regs到内核vCPU中。
          -> arch_cpu.kvm_vcpu_exec
            -> 启动vCPU。

以上的代码流程理清了init_vcpu时设置的寄存器是什么时机设置到内核vCPU的。即回答了Q3,在vCPU启动前完成的。

3 总结

  1. 以X86为例,StratoVirt中通过提前加载内核到guest机内存中,同时为内核准备好GDT、各个寄存器。vCPU启动时,
    内核直接进入了保护模式,通过这种方式,最大限度地提高了虚拟机的开机效率。
  2. 通过梳理这个流程,也学习到了rust的一些语言特性:trait中可以定义默认函数;trait方法的灵活调用方式等。

版权声明:本文为u012520854原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/u012520854/article/details/125158629