1.内存虚拟化技术实现原理
内存虚拟化其实就是关于如何做Guest虚机到host宿主机物理内存之间的各种地址转换,KVM经历了三代的内存虚拟化技术,大大加快了内存的访问速率。
先看看虚拟化环境和非虚拟化环境,内存分配的差异:
非虚拟化环境,内存分配时逻辑地址需要转换为线性地址,然后由线性地址转换为物理地址。
逻辑地址 ==> 线性地址 ==> 物理地址
虚拟化环境下,由于qemu-kvm进程在宿主机上作为一个普通进程,那对于Guest而言,需要的转换过程就是这样:
GVA===>GPA===>HVA===>HPV
Guest Physical Address, GPA 客户机物理地址
Guest Virtual Address, GVA 客户机虚拟地址
Host Physical Address, HPA 宿主机物理地址
Host Virtual Address, HVA 宿主机虚拟地址
为了实现内存虚拟化,让客户机使用一个隔离的、从零开始且具有连续的内存空间,像KVM虚拟机引入一层新的地址空间,即客户机物理地址空间 (Guest Physical Address, GPA),这个地址空间并不是真正的物理地址空间,它只是宿主机虚拟地址空间在客户机地址空间的一个映射。对客户机来说,客户机物理地址空间都是从零开始的连续地址空间,但对于宿主机来说,客户机的物理地址空间并不一定是连续的,客户机物理地址空间有可能映射在若干个不连续的宿主机地址区间。
在虚拟化环境中,由于虚拟机物理地址不能直接用于宿主机物理MMU进行寻址,所以需要把虚拟机物理地址转换成宿主机虚拟地址 (Host Virtual Address, HVA)。运行在硬件之上的Hypervisor首先会对物理内存进行虚拟地址 (Host Virtual Address, HVA)转换,然后还需要对转换后的虚拟地址内存空间进行再次虚拟,然后输出给上层虚拟机使用,而在虚拟机中同样又要进行GVA转换到GPA操作。显然通过这种映射方式,虚拟机的每次内存访问都需要Hypervisor介入,并由软件进行多次地址转换,其效率是非常低的。
因此,为了提高GVA到HPA转换的效率,目前有两种实现方式来进行客户机虚拟地址到宿主机物理地址之间的直接转换。其一是基于纯软件的实现方式,也即通过影子页表(Shadow Page Table)来实现客户虚拟地址GVA到宿主机物理地址HPA之间的直接转换(KVM虚拟机是支持的)。其二是基于硬件辅助MMU对虚拟化的支持,来实现两者之间的转换。
其中Shadow Page Table(影子页表),其实现非常复杂,因为每一个虚拟机都需要有一个Shadow Page Table。并且这种情况会出现一种非常恶劣的结果,那就是TLB(Translation Lookaside Buffer,传输后备缓冲器)很难命中,尤其是由多个虚拟主机时,因为TLB中缓存的是GVA到GPA的转换关系,所以每一次虚拟主机切换都需要清空TLB,不然主机之间就会发生数据读取错误(因为各主机间都是GVA到GPA)。传输后备缓冲器是一个内存管理单元用于改进虚拟地址到物理地址转换后结果的缓存,而这种问题也会导致虚拟机性能低下。
此外,Intel的EPT(Extent Page Table) 技术和AMD的NPT(Nest Page Table) 技术都对内存虚拟化提供了硬件支持。这两种技术原理类似,都是在硬件层面上实现客户机虚拟地址到宿主机物理地址之间的转换。称为Virtualation MMU。当有了这种MMU虚拟化技术后,对于虚拟机进程来说还是同样把GVA通过内部MMU转换为GPA,并不需要改变什么,保留了完全虚拟化的好处。但是同时会自动把GVA通过Virtualation MMU技术转换为真正的物理地址(HPA)。很明显减少了由GPA到HPA的过程,提升虚拟机性能。
并且CPU厂商还提供了TLB硬件虚拟化技术,以前的TLB只是标记GVA到GPA的对应关系,就这两个字段,现在被扩充为了三个字段,增加了一个主机字段,并且由GVA到GPA以及对应变成了GVA到HPA的对应关系。明确说明这是哪个虚拟机它的GVA到HPA的映射结果。
这里主要讲讲EPT技术的原理:

CR3将客户机程序所见的客户机虚拟地址转化成客户机物理地址,然后通过EPT将客户机物理地址转换成宿主机物理地址,而这两次都是通过CPU硬件自动完成的。EPT是在原有的CR3的页表地址映射的基础上引入了另一次映射,这样就可以直接两次转换了。
EPT(extended page table)可以看做一个硬件的影子页表,在Guest中通过增加EPT寄存器,当Guest产生了CR3和页表的访问的时候,由于对CR3中的页表地址的访问是GPA,当地址为空时候,也就是Page fault后,产生缺页异常,如果在软件模拟或者影子页表的虚拟化方式中,此时会有VM退出,qemu-kvm进程接管并获取到此异常。但是在EPT的虚拟化方式中,qemu-kvm忽略此异常,Guest并不退出,而是按照传统的缺页中断处理,在缺页中断处理的过程中会产生EXIT_REASON_EPT_VIOLATION,Guest退出,qemu-kvm捕获到异常后,分配物理地址并建立GVA->HPA的映射,并保存到EPT中,将EPT载入到MMU,下次转换时候直接查询根据CR3查询EPT表来完成GVA->HPA的转换。以后的转换都由硬件直接完成,大大提高了效率,且不需要为每个进程维护一套页表,减少了内存开销。
2.qemu-kvm 内存虚拟化实现流程
在虚拟机启动时,qemu在qemu进程地址空间申请内存,即内存的申请是在用户空间完成的。通过kvm提供的API,把地址信息注册到KVM中,这样KVM中维护有虚拟机相关的slot,这些slot构成了一个完整的虚拟机物理地址空间。slot中记录了其对应的HVA,页面数、起始GPA等,利用它可以把一个GPA转化成HVA,想到这一点自然和硬件虚拟化下的地址转换机制EPT联系起来,不错,这正是KVM维护EPT的技术核心。整个内存虚拟化可以分为两部分:qemu部分和kvm部分。qemu完成内存的申请,kvm实现内存的管理。看起来简单,但是内部实现机制也并非那么简单。本文先介绍qemu部分。
1)qemu 部分设计的主要结构体
qemu中内存管理的数据结构主要涉及:MemoryRegion、AddressSpace、FlatView、FlatRange、MemoryRegionSection、RAMList、RAMBlock、KVMSlot、kvm_userspace_memory_region等,来张关系图:
1. Multiple types of MemoryRegion (MemoryRegion直接操作内存,每一棵MR树的树根对应一个RAMBlock,其host即为通过mmap()分配的HVA)
- RAM: a range of host memory that can be made available to the guest. e.g. “pc.ram”, “pc.bios”, “pc.rom”, ram/rom for devices like “vga.ram”, “vga.rom”, etc.
- IOMMU region: translate addresses esp. for each PCI device for DMA usage
- container: includes other memory regions, each at a different offset. Use memory_region_init() to initialize.
- alias: a subsection of another region. Initialize with memory_region_init_alias().
2. AddressSpace (代表某种用途的内存,比如"memory", "I/O", "cpu-memory"等,将其他几个内存相关的结构体联系到一起)
- Links all important structures together: MemoryRegion, MemoryRegionListener, FlatView, AddressSpaceDispatch, MemoryRegionIoevented, and so on.
- Initialize with address_space_init().
3. FlatView (将树状的MemoryRegion展成平坦型的FlatView,用于内存映射)
- Spread the MR-tree to Flat FlatView, which is filled with several FlatRange.
4. MemoryListener (用于监听内存以及内存更新)
- Callbacks structure for updates to the physical memory map
- Allows a component to adjust to changes in the guest-visible memory map. Use with memory_listener_register() and memory_listener_unregister().

仅有的几个字段意义比较明确,理论上一个RAMBlock 就代表一段虚拟内存,host指向申请的ram的虚拟地址,是HVA。所有的RAMBlock通过next字段连接起来,表头保存在一个全局的RAMList结构中,但是根据代码来看,原始MR分配内存时分配的是一整块block,之所以这样做也许是为了扩展用吧!!RAMList中有个字段mru_block指针,指向最近使用的block,这样需要遍历所有的block时,先判断指定的block是否是mru_block,如果不是再进行遍历从而提高效率。
qemu的内存管理在交付给KVM管理时,中间又加了一个抽象层,叫做address_space.如果说MR管理的host的内存,那么address_space管理的更偏向于虚拟机。正如其名字所描述的,它是管理地址空间的,qemu中有几个全局的AddressSpace,address_space_memory和address_space_io,很明显一个是管理系统地址空间,一个是IO地址空间。它是如何进行管理的呢?展开下AddressSpace的结构;
具体的范围由一个AddrRange结构描述,其描述了起始地址和大小,offset_in_region表示该区间在全局的MR中的offset,根据此可以进行GPA到HVA的转换,mr指向所属的MR。
到此为止,负责管理的结构基本就介绍完毕,剩余几个主要起中介的作用,MemoryRegionSection对应于FlatRange,一个FlatRange代表一个物理地址空间的片段,但是其偏向于address-space,而MemoryRegionSection则在MR端显示的表明了分片,其结构如下:
其中注意两个偏移,offset_within_region和offset_within_address_space。前者描述的是该section在整个MR中的偏移,一个address_space可能有多个MR构成,因此该offset是局部的。而offset_within_address_space是在整个地址空间中的偏移,是全局的offset。
KVMSlot也是一个中介,只不过更加接近kvm了,
kvm_userspace_memory_region是和kvm共享的一个结构,说共享不太恰当,但是其实最终作为参数给kvm使用的,kvm获取控制权后,从栈中复制该结构到内核,其中字段意思就很简单,不在赘述。
整体布局大致如图所示:

2)qemu分配内存的具体实现流程
qemu部分的内存申请流程上可以分为三小部分,分成三小部分主要是我在看代码的时候觉得这三部分耦合性不是很大,相对而言比较独立。众所周知,qemu起始于vl.c中的main函数,那么这三部分也按照在main函数中的调用顺序分别介绍。
2.1) 回调函数的注册
涉及函数:1)addressspace初始化:main()-->cpu_exec_init_all()-->memory_map_init()
cpu_exec_init_all()函数,该函数主要初始化了IO地址空间和系统地址空间。memory_map_init()函数初始化系统地址空间,有一个全局的MemoryRegion指针system_memory指向该区域的MemoryRegion结构。
所以在函数起始,就对system_memory分配了内存,然后调用了memory_region_init函数对其进行初始化,其中size设置为整个地址空间:如果是64位就是2^64.接着调用了address_space_init函数对address_space_memory进行了初始化。同理对io地址空间的的初始化也是一样。
函数主要做了以下几个工作,设置addressSpace和MR的关联,address_space_memory加入到全局的address_spaces链表中,接下来调用address_space_update_topology更新FlatView,函数address_space_update_topology,将指定的AddressSpace下的MemoryRegion树进行展平,形成了对应一维内存逻辑表示的FlatView,然后再address_space_update_topology_pass中将FlatView模型通过KVM_SET_USER_MEMORY_REGION注册到KVM模块中(具体实现见下文);最后调用address_space_update_ioeventfds初始化设置MR的ioeventfds。回到memory_map_init()函数中,接下来按照同样的模式对IO区域system_io和IO地址空间address_space_io做了初始化。
MR和Flatview关系:MemoryRegion是QEMU管理内存的树状结构,便于按照功能、属性分类;但这只是管理结构。但虚拟机的内存需要通过KVM_SET_USER_MEMORY_REGION,将GPA与HVA的对应关系注册到KVM模块的memslot,才可以生效成为EPT。如果QEMU直接使用MemoryRegion进行注册,那么注册的过程将会很麻烦,也容易不断的出现重叠判断等。所以在通过KVM_SET_USER_MEMORY_REGION注册前,加了一层转换机制,先将树状的MemoryRegion展开物理内存样子的一维区间结构Flatview,然后再通过KVM_SET_USER_MEMORY_REGION将这个展开的物理内存注册到KVM内核模块中,就方便了许多。这个转换机制是FlatView模型。
2)addressspace中注册listener:configure_accelerator() ----->accel_init_machine()----->acc->machine()(kvm_init())----->memory_listener_register ()
这里所说的accelerator在这里就是kvm,初始化函数自然调用了kvm_init,该函数主要完成对kvm的初始化,包括一些常规检查如CPU个数、kvm版本等,还会通过ioctl和内核交互创建kvm结构,这些并非本文重点,不在赘述。在kvm_init函数的结尾调用了memory_listener_register。
kvm_memory_listener_register(s, &s->memory_listener, &address_space_memory, 0) ==> memory_listener_register(&kml->listener, as);
memory_listener_register(&kvm_io_listener, &address_space_io);
通过memory_listener_register函数,针对地址空间注册了lisenter,lisenter本身是一组函数表,当地址空间发生变化的时候,会调用到listener中的相应函数,从而保持内核和用户空间的内存信息的一致性。虚拟机包含有两个地址空间address_space_memory和address_space_io,很容易理解,正常系统也包含系统地址空间和IO地址空间。
看下memory_listener_register函数实现:
/*如果当前地址空间的as->listener链表为空或者当前listener的优先级大于最后一个listener的优先级,则可以直接插入到当前地址空间的as->listener链表中*/
系统中可以存在多个listener,listener之间有着明确的优先级关系,通过链表进行组织,链表头是全局的memory_listeners。函数中,如果memory_listeners为空或者当前listener的优先级大于最后一个listener的优先级,即直接把当前listener插入。否则需要挨个遍历链表,找到合适的位置。具体按照优先级升序查找。注册listener到相应的地址空间链表&as->listeners中也是同理。在函数最后调用listener_add_address_space函数,该函数对其对应的address_space管理的flatrange,通过函数listener_add_address_space向KVM注册。当然,实际上此时listener里的函数数都未经过初始化,所以这里的循环其实是空循环。
3)实际内存的分配
前面注册listener也好,或是初始化addressspace也好,实际上均没有对应的物理内存,目前为止只是初始化好了所有Qemu中需要维护的相关的内存结构,并完成了在KVM中的注册。下面需要初始化KVM中的MMU支持。顺着main函数往下走,会调用到machine->init,实际上对应于pc_init1函数,在该函数中有pc_memory_init()函数对实际的内存做了分配。大致流程如下:
先直接从pc_memory_init()函数开始:
上面函数中的memory_region_add_subregion函数会造成我们MR的变化,地址空间已经发生变化(分配变化了),自然要把变化和KVM(内存管理侧)进行同步,这一工作需要由memory_region_transaction_commit()来实现。
memory_region_transaction_commit在上述函数的调用流程
memory_region_add_subregion-->memory_region_add_subregion_common-->memory_region_update_container_subregions-->memory_region_transaction_commit
接下来看看memory_region_transaction_commit函数:
可以看到,这里listener的作用就凸显出来了。对于每个address_space,先调用flatviews_reset生成新mr的flat_view,并用hash表flat_views维护,然后调用address_space_set_flatview()执行更新。里面还涉及个重要的函数是 address_space_update_topology_pass,他根据oldview和newview对当前视图进行更新。
flatviews_reset,主要是遍历全局as链表address_spaces中每个as,如果hash表flat_views中没有mr被维护,则调用generate_memory_topology重新生成该as中MR对应的flat_view,并用hash表flat_views维护,我们先看下函数generate_memory_topology代码:
先申请一个FlatView结构,并对其进行初始化,然后调用render_memory_region函数实现核心功能,然后调用flatview_simplify尝试合并相邻的FlatRange.,最后再建立MemoryRegionSection与FlatRange的关系。
void render_memory_region(FlatView *view,MemoryRegion *mr,Int128 base,AddrRange clip,bool readonly)是一个递归函数,参数中,view表示当前FlatView,mr最初为system_memory即全局的MR,base起初为0表示从地址空间的其实开始,clip最初为一个完整的地址空间。readonly标识是否只读。看下他的具体实现:
此函数的核心工作起始于一个for循环,循环的条件是 view->nr && int128_nz(remain),表示当前还有未遍历的FR并且remain还有剩余。循环中如果MR base的值大于或者等于当前FR的end,则继续往后遍历FR,否则进入下面,如果base小于当前FR的start,则表明base到start之间的区间还没有映射,则为其进行映射,now表示要映射的长度,取remain和int128_sub(view->ranges[i].addr.start, base)之间的最小值,后者表示下一个FR的start和base之间的差值,当然按照clip为准。接下来就没难度了,设置FR的offset_in_region和addr,然后调用flatview_insert插入到FlatView的FlatRange数组中。不过由于FR按照地址顺序排列,如果插入位置靠前,则需要移动较多的项,不知道为何不用链表实现。下面就很自然了移动base,增加offset_in_region,减少remain等操作。出了if,此时base已经和FR的start对齐,所以还需要略过当前FR。就这么一直映射下去。
出了for循环,如果remain不为0,则表明还有没有映射的,但是现在已经没有已经存在的FR了,所以不会发生冲突,直接把remain直接映射成一个FR即可。
按照这个思路,把所有的subregion都映射下去,最终把FlatView返回。这样generate_memory_topology就算是介绍完了,下面的flatview_simplify是对数组表项的尝试合并,这里就不再介绍。到此为止,已经针对当前MR生成了一个新的FlatView,然后需要对as对应的FlatView进行更新。
address_space_set_flatview函数主要就对as对应的FlatView进行更新: atomic_rcu_set(&as->current_map, new_view); mr变->as的FlatView就需要更新
该函数主体是一个while循环,循环条件是old_view->nr和new_view->nr,表示新旧view的可用FlatRange数目。这里依次对FR数组的对应FR 做对比,主要由下面几种情况:frold和frnew均存在、frold存在但frnew不存在,frold不存在但frnew存在。下面的if划分和上面的略有不同:
1、如果frold不为空&&(frnew为空||frold.start<frnew.start||frold.start=frnew.start)&&frold!=frnew 这种情况是新旧view的地址范围不一样,则需要调用lienter的region_del对frold进行删除。
2、如果frold和frnew均不为空且frold.start=frnew.start 这种情况需要判断日志掩码,如果frold->dirty_log_mask && !frnew->dirty_log_mask,调用log_stop回调函数;如果frnew->dirty_log_mask && !frold->dirty_log_mask,调用log_start回调函数。
3、frold为空但是frnew不为空 这种情况直接调用region_add回调函数添加region。
函数主体逻辑基本如上所述,那我们注意到,当adding为false时,执行的只有第一个情况下的处理,就是删除frold的操作,其余的处理只有在adding 为true的时候才得以执行。这意图就比较明确,首次执行先删除多余的,下次直接添加或者对日志做更新操作了.
这个函数的region_add会调用KVMMemoryListener初始化时注册的MemoryListener回调函数kvm_region_add,此函数最终调用kvm_set_user_memory_region,实现qemu内核与kvm内存的同步。
接下来再看看MEMORY_LISTENER_UPDATE_REGION是如何实现kvm_region_add的调用:
这里有个_direction,其实就是遍历方向,因为listener按照优先级从低到高排列,所以这里其实就是确定让谁先处理。Forward就是从前向后,而reverse就是从后向前。知道这些,看这些代码就不成问题了。
如果是新增了mr,fr被更新了,kvm_region_add()函数将得到执行,那咱们就从kvm_region_add函数开始,对KVM部分的内存管理做介绍。kvm_region_add()函数是listener注册时初始化的region_add回调,在qemu申请好内存后,针对每个FR(宏转化成MemoryRegionSection),调用了listener的region_add函数。最终需要利用此函数把region信息告知KVM,KVM以此对内存信息做记录。咱们直奔主题,函数核心在static void kvm_set_phys_mem(MemoryRegionSection *section, bool add)函数
该函数首先获取section对应的MR的一些属性,如writeable、readonly_flag。获取section的start_addr和size,其中start_addr就是section中的offset_within_address_space也就是FR中的offset_in_region,接下来对size进行了对齐操作 。如果对应的MR关联的内存并不是作为ram存在,就要进行额外的验证。这种情况如果writeable允许写操作或者kvm不支持只读内存,那么直接返回。然后根据第三个布尔参数决定移除还是添加这个section。
接下来就调用kvm_set_user_memory_region把new slot映射进去。基本思路就是这样。下面看下核心函数kvm_set_user_memory_region
可以看到该函数使用了一个kvm_userspace_memory_region对应,该结构本质上作为参数传递给KVM,只是由于不能共享堆栈,在KVM中需要把该结构复制到内核空间,代码本身没什么难度,只是这里如果是只读的mem,需要调用两次kvm_vm_ioctl,第一次设置mem的size为0,具体原因,在kvm侧说明:
kvm对于kvm_vm_ioctl中命令字KVM_SET_USER_MEMORY_REGION的处理如下:
/*
这里如果检查都通过了,首先通过传递进来的slot的id在kvm维护的slot数组中找到对应的slot结构,此结构可能为空或者为旧的slot。然后获取物理页框号、页面数目。如果页面数目大于KVM_MEM_MAX_NR_PAGES,则失败;如果npages为0,则去除KVM_MEM_LOG_DIRTY_PAGES标志。使用旧的slot对新的slot内容做初始化,然后对new slot做设置,参数基本是从用户空间接收的kvm_userspace_memory_region的参数。然后进入下面的if判断
1、如果npages不为0,表明本次要添加slot此时如果old slot的npages为0,表明之前没有对应的slot,需要添加新的,设置change为KVM_MR_CREATE;如果不为0,则需要先修改已有的slot,注意这里如果old slot和new slot的page数目和用户空间地址userspace_addr必须相等,还有就是两个slot的readonly属性必须一致。如果满足上述条件,进入下面的流程。如果映射的物理页框号不同,则设置change KVM_MR_MOVE,如果flags不同,设置KVM_MR_FLAGS_ONLY,否则,什么都不做。
2、如果npages为0,而old.pages不为0,表明需要删除old slot,设置change为KVM_MR_DELETE。到这里基本是做一些准备工作,确定用户空间要进行的操作,接下来就执行具体的动作了。
这里就根据change来做具体的设置了,如果 KVM_MR_CREATE,则设置new.用户空间地址为新的地址。如果new slot要求KVM_MEM_LOG_DIRTY_PAGES,但是new并没有分配dirty_bitmap,则为其分配。如果change为KVM_MR_DELETE或者KVM_MR_MOVE,这里主要由两个操作,一是设置对应slot标识为KVM_MEMSLOT_INVALID,更新页表。二是增加slots->generation,撤销iommu mapping。接下来对于私有映射的话(memslot->id >= KVM_USER_MEM_SLOTS),如果是要创建,则需要手动建立映射。
接下来确保slots不为空,如果是KVM_MR_CREATE或者KVM_MR_MOVE,就需要重新建立映射,使用kvm_iommu_map_pages函数 ,而如果是KVM_MR_DELETE,就没必要为new设置dirty_bitmap,并对其arch字段的结构清零。最终都要执行操作install_new_memslots,不过当为delete操作时,new的memory size为0,那么看下该函数做了什么。
参照:https://www.cnblogs.com/ck1020/p/6729224.html
https://blog.csdn.net/Shirleylinyuer/article/details/
http://oenhan.com/qemu-memory-struct
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjsbk/9416.html