virtio前端机制详解

Virtio 基本概念

KVM是必须使用硬件虚拟化辅助技术(如Intel VT-x、AMD-V)的hypervisor,尽管KVM能够借助于物理硬件提供的CPU、内存等的虚拟化支持来提升效率,但KVM本身不提供IO设备以及虚拟机管理方面的支持,而是借助于Qemu来提供这些功能。KVM加上Qemu的组合可以为用户提供全虚拟化的解决方案,但QEMU通过纯软件的方式模拟得到的IO设备每次I/O操作的路径比较长,虚拟机和宿主机之间的切换(也就是VMEntry和VMExit这种上下文切换),需要耗费一定量的CPU时钟周期,而且很容易引起CPU之间的xcall,性能较差,而Virtio就是因为解决了这个性能问题而被人熟知的。当然,virtio的也是有缺点的,它必须要客户机安装特定的Virtio驱动使其知道是运行在虚拟化环境中,且按照Virtio的规定格式进行数据传输。

VirtIO 是对准虚拟化 hypervisor 中的一组通用模拟设备IO的抽象。它是一种框架,通过它hypervisor 导出一组通用的模拟设备,并通过一个通用的应用编程接口(API)让它们在虚拟机中变得可用。它构造了一种虚拟化环境所独有的存储设备,因此需要在虚拟机内部安装特定的驱动程序才能正常驱使该设备进行工作。通常我们称虚拟机内部的驱动为前端驱动,称负责实现其功能模拟的程序(KVM平台下即为qemu程序)为后端程序,半模拟技术也常常被叫做前后端技术。采用半摸拟技术后,配合前端驱动,虚拟化设备完全可以采用全新的事件通知和数据传递机制进而大幅提升性能,例如在virtio-blk磁盘中,采用io_event_fd进行前端到后端通知,采用中断注入方式实现后端到前端的通知,并通过IO环(vring)进行数据的共享,IO模型也随之发生变化

QEMU/KVM中,Virtio的基本结构框架如图所示:

其中前端驱动(frondend,如virtio-blk、virtio-net等)是在客户机中存在的驱动程序模块,而后端处理程序(backend)是在QEMU中实现的。在这前后端驱动之间,还定义了两层来支持客户机与QEMU之间的通信。其中,“virtio”这一层是虚拟队列接口,它在概念上将前端驱动程序附加到后端处理程序。一个前端驱动程序可以使用0个或多个队列,具体数量取决于需求。例如,virtio-net网络驱动程序使用两个虚拟队列(一个用于接收,另一个用于发送),而virtio-blk块驱动程序仅使用一个虚拟队列。虚拟队列实际上被实现为跨越客户机操作系统和hypervisor的衔接点,但它可以通过任意方式实现,前提是客户机操作系统和virtio后端程序都遵循一定的标准,以相互匹配的方式实现它。而virtio-ring实现了环形缓冲区(ring buffer),用于保存前端驱动和后端处理程序执行的信息,并且它可以一次性保存前端驱动的多次I/O请求,并且交由后端去动去批量处理,最后实际调用宿主机中设备驱动实现物理上的I/O操作,这样做就可以根据约定实现批量处理而不是客户机中每次I/O请求都需要处理一次,从而提高客户机与hypervisor信息交换的效率。

因此,对与 guest OS 来说,只需要添加一个 PCI 设备驱动,然后 Hypervisor 添加设备的 vring 支持即可以添加一个 virtio 设备。一般没特殊说明,驱动就是指virtio的前端驱动,设备就是virtio的后端驱动(vhost)所模拟的PCI设备。

virtio原理

拿网络驱动部分做案例,网络驱动virtio-net有两个队列:接收队列和发送队列;每个队列都对应一个virtqueue,两个队列之间是互不影响的。

前后端利用virtqueue的方式如下图所示:
virtio前端机制详解_第1张图片

当收包时,ReceiveQueue需要客户机 driver提前填充分配好的空buffer,然后记录到availRing,并在恰当的时机通知后端,当外部网络有数据包到达时,qemu后端就从availRing 中获取一个buffer,然后填充数据,完事后记录buffer head index到usedRing.最后在恰当的时机通知客户机(向客户机注入中断),客户机接收到信号便知道有数据包到达,这里只需要从usedRing 中获取到index,然后取data数组的第i个元素即可。因为在客户机填充buffer的时候把逻辑buffer的指针保存在data数组中。

而SendQueue同样需要客户机去填充,只不过这里是当客户机需要发送数据包时,把数据包构造成逻辑buffer,然后填充到send Queue,并在恰当的时机通知后端,qemu后端收到通知就知道那个队列有请求到达,如果当前没有处理其他数据包就着手处理这个数据包。
virtio前端机制详解_第2张图片
具体就同样是从AvailRing中取出buffer head index,然后从描述符表中get到buffer,这时就需要从buffer中copy数据了,因为要把数据包从host发送出去,然后更新usedRing。最后同样要在恰当的时机通知客户机。注意这里客户机同样需要从usedRing 中get index,但是这里主要是用于delay notify,因为数据包由客户机构造,其占用的buffer并不能重复使用,只是每次有数据包就把其构造成buffer而已。
virtio前端机制详解_第3张图片

Virtqueue

struct VirtQueue
{
    VRing vring;  /* vring的元数据 */
    hwaddr pa;  /* vring实际的内存地址 */
    uint16_t last_avail_idx;
    /* Last used index value we have signalled on */
    uint16_t signalled_used;

    /* Last used index value we have signalled on */
    bool signalled_used_valid;

    /* Notification enabled? */
    bool notification;

    uint16_t queue_index;

    int inuse;

    uint16_t vector;
    void (*handle_output)(VirtIODevice *vdev, VirtQueue *vq);
    VirtIODevice *vdev;
    EventNotifier guest_notifier;
    EventNotifier host_notifier;
};

每个设备拥有多个 virtqueue 用于大块数据的传输。virtqueue 是一个简单的队列,guest 把 buffers 插入其中,每个 buffer 都是一个分散-聚集数组。驱动调用 find_vqs()来创建一个与 queue 关联的结构体。virtqueue 的数目根据设备的不同而不同,比如 block 设备有一个 virtqueue,network 设备有 2 个 virtqueue,一个用于发送数据包,一个用于接收数据包,Balloon 设备有 3 个 virtqueue.

针对 virtqueue 的操作包括:

1).int virtqueue_add_buf(struct virtqueue *_vq, struct scatterlist sg[], unsigned int out, unsigned int in, void *data, gfp_t gfp):
用于向 queue 中添加一个新的 buffer,参数 data 是一个非空的令牌,用于识别 buffer,当 buffer 内容被消耗后,data 会返回。

2).virtqueue_kick():
Guest 通知 host 单个或者多个 buffer 已经添加到 queue 中,调用 virtqueue_notify(),notify 函数会向 queue notify(VIRTIO_PCI_QUEUE_NOTIFY)寄存器写入 queue index 来通知 host。

3).void *virtqueue_get_buf(struct virtqueue *_vq, unsigned int *len)
返回使用过的 buffer,len 为写入到 buffer 中数据的长度。获取数据,释放 buffer,更新 vring 描述符表格中的 index。

4).virtqueue_disable_cb()
示意 guest 不再需要再知道一个 buffer 已经使用了,也就是关闭 device 的中断。驱动会在初始化时注册一个回调函数,disable_cb()通常在这个 virtqueue 回调函数中使用,用于关闭再次的回调发生。

5).virtqueue_enable_cb()
与 disable_cb()刚好相反,用于重新开启设备中断的上报。

Vring

typedef struct VRing
{
    unsigned int num;
    unsigned int align;
    hwaddr desc;
    hwaddr avail;
    hwaddr used;
} VRing;

virtio_ring 是 virtio 传输机制的实现,vring 引入 ring buffers 来作为我们数据传输的载体。virtio_ring 包含 3 部分:

  1. 描述符数组(descriptor table)用于存储一些关联的描述符,每个描述符都是一个对 buffer 的描述,包含一个 address/length 的配对。
  2. 可用的 ring(available ring)用于 guest 端表示那些描述符链当前是可用的。
  3. 使用过的 ring(used ring)用于表示 Host 端表示那些描述符已经使用。
    Ring 的数目必须是 2 的次幂。

avail ring有两个用途,一是发送侧(send queue)前端驱动发送报文的时,将待发送报文加入avail ring等待后端的处理,后端处理完后,会将其放入used ring,并由前端将其释放desc中(free_old_xmit_skbs, detach_buf),最后通过try_fill_recv重新装入avail ring中; 二是接收侧(receive qeueu),前端将空白物理块加入avail ring中,提供给后端用来接收报文,后端接收完报文会放入used ring。

可以看出,都是后端用完前端的avail ring的东西放入used ring,即avail ring是前端维护,后端访问;used ring 是后端修改,前端访问。

1,描述符和描述符表格

vring descriptor 用于指向 guest 使用的 buffer。

typedef struct VRingDesc
{
    uint64_t addr;
    uint32_t len;
    uint16_t flags;
    uint16_t next;
} VRingDesc;

addr:guest 物理地址
len:buffer 的长度
flags:flags 的值含义包括:

  • VRING_DESC_F_NEXT:用于表明当前 buffer 的下一个域是否有效,也间接表明当前 buffer 是否是 buffers list 的最后一个。
  • VRING_DESC_F_WRITE:当前 buffer 是 read-only 还是 write-only。
  • VRING_DESC_F_INDIRECT:表明这个 buffer 中包含一个 buffer 描述符的 list

next:所有的 buffers 通过 next 串联起来组成 descriptor table

多个 buffer 组成一个 list 由 descriptor table 指向这些 list。
约定俗成,每个 list 中,read-only buffers 放置在 write-only buffers 前面。
virtio前端机制详解_第4张图片
图 2.descriptor table

有些设备可能需要同时完成大量数据传输的大量请求,设备 VIRTIO_RING_F_INDIRECT_DESC 特性能够满足这种需求。为了增加 ring 的容量,vring 可以指向一个可以处于内存中任何位置 indirect descriptors table,而这个 table 指向一组 vring descriptors,而这些 vring descriptor 分别指向一组 buffer list(如图所示)。当然 indirect descriptors table 中的 descriptor 不能再次指向 indirect descriptors table。单个 indirect descriptor table 可以包含 read-only 和 write-only 的 descriptors,带有 write-only flag 的 descriptor 会被忽略。
virtio前端机制详解_第5张图片
图 3.indirect decriptors

2,Available Ring

typedef struct VRingAvail
{
    uint16_t flags;
    uint16_t idx;
    uint16_t ring[0];
} VRingAvail;

Available ring 指向 guest 提供给设备的描述符,它指向一个 descriptor 链表的头。Available ring 结构如下图所示。其中标识 flags 值为 0 或者 1,1 表明 Guest 不需要 device 使用完这些 descriptor 时上报中断。idx 指向我们下一个 descriptor 入口处,idx 从 0 开始,一直增加,使用时需要取模:
idx=idx&(vring.num-1)
virtio前端机制详解_第6张图片
图 4.available ring

3,Used Ring

typedef struct VRingUsedElem
{
    uint32_t id;
    uint32_t len;
} VRingUsedElem;

typedef struct VRingUsed
{
    uint16_t flags;
    uint16_t idx;
    VRingUsedElem ring[0];
} VRingUsed;

Used ring 指向 device(host)使用过的 buffers。Used ring 和 Available ring 之间在内存中的分布会有一定间隙,从而避免了 host 和 guest 两端由于 cache 的影响而会写入到 virtqueue 结构体的同一部分的情况。

flags 用于 device 告诉 guest 再次添加 buffer 到 available ring 时不再提醒,也就是说 guest 添加 buffers 到 available ring 时不必进行 kick 操作。

Used vring element 包含 id 和 len,id 指向 descriptor chain 的入口,与之前 guest 写入到 available ring 的入口项一致。

len 为写入到 buffer 中的字节数。

Guest 向设备提供 buffer

1.把 buffer 添加到 description table 中,填充 addr,len,flags
2.更新 available ring head
3.更新 available ring 中的 index
4.通知 device,通过写入 virtqueue index 到 Queue Notify 寄存器

Device 使用 buffer 并填充 used ring

device 端使用 buffer 后填充 used ring 的过程如下:
1.virtqueue_pop()——从描述符表格(descriptor table)中找到 available ring 中添加的 buffers,映射内存
2.从分散-聚集的 buffer 读取数据
3.virtqueue_fill()——取消内存映射,更新 ring[idx]中的 id 和 len 字段
4.virtqueue_flush()——更新 vring_used 中的 idx
5.virtio_notify()——如果需要的话,在 ISR 状态位写入 1,通知 guest 描述符已经使用

VIRTIO的vring收发队列创建流程:

在初始化阶段,前端分配好内存区,并初始化好前端的vring后,就把内存区的信息传递到后端,后端也利用这个内存区的信息初始化队列相关的vring。这样vring就在前后端保持了一致。原理就是如此,下面看具体初始化代码:

// GUEST前端驱动,以网络设备为例: Virtio-net: PCI 发现后,通过PCI总线分配收发队列,函数调用依次如下:前端:virtnet_probe->init_vqs->virtnet_find_vqs->vi->vdev->config->find_vqs(vp_find_vqs)->vp_try_to_find_vqs->setup_vq,在setup_vp中通过IO端口和后端交互

static int virtnet_probe(structvirtio_device *vdev)
{
        /*
         * 初始化virtqueue
        * 创建和初始化发送/接收队列
        */
        err = init_vqs(vi);
}

/*创建和初始化发送/接收队列*/
static int init_vqs(struct virtnet_info*vi)
{
        /*分配*/
        ret = virtnet_alloc_queues(vi);
        if (ret)
                  goto err;

        /*通过find vqs来创建vring*/
        ret = virtnet_find_vqs(vi);
        if (ret)
                  goto err_free;
}

/*通过find vqs来创建vring*/
static int virtnet_find_vqs(structvirtnet_info *vi)
{
        /*最后调用的是vp_find_vqs,真正的创建virtqueue内部结构和分配地址,并将地址告诉后端QEMU驱动*/
        ret = vi->vdev->config->find_vqs(vi->vdev,total_vqs, vqs, callbacks, names);
}

//VIRTIO PCI总线
static int vp_find_vqs(struct virtio_device*vdev, unsigned nvqs,struct virtqueue *vqs[],vq_callback_t *callbacks[],const char *names[])
{
        int err;
        err = vp_try_to_find_vqs(vdev,nvqs, vqs, callbacks, names, true, true);
        err = vp_try_to_find_vqs(vdev,nvqs, vqs, callbacks, names, true, false);
        return vp_try_to_find_vqs(vdev,nvqs, vqs, callbacks, names, false, false);

}

static int vp_try_to_find_vqs()
{
                  /*最核心的是setup_vq()*/
                  vqs[i] = setup_vq(vdev,i, callbacks[i], names[i], msix_vec);

}

static struct virtqueue *setup_vq(structvirtio_device *vdev, unsigned index,void (*callback)(struct virtqueue *vq),const char *name,u16 msix_vec)
{

        /*这里实际上把info->queue的GPA(页框号写入到了设备的VIRTIO_PCI_QUEUE_PFN),这样后端就会得到这块内存区的信息。然后我们先看下前端利用这块内存区做了什么?看下面的vring_new_virtqueue函数,该函数中调用vring_init来初始化vring*/

        iowrite32(virt_to_phys(info->queue)>> VIRTIO_PCI_QUEUE_ADDR_SHIFT, vp_dev->ioaddr +VIRTIO_PCI_QUEUE_PFN);

/* create the vring */
    vq = vring_new_virtqueue(index, info->num, VIRTIO_PCI_VRING_ALIGN, vdev,
                 true, info->queue, vp_notify, callback, name);
}

static inline void vring_init(struct vring *vr, unsigned int num, void *p,
                  unsigned long align)
{
    vr->num = num;
    vr->desc = p;
    vr->avail = p + num*sizeof(struct vring_desc);
    vr->used = (void *)(((unsigned long)&vr->avail->ring[num] + sizeof(__u16)
        + align-1) & ~(align - 1));
}

// 这个函数正好体现了我们前面那个结构图。这样前端vring就初始化好了。对队列填充数据时就是根据这个vring填充信息。

// QEMU后端驱动:
// 下面在通过VIRTIO_PCI_QUEUE_PFN传递地址的时候,调用virtio_queue_set_addr设置后端相关队列的vring,该函数实现较简单

static void virtio_ioport_write(void*opaque, uint32_t addr, uint32_t val)
{
    switch (addr) {
    case VIRTIO_PCI_QUEUE_PFN:
        /*pa就是desc的GPA*/
        pa = (target_phys_addr_t)val << VIRTIO_PCI_QUEUE_ADDR_SHIFT;
        if (pa == 0) {
            virtio_pci_stop_ioeventfd(proxy);
            virtio_reset(proxy->vdev);
            msix_unuse_all_vectors(&proxy->pci_dev);
        }
        else
            //下面在通过VIRTIO_PCI_QUEUE_PFN传递地址的时候,调用virtio_queue_set_addr设置后端相关队列的vring
            virtio_queue_set_addr(vdev, vdev->queue_sel, pa);
        break;
    //仅仅是标记了下设备中的queue_sel表示当前操作的队列索引
    case VIRTIO_PCI_QUEUE_SEL:
        if (val < VIRTIO_PCI_QUEUE_MAX)
            vdev->queue_sel = val;
        break;
    case VIRTIO_PCI_QUEUE_NOTIFY:
        if (val < VIRTIO_PCI_QUEUE_MAX) {
            virtio_queue_notify(vdev, val);
        }
        break;
}

void virtio_queue_set_addr(VirtIODevice *vdev, int n, target_phys_addr_t addr)
{
    vdev->vq[n].pa = addr;
    virtqueue_init(&vdev->vq[n]);
}

static void virtqueue_init(VirtQueue *vq)
{
    target_phys_addr_t pa = vq->pa;

    vq->vring.desc = pa;
    vq->vring.avail = pa + vq->vring.num * sizeof(VRingDesc);
    vq->vring.used = vring_align(vq->vring.avail +
                                 offsetof(VRingAvail, ring[vq->vring.num]),
                                 VIRTIO_PCI_VRING_ALIGN);
}

看到这里有么有很面熟,没错,这个函数和前端初始化vring的函数很是类似,这样前后端的vring就同步起来了……

而在guest通知后端的时候,通过VIRTIO_PCI_QUEUE_NOTIFY接口,该函数调用了virtio_queue_notify_vq继而调用 vq->handle_output……就这样,后端就得到通知着手处理了!

还有个细节问题,前端驱动写入的应该是QEUEU的GPA
“iowrite32(virt_to_phys(info->queue)>> VIRTIO_PCI_QUEUE_ADDR_SHIFT,vp_dev->ioaddr+ VIRTIO_PCI_QUEUE_PFN);”

这个被后端QEMU截获后,QEMU怎么直接使用了GPA呢?哪里完成的GPA->HVA的转换呢?

这个是在QEMU从virtqueue中取消息的时候,进行转换的

QEMU代码,在收到VIRTIO通知后,会通过virtqueue_pop从共享队列中取出消息

intvirtqueue_pop(VirtQueue *vq, VirtQueueElement *elem)
{
    /*Now map what we have collected */
    virtqueue_map_sg(elem->in_sg,elem->in_addr, elem->in_num, 1);
    virtqueue_map_sg(elem->out_sg,elem->out_addr, elem->out_num, 0);
}

voidvirtqueue_map_sg()
{
   for (i = 0; i < num_sg; i++) {
       len = sg[i].iov_len;
       sg[i].iov_base =cpu_physical_memory_map(addr[i], &len, is_write);
       if (sg[i].iov_base == NULL || len != sg[i].iov_len) {
           error_report("virtio: trying to map MMIO memory");
           exit(1);
       }
    }
}

/*完成一个GUEST的物理地址GPA到HVA的转换*/
void*cpu_physical_memory_map(hwaddr addr,hwaddr *plen,int is_write)
{
   return address_space_map(&address_space_memory,addr, plen, is_write);
}

收包过程如下所示:
这里写图片描述
1,前端填充好desc(addr/len),并更新vring->avail(ring[0])
2,后端读取avail ring索引,找到desc(if ring[0]=2,then desctable[2] 记录的就是一个逻辑buffer的首个物理块的信息),填充buffer数据;将buffer索引存在desc,将desc索引存放在used ring中
3,前端读取used ring索引,找到desc,获取buffer数据

你可能感兴趣的:(云计算虚拟化)