Qemu-kvm memory 虚拟化

内存虚拟化

内存初始化

qemu的虚拟地址作为guest的物理地地址,guest运行在虚拟的地址空间,但终究是存在物理内存上的,所以要建立虚拟地址空间与物理地址之间的映射。另外,必须要为虚拟机运行程序提供安全的、隔离的运行环境,避免虚拟机之间、虚拟机与宿主机之间的相互干扰。qemu会提前为guest申请好虚拟地址,只有真正需要的时候,通过产生页中断申请物理地址,并且建立页表产生对应关系。

guest对内存的要求

对操作系统从加载到执行的情况来分析,它需要使用一个从0开始的、连续的物理地址空间.在有虚拟机运行的硬件平台上,唯一的物理内存是用于加载、运行宿主机的操作系统的,因此Guest OS是不能直接在物理内存上加载、运行的,唯一可行的办法是为Guest OS 提供一个虚拟的物理内存空间,我们称之为虚拟机物理地址空间,这个虚拟的内存空间是从0开始的、连续的内存空间,也是Guest OS能够感知和管理的虚拟物理内存单元,Guest OS在这个虚拟机物理内存空间的基础上为应用程序分配内存空间,创建页表等;
关键名词:
gva : Guest Virtual Address 虚拟机线性地址
gpa : Guest Physical Address 虚拟机物理地址
gfn : Guest Page Frame Number 虚拟机物理地址空间中一个物理页对应的页帧号
hva : Host Virtual Address 宿主机线性地址
hpa : Host Physical Address 宿主机物理地址
hfn : Host Page Frame Number 宿主机物理地址空间中一个物理页对应的页帧号
pte : Page Table Entry 页表项(包含页表的起始地址及相应访问权限位)
gpte : Guest Page Table Entry 虚拟机页表项
spte : Shadow Page Table Entry EPT表中的页表项

Linux 内核中采用四级页表:
页全局目录 (Page Global Directory, pgd);
页上级目录 (Page Upper Directory, pud);
页中间目录 (Page Middle Directory, pmd);
页表 (Page Table, pt)。

Qemu的内存布局

  1. 关键数据结构罗列
    AddressSpace、 MemoryRegion、 RAMBlock、 FlatView、 MemoryRegionSection、 KVMSlot、AddressSpaceDispatch、 kvm_userspace_memory_region。

  2. 全局内存数据结构
    AddressSpace:address_space_memory、address_space_io;
    MemoryRegion:system_memory、 system_io;

  3. 从数据结构间的关系分析Qemu的内存布局
    qemu中用AddressSpace用来表示CPU/设备看到的内存,两个全局 Address_sapce: address_space_memory、 address_space_io,地址空间之间通过链表连接起来;在一个全局地址空间下挂着一个全局的memoryRegion:system_memory、 system_io。在Qemu中通过一棵memoryRegion树的形式管理着不同的内存区间。MemoryRegion又可分为container, alias, subregions,这些类型用来组织管理整个内存空间。另外,涉及到内存的不同用途,mr又可分为ram, rom, mmio等。 AddressSpace构建了虚拟机的一部分地址空间,而挂载在AddressSpace下的根mr及其子树瓜分了该地址空间。利用 Mr树形结构,将地址空间划分不同的内存域进行分域管理。
  4. 从代码流程分析qemu内存布局
    4.1 地址空间初始化
    两个全局AS:address_space_memory, address_space_io;
    两个全局MR:system_memory、 system_io

    4.11 为AS注册回调函数

    kvm_memory_listener_register(s, &s->memory_listener,&address_space_memory, 0);
    memory_listener_register(&kvm_io_listener,&address_space_io);
    

    4.12 初始化地址空间

    1
    main -> cpu_exec_init_all -> memory_map_init->memory_region_init / address_space_init
    为全局 system\_memory、 system\_io MR分配内存,分别建立全局MR和全局AS的连接。初始化MR和AS的相关成员变量,并且为AS添加与MR相关事件(add, commit)的侦听函数,最后把不同的AS分别加入到全局的address\_spaces链表中,最后调用 memory\_region\_transaction\_commit() 提交本次修改.
    

    4.13 申请内存

    以申请system_memory为例,描述内存申请与管理的流程。 
    
    memory_region_allocate_system_memory - memory_region_init_alias - memory_region_add_subregion
        |
    allocate_system_memory_nonnuma
        |
    memory_region_init_ram
        |
    qemu_ram_alloc
        |
    ram_block_add
        |
    phys_mem_alloc //static void *(*phys_mem_alloc)(size_t size, uint64_t *align) = qemu_anon_ram_alloc;
        |
    qemu_anon_ram_alloc // include/qemu/osdep.h
    qemu_ram_mmap
    mmap       
    

    说明:memory_region_allocate_\system_memory 为一个mr申请了RAM;

     memory\_region\_init\_alias: 将上述mr RAM 分成了低4G的内存和高4G内存的alias.
     memory\_region\_add\_subregion:将alias挂载到system memory下面。
    
    void pc_memory_init(PCMachineState *pcms,
                        MemoryRegion *system_memory,
                        MemoryRegion *rom_memory,
                        MemoryRegion **ram_memory)
    {
        memory_region_allocate_system_memory(ram, NULL, "pc.ram",
                                             machine->ram_size);//为mr分配存储空间
        *ram_memory = ram;
        ram_below_4g = g_malloc(sizeof(*ram_below_4g));
        memory_region_init_alias(ram_below_4g, NULL, "ram-below-4g", ram,0, pcms->below_4g_mem_size);//将 mr ram从0开始偏移的4G设置成"ram-below-4g" alias也是一个mr,但是其ram block为NULL
        memory_region_add_subregion(system_memory, 0, ram_below_4g);//将ram_below_4G挂在system memory下
    
        if (pcms->above_4g_mem_size > 0) {
            ram_above_4g = g_malloc(sizeof(*ram_above_4g));
            memory_region_init_alias(ram_above_4g, NULL, "ram-above-4g", ram,
                                     pcms->below_4g_mem_size,
                                     pcms->above_4g_mem_size);
            memory_region_add_subregion(system_memory, 0x100000000ULL,
                                        ram_above_4g);
        }
    }
    

    分配后的内存模型如下:
    qemu内存布局
    当在qemu中的内存布局发生变化后,需要将这个变化同步到kvm中,这个过程实在上述函数 memory_region_add_subregion 中实现的。

    memory_region_add_subregion -> memory_region_add_subregion_common -> memory_region_update_container_subregions
    -> memory_region_transaction_commit 
    

    memory_region_add_subregion_common 将subrigion的container设置为system_memory,置其addr字段为offset,即在全局MR中的偏移。然后就按照优先级顺序把subregion插入到system_memory的subregions链表中。之后调用函数 memory_region_transaction_commit 进行内存拓扑的同步。

    memory_region_transaction_commit -> address_space_update_topology -> address_space_update_topology_pass 
    

    对于每个address_space,调用address_space_update_topology()执行更新。里面涉及两个重要的函数generate_memory_topology和address_space_update_topology_pass。前者对于一个给定的MR,生成其对应的FlatView,而后者则根据oldview和newview对当前视图进行更新。

    struct FlatView {
        struct rcu_head rcu;
        unsigned ref;
        FlatRange *ranges;//flatrange 数组
        unsigned nr;//flat range 数目
        unsigned nr_allocated;
    };
    /* Range of memory in the global map.  Addresses are absolute. */
    struct FlatRange {
        MemoryRegion *mr;
        hwaddr offset_in_region;//全局mr中的offset,GPA
        AddrRange addr;//地址区间,HVA
        uint8_t dirty_log_mask;
        bool romd_mode;
        bool readonly;
    };
    
    FlatView *old_view = address_space_get_flatview(as)//从as的current_map中获取
    FlatView *new_view = generate_memory_topology(as->root);
    

    如何生成一个新的FlatView:

    generate_memory_topology -> render_memory_region+flatview_simplify
    

    在获取了新旧两个FlatView之后,调用了两次address_space_update_topology_pass()函数,首次调用主要在于删除原来的视图,而后者主要在于添加新的视图。之后设置as->current_map = new_view。并对old_view减少引用,当引用计数为1时会被删除。

    address_space_update_topology_pass
            |
    MEMORY_LISTENER_UPDATE_REGION//将flatrange转化为 MemoryRegionSection
            |
    region_add(kvm_region_add) / region_del(kvm_region_del)
            |
    kvm_set_phys_mem// 将 MemoryRegionSection 记录到 KVMSlot中
            |
    kvm_userspace_memory_region//将 slot中的数据记录到 kvm_userspace_memory_region中,然后调用 KVM_SET_USER_MEMORY_REGION 进入kvm中,kvm_userspace_memory_region 这个结构qemu和kvm共同拥有
    

    MemoryRegionSection -> KVMSlot

    /**
 * MemoryRegionSection: describes a fragment of a #MemoryRegion
 *
 * @mr: the region, or %NULL if empty
 * @address_space: the address space the region is mapped in
 * @offset_within_region: the beginning of the section, relative to @mr's start
 * @size: the size of the section; will not exceed @mr's boundaries
 * @offset_within_address_space: the address of the first byte of the section
 *     relative to the region's address space
 * @readonly: writes to this section are ignored
 */
struct MemoryRegionSection {
    MemoryRegion *mr;
    AddressSpace *address_space;
    hwaddr offset_within_region;
    Int128 size;
    hwaddr offset_within_address_space;
    bool readonly;
};
typedef struct KVMSlot
{
    hwaddr start_addr;//gpa
    ram_addr_t memory_size;
    void *ram;//hva
    int slot;
    int flags;
} KVMSlot;

MemoryRegionSection -> KVMslot?

根据 region 的起始 HVA(memory_region_get_ram_ptr) + region section 在 region 中的偏移量 (offset_within_region) + 页对齐修正 (delta) 得到 section 真正的起始 HVA,填入 userspace_addr
在 memory_region_get_ram_ptr 中,如果当前 region 是另一个 region 的 alias,则会向上追溯,一直追溯到非 alias region(实体 region) 为止。将追溯过程中的 alias_offset 加起来,可以得到当前 region 在实体 region 中的偏移量。
由于实体 region 具有对应的 RAMBlock,所以调用 qemu_map_ram_ptr ,将实体 region 对应的 RAMBlock 的 host 和总 offset 加起来,得到当前 region 的起始 HVA。
根据 region section 在 AddressSpace 内的偏移量 (offset_within_address_space) + 页对齐修正 (delta) 得到 section 真正的 GPA,填入 start_addr
根据 region section 的大小 (size) - 页对齐修正 (delta) 得到 section 真正的大小,填入 memory_size

KVMSlot -> kvm_userspace_memory_region?

    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;

/* for KVM_SET_USER_MEMORY_REGION */
struct kvm_userspace_memory_region {
    __u32 slot;//slot编号
    __u32 flags;//标志位,是否需要追踪对该页的写、是否可读等
    __u64 guest_phys_addr;//gpa
    __u64 memory_size; /* bytes,内存大小 */
    __u64 userspace_addr; /* start of the userspace allocated memory ,HVA*/
}//

在qemu中的整个拓扑图如下,至此,内存管理在Qemu中的流程结束。
qemu内存模型传递图

在Qemu中调用kvm提供的kvm_userspace_memory_region API进行访问,函数链如下:

kvm_vm_ioctl_set_memory_region() -> kvm_set_memory_region(kvm, mem) -> __kvm_set_memory_region(kvm, mem)

两个关键数据结构:

    struct kvm_userspace_memory_region {
    __u32 slot;//slot编号
    __u32 flags;//标志位,是否需要追踪对该页的写、是否可读等
    __u64 guest_phys_addr;//gpa
    __u64 memory_size; /* bytes,内存大小 */
    __u64 userspace_addr; /* start of the userspace allocated memory ,HVA*/
}//
    struct kvm_memory_slot {
    gfn_t base_gfn;// slot的起始gfn
    unsigned long npages;//page数量
    unsigned long *dirty_bitmap;//脏页记录表
    struct kvm_arch_memory_slot arch;//结构相关,包括 rmap 和 lpage_info 等
    unsigned long userspace_addr;//对应的起始 HVA
    u32 flags;
    short id;
};

__kvm_set_memory_region: kvm_userspace_memory_region -> kvm_memory_slot

常规检查,内存大小是否小于一页等。
调用id_to_memslot来获得kvm->memslots中对应的memslot指针;
设置memslot的base_gfn、npages等域;
处理和已经存在的memslots的重叠;
调用install_new_memslots装载新的memslot;
将会根据新的npages变量和原来的npages变量来判断用户的操作属于哪一类:
    KVM_MR_CREATE:现在有页而原来没有,则为新增内存区域,创建并初始化 slot;
    KVM_MR_DELETE: 现在没有页而原来有,则为删除内存区域,将 slot 标记为 KVM_MEMSLOT_INVALID;
    KVM_MR_FLAGS_ONLY / KVM_MR_MOVE:现在有页且原来也有,则为修改内存区域,如果只有 flag 变了,则为     KVM_MR_FLAGS_ONLY ,目前只有可能是 KVM_MEM_LOG_DIRTY_PAGES ,则根据 flag 选择是要创建还是释放     dirty_bitmap;如果 GPA 有变,则为 KVM_MR_MOVE ,需要进行移动。其实就直接将原来的 slot 标记为     KVM_MEMSLOT_INVALID,然后添加新的.

至此,从qemu到kvm的整个内存管理体系建立起来了,从qemu中将AS以flatview的形式展示出来,然后翻译成为slot,然后以kvm_userspace_memory_region 为中介传递至kvm,该结构记录了GPA->HVA,最后在kvm中以kvm_memory_slot的形式保存下来。但是在申请内存之后并没有建立与物理页的连接,该部分的连接工作将由kvm来处理。guest在访问内存的时候,首先通过guest内部的mmu建立页表转换机制,当发现物理页不可访问的时候发生EPT Violation。

EPT Violation

当 Guest 第一次访问某个页面时,由于没有 GVA 到 GPA 的映射,触发 Guest OS 的 page fault,于是 Guest OS 会建立对应的 pte 并修复好各级页表,最后访问对应的 GPA。由于没有建立 GPA 到 HVA 的映射,于是触发 EPT Violation,VMEXIT 到 KVM。 KVM 在 vmx_handle_exit 中执行 kvm_vmx_exit_handlers[exit_reason],发现 exit_reason 是 EXIT_REASON_EPT_VIOLATION ,因此调用 handle_ept_violation 。
首先看一下调用的函数链,从整体感受一下。

handle_ept_violation -> kvm_mmu_page_fault(gpa) -> vcpu->arch.mmu.page_fault(tdp_page_fault) ;

函数handle_ept_violation中,vmcs_readl(EXIT_QUALIFICATION)获取 EPT 退出的原因,EXIT_QUALIFICATION 是 Exit reason 的补充;vmcs_read64(GUEST_PHYSICAL_ADDRESS)获取发生缺页的 GPA;根据 exit_qualification 内容得到 error_code,可能是 read fault / write fault / fetch fault / ept page table is not present;

tdp_page_fault 函数是主要的处理过程:

static int tdp_page_fault(struct kvm_vcpu *vcpu, gva_t gpa, u32 error_code,
          bool prefault)
{
    gfn_t gfn = gpa >> PAGE_SHIFT;//得到子机的gfn
    force_pt_level = !check_hugepage_cache_consistency(vcpu, gfn,
                               PT_DIRECTORY_LEVEL);
    level = mapping_level(vcpu, gfn, &force_pt_level);//计算gfn在页表中所属level,4kB下通常为1,大页通常为2,物理页为大页时,3级页表
    if (fast_page_fault(vcpu, gpa, level, error_code))///尝试快速修复page fault,让CPU重新尝试发生page fault的操作
        return RET_PF_RETRY;

    mmu_seq = vcpu->kvm->mmu_notifier_seq;
    smp_rmb();

    if (try_async_pf(vcpu, prefault, gfn, gpa, &pfn, write, &map_writable))
        return RET_PF_RETRY;

    r = __direct_map(vcpu, write, map_writable, level, gfn, pfn, prefault);

    return r;
}

tdp_page_fault()
-> mapping_level //计算页表级别,4kB下通常为1,大页通常为2,物理页为大页时,3级页表
-> fast_page_fault()//尝试快速修复page fault,让CPU重新尝试发生page fault的操作,热迁移时的write protection导致子机的write page fault,快速修复
以下情况下可执行fast page fault:

       - many spurious page fault due to tlb lazily flushed
       - lots of write-protect page fault (dirty bit track for guest pte, shadow
        page table write-protected, frame-buffer under Xwindow, migration, ...)
-> try\_async\_pf() //将gfn转化为pfn,直接请求或者异步请求,关于异步pf,可以选择是否支持,可以通过配置来决定。

-> try_async_pf()-> kvm_vcpu_gfn_to_memslot => __gfn_to_memslot//找到gfn对应的slot
                 -> __gfn_to_pfn_memslot//寻找gfn对应的pfn,不允许io wait
                     -> __gfn_to_hva_many -> __gfn_to_hva_memslot//计算gfn对应的起始 HVA,slot->userspace_addr + (gfn - slot->base_gfn) * PAGE_SIZE;
                    -> hva_to_pfn //计算 HVA 对应的 pfn,同时确保该物理页在内存中
                        -> hva_to_pfn_fast//
                        -> hva_to_pfn_slow//申请物理页,即为pfn赋值,可能会sleep,可能会等待swap page载入
                -> kvm_arch_setup_async_pf//支持异步
                -> __gfn_to_pfn_memslot//允许io wait

-> __direct_map // 更新 EPT,将新的映射关系逐层添加到 EPT 中

    -> for_each_shadow_entry//根据传进来的gpa进行计算,level-4页表开始,一级一级地填写相应页表项
        -> mmu_set_spte//到达level层后设置页表项
        -> is_shadow_present_pte//中间某一层页表页没有初始化时执行下列函数
            -> kvm_mmu_get_page//新建一个页表页
            -> link_shadow_page //将新建的页表页连接到上一层页表的spetp上
                -> mmu_spte_set(sptep, spte);//为当前页表项的值 (*spetp) 设置下一级页表页的 HPA
                mmu_page_add_parent_pte(vcpu, sp, sptep)-> pte_list_add//将当前项的地址 (spetp) 加入到下一级页表页的 parent_ptes 中,做反向映射

反向映射:每一级页表的spetp指向下一级页表的HPA,同时下一级页表页的parent_ptes反向指向该级别的页表项。这样,当Host需要将guest的某个GPA对应的page移除时,可直接通过反向索引找到该gfn的相关表项进行修改;

关键函数 try_async_pf 的详细解释:
在该函数中首先会尝试寻找gfn对应的pfn,如果找到就返回;

slot = kvm_vcpu_gfn_to_memslot(vcpu, gfn);
async = false;
//已经找到pfn,不需要异步,返回,执行过程中,如果需要IO wait,则不等待
*pfn = __gfn_to_pfn_memslot(slot, gfn, false, &async, write, writable);`
if (!async)
    return false; /* *pfn has correct page already */`

//判断是否能够调用异步机制,如果可以就使用异步机制
if (!prefault && kvm_can_do_async_pf(vcpu)) {//判断vcpu是否允许注入中断
    trace_kvm_try_async_get_page(gva, gfn);
    if (kvm_find_async_pf_gfn(vcpu, gfn)) {//是否已经为该gfn建立了异步寻页机制呢?
        trace_kvm_async_pf_doublefault(gva, gfn);
        kvm_make_request(KVM_REQ_APF_HALT, vcpu);//暂停vcp,等待 异步过程完成后在进入guest
        return true;
    } else if (kvm_arch_setup_async_pf(vcpu, gva, gfn))//建立异步寻页机制,向guest注入中断
        return true;
}

//不能使用异步机制就等待为gfn分配pfn,此时允许 IO wait
*pfn = __gfn_to_pfn_memslot(slot, gfn, false, NULL, write, writable);
return false;

建立异步寻页机制

kvm_arch_setup_async_pf 、kvm_async_page_present_sync 函数中分别会将 KVM_PV_REASON_PAGE_NOT_PRESENT 和 KVM_PV_REASON_PAGE_READY 中断以 KVM_REQ_EVENT形式注入到子机,子机中会调用 do_async_page_fault 函数处理中断。总的来说,对于swapout mem,可以选择异步模式获取物理页。向guest中发送async page fault 中断,可以帮助子机切换进程,物理页取回来之后再告诉子机把原来的进程调度回来。当子机不能切换进程,此时可以暂停子机。大致流程见下图:
异步寻页机制