You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

34 KiB

52 | 计算虚拟化之内存:如何建立独立的办公室?

上一节我们解析了计算虚拟化之CPU。可以看到CPU的虚拟化是用户态的qemu和内核态的KVM共同配合完成的。它们二者通过ioctl进行通信。对于内存管理来讲也是需要这两者配合完成的。

咱们在内存管理的时候讲过,操作系统给每个进程分配的内存都是虚拟内存,需要通过页表映射,变成物理内存进行访问。当有了虚拟机之后,情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程,但是虚拟机里面也有内核,也有虚拟机里面跑的进程。所以有了虚拟机,内存就变成了四类:

  • 虚拟机里面的虚拟内存Guest OS Virtual MemoryGVA这是虚拟机里面的进程看到的内存空间
  • 虚拟机里面的物理内存Guest OS Physical MemoryGPA这是虚拟机里面的操作系统看到的内存它认为这是物理内存
  • 物理机的虚拟内存Host Virtual MemoryHVA这是物理机上的qemu进程看到的内存空间
  • 物理机的物理内存Host Physical MemoryHPA这是物理机上的操作系统看到的内存。

咱们内存管理那一章讲的两大内容一个是内存管理它变得非常复杂另一个是内存映射具体来说就是从GVA到GPA到HVA再到HPA这样几经转手计算机的性能就会变得很差。当然虚拟化技术成熟的今天有了一些优化的手段具体怎么优化呢我们这一节就来一一解析。

内存管理

我们先来看内存管理的部分。

由于CPU和内存是紧密结合的因而内存虚拟化的初始化过程和CPU虚拟化的初始化是一起完成的。

上一节说CPU虚拟化初始化的时候我们会调用kvm_init函数这里面打开了"/dev/kvm"这个字符文件并且通过ioctl调用到内核kvm的KVM_CREATE_VM操作除了这些CPU相关的调用接下来还有内存相关的。我们来看看。

static int kvm_init(MachineState *ms)
{
    MachineClass *mc = MACHINE_GET_CLASS(ms);
......
    kvm_memory_listener_register(s, &s->memory_listener,
                                 &address_space_memory, 0);
    memory_listener_register(&kvm_io_listener,
                             &address_space_io);
......
}

AddressSpace address_space_io;
AddressSpace address_space_memory;

这里面有两个地址空间AddressSpace一个是系统内存的地址空间address_space_memory一个用于I/O的地址空间address_space_io。这里我们重点看address_space_memory。

struct AddressSpace {
    /* All fields are private. */
    struct rcu_head rcu;
    char *name;
    MemoryRegion *root;

    /* Accessed via RCU.  */
    struct FlatView *current_map;

    int ioeventfd_nb;
    struct MemoryRegionIoeventfd *ioeventfds;
    QTAILQ_HEAD(, MemoryListener) listeners;
    QTAILQ_ENTRY(AddressSpace) address_spaces_link;
};

对于一个地址空间会有多个内存区域MemoryRegion组成树形结构。这里面root是这棵树的根。另外还有一个MemoryListener链表当内存区域发生变化的时候需要做一些动作使得用户态和内核态能够协同就是由这些MemoryListener完成的。

在kvm_init这个时候还没有内存区域加入进来root还是空的但是我们可以先注册MemoryListener这里注册的是KVMMemoryListener。

void kvm_memory_listener_register(KVMState *s, KVMMemoryListener *kml,
                                  AddressSpace *as, int as_id)
{
    int i;

    kml->slots = g_malloc0(s->nr_slots * sizeof(KVMSlot));
    kml->as_id = as_id;

    for (i = 0; i < s->nr_slots; i++) {
        kml->slots[i].slot = i;
    }

    kml->listener.region_add = kvm_region_add;
    kml->listener.region_del = kvm_region_del;
    kml->listener.priority = 10;

    memory_listener_register(&kml->listener, as);
}

在这个KVMMemoryListener中是这样配置的当添加一个MemoryRegion的时候region_add会被调用这个我们后面会用到。

接下来在qemu启动的main函数中我们会调用cpu_exec_init_all->memory_map_init.

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");
}

在这里对于系统内存区域system_memory和用于I/O的内存区域system_io我们都进行了初始化并且关联到了相应的地址空间AddressSpace。

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_memory我们需要把它里面内存区域的根root设置为system_memory。

另外在这里我们还调用了address_space_update_topology。

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);
}

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);

    if (old_view == new_view) {
        return;
    }
......
    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);
    }

    /* Writes are protected by the BQL.  */
    atomic_rcu_set(&as->current_map, new_view);
......
}

这里面会生成AddressSpace的flatview。flatview是什么意思呢

我们可以看到在AddressSpace里面除了树形结构的MemoryRegion之外还有一个flatview结构其实这个结构就是把这样一个树形的内存结构变成平的内存结构。因为树形内存结构比较容易管理但是平的内存结构比较方便和内核里面通信来请求物理内存。虽然操作系统内核里面也是用树形结构来表示内存区域的但是用户态向内核申请内存的时候会按照平的、连续的模式进行申请。这里qemu在用户态所以要做这样一个转换。

在address_space_set_flatview中我们将老的flatview和新的flatview进行比较。如果不同说明内存结构发生了变化会调用address_space_update_topology_pass->MEMORY_LISTENER_UPDATE_REGION->MEMORY_LISTENER_CALL。

这里面调用所有的listener。但是这个逻辑这里不会执行的。这是因为这里内存处于初始化的阶段全局的flat_views里面肯定找不到。因而generate_memory_topology第一次生成了FlatView然后才调用了address_space_set_flatview。这里面老的flatview和新的flatview一定是一样的。

但是请你记住这个逻辑到这里我们还没解析qemu有关内存的参数所以这里添加的MemoryRegion虽然是一个根但是是空的是为了管理使用的后面真的添加内存的时候这个逻辑还会调用到。

我们再回到qemu启动的main函数中。接下来的初始化过程会调用pc_init1。在这里面对于CPU虚拟化我们会调用pc_cpus_init。这个我们在上一节已经讲过了。另外pc_init1还会调用pc_memory_init进行内存的虚拟化我们这里解析这一部分。

void pc_memory_init(PCMachineState *pcms,
                    MemoryRegion *system_memory,
                    MemoryRegion *rom_memory,
                    MemoryRegion **ram_memory)
{
    int linux_boot, i;
    MemoryRegion *ram, *option_rom_mr;
    MemoryRegion *ram_below_4g, *ram_above_4g;
    FWCfgState *fw_cfg;
    MachineState *machine = MACHINE(pcms);
    PCMachineClass *pcmc = PC_MACHINE_GET_CLASS(pcms);
......
    /* Allocate RAM.  We allocate it as a single memory region and use
     * aliases to address portions of it, mostly for backwards compatibility with older qemus that used qemu_ram_alloc().
     */
    ram = g_malloc(sizeof(*ram));
    memory_region_allocate_system_memory(ram, NULL, "pc.ram",
                                         machine->ram_size);
    *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);
    memory_region_add_subregion(system_memory, 0, ram_below_4g);
    e820_add_entry(0, pcms->below_4g_mem_size, E820_RAM);
    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);
        e820_add_entry(0x100000000ULL, pcms->above_4g_mem_size, E820_RAM);
    }
......
}

在pc_memory_init中我们已经知道了虚拟机要申请的内存ram_size于是通过memory_region_allocate_system_memory来申请内存。

接下来的调用链为memory_region_allocate_system_memory->allocate_system_memory_nonnuma->memory_region_init_ram_nomigrate->memory_region_init_ram_shared_nomigrate。

void memory_region_init_ram_shared_nomigrate(MemoryRegion *mr,
                                             Object *owner,
                                             const char *name,
                                             uint64_t size,
                                             bool share,
                                             Error **errp)
{
    Error *err = NULL;
    memory_region_init(mr, owner, name, size);
    mr->ram = true;
    mr->terminates = true;
    mr->destructor = memory_region_destructor_ram;
    mr->ram_block = qemu_ram_alloc(size, share, mr, &err);
......
}

static
RAMBlock *qemu_ram_alloc_internal(ram_addr_t size, ram_addr_t max_size, void (*resized)(const char*,uint64_t length,void *host),void *host, bool resizeable, bool share,MemoryRegion *mr, Error **errp)
{
    RAMBlock *new_block;
    size = HOST_PAGE_ALIGN(size);
    max_size = HOST_PAGE_ALIGN(max_size);
    new_block = g_malloc0(sizeof(*new_block));
    new_block->mr = mr;
    new_block->resized = resized;
    new_block->used_length = size;
    new_block->max_length = max_size;
    new_block->fd = -1;
    new_block->page_size = getpagesize();
    new_block->host = host;
......
    ram_block_add(new_block, &local_err, share);
    return new_block;
}

static void ram_block_add(RAMBlock *new_block, Error **errp, bool shared)
{
    RAMBlock *block;
    RAMBlock *last_block = NULL;
    ram_addr_t old_ram_size, new_ram_size;
    Error *err = NULL;
    old_ram_size = last_ram_page();
    new_block->offset = find_ram_offset(new_block->max_length);
    if (!new_block->host) {
        new_block->host = phys_mem_alloc(new_block->max_length, &new_block->mr->align, shared);
......
        }
    }
......
}

这里面我们会调用qemu_ram_alloc创建一个RAMBlock用来表示内存块。这里面调用ram_block_add->phys_mem_alloc。phys_mem_alloc是一个函数指针指向函数qemu_anon_ram_alloc这里面调用qemu_ram_mmap在qemu_ram_mmap中调用mmap分配内存。

static void *(*phys_mem_alloc)(size_t size, uint64_t *align, bool shared) = qemu_anon_ram_alloc;

void *qemu_anon_ram_alloc(size_t size, uint64_t *alignment, bool shared)
{
    size_t align = QEMU_VMALLOC_ALIGN;
    void *ptr = qemu_ram_mmap(-1, size, align, shared);
......
    if (alignment) {
        *alignment = align;
    }
    return ptr;
}

void *qemu_ram_mmap(int fd, size_t size, size_t align, bool shared)
{
    int flags;
    int guardfd;
    size_t offset;
    size_t pagesize;
    size_t total;
    void *guardptr;
    void *ptr;
......
    total = size + align;
    guardfd = -1;
    pagesize = getpagesize();
    flags = MAP_PRIVATE | MAP_ANONYMOUS;
    guardptr = mmap(0, total, PROT_NONE, flags, guardfd, 0);
......
    flags = MAP_FIXED;
    flags |= fd == -1 ? MAP_ANONYMOUS : 0;
    flags |= shared ? MAP_SHARED : MAP_PRIVATE;
    offset = QEMU_ALIGN_UP((uintptr_t)guardptr, align) - (uintptr_t)guardptr;
    ptr = mmap(guardptr + offset, size, PROT_READ | PROT_WRITE, flags, fd, 0);
......
    return ptr;
}

我们回到pc_memory_init通过memory_region_allocate_system_memory申请到内存以后为了兼容过去的版本我们分成两个MemoryRegion进行管理一个是ram_below_4g一个是ram_above_4g。对于这两个MemoryRegion我们都会初始化一个alias也即别名意思是说两个MemoryRegion其实都指向memory_region_allocate_system_memory分配的内存只不过分成两个部分起两个别名指向不同的区域。

这两部分MemoryRegion都会调用memory_region_add_subregion将这两部分作为子的内存区域添加到system_memory这棵树上。

接下来的调用链为memory_region_add_subregion->memory_region_add_subregion_common->memory_region_update_container_subregions。

static void memory_region_update_container_subregions(MemoryRegion *subregion)
{
    MemoryRegion *mr = subregion->container;
    MemoryRegion *other;

    memory_region_transaction_begin();

    memory_region_ref(subregion);
    QTAILQ_FOREACH(other, &mr->subregions, subregions_link) {
        if (subregion->priority >= other->priority) {
            QTAILQ_INSERT_BEFORE(other, subregion, subregions_link);
            goto done;
        }
    }
    QTAILQ_INSERT_TAIL(&mr->subregions, subregion, subregions_link);
done:
    memory_region_update_pending |= mr->enabled && subregion->enabled;
    memory_region_transaction_commit();
}

在memory_region_update_container_subregions中我们会将子区域放到链表中然后调用memory_region_transaction_commit。在这里面我们会调用address_space_set_flatview。因为内存区域变了flatview也会变就像上面分析过的一样listener会被调用。

因为添加了一个MemoryRegionregion_add也即kvm_region_add。

static void kvm_region_add(MemoryListener *listener,
                           MemoryRegionSection *section)
{
    KVMMemoryListener *kml = container_of(listener, KVMMemoryListener, listener);
    kvm_set_phys_mem(kml, section, true);
}

static void kvm_set_phys_mem(KVMMemoryListener *kml,
                             MemoryRegionSection *section, bool add)
{
    KVMSlot *mem;
    int err;
    MemoryRegion *mr = section->mr;
    bool writeable = !mr->readonly && !mr->rom_device;
    hwaddr start_addr, size;
    void *ram;
......
    size = kvm_align_section(section, &start_addr);
......
    /* use aligned delta to align the ram address */
    ram = memory_region_get_ram_ptr(mr) + section->offset_within_region + (start_addr - section->offset_within_address_space);
......
    /* register the new slot */
    mem = kvm_alloc_slot(kml);
    mem->memory_size = size;
    mem->start_addr = start_addr;
    mem->ram = ram;
    mem->flags = kvm_mem_flags(mr);

    err = kvm_set_user_memory_region(kml, mem, true);
......
}

kvm_region_add调用的是kvm_set_phys_mem这里面分配一个用于放这块内存的KVMSlot结构就像一个内存条一样当然这是在用户态模拟出来的内存条放在KVMState结构里面。这个结构是我们上一节创建虚拟机的时候创建的。

接下来kvm_set_user_memory_region就会将用户态模拟出来的内存条和内核中的KVM模块关联起来。

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;
......
    mem.memory_size = slot->memory_size;
    ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem);
    slot->old_flags = mem.flags;
......
    return ret;
}

终于在这里我们又看到了可以和内核通信的kvm_vm_ioctl。我们来看内核收到KVM_SET_USER_MEMORY_REGION会做哪些事情。

static long kvm_vm_ioctl(struct file *filp,
               unsigned int ioctl, unsigned long arg)
{
    struct kvm *kvm = filp->private_data;
    void __user *argp = (void __user *)arg;
    switch (ioctl) {
    case KVM_SET_USER_MEMORY_REGION: {
        struct kvm_userspace_memory_region kvm_userspace_mem;
        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);
        break;  
    }   
......
}

接下来的调用链为kvm_vm_ioctl_set_memory_region->kvm_set_memory_region->__kvm_set_memory_region。

int __kvm_set_memory_region(struct kvm *kvm,
			    const struct kvm_userspace_memory_region *mem)
{
	int r;
	gfn_t base_gfn;
	unsigned long npages;
	struct kvm_memory_slot *slot;
	struct kvm_memory_slot old, new;
	struct kvm_memslots *slots = NULL, *old_memslots;
	int as_id, id;
	enum kvm_mr_change change;
......
	as_id = mem->slot >> 16;
	id = (u16)mem->slot;

	slot = id_to_memslot(__kvm_memslots(kvm, as_id), id);
	base_gfn = mem->guest_phys_addr >> PAGE_SHIFT;
	npages = mem->memory_size >> PAGE_SHIFT;
......
	new = old = *slot;

	new.id = id;
	new.base_gfn = base_gfn;
	new.npages = npages;
	new.flags = mem->flags;
......
 	if (change == KVM_MR_CREATE) {
		new.userspace_addr = mem->userspace_addr;

		if (kvm_arch_create_memslot(kvm, &new, npages))
			goto out_free;
	}
......
	slots = kvzalloc(sizeof(struct kvm_memslots), GFP_KERNEL);
	memcpy(slots, __kvm_memslots(kvm, as_id), sizeof(struct kvm_memslots));
......
	r = kvm_arch_prepare_memory_region(kvm, &new, mem, change);

	update_memslots(slots, &new);
	old_memslots = install_new_memslots(kvm, as_id, slots);

	kvm_arch_commit_memory_region(kvm, mem, &old, &new, change);
	return 0;
......
}

在用户态每个KVMState有多个KVMSlot在内核里面同样每个struct kvm也有多个struct kvm_memory_slot两者是对应起来的。

//用户态
struct KVMState
{
......
    int nr_slots;
......
    KVMMemoryListener memory_listener;
......
};

typedef struct KVMMemoryListener {
    MemoryListener listener;
    KVMSlot *slots;
    int as_id;
} KVMMemoryListener

typedef struct KVMSlot
{
    hwaddr start_addr;
    ram_addr_t memory_size;
    void *ram;
    int slot;
    int flags;
    int old_flags;
} KVMSlot;

//内核态
struct kvm {
	spinlock_t mmu_lock;
	struct mutex slots_lock;
	struct mm_struct *mm; /* userspace tied to this vm */
	struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];
......
}

struct kvm_memslots {
	u64 generation;
	struct kvm_memory_slot memslots[KVM_MEM_SLOTS_NUM];
	/* The mapping table from slot id to the index in memslots[]. */
	short id_to_index[KVM_MEM_SLOTS_NUM];
	atomic_t lru_slot;
	int used_slots;
};

struct kvm_memory_slot {
	gfn_t base_gfn;//根据guest_phys_addr计算
	unsigned long npages;
	unsigned long *dirty_bitmap;
	struct kvm_arch_memory_slot arch;
	unsigned long userspace_addr;
	u32 flags;
	short id;
};

并且id_to_memslot函数可以根据用户态的slot号得到内核态的slot结构。

如果传进来的参数是KVM_MR_CREATE表示要创建一个新的内存条就会调用kvm_arch_create_memslot来创建kvm_memory_slot的成员kvm_arch_memory_slot。

接下来就是创建kvm_memslots结构填充这个结构然后通过install_new_memslots将这个新的内存条添加到struct kvm结构中。

至此,用户态的内存结构和内核态的内存结构算是对应了起来。

页面分配和映射

上面对于内存的管理,还只是停留在元数据的管理。对于内存的分配与映射,我们还没有涉及,接下来,我们就来看看,页面是如何进行分配和映射的。

上面咱们说了内存映射对于虚拟机来讲是一件非常麻烦的事情从GVA到GPA到HVA到HPA性能很差为了解决这个问题有两种主要的思路。

影子页表

第一种方式就是软件的方式,影子页表 Shadow Page Table

按照咱们在内存管理那一节讲的内存映射要通过页表来管理页表地址应该放在cr3寄存器里面。本来的过程是客户机要通过cr3找到客户机的页表实现从GVA到GPA的转换然后在宿主机上要通过cr3找到宿主机的页表实现从HVA到HPA的转换。

为了实现客户机虚拟地址空间到宿主机物理地址空间的直接映射。客户机中每个进程都有自己的虚拟地址空间所以KVM需要为客户机中的每个进程页表都要维护一套相应的影子页表。

在客户机访问内存时使用的不是客户机的原来的页表而是这个页表对应的影子页表从而实现了从客户机虚拟地址到宿主机物理地址的直接转换。而且在TLB和CPU 缓存上缓存的是来自影子页表中客户机虚拟地址和宿主机物理地址之间的映射,也因此提高了缓存的效率。

但是影子页表的引入也意味着 KVM 需要为每个客户机的每个进程的页表都要维护一套相应的影子页表,内存占用比较大,而且客户机页表和和影子页表也需要进行实时同步。

扩展页表

于是就有了第二种方式就是硬件的方式Intel的EPTExtent Page Table扩展页表技术。

EPT在原有客户机页表对客户机虚拟地址到客户机物理地址映射的基础上又引入了 EPT页表来实现客户机物理地址到宿主机物理地址的另一次映射。客户机运行时客户机页表被载入 CR3而EPT页表被载入专门的EPT 页表指针寄存器 EPTP。

有了EPT在客户机物理地址到宿主机物理地址转换的过程中缺页会产生EPT 缺页异常。KVM首先根据引起异常的客户机物理地址映射到对应的宿主机虚拟地址然后为此虚拟地址分配新的物理页最后 KVM 再更新 EPT 页表,建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。

KVM 只需为每个客户机维护一套 EPT 页表,也大大减少了内存的开销。

这里我们重点看第二种方式。因为使用了EPT之后客户机里面的页表映射也即从GVA到GPA的转换还是用传统的方式和在内存管理那一章讲的没有什么区别。而EPT重点帮我们解决的就是从GPA到HPA的转换问题。因为要经过两次页表所以EPT又称为tdptwo dimentional paging

EPT的页表结构也是分为四层EPT Pointer EPTP指向PML4的首地址。

管理物理页面的Page结构和咱们讲内存管理那一章是一样的。EPT页表也需要存放在一个页中这些页要用kvm_mmu_page这个结构来管理。

当一个虚拟机运行进入客户机模式的时候我们上一节解析过它会调用vcpu_enter_guest函数这里面会调用kvm_mmu_reload->kvm_mmu_load。

int kvm_mmu_load(struct kvm_vcpu *vcpu)
{
......
	r = mmu_topup_memory_caches(vcpu);
	r = mmu_alloc_roots(vcpu);
	kvm_mmu_sync_roots(vcpu);
	/* set_cr3() should ensure TLB has been flushed */
	vcpu->arch.mmu.set_cr3(vcpu, vcpu->arch.mmu.root_hpa);
......
}

static int mmu_alloc_roots(struct kvm_vcpu *vcpu)
{
	if (vcpu->arch.mmu.direct_map)
		return mmu_alloc_direct_roots(vcpu);
	else
		return mmu_alloc_shadow_roots(vcpu);
}

static int mmu_alloc_direct_roots(struct kvm_vcpu *vcpu)
{
	struct kvm_mmu_page *sp;
	unsigned i;

	if (vcpu->arch.mmu.shadow_root_level == PT64_ROOT_LEVEL) {
		spin_lock(&vcpu->kvm->mmu_lock);
		make_mmu_pages_available(vcpu);
		sp = kvm_mmu_get_page(vcpu, 0, 0, PT64_ROOT_LEVEL, 1, ACC_ALL);
		++sp->root_count;
		spin_unlock(&vcpu->kvm->mmu_lock);
		vcpu->arch.mmu.root_hpa = __pa(sp->spt);
	} 
......
}

这里构建的是页表的根部也即顶级页表并且设置cr3来刷新TLB。mmu_alloc_roots会调用mmu_alloc_direct_roots因为我们用的是EPT模式而非影子表。在mmu_alloc_direct_roots中kvm_mmu_get_page会分配一个kvm_mmu_page来存放顶级页表项。

接下来当虚拟机真的要访问内存的时候会发现有的页表没有建立有的物理页没有分配这都会触发缺页异常在KVM里面会发送VM-Exit从客户机模式转换为宿主机模式来修复这个缺失的页表或者物理页。

static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {
    [EXIT_REASON_EXCEPTION_NMI]           = handle_exception,
    [EXIT_REASON_EXTERNAL_INTERRUPT]      = handle_external_interrupt,
    [EXIT_REASON_IO_INSTRUCTION]          = handle_io,
......
    [EXIT_REASON_EPT_VIOLATION]       = handle_ept_violation,
......
}

咱们前面讲过虚拟机退出客户机模式有很多种原因例如接收到中断、接收到I/O等EPT的缺页异常也是一种类型我们称为EXIT_REASON_EPT_VIOLATION对应的处理函数是handle_ept_violation。

static int handle_ept_violation(struct kvm_vcpu *vcpu)
{
	gpa_t gpa;
......
	gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS);
......
	vcpu->arch.gpa_available = true;
	vcpu->arch.exit_qualification = exit_qualification;

	return kvm_mmu_page_fault(vcpu, gpa, error_code, NULL, 0);
}

int kvm_mmu_page_fault(struct kvm_vcpu *vcpu, gva_t cr2, u64 error_code,
		       void *insn, int insn_len)
{
......
	r = vcpu->arch.mmu.page_fault(vcpu, cr2, lower_32_bits(error_code),false);
......
}

在handle_ept_violation里面我们从VMCS中得到没有解析成功的GPA也即客户机的物理地址然后调用kvm_mmu_page_fault看为什么解析不成功。kvm_mmu_page_fault会调用page_fault函数其实是tdp_page_fault函数。tdp的意思就是EPT前面我们解释过了。

static int tdp_page_fault(struct kvm_vcpu *vcpu, gva_t gpa, u32 error_code, bool prefault)
{
	kvm_pfn_t pfn;
	int r;
	int level;
	bool force_pt_level;
	gfn_t gfn = gpa >> PAGE_SHIFT;
	unsigned long mmu_seq;
	int write = error_code & PFERR_WRITE_MASK;
	bool map_writable;

	r = mmu_topup_memory_caches(vcpu);
	level = mapping_level(vcpu, gfn, &force_pt_level);
......
	if (try_async_pf(vcpu, prefault, gfn, gpa, &pfn, write, &map_writable))
		return 0;

	if (handle_abnormal_pfn(vcpu, 0, gfn, pfn, ACC_ALL, &r))
		return r;

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

既然没有映射就应该加上映射tdp_page_fault就是干这个事情的。

在tdp_page_fault这个函数开头我们通过gpa也即客户机的物理地址得到客户机的页号gfn。接下来我们要通过调用try_async_pf得到宿主机的物理地址对应的页号也即真正的物理页的页号然后通过__direct_map将两者关联起来。

static bool try_async_pf(struct kvm_vcpu *vcpu, bool prefault, gfn_t gfn, gva_t gva, kvm_pfn_t *pfn, bool write, bool *writable)
{
	struct kvm_memory_slot *slot;
	bool async;

	slot = kvm_vcpu_gfn_to_memslot(vcpu, gfn);
	async = false;
	*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)) {
		if (kvm_find_async_pf_gfn(vcpu, gfn)) {
			kvm_make_request(KVM_REQ_APF_HALT, vcpu);
			return true;
		} else if (kvm_arch_setup_async_pf(vcpu, gva, gfn))
			return true;
	}
	*pfn = __gfn_to_pfn_memslot(slot, gfn, false, NULL, write, writable);
	return false;
}

在try_async_pf中要想得到pfn也即物理页的页号会先通过kvm_vcpu_gfn_to_memslot根据客户机的物理地址对应的页号找到内存条然后调用__gfn_to_pfn_memslot根据内存条找到pfn。

kvm_pfn_t __gfn_to_pfn_memslot(struct kvm_memory_slot *slot, gfn_t gfn,bool atomic, bool *async, bool write_fault,bool *writable)
{
	unsigned long addr = __gfn_to_hva_many(slot, gfn, NULL, write_fault);
......
	return hva_to_pfn(addr, atomic, async, write_fault,
			  writable);
}

在__gfn_to_pfn_memslot中我们会调用__gfn_to_hva_many从客户机物理地址对应的页号得到宿主机虚拟地址hva然后从宿主机虚拟地址到宿主机物理地址调用的是hva_to_pfn。

hva_to_pfn会调用hva_to_pfn_slow。

static int hva_to_pfn_slow(unsigned long addr, bool *async, bool write_fault,
			   bool *writable, kvm_pfn_t *pfn)
{
	struct page *page[1];
	int npages = 0;
......
	if (async) {
		npages = get_user_page_nowait(addr, write_fault, page);
	} else {
......
		npages = get_user_pages_unlocked(addr, 1, page, flags);
	}
......
	*pfn = page_to_pfn(page[0]);
	return npages;
}

在hva_to_pfn_slow中我们要先调用get_user_page_nowait得到一个物理页面然后再调用page_to_pfn将物理页面转换成为物理页号。

无论是哪一种get_user_pages_XXX最终都会调用__get_user_pages函数。这里面会调用faultin_page在faultin_page中我们会调用handle_mm_fault。看到这个是不是很熟悉这就是咱们内存管理那一章讲的缺页异常的逻辑分配一个物理内存。

至此try_async_pf得到了物理页面并且转换为对应的物理页号。

接下来__direct_map会关联客户机物理页号和宿主机物理页号。

static int __direct_map(struct kvm_vcpu *vcpu, int write, int map_writable,
			int level, gfn_t gfn, kvm_pfn_t pfn, bool prefault)
{
	struct kvm_shadow_walk_iterator iterator;
	struct kvm_mmu_page *sp;
	int emulate = 0;
	gfn_t pseudo_gfn;

	if (!VALID_PAGE(vcpu->arch.mmu.root_hpa))
		return 0;

	for_each_shadow_entry(vcpu, (u64)gfn << PAGE_SHIFT, iterator) {
		if (iterator.level == level) {
			emulate = mmu_set_spte(vcpu, iterator.sptep, ACC_ALL,
					       write, level, gfn, pfn, prefault,
					       map_writable);
			direct_pte_prefetch(vcpu, iterator.sptep);
			++vcpu->stat.pf_fixed;
			break;
		}

		drop_large_spte(vcpu, iterator.sptep);
		if (!is_shadow_present_pte(*iterator.sptep)) {
			u64 base_addr = iterator.addr;

			base_addr &= PT64_LVL_ADDR_MASK(iterator.level);
			pseudo_gfn = base_addr >> PAGE_SHIFT;
			sp = kvm_mmu_get_page(vcpu, pseudo_gfn, iterator.addr,
					      iterator.level - 1, 1, ACC_ALL);

			link_shadow_page(vcpu, iterator.sptep, sp);
		}
	}
	return emulate;
}

__direct_map首先判断页表的根是否存在当然存在我们刚才初始化了。

接下来是for_each_shadow_entry一个循环。每一个循环中先是会判断需要映射的level是否正是当前循环的这个iterator.level。如果是则说明是叶子节点直接映射真正的物理页面pfn然后退出。接着是非叶子节点的情形判断如果这一项指向的页表项不存在就要建立页表项通过kvm_mmu_get_page得到保存页表项的页面然后将这一项指向下一级的页表页面。

至此,内存映射就结束了。

总结时刻

我们这里来总结一下虚拟机的内存管理也是需要用户态的qemu和内核态的KVM共同完成。为了加速内存映射需要借助硬件的EPT技术。

在用户态qemu中有一个结构AddressSpace address_space_memory来表示虚拟机的系统内存这个内存可能包含多个内存区域struct MemoryRegion组成树形结构指向由mmap分配的虚拟内存。

在AddressSpace结构中有一个struct KVMMemoryListener当有新的内存区域添加的时候会被通知调用kvm_region_add来通知内核。

在用户态qemu中对于虚拟机有一个结构struct KVMState表示这个虚拟机这个结构会指向一个数组的struct KVMSlot表示这个虚拟机的多个内存条KVMSlot中有一个void *ram指针指向mmap分配的那块虚拟内存。

kvm_region_add是通过ioctl来通知内核KVM的会给内核KVM发送一个KVM_SET_USER_MEMORY_REGION消息表示用户态qemu添加了一个内存区域内核KVM也应该添加一个相应的内存区域。

和用户态qemu对应的内核KVM对于虚拟机有一个结构struct kvm表示这个虚拟机这个结构会指向一个数组的struct kvm_memory_slot表示这个虚拟机的多个内存条kvm_memory_slot中有起始页号页面数目表示这个虚拟机的物理内存空间。

虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的只有当虚拟机的内存被访问的时候也即mmap分配的虚拟内存空间被访问的时候先查看EPT页表是否已经映射过如果已经映射过则经过四级页表映射就能访问到物理页面。

如果没有映射过则虚拟机会通过VM-Exit指令回到宿主机模式通过handle_ept_violation补充页表映射。先是通过handle_mm_fault为虚拟机的物理内存空间分配真正的物理页面然后通过__direct_map添加EPT页表映射。

课堂练习

这一节,影子页表我们没有深入去讲,你能自己研究一下,它是如何实现的吗?

欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。