Qemu内存模拟
词汇约定
缩写 | 意义 |
---|---|
VA | Virtual Address 虚拟地址 |
PA | Physical Address 物理地址 |
PT | Page Table 页表(一级页表) |
PDT | Page Directory Table 页目录表(二级页表) |
PDPT | Page Directory Pointers Table 页目录指针表(三级页表) |
PML4T | Page Map Level 4 Table 四级页表 |
PGD | Page Global Directory 页全局目录 |
PUD | Page Upper Directory 页上级目录 |
PMD | Page Middle Directory 页中间目录 |
GVA | Guest Virtual Address 客户机虚拟地址 |
GPA | Guest Physical Address 客户机物理地址 |
HVA | Host Virtual Address 宿主机虚拟地址 |
HPA | Host Physical Address 宿主机物理地址 |
GFN | Guest Frame Number 虚拟机的页框号 |
PFN | Host Page Frame Number 宿主机的页框号 |
SPT | Shadow Page Table 影子页表 |
我们主要研究的是GVA
,GPA
,HVA
,HPA
之间的关系和相互转化。
Qemu的内存模拟
Qemu
利用mmap
系统调用,在Qemu
进程的虚拟地址空间中申请连续的大小的空间,作为Guest
的物理内存,即内存的申请是在用户空间完成的。 通过kvm
提供的API
,Qemu
将Guest
内存的地址信息传递并注册到kvm
中维护,即内存的管理是由内核空间的kvm
实现的。
即:Qemu
负责申请客户机物理内存,kvm
负责管理客户机物理内存。
在这样的架构下,内存地址访问有四层映射:
GVA
=>GPA
=>HVA
=>HPA
GVA
=>GPA
的映射由guest OS
负责维护,而HVA
=>HPA
由host OS
负责维护。
而内存虚拟化的关键是GPA
=>HVA
的映射。
1 | Guest's processes |
为了提高从GVA
到HPA
的转换效率,KVM
常用的实现有SPT(Shadow Page Table)
和EPT/NPT
,前者通过软件维护影子页表直接将GVA
转换成HPA
,省略中间的映射;后者通过硬件特性实现二级映射(two dimentional paging
),将GPA
转换成HPA
。
GVA=>GPA
我们先来分析一下GVA
=>GPA
,这是比较基础的计算机知识,也是写qemu
漏洞利用经常用到的地方,例子如下:
1 |
|
gva_to_gpa
函数原理下方文章里解释地非常详细,这里不再多赘述:
GPA=>HVA
这个是比较复杂的地方,也是Qemu
内存模拟的核心,主要有两种方法,一种是SPT
,一种是EPT(Extent Page Table)
,前者通过软件维护影子页表,后者通过硬件特性实现二级映射。
SPT(影子页表)
KVM
通过维护记录GVA=>HPA
的影子页表SPT
,减少了地址转换带来的开销,可以直接将GVA
转换为HPA
。
在软件虚拟化的内存转换中,GVA
到GPA
的转换通过查询CR3
寄存器来完成,CR3
中保存了Guest
的页表基地址,然后载入MMU
中进行地址转换。
在加入了SPT
技术后,当Guest
访问CR3
时,KVM
会捕获到这个操作EXIT_REASON_CR_ACCESS
,之后KVM
会载入特殊的CR3
和影子页表,欺骗Guest
这就是真实的CR3
。之后就和传统的访问内存方式一致,当需要访问物理内存的时候,只会经过一层影子页表的转换。
影子页表由KVM
维护,实际上就是一个Guest
页表到Host
页表的映射。KVM
会将Guest
的页表设置为只读,当Guest OS
对页表进行修改时就会触发Page Fault
,VM-EXIT
到KVM
,之后KVM
会对GVA
对应的页表项进行访问权限检查,结合错误码进行判断:
- 如果是
Guest OS
引起的,则将该异常注入回去,Guest OS
将调用自己的缺页处理函数,申请一个Page
,并将Page
的GPA
填充到上级页表项中。 - 如果是
Guest OS
的页表和SPT
不一致引起的,则同步SPT
,根据Guest
页表和mmap
映射找到GPA
到HVA
的映射关系,然后在SPT
中增加/更新GVA-HPA
表项。
为了快速检索Guest页表对应的影子页表,KVM为每个客户机维护了一个hash表来进行客户机页表到影子页表之间的映射。
对于每一个Guest来说,其页目录和页表都有唯一的GPA,通过页目录/页表的GPA就可以在哈希链表中快速地找到对应的影子页目录/页表。
当Guest
切换进程时,会把带切换进程的页表基址载入到Guest
的CR3
中,导致VM-EXIT
到KVM
中,KVM
再通过哈希表找到对应的 SPT
,然后加载到机器的CR3
中。
优点:影子页表的引入,减少了GVA=>HPA
的转换开销。
缺点:需要为Guest
的每个进程都维护一个影子页表,这将带来很大的内存开销。同时影子页表的建立是很耗时的,如果Guest
的进程过多,将导致影子页表频繁切换。因此Intel
和AMD
在此基础上提供了基于硬件的虚拟化技术。
EPT/NPT
Intel
的EPT(Extent Page Table)
技术和AMD
的NPT(Nest Page Table)
技术都对内存虚拟化提供了硬件支持。
这两种技术原理类似,都是在硬件层面上实现GVA
到HPA
之间的转换。
下面就以EPT
为例分析一下KVM
基于硬件辅助的内存虚拟化实现。
Intel EPT
技术引入了EPT(Extended Page Table)
和EPTP(EPT base pointer EPT页表基址寄存器)
的概念。EPT
中维护着GPA
到 HPA
的映射,而EPTP
负责指向EPT
基址,类似于CR3
指向Guest OS
页表基址。
在Guest OS
运行时,Guest
对应的EPT
地址被加载到EPTP
,而Guest OS
当前运行的进程页表基址被加载到CR3
。于是在进行地址转换时,首先通过CR3
指向的页表实现GVA
到GPA
的转换,再通过EPTP
指向的EPT
完成GPA
到HPA
的转换。当发生EPT Page Fault
时,需要VM-EXIT
到KVM
,更新EPT
。
- 优点:
Guest
的缺页在Guest OS
内部处理,不会VM-EXIT
到KVM
中。地址转化基本由硬件(MMU
)查页表来完成,大大提升了效率,且只需为Guest
维护一份EPT
页表,减少内存的开销。 - 缺点:两级页表查询,只能寄望于
TLB
命中。
Qemu与KVM的分工合作
QEMU
和KVM
之间是通过KVM
提供的ioctl()
接口进行交互的。在linux kernel 2.6
的kvm_vm_ioctl()
函数中,设置虚拟机内存的系统调用为KVM_SET_USER_MEMORY_REGION
:
1 | // /virt/kvm/kvm_main.c |
参数类型为kvm_userspace_memory_region
:
1 | /* for KVM_SET_USER_MEMORY_REGION */ |
KVM_SET_USER_MEMORY_REGION
这个ioctl
主要目的就是设置GPA=>HVA
的映射关系,KVM
会继续调用kvm_vm_ioctl_set_memory_region()
,在内核空间维护并管理Guest
的内存。
核心数据结构
AddressSpace
1 | // include/exec/memory.h |
QEMU
用AddressSpace
结构体表示Guest
中CPU/设备看到的内存,类似于物理机中地址空间的概念,但在这里表示的是Guest
的一段地址空间,如内存地址空间address_space_memory
、I/O 地址空间address_space_io
。
每个AddressSpace
一般包含一系列的MemoryRegion
:root
指针指向根级MemoryRegion
,而root
可能有自己的若干个subregions
,于是形成树状结构。这些MemoryRegion
通过树连接起来,树的根即为AddressSpace
的root
域。
AddressSpace
有两个静态全局变量address_space_memory
和address_space_io
,这两个变量的root
域分别指向后面要说的system_memory
和syetem_io
。
1 | // exec.c 可能不同版本的qemu源码位置不一样 |
MemoryRegion
MemoryRegion
表示在Guest Memory Layout
中的一段内存区域,它是联系GPA
和RAMBlocks(描述真实内存)
之间的桥梁。
1 | // include/exec/memory.h |
其同样有两个全局变量system_memory
和system_io
:
1 | // exec.c |
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
划分为以下三种类型:
root MemoryRegion
:
直接通过memory_region_init
初始化,没有自己的内存,用于管理subregion
,例如之前说的system_memory
:
1 | void memory_region_init(MemoryRegion *mr, |
实体 MemoryRegion
:
通过memory_region_init_ram
初始化,有自己的内存(从qemu
进程地址空间中分配),大小为size
,例如ram_memory
,pci_memory
:
1 | void *pc_memory_init(MemoryRegion *system_memory, |
alias MemoryRegion
:
通过memory_region_init_alias()
初始化,没有自己的内存,表示实体 MemoryRegion
的一部分。通过alias
成员指向实体 MemoryRegion
,alias_offset
为在实体 MemoryRegion
中的偏移量,例如ram_below_4g
、ram_above_4g
:
1 | void pc_memory_init(PCMachineState *pcms, |
RAMBlock
MemoryRegion
用来描述一段逻辑层面上的内存区域,而记录实际分配的内存地址信息的结构体则是RAMBlock
:
1 | // include/exec/ram_addr.h |
可以看到在RAMBlock
中host
和offset
域分别对应了HVA
和GPA
,因此也可以说RAMBlock 中存储了GPA->HVA
的映射关系,另外每一个RAMBlock
都会指向其所属的MemoryRegion
。
全局变量ramlist
以单链表的形式管理所有的RAMBlock
:
1 | // include/exec/ramlist.h |
每一个新分配的RAMBlock
都会被插入到ram_list
的头部。如需查找地址所对应的RAMBlock
,则需要遍历ram_list
,当目标地址落在当前RAMBlock
的地址区间时,该RAMBlock
即为查找目标。
关系小结
AddressSpace
、MemoryRegion
、RAMBlock
之间的关系如下所示:
可以看到AddressSpace
的root
域指向根级MemoryRegion
,AddressSpace
是由root
域指向的MemoryRegion
及其子树共同表示的。MemoryRegion作为一个逻辑层面的内存区域,还需借助分布在其中的RAMBlock来存储真实的地址映射关系。
FlatView
AddressSpace 的root
域及其子树共同构成了 Guest 的物理地址空间,但这些都是在QEMU
侧定义的。要传入KVM
进行设置时,复杂的树状结构是不利于内核进行处理的,因此需要将其转换为一个平坦
的地址模型,也就是一个从零开始、只包含地址信息的数据结构,这在QEMU
中通过FlatView
来表示。每个AddressSpace
都有一个与之对应的FlatView
指针current_map
,表示其对应的平面展开视图。
1 | // include/exec/memory.h |
在FlatView
中,FlatRange
表示在FlatView
中的一段内存范围:
1 | // memory.c |
每个FlatRange
对应一段虚拟机物理地址区间,各个FlatRange
不会重叠,按照地址的顺序保存在数组中,具体的地址范围由一个 AddrRange
结构来描述:
1 | // memory.c |
MemoryRegionSection
在QEMU
中,还有几个起到中介作用的结构体,MemoryRegionSection
就是其中之一。
之前介绍的FlatRange
代表一个物理地址空间的片段,偏向于描述在Host
侧即AddressSpace
中的分布,而MemoryRegionSection
则代表在Guest
侧即MemoryRegion
中的片段。
1 | // include/exec/memory.h |
AddressSpace
的root
指向对应的根级MemoryRegion
,current_map
指向root
通过generate_memory_topology()
生成的FlatView
。FlatView
中的ranges
数组表示该MemoryRegion
所表示的Guest
地址区间,并按照地址的顺序进行排列。MemoryRegionSection
由ranges
数组中的FlatRange
对应生成,作为注册到KVM
中的基本单位。
参考
https://abelsu7.top/2019/07/07/kvm-memory-virtualization/
https://www.binss.me/blog/qemu-note-of-memory/
[https://juniorprincewang.github.io/2018/07/20/qemu%E5%86%85%E5%AD%98%E8%99%9A%E6%8B%9F%E5%8C%96/
https://www.slideshare.net/HwanjuKim/4memory-virtualization-and-management?next_slideshow=1