设备直通给虚拟机能够极大提升虚拟机对物理设备访问的性能,本文通过vfio内核模块和qemu用户态实现介绍vfio设备直通时的关键部分,包括:用户态访问设备IO地址空间,DMA重映射,中断重映射等.
VFIO访问直通设备IO地址空间
1.PIO和MMIO
设备的IO地址空间的访问有PIO和MMIO两种方式,前者通过独立的IO端口访问设备,而MMIO是在物理内存中映射一段区间,直接访问该内存就可以访问设备的配置空间.在虚拟化的场景下,虚拟机通过PIO访问直通设备时,首先会VM-exit到qemu,由qemu通过转换表完成对该PIO操作的转发.对于PCI设备而言,其bar空间地址是通过PIO的方式设置的,如果将设备的PIO访问完全暴露给虚拟机,虚拟机修改了真实的物理设备的PCI Bar空间基地址配置,与host上不一致,可能会出现严重的问题,所以对于设备的PIO访问需要建立转换表,在VM-exit之后由qemu来完成设置的转发.
对于设备的MMIO空间访问,则可以通过建立EPT页表将设备的MMIO物理内存映射到虚拟的MMIO地址空间,让虚拟机能够直接通过MMIO访问PCI设备的bar空间,提高IO性能.
2.获取直通设备信息
通过VFIO提供的接口可以获取到设备的基本信息,包括设备的描述符、region的数量等。
vfio_get_device: fd = ioctl(group->fd, VFIO_GROUP_GET_DEVICE_FD, name); ret = ioctl(fd, VFIO_DEVICE_GET_INFO, & dev_info); vbasedev->fd = fd; vbasedev->group = group; QLIST_INSERT_HEAD(& group->device_list, vbasedev, next); vbasedev->num_irqs = dev_info.num_irqs; vbasedev->num_regions = dev_info.num_regions; vbasedev->flags = dev_info.flags;
2.直通设备PCI配置空间模拟
Qemu为每个PCI直通设备都建立一个虚拟数据结构 VFIOPCIDevice,保存物理PCI设备的相关信息,由vfio_get_device来获取,保存到vbasedev中。
typedef struct VFIOPCIDevice { PCIDevice pdev; VFIODevice vbasedev;
VFIO设备作为qemu的设备模型的一部分,qemu对直通设备的模拟初始化入口在 vfio_realize,通过vfio_get_device获取到直通设备的基本信息之后,会调用pread设备的fd获取到设备的配置空间信息的一份拷贝,qemu会写入一些自定义的config配置。
vfio_realize: /* Get a copy of config space */ ret = pread(vdev->vbasedev.fd, vdev->pdev.config, MIN(pci_config_size(& vdev->pdev), vdev->config_size), vdev->config_offset); if (ret < (int)MIN(pci_config_size(& vdev->pdev), vdev->config_size)) { ret = ret < 0 ? -errno : -EFAULT; error_setg_errno(errp, -ret, "failed to read device config space"); goto error; } /* vfio emulates a lot for us, but some bits need extra love */ vdev->emulated_config_bits = g_malloc0(vdev->config_size); /* QEMU can choose to expose the ROM or not */ memset(vdev->emulated_config_bits + PCI_ROM_ADDRESS, 0xff, 4); /* QEMU can change multi-function devices to single function, or reverse */ vdev->emulated_config_bits[PCI_HEADER_TYPE] = PCI_HEADER_TYPE_MULTI_FUNCTION; /* Restore or clear multifunction, this is always controlled by QEMU */ if (vdev->pdev.cap_present & QEMU_PCI_CAP_MULTIFUNCTION) { vdev->pdev.config[PCI_HEADER_TYPE] |= PCI_HEADER_TYPE_MULTI_FUNCTION; } else { vdev->pdev.config[PCI_HEADER_TYPE] & = ~PCI_HEADER_TYPE_MULTI_FUNCTION; } /* * Clear host resource mapping info. If we choose not to register a * BAR, such as might be the case with the option ROM, we can get * confusing, unwritable, residual addresses from the host here. */ memset(& vdev->pdev.config[PCI_BASE_ADDRESS_0], 0, 24); memset(& vdev->pdev.config[PCI_ROM_ADDRESS], 0, 4);
3.直通设备MMIO映射
直通PCI设备的MMIO内存主要是指其Bar空间,qemu使用vfio_populate_device函数调用VFIO接口获取到PCI设备的Bar空间信息,然后通过vfio_region_setup获取到对应region的信息,并将qemu内存虚拟化的MemoryRegion设置为IO类型的region。重要的是,qemu会为该IO类型的MemoryRegion设置ops为vfio_region_ops,这样后续对于该块内存的读写会经过qemu VFIO模块注册的接口来进行。
vfio_populate_device: for (i = VFIO_PCI_BAR0_REGION_INDEX; i < VFIO_PCI_ROM_REGION_INDEX; i++) { char *name = g_strdup_printf("%s BAR %d", vbasedev->name, i); ret = vfio_region_setup(OBJECT(vdev), vbasedev, & vdev->bars[i].region, i, name); -> vfio_get_region_info -> memory_region_init_io(region->mem, obj, & vfio_region_ops, region, name, region->size); QLIST_INIT(& vdev->bars[i].quirks); } ret = vfio_get_region_info(vbasedev, VFIO_PCI_CONFIG_REGION_INDEX, & reg_info); -> ioctl(vbasedev->fd, VFIO_DEVICE_GET_REGION_INFO, *info)) ret = ioctl(vdev->vbasedev.fd, VFIO_DEVICE_GET_IRQ_INFO, & irq_info);
到这里已经获取到了PCI设备的MMIO内存信息,但是还没有真正的将物理内存中的Bar空间映射到qemu,这一动作在vfio_bars_setup中完成,vfio_region_mmap会对region中每个需要map的内存地址完成映射,然后将映射的物理内存通过qemu注册到虚拟机作为一段虚拟机的物理地址空间。
vfio_bars_setup: for (i = 0; i < PCI_ROM_SLOT; i++) vfio_bar_setup(vdev, i); vfio_region_mmap(& bar->region) for (i = 0; i < region->nr_mmaps; i++) { region->mmaps[i].mmap = mmap(NULL, region->mmaps[i].size, prot, MAP_SHARED, region->vbasedev->fd, region->fd_offset + region->mmaps[i].offset); memory_region_init_ram_device_ptr memory_region_add_subregion pci_register_bar(& vdev->pdev, nr, type, bar->region.mem);
这里的映射mmap接口对应的是VFIO设备在内核中注册的vfio_pci_mmap 函数,在内核中,该函数会为vma注册一个mmap的ops,对应着注册了一个缺页处理函数,当用户态程序访问该段虚拟内存缺页时,调用注册的缺页处理函数,完成虚拟地址到实际物理地址的映射。
vfio_pci_mmap: vma->vm_flags |= VM_IO | VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP; vma->vm_ops = & vfio_pci_mmap_ops; -> .fault = vfio_pci_mmap_fault, -> if (remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, vma->vm_end - vma->vm_start, vma->vm_page_prot))
简单来说,对于MMIO内存的的映射,主要是将物理内存中的MMIO空间映射到了qemu的虚拟地址空间,然后再由qemu将该段内存注册进虚拟机作为虚拟机的一段物理内存,在这个过程中会建立从gpa到hpa的EPT页表映射,提升MMIO的性能。
DMA重映射
首先关于DMA,设备通过DMA可以直接使用iova地址访问物理内存,从iova到实际物理地址的映射是在IOMMU中完成的,一般在dma_allooc分配设备能够访问的内存的时候,会分配iova地址和实际的物理地址空间,并在iommu中建立映射关系。
所以说要让设备进行DMA最关键的几个部分:
- 设备能够识别的地址:IOVA
- 一段物理内存
- IOVA到物理内存在IOMMU中的映射关系
基于这几点来看VFIO的DMA重映射就比较清晰,首先从VFIO设备的初始化开始,在获取设备信息之前会先获取到设备所属的group和Container,并调用VFIO_SET_IOMMU完成container和IOMMU的绑定,并attach由VFIO管理的所有设备。此外注意到这里的 pci_device_iommu_address_space 函数,意思是qemu为设备dma注册了一段专门的地址空间,这段内存作为虚拟机的一段物理内存存在,在VFIO_SET_IOMMU之后,注册该地址空间,其region_add函数为 vfio_listener_region_add,意思是当内存空间布局发生变化这里是增加内存的时候都会调用该接口。
vfio_realize: group = vfio_get_group(groupid, pci_device_iommu_address_space(pdev), errp); vfio_connect_container(group, as, errp) ret = ioctl(fd, VFIO_SET_IOMMU, container->iommu_type); container->listener = vfio_memory_listener; memory_listener_register(& container->listener, container->space->as); -> .region_add = vfio_listener_region_add,
那么跟DMA有什么关系呢,当为设备进行DMA分配一块内存时,实际是以MemoryRegion的形式存在的,也就是说虚拟机进行dma alloc 会调用region_add函数,进而调用注册的memory_listener_region_add函数,MemoryRegion有了意味着分配了一块物理内存,还需要IOVA和映射关系才行。这里,IOVA地址使用的是section->offset_within_address_space,为什么可以这样,因为IOVA地址只是作为设备识别的地址,只要建立了映射关系就有意义。
vfio_listener_region_add: iova = TARGET_PAGE_ALIGN(section->offset_within_address_space); /* Here we assume that memory_region_is_ram(section->mr)==true */ vaddr = memory_region_get_ram_ptr(section->mr) + section->offset_within_region + (iova - section->offset_within_address_space); ret = vfio_dma_map(container, iova, int128_get64(llsize), vaddr, section->readonly); vfio_dma_map: struct vfio_iommu_type1_dma_map map = { .argsz = sizeof(map), .flags = VFIO_DMA_MAP_FLAG_READ, .vaddr = (__u64)(uintptr_t)vaddr, .iova = iova, .size = size, }; ioctl(container->fd, VFIO_IOMMU_MAP_DMA, & map)
建立映射的关键在于vfio_dma_map,通过ioctl调用container->fd接口VFIO_IOMMU_MAP_DMA完成DMA重映射。为什么是container->fd,因为VFIO Container管理内存资源,与IOMMU直接绑定,而IOMMU是完成IOVA到实际物理内存映射的关键。值得注意的是qemu只知道这一段内存的虚拟地址vaddr,所以将vaddr,iova和size传给内核,由内核获取物理内存信息完成映射。
vfio_dma_do_map: vfio_pin_map_dma while (size) { /* Pin a contiguous chunk of memory */ npage = vfio_pin_pages_remote(dma, vaddr + dma->size, size >> PAGE_SHIFT, & pfn, limit); /* Map it! */ vfio_iommu_map(iommu, iova + dma->size, pfn, npage, dma->prot); list_for_each_entry(d, & iommu->domain_list, next) iommu_map(d->domain, iova, (phys_addr_t)pfn < < PAGE_SHIFT, npage < < PAGE_SHIFT, prot | d->prot); arm_smmu_map __arm_lpae_map size -= npage < < PAGE_SHIFT; dma->size += npage < < PAGE_SHIFT; }
内核完成建立iova到物理内存的映射之前会将分配的DMA内存给pin住,使用vfio_pin_pages_remote接口可以获取到虚拟地址对应的物理地址和pin住的页数量,然后vfio_iommu_map进而调用iommu以及smmu的map函数,最终用iova,物理地址信息pfn以及要映射的页数量在设备IO页表中建立映射关系。
+--------+ iova +--------+ gpa +----+ | device | -> | memory | < - | vm | +--------+ +--------+ +----+
最终完成了DMA重映射,设备使用qemu分配的iova地址通过IOMMU映射访问内存,虚拟机使用gpa通过Stage 2页表映射访问内存
中断重映射
对于PCI直通设备中断的虚拟化,主要包括三种类型INTx,Msi和Msi-X。
1.INTx中断初始化及enable
对于INTx类型的中断,在初始化的时候就进行使能了,qemu通过VFIO device的接口将中断irq set设置到内核中,并且会注册一个eventfd,设置了eventfd的handler,当发生intx类型的中断时,内核会通过eventfd通知qemu进行处理,qemu会通知虚拟机进行处理。
vfio_realize: if (vfio_pci_read_config(& vdev->pdev, PCI_INTERRUPT_PIN, 1)) { pci_device_set_intx_routing_notifier(& vdev->pdev, vfio_intx_update); ret = vfio_intx_enable(vdev, errp); *pfd = event_notifier_get_fd(& vdev->intx.interrupt); qemu_set_fd_handler(*pfd, vfio_intx_interrupt, NULL, vdev); ret = ioctl(vdev->vbasedev.fd, VFIO_DEVICE_SET_IRQS, irq_set);
2.MSI-X初始化
MSIX在vfio_realzie初始化时,首先获取到物理设备的中断相关的配置信息,将其设置到注册给对应的MMIO内存中
vfio_msix_early_setup: pos = pci_find_capability(& vdev->pdev, PCI_CAP_ID_MSIX); if (pread(fd, & ctrl, sizeof(ctrl), vdev->config_offset + pos + PCI_MSIX_FLAGS) != sizeof(ctrl)) { if (pread(fd, & table, sizeof(table), vdev->config_offset + pos + PCI_MSIX_TABLE) != sizeof(table)) { if (pread(fd, & pba, sizeof(pba), vdev->config_offset + pos + PCI_MSIX_PBA) != sizeof(pba)) { ctrl = le16_to_cpu(ctrl); table = le32_to_cpu(table); pba = le32_to_cpu(pba); msix = g_malloc0(sizeof(*msix)); msix->table_bar = table & PCI_MSIX_FLAGS_BIRMASK; msix->table_offset = table & ~PCI_MSIX_FLAGS_BIRMASK; msix->pba_bar = pba & PCI_MSIX_FLAGS_BIRMASK; msix->pba_offset = pba & ~PCI_MSIX_FLAGS_BIRMASK; msix->entries = (ctrl & PCI_MSIX_FLAGS_QSIZE) + 1; vfio_msix_setup: msix_init(& vdev->pdev, vdev->msix->entries, vdev->bars[vdev->msix->table_bar].region.mem, vdev->msix->table_bar, vdev->msix->table_offset, vdev->bars[vdev->msix->pba_bar].region.mem, vdev->msix->pba_bar, vdev->msix->pba_offset, pos); memory_region_init_io(& dev->msix_table_mmio, OBJECT(dev), & msix_table_mmio_ops, dev, "msix-table", table_size); memory_region_add_subregion(table_bar, table_offset, & dev->msix_table_mmio); memory_region_init_io(& dev->msix_pba_mmio, OBJECT(dev), & msix_pba_mmio_ops, dev, "msix-pba", pba_size); memory_region_add_subregion(pba_bar, pba_offset, & dev->msix_pba_mmio); vfio_realize: /* QEMU emulates all of MSI & MSIX */ if (pdev->cap_present & QEMU_PCI_CAP_MSIX) { memset(vdev->emulated_config_bits + pdev->msix_cap, 0xff, MSIX_CAP_LENGTH); if (pdev->cap_present & QEMU_PCI_CAP_MSI) { memset(vdev->emulated_config_bits + pdev->msi_cap, 0xff, vdev->msi_cap_size);
3. MSI/MSI-X enable 与 irqfd的注册
当虚拟机因为写PCI配置空间而发生VM-exit时,最终会完成msi和msix的使能,以MSIX的使能为例,在qemu侧会设置eventfd的处理函数,并通过kvm将irqfd注册到内核中,进而注册虚拟中断给虚拟机。
kvm_cpu_exec: vfio_pci_write_config: vfio_msi_enable(vdev); vfio_msix_enable(vdev); for (i = 0; i < vdev->nr_vectors; i++) { if (event_notifier_init(& vector->interrupt, 0)) { qemu_set_fd_handler(event_notifier_get_fd(& vector->interrupt), vfio_msi_interrupt, NULL, vector); vfio_add_kvm_msi_virq(vdev, vector, i, false); kvm_irqchip_add_msi_route(kvm_state, vector_n, & vdev->pdev); vfio_add_kvm_msi_virq kvm_irqchip_add_irqfd_notifier_gsi kvm_vm_ioctl(s, KVM_IRQFD, & irqfd); /* Set interrupt type prior to possible interrupts */ vdev->interrupt = VFIO_INT_MSI; ret = vfio_enable_vectors(vdev, false); ret = ioctl(vdev->vbasedev.fd, VFIO_DEVICE_SET_IRQS, irq_set);
小结
要让设备直通给虚拟机,需要将设备的DMA能力、中断响应和IO地址空间访问安全地暴露给用户态,本文主要介绍了VFIO设备直通关键的几个环节,包括如何在用户态访问物理设备的IO地址空间、如何进行DMA重映射和中断重映射。
Reference
- https://kernelgo.org/vfio-insight.html