kvmtool阅读笔记(三) | Linux内核加载

0X01 准备物理内存

计算机启动之后,主办的BIOS ROM将会被映射到内存地址空间,x86的32位系统,映射的地址空间为0x000F0000 ~ 0x000FFFFF,然后CPU跳转到0xF000:0xFFF0处,开始运行BIOS代码。BIOS会检查内存信息,记录下来,并对外提供内存查询功能。因此VMM需要完成如下事情

  1. 主办的BIOS ROM是被系统映射到内存地址空间0x000F000 ~ 0x000FFFFF。对于软件模拟而言,就不需要这个映射,可以将模拟BIOS中的查询系统内存信息的中断函数直接置于0x000F0000 ~ 0x000FFFFF中。
  2. 在建立IVT(中断向量表),设置第0x15个表项中的中断函数地址指向模拟的BIOS中的内存查询处理函数的地址
  3. 对于真实的物理系统,BIOS会查询真实的物理内存情况,建立内存信息。而对于VMM而言,则需要根据用户配置的虚拟机的内存信息,在BIOS数据区中自己制造内存信息表

中断号0x15功能最强的是将eax寄存器设置为0xE820,0x15号中断处理函数返回主机的完整的内存信息。因此VMM需要模拟的BIOS于内存相关部分如图

 

GPA到HVA

在进行内核获取内存之前,首先来分析kvmtool中将GPA转换为HVA的函数guest_flat_to_host。

虚拟内存有四种状态GPA(Guest Physical Address),GVA(Guest Virtual Address),HPA(Host Physical Address),HVA(Host Virtual Address)。与主机虚拟内存一致,作为虚拟化内存同样需要将物理内存映射到虚拟机上。当Guest运行在实模式时,从Guest的逻辑地址到Host的物理内存需要经过3次地址转换。如图

 

 

 

首先,Guest的逻辑地址(GVA)通过分段机制转换为Guest的物理地址(GPA);其次,Guest的物理地址(GPA)通过虚拟内存条转换为Host的虚拟地址(HVA);最后,Host的虚拟地址(HVA)通过宿主机系统的内存管理机制映射Host的物理地址(HPA)。当发生缺页异常后,在退出Guest模式之前,CPU首先将cr2寄存器中记录的引发缺页异常的地址记录到VMCS中的字段Exit qualification中。这里也在kvm结构体中加入了内存的链表并且在对ram进行KVM_SET_USER_MEMORY_REGION操作之后初始化,trivial-kvm在实现过程中将初始化缺页链表加入到函数kvm_ram__init

struct list_head {
	struct list_head *next, *prev;
};

struct kvm {

    u32 mem_slots; /* for KVM_SET_USER_MEMORY_REGION */
    struct list_head mem_banks;
    
};


static inline void INIT_LIST_HEAD(struct list_head *list) {
	list->next = list;
	list->prev = list;
}

 

而对于保护模式Gues的寻址是为每个Guest进程分别制作一张表,这张表记录者GVA到HPA的映射关系。Guest模式下的cr3寄存器不再指向Guest内部那张只能完成GVA到GPA映射的表,而是指向新表。其中有两个关键点

  1. KVM需要构建从GVA映射到HPA的页表,而且这个页表需要根据Guest内部页表的信息更新。在实际进行地址映射的时候,因为cr3指向的是KVM构建的页表,所以生效的是这张表,其会将Guest内部的页表给遮挡(shadow)起来,因此也称这个页表为影子页表
  2. 保护模式的Guest有自己的页表,而且不只有一个页表,Guest中每个人物都会有自己的页表,这个页表随着任务的切换而进行切换。所以要求KVM也准备多个影子页表,每个Guest任务对应的一个。而且在Guest内部任务切换时,KVM需要洞悉这一切换时刻,切换对应的影子页表

 

影子页表构建好之后,在映射建立完成后,GVA到HPA经过一次映射即可,但是在建立映射的时候是需要经过3次转换:第一次为Guest使用自身的页表完成GVA到HPA的转换;第二次是由KVM根据内存条信息完成GPA到HVA的转换;第三次是Host利用内核的内存管理机制完成HVA到HPA的转换。如图

guest_flat_to_host代码很简洁,如下

#define container_of(ptr, type, member) ({                      \
    const typeof( ((type *)0)->member ) *__mptr = (ptr);    \
    (type *)( (char *)__mptr - offsetof(type,member) );})


#define list_entry(ptr, type, member) \
	container_of(ptr, type, member)

#define list_first_entry(ptr, type, member) \
    list_entry((ptr)->next, type, member)

#define list_next_entry(pos, member) \
	list_entry((pos)->member.next, typeof(*(pos)), member)

#define list_for_each_entry(pos, head, member) \
	for (pos = list_first_entry(head, typeof(*pos), member); \
	     &pos->member != (head); \
	     pos = list_next_entry(pos, member))

void *guest_flat_to_host(struct kvm *kvm, u64 offset) {
    struct kvm_mem_bank *bank;

    list_for_each_entry(bank, &kvm->mem_banks, list)
    {
        u64 bank_start = bank->guest_phys_addr;
        u64 bank_end = bank_start + bank->size;

        if (offset >= bank_start && offset < bank_end)
            return bank->host_addr + (offset - bank_start);
    }

    printf("unable to translate guest address 0x%llx to host\n",
           (unsigned long long)offset);
    return NULL;
}

list_for_each_entry函数主要的作用为for循环找到以kvm->ram_start的原始地址,在for循环内部获取当前链表页面的起始和结束位置并计算内存大小,kvm->ram-start的地址纪即为虚拟机的初始0地址,之后加上offset来表示内存的其实偏移位并且映射到具体的Host宿主机的内存位置。

0X02 加载内核以镜像

以x86架构为例子,加载内核和镜像的代码位于./kvm.ckvm__load_kernel函数中,本文主要分析x86/kvm.c文件中的内核加载到内存的函数load_bzimage函数,简化的后的函数可以看https://github.com/christasa/trivial-kvm/blob/main/trivial-kvm.c#L722,基本有如下几个操作

1. 加载内核文件。内核kernel文件通过read_in_full自动解析到boot_params结构体中,该代码通过地址的不同将kernel文件解析到指定的boot_params参数中。之后将boot的header设置为HdrS,其值为魔数0x53726448[1],其为boot默认的协议版本。lseek检查内核文件是否能够被seek,x86架构可以无需这行代码。这里分析一下实模式转换成物理地址的函数guest_real_to_host

static inline void *guest_real_to_host(struct kvm *kvm, u16 selector, u16 offset) {
    unsigned long flat = ((u32)selector << 4) + offset;

    return guest_flat_to_host(kvm, flat);
}

实模式的寻址方式为PhysicalAddress = Segment * 16 + Offset[2],除了设置cs的selector外还需要设置cs的base,这是为了避免每次都要做左移动计算,每次设置cs的时候都要把cs_selector << 4存入descriptor cache中,即cs base。setup_sects则表示该扇区后面的的实模式占用几个扇区[3]。偏移9位即为乘512,其原因位所有扇区大小都为512 byte[1]。最后将内核读入指定的扇区之中。将内核文件完整读入BZ_KERNEL_START地址,对应代码file_size = read_file(fd_kernel, p, kvm->cfg.ram_size - BZ_KERNEL_START)

2. 读取内核启动命令。即将启动命令读入hdr头信息中,这里设置固定值noapic noacpi pci=conf1 reboot=k panic=1 i8042.direct=1 i8042.dumbkbd=1 i8042.nopnp=1 earlyprintk=serial i8042.noaux=1 console=ttyS0 root=/dev/vda rw "

3. 从虚拟内存中加载结构体boot_params。这里重载的原因是在代码read_in_full(fd_kernel, p, file_size)之后内核文件已经被加载进了内存之中,因此新的boot地址就直接从虚拟地址内加载并且进行后续的设置,加载完成之后进行一些列bios setup操作

4. 加载内核文件系统文件,即现成的操作系统文件架构。kvmtool是可选项,但是trivial-kvm直接使用了文件系统,因此这里作为默认项。代码

unsigned long addr;

for (;;) {
			if (addr < BZ_KERNEL_START)
				die("Not enough memory for initrd");
			else if (addr < (kvm->ram_size - initrd_stat.st_size))
				break;
			addr -= 0x100000;
}

获得initrd加载的起始地址,之后将initrd文件内容读入到转换成虚拟内存地址的内存中。

5.  设置boot load selector和boot开始的SP:IP,用于CPU启动时候的设置。trivial-kvm将该部分直接设置到CPU初始化中

 

至此,Linux文件加载到虚拟内存完成

 

0XFF Reference

  1. https://www.kernel.org/doc/Documentation/x86/boot.txt
  2. Wiki real mode
  3. https://frankjkl.github.io/2019/03/12/Linux%E5%86%85%E6%A0%B8-%E5%BC%95%E5%AF%BC%E5%90%AF%E5%8A%A8/
  4. Inside the Linux Virtualization Principle and Implementation