接口用法
libvirt
libvirt在配置virtio网卡时,通过指定driver为vhost使能该特性,如下:
<interface>
<model type='virtio'/>
<driver name='vhost'/>
......
</interface>
qemu
qemu将vhost设计为网络设备的一个属性,virtio网卡指定后端的网络设备为tap设备并使能vhost,用法如下:
-netdev tap,fd=37,id=hostnet0,vhost=on,vhostfd=38 -device virtio-net-pci,netdev=hostnet0,id=net0,mac=24:42:54:20:50:46,bus=pci.0,addr=0x7
数据结构
配置信息
virtqueue的地址,这是组成vring的三个关键元素的主机虚拟地址,包括描述符表基址、可用环虚拟地址、已用环虚拟地址,slave必须知道这些才能取出virtio队列上的数据。 Guest内存布局,在qemu中由一组RAM MemoryRegionSection构成,实际上就是qemu分配给虚机的整个内存。因为virtqueue中存放的是数据的地址(只是元数据),真正的数据散布在虚机的物理地址空间,因此slave必须获得整个虚机的内存布局才能访问virtqueue上指向的数据。 ioeventfd,前端数据可用,内核通过此描述符通知用户态的qemu virtqueue队列数据可用,slave拿到描述符,可以接收前端数据通知,从而对virtqueue上的数据进行处理。 irqfd,后端数据处理完成后,内核通过此描述符向虚机vCPU注入中断,通知前端数据已处理,slave使用此描述符,可以通知到前端virtqueue上数据已处理。
以上4类信息,qemu设计了对应的数据结构来描述,如下:
vhost_vring_addr
vhost_vring_addr
用于传递一个virtqueue的地址,它将队列的index、描述符表起始地址、已用环地址、可用环地址封装起来,通过VHOST_SET_VRING_ADDR
命令字发送给vhost-net
struct vhost_vring_addr {
unsigned int index; /* 队列index */
/* Option flags. */
unsigned int flags;
/* Start of array of descriptors (virtually contiguous) */
uint64_t desc_user_addr; /* 描述符表起始地址(HVA) */
/* Used structure address. Must be 32 bit aligned */
uint64_t used_user_addr; /* 已用环地址(HVA)*/
/* Available structure address. Must be 16 bit aligned */
uint64_t avail_user_addr; /* 可用环地址(HVA)*/
......
};
vhost_memory
vhost_memory
用于描述虚机内存布局,我们知道qemu用MemoryRegion
来描述虚拟机的内存,用MemoryRegionSection
来描述一段MemoryRegion
,vhot_memory
想要描述内存布局,数据的输入来自这两个结构体,vhost_memory
结构体的 vhost_memory_region
成员对应MemoryRegionSection
结构体,当内存布局被完整搜集后,master通过VHOST_SET_MEM_TABLE
命令字发送给vhost-net。实际上,vhost_memory
与qemu内存注册到kernel时的kvm_userspace_memory_region
设计目的相同,都是作为临时结构体传递给内核。
struct vhost_memory_region {
uint64_t guest_phys_addr; /* 内存段的虚机起始物理地址(GPA) */
uint64_t memory_size; /* bytes 内存段长度 */
uint64_t userspace_addr; /* 内存段的起始用户虚拟地址(HVA) */
uint64_t flags_padding; /* No flags are currently specified. */
};
struct vhost_memory {
uint32_t nregions; /* 包含多少个内存段 */
uint32_t padding;
struct vhost_memory_region regions[0]; /* 内存段数组基址 */
};
vhost_vring_file
vhost_vring_file
用于保存fd,有两种场景会使用这个数据结构,一种是qemu想传递用于前后端消息通知的fd时(这时每个virtio队列会关联不同的fd),fd封装成此结构体传递给vhost-net;一种是qemu想传递TAP设备的fd时(这时网卡设备上所有virtio队列设置为相同的fd)
struct vhost_vring_file {
unsigned int index; /* virtqueue队列索引 */
/* fd文件描述符
* Pass -1 to unbind from file.
**/
int fd;
};
vhost网卡
Netdev
qemu支持多种driver的的网络设备,常见的就是网卡、tap设备、vhost-user等,Netdev
描述了qemu中的网络设备,它在net.json文件中定义如下:
{ 'union': 'Netdev',
'base': { 'id': 'str', 'type': 'NetClientDriver' },
'discriminator': 'type',
'data': {
'nic': 'NetLegacyNicOptions',
'user': 'NetdevUserOptions',
'tap': 'NetdevTapOptions', /* tap 设备选项 */
......
'vhost-user': 'NetdevVhostUserOptions',
'vhost-vdpa': 'NetdevVhostVDPAOptions' } }
struct Netdev {
char *id;
NetClientDriver type;
union { /* union tag is @type */
NetLegacyNicOptions nic;
NetdevUserOptions user;
NetdevTapOptions tap; /* tap设备作为网络设备后端驱动 */
......
NetdevVhostUserOptions vhost_user; /* dpdk设备作为网络设备后端驱动 */
NetdevVhostVDPAOptions vhost_vdpa; /* dpdk-vdpa设备作为网络设备后端驱动 */
} u;
};
NetdevTapOptions
netdev指定tap作为driver时,有如下的选项可以设置:
{ 'struct': 'NetdevTapOptions',
'data': {
'*ifname': 'str',
'*fd': 'str', /* 打开/dev/tun设备获得的tap设备fd,可选,可以由libvirt打开,也可以由qemu自己打开 */
......
'*vhost': 'bool', /* 是否使能vhost特性 */
'*vhostfd': 'str', /* 打开/dev/tun设备获得的tap设备fd */
......} }
struct NetdevTapOptions {
bool has_ifname;
char *ifname;
bool has_fd;
char *fd;
......
bool has_vhost;
bool vhost; /* 是否使能vhost特性 */
bool has_vhostfd;
char *vhostfd;
......
};
再次分析qemu的命令行,tap作为driver时,使能了vhost特性,并指定fd和vhostfd。其中fd是libvirt通过打开/dev/tun字符设备获得并传递给qemu的,vhostfd是libvirt通过打开/dev/vhost-net字符设备获得并传递给qemu的。
-netdev tap,fd=37,id=hostnet0,vhost=on,vhostfd=38 -device virtio-net-pci,netdev=hostnet0,id=net0,mac=24:42:54:20:50:46,bus=pci.0,addr=0x7
流程
在分析设备初始化之前,我们解析libvirt传递的tap设备命令行参数如下:
has_fd = true
fd = "37"
has_vhost = true
vhost = true
has_vhostfd = true
vhostfd = "38"
netdev初始化
qemu主进程在启动时解析到-netdev
,如果指定tap driver实现网络报文收发,libvirt会事先创建好tap设备,qemu只需要取出从libvirt传来的tap设备对应fd,类似迁移时从libvirt获取fd的过程,网络设备的初始化在qemu解析完参数后,由qemu_create_late_backends
进入:
qemu_init
qemu_create_late_backends
net_init_clients
qemu_opts_foreach(qemu_find_opts("netdev"), net_init_netdev, NULL, errp)
net_init_netdev
net_client_init
net_client_init1
net_client_init_fun[netdev->type](netdev, netdev->id, peer, errp) <=> net_init_tap
qemu主进程在初始化时会通过qemu_create_late_backends
创建所有qemu设备模拟需要用到的后端设备,因为qemu是借助linux的tap设备(或者其它工具)模拟网卡,因此网卡设备的后端需要在这里提前准备好,供qemu适用。最终调用net_init_tap
初始化tap设备。 net_init_tap
根据qemu命令行参数进行对应的处理,如下:
net_init_tap
if (tap->has_fd) {
......
fd = monitor_fd_param(cur_mon, tap->fd, &err); /* 获取libvirt传来的tap设备fd */
qemu_set_nonblock(fd); /* 设置tap fd非阻塞 */
......
vnet_hdr = tap_probe_vnet_hdr(fd); /* 检查kernel tap设备是否支持IFF_VNET_HDR特性 */
net_init_tap_one(tap, peer, "tap", name, NULL,
script, downscript,
vhostfdname, vnet_hdr, fd, &err);
......
}
vhost特性使能在tap设备初始化中完成,继续分析net_init_tap_one
:
net_init_tap_one
if (tap->has_vhost ? tap->vhost :
vhostfdname || (tap->has_vhostforce && tap->vhostforce)) { /* 如果使能了tap设备vhost*/
options.backend_type = VHOST_BACKEND_TYPE_KERNEL; /* 设置vhost后端为kernel */
if (vhostfdname) { /* 如果libvirt传入了vhostfd,这里的vhostfdname就不为空,我们的情况就是这样 */
vhostfd = monitor_fd_param(cur_mon, vhostfdname, &err); /* 取出vhostfd */
......
qemu_set_nonblock(vhostfd); /* 设置vhostfd为非阻塞 */
} else { /* 如果libvirt没有传入vhostfd,qemu自己打开字符设备获取vhostfd */
vhostfd = open("/dev/vhost-net", O_RDWR); /* 从这里也能看到,vhostfd就是通过打开/dev/vhost-net字符设备获取的 */
......
qemu_set_nonblock(vhostfd);
}
到这里,针对tap设备的初始化和vhost特性的使能的相关工作基本结束,这个过程比较简单,没有实质性的内容,qemu只是根据参数将libvirt传来的fd获取到,并初始化相关数据结构。在获取到vhostfd之后,qemu会配置vhost,这个过程会涉及vhost protocol相关内容。
vhost后端驱动注册
qemu在获取vhostfd之后会对vhost进行配置,核心工作就是注册vhost api,如果是kernel作为vhost后端,就是注册kernel_ops
作为vhost api,这个过程在vhost_net_init
中完成,如下:
net_init_tap_one
vhost_net_init
vhost_dev_init
vhost_set_backend_type
dev->vhost_ops = &kernel_ops /* 设置vhost设备的后端驱动的API */
for (i = 0; i < hdev->nvqs; ++i, ++n_initialized_vqs) {
vhost_virtqueue_init(hdev, hdev->vqs + i, hdev->vq_index + i);
}
针对每个vhost设备设置后端驱动,从这里可以看出,同一个虚机的不同vhost设备,可以有不同的后端驱动,比如kernel、用户态的dpdk或者用户态的dpdk-vdpa。tap设备的初始化还包括对virtio队列的初始化,这个在vhost_virtqueue_init
中实现。
配置信息传输
eventfd
vhost驱动注册完成后,会依次为每个virtio队列创建eventfd并作为irqfd使用,传统的流程,每当qemu需要通知虚机virtio队列上的数据处理完成,就会往irqfd的wfd写1通知内核,另一端kvm读irqfd的rfd为1,则直接发起中断注入通知虚机。使能vhost特性之后,qemu不再处理virtio队列,需要将通知kvm的工作交给内核中的vhost-net来做,所以qemu创建eventfd完成后,需要将eventfd的rfd传递给了vhost-net,vhost-net根据rfd找到eventfd在内核的上下文eventfd_ctx
,之后便使用此数据结构通知kvm注入中断,qemu将不再参与对virtio队列的处理。qemu通过VHOST_SET_VRING_CALL
命令字传递irqfd,这个过程在vhost_virtqueue_init
中完成,如下:
vhost_virtqueue_init
event_notifier_init(&vq->masked_notifier, 0) /* 创建irqfd */
file.fd = event_notifier_get_fd(&vq->masked_notifier) /* 取出irqfd的rfd传递给内核 */
dev->vhost_ops->vhost_set_vring_call(dev, &file) <=> vhost_kernel_set_vring_call
vhost_kernel_call(dev, VHOST_SET_VRING_CALL, file) /* 通过VHOST_SET_VRING_CALL命令字传递fd */
virtqueue addr
virtio队列由描述符表、可用环、已处理环三个元素组成,虚机创建这三个元素后,将这三个元素的地址(GPA)写到VIRTIO_PCI_CAP_COMMON_CFG
空间的queue_desc、 queue_avail、queue_used
三个字段,通知qemu。qemu获取地址后将其翻译成(HVA),在主机侧创建对应的描述符表、可用环、已处理数据结构维护起来,从而实现virtio队列共享。这是传统的virtio队列初始化流程。使能vhost特性之后,除了完成上面的工作,在virtio探测到virtio设备加载驱动时,只要往VIRTIO_PCI_CAP_COMMON_CFG
的device_status
字段写入状态(首先是ACKNOWLEDGE
、然后是DRIVER
和DRIVER_OK
),后端qemu就会对应的设置virtio设备的状态,对于vhost使能了net设备,就会启动vhost net设备,其中有一个核心步骤是传递virtio设备的地址到内核,流程如下:
virtio_ioport_write
case VIRTIO_PCI_STATUS:
virtio_set_status
k->set_status(vdev, val) <=> virtio_net_set_status
virtio_net_set_status
virtio_net_vhost_status
vhost_net_start
for (i = 0; i < nvhosts; i++) {
vhost_net_start_one(get_vhost_net(peer), dev);
vhost_set_vring_enable(peer, peer->vring_enable);
}
vhost_dev_start
hdev->vhost_ops->vhost_set_mem_table(hdev, hdev->mem) <=> vhost_kernel_set_mem_table
vhost_virtqueue_start
vhost_virtqueue_start
中会搜集qemu维护的virtiqueue相关信息,放到vhost_dev
的vhost_virtqueue
结构体中,当需要传递virtiqueue相关信息时,直接从该结构体取需要的信息即可,传递到内核的virtqueue地址vhost_vring_addr
便是如此,流程如下:同样从vhost_virtqueue
取出信息组装
vhost_virtqueue_start
vq->num = state.num = virtio_queue_get_num(vdev, idx);
vq->desc_size = s = l = virtio_queue_get_desc_size(vdev, idx);
a = virtio_queue_get_desc_addr(vdev, idx);
vq->desc_phys = a;
vq->desc = vhost_memory_map(dev, a, &l, false);
vq->avail_size = s = l = virtio_queue_get_avail_size(vdev, idx);
vq->avail_phys = a = virtio_queue_get_avail_addr(vdev, idx);
vq->avail = vhost_memory_map(dev, a, &l, false);
vq->used_size = s = l = virtio_queue_get_used_size(vdev, idx);
vq->used_phys = a = virtio_queue_get_used_addr(vdev, idx);
vq->used = vhost_memory_map(dev, a, &l, true);
vhost_virtqueue_set_addr(dev, vq, vhost_vq_index, dev->log_enabled);
dev->vhost_ops->vhost_set_vring_addr(dev, &addr) <=> vhost_kernel_set_vring_addr
vhost_kernel_call(dev, VHOST_SET_VRING_ADDR, addr)
guest memory layout
在qemu启动vhost net的流程中,传递virtio队列地址前,还有一步是传递虚机的内存布局,同样在vhost_dev_start
中完成:
vhost_dev_start
hdev->vhost_ops->vhost_set_mem_table(hdev, hdev->mem) <=> vhost_kernel_set_mem_table
vhost_kernel_call(dev, VHOST_SET_MEM_TABLE, mem)
这个流程很简单,将vhost_dev
中的vhost_memory
信息传递给内核,该信息就是guest memory的布局,再看看这个信息怎么搜集得来的,hdev->mem在vhost设备初始化时被分配好空间,如下:
vhost_dev_init
hdev->mem = g_malloc0(offsetof(struct vhost_memory, regions));
这里qemu使用了一个小trick,只为结构体的nregions和padding成员分配了内存,vhost_memory_region成员并没有分配空间,因为它后续会被用来动态分配,贴上vhost_memory结构体:
struct vhost_memory {
uint32_t nregions;
uint32_t padding;
struct vhost_memory_region regions[0];
};
offsetof宏的意思就是取结构体(TYPE)的变量成员(MEMBER)在此结构体中的偏移量:
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
我们还注意到,qemu使用g_malloc0函数分配内存,这段内存会被初始化为0,因此nregions成员默认为0。vhost_memory结构体中regions成员未分配空间,继续分析regions空间在哪里实现的。回到vhost_dev_init函数:
vhost_dev_init
hdev->memory_listener = (MemoryListener) {
.name = "vhost",
.begin = vhost_begin,
.commit = vhost_commit, /* 1 */
.region_add = vhost_region_addnop,
.region_nop = vhost_region_addnop,
.log_start = vhost_log_start,
.log_stop = vhost_log_stop,
.log_sync = vhost_log_sync, /* 2 */
.log_global_start = vhost_log_global_start,
.log_global_stop = vhost_log_global_stop,
.eventfd_add = vhost_eventfd_add,
.eventfd_del = vhost_eventfd_del,
.priority = 10
};
memory_listener_register(&hdev->memory_listener, &address_space_memory);
vhost网络设备在初始化时注册了一个memory_listener,为什么需要这个?首先我们知道memory_listener的作用是当虚机内存发生变化时,其它模块
其它信息传输
TAP设备fd
qemu初始化vhost-net网卡时会打开内核提供的/dev/tun字符设备,返回一个fd用于读写网络包,在virtio-net场景下这个fd供qemu使用,但在vhost-net场景下,显然fd会被内核使用,进一步说是被内核的vhost-net模块使用。那么初始化vhost-net网卡时qemu也需要将这个fd传递给内核。仔细看头文件中的解释:
/* Attach virtio net ring to a raw socket, or tap device.
* The socket must be already bound to an ethernet device, this device will be
* used for transmit. Pass fd -1 to unbind from the socket and the transmit
* device. This can be used to stop the ring (e.g. for migration). */
#define VHOST_NET_SET_BACKEND _IOW(VHOST_VIRTIO, 0x30, struct vhost_vring_file)
让内核将virtio net的队列与socket或者tap设备关联,传入的vhost_vring_file中包含的fd信息可以是打开一个tap设备返回的fd,也可以是打开一个socket返回的fd。内核的工作就是将其与网卡的virtio队列关联起来,在后续的收发包时使用。 流程在vhost_net_start_one函数中完成,如下:
vhost_net_start_one
vhost_dev_start(&net->dev, dev);
if (net->nc->info->type == NET_CLIENT_DRIVER_TAP) {
qemu_set_fd_handler(net->backend, NULL, NULL, NULL);
file.fd = net->backend;
for (file.index = 0; file.index < net->dev.nvqs; ++file.index) {
if (!virtio_queue_enabled(dev, net->dev.vq_index +
file.index)) {
/* Queue might not be ready for start */
continue;
}
vhost_net_set_backend(&net->dev, &file); <=> vhost_kernel_net_set_backend
}
}
vhost_net_open
vhost_poll_init
/* 用户态程序只要通过/dev/vhost-net打开字符设备
* 得到fd往里面写数据时就是触发注册好的handle_tx_net钩子函数 */
vhost_poll_init(n->poll + VHOST_NET_VQ_TX, handle_tx_net, EPOLLOUT, dev);
handle_tx_net
handle_tx
sock = vhost_vq_get_backend(vq)
handle_tx_copy(net, sock)
sock->ops->sendmsg(sock, &msg, len)