0x01 虚拟化内存
因为当今的物理内存非常大,并且页式内存管理方式也可以提供比段式管内存管理更多、更灵活的内存保护方式,于是段就显得不那么重要了。为了先后兼容又不能将段式去掉,“平台内存模型”由然而出。
平坦模型建议创建4个段,分别为用于特权级3级的用户代码段、数据段以及用于特权级0的内核代码段、数据段。四个段基址都为0,并且地址空间完全相同。平坦内存模型几乎完全隐藏了分段机制。Linux内核从2.1.43开始使用平坦模型,定义四个段如下
// arch/i386/kernel/head.S
ENTRY(gdt_table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* not used */
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
VMM为Guest准备物理内存
计算机启动之后,主板的BIOS ROM将会被映射到内存地址空间,以x86架构的32位系统为例,映射的地址空间为0x000F0000 ~ 0x000FFFFF,然后CPU跳转到0xF000:0xFFF0处,开始运行BIOS代码。 BIOS会检查内存信息,记录下来,并且对外提供信息函数的地址,后续的boot loader或者OS就可以通过发起中断号为0行5的软中断,调用BIOS中的这个函数,获取系统内存信息。
因此,为了给Guest提供这个功能,VMM需要模拟BIOS的操作
- 主板的BIOS ROM是被系统映射到内存地址空间 0x000F0000 ~ 0x000FFFFF。对于软件模拟而言,就不需要这个映射过程,VMM可以自己实现查询内存信息的终端处理函数,并且放入上述内存中
- 在建立IVT(中断表向量)时,设置第0x15个表项中的中断函数地址指向模拟的BIOS中的内存查询处理函数的地址
- 对于真实物理地址,BIOS会查询真实的物理内存情况,建立内存信息。而对于VMM而言,则需要根据用户配置的虚拟机的内存信息,在BIOS数据区中自己制造内存信息表
中断号为0x15的BIOS中断处理函数,即寄存器eax、ah中的值将返回不同的内存信息。比如将ah寄存器设置为0x8A时,中断将返回扩展内存大小,即地址在1MB以上的内存的尺寸;将ah寄存器设为0x88时,最多检测64MB的内存,超过64也返回64。功能最强的将wax寄存器的值设置为0xE820,0x15号中断处理函数将返回主机的完整的内存信息。VMM需要模拟的BIOS与内存相关部分如图
在BIOS探测完内存信息后,会将内存信息保存在BIOS的数据区BDA/EBDA中,当bootloader或者OS通过软中断的方式向BIOS询问内存信息时,BIOS中的中断处理函数将从BIOS的数据区中复制内存信息到调用者指定的位置。因此VMM是为系统提供一个透明的环境,那么就需要在虚拟BIOS使用的数据区,按照e820记录的格式准备好内存段的信息。关于IVT设置以及BIOS初始化等操作都位于加载内核文件处进行处理,因此这一部门暂时跳过
0X02 CORE_INIT
笔记一提到core_init作为函数运行优先级最高的方法,其运行的函即为kvm__init,作用加载文件/dev/kvm,创建KVM_GET_API_VERSION,初始化内存以及kvm__load_kernel
加载内核镜像。在这笔记中分析kvm__load_kernel
之前内容。分析./kvm.c
文件中kvm__init函数
kvm->sys_fd = open(kvm->cfg.dev, O_RDWR);
if (kvm->sys_fd < 0) {
...
goto err_free;
}
ret = ioctl(kvm->sys_fd, KVM_GET_API_VERSION, 0);
if (ret != KVM_API_VERSION) {
pr_err("KVM_API_VERSION ioctl");
ret = -errno;
goto err_sys_fd;
}
kvm->vm_fd = ioctl(kvm->sys_fd, KVM_CREATE_VM, kvm__get_vm_type(kvm));
if (kvm->vm_fd < 0) {
pr_err("KVM_CREATE_VM ioctl");
ret = kvm->vm_fd;
goto err_sys_fd;
}
该代码对文件/dev/kvm
进行读取来创建KVM的子系统。KVM_GET_API_VERSION将KVM API版本写入/dev/kvm中,之后使用KVM_CREATE_VM使得KVM API创建一个新的VM,并且返回他fd句柄。需要注意的是KVM_CREATE_VM并不会自动创建虚拟CPU以及虚拟内存。
函数kvm__check_extensions通过代码
ioctl(kvm->sys_fd, KVM_CHECK_EXTENSION, extension)
检查系统是否支持KVM需要的拓展。
kvm__arch_init
该函数对KVM的设计模式做初始化,代码位于./x86/kvm.c
中。在这个函数函数内,程序做了使用mmap分配虚拟化内存的实际动作,在此之前有如下几个操作
- 使用API
KVM_SET_TSS_ADDR
定义了一个三级页表的物理地址给guest地址,每一块区域都在4GB之内,并且不与其他内存条冲突。VMM TSS表将会被设置到0xfffbd000
,因此设置物理页面为0xfffbd000
。 - 使用
KVM_CREATE_PIT2
为内核使用PIT(i8524)时钟模拟。将.flag设置为0表示不设置额外的选项。.flag可选的选项为- KVM_PIT_SPEAKER_DUMMY: 禁用PIT信道并且不产生任何输出
- KVM_PIT_FLAGS_HPET_LEGACY: 设置PIT模式为HPET映射模式
判断需要的内存是否大于32bit的最大支持内存(这里默认不大于)。之后进入函数mmap_anon_or_hugetlbfs
使用mmap进行虚拟内存分配
void *mmap_anon_or_hugetlbfs(struct kvm *kvm, const char *hugetlbfs_path, u64 size)
{
if (hugetlbfs_path)
// 不进入判断
/*
* We don't /need/ to map guest RAM from hugetlbfs, but we do so
* if the user specifies a hugetlbfs path.
*/
return mmap_hugetlbfs(kvm, hugetlbfs_path, size);
else {
kvm->ram_pagesize = getpagesize();
return mmap(NULL, size, PROT_RW, MAP_ANON_NORESERVE, -1, 0);
}
}
在这之后,利用KVM_CREATE_IRQCHIP创建中断控制模式。至此,虚拟内存的起始地址分配完成。
kvm__init_ram
用于配分内存区域,使得VM能够找到分配的虚拟内存空间。首先就是结构体kvm内的参数mem_banks。一个双向链表的结构体,用于存储虚拟内存的多级页表。在进入init_ram函数之间使用INIT_LIST_HEAD
对mam_banks链表做初始化。函数内默认设置内存小于4GB,进入if之后的判断进入函数kvm__register_ram中进行内存初始化。kvm__register_ram的函数位于./kvm.c
中,我们会在函数中看到这样一段代码
list_for_each_entry(bank, &kvm->mem_banks, list)
{
u64 bank_end = bank->guest_phys_addr + bank->size - 1;
u64 end = guest_phys + size - 1;
if (guest_phys > bank_end || end < bank->guest_phys_addr)
{
if (bank->slot == slot)
{
slot++;
prev_entry = &bank->list;
}
continue;
}
/* Merge overlapping reserved regions */
if (bank->type == KVM_MEM_TYPE_RESERVED &&
type == KVM_MEM_TYPE_RESERVED)
...
}
list_for_each_entry函数用于合并保存内存条,用于暂停VM时使用,实测在使用命令运行时kvm__init_ram函数进入这个代码块的时候并不会运行这个一块代码,因此暂时忽略。因此进入到kvm__register_ram
所带入的参数是KVM_MEM_TYPE_RAM
,因此会进入到如下if判断中
userspace_addr = kvm->ram_start;
guest_phys = 0;
size = kvm->ram_size;
...
if (type != KVM_MEM_TYPE_RESERVED) {
mem = (struct kvm_userspace_memory_region){
.slot = slot,
.flags = flags,
.guest_phys_addr = guest_phys,
.memory_size = size,
.userspace_addr = (unsigned long)userspace_addr,
};
ret = ioctl(kvm->vm_fd, KVM_SET_USER_MEMORY_REGION, &mem);
if (ret < 0)
{
ret = -errno;
goto out;
}
}
和真实的物理机可能有多个内存条一样,创建虚拟机时,VMM也需要为虚拟机分配内存条。因此定义了结构体kvm_memory_region
。其中slot表示这个结构体kvm_memory_region实例描述的是第几个内存条,guest_phys_addr表示这个内存条在Guest物理地址空间中的起始地址,memory_size表示内存条大小
因为linux在创建虚拟内存条的时候就为整个内存条预先静态分配好了所有内存页面的方式,如果Guest用不到其中的部分页面,内存就浪费了。并且这种方式不能利用虚拟内存交换机制,因此虚拟机的数量要受物理机物理内存大小的限制。因此由内核分配的方式演化为由用户空间分配,这样就可以利用虚拟内存机制,字段userspace_addr
即由此设计。当Guest发生缺页异常陷入KVM时,KVM根据地址Guest的缺页地址,计算出该地址属于的内存条,以内存条在Host用户空间的起始地址userspace_addr
为基址,加上缺页地址在内存段的偏移,即可将GPA空间的地址转换为HVA空间的地址,在转化为HVA地址后,内核就可以使用函数get_user_page按需动态分配物理页面了。 这里GPA地址也就是guest_phys_addr
默认设置为0,相关代码为
if (kvm->ram_size < KVM_32BIT_GAP_START)
{
/* Use a single block of RAM for 32bit RAM */
phys_start = 0;
phys_size = kvm->ram_size;
host_mem = kvm->ram_start;
kvm__register_ram(kvm, phys_start, phys_size, host_mem);
}
最后通过API KVM_SET_USER_MEMORY_REGION
将设置的内存条写入VM中。因为启动VM的方式是使用pthread_join
多线程启动CPU的函数,因此内存分配方式需要使用pthread_mutex_lock
进行上锁。
至此对VM的内存初始化结束,相关改动代码位于https://github.com/christasa/trivial-kvm/tree/073d9e32329b8668448c4417721af9dfd94bfed5
0XFF Reference
- https://www.kernel.org/doc/Documentation/virtual/kvm/api.txt
- https://en.wikipedia.org/wiki/Kernel-based_Virtual_Machine
- Inside the Linux Virtualization Principle and Implementation