1.大部分转载自QEMU 内存虚拟化源码分析 | Keep Coding | 苏易北 2.原文源码为 QEMU1.2.0,版本较旧,部分源码内容根据 QEMU6.2 版本修改 3.部分内容根据自己理解补充添加
概述 我们知道操作系统给每个进程分配虚拟内存,通过页表映射,变成物理内存进行访问。当有了虚拟机之后,情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程,但是虚拟机里面也有内核,也有虚拟机里面跑的进程。所以有了虚拟机,内存就变成了四类:
虚拟机里面的虚拟内存(Guest OS Virtual Memory,GVA),这是虚拟机里面的进程看到的内存空间;
虚拟机里面的物理内存(Guest OS Physical Memory,GPA),这是虚拟机里面的操作系统看到的内存,它认为这是物理内存;
物理机的虚拟内存(Host Virtual Memory,HVA),这是物理机上的 qemu 进程看到的内存空间;
物理机的物理内存(Host Physical Memory,HPA),这是物理机上的操作系统看到的内存。
内存虚拟化的关键在于维护 GPA
到 HVA
的映射关系。
页面分配和映射的两种方式 要搞清楚 QEMU system emulation 的仿真架构,首先对于 Host OS,将 QEMU 作为进程启动,然后对于 QEMU 进程,会仿真各种硬件和运行 Guest OS,在这层 OS 上运行要全系统模拟的应用程序,因此对于 Guest OS 管理的内存要实现到 QEMU 进程的虚拟空间的转换需要 softMMU(即需要对 GPA 到 HVA 进行转换)。从 GVA 到 GPA 到 HVA 到 HPA,性能很差,为了解决这个问题,有两种主要的思路。
影子页表 Shadow Page Table,SPT 第一种方式就是软件的方式,影子页表(Shadow Page Table)。
KVM 通过维护记录 GVA->HPA 的影子页表 SPT,减少了地址转换带来的开销,可以直接将 GVA 转换为 HPA。
内存映射要通过页表来管理,页表地址应该放在 CR3 寄存器里面。在软件虚拟化的内存转换中,GVA 到 GPA 的转换通过查询 CR3 寄存器来完成,CR3 中保存了 Guest 的页表基地址,然后载入 MMU 中进行地址转换。
在加入了 SPT 技术后,当 Guest 访问 CR3 时,KVM 会捕获到这个操作 EXIT_REASON_CR_ACCESS,之后 KVM 会载入特殊的 CR3 和影子页表,欺骗 Guest 这就是真实的 CR3。之后就和传统的访问内存方式一致,当需要访问物理内存的时候,只会经过一层影子页表的转换。
本来的过程是,客户机要通过 cr3 找到客户机的页表,实现从 GVA 到 GPA 的转换,然后在宿主机上,要通过 cr3 找到宿主机的页表,实现从 HVA 到 HPA 的转换。 为了实现客户机虚拟地址空间到宿主机物理地址空间的直接映射。客户机中每个进程都有自己的虚拟地址空间,所以 KVM 需要为客户机中的每个进程页表都要维护一套相应的影子页表。 在客户机访问内存时,使用的不是客户机的原来的页表,而是这个页表对应的影子页表,从而实现了从客户机虚拟地址到宿主机物理地址的直接转换。而且,在 TLB 和 CPU 缓存上缓存的是来自影子页表中客户机虚拟地址和宿主机物理地址之间的映射,也因此提高了缓存的效率。
为了快速检索 Guest 页表对应的影子页表,KVM 为每个客户机维护了一个 hash 表来进行客户机页表到影子页表之间的映射。对于每一个 Guest 来说,其页目录和页表都有唯一的 GPA,通过页目录/页表的 GPA 就可以在哈希链表中快速地找到对应的影子页目录/页表。
当 Guest 切换进程时,Guest 会把待切换进程的页表基址载入 CR3,而 KVM 将会截获这一特权指令。KVM 在哈希表中找到与此页表基址对应的影子页表基址,载入 Guest CR3,使 Guest 在恢复运行时 CR3 实际指向的是新切换进程对应的影子页表。
影子页表的引入,减少了 GVA->HPA 的转换开销,但是缺点在于需要为 Guest 的每个进程都维护一个影子页表,这将带来很大的内存开销。同时影子页表的建立是很耗时的,如果 Guest 的进程过多,将导致影子页表频繁切换。
因此 Intel 和 AMD 在此基础上提供了基于硬件的虚拟化技术 EPT。
扩展页表 Extent Page Table,EPT Intel 的 EPT(Extent Page Table)技术和 AMD 的 NPT(Nest Page Table)技术都对内存虚拟化提供了硬件支持。这两种技术原理类似,都是在硬件层面上实现 GVA 到 HPA 之间的转换。下面就以 EPT 为例分析一下 KVM 基于硬件辅助的内存虚拟化实现。
EPT 在原有客户机页表对客户机虚拟地址 GVA 到客户机物理地址 GPA 映射的基础上,又引入了 EPT 页表来实现客户机物理地址 GPA 到宿主机物理地址 HPA 的另一次映射。客户机运行时,客户机页表被载入 CR3,而 EPT 页表被载入专门的 EPT 页表指针寄存器 EPTP。
即 EPT 技术采用了在两级页表结构,即原有 Guest OS 页表对 GVA->GPA 映射的基础上,又引入了 EPT 页表来实现 GPA->HPA 的另一次映射,这两次地址映射都是由硬件自动完成 。
有了 EPT,在GPA->HPA 转换的过程中,缺页会产生 EPT 缺页异常。KVM 首先根据引起异常的客户机物理地址,映射到对应的宿主机虚拟地址,然后为此虚拟地址分配新的物理页,最后 KVM 再更新 EPT 页表,建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。
KVM 只需为每个客户机维护一套 EPT 页表,也大大减少了内存的开销。
这里,我们重点看第二种方式。因为使用了 EPT 之后,客户机里面的页表映射,也即从 GVA 到 GPA 的转换,还是用传统的方式,和在内存管理那一章讲的没有什么区别。而 EPT 重点帮我们解决的就是从 GPA 到 HPA 的转换问题。因为要经过两次页表,所以 EPT 又 tdp(two dimentional paging)。
EPT 的页表结构也是分为四层,EPT Pointer(EPTP)指向 PML4 的首地址。
QEMU 的主要工作 内存虚拟化的目的就是让虚拟机能够无缝的访问内存。有了 Intel EPT 的支持后,CPU 在 VMX non-root 状态时进行内存访问会再做一次 EPT 转换。在这个过程中,QEMU 会负责以下内容:
首先需要从自己的进程地址空间中申请内存用于 Guest 需要将上一步中申请到的内存的虚拟地址(HVA)和 Guest 的物理地址之间的映射关系传递给 KVM(kernel),即 GPA->HVA 需要组织一系列的数据结构来管理虚拟内存空间,并在内存拓扑结构更改时将最新的内存信息同步至 KVM 中
QEMU 和 KVM 的工作分界 QEMU 和 KVM 之间是通过 KVM 提供的 ioctl() 接口进行交互的。在内核的 kvm_vm_ioctl() 中,设置虚拟机内存 的系统调用【kernel 就是一系列系统调用函数接口和处理逻辑,其中有个处理”创建/设置虚拟机内存“的系统调用接口】为 VM_SET_USER_MEMORY_REGION
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static long kvm_vm_ioctl (struct file *filp, unsigned int ioctl, unsigned long arg) { case KVM_SET_USER_MEMORY_REGION: { struct kvm_userspace_memory_region kvm_userspace_mem ; r = -EFAULT; if (copy_from_user(&kvm_userspace_mem, argp, sizeof kvm_userspace_mem)) goto out; r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem, 1 ); if (r) goto out; break ; } }
可以看到这里需要传递的参数类型为 kvm_userspace_memory_region
:
1 2 3 4 5 6 7 8 struct kvm_userspace_memory_region { __u32 slot; __u32 flags; __u64 guest_phys_addr; __u64 memory_size; __u64 userspace_addr; };
KVM_SET_USER_MEMORY_REGION
这个 ioctl
主要目的就是设置GPA->HVA
的映射关系,KVM 会继续调用kvm_vm_ioctl_set_memory_region()
,在内核空间维护并管理 Guest 的内存。
相关数据结构 AddressSpace 结构体定义 QEMU 用 AddressSpace 结构体表示 Guest 中 CPU/设备看到的内存【也就是Guest OS 可以在 QEMU 进程虚存中用到的所有内存,是 MemoryRegion 的集合,即 GPA 的整体】,类似于物理机中地址空间的概念,但在这里表示的是 Guest 的一段地址空间,如内存地址空间 address_space_memory
、I/O
地址空间address_space_io
,它在 QEMU 源码memory.c
中定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 struct AddressSpace { struct rcu_head rcu ; char *name; MemoryRegion *root; struct FlatView *current_map ; int ioeventfd_nb; struct MemoryRegionIoeventfd *ioeventfds ; QTAILQ_HEAD(, MemoryListener) listeners; QTAILQ_ENTRY(AddressSpace) address_spaces_link; };
每个 AddressSpace 一般包含一系列的 MemoryRegion:root
指针指向根级 MemoryRegion,而 root 可能有自己的若干个 sub-regions(子节点),于是形成树状结构。这些 MemoryRegion 通过树连接起来,树的根即为 AddressSpace 的 root 域。
全局变量 另外,QEMU 中有两个全局的静态 AddressSpace,在 memory.c 中定义:
1 2 static AddressSpace address_space_memory; static AddressSpace address_space_io;
其 root 域分别指向之后会提到的两个 MemoryRegion 类型变量:system_memory、system_io。
MemoryRegion 结构体定义 MemoryRegion
表示在 Guest Memory Layout
中的一段内存区域【也就是单元级 GPA 的概念,Guest OS 可以管理到的那些 Guest 物理内存单元】,它是联系 GPA
和 RAMBlocks
(描述真实内存)之间的桥梁,在memory.h
中定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 struct MemoryRegion { const MemoryRegionOps *ops; void *opaque; MemoryRegion *parent; Int128 size; target_phys_addr_t addr; void (*destructor)(MemoryRegion *mr); ram_addr_t ram_addr; bool subpage; bool terminates; bool readable; bool ram; bool readonly; bool enabled; bool rom_device; bool warning_printed; MemoryRegion *alias; target_phys_addr_t alias_offset; unsigned priority; bool may_overlap; QTAILQ_HEAD(subregions, MemoryRegion) subregions; QTAILQ_ENTRY(MemoryRegion) subregions_link; QTAILQ_HEAD(coalesced_ranges, CoalescedMemoryRange) coalesced; const char *name; uint8_t dirty_log_mask; unsigned ioeventfd_nb; MemoryRegionIoeventfd *ioeventfds; };
全局变量 在 QEMU 的 exec.c 中也定义了两个静态的 MemoryRegion 指针变量:
1 2 static MemoryRegion *system_memory; static MemoryRegion *system_io;
与两个全局 AddressSpace 对应,即 AddressSpace 的 root 域指向这两个 MemoryRegion。
MemoryRegion 的类型 MemoryRegion 有多种类型,可以表示一段 RAM、ROM、MMIO、alias(别名)。
若为 alias 则表示一个 MemoryRegion 的部分区域,例如,QEMU 会为 pc.ram 这个表示 RAM 的 MemoryRegion 添加两个 alias:ram-below-4g 和 ram-above-4g,之后会看到具体的代码实例。
另外,MemoryRegion 也可以表示一个 container,这就表示它只是其他若干个 MemoryRegion 的容器。
那么要如何创建不同类型的 MemoryRegion
呢?
在 QEMU 中实际上是通过调用不同的初始化函数区分的。根据不同的初始化函数及其功能,可以将 MemoryRegion 划分为以下三种类型:
根级 MemoryRegion:直接通过 memory_region_init 初始化,没有自己的内存,用于管理 subregion,例如 system_memory:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void memory_region_init (MemoryRegion *mr, const char *name, uint64_t size) { mr->ops = NULL ; mr->parent = NULL ; mr->size = int128_make64(size); if (size == UINT64_MAX) { mr->size = int128_2_64(); } mr->addr = 0 ; mr->subpage = false ; mr->enabled = true ; mr->terminates = false ; mr->ram = false ; mr->readable = true ; mr->readonly = false ; mr->rom_device = false ; mr->destructor = memory_region_destructor_none; mr->priority = 0 ; mr->may_overlap = false ; mr->alias = NULL ; QTAILQ_INIT(&mr->subregions); memset (&mr->subregions_link, 0 , sizeof mr->subregions_link); QTAILQ_INIT(&mr->coalesced); mr->name = g_strdup(name); mr->dirty_log_mask = 0 ; mr->ioeventfd_nb = 0 ; mr->ioeventfds = NULL ; }
可以看到 mr->addr 被设置为 0,而 mr->ram_addr 则并没有初始化。
实体 MemoryRegion
:通过memory_region_init_ram()
初始化,有自己的内存(从 QEMU 进程地址空间中分配),大小为size
,例如ram_memory
、 pci_memory
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void *pc_memory_init (MemoryRegion *system_memory, const char *kernel_filename, const char *kernel_cmdline, const char *initrd_filename, ram_addr_t below_4g_mem_size, ram_addr_t above_4g_mem_size, MemoryRegion *rom_memory, MemoryRegion **ram_memory) { MemoryRegion *ram, *option_rom_mr; ram = g_malloc(sizeof (*ram)); memory_region_init_ram(ram, "pc.ram" , below_4g_mem_size + above_4g_mem_size); vmstate_register_ram_global(ram); *ram_memory = ram; }
1 2 3 4 5 6 7 8 9 10 void memory_region_init_ram (MemoryRegion *mr, const char *name, uint64_t size) { memory_region_init(mr, name, size); mr->ram = true ; mr->terminates = true ; mr->destructor = memory_region_destructor_ram; mr->ram_addr = qemu_ram_alloc(size, mr); }
可以看到这里是先调用了memory_region_init()
,之后设置 RAM
属性,并继续调用qemu_ram_alloc()
分配内存。
别名 MemoryRegion
:通过memory_region_init_alias()
初始化,没有自己的内存,表示实体 MemoryRegion
的一部分。通过 alias
成员指向实体 MemoryRegion
,alias_offset
为在实体 MemoryRegion
中的偏移量,例如ram_below_4g
、ram_above_4g
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void *pc_memory_init (MemoryRegion *system_memory, const char *kernel_filename, const char *kernel_cmdline, const char *initrd_filename, ram_addr_t below_4g_mem_size, ram_addr_t above_4g_mem_size, MemoryRegion *rom_memory, MemoryRegion **ram_memory) { MemoryRegion *ram_below_4g, *ram_above_4g; ram_below_4g = g_malloc(sizeof (*ram_below_4g)); memory_region_init_alias(ram_below_4g, "ram-below-4g" , ram, 0 , below_4g_mem_size);
1 2 3 4 5 6 7 8 9 10 void memory_region_init_alias (MemoryRegion *mr, const char *name, MemoryRegion *orig, target_phys_addr_t offset, uint64_t size) { memory_region_init(mr, name, size); mr->alias = orig; mr->alias_offset = offset; }
RAMBlock 结构体定义 MemoryRegion
用来描述一段逻辑层面上的内存区域,而记录实际分配的内存地址信息的结构体则是 RAMBlock
,在ramblock.h
中定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 struct RAMBlock { struct rcu_head rcu ; struct MemoryRegion *mr ; uint8_t *host; uint8_t *colo_cache; ram_addr_t offset; ram_addr_t used_length; ram_addr_t max_length; void (*resized)(const char *, uint64_t length, void *host); uint32_t flags; char idstr[256 ]; QLIST_ENTRY(RAMBlock) next; QLIST_HEAD(, RAMBlockNotifier) ramblock_notifiers; int fd; size_t page_size; unsigned long *bmap; unsigned long *receivedmap; unsigned long *clear_bmap; uint8_t clear_bmap_shift; ram_addr_t postcopy_length; };
可以看到在 RAMBlock
中 host 和 offset 域分别对应了 HVA
和GPA
,因此也可以说 RAMBlock
中存储了GPA->HVA
的映射关系,另外每一个 RAMBlock
都会指向其所属的 MemoryRegion
。
全局变量 ram_list QEMU 在ramlist.h
中定义了一个全局变量ram_list
,以链表的形式维护了所有的 RAMBlock
:
1 2 3 4 5 6 7 8 9 10 typedef struct RAMList { QemuMutex mutex; RAMBlock *mru_block; QLIST_HEAD(, RAMBlock) blocks; DirtyMemoryBlocks *dirty_memory[DIRTY_MEMORY_NUM]; uint32_t version; QLIST_HEAD(, RAMBlockNotifier) ramblock_notifiers; } RAMList;extern RAMList ram_list;
每一个新分配的 RAMBlock
都会被插入到ram_list
的头部。如需查找地址所对应的 RAMBlock
,则需要遍历ram_list
,当目标地址落在当前RAMBlock
的地址区间时,该 RAMBlock
即为查找目标。
AS、MR、RAMBlock 之间的关系
FlatView AddressSpace 的 root 域及其子树共同构成了 Guest 的物理地址空间,但这些都是在 QEMU 侧定义的。要传入 KVM 进行设置时,复杂的树状结构是不利于内核进行处理的,因此需要将其转换为一个“平坦”的地址模 型,也就是一个从零开始、只包含地址信息的数据结构,这在 QEMU 中通过 FlatView 来表示。每个 AddressSpace 都有一个与之对应的 FlatView 指针 current_map,表示其对应的平面展开视图。
结构体定义 FlatView
在memory.c
中定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 struct FlatView { struct rcu_head rcu ; unsigned ref; FlatRange *ranges; unsigned nr; unsigned nr_allocated; struct AddressSpaceDispatch *dispatch ; MemoryRegion *root; };
其中,ranges
是一个数组,记录了 FlatView
下所有的 FlatRange
。
FlatRange 在 FlatView
中,FlatRange
表示在 FlatView
中的一段内存范围,同样在memory.c
中定义:
1 2 3 4 5 6 7 8 9 10 struct FlatRange { MemoryRegion *mr; hwaddr offset_in_region; AddrRange addr; uint8_t dirty_log_mask; bool romd_mode; bool readonly; bool nonvolatile; };
每个 FlatRange
对应一段虚拟机物理地址区间,各个 FlatRange
不会重叠,按照地址的顺序保存在数组中 ,具体的地址范围由一个 AddrRange
结构来描述:
1 2 3 4 5 6 7 struct AddrRange { Int128 start; Int128 size; };
MemoryRegionSection 结构体定义 在 QEMU
中,还有几个起到中介作用的结构体,MemoryRegionSection
就是其中之一。
之前介绍的 FlatRange
代表一个物理地址空间的片段,偏向于描述在 Host
侧即 AddressSpace 中的分布【Guest 的物理空间】 ,而 MemoryRegionSection
则代表在 Guest
侧即 MemoryRegion 中的片段 。MemoryRegionSection
在memory.h
中定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct MemoryRegionSection { MemoryRegion *mr; MemoryRegion *address_space; target_phys_addr_t offset_within_region; uint64_t size; target_phys_addr_t offset_within_address_space; bool readonly; };
offset_within_region
:在所属 MemoryRegion
中的offset
。一个AddressSpace
可能由多个 MemoryRegion
组成,因此该 offset
是局部的
offset_within_address_space
:在所属 AddressSpace
中的 offset
,它是全局的
和其他数据结构之间的关系
AddressSpace
的root
指向对应的根级MemoryRegion
,current_map
指向AddressSpace
的root
通过generate_memory_topology()
生成的 FlatView
FlatView
中的ranges
数组表示该MemoryRegion
所表示的Guest
地址区间【GPA 的整个平坦物理空间】,并按照地址的顺序进行排列
MemoryRegionSection
由ranges
数组中的 FlatRange
对应生成,作为注册到 KVM
中的基本单位
QEMU
在用户空间申请内存后,需要将内存信息通过一系列系统调用传入内核空间的 KVM
,由 KVM
侧进行管理,因此 QEMU
侧也定义了一些用于向 KVM
传递参数的结构体。
以下为KVM
相关的数据结构。
KVMSlot 在 kvm_init.h
中定义,是 KVM
中内存管理的基本单位:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 typedef struct KVMSlot { hwaddr start_addr; ram_addr_t memory_size; void *ram; int slot; int flags; unsigned long *dirty_bmap; unsigned long dirty_bmap_size; int as_id; ram_addr_t ram_start_offset; } KVMSlot;
KVMSlot
类似于内存插槽的概念。
kvm_userspace_memory_region 调用ioctl(KVM_SET_USER_MEMORY_REGION)
时需要向 KVM
传递的参数,在kvm.h
中定义
1 2 3 4 5 6 7 8 struct kvm_userspace_memory_region { __u32 slot; __u32 flags; __u64 guest_phys_addr; __u64 memory_size; __u64 userspace_addr; };
MemoryListener 结构体定义 为了监控虚拟机的物理地址访问,对于每一个 AddressSpace
,都会有一个 MemoryListener
与之对应。每当物理映射GPA->HVA
发生改变时,就会回调这些函数。MemoryListener 是对一些事件的回调函数合集 ,在memory.h
中定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 struct MemoryListener { void (*begin)(MemoryListener *listener); void (*commit)(MemoryListener *listener); void (*region_add)(MemoryListener *listener, MemoryRegionSection *section); void (*region_del)(MemoryListener *listener, MemoryRegionSection *section); void (*region_nop)(MemoryListener *listener, MemoryRegionSection *section); void (*log_start)(MemoryListener *listener, MemoryRegionSection *section); void (*log_stop)(MemoryListener *listener, MemoryRegionSection *section); void (*log_sync)(MemoryListener *listener, MemoryRegionSection *section); void (*log_global_start)(MemoryListener *listener); void (*log_global_stop)(MemoryListener *listener); void (*eventfd_add)(MemoryListener *listener, MemoryRegionSection *section, bool match_data, uint64_t data, EventNotifier *e); void (*eventfd_del)(MemoryListener *listener, MemoryRegionSection *section, bool match_data, uint64_t data, EventNotifier *e); unsigned priority; MemoryRegion *address_space_filter; QTAILQ_ENTRY(MemoryListener) link; };
全局变量 memory_listeners 所有的 MemoryListener
都会挂在全局变量memory_listeners
链表上,在memory.c
中定义:
1 2 static QTAILQ_HEAD (, MemoryListener) memory_listeners = QTAILQ_HEAD_INITIALIZER(memory_listeners);
在memory.c
中枚举了ListenerDirection
:
1 enum ListenerDirection { Forward, Reverse };
重要数据结构总览
结构体名
说明
AddressSpace
VM 能看到的一段地址空间,偏向 Host 侧【注意指的是偏向】
MemoryRegion
地址空间中一段逻辑层面的内存区域,偏向 Guest 侧
RAMBlock
记录实际分配的内存地址信息,存储了 GPA->HVA 的映射关系
FlatView
MemoryRegion 对应的平面展开视图,包含一个 FlatRange 类型的 ranges 数组
FlatRange
对应一段虚拟机物理地址区间,各个 FlatRange 不会重叠,按照地址的顺序保存在数组中
MemoryRegionSection
表示 MemoryRegion 中的片段
MemoryListener
回调函数集合
KVMSlot
KVM 中内存管理的基本单位,表示一个内存插槽
kvm_userspace_memory_region
调用 ioctl(KVM_SET_USER_MEMORY_REGION) 时需要向 KVM 传递的参数
具体实现机制 QEMU 的内存申请流程大致可分为三个部分:回调函数的注册、AddressSpace 的初始化、实际内存的分配。下面将根据在 vl.c 的 main() 函数中的调用顺序分别介绍。
回调函数的注册
1 2 3 4 5 6 7 8 9 10 11 int main () └─ static int configure_accelerator () └─ int kvm_init () ├─ int kvm_ioctl (KVM_CREATE_VM) ├─ int kvm_arch_init () └─ void memory_listener_register () └─ static void listener_add_address_space () └─ static void kvm_region_add () └─ static void kvm_set_phys_mem () └─ static int kvm_set_user_memory_region () └─ int ioctl (KVM_SET_USER_MEMORY_REGION)
进入configure_accelerator()
后,QEMU
会先调用configure_accelerator()
设置 KVM
的加速支持,之后进入kvm_init()
。该函数主要完成对 KVM 的初始化,包括一些常规检查如 CPU
个数、KVM
版本等,之后通过kvm_ioctl(KVM_CREATE_VM)
与内核交互,创建 KVM
虚拟机。在kvm_init()
的最后,会调用memory_listener_register()
注册kvm_memory_listener
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 static int kvm_init (MachineState *ms) { MachineClass *mc = MACHINE_GET_CLASS(ms); s->fd = qemu_open_old("/dev/kvm" , O_RDWR); do { ret = kvm_ioctl(s, KVM_CREATE_VM, type); } while (ret == -EINTR); ret = kvm_arch_init(s); kvm_memory_listener_register(s, &s->memory_listener, &address_space_memory, 0 , "kvm-memory" ); memory_listener_register(&kvm_coalesced_pio_listener, &address_space_io); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 void memory_listener_register (MemoryListener *listener, AddressSpace *as) { MemoryListener *other = NULL ; assert(!(listener->log_sync && listener->log_sync_global)); listener->address_space = as; if (QTAILQ_EMPTY(&memory_listeners) || listener->priority >= QTAILQ_LAST(&memory_listeners)->priority) { QTAILQ_INSERT_TAIL(&memory_listeners, listener, link); } else { QTAILQ_FOREACH(other, &memory_listeners, link) { if (listener->priority < other->priority) { break ; } } QTAILQ_INSERT_BEFORE(other, listener, link); } if (QTAILQ_EMPTY(&as->listeners) || listener->priority >= QTAILQ_LAST(&as->listeners)->priority) { QTAILQ_INSERT_TAIL(&as->listeners, listener, link_as); } else { QTAILQ_FOREACH(other, &as->listeners, link_as) { if (listener->priority < other->priority) { break ; } } QTAILQ_INSERT_BEFORE(other, listener, link_as); } listener_add_address_space(listener, as); }
最后的listener_add_address_space()
主要是将 listener 注册到其对应的 AddressSpace
上,并根据 AddressSpace
对应的 FlatRange
数组,生成 MemoryRegionSection
【MemoryRegionSection
就像是为FlatRange
数组设置的一种中介表示,便于传入KVM
,因为传入KVM
应该是对平坦内存的一种表示】,并注册到 KVM
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 static void listener_add_address_space (MemoryListener *listener, AddressSpace *as) { FlatView *view; FlatRange *fr; if (listener->begin) { listener->begin(listener); } if (global_dirty_tracking) { if (listener->log_global_start) { listener->log_global_start(listener); } } view = address_space_get_flatview(as); FOR_EACH_FLAT_RANGE(fr, view) { MemoryRegionSection section = section_from_flat_range(fr, view); if (listener->region_add) { listener->region_add(listener, §ion); } if (fr->dirty_log_mask && listener->log_start) { listener->log_start(listener, §ion, 0 , fr->dirty_log_mask); } } if (listener->commit) { listener->commit(listener); } flatview_unref(view); }
由于此时 AddressSapce
尚未初始化,所以此处的循环为空,仅是在全局注册了kvm_memory_listener
。最后调用了kvm_memory_listener->region_add()
,对应的实现是kvm_region_add()
,该函数最终会通过ioctl(KVM_SET_USER_MEMORY_REGION)
,将 QEMU
侧申请的内存信息传入 KVM
进行注册,这里的流程会在下一部分进行分析。
AddressSpace 的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int main () └─ void cpu_exec_init_all () ├─ static void memory_map_init () | ├─ void memory_region_init () | ├─ void set_system_memory_map () | | └─ static void memory_region_update_topology () | | └─ static void address_space_update_topology () | | └─ static void address_space_update_topology_pass () | | └─ static void kvm_region_add () | | └─ static void kvm_set_phys_mem () | | └─ static int kvm_set_user_memory_region () | | └─ int ioctl (KVM_SET_USER_MEMORY_REGION) | | | └─ void memory_listener_register () | └─ static void listener_add_address_space () | └─ static void io_mem_init () └─ void memory_region_init_io () └─ void memory_region_init ()
第一部分在全局注册了kvm_memory_listener
,但由于AddressSpace
尚未初始化,实际上并未向 KVM
中注册任何实际的内存信息。QEMU
在main()
函数中会继续调用cpu_exec_init_all()
对AddressSpace
进行初始化,该函数实际上是对两个 init
函数的封装调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void cpu_exec_init_all (void ) { qemu_mutex_init(&ram_list.mutex); finalize_target_page_bits(); io_mem_init(); memory_map_init(); qemu_mutex_init(&map_client_list_lock); }
先来看memory_map_init()
,主要用来初始化两个全局的系统地址空间system_memory
、system_io
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static void memory_map_init (void ) { system_memory = g_malloc(sizeof (*system_memory)); memory_region_init(system_memory, NULL , "system" , UINT64_MAX); address_space_init(&address_space_memory, system_memory, "memory" ); system_io = g_malloc(sizeof (*system_io)); memory_region_init_io(system_io, NULL , &unassigned_io_ops, NULL , "io" , 65536 ); address_space_init(&address_space_io, system_io, "I/O" ); }
这里比较重要的是address_space_init()
,先设置 AddressSpace
对应的 MemoryRegion
,之后根据system_memory
更新address_space_memory
对应的 FlatView
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void address_space_init (AddressSpace *as, MemoryRegion *root, const char *name) { memory_region_ref(root); as->root = root; as->current_map = NULL ; as->ioeventfd_nb = 0 ; as->ioeventfds = NULL ; QTAILQ_INIT(&as->listeners); QTAILQ_INSERT_TAIL(&address_spaces, as, address_spaces_link); as->name = g_strdup(name ? name : "anonymous" ); address_space_update_topology(as); address_space_update_ioeventfds(as); }
address_space_update_topology()
会继续调用generate_memory_topology()
生成 AddressSpace
对应的 FlatView
视图:
1 2 3 4 5 6 7 8 9 10 static void address_space_update_topology (AddressSpace *as) { MemoryRegion *physmr = memory_region_get_flatview_root(as->root); flatviews_init(); if (!g_hash_table_lookup(flat_views, physmr)) { generate_memory_topology(physmr); } address_space_set_flatview(as); }
address_space_update_topology()
会先调用generate_memory_topology()
生成system_memory
更新后的视图new_view
,再将address_space_memory
的current_map
指向这个new_view
,最后销毁old_view
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 static void address_space_set_flatview (AddressSpace *as) { FlatView *old_view = address_space_to_flatview(as); MemoryRegion *physmr = memory_region_get_flatview_root(as->root); FlatView *new_view = g_hash_table_lookup(flat_views, physmr); assert(new_view); if (old_view == new_view) { return ; } if (old_view) { flatview_ref(old_view); } flatview_ref(new_view); if (!QTAILQ_EMPTY(&as->listeners)) { FlatView tmpview = { .nr = 0 }, *old_view2 = old_view; if (!old_view2) { old_view2 = &tmpview; } address_space_update_topology_pass(as, old_view2, new_view, false ); address_space_update_topology_pass(as, old_view2, new_view, true ); } qatomic_rcu_set(&as->current_map, new_view); if (old_view) { flatview_unref(old_view); } if (old_view) { flatview_unref(old_view); } }
在address_space_update_topology_pass()
的最后,会调用MEMORY_LISTENER_UPDATE_REGION
这个宏,触发region_add
对应的回调函数kvm_region_add()
。
这个宏在memory.c
中定义,会将 FlatView
中的 FlatRange
转换为 MemoryRegionSection
,作为入参传递给kvm_region_add()
:
1 2 3 4 5 6 7 #define MEMORY_LISTENER_UPDATE_REGION(fr, as, dir, callback, _args...) \ do { \ MemoryRegionSection mrs = section_from_flat_range(fr, \ address_space_to_flatview(as)); \ MEMORY_LISTENER_CALL(as, callback, dir, &mrs, ##_args); \ } while(0)
而kvm_region_add()
实际上是对kvm_set_phys_mem()
的封装调用。该函数比较复杂,会根据传入的section
填充 KVMSlot,再传递给kvm_set_user_memory_region()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 static int kvm_set_user_memory_region (KVMMemoryListener *kml, KVMSlot *slot, bool new ) { KVMState *s = kvm_state; struct kvm_userspace_memory_region mem ; int ret; mem.slot = slot->slot | (kml->as_id << 16 ); mem.guest_phys_addr = slot->start_addr; mem.userspace_addr = (unsigned long )slot->ram; mem.flags = slot->flags; if (slot->memory_size && !new && (mem.flags ^ slot->old_flags) & KVM_MEM_READONLY) { mem.memory_size = 0 ; ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem); if (ret < 0 ) { goto err; } } mem.memory_size = slot->memory_size; ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem); slot->old_flags = mem.flags; return ret; }
可以看到这里又将 KVMSlot
转换为 kvm_userspace_memory_region
,作为ioctl()
的参数,交给内核中的 KVM
进行内存的注册【设置GPA->HVA
的映射关系,在内核空间维护并管理 Guest 的内存】。
至此 QEMU 侧负责管理内存的数据结构均已完成初始化,可以参考下面的图片了解各数据结构之间的对应关系
实际内存的分配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 int main () └─ void machine->init (ram_size, ...) └─ static void pc_init_pci (ram_size, ...) └─ static void pc_init1 (system_memory, system_io, ram_size, ...) ├─ void memory_region_init (pci_memory, "pci" , ...) └─ void pc_memory_init () ├─ void memory_region_init_ram () | ├─ void memory_region_init () | └─ ram_addr_t qemu_ram_alloc () | └─ ram_addr_t qemu_ram_alloc_from_ptr () | ├─ void vmstate_register_ram_global () | └─ void vmstate_register_ram () | └─ void qemu_ram_set_idstr () | ├─ void memory_region_init_alias () └─ void memory_region_add_subregion () └─ static void memory_region_add_subregion_common () └─ static void memory_region_update_topology () └─ static void address_space_update_topology () └─ static void address_space_update_topology_pass () └─ static void kvm_region_add () └─ static void kvm_set_phys_mem () └─ static int kvm_set_user_memory_region () └─ int ioctl (KVM_SET_USER_MEMORY_REGION)
之前的回调函数注册、AddressSpace 的初始化,实际上均没有对应的物理内存。【实际的内存是在 RAMBlock 中】
我们再回到 qemu
启动的 main
函数中。接下来的初始化过程会调用 pc_init1
。在这里面,对于 CPU
虚拟化,我们会调用 pc_cpus_init
。另外,pc_init1
还会调用pc_memory_init
,进行内存的虚拟化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 void *pc_memory_init (MemoryRegion *system_memory, const char *kernel_filename, const char *kernel_cmdline, const char *initrd_filename, ram_addr_t below_4g_mem_size, ram_addr_t above_4g_mem_size, MemoryRegion *rom_memory, MemoryRegion **ram_memory) { MemoryRegion *ram, *option_rom_mr; MemoryRegion *ram_below_4g, *ram_above_4g; ram = g_malloc(sizeof (*ram)); memory_region_init_ram(ram, "pc.ram" , below_4g_mem_size + above_4g_mem_size); vmstate_register_ram_global(ram); *ram_memory = ram; ram_below_4g = g_malloc(sizeof (*ram_below_4g)); memory_region_init_alias(ram_below_4g, "ram-below-4g" , ram, 0 , below_4g_mem_size); memory_region_add_subregion(system_memory, 0 , ram_below_4g); if (above_4g_mem_size > 0 ) { ram_above_4g = g_malloc(sizeof (*ram_above_4g)); memory_region_init_alias(ram_above_4g, "ram-above-4g" , ram, below_4g_mem_size, above_4g_mem_size); memory_region_add_subregion(system_memory, 0x100000000 ULL, ram_above_4g); } }
这里的重点在于memory_region_init_ram()
,它通过qemu_ram_alloc()
获取 ram 这个 MemoryRegion
对应的 RAMBlock
的offset
,并存入ram.ram_addr
,这样就可以在ram_list
中根据该字段查找 MR
对应的 RAMBlock
:
1 2 3 4 5 6 7 8 void memory_region_init_ram (MemoryRegion *mr, const char *name, uint64_t size) { memory_region_init(mr, name, size); mr->ram = true ; mr->terminates = true ; mr->destructor = memory_region_destructor_ram; mr->ram_addr = qemu_ram_alloc(size, mr); }
而 qemu_ram_alloc() 最终会调用 qemu_ram_alloc_from_ptr(),创建一个对应大小 RAMBlock 并分配内存,返回对应的 GPA 地址存入 mr->ram_addr 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 ram_addr_t qemu_ram_alloc_from_ptr (ram_addr_t size, void *host, MemoryRegion *mr) { RAMBlock *new_block; size = TARGET_PAGE_ALIGN(size); new_block = g_malloc0(sizeof (*new_block)); new_block->mr = mr; new_block->offset = find_ram_offset(size); if (host) { new_block->host = host; new_block->flags |= RAM_PREALLOC_MASK; } else { if (mem_path) { #if defined (__linux__) && !defined(TARGET_S390X) new_block->host = file_ram_alloc(new_block, size, mem_path); if (!new_block->host) { new_block->host = qemu_vmalloc(size); qemu_madvise(new_block->host, size, QEMU_MADV_MERGEABLE); }#else fprintf (stderr , "-mem-path option unsupported\n" ); exit (1 );#endif } else { if (xen_enabled()) { xen_ram_alloc(new_block->offset, size, mr); } else if (kvm_enabled()) { new_block->host = kvm_vmalloc(size); } else { new_block->host = qemu_vmalloc(size); } qemu_madvise(new_block->host, size, QEMU_MADV_MERGEABLE); } } new_block->length = size; QLIST_INSERT_HEAD(&ram_list.blocks, new_block, next); ram_list.phys_dirty = g_realloc(ram_list.phys_dirty, last_ram_offset() >> TARGET_PAGE_BITS); memset (ram_list.phys_dirty + (new_block->offset >> TARGET_PAGE_BITS), 0 , size >> TARGET_PAGE_BITS); cpu_physical_memory_set_dirty_range(new_block->offset, size, 0xff ); qemu_ram_setup_dump(new_block->host, size); if (kvm_enabled()) kvm_setup_guest_memory(new_block->host, size); return new_block->offset; }
这样一来ram
【其实就是system memory
,整个Guest
物理空间的大小】对应的 RAMBlock
中就分配好了 GPA
和 HVA
,就可以将内存信息同步至 KVM 侧 了。
最后回到pc_memory_init()
中,在分配完实际内存后,会先调用memory_region_init_alias()
初始化ram_below_4g
、ram_above_4g
这两个alias
,之后调用memory_region_add_subregion()
将这两个 alias
指向ram
这个实体 MemoryRegion
。如下图,该函数最终会触发kvm_region_add()
回调,将实际的内存信息传入 KVM
注册。该过程如下图所示,与之前分析的流程相同,此处不再赘述。
总结 虚拟机的内存管理也是需要用户态的 qemu
和内核态的 KVM
共同完成。为了加速内存映射,需要借助硬件的 EPT
技术。
QEMU 侧
创建一系列 MemoryRegion
,分别表示 Guest
中的 RAM
、ROM
等区域。MemoryRegion
之间通过 alias
或 subregions
的方式维护相互之间的关系,从而进一步细化区域的定义
对于一个实体 MemoryRegion
(非 alias
),在初始化内存的过程中 QEMU
会创建它所对应的 RAMBlock
。该 RAMBlock
通过调用qemu_ram_alloc_from_ptr()
从 QEMU
的进程地址空间中以 mmap 的方式分配内存 ,并负责维护该 MemoryRegion 对应内存的起始 GPA/HVA/size 等相关信息 【在qemu_ram_alloc_from_ptr
中创建的新RAMBlock
有offset
、host
的赋值,即GPA->HVA
的对应关系】
AddressSpace
表示 Guest
的物理地址空间。如果 AddressSpace
中的 MemoryRegion
发生变化,则注册的 listener
会被触发,将所属的 MemoryRegion
树展开生成一维的 FlatView
,比较 FlatRange 是否发生了变化。如果是,则调用相应的方法对 MemoryRegionSection
进行检查,更新 QEMU
中的 KVMSlot
,同时填充kvm_userspace_memory_region
结构体,作为ioctl()
的参数更新 KVM
中的kvm_memory_slot
KVM 侧
当 QEMU
通过ioctl()
创建 vcpu
时,调用kvm_mmu_create()
初始化 MMU
相关信息。 当 KVM
要进入 Guest
前,vcpu_enter_guest()=>kvm_mmu_reload()
会将根级页表地址加载到 VMCS
,让 Guest
使用该页表
当发生EPT Violation
时,VM-EXIT
到 KVM
中。如果是缺页,则根据 GPA
算出 gfn
,再根据 gfn
找到对应的 KVMSlot
,从中得到对应的 HVA
。然后根据 HVA
算出对应的 pfn
,确保该 Page
位于内存中。填好缺失的页之后,需要更新 EPT
,完善其中缺少的页表项,逐层补全页表
虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的,只有当虚拟机的内存被访问的时候,也即 mmap
分配的虚拟内存空间被访问的时候,先查看 EPT
页表,是否已经映射过,如果已经映射过,则经过四级页表映射,就能访问到物理页面。 如果没有映射过,则虚拟机会通过VM-Exit
指令回到宿主机模式,通过 handle_ept_violation
补充页表映射。先是通过 handle_mm_fault
为虚拟机的物理内存空间分配真正的物理页面,然后通过 __direct_map
添加 EPT
页表映射。
Reference “QEMU 内存空间虚拟化及内存管理” - B10g | FΓom 许大仙 【原创】Linux 虚拟化 KVM-Qemu 分析(五)之内存虚拟化 - LoyenWang - 博客园 KVM/Qemu 工作原理系列目录_xiongwenwu 的专栏-CSDN 博客_qemu 目录结构 QEMU 内存虚拟化源码分析 | Keep Coding | 苏易北
评论