QEMU中Virtio与Vhost的协作机制详解
简介:区别于VMWare专注于x86架构,Qemu通过动态二进制翻译(TCG)模拟任意架构实现跨架构模拟。通过Virtio的标准化接口实现半虚拟化,引入Vhost绕过Qemu将IO操作提高到接近物理机的IO性能。
引言
Qemu作为虚拟化工具不同于VMWare,支持模拟多种架构(x86、ARM、RISC-C等)。通过Qemu全模拟的虚拟机,需要从物理设备开始模拟寄存器、中断机制等所有细节,简言之全模拟状态下的客户机认为自己在和真正的硬件交互,但实际上所有指令都由Qemu逐条翻译执行。导致每条硬件指令都需要Qemu在用户态模拟造成大量状态切换性能下降;引入Virtio和Vhost,不需要Qemu完全模拟物理机硬件,Virtio提供了一整套的**前端(客户机)和后端(宿主机)**通信的标准化接口,实现客户机与宿主机之间的协作,规避了全模拟的开销。但仍需要Qemu的用户态进程作为后端处理数据,而将Vhost作为后端替代Qemu意味着将后端处理完全卸载到更高效的执行环境,绕过了Qemu的用户空间瓶颈,从而获得更高的IOPS以及更低延迟的网络交互。
系统架构
通过qemu创建的虚拟机,使用virtio框架实现宿主机vhost进程与虚拟机通信。其框架如下图所示:
Virtio在虚拟机创建时预先分配共享内存,交互时数据直接通过Virtio队列,且可进行批量IO请求处理提高IO操作读写速率。vhost执行在宿主机,作为Virtio队列的管理程序进行数据的收发。
通过二层虚拟网络设备TAP实现虚拟机与宿主机网络栈的数据交互,对宿主机体现为一个可配置的虚拟网卡而在虚拟机上体现为一个虚拟以太网接口。
代码实现
环境安装与配置
首先需要安装Qemu相关工具:
sudo apt install qemu qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virt-manager
确认当前机器开启了KVM硬件虚拟化:
egrep -c '(vmx|svm)' /proc/cpuinfo # 输出 >0 表示支持
创建虚拟机磁盘:
qemu-img create -r raw qemu-img-20G 20G
安装最小内核映像(Tiny Core Linux, Micro Core Linux, 12MB Linux GUI Desktop, Live, Frugal, Extendable),下载最小内核映像:
此时qemu创建虚拟机所需环境搭建完成在目录下应该有Core-current.iso
和qemu-img-20G.raw
文件,输入指令启动虚拟机:
qemu-system-x86_64 #启动64位CPU架构为x86的虚拟机
-enable-kvm #启用 KVM(Kernel-based Virtual Machine)硬件加 速
-m 512 #分配512MB内存给虚拟机
-object memory-backend-file,id=mem0,size=512M,mem-path=/mnt/huge/,share=on, #配置内存后端为文件,使用大页(Huge Pages)优化性能
-drive file=/home/zhengpan/share/9.5_vritio/qemu-img-20G.raw,format=raw #加载虚拟机的磁盘镜像文件
-cdrom /home/zhengpan/share/9.5_vritio/Core-current.iso #挂载 ISO 文件作为虚拟光驱,用于安装操作系统
以上命令就会在当前物理机上创建一个64位x86架构的虚拟机,并为其分配512M内存。
此时在宿主机上查看,可以看到qemu在物理机上作为一个进程执行:
前后端通信
通过上一步的配置,成功启动最小内核的qemu虚拟机。这种情况下由Qemu全模拟硬件设备,宿主机与客户机之间的通信等同于两个毫无关系的主机,虚拟机中的网络协议栈虚拟地址物理地址等由Qemu全模拟,由Qemu统一转换后交给物理机。
在宿主机创建vhost,通过Unix套接字进行本机通信:
int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un sa;
sa.sun_family = AF_UNIX;
sprintf(sa.sun_path, "%s", argv[1]);
bind(sockfd, (struct sockaddr*)&sa, sizeof(struct sockaddr_un)
listen(sockfd, 10);
不同于网络通信的socket套接字,本地通信的unix套接字需要指定一个本地文件路径作为双方通信的结点。宿主机开启监听后,等待进程通过相同路径进行连接。
后续操作与网络通信流程类似,accept接收连接后操作客户端fd接收数据。
struct vhost_user_msg{//每次将qemu传输过来的数据转换成这个结构体
uint32_t request;
uint32_t flags;
uint32_t size;
union{
uint64_t num;
struct vhost_vring_state state;
};
int fds[8];//来自前端的fd 作为与虚拟机之间通信的一个内存
} __attribute__((packed));//按照单字节对齐
定义用户数据结构体,根据前端发来的数据长度将其强行转换为struct vhost_user_msg类型(基于Virtio协议),进行数据后续操作。
int clientfd = accept(sockfd, 0, 0);
while(1){
char buffer[1024] = {0};
int rlen = recv(clientfd, buffer, 1024, 0);
if(rlen > 0){
struct vhost_user_msg *msg = (struct vhost_user_msg*)buffer;
printf("rlen: %d, request: %d, flags: %d, size: %d\n", rlen, msg->request, msg->flags, msg->size);
vhost_user_msg_handler(clientfd, msg);
}
}
宿主机执行./vhsot /tmp/vhost.sock
在指定路径下创建通信结点。创建qemu时需要配置一系列vhsot相关配置:
qemu-system-x86_64
-enable-kvm
-m 512
-object memory-backend-file,id=mem0,size=512M,mem-path=/mnt/huge/,share=on
-numa node,memdev=mem0 #配置 NUMA (非统一内存访问) 节点s
-chardev socket,id=vhost0,path=/tmp/vhost.sock #创建 UNIX socket 字符设备用于 vhost-user 通信
-netdev vhost-user,id=user0,chardev=vhost0 #配置 vhost-user 网络设备后端
-device virtio-net-pci,id=net0,netdev=user0 #添加 VirtIO 网络设备
-drive file=/home/zhengpan/share/9.5_vritio/qemu-img-20G.raw,format=raw
-cdrom /home/zhengpan/share/9.5_vritio/Core-current.iso
虚拟机在启动时就会通过套接字向宿主机发送数据进行通信:
vhost-user协议
上一步我们获得了qemu启动时发来的请求,数据长度为12个字节,而后续的字段又代表什么含义?Qemu官方技术文档关于虚拟主机用户协议中的Header数据有如下定义:
所有数据头按照主机字节序由request、flags、size、payload四个字段组成,分别定义了各字段代表的含义,因此虚拟机启动时发来的12字节数据分别对应了4字节的request、4字节的flags字段、标识有效数据的size字段。那么就此可以解析出:Qemu在启动阶段向vhost进行类型为1的请求,标识的版本为0x01,带来的有效数据大小为0。
那么在这个协议中request请求各值代表了什么含义呢?
官方文档中明确提出宿主机与客户机可通过Unix套接字等效于内核实现,在前后端的通信过程中,有以下request是vhost必须回复的:
VHOST_USER_GET_FEATURES
VHOST_USER_GET_PROTOCOL_FEATURES
VHOST_USER_GET_VRING_BASE
VHOST_USER_SET_LOG_BASE(如果VHOST_USER_PROTOCOL_F_LOG_SHMFD)
VHOST_USER_GET_INFLIGHT_FD(如果VHOST_USER_PROTOCOL_F_INFLIGHT_SHMFD)
而宏定义VHOST_USER_GET_FEATURES
在前端消息类型中代表1:
意味着vhost需要处理id为1的来自前端的请求,要求设置向前端回应Virtio网络设备的特性支持位掩码,用于Virtio设备的功能协商。
同理按照功能需求引入一系列宏定义:
#define VHOST_USER_GET_FEATURES 1 //获取后端支持的特性位掩码,查询后端所支持的virtio特性。
#define VHOST_USER_SET_FEATURES 2 //设定协商好的特性位掩码,把最终确定启用的特性告知给后端。
#define VHOST_USER_SET_OWNER 3 //声明对vhost设备的所有权,表明自己将成为设备的控制者。
#define VHOST_USER_RESET_OWNER 4 //释放vhost设备的所有权,放弃对设备的控制权限。
#define VHOST_USER_SET_MEM_TABLE 5 //设置虚拟机的内存布局,向后端传递虚拟机的内存映射信息。
#define VHOST_USER_SET_LOG_BASE 6 //设置日志缓冲区的基地址,配置日志记录的内存位置。
#define VHOST_USER_SET_LOG_FD 7 //设置日志输出的文件描述符,指定日志数据的输出目标。
#define VHOST_USER_SET_VRING_NUM 8 //设置virtqueue中的描述符数量,配置virtqueue的大小。
#define VHOST_USER_SET_VRING_ADDR 9 //设置virtqueue的内存地址,告知后端virtqueue在内存中的具体位置。
#define VHOST_USER_SET_VRING_BASE 10 //设置 virtqueue 的基索引,指定virtqueue的起始索引。
#define VHOST_USER_GET_VRING_BASE 11 //获取 virtqueue 的当前基索引,查询virtqueue的当前状态。
#define VHOST_USER_SET_VRING_KICK 12 //设置 kick 事件的 fd,用于通知后端的事件 fd。
#define VHOST_USER_SET_VRING_CALL 13 //设置 call 事件的 fd,用于接收后端通知的事件 fd。
#define VHOST_USER_SET_VRING_ERR 14 //设置错误事件的 fd,配置用于接收错误通知的事件 fd。
#define VHOST_USER_GET_PROTOCOL_FEATURES 15 //获取 vhost-user 协议级别的特性,查询后端支持的高级协议特性,像流式处理、批量操作等。
基于这些宏定义实现了虚拟机于宿主机之间的通信。
通信架构
基于上述控制信息,实现基于共享内存的数据通信。对于宿主机和客户机来说,双方仍是基于网络协议进行通信,需要一个可以接收并处理网络数据包的应用配合着共享内存实现数据通信。
本文采用TAP作为通信的桥梁,TAP可用于模拟网络协议栈中的二层设备,运行客户机读写该模拟以太网设备来发送和接收网络数据包。通过tun_alloc()函数打开内核中的TAP设备文件,最后返回一个标识TAP虚拟网路设备的文件描述符,后续可操作该文件描述符进行数据的读写。
int tun_alloc(char *dev){
struct ifreq ifr = {0};
int fd = open("/dev/net/tun", O_RDWR);//打开TAP设备文件,
//IFF_TAP:指定创建 TAP 设备(处理二层以太网帧)
//IFF_NO_PI:告诉内核不添加额外的数据包信息,使程序直接获取原始数据包内容。
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
if (*dev) {
memcpy(ifr.ifr_name, dev, strlen(dev));
}
ioctl(fd, TUNSETIFF, (void *)&ifr)//设备的特殊控制操作 将配置信息(设备类型、名称)传递给内核
return fd;
}
主线程在本地开启监听获取到连接之前开辟新线程启用TAP,通过回调函数处理。
pthread_t tid;
pthread_create(&tid, NULL, vhost_user_tap_start, NULL);
void* vhost_user_tap_start(void *arg){
int fd = tun_alloc("tap0");
struct pollfd pfd = {0};
pfd.fd = fd;
pfd.events = POLLIN | POLLOUT;
while(1){
int ret = poll(&pfd, 1, -1);
if(ret < 0){
usleep(0);//让出当前cpu 避免空转降低cpu占用率
continue;
}
//tx
...
//rx
...
}
return NULL;
}
考虑到仅需要处理一个虚拟设备的文件描述符,此处采用poll管理有关IO操作。
有了可支持的硬件设备,后续的流程都是围绕前文所提到的15个宏定义进行通信双方的状态设置。基于此将数据的收发分为两个阶段:配置虚拟队列和共享内存的初始化阶段、kickfd发送数据/callfd接收数据的数据收发阶段。
- 初始化阶段
在次阶段,需要初始化一系列用于数据收发的结构体,其大致关系如下:
virtq_desc描述virtqueue中的一块缓冲区,而virtq_avail、virtq_used分别标识virtqueue中未处理/已处理的描述符,基于这些内容构成了一个完成的virtqueue用于数据的收发;vhost_user_region描述了客户机中物理内存的映射区域,组成该结构体的四个属性用于数据通信的地址转换,vhost_user_mem统一管理所有的共享内存区域;最后virtio_dev囊括了virtqueue和vhost_user_mem,换句话说该结构体代表了一个完整的virtio设备,包含了数据收发的virtqueue队列和共享内存vhost_user_mem的映射信息。
在这些结构体之上,15个宏定义中有如下几个需要在初始化阶段进行设置:
VHOST_USER_GET_FEATURES //返回所支持的virtio特性
VHOST_USER_SET_FEATURES //启用特性
VHOST_USER_SET_OWNER //声明virtiodev的所有权
VHOST_USER_SET_MEM_TABLE //传递客户机的虚拟内存映射信息
VHOST_USER_SET_VRING_NUM //配置virtqueue中的描述符数量 配置其大小
VHOST_USER_SET_VRING_ADDR //设置virtqueue的内存地址
VHOST_USER_SET_VRING_BASE //指定virtqueue的起始地址
-
VHOST_USER_GET_FEATURES
客户机初始化向vhost发送request值为1的请求,按照基础要求设置所支持的特性:
#define VHOST_SUPPORTED_FEATURES \
(1ULL << VIRTIO_NET_F_GUEST_TSO4) | \ // 支持IPv4 TCP分片卸载
(1ULL << VIRTIO_NET_F_GUEST_TSO6) | \ // 支持IPv6 TCP分片卸载
(1ULL << VIRTIO_NET_F_GUEST_CSUM) | \ // 支持Guest校验和计算
(1ULL << VIRTIO_F_VERSION_1) // 使用Virtio 1.0+协议
#define VHOST_USER_VERSION_MASK 0x3 // 低2位掩码(版本号占2bit)
#define VHOST_USER_REPLY_MASK (0x1 << 2) // 第3位掩码(回复标志)
#define VHOST_USER_VERSION 0x1 // 协议版本1
组装配置信息发送给虚拟机:
msg->num = vhost_supported_featrues;
msg->size = sizeof(vhost_supported_featrues);
msg->flags &= ~VHOST_USER_VERSION_MASK;//将低两位置为0
msg->flags |= VHOST_USER_VERSION;
msg->flags |= VHOST_USER_REPLY_MASK;
size_t count = offsetof(struct vhost_user_msg, num) + msg->size;//将数据完成发送 header+num
send(clientfd, msg, count, 0);//只将message头发送出去
- VHOST_USER_SET_FEATURES
接收到前端确认启用的特性位图,前端从后端支持的特性中选择子集并回传。简单保存后打印到屏幕:
vhost_supported_featrues = msg->num;
printf("features: %lx\n", vhost_supported_featrues);
- VHOST_USER_SET_OWNER
通过这一步前端与后端完成主从关系的建立,表示前端将设备的控制权移交给后端进程。获取到用于读写的eventfd,同时初始化virtiodev用于后续数据的收发:
printf("set owner: %d\n", msg->fds[0]);
//提前分配好所需要的内存空间
virtiodev = (struct virtio_dev*)malloc(sizeof(struct virtio_dev));
memset(virtiodev, 0, sizeof(struct virtio_dev));
需要注意的是此处的eventfd是一个用于控制通信的描述符,标识了前后端之间正式建立了事件通知通道。后续的操作中还会遇到类似的事件fd,分别有不同的功能。
- VHOST_USER_SET_MEM_TABLE
这一步需要初始化vhost-user协议中零拷贝共享内存的相关配置。来自客户端id为5的请求,一共发送72字节数据:
此时客户端会传送两个用于控制本地内存的文件描述符,双方基于这两个fd直接共享内存避免数据拷贝。但fd在操作系统中对应着文件管理的控制表,而recv/send只能简单传输字节值。操作系统提供了跨进程传输文件描述符的接口:recvmsg/sendmsg。
因此在接收数据时,需要使用recvmsg先接受辅助数据(带外数据)以及其余准备工作:
struct vhost_user_msg msg = {0};//存储接收到的信息
struct iovec iov;//描述内存缓冲区 类似于read(fd, buf, len)中的buf、len
iov.iov_base = &msg;
iov.iov_len = hdrsz;//按照协议request/flags/size指定为12个字节
size_t fdsize = sizeof(msg.fds);//计算存放接收到fd的数组大小
char control[CMSG_SPACE(fdsize)];//宏计算承载fd数组所需控制缓冲区的大小
//配置消息头
struct msghdr msgh = {0};
msgh.msg_iov = &iov; // 指向普通数据缓冲区
msgh.msg_iovlen = 1; // 只有一个iovec
msgh.msg_control = control; // 指向控制数据缓冲区
msgh.msg_controllen = sizeof(control); // 控制缓冲区长度
基于这些信息,宿主机先接收固定长度的消息头(通过iovec指定的缓冲区),同时接收可能存在的带外数据(文件描述符),根据消息头中的size字段决定是否需要接收后续更多的数据。
int rc = recvmsg(clientfd, &msgh, 0);//接收头部消息
if(msgh.msg_flags & (MSG_TRUNC | MSG_CTRUNC)){//消息截断检查
break;
}
struct cmsghdr *cmsg;
//开始遍历所有控制消息头
for(cmsg = CMSG_FIRSTHDR(&msgh);cmsg != NULL;cmsg = CMSG_NXTHDR(&msgh, cmsg)){
if((cmsg->cmsg_level == SOL_SOCKET) && (cmsg->cmsg_type == SCM_RIGHTS)){
//检查控制消息级别和类型分别为SOL_SOCKET、SCM_RIGHTS
memcpy(msg.fds, CMSG_DATA(cmsg), fdsize);//如果是文件描述符类型 将其复制到msg中的fd数组
}
}
上述操作通过SCM_RIGHTS机制基于控制消息结构图跨进程传递文件描述符,确保了虚拟机和后端进程之间可以安全高效地共享资源。
在调用recvmsg接收消息头和带外数据时,msg.size已被赋值。当解析完消息头且处理完带外数据后,根据msg.size判断是否需要继续接收后续数据。
if(msg.size > 0){
rc = recv(clientfd, &msg.num, msg.size, 0);
if(rc != msg.size){
perror("recv");
}
}
至此通过recvmsg和recv实现了跨进程的文件描述符传输和数据传送。获取到来自前端的文件描述符,需要对应的在本地开辟相关内存区域,通过mmap直接访问虚拟机内存。
int vhost_user_set_mem_table(struct virtio_dev *dev, struct vhost_user_msg *msg){
if(!dev) return -1;
if(!dev->mem){
dev->mem = (struct vhost_user_mem*)malloc(sizeof(struct vhost_user_mem));
memset(dev->mem, 0, sizeof(struct vhost_user_mem));
}
struct vhost_user_mem *memory = &msg->mem;//将memory指向msg
dev->mem->nregions = memory->nregions;
printf("nregions: %d\n", memory->nregions);
int i = 0;
for(i = 0;i < memory->nregions;i ++){
//将其拷贝到本地
memcpy(&dev->mem->regions[i], &memory->regions[i], sizeof(struct vhost_user_region));
//取到qemu的fd 找到其fd在物理机上映射的物理地址 做到物理机与qemu操作同一块内存
size_t size = dev->mem->regions[i].size + dev->mem->regions[i].mmap_offset;
size = ROUNDUP(size, 2 << 20);
void *mmap_addr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED,
msg->fds[i], 0);
dev->mem->regions[i].mmap_offset = (uint64_t)mmap_addr + memory->regions[i].mmap_offset
- memory->regions[i].guest_address;
}
return 0;
}
首先初始化virtio_dev中用于统一管理共享内存的vhost_user_mem结构体,从前端的消息msg中提取出内存区域配置,循环处理每一块内存区域。调用mmap函数将前端传送来的共享内存fd与后端内存区域关联,使得后端可以直接访问虚拟机的物理内存。
值得注意的是,通过mmap获取到映射的后端虚拟地址,便于后续的地址转化此处计算mmap_offset便于后续快速转换GPA与HVA。(uint64_t)mmap_addr
指向了主机虚拟地址的起始位置,memory->regions[i].mmap_offset
表示该区域在共享内存文件中的偏移量,memory->regions[i].guest_address
表示虚拟机物理地址的起始位置。计算逻辑则是基于物理机的虚拟地址,加上文件内偏移量,此时指针实际指向了共享内存的起始位置,再减去虚拟机的地址起始值,得到了转换的基准值。
至此前后端不仅建立了主从关系,且通过recvmsg所传递的文件描述符等带外数据将前后端与共享内存区域关联,后续的数据传输都发生在这个共享的内存区域内。
光有共享内存,数据还需要通过数据结构进行组织发送。Virtio中的vring就是基于共享内存的环形队列,作为前端和后端的数据中转站,负责传递IO请求和响应。
vring由三大关键部分组成,所以需要在使用分别初始化。
- VHOST_USER_SET_VRING_NUM
设置virtqueue的大小,一般情况下为256(即支持256个文件描述符)。
printf("\nset vring num: %d\n", msg->state.num);
virtiodev->vq[msg->state.index].num = msg->state.num;//256
- VHOST_USER_SET_VRING_BASE
指定大小为256的vring从哪一块开始操作:
printf("\nset vring base: %d\n", msg->state.index);
virtiodev->vq[msg->state.index].idx = msg->state.index;//总共有256个 接下来操作哪一块
- VHOST_USER_SET_VRING_ADDR
至此前后端已找到共享内存所在的虚拟地址位置,后续所有的操作都是基于映射后的虚拟地址进行数据处理。位于共享内存中的virtqueue在双方的视角中地址都不相同,但虚拟机的物理地址也是Qemu进行模拟的,实际上在硬件上前后端的虚拟地址都指向了同一块物理地址。
正是因为双方使用了不同的地址空间,所以才需要通过转换才能正确访问共享内存中的virtqueue。Qemu会将其virtqueue的虚拟地址通过VHOST_USER_SET_VRING_ADDR命令发送给后端。
此时后端接收到40字节数据,后端将其转换为以下结构体:
struct vhost_user_msg{
uint32_t request; // 4字节(值为9,表示VHOST_USER_SET_VRING_ADDR)
uint32_t flags; // 4字节(标志位,如flags=1)
uint32_t size; // 4字节(值为40,表示联合体数据部分长度)
struct vhost_vring_addr addr; // 40字节(实际数据)
} __attribute__((packed));
struct vhost_vring_addr {
unsigned int index; // 4字节(队列索引,如0=TX队列,1=RX队列)
unsigned int flags; // 4字节(标志位,VHOST_VRING_F_LOG)
#define VHOST_VRING_F_LOG 0
uint64_t desc_user_addr; // 8字节(描述符表虚拟机地址)
uint64_t used_user_addr; // 8字节(已用环虚拟机地址)
uint64_t avail_user_addr; // 8字节(可用环虚拟机地址)
uint64_t log_guest_addr; // 8字节(日志地址,通常为0)
};
struct vhost_vring_addr中各属性即对应virtqueue的值,当然还需要将虚拟机的(虚拟)地址转换为物理机的(虚拟)地址。
地址转换公式如下:
HVA = (gva_addr - region->user_address)
+ region->mmap_offset
+ region->guest_address;
各变量含义如下:
变量 | 类型 | 说明 |
---|---|---|
gva_addr | uint64_t | 虚拟机虚拟地址(Guest Virtual Address),需转换的目标地址。 |
region->user_address | uint64_t | 物理机虚拟地址(Host Virtual Address)的起始值,通过 mmap 映射获得。 |
region->mmap_offset | uint64_t | 共享内存文件(如 memfd )中的偏移量,通常与 guest_address 对齐。 |
region->guest_address | uint64_t | 虚拟机物理地址(Guest Physical Address)的起始值,由 QEMU 模拟。 |
首先计算GVA在HVA内的偏移量,接着叠加共享内存中的文件偏移量,最后将总偏移量加上物理机的物理地址作为基准即可以将其转为对应在物理机的虚拟机的虚拟地址。
使用这个函数将virtqueue中的所有地址转换为物理机的虚拟地址:
struct virtqueue *vq = &virtiodev->vq[msg->addr.index];
vq->desc = (struct virtq_desc*)gva_to_hva(virtiodev,msg->addr.desc_user_addr);
vq->avail = (struct virtq_avail*)gva_to_hva(virtiodev, msg->addr.avail_user_addr);
vq->used = (struct virtq_used*)gva_to_hva(virtiodev, msg->addr.used_user_addr);
至此,数据交互的准备工作结束,前后端能够同时对位于共享内存中的virtqueue进行操作,配合着后续的kickfd和callfd机制进行数据的收发。
- 数据通信阶段
vhost-user协议为我们提供了高效的信息通知机制:kickfd/callfd。当前端有数据发送给后端,首先将数据放入virtqueue的avail环中,通过kickfd通知后端,后端通过监听kickfd来感知;同理当后端收到通知处理数据后,将数放入virtqueue的used环中,通过callfd通知前端。在这种方案下至少需要开辟一条线程用于监听kickfd并进行后续处理。
而本文通过统一监听TAP设备中的读写事件,通过一个中转站同时处理两类请求。具体来说,启动TAP线程时使用poll统一监听读写事件,当有POLLIN事件到来有来自前端的kick请求;当有POLLOUT事件,说明后端处理完数据需要向前端发送call请求。
- VHOST_USER_SET_VRING_KICK VHOST_USER_SET_VRING_CALL
通过带外数据收到来自前端的kickfd将其放入到virtiodev所管理的virtqueue中;后端向前端发送的callfd处理方式相同。
而具体数据的处理在创建TAP所开辟的线程vhost_user_tap_start中:
- 接收数据
当poll监听到可写事件POLLOUT,首先接收来自前端的数据:
if(pfd.revents & POLLIN){
struct mbuf *m;
int np = vhost_tx(virtiodev, &m, 1);
}
其中mbuf承载所接收到的数据,在vhost_tx中处理来自前端的数据:
int vhost_tx(struct virtio_dev *dev, struct mbuf *pkts[], uint16_t npkts) {
const int qidx = 1;//初始化队列索引 tx一般为1
struct virtqueue *vq = &dev->vq[qidx];
//取出avail环中可用的描述符索引
uint16_t desc_idx[MAX_PKT_BURST] = {0};
int i = 0;
for (i = 0;i < npkts;i ++) {
desc_idx[i] = vq->avail->ring[(vq->idx + i) % vq->num];
}
//依次处理每个数据包
for (i = 0;i < npkts;i ++) {
pkts[i] = vhost_new_mbuf();
copy_desc_to_mbuf(dev, vq, pkts[i], desc_idx[i]);//从描述符复制到mbuf中
//依次更新used ring 告知前端哪些缓冲区已处理
uint32_t used_idx = (vq->idx ++) % vq->num;
vq->used->ring[used_idx].id = desc_idx[i];
vq->used->ring[used_idx].len = 0;
}
vq->used->idx += i;
return i;
}
最后将处理好的数据交给TAP,原因在于vhost/virtio只负责数据传输,而有关的网络数据还需要网络协议栈的进一步处理。
int ret = write(fd, m->data, m->len);
- 发送数据
所有的数据都以TAP为核心,所以后端将发送给前端的数据传入到TAP中,因此首先从TAP将待发送的数据取出:
if(pfd.revents & POLLIN){
struct mbuf *m;
int np = recvfrom_peer(fd, &m);
...
}
在recvfrom_peer中使用IO操作将数据置于mbuf中:
static int recvfrom_peer(int fd, struct mbuf **mbuf)
{
int rc;
struct mbuf *m;
m = vhost_new_mbuf();
rc = read(fd, m->data, m->len);
m->len = rc;
*mbuf = m;
return 1;
}
mbuf中装入代发送的数据,下一步应该将数据放入共享内存中的virtqueue的used环中:
int vhost_rx(struct virtio_dev *dev, int qidx, struct mbuf *pkts[], int npkts) {
struct virtqueue *vq = &dev->vq[qidx];
uint32_t desc_idx[MAX_PKT_BURST] = {0};
uint16_t avail_idx = *((uint16_t*)&vq->avail->idx);
...
//获取可用描述符索引
int i = 0;
for (i = 0;i < npkts;i ++) {
desc_idx[i] = vq->avail->ring[(vq->idx + i) & (vq->num - 1)];
}
//处理每个数据包
uint16_t used_idx = 0;
for (i = 0;i < npkts;i ++) {
//将数据包复制到virtqueue中
copy_mbuf_to_desc(dev, vq, pkts[i], desc_idx[i]);
//更新used环
used_idx = (vq->idx++) & (vq->num - 1);
vq->used->ring[used_idx].id = desc_idx[i];
vq->used->ring[used_idx].len = pkts[i]->len + sizeof(struct virtio_net_hdr);
}
vq->used->idx += i;
eventfd_write(vq->callfd, 1);//通过callfd通知前端
return i;
}
至此一个完整的前后端数据收发流程构建完成,其大致示意图如下:
前后端整个通信的过程中围绕共享内存中的virtqueue进行数据的交互,后端以TAP为桥梁监听数据状态。在初始化阶段,最重要的是根据前后端的消息交互初始化virtqueue队列,以及使用recvmsg读取前端的文件描述符构建共享内存,在共享内存和virtqueue队列基础上所有的数据以TAP为中介点进行传输;接收数据时从队列中取出数据放入TAP中、发送数据时从TAP中取出数据放入队列。
常见问题与解决方案
问题一:为什么需要进行地址的转换
虽然宿主机和虚拟机同时操作同一块共享内存,但各自访问的是自己视角内的地址,因此宿主机收到虚拟机的内存访问请求时需要将其转换为宿主机视角内的可用地址,才能直接读写共享内存。
问题二:简单介绍virtqueue的组成,以及avail环和used环在数据传输中发挥的不同功能
Virtqueue是virtio设备中数据传输的核心数据结构其组成部分由以下示意图所示:
虚拟机将准备好的文件描述符放入Avail环中,同时通知物理机有新的数据待处理,其中idx表示下一个可用索引;物理机将来自虚拟机的数据处理完成后将结果放入Used环。
问题三:简述sendmsg/recvmsg的原理,以及在本项目中如何通过这两个接口实现共享内存
sendmsg/recvmsg是Linux提供的区别与普通的send/recv函数,使用该接口可以同时操作多个不连续的内存缓冲区,且其支持的带外数据可以传输文件描述符等系统信息。整个数据的传输围绕msghdr:
struct msghdr {
void *msg_name; // 可选地址
socklen_t msg_namelen; // 地址长度
struct iovec *msg_iov; // 分散/聚集数组
int msg_iovlen; // 数组元素个数
void *msg_control; // 辅助数据缓冲区
socklen_t msg_controllen; // 辅助数据长度
int msg_flags; // 接收消息标志
};
struct iovec {
void *iov_base; // 缓冲区起始地址
size_t iov_len; // 缓冲区长度
};
struct cmsghdr {
socklen_t cmsg_len; // 包含头部的数据长度
int cmsg_level; // 原始协议级别
int cmsg_type; // 协议特定类型
/* 后面跟着实际的控制消息数据 */
};
在本项目中共享内存由虚拟机主动创建,且主动将文件描述符和内存信息打包通过sendmsg将数据发送到物理机上以vhost_user_msg结构解析;物理机使用recvmsg接收消息头和文件描述符,按照传递信息使用mmap映射接收到的文件描述符,建立虚拟地址和物理地址的映射关系。
问题四:简单介绍一下tap/tun在网络协议栈中所在的位置,如何实现基于两层的node-to-node应用
TAP往往工作在网络协议栈的第二层,代替交换机进行以太网帧的转发;TUN位于第三层,处理IP层的数据报。
基于TAP首先node-to-node应用,其基本原理是基于TAP作为转发桥梁,需要自定义以太网协议类型同时考录到MAC地址的转发表的学习以及维护等。
问题五:是否了解过vitrio中除网络数据之外的协议,如果有简单介绍一下
特性 | vhost-user-gpu | vhost-user-blk | vhost-user-net |
---|---|---|---|
用途 | 虚拟 GPU 加速 | 虚拟块设备(存储) | 虚拟网络设备 |
核心功能 | 3D 渲染、显示输出 | 存储 I/O 加速 | 网络包处理加速 |
典型后端 | Virgl、Mesa3D | SPDK、Ceph | DPDK、OVS |
共享内存机制 | DMA-BUF + 共享内存 | 直接内存访问 | 描述符环 + 包缓冲区 |
性能关键点 | 渲染指令吞吐量 | IOPS(每秒 I/O 操作数) | PPS(每秒包处理数) |
延迟敏感度 | 高(图形渲染) | 中(存储访问) | 极高(网络通信) |
多队列支持 | 可选(多显示器) | 是(Multi-queue) | 是(Multi-queue) |
零拷贝支持 | 是(DMA-BUF) | 是(SPDK) | 是(DPDK) |
典型应用 | 云游戏、远程桌面 | 云存储、数据库 | 云计算网络、NFV |
问题六:基于本项目如何实现多线程
主要围绕以下几个改进点:
- 创建多个工作线程来处理数据包的收发
添加线程池结构体:
#define MAX_THREADS 4
struct thread_pool {
pthread_t threads[MAX_THREADS];
int thread_count;
int stop_flag;
};
struct thread_pool tx_pool;
struct thread_pool rx_pool;
- 将收发队列分离到不同线程
在virtqueue中添加相关同步变量:
struct virtqueue {
....
pthread_mutex_t lock; // 添加互斥锁
pthread_cond_t cond; // 添加条件变量
};
将数据的收发交给不同线程进行管理:
// 发送线程工作函数
void* tx_worker(void* arg) {
struct virtio_dev *dev = (struct virtio_dev*)arg;
int qidx = 1; // TX队列索引
while (!tx_pool.stop_flag) {
struct virtqueue *vq = &dev->vq[qidx];
struct mbuf *pkts[MAX_PKT_BURST];
pthread_mutex_lock(&vq->lock);
// 检查是否有数据需要发送
if (vq->avail->idx == vq->used->idx) {
pthread_cond_wait(&vq->cond, &vq->lock);
pthread_mutex_unlock(&vq->lock);
continue;
}
int npkts = vhost_tx(dev, pkts, MAX_PKT_BURST);
pthread_mutex_unlock(&vq->lock);
// 实际发送数据
for (int i = 0; i < npkts; i++) {
if (pkts[i]) {
write(tap_fd, pkts[i]->data, pkts[i]->len);
vhost_free_mbuf(pkts[i]);
}
}
}
return NULL;
}
// 接收线程工作函数
void* rx_worker(void* arg) {
struct virtio_dev *dev = (struct virtio_dev*)arg;
int qidx = 0; // RX队列索引
while (!rx_pool.stop_flag) {
struct virtqueue *vq = &dev->vq[qidx];
struct mbuf *pkts[MAX_PKT_BURST];
int npkts = 0;
// 从TAP设备读取数据
for (int i = 0; i < MAX_PKT_BURST; i++) {
pkts[i] = vhost_new_mbuf();
if (!pkts[i]) break;
int rc = read(tap_fd, pkts[i]->data, MBUF_DATA_LENGTH);
if (rc <= 0) {
vhost_free_mbuf(pkts[i]);
break;
}
pkts[i]->len = rc;
npkts++;
}
if (npkts > 0) {
pthread_mutex_lock(&vq->lock);
vhost_rx(dev, qidx, pkts, npkts);
pthread_mutex_unlock(&vq->lock);
// 通知虚拟机有数据到达
eventfd_write(vq->callfd, 1);
}
}
return NULL;
}
总结
Qemu通过Virtio与Vhost的协同工作机制,实现了高效、灵活的虚拟化I/O解决方案。Virtio作为前端驱动与后端设备之间的标准化接口,通过共享内存和环形队列机制避免了Qemu全模拟带来的性能损耗;而Vhost进一步将后端处理从QEMU用户态卸载到内核或专用进程,显著提升了I/O性能。整个架构中,TAP设备作为关键的网络数据中转站,配合基于事件驱动的kickfd/callfd通知机制,实现了虚拟机与宿主机之间接近原生性能的数据传输。这种分层设计不仅保持了虚拟化的隔离性,还通过内存零拷贝、批量处理等优化手段,使得虚拟化网络I/O性能达到近乎物理机的水平,为云计算等场景提供了高性能的虚拟化网络基础架构。