qemu/kvm虚拟机内存分配机制

目标

分析qemu/kvm虚拟机的内存分配机制。学习和交流。本文的关注点是整体流程及核心过程,而不注重于代码细节。(当需讨论更多代码细节时,通过其它更多的文章展开)

在下文描述中,当指代虚拟化技术时,qemu和kvm两个名称互通;当指代用户态和内核态不同代码实现时,qemu和kvm具有不同含义。(关于qemu和kvm的,参见附录)
代码分析环境:

  • arm64架构
  • qemu-6.2.0版本

分为以下几部分:

  • 一 总体
    • 硬件基础:二阶(stage-2)页表转换
    • 虚拟机内存使用全流程概述
  • 二 [流程1]: 虚拟机创建内存分配初始化
    • [流程1.1]: 用户态虚拟机内存申请;
    • [流程1.2]: 虚拟机物理内存分块和注册
  • 三 [流程2]: 虚拟机运行内存实际分配
  • 四 附录
    • qemu和kvm概念区分
    • kvm的VHE模式和NVHE模式

一 总体

硬件基础:二阶(stage-2)页表转换

首先,arm64下虚拟机内存管理依赖硬件支持,即如果没有硬件支持,arm64不能完成内存虚拟化的内存管理。内存管理的根本目的,是达到虚拟机实例间的内存隔离。传统操作系统的MMU,即内存地址映射机制即是实现内存隔离;脱开概念层面的固有定义,MMU本身就是一种"虚拟化",只不过这种"虚拟化"是针对进程的。

arm64下虚拟机内存管理硬件上基于二阶页表(stage-2 translation);其实质是对MMU的多一级扩展,在原有页表转换的基础上,再增加了一层转换。由原有的二级转换,增加到三级转换。

绘图1_1
图1 一阶二阶页表转换示意图

原来的转换过程是:
"1虚拟地址➡️2物理地址",
现在变成了:
"1虚拟机虚拟地址(gva)➡️2虚拟机物理地址(gpa)➡️3主机物理地址(hpa)"
1到2就是原有MMU转换,但是在虚拟化下换了一个名字叫一阶(stage-1)转换;2到3就是虚拟化的二阶(stage-2)转换。可见引入虚拟化后最大的区别是,原有MMU转换的结果不再是一个真实物理地址,而是一个新的表示[虚拟机内部]物理地址的虚拟地址。而二阶页表转换的目标是转换到最终实际物理地址。

涉及到一阶和二阶页表转换的典型寄存器有如下一些(此处仅做相关基础内容提示,不展开)

  • 一阶页表转换
    • ttbr0_el1、ttbr1_el1、tcr_el1、sctlr_el1
  • 二阶页表转换
    • hcr_el2、vttbr_el2、ttbr0_el2、tcr_el2、sctlr_el2

虚拟机内存使用全流程概述

首先,qemu虚拟机的内存形式上,是linux系统的一个进程。为什么先说明这一点?因为这是一种特别的方式。虚拟化本身是一个大的技术内容,而qemu只是这个技术原理的一个实现。虚拟化下对虚拟机内存的管理不总是实现为一个宿主机的进程,而qemu是这样做的,这是它的实现方式,但不代表是唯一的方式,例如xen,虚拟机内存是直接由xen本身去管理的,而不是linux作为一个进程内存去管理。回过来总结一下,qemu属于Type-II虚拟化类型,它是一种宿主结构,虚拟机管理依赖于宿主系统;与之对比,Type-I虚拟化类型,没有这种依赖性。因此,qemu虚拟机内存管理,也没有什么特别的,就是linux对进程的内存空间管理,当然在具体执行过程中还额外牵连了虚拟化原理如上述二阶页表,使整个过程在契合linux内存管理框架的同时,多了一层复杂度。

绘图1_2
图2 kvm虚拟化结构示意图

以下,尝试概括性描述qemu虚拟机内存管理的全过程,脱开代码和细节。既然qemu虚拟机是linux的一个普通进程,那么qemu虚拟机分配内存就是普通进程再分配内存。用户程序分配内存有两种方式,静态数组或malloc。qemu当然是malloc的方式,因为一次申请比如4G内存。这4G内存,当然不是一次性分配的,而是遵循linux系统原理通过“缺页异常”机制在实际读写时按页分配。需明确,这4G内存是qemu用户态虚拟地址。

绘图1_3
图3 虚拟机内存分配初始化流程示意图

但是qemu毕竟是虚拟机,又不仅仅是普通程序。虚拟机自己还有一套地址,叫做“虚拟机物理地址(gpa)”;这个和上面通过malloc分配的地址在概念上是不一样的(但是在系统最底层其实还是物理页分配上一致的) 。上面说了qemu在创建时先完成了malloc的用户态分配,这是虚拟机初始化的一部分。接下来在初始化过程中做的另一个主要工作是,在kvm中注册虚拟机内存,即建立虚拟机物理gpa和主机虚拟地址hva的映射关系。gpa地址是在初始化中qemu建立的,这是虚拟机自己视角看到的物理地址,但是为什么还要和主机hva地址做关联?这种关联例如,把虚拟机的0x4000000开始一段内存地址,映射到主机ffff03a00000开始的一段内存地址(注意这两个地址都是“虚地址”,还没有到真实的物理地址)。这个原因在于,当虚拟机发生对虚拟地址的读写而需要系统填充真实物理页表时,kvm首先看到的是gpa地址,而linux系统分配物理页的缺页异常的作用机制是需要主机的虚拟地址hva,linux需要能从gpa能索引到hva。所以,这个“虚拟机内存的kvm注册”操作的目的,就是为后续的通过缺页异常的虚拟机页面实际分配做准备。

实际中,虚拟机内存的kvm注册,是分块进行的, qemu的结构叫KVMSlot(kvm内存槽)。这个意思是说,当使用类似于 -m 4G参数启动虚拟机时,这个4G内存是作为一个slot去注册的。除了这个slot,初始化实际中还有别的slot,而且如果变换启动参数,如-numa node,mem=2G -numa node,mem=2G,内存总大小是4G但是会生成两个slot。简单说,一个slot就是从虚拟机视角的,“一段连续”的RAM物理内存区间。多个slot的物理内存区间一定是不连续的。(此处只是对该环节的一个补充说明,属于一定程度的细节,和核心原理无关)

最后,虚拟机创建完毕,初始化完毕,进入运行。内存页的实际分配是在这个阶段。这个阶段因为引入了虚拟化,相当于多了一个层级的系统运行显得较为复杂,但是基本原理,仍然遵循1. 应用首次访未映射内存区域→ 2 触发缺页异常转入系统层,应用执行被暂停 → 3. 系统层实际分配物理页 → 4. 返回应用,继续执行的总体流程。下面举个例子,尽可能简单说明虚拟化下的内存实际分配流程。

绘图1_4
图4 虚拟机运行内存实际分配流程示意图

首先假定虚拟机OS上跑了一个P应用,注意这个P应用是跑在虚拟机OS上的。P应用先malloc了一片内存,在实际业务中进行了读写。该操作首先基于OS本身的页表(一阶页表),触发了一个虚拟机OS的缺页异常,为了区分和主机上的缺页异常,此处称为“虚拟机缺页异常”,第一次页表异常。这个操作在虚拟机内部正常响应,但是在完成一阶页表分配后返回应用再次尝试读写操作时,会发生第二次异常,没错,就是上面说的二阶页表的异常,因为二阶页表没有实际分配,也缺页。转到到kvm虚拟化模块。在这个路径中,kvm会记录发生二阶页表异常的gpa地址,进一步和主机系统的缺页分配模块交互,调用主机系统的缺页异常处理完成实际分配。注意,这个过程,“主机的缺页异常”实际是没有发生的,发生的是虚拟机的二阶页表的异常,这是由kvm响应的,但是最终调用了主机缺页异常的函数过程。在由主机linux完成物理页分配后,以分配的物理地址进一步填充缺失的虚拟机系统二阶页表项,并最终返回到虚拟机程序P执行,完成全部过程。

以下分析具体流程。

二 [流程1]: 虚拟机创建内存分配初始化

kvm虚拟机创建起始于qemu用户态代码,有如下调用链

main() [softmmu/main.c]
└─ qemu_init [softmmu/vl.c]
	└─ qmp_x_exit_preconfig 
		└─ qemu_init_board
			└─ create_default_memdev  [softmmu/vl.c]    // 流程1.1
			└─ machine_run_board_init [hw/core/machine.c] // 流程 1.2

qemu_init_board @softmmu/main.c

static void qemu_init_board(void)
{
    MachineClass *machine_class = MACHINE_GET_CLASS(current_machine);

    if (machine_class->default_ram_id && current_machine->ram_size &&
        numa_uses_legacy_mem() && !current_machine->ram_memdev_id) {
        create_default_memdev(current_machine, mem_path);
    }

    /* process plugin before CPUs are created, but once -smp has been parsed */
    qemu_plugin_load_list(&plugin_list, &error_fatal);

    /* From here on we enter MACHINE_PHASE_INITIALIZED.  */
    machine_run_board_init(current_machine);

    drive_check_orphaned();

    realtime_init();

    if (hax_enabled()) {
        /* FIXME: why isn't cpu_synchronize_all_post_init enough? */
        hax_sync_vcpus();
    }
}``c

从qemu_init_board 展开了内存初始化的两个主要过程: 在create_default_memdev中做虚拟机内存的动态申请,在machine_run_board_init中对虚拟机物理内存分块。

[流程1.1]: 用户态虚拟机内存申请

从create_default_memdev后的调用流程有

create_default_memdev [softmmu/vl.c]
└─ user_creatable_complete [qom/object_interfaces.c]
	└─ host_memory_backend_memory_complete [backends/hostmem-ram.c]
		└─ ram_backend_memory_alloc []
			└─ memory_region_init_ram_flags_nomigrate [softmmu/memory.c]
				└─ qemu_ram_alloc [softmmu/physmem.c]

qemu_ram_alloc [softmmu/physmem.c]
└─  qemu_ram_alloc_internal
	 └─  ram_block_add []
	 	 └─  qemu_anon_ram_alloc [util/oslib-posix.c]
		 	 └─ qemu_ram_mmap [util/mmap-alloc.c]

qemu_ram_mmap是最终的分配函数,如下

void *qemu_ram_mmap(int fd,
                    size_t size,
                    size_t align,
                    uint32_t qemu_map_flags,
                    off_t map_offset)
{
    const size_t guard_pagesize = mmap_guard_pagesize(fd);
    size_t offset, total;
    void *ptr, *guardptr;

    total = size + align;

    guardptr = mmap_reserve(total, fd);
    if (guardptr == MAP_FAILED) {
        return MAP_FAILED;
    }

    assert(is_power_of_2(align));
    /* Always align to host page size */
    assert(align >= guard_pagesize);

    offset = QEMU_ALIGN_UP((uintptr_t)guardptr, align) - (uintptr_t)guardptr;

    ptr = mmap_activate(guardptr + offset, size, fd, qemu_map_flags,
                        map_offset);
    if (ptr == MAP_FAILED) {
        munmap(guardptr, total);
        return MAP_FAILED;
    }

    if (offset > 0) {
        munmap(guardptr, offset);
    }

    total -= offset;
    if (total > size + guard_pagesize) {
        munmap(ptr + size + guard_pagesize, total - size - guard_pagesize);
    }

    return ptr;
}

可见,虚拟机分配动态内存最终是用mmap相关系统调用,该调用和malloc的作用是基本一致的,在应用程序堆区分配虚拟内存。

[流程1.2]: 虚拟机物理内存分块和注册

虚拟机物理内存分块的直接原因是,一个真实的物理机,RAM内存可能是不连续的。例如服务器,numa结构可能是几块内存。qemu虚拟机系统是去模拟真实的物理硬件。另一方面原因,是存在多种不同类型的物理内存,如ROM固件等。这些和qemu -m 参数指定的物理内存一定不是同一区域。但又需要做内存分配。总之,qemu中使用了KVMSlot的结构去表示内存块,相应的kvm中的结构是kvm_memory_slot。slot按先后创建的顺序有编号,从0开始。

从machine_run_board_init之后的调用流程有,

machine_run_board_init [hw/core/machine.c]
└─ machine_class->init(machine) => machvirt_init
	└─ memory_region_add_subregion
	
memory_region_add_subregion [softmmu/memory.c]
└─ memory_region_add_subregion_common
	└─ memory_region_transaction_commit
		└─ address_space_set_flatview
			└─ address_space_update_topology_pass
				└─  kvm_region_add [accel/kvm/kvm-all.c]
				
kvm_region_add [accel/kvm/kvm-all.c]
└─ kvm_set_phys_mem
	└─ kvm_set_user_memory_region

kvm_set_user_memory_region的函数实现是

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;
err:
    // ... ...
    return ret;
}

在这个函数中,完成对内存块的注册。内存块的数据结构,用户层是KVMSlot

typedef struct KVMSlot
{
    hwaddr start_addr;
    ram_addr_t memory_size;
    void *ram;
    int slot;
    int flags;
    int old_flags;
    unsigned long *dirty_bmap;
    unsigned long dirty_bmap_size;
    int as_id;
    ram_addr_t ram_start_offset;
} KVMSlot;

几个关键参数: start_addr是本内存块起始虚拟机地址(gpa);memory_size是块大小;ram是本内存块映射到主机的地址(hva)。

在一个基于arm64的虚拟机,有以下虚拟机运行示例。虚拟机配置为4核2G。
虚拟机内的cat /proc/iomem所代表的虚拟机完整地址空间分配情况是

09000000-09000fff : pl011@9000000
  09000000-09000fff : 9000000.pl011 pl011@9000000
09010000-09010fff : pl031@9010000
  09010000-09010fff : rtc-pl031
09020000-09020017 : fw_cfg_mem
09030000-09030fff : pl061@9030000
  09030000-09030fff : 9030000.pl061 pl061@9030000
10000000-3efeffff : pcie@10000000
  10000000-1003ffff : 0000:00:01.0
40000000-bfffffff : System RAM
  40200000-419affff : Kernel code
  419b0000-41e2ffff : reserved
  41e30000-42e9ffff : Kernel data
  48000000-498f2fff : reserved
  49a00000-49afffff : reserved
  bd000000-beffffff : reserved
  bfc90000-bff90fff : reserved
  bff91000-bffd8fff : reserved
  bffdb000-bffddfff : reserved
  bffde000-bffeafff : reserved
  bffeb000-bfff5fff : reserved
  bfff6000-bfffffff : reserved
4010000000-401fffffff : PCI ECAM
8000000000-ffffffffff : pcie@10000000
  8000000000-8000003fff : 0000:00:01.0
    8000000000-8000003fff : virtio-pci-modern

虚拟机启动输出的用户内存块创建信息有如下

configure accelerator virt-6.2 start
machine init start
kvm_set_user_memory_region.3:0 0 ffff07c00000 4000000                         
kvm_set_user_memory_region.3:1 4000000 ffff03a00000 4000000
cpu init start
kvm_set_user_memory_region.3:2 40000000 ffff0be00000 80000000
kvm_set_user_memory_region.3:3 90a0000 ffff90200000 1000
add rom file: virtio-net-pci.rom
load the kernel
device init start
reset all devices
kvm_set_user_memory_region.3:0 0 ffff07c00000 0
kvm_set_user_memory_region.3:0 0 ffff07c00000 4000000
qmp cont is received and vm is started
qemu enter main_loop
[    0.000000][    T0] Booting Linux on physical CPU 0x0000000000 [0x414fd0b1]

cat /proc/iomem输出的是虚拟机的完整地址空间布局信息,同时包括物理内存和IO外设的MMIO区域。

上述kvm_set_user_memory_region.3 打印中,@1为slot的编号,@2为虚拟机物理地址gpa, @3为映射到主机的虚拟地址hva, @4为块/区间长度。可见,kvm虚拟化为所申请的2G内存在System RAM主内存区建立了slot2。同时还有slot0 1和3,但是并没有在命令行输出反映。原因可能是(没有完全清楚)这一部分内存是ROM固件的类型,或是其它类型。但不是虚拟机可以正常读写的普通RAM。

同时注意到,kvm并没有为其它IO空间,例如pl031@9010000建立内存块映射。为什么没有建立? 是因为虚拟化中,需要对IO空间和实际内存空间有所区分。在后续[流程2]中会做详细解释。该部分操作值得注意的重要细节: 虚拟机在创建时对物理内存块的kvm注册操作kvm_set_user_memory_region,只针对普通内存区域,而不针对任何MMIO区域。

三 流程2: 虚拟机运行内存实际分配

虚拟机实际内存分配,起始于虚拟机应用或内核初次访问已分配的内存区,在虚拟机系统内部完成了一阶页表后,随即因为二阶页表项缺失,触发中止异常(abort)。该异常由位于el2级别的kvm虚拟机监视器(hypervisor)截获转入el2异常处理入口(该过程实际上就是通常x86架构下的“虚拟机退出”VM-Exit事件)。arm64非VHE模式下(以下只它讨论该模式,不讨论VHE模式),kvm随即超调用(hypercall)返回到位于el1的kvm内核模块。

上述代码调用流程有如下

kvm_arch_vcpu_ioctl_run [arch/arm64/kvm/arm.c]
└─ __kvm_vcpu_run [arch/arm64/kvm/hyp/nvhe/hyp-main.c]
	└─  handle_exit [arch/arm64/kvm/handle_exit.c]
		└─ handle_trap_exceptions
			└─ exit_handler = arm_exit_handlers
				└─ kvm_handle_guest_abort

以上kvm_arch_vcpu_ioctl_run是kvm虚拟机内核运行循环,由qemu用户层在虚拟机创建初始化之后ioctl /dev/kvm进入。上述arm_exit_handlers是一个根据寄存器ESR寄存器值的判断函数,ESR寄存器用于arm64异常状态报告(此处不深入,可参阅相关)。最终由于ESR_ELx_EC_DABT_LOW值原因,进入kvm_handle_guest_abort。该函数基本作用如名字,用于处理客户机中止异常,实际上就是kvm虚拟机任何abort类异常的响应函数。

kvm_handle_guest_abort中有如下关键片段

int kvm_handle_guest_abort(struct kvm_vcpu *vcpu)
{
	unsigned long fault_status;
	phys_addr_t fault_ipa;
	struct kvm_memory_slot *memslot;
	unsigned long hva;
	bool is_iabt, write_fault, writable;
	gfn_t gfn;
	int ret, idx;
	
	// ... ...
	
	gfn = fault_ipa >> PAGE_SHIFT;
	memslot = gfn_to_memslot(vcpu->kvm, gfn);
	hva = gfn_to_hva_memslot_prot(memslot, gfn, &writable);
	write_fault = kvm_is_write_fault(vcpu);
	if (kvm_is_error_hva(hva) || (write_fault && !writable)) {   // ==>位置1
		if (is_iabt) {
			ret = -ENOEXEC;
			goto out;
		}
		// ...

		fault_ipa |= kvm_vcpu_get_hfar(vcpu) & ((1 << 12) - 1);
		ret = io_mem_abort(vcpu, fault_ipa);
		goto out_unlock;
	}
	// ...
	ret = user_mem_abort(vcpu, fault_ipa, memslot, hva, fault_status);   // ==>位置2
	if (ret == 0)
		ret = 1;
}

上述位置1, kvm_is_error_hva判断hva是否有效,hva即是由客户机物理地址(fault_ipa,即gpa)查询得来,就是根据在[过程1.2]中所述的注册的内存块slot,如果在上述过程中注册了hva一定可以查询得到不为NULL,如果没注册查询的结果就是NULL,被判定为error_hva地址。即该地址不是一个正常的内存地址,而是mmio地址,即设备IO地址空间,此时kvm虚拟化执行设备模拟过程,通过后续的io_mem_abort。否则,才认为是一个正常的申请使用内存的二阶页表缺页,进入物理内存分配过程,通过位置2的user_mem_abort。该函数负责具体完成物理页分配和二阶页表缺失项填充。实际上到这里,已经基本表示了虚拟机页面分配的全过程。

user_mem_abort本身具有很多细节,以下简要展示其内存分配相关的主要过程:

user_mem_abort [arch/arm64/kvm/mmu.c]
└─ gfn_to_pfn_prot  [virt/kvm/kvm_main.c]
	└─ __gfn_to_pfn_memslot 
		└─ hva_to_pfn 
			 └─ hva_to_pfn_slow
			 	 └─ get_user_pages_unlocked [mm/gup.c]
└─ kvm_pgtable_stage2_map [arch/arm64/kvm/hyp/pgtable.c]

get_user_pages_unlocked [mm/gup.c]
└─ __get_user_pages 
	└─ faultin_page
		 └─  handle_mm_fault [mm/memory.c]

最后调用到handle_mm_fault, 即linux缺页异常分配函数,完成物理页分配。此后,在user_mem_abort中在分配完物理之后,会调用kvm_pgtable_stage2_map完成填充二阶页表缺失项。

后续的用户对同一内存地址的读写因为二阶页表已经完整建立,不会触发新的中止异常。至此,完成了虚拟机内存管理的全部过程。

四 附录:

关于qemu和kvm概念的区分解释: qemu本身是一个模拟器,不能称作是虚拟化软件。严格讲,模拟器(emulator)和虚拟化(virutalization)是有区别的。模拟器主要是对不同架构指令的翻译,而虚拟化主要是基于同一架构做系统OS间的隔离。早些时,qemu和kvm是两个软件,kvm借用了qemu的代码做设备模拟;后来,两个项目合并了。所以目前来说,kvm相当于是qemu的一个性能增强插件,或者叫加速器。没有kvm,qemu也能跑类似虚拟机,但是性能很差。我们一般说的虚拟化是指启用了kvm加速的qemu。从代码结构去看,qemu和kvm是有分别的,代表了两方面,qemu是虚拟机的用户态实现,而kvm提供了包括虚拟机监视器的内核或系统层实现。

kvm的VHE和NVHE模式。NVHE是kvm“经典”模式,此模式下,kvm分为el1的高层监视器部分,和位于el2的低层监视器部分。因为传统arm64架构下,OS操作系统无法运行在el2特权级;自arm v8.2硬件之后,硬件支持OS操作系统运行在el2级,这就是VHE模式。可见,此时OS和虚拟机监视器同级了,这是一种性能优化结构,但不是虚拟化的经典结构。

posted @ 2026-06-15 22:55  happysc  阅读(46)  评论(0)    收藏  举报