PCIE设备透传解决的是使虚拟机直接访问PCIE设备的技术,通常情况下,为了使虚拟机能够访问Hypervisor上的资源,QEMU,KVMTOOL等虚拟机工具提供了"trap and emulate", Virtio半虚拟化等机制实现。但是这些实现都需要软件的参与,性能较低。
trap and emulate情况下,虚拟机每次访问硬件资源都要进行VMExit退出虚拟机执行相应的设备模拟或者访问设备的操作,完成后再执行VMEnter进入虚拟机。频繁的模式切换导致IO访问的低效。
而Virtio则是一种半虚拟化机制,要求虚拟机中运行的操作系统需要加载特殊的virtio前端驱动(Virtio-xxx),虚拟机通过循环命令队列和Hypervisor上运行的Virtio后端驱动进行通信,后端驱动负责适配不同的物理硬件设备,再收到命令后,后端驱动执行命令。
PCIE设备透传到底"透"了什么?
参考如下两篇文章搭建PCIE设备PASS-THROUGH的环境:
ubuntu18.04下pass-through直通realteck PCI设备到qemu-kvm虚拟机实践_kvm网卡直通-CSDN博客
透了HOST MEMORY
设备透传解决了让虚拟机中的驱动使用IOVA访问物理内存的问题,在KVMTOOL中,它是通过调用VFIO的VFIO_IOMMU_MAP_DMA 命令来实现的,用来将IOVA映射到具体的物理页面上(通过HVA 得到HVA对应的物理页面,再进行映射)。下图说明了一切问题:
0.映射SIZE为整个GPA大小,也就是虚拟机的整个物理内存。
1.kvm->ram_start和bank->host_addr相同,表示被映射的区域,VFIO驱动会通过bank->host_addr找到对应的PAGE页面。
2.iova为bank->guest_phys_addr,也就是虚拟机内的GPA。也就是说,IOMMU页表建立后,透传的设备驱动可以通过和CPU一致的物理地址,访问到真实的物理页面上(HPA),这样,从CPU和涉笔的角度,可以做大IOVA==GPA。
3.映射完成后,从虚拟机的角度来看,CPU看到的物理地址(GPA)和硬件看到的物理地址(IOVA)都通过各自的路径(前者通过EPT,后者通过IOMMU)访问同一个存储单元。
4. IOVA到HPA的映射通过HOST主机的VFIO驱动完成,VFIO驱动代码规模比较小,VFIO驱动的一个重要功能之一通过设备节点的方式,使用户态应用能够进行IOMMU映射,从这个角度来讲,VFIO是一个精简的IOMMU驱动和管理框架。
GPA和IOVA建立后的效果如下,设备和CPU通过相同的地址,就可以访问到同一个物理单元,这样虚拟机系统不需通过VMM就可以直接访问到设备,这就是设备“透传”的本质吧。
下面是一个演示设备透传的程序:
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>
#include <errno.h>
#include <linux/vfio.h>
#define IOVA_DMA_MAPSZ (1*1024UL*1024UL)
#define IOVA_START (0UL)
#define VADDR 0x400000000000
// refer https://www.cnblogs.com/dream397/p/13546968.html
// container fd: the container provides little functionality, with all but a couple vrson and extension query interfaces.
// 1. first identify the group associated wth the desired device.
// 2. unbinding the device from the host driver and binding it to a vfio driver, then a new group would appear for the group as /dev/vfio/$group.
// make sure all the devices belongs to the group are all need to do the unbind and binding operations or error will got for next group ioctl.
// 3. group is ready, then add to the caontainer by opening the vfio group character device and use VFIO_GROUP_SET_CONTAINER ioctl to add the group
// fd to container.depending the iommu, multi group can be set to one container.
// 4. after group adding to container, the remaning ioctls became available. enable the iommu device access.now you can get each device belongs the
// iommu group and get the fd.
// 5. the vfio device ioctls includes for describing the device, the IO regions, and their read/write/mmap operations, and others such as describing
// and registering interrupt notificactions.
/*
* #1:echo vfio-pci > /sys/bus/pci/devices/0000:02:00.0/driver_override
* #2:echo 10de 1d13 > /sys/bus/pci/drivers/vfio-pci/new_id
*root@zlcao-RedmiBook-14:~# ls -l /dev/vfio/
*总用量 0
*crw------- 1 root root 243, 0 11月 8 12:40 12
*crw-rw-rw- 1 root root 10, 196 11月 8 12:31 vfio
*/
int main(void)
{
int container, group, device, i;
void *maddr = NULL;
struct vfio_group_status group_status = { .argsz = sizeof(group_status) };
struct vfio_iommu_type1_info *iommu_info = NULL;
size_t iommu_info_size = sizeof(*iommu_info);
struct vfio_device_info device_info = { .argsz = sizeof(device_info) };
struct vfio_iommu_type1_dma_map dma_map;
struct vfio_iommu_type1_dma_unmap dma_unmap;
container = open("/dev/vfio/vfio", O_RDWR);
if (container < 0) {
printf("%s line %d, open vfio container error.\n", __func__, __LINE__);
return 0;
}
if (ioctl(container, VFIO_GET_API_VERSION) != VFIO_API_VERSION) {
printf("%s line %d, vfio api version check failure.\n", __func__, __LINE__);
return 0;
}
if (ioctl(container, VFIO_CHECK_EXTENSION, VFIO_TYPE1_IOMMU) == 0) {
printf("%s line %d, vfio check extensin failure.\n", __func__, __LINE__);
return 0;
}
group = open("/dev/vfio/9", O_RDWR);
if (group < 0) {
printf("%s line %d, open vfio group error.\n", __func__, __LINE__);
return 0;
}
if (ioctl(group, VFIO_GROUP_GET_STATUS, &group_status)) {
printf("%s line %d, failed to get vfio group status.\n", __func__, __LINE__);
return 0;
}
if ((group_status.flags & VFIO_GROUP_FLAGS_VIABLE) == 0) {
printf("%s line %d, vfio group is not viable.\n", __func__, __LINE__);
return 0;
}
if (ioctl(group, VFIO_GROUP_SET_CONTAINER, &container)) {
printf("%s line %d, vfio group set conatiner failure.\n", __func__, __LINE__);
return 0;
}
if (ioctl(container, VFIO_SET_IOMMU, VFIO_TYPE1_IOMMU) != 0) {
printf("%s line %d, vfio set type1 mode failure %s.\n", __func__, __LINE__, strerror(errno));
return 0;
}
iommu_info = malloc(iommu_info_size);
if (iommu_info == NULL) {
printf("%s line %d, vfio alloc iommu info failure %s.\n", __func__, __LINE__, strerror(errno));
return 0;
}
memset(iommu_info, 0x00, iommu_info_size);
iommu_info->argsz = iommu_info_size;
if (ioctl(container, VFIO_IOMMU_GET_INFO, iommu_info)) {
printf("%s line %d, vfio failed to get iomu info, %s.\n", __func__, __LINE__, strerror(errno));
return 0;
}
// todo
// collect available iova regions from VFIO_IOMMU_GET_INFO.
// 0000:02:00.0 must in this group.
device = ioctl(group, VFIO_GROUP_GET_DEVICE_FD, "0000:02:00.0");
if (device < 0) {
printf("%s line %d, get vfio group device error.\n", __func__, __LINE__);
return 0;
}
ioctl(device, VFIO_DEVICE_RESET);
if (ioctl(device, VFIO_DEVICE_GET_INFO, &device_info)) {
printf("%s line %d, get vfio group device info error.\n", __func__, __LINE__);
return 0;
}
{
struct vfio_region_info region = {
.index = VFIO_PCI_CONFIG_REGION_INDEX,
.argsz = sizeof(struct vfio_region_info),
};
if (ioctl(device, VFIO_DEVICE_GET_REGION_INFO, ®ion)) {
printf("%s line %d, get vfio group device region info error.\n", __func__, __LINE__);
return 0;
}
}
maddr = mmap((void *)VADDR, IOVA_DMA_MAPSZ, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
if (maddr == MAP_FAILED) {
printf("%s line %d, faild to map buffer, error %s.\n", __func__, __LINE__, strerror(errno));
return -1;
}
memset(&dma_map, 0x00, sizeof(dma_map));
dma_map.argsz = sizeof(dma_map);
dma_map.flags = VFIO_DMA_MAP_FLAG_READ | VFIO_DMA_MAP_FLAG_WRITE;
dma_map.iova = IOVA_START;
dma_map.vaddr = (unsigned long)maddr;
dma_map.size = IOVA_DMA_MAPSZ;
if (ioctl(container, VFIO_IOMMU_MAP_DMA, &dma_map)) {
printf("%s line %d, faild to do dma map on this conatainer.\n", __func__, __LINE__);
return -1;
}
printf("%s line %d, do vfio dma mamp 1M memory buffer success, the iova is 0x%llx, dmaavddr 0x%llx, userptr %p.\n",
__func__, __LINE__, dma_map.iova, dma_map.vaddr, maddr);
memset(&dma_unmap, 0x00, sizeof(dma_unmap));
dma_unmap.argsz = sizeof(dma_unmap);
dma_unmap.iova = IOVA_START;
dma_unmap.size = IOVA_DMA_MAPSZ;
if (ioctl(container, VFIO_IOMMU_UNMAP_DMA, &dma_unmap)) {
printf("%s line %d, faild to do dma unmap on this conatainer.\n", __func__, __LINE__);
return -1;
}
munmap((void *)maddr, IOVA_DMA_MAPSZ);
close(device);
close(group);
close(container);
return 0;
}
测试程序在IOMMU上影射了1M的空间,IOVA范围为[0, 0x100000],我们DUMP PCIE连接IOMMU其页表为:
然后DUMP HOST HVA[0x400000000000,0x400000100000]范围的页表映射:
仔细对比两张截图,会发现他们的物理页框顺序完全一致,这样,在虚拟机中CPU通过GPA访问得到的数据和设备通过同样的IOVA访问的数据会保持一致,就像在真实的硬件上执行时的情况一样,这就是透传的效果,IOMMU功不可没,它让GUEST OS中具备了越过VMM直接访问设备的能力。
PCIE BAR空间的透传
PCI设备上可能会有板上的存储空间,比如PCIE显卡上的独立显存,或者PCIE网卡上的发送和接收缓冲队列,处理器需要将这些板上的内存映射到地址空间进行访问,但是与标准中预先定义好的内存不同,不同的机器上插的PCIE设备不同,这些都是变化的,处理器不可能为所有的PCIE设备预先定义一个地址空间的映射方案,因此,PCI标准提出了一个灵活的办法,各个PCIE设备自己提出需要占据的地址空间大小,以及映射方式(MMIO还是PIO),然后将这些诉求信息记录在配置空间的BAR字段,每个PCIE设备最多可以映射六个区域,对应六个BAR,至于映射到地址空间的什么位置,由BIOS在系统初始化时,查询PCIE设备的诉求信息,统一为PCI设备划分地址空间。
注意,前面提到的地址空间是PA(HPA 或者GPA)。
那么VCPU是如何访问透传到虚拟机的PCIE设备的BAR空间,从而达到访问PCIE设备上的存储的目的的呢?
先看一个PCIE设备透传前后,BAR空间映射的例子:
Realtek的一块PCIE有线网卡的信息如下,可以看到,它有三个BAR空间, BAR0是PIO模式访问的IO空间,BAR2是一块MMIO映射的Memory,大小为4K,启动时BIOS分配的地址是0xdf104000,最后一块BAR是Region4,它的起始地址为0xdf100000,大小为16K。当前设备使用的kernel-driver是vfio-pci,说明当前设备已经处于透传状态。
在虚拟机中的设备状态如下,虚拟机中的lspci工具基于BB,比较简陋,但是我们仍然能够通过vendor id/product id确认设备00:00.0就是透传到虚拟机的网卡设备:
lspci无法得到设备BAR信息,可以通过/proc/iomem以及/proc/ioport获取,如下图,我们找到了透传到虚拟机后的网卡的三个BAR空间信息,从每个BAR的大小来看,是和主机端一致的,这也侧面说明我们找对了。
BAR资源的透传是说,从VM中访问资源地址0xd2004000, 和在HOST中访问0xdf100000访问到的内容是一样的,它的过程和在KVM中添加一个内存条的步骤是一致的,都是将一个HVA区域映射到 VM中一段指定的GPA,只不过,在映射内存的时候,HVA是mmap映射的一段主存,而GPA是0(VM的物理地址从0开始),而在映射BAR空间的时候,HVA则是用户态MMAP HOST机上的BAR空间得到的用户态地址,GPA则成为了一段VM中的一段空闲的物理空间,这里是0xd2004000。
整个映射的逻辑如下图所示:
经过软硬件的层层映射,最终达到了GUEST OS中的应用程序通过VCPU访问PCIE BAR空间的目的。
KVMTOOL定义了PCIE MMIO GPA的范围:
下图是验证BAR内存从HPA(0xdf104000)到HVA(0x7f7e59eb5000)再到GPA(0xd2004000)的映射过程,利用内核EXPORT接口follow_pfn反查页表,发现GPA对应的HVA,反查得到的页表项恰好就是HPA BAR地址,证明了上图分析的正确性。
注意,在VFIO的视线中,HOST MMAP的是通过PAGE FAULT填充BAR空间的资源页的,由于BAR资源没有PAGE结构对应,所以vfio_pci_mmap_fault直接调用内核接口io_remap_pfn_range建立映射。
#
# _-----=> irqs-off
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
cat-29366 [000] .... 62928.901313: vfio_pci_mmap_fault <-__do_fault
cat-29366 [000] .... 62928.901327: <stack trace>
=> vfio_pci_mmap_fault
=> __do_fault
=> __handle_mm_fault
=> handle_mm_fault
=> fixup_user_fault
=> iopfn_map_get
=> my_seq_ops_show
=> seq_read
=> proc_seq_read
=> proc_reg_read
=> __vfs_read
=> vfs_read
=> ksys_read
=> __x64_sys_read
=> do_syscall_64
=> entry_SYSCALL_64_after_hwframe
总结
1.透传场景下,设备内存映射给VCPU,不会通过DMAR映射给设备自身,设备内存本身就在设备上,设备可以直接访问(通过设备内部的IOMMU),不需要映射给DMAR。资源内存映射给DMAR的情况一般是三方外设通过自身IOMMU DOMAIN(自身设备绑定)访问其他设备上的存储资源。
2.透传的设备访问主存仅仅需要一次映射,由DMAR完成IOVA到HPA的翻译。而VCPU访问无论访问主存还是访问设备内存,都需要走两次映射,第一次在虚拟机内部,实现GVA到GPA翻译,由MMU完成,第二次由EPT完成,实现从GPA到HPA的翻译。
3.由于资源内存没有struct page管理,所以EPT在建表做映射时,获取HPFN的方式会有所区别,对于主内存可以得到其struct page,但是对于设备内存来说,由于没有struct page对应,需要手搓查页表获取HPFN(资源存储映射的VMA会设置VM_IO | VM_PFNMAP标志),具体可以分析hva_to_pfn调用hva_to_pfn_remapped的分支实现.
4.IOMMU和EPT中的页表项填充的地址都是HPA地址,这个HPA地址并不是直接分配获取的,而是透过进程HVA地址空间分配->查页表方式获取的。这样既利用了DMAR的REMAP实现透传到不同虚拟机的设备之间的隔离,又将这种隔离透过进程间通信等方式,实现HVA之间的页面共享,隔离和共享均控制在用户手中。避免了设备直接访问内存带来的不确定性。
5.GPA==IOVA, 也就是说,IO function看到的物理地址空间和CPU看到的物理地址空间完全一致了。
更新:
透传设备访问自身资源和其他设备访问本设备路径应该是不同的,本设备资源地址从自身地址域开始,而其他设备映射本设备时,是从系统物理映射的物理地址资源域作为物理地址映射的。
更新:
DMAR既可以映射设备内存,也可以映射主存,本设备通过本地SMMU访问,其他设备通过本地SMMU+DMAR映射总线地址访问。
参考文章
https://www.kernel.org/doc/ols/2007/ols2007v1-pages-225-230.pdf
VIRTIO后端框架QEMU与VHOST分析_qemu vhost-CSDN博客