VirtIO实现原理——数据传输演示

初始化
示意图
假设Host配置的virtqueue队列深度是10,初始化时Descriptor Table有10个条目,用于存放10个buffer指针,如下图所示,Descriptor Table的每一个条目初始化时都指向数组的下一个元素,所有10个entry组成一个descriptor chain。virtqueue的关键成员初始值如下:
num_free:10,表示有10个空闲buffer
free_head:0,表示当前Descriptor Table中,空闲buffer的头是第0个entry
avail_idx_shadow:0,指示下一次Guest添加buffer后,应该将buffer的头部的2级索引存放到Avail Ring[]的第0个entry中。idx用于记录头部的1级索引,应该被设置成0。
Avail Ring成员初始化如下:
idx:0,表示Guest下一次添加buffer将把其头部记录到Avail Ring的第0个entry
Host VQ关键成员初始值如下:
last_avail_idx:0,Host自己也维护了一个Avail Ring可用buffer的索引,用来记录自己实际的工作位置,当 Guest告知的可用buffer索引比自己的大时,说名Guest添加了新的可用buffer,Host需要处理
shadow_avail_idx: 0,Host从VQ中取出Guest设置的avail_idx_shadow,存放到shadow_avail_idx中,其初始化为0
在这里插入图片描述
代码分析
Guest初始化virtqueue的函数如下

truct virtqueue *__vring_new_virtqueue(unsigned int index,
                    struct vring vring,
                    struct virtio_device *vdev,
                    bool weak_barriers,
                    bool context,
                    bool (*notify)(struct virtqueue *),
                    void (*callback)(struct virtqueue *),
                    const char *name)
{
	......
    vq->vring = vring;						/* 1 */
    vq->vq.num_free = vring.num;
    vq->vq.index = index;					/* 2 */
    vq->notify = notify;					/* 3 */
    vq->avail_idx_shadow = 0;				/* 4 */ 
	......
    /* Put everything in free lists. */		/* 5 */
    vq->free_head = 0;
    for (i = 0; i < vring.num-1; i++)
        vq->vring.desc[i].next = cpu_to_virtio16(vdev, i + 1);	/* 6 */

Host 复位virtio设备函数如下

void virtio_reset(void *opaque)
{
	......   
    for(i = 0; i < VIRTIO_QUEUE_MAX; i++) {	/* 7 */
        vdev->vq[i].vring.desc = 0;
        vdev->vq[i].vring.avail = 0;
        vdev->vq[i].vring.used = 0;
        vdev->vq[i].last_avail_idx = 0;
        vdev->vq[i].shadow_avail_idx = 0;
        vdev->vq[i].vring.num = vdev->vq[i].vring.num_default;
    }
}

1. 将分配的环放到virtqueue中,设置队列中空闲buffer个数为队列的深度
2. 设置virtqueue的索引,一个设备可以有多个virtio队列
3. 设置virtio队列添加buffer后的通知函数,基于pci和基于mmio的virtio设备实现不同,我们这里是基于pci的,通知函数是vp_notify
4. 初始化Guest在Avail Ring上的工作位置,初始化为0
5. 初始化Avail Ring的空闲buffer起始位置,设置为0
6. 将descriptor表中所有buffer前后相连,组织成一条链
7. Host端的环初始化,Host端初始化需要设置的信息较Guest少,因为buffer的添加是在Guest端做的,Host主要初始化自己在Avail Ring的工作位置last_avail_idx,设置为0。同时初始化将来存放Guest在Avail Ring工作位置的变量shadow_avail_idx,设置为0

Guest第一次添加buffer
示意图
假设第一次Guest添加4个buffer,Guest首先通过free_head找到Descriptor Table空闲buffer的入口,完成4个buffer的添加,然后更新Avail Ring,将添加的buffer头的索引记录到Avail Ring中。virtqueue关键成员更新如下:
num_free:6,Descriptor Table被占用了4个buffer,因此空闲buffer数减4
free_head:4,Descriptor Table前4个buffer已经被使用,因此空闲buffer头在Descriptor Table的第4个entry
avail_idx_shadow:1,下一次添加buffer,其头部将记录到Avail Ring的第1个entry,idx也应该设置成1. 。
Avail Ring成员更新如下:
idx:1,表示下一次添加buffer将把其头部记录到Avail Ring的第1个entry。Guest工作位置往下移动一个Avail Ring的entry。
Avail Ring[0]:0,记录每一次添加buffer的头部在Descriptor Table的索引值。这次添加的buffer在Descriptor Table索引为0
在这里插入图片描述
Guest添加完buffer是否通知Host,有两种机制,一是判断Ring中的flags,二是Event_idx。我们介绍Event_idx机制,其核心原则是:如果Host能够处理VQ上的数据,处理够快,就通知;如果Host处理VQ上的数据慢,就不通知,Guest通过什么方法判断Host的处理速度,我们在最后的速度控制一节中介绍。
Guest通知Host的具体实现,就是往pci附加配置空间的virtio_pci_cap_notify_cfg字段写入数据,敏感指令触发VMExit,从而退出客户端,kvm检查是IO请求,从内核态返回到用户态qemu进行IO处理或者通过ioeventfd通知Guest,具体实现与内核版本有关。之后会单独写一篇文章分析整个流程
Guest Kick 流程

static inline int virtqueue_add(struct virtqueue *_vq,
                struct scatterlist *sgs[],		/* 1 */
                unsigned int total_sg,
                unsigned int out_sgs,			/* 2 */
                unsigned int in_sgs,			/* 3 */
                void *data,
                void *ctx,
                gfp_t gfp)
{
	......
    head = vq->free_head;						/* 4 */
    desc = vq->vring.desc;						
    i = head;									/* 5 */
	......
    for (n = 0; n < out_sgs; n++) {				/* 6 */
        for (sg = sgs[n]; sg; sg = sg_next(sg)) {
            dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_TO_DEVICE);

            desc[i].flags = cpu_to_virtio16(_vq->vdev, VRING_DESC_F_NEXT);		/* 7 */  
            desc[i].addr = cpu_to_virtio64(_vq->vdev, addr);
            desc[i].len = cpu_to_virtio32(_vq->vdev, sg->length);
            prev = i;
            i = virtio16_to_cpu(_vq->vdev, desc[i].next);
        }
    }
    for (; n < (out_sgs + in_sgs); n++) {										/* 8 */
        for (sg = sgs[n]; sg; sg = sg_next(sg)) {
            dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_FROM_DEVICE);

            desc[i].flags = cpu_to_virtio16(_vq->vdev, VRING_DESC_F_NEXT | VRING_DESC_F_WRITE);
            desc[i].addr = cpu_to_virtio64(_vq->vdev, addr);
            desc[i].len = cpu_to_virtio32(_vq->vdev, sg->length);
            prev = i;
            i = virtio16_to_cpu(_vq->vdev, desc[i].next);
        }
    }
    /* Last one doesn't continue. */
    desc[prev].flags &= cpu_to_virtio16(_vq->vdev, ~VRING_DESC_F_NEXT);			/* 9 */
	......
    /* We're using some buffers from the free list. */
    vq->vq.num_free -= descs_used;												/* 10 */ 
	......
  	vq->free_head = i;															/* 11 */ 
	......
    avail = vq->avail_idx_shadow & (vq->vring.num - 1);							/* 12 */
    vq->vring.avail->ring[avail] = cpu_to_virtio16(_vq->vdev, head);	
	......
    vq->avail_idx_shadow++;									
    vq->vring.avail->idx = cpu_to_virtio16(_vq->vdev, vq->avail_idx_shadow);	
    vq->num_added++;
	......

1. 添加的数据buffer首地址,buffer可以被host用来读,也可用于写,因此sgs数组既为Host提供读buffer,也提供写buffer,读buffer如果存在,必
须在写buffer前面
2. sgs数组中读buffer的个数
3. sgs数组中写buffer的个数
4. 取出descriptor表空闲buffer的起始索引,根据索引在可用descriptor表地址,有了这两个信息就可以知道添加的数据buffer的地址
了,之后可以开始往descriptor表中添加数据
5. 保存起始位置到一个临时变量中,临时变量之后作为索引用来添加buffer,递增
6. 从sgs数组中挨个取出发送数据的buffer地址,填入descriptor表中,发送数据,这里填入的是物理地址(GPA),并没有数据的拷贝,因此virtio
读写性能高。那host侧从descriptor取出的也是虚机内部buffer的物理地址(GPA),怎么通过(GPA)访问其数据呢?实际上,qemu保存了GPA
到HVA的映射关系(MemoryRegion中有虚机的物理地址(GPA),它所属的RAMBlock有对应的虚拟地址(HVA)),qemu只要利用
MemoryRegion和RAMBlock就能够将GPA转换成HVA访问。再想一下,整个虚机的内存都是qemu通过申请进程的匿名映射区提供的,qemu怎么
会不知道GPA和HVA的关系呢
7. 将描述发送数据buffer的descriptor flags设置成Host只读,通过这里,我们就能看到这是再往主机侧发送数据,因为主机只能读
8. 从sgs数组中取buffer地址填入descriptor表中,这个buffer地址是用于接收Host侧发来的数据,为收数据提供buffer。这里的接收操作比较难理
解,通常都是发送方自己分配空间后往里面写入数据,传给接收方,但这里是接收方提供空间,让发送方往里面写数据,写完之后通知接收方去取
数据。virtio的guest接收数据之所以这么实现,是因为Host没有办法分配一块内存空间,然后让Guest知道呀,首先如果Host分配了空间写入数据
后,如果地址传的是HVA,Guest是肯定不知道的,如果地址传的是GPA,Guest可以知道,但Host这样做,首先要看虚机里面哪段物理空间没有
被使用,然后分配,写入数据,完成后Host必须要通知虚机,这段空间不能用了。这样的机制,让虚机的操作系统使用内存时依赖了主机的信息,
没法完全独立了。最好的方法,是客户机自己分配空间,让主机知道。
9. buffer添加完成后,在最后一个descriptor的flags中标记数据结束
10. 更新descriptor表中的空间buffer数量,减去本次添加用掉的buffer个数
11. 更新descriptor表中空闲buffer的起始位置,下一次添加buffer时从这里开始
12. 以上两个字段的更新都是关于descriptor表的。Avail Ring的字段也要更新,
有两个信息要记录,一是本次buffer添加的起始位置,这个存放在ring[]数组
的元素中,二是Guest在环上的工作位置,这个存放在Avail Ring中的idx,
每添加一次buffer,工作位置加1。为什么要记录这个位置?其实这是为了判断
后端处理buffer的速度。从上面的工作原理可以分析,前端添加buffer是异步的,
它只做一件事情,添加完buffer,然后离开。当再次有buffer到来时,它继续往
descriptor表中添加,然后离开。为了保证后端知道有buffer到来,前端可以每
次添加完buffer之后,notify后端。但这样有一个问题,当后端处理较慢时,假
设后端正在处理前端第一次添加的buffer,这时前端已经第四次添加buffer
了,每次添加时都notify后端去处理,实际上前端的后三次notify没有用的,因
为后端忙不过来,还在处理第一次的buffer,优雅的做法是,前端在添加完buffer
之后,先判断下后端是不是很忙,如果很忙就不要notify,因为没有用。可以
等到下一次添加buffer的时候,再判断下,如果这个时候后端闲下来了,就可以
通知了,这样做就少去了一部分notify的开销。怎么判断后端忙不忙呢?需要
两个信息,一是前端的工作位置,一是后端的工作位置,只要前后端工作位置
相差不远,说明后端处理及时,就可以notify。因此,前端的工作位置需要记录,
同时,后端会将自己的工作位置记录到Used Ring数组的最后一个元素,前端
可以读到,这样一比较,就可以判断后端忙不忙了。同样地,后端在处理完buffer
之后,也需要先判断前端是否忙碌,然后决定是否发送中断,如果不忙就发送
中断通知,前端将buffer detach,然后处理。

Notify Host
当Guest添加完buffer之后,首先判断后端是否忙碌,如果不忙碌,通知后端有buffer需要处理,Notify的实现是往Virtio-PCI配置空间的VIRTIO_PCI_QUEUE_NOTIFY写入virtio队列的索引,以virtio磁盘为例,实现代码如下:

static int virtio_queue_rq(struct blk_mq_hw_ctx *hctx,
               const struct blk_mq_queue_data *bd)
{
	......
	__virtblk_add_req(vblk->vqs[qid].vq, vbr, vbr->sg, num);	/* 1 */
	
	if (virtqueue_kick_prepare(vblk->vqs[qid].vq))				/* 2 */
        notify = true;
        
   	if (notify)													/* 3 */			
        virtqueue_notify(vblk->vqs[qid].vq)
  	......
}

1. 往virtqueue上添加buffer,最终就是往descriptor中添加buffer地址,如同上一节讲解的内容
2. 判断后端是否忙碌,这里就是上一节提到的,利用前后端工作位置,判断的,前端的工作位置由vring.avail->idx提供,后端的工作位置由Used
Ring的最后一个数组used->ring[(vr)->num]提供
3. 通知后端,调用virtqueue上实现的notify函数,这个函数在初始化virtiqueue的时候注册,这里基于pci的virtio实现,对应的nofity为vp_notify

Host第一次处理buffer
示意图
为了提高qemu的IO性能,qemu最近加入了dataplane特性,该特性的原理是:通过ioeventfd将fd注册到内核,当虚拟机写pci空间的VIRTIO_PCI_QUEUE_NOTIFY对应地址时,因为是IO指令会触发VMExit,KVM检查后通过ioeventfd通知qemu,qemu触发提前注册的回调函数virtio_blk_data_plane_handle_output,最后会从VQ中取数据。从而实现buffer的处理。
Host首先取自己的工作位置last_avail_idx,初始化时被设置为0,再从VQ上取出Guest的工作位置avail_idx_shadow,如果Guest比Host位置超前说明Guest添加了buffer(从下图小绿人看,Guest每添加1次,小绿人就往下移动1个Avain Ring的单位),需要从VQ上取出数据,否则VQ为空,不做任何操作,返回。
Host根据last_avail_idx再Avail Ring中索引buffer头的索引,为0,根据buffer头的索引从Descriptor Table中找到buffer的入口,依次映射成iov entry
完成映射后,Host更新last_avail_idx加1,工作位置往下摞1,同时更新used_idx为1,并记录到Used Ring的idx字段,记录下一次处理buffer的索引
在这里插入图片描述

void *virtqueue_pop(VirtQueue *vq, size_t sz)
{
	......
    if (virtio_queue_empty_rcu(vq)) {									/* 1 */
        goto done;							
    }
	......
    if (!virtqueue_get_head(vq, vq->last_avail_idx++, &head)) {			/* 2 */ 
        goto done;										
    }

    if (virtio_vdev_has_feature(vdev, VIRTIO_RING_F_EVENT_IDX)) {
        vring_set_avail_event(vq, vq->last_avail_idx);					/* 3 */ 
    }

    desc_cache = &caches->desc;
    vring_desc_read(vdev, &desc, desc_cache, i);						/* 4 */ 
	......
    /* Collect all the descriptors */									/* 5 */ 
    do {
        if (desc.flags & VRING_DESC_F_WRITE) {
            map_ok = virtqueue_map_desc(vdev, &in_num, addr + out_num,
                                        iov + out_num,
                                        VIRTQUEUE_MAX_SIZE - out_num, true,
                                        desc.addr, desc.len);
        } else {
            map_ok = virtqueue_map_desc(vdev, &out_num, addr, iov,
                                        VIRTQUEUE_MAX_SIZE, false,
                                        desc.addr, desc.len);
        }

        rc = virtqueue_read_next_desc(vdev, &desc, desc_cache, max, &i);
    } while (rc == VIRTQUEUE_READ_DESC_MORE);

1. 判断VQ中是否有数据,判断方法:首先将Host的工作位置和上一次取buffer时从Avail Ring取下并保存的Guest工作位置shadow_avail_idx比
较,如果两个不相等,说明Host现在的工作位置还没有赶上Guest在上一次添加buffer的位置,Avail环不为空(这种情况大多时Host处理不过
来)。反之,说明Host已经把Guest上一次添加的buffer给处理了,需要从Avail 环上取出当前Guest的工作位置,与Host的工作位置比较,如果不
等,说明Guest在上一次Host取buffer之后,又添加的buffer,Avail环不为空。
2. 取出Host自己记录的工作位置,为读取descriptor表中buffer的地址做准备
3. 将Host的工作位置更新,记录到Used 环的最后一个元素中,方便Guest读取,用于判断Host是否繁忙
4. 读取descriptor表的基址,和2一样,为读取descriptor表中buffer的地址做准备
5. 将descriptor表中的buffer地址转换成主机的虚拟地址(GPA->HVA)

Guest第二次添加buffer
假设第二次添加5个buffer,首选取出Descriptor Table空闲buffer头free_head 4,映射5个sg,之后更新VQ参数,分别是:
num_free:1,Descriptor Table被占用了5个buffer,空闲buffer数减5
free_head:9,Descriptor Table前9个buffer已经被使用,空闲buffer从第9个开始
avail_idx_shadow:2,下一次添加buffer,头部将记录到Avail Ring的第2个entry
Avail Ring成员更新如下:
idx:2,下次添加buffer将头部索引Avail Ring的位置。Guest工作位置往下移。
Avail Ring[1]:4,这次添加的buffer头在Descriptor Table的索引
在这里插入图片描述
Host第二次处理buffer
在这里插入图片描述
Q&A
Q: last_avail_idx和shadow_avail_idx两者有什么区别?
A: last_avail_idx是device在从VQ取数据时使用的变量,每次从VQ的avail ring取一次,就自动+1,因此last_avail_idx是用来记录device(或者说host)工作位置的变量。shadow_avail_idx是device从VQ的avail ring结构的idx字段取出的值,是guest记录的VQ当前可用的位置。设备在初始化完成后,如果device是第一次从VQ中取buffer,那么取完后last_avail_idx就从0变为1,shadow_avail_idx在host第一次取VQ数据过程中一直是0,在第二次取VQ数据过程中也是随last_avail_idx之后变为1,它像影子一样紧随last_avail_idx。通过对比last_avail_idx和shadow_avail_idx,我们可以判断Guest是否新添加了buffer,当两者相等的时候。Guest就是空的,两者不等,说明有新增buffer。
————————————————

                        版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/huang987246510/article/details/103708461

Linux上,virtio的具体实现涉及到几个关键的结构和函数。首先,在virtio驱动中,使用了virtio_device_id结构来匹配设备,并在virtio_driver结构中包含了支持的virtio_device_id列表。\[1\]其次,对于特定的虚拟设备,比如virtio-net网卡驱动,它的实现包括了网卡驱动和virtio操作。\[2\]在创建virtqueue时,核心流程包括了一系列的操作,其中重点是核心流程的梳理。\[2\]最后,对于挂接在virtio总线上的设备,对应了struct virtio_device结构。对于virtio-net设备,它提供了自己的驱动即struct virtio_driver virtio_net_driver。当virtio-net设备挂接到virtio总线上或者virtio_net_driver注册到virtio总线上时,会调用virtio bus的探测函数virtio_dev_probe来找到驱动探测函数virtnet_probe。最终,通过register_netdev()函数将网络设备注册到Linux网络协议栈中。\[3\]这些是virtioLinux上的具体实现的一些关键点。 #### 引用[.reference_title] - *1* *2* [virtio-net 实现机制【一】(图文并茂)](https://blog.csdn.net/m0_74282605/article/details/128114444)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [Linux virtio-net driver](https://blog.csdn.net/lingshengxiyou/article/details/127771119)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值