virtio split virtqueue数据流
基本概念解释
buffers和notifications:
一个virtqueue的buffer是由guest来分配,host来消费的,host可以读和写这些buffer。
一个buffer只能是只读或者只写的,但是不能读和写同时存在。
buffer的描述符可以是通过指针链接起来的,即indriect descriptor,通过将消息扩展会更加方便,例如在一个buffer中放入和2000字节的数据和使用两个1000字节的数据buffer存放效果是一样的。
driver,device互相通知的方式:
virtqueue只规定了语义,并没有规定实现方式。
device->driver一般通过中断,driver->devcie通过notifications。
driver和device还提供了一种优化特性,抑制对方的通知来减少不必要的通知损耗。
split virtqueue
split virtqueue将virtqueue分为三个部分(又称为table, virtqueue),每一部分都是一个环形buffer,每一部分可以被driver或者device写,但是不会允许双方都可写。规范中并没有要求这三部分是物理连续的,但是为了实现方便,实际的驱动中是将三个部分连续存放的。
descriptor Area:用来描述buffer,下面两部分需要根据索引查找该表才能找到实际的buffer
Driver Area: driver生产的data,device来消费,被称为avail virtqueue,该表只有driver可写
Device Area: device生产的data,driver来消费,该表只有device可写
virtqueue需要在driver中申请,因为需要双方都可以访问,没有比driver中申请管理更直接的方式了。buffer的地址直接存放在driver中,而device需要通过地址转换gpa->hva才能访问这些buffer。device有很多方式可来访问,取决于接入的方式:
- qemu中的仿真device,guest的地址一定位于qemu进程内的
- 对于其他的仿真设备,像vhost-net, vhost-user,需要通过内存共享映射来完成,例如POSIX shared memory,使用文件描述符通过vhost协议来共享这块内存
- 对于一个真实的硬件设备,需要一个硬件级别的地址转换,通常是IOMMU
descriptor area
它实际上是一个环形的buffer,又可以被称为descriptor ring,是第一个需要理解的ring。它包括一个数组,数组中每一项的元素包括指向guest buffer的地址和长度。另外每一个desc还包含一些其他信息。例如这个desc指向的不是真实的buffer而是一组desc时需要标为INDIRECT。如果这组buffer标记位device只写,设置WRITE,反之如果只读则清楚WRITE。
下面是desc的结构:
struct virtq_desc {
le64 addr;
le32 len;
le16 flags;
le16 next; // Will explain this one later in the section "Chained descriptors"
};
avail area
它也是一个环形buffer, 又被称为avail vring。driver需要给device提供数据,而数据的元数据存放在descriptor ring中,所以此时填入其中的是descriptor ring中的index,device根据index去desc ring中找到对应的desc,然后获得地址信息:gpa和len,最后再转换成hva来消费这些数据。
注意,driver放置这些buffer并不意味着device需要立即消费他们
例如virtio-net提供了一些buffer,当有网络包到来时将网络数据放到这些buffer中,此时device才算消费了这些buffer。
avail ring有两个重要的域:idx和flags。 idx指向下一个driver可用的desc ring index。flags的最低位表明driver是否需要中断通知VIRTQ_AVAIL_F_NO_INTERRUPT。在这两个域之后,是和desc ring相同长度的数组,其中存放的是descriptor ring中的index。
struct virtq_avail {
le16 flags;
le16 idx;
le16 ring[ /* Queue Size */ ];
};
图1展示了descriptor table中指向了一块地址为 0x8000,长度为2000字节的buffer,此时descriptor已经有desc指向了这块区域,不过avail ring还没有任何的数据。
第一步:driver分配内存;第二步需要更新并填充desc指向这块buffer。
在填充完descriptor entry之后,driver需要发布这块desc到avail ring中。它将desc index 0写入avail ring数组的第一项,之后更新idx域。结果如下图2。如果提供了chained buffer时,只需要将descriptor的头写入avail 的数组中,avail idx只需要加1,和添加一个desc的情况处理是相同的。
现在,driver不应该再修改avail idx,desc以及指向的buffer了,他们现在已经归属device管理了。现在driver需要通知device,如果需要通知则发送notifications,如下图中的step4。
avail ring中需要处理所有的descriptor ring中的desc,所以他们的数组项一定是相同的。descriptor ring中项数是2的次方,所以在到达ring尾部时可以自动回绕。如果ring的size是256,idx 1, 257,513…引用的是相同的desc,这样两方都不必困扰会遇到无效的idx。
注意:
desc可以以任何的顺序添加到avail ring中,不必要一定从idx 0开始或者从上一次结束的位置开始。实际上使用时通常是从0或上一次结束的位置开始下一个desc,除非乱序有什么必然的好处。
chained descriptor
driver可以通过chained descriptor一次性提供给device多个buffer区域。如果desc flags的NEXT域置位,表示之后还有desc,下一个idx存放在NEXT中,通过next链接起来多个desc形成一个链,直到desc flags.NEXT没有置位。
注意chain desc并不共享flags:一部分buffer可以是只读,一部分buffer可以是只写的。在这种情况下,只写的buffer放在只读的buffer后面。
下图中,driver需要通知给device两个buffer,分别放在了desc 0和1中,随后更新avail idx和ring[], idx只是加1,ring[0]指向desc 0位置处。
Used ring
device通过used ring来将消费过的buffer返回给driver。和avail ring一样,它同样也有flags和idx,布局和用途相同,不需要通知的flags现在叫VIRTQ_USED_F_NO_NOTIFY.
在这之后,它也维护了一个used descriptor数组,在这个数组项,device需要返回descriptor的idx,如果buffer是device可写的还需要返回长度。
struct virtq_used {
le16 flags;
le16 idx;
struct virtq_used_elem ring[ /* Queue Size */];
};
struct virtq_used_elem {
/* Index of start of used descriptor chain. */
le32 id;
/* Total length of the descriptor chain which was used (written to) */
le32 len;
};
在使用chained descriptor时,也时只需要返回chain desc 的头位置,还有所有desc已经写的长度,不包括device只读数据的长度。device并不会修改descriptor table,它只会读/写buffer。这就是下图中的step 5
最终,通过avail flag检查driver是否需要通知,需要则通过约定方式通知driver(一般是中断)
Indirect descriptors
上面的普通或者chained方式,一个 descriptor desc只能存储一块buffer的元数据,当提供大量的小块buffer给device时势必需要很多的desc,这样就要求virtqueue size往往按照最坏的情景来申请资源,会多占点内存。所以就增加了一个indirect descriptor,相当于原来的一级指针数据扩充成了二级指针数据,间接地扩充了vring的长度。
driver可以额外申请一个indriect descriptor的表,它的使用方式和virtqueue中的descriptor是完全相同的,然后在virtqueue desc中插入indirect descriptor的信息,和普通的buffer基本相同,只是需要标记flag VIRTQ_DESC_F_INDIRECT来表明它指向的是一个indirect descriptor table而不是普通的buffer,desc的长度对应了indirect table本身所占的长度。
如果我们想要在indirect table中使用chain descriptor,如下图中我们需要插入两个buffer,首先driver需要首先申请indirect table的空间,从0x2000处申请到了合适的空间,即两个desc,更新desc指向buffer基址;indirect desc 0还需要标记flag NEXT并且next域指向下一个desc的索引1
Buffer | Len | Flags | Next |
---|---|---|---|
0x8000 | 0x2000 | W|N | 1 |
0xD000 | 0x2000 | W | … |
之后跟新descriptor table中的项,buffer地址指向indirect table地址0x2000,len为indirect table长度,flags标记上INDIRECT。
Buffer | Len | Flags | Next |
---|---|---|---|
0x2000 | 32 | I | … |
在此之后,和正常的操作相同,更新avail idx和avail ring[0],然后通知device
device使用indirect+ chained buffer时,将会根据descriptor flags获取indirect table,再从indirect table中遍历chained descriptor拿到总共0x3000长度的buffer( 0x8000-0x9FFF 和 0xD000-0xDFFF))。一旦device通过used ring通知已经消费完后,driver就可以释放indirect table,indirect生命周期到此结束。
descriptor如果被标记了INDIRECT,它指向的不再是普通的buffer而是buffer的元数据,所以不可以再标记WRITE flags。同时也不能再标记NEXT标记,目前只支持在indirect table中存放chained descriptor而不支持将indirect table像普通buffer一样形成chained descriptor. 同时,单个indirect table中存放的desc数目不能超过queue size,它只是间接地扩大了queue地size,但是一次性超越了queue size可能会导致device或者driver出现问题。
Notifications优化
在许多系统中,used和avail buffer地通知会有明显的损耗。为了缓解这个问题,virtqueue都提供了一项特性来抑制通知:告知对方自己是否希望被通知。driver提供的flag只能被device读,反之亦然。
需要注意的是,这种方式是异步的,device/driver一方disable或者enable时,另一方并不会直到这个事件,此时你有可能会错过通知。所以目前这种方式基本上不再被使用。
另外一种有效的方式是通过driver和device协商使用VIRTIO_F_EVENT_IDX,不再是disable通知而是指定一个notification index来暗示对方何时需要发送notification,这个index位于ring的尾部,ring实际的布局是:
struct virtq_avail {
le16 flags;
le16 idx;
le16 ring[ /* Queue Size */ ];
le16 used_event;
};
struct virtq_used {
le16 flags;
le16 idx;
struct virtq_used_elem ring[Q. size];
le16 avail_event;
};
这样,driver每次发布一个avail buffer时都检查used ring中的avail_event: 如果driver的idx域等于avail_event,需要发送notification到device,此时会忽略used ring的VIRTQ_USED_F_NO_NOTIFY标志。
同样的,如果driver支持VIRTIO_F_EVENT_IDX ,device每次发一个used buffer都会检查avail ring中的used_event.
这种方式可以减少无意义的notification带来的损耗。