网络虚拟化——virtio

前言

在上一篇文章(网络虚拟化——QEMU虚拟网卡)中,讨论了经典的网络设备全虚拟化技术。这种技术不需要guest内核对虚拟网络设备有任何的感知和特殊处理,但性能较差,一次读写操作可能会产生多次需要Hypervisor模拟实现的行为。

为了改善虚拟网络设备的性能,各类Hypervisor都提供了自己的半虚拟化网络技术。在这种模式下,guest内核中使用的网络设备和驱动是为虚拟化场景特殊设计和实现的,驱动的实现特别适配了Hypervisor的一些特性,尽量减少了需要Hypervisor介入进行模拟实现的指令数量,从而改善了虚拟网络设备的性能。但随着Linux下Hypervisor类型的不断增加,内核中出现了各种虚拟化专用的网络驱动和设备,这些设备驱动功能基本相同却又有细微的差别,还只能适配特定的Hypervior后端实现。而且这些设备驱动也没有得到充分的维护和性能优化。

因此,到2008年,linux内核社区大佬Rusty Russell提出了virtio的模型和实现:virtio: Towards a De-Facto Standard For Virtual I/O Devices。virtio是一种标准的半虚拟化IO设备模型,Rusty Russell希望通过这个模型,将半虚拟化的IO设备驱动(网卡、磁盘块设备等)统一起来,便于后续的维护、扩展和优化。任何Hypervisor或其他后端都可以根据virtio设备的标准实现virtio设备的后端功能,从而避免继续向内核中加入新的半虚拟化设备驱动。guest用户也可以在不同虚拟化实现下使用相同的虚拟IO设备和功能,不用考虑不同的Hypervisor下还要适配不同虚拟设备的问题。

本文将对virtio技术进行分析和介绍,包括virtio的原理、接口和linux下的虚拟网络实现virtio-net。

本文主要根据Russell的论文内容进行介绍,具体的virtio接口和实现在过去的十多年里必然已经大不相同了,但根本的思想和原理并没有变。

问题

  1. virtio作为通用的IO虚拟化模型,是如何定义通用的IO控制面和数据面接口的?或者说,基于virtio的网络设备virtio-net和块存储设备virtio-blk,有哪些共通点?
  2. 在linux内核下,有virtio、virtio-pci、virtio-net、virtio-blk等virtio相关驱动。这些驱动是如何组织的,多个驱动间是什么关系?
  3. 一个virtio设备,是如何加入到虚拟机设备模型中,被内核发现和驱动的?
  4. virtio-net具体又提供了哪些标准接口?控制面和数据面接口是如何定义的?
  5. virtio技术为虚拟化而产生,但它能否脱离虚拟化环境使用?例如在普通的容器环境或者物理机环境?

virtio

virtio作为一种通用的虚拟IO设备驱动模型,主要定义了两方面的标准模型和接口:控制面的设备配置和初始化,以及数据面的数据传输。

上图是在qemu/kvm虚拟机中实现virtio的架构。可见基本逻辑和其他虚拟网卡是相同的,只是交互方式通过vring队列实现。

控制面定义

virtio的控制面接口可以分为4个部分:

1. 读写特性位

特性位用于device和driver间同步设备特性,例如VIRTIO_NET_F_CSUM表示网卡是否支持checksum offload。driver读取特性位来获取网卡后端支持的特性,driver写入特性位来通知网卡后端需要使用的特性。

2. 读写配置

配置是一个表示设备配置信息的数据结构。driver和device间通过这个结构来获取和设置设备的配置,例如网卡的MAC地址等。

3. 读写状态位

状态位用于driver通知后端自己的初始化进度。driver将状态位设置为VIRTIO_CONFIG_S_DRIVER_OK就表示driver已经完成特性初始化,host在收到这个消息后就可以确定driver需要使用的设备特性。

4. 重启设备

用于移除或者重置virtio设备驱动。

每个virtio设备会有一个virtio_config_ops,其中包括了对上述控制面接口的实现。这些接口的实现和系统提供virtio设备的方式有关,如果是最常见的virtio-pci模式,则这些实现基本上都是对下面会介绍的virtio_pci_common_cfg配置空间的IO读写操作。

virtqueue:数据传输模型

virtio中定义了virtqueue作为guest驱动和host后端间的数据传输结构。块设备只需要一个virtqueue用于数据读写,而网络设备则需要两个virtqueue分别用于网络报文的收和发。

virtqueue是一个队列的抽象模型。guest驱动负责向virtqueue中插入一个个数据buffer,而host后端则负责处理这些buffer。每个buffer都可以由多段不连续的数据空间链接而成,每段数据空间可以有不同的读写权限用于不同的用途。例如用于块设备读取的buffer,可以包含一段guest负责写入的读取信息(位置、长度等),以及一段host负责写入的读取数据内容。buffer的具体结构和设备类型相关。

virtqueue需要支持5个接口,从而实现数据在guest和host间的传输:

struct virtqueue_ops {
    int (*add_buf)(struct virtqueue *vq,
                   struct scatterlist sg[],
                   unsigned int out_num,
                   unsigned int in_num,
                   void *data);
    void (*kick)(struct virtqueue *vq);
    void *(*get_buf)(struct virtqueue *vq,
                     unsigned int *len);
    void (*disable_cb)(struct virtqueue *vq);
    bool (*enable_cb)(struct virtqueue *vq);
};

add_buf用于向virtqueue中插入一个待host处理的buffer,参数data是一个由驱动定义的标识符,用于标识buffer;

kick用于通知host有新的buffer加入,需要处理;

get_buf用于从virtqueue中获取一个host处理完成的buffer,返回值就是add_buf时传入的data参数;

disable_cbenable_cb类似于普通设备驱动中的关中断和开中断,用于设置virtqueue的callback函数在host处理完一个buffer后是否会被调用。callback函数是在driver初始化时注册给virtqueue的。

virtio_ring:数据传输实现

virtqueue是数据传输的抽象模型,而virtio_ring则是这个模型的一种高效实现。

一个virtio_ring由三个部分构成:descriptor资源数组、available ring和used ring。

  • descriptor资源数组。
struct vring_desc
{
    __u64 addr;
    __u32 len;
    __u16 flags;
    __u16 next;
};

每个descriptor可以指示一段内存空间的地址(addr)和长度(len)。多个descriptor可以形成一个链(next),用于表示virtqueue模型中的一个buffer。descriptor还有一个字段flags,用于指示当前descriptor是否是链尾,以及数据段是可读的还是可写的。

  • available ring,用于guest提交descriptor链供host处理。
struct vring_avail
{
    __u16 flags;
    __u16 idx;
    __u16 ring[NUM];
};

这是一个环形队列,ring[NUM]中每个位置保存一个descriptor链的索引(在descriptor资源数组中的下标),idx用于指示最后插入的descriptor链的位置。flags用于guest通知host是否需要在处理完buffer后产生中断。

virtqueue的add_buf就是通过available ring来实现。

  • used ring,用于host返回处理完成的descriptor链。
struct vring_used_elem
{
    __u32 id;
    __u32 len;
};

struct vring_used
{
    __u16 flags;
    __u16 idx;
    struct vring_used_elem ring[];
};

和available ring一样,used ring也是一个环形队列。flags用于host通知guest是否需要在增加buffer后kick。唯一不同的是,used ring中的每个元素除了包括descriptor index之外,还包括了一个len字段,用于表示host处理后的descriptor链中有效数据的总长度。

virtqueue的get_buf就通过这个ring实现。

descriptor的所有权就一直按《descriptor数组->available ring->used ring->descriptor数组》这个循环不断流转,如下图所示。

值得注意的是,和常见的环形队列不同,vring中并没有对端的消费进度字段。因此guest driver和host backend事实上在向vring中插入元素时是不知道vring中的剩余空间情况的。之所以不用担心vring插入时出现溢出的问题,是因为vring实现时将descriptor数组、available ring和used ring设置成了相同大小。因此只要还有descriptor可以向vring中插入,就说明vring上一定还有空余的位置。

还有一点要说明的是,descriptor链被guest插入available ring的顺序和被host处理完成并插入used ring的顺序不一定是相同的,因为后发出的请求有可能被先执行完成(例如块设备读写,后发的的小块读写可能在先发的大块读写前完成)。那么这里就有个疑问:是否可能顺序靠后的descriptor被回收了导致available ring可以被写入而覆盖了顺序在前的descriptor?这也是不可能的,vring是一个先进先出队列,顺序靠前的descriptor永远被先开始处理,因此当后面的descriptor被回收时,在它前面的descriptor肯定已经被对端处理过了,其descriptor index信息已经不再需要,available ring将descriptor index覆盖也不影响对端对descriptor本身的处理。

virtio设备驱动

基于前文介绍的virtio接口定义和vring实现,可以实现各种类型的virtio设备驱动。目前被广泛使用的virtio驱动主要有两种:virtio-blk用于virtio块设备,以及virtio-net用于virtio网络设备。

virtio-blk

virtio-blk只需要一个virtqueue来发送块读写请求并获取结果。其中每个buffer(descriptor链)由三部分构成:请求信息virtio_blk_outhdr、读写数据段信息和结果状态。一般实现中会把这三部分分别放置在三个descriptor中。

1. virtio_blk_outhdr

struct virtio_blk_outhdr
{
    __u32 type;
    __u32 ioprio;
    __u64 sector;
};

host只读的descriptor

type字段表示请求的类型:读、写、或者其他磁盘操作命令。

ioprio字段表示请求的优先级,数值越大优先级越高,后端可以根据该字段决定请求处理顺序。

sector字段表示读写请求的偏移位置。这里的sector表示偏移位置以扇区(512字节)为单位。

2. 数据段

纯粹的数据段,操作类型决定host可读或可写。

3. 结果状态

只有1个字节,host可写,用于host反馈请求的处理结果是成功(0)、失败(1)或不支持(2)。

virtio-net

virtio-net需要两个virtqueue分别用于网络报文的发送和接收。virtio-net中的buffer也有一个header,用于传递checksum offload和segmentation offload。

struct virtio_net_hdr
{
    // Use csum_start, csum_offset
    #define VIRTIO_NET_HDR_F_NEEDS_CSUM 1
    __u8 flags;
    #define VIRTIO_NET_HDR_GSO_NONE 0
    #define VIRTIO_NET_HDR_GSO_TCPV4 1
    #define VIRTIO_NET_HDR_GSO_UDP 3
    #define VIRTIO_NET_HDR_GSO_TCPV6 4
    #define VIRTIO_NET_HDR_GSO_ECN 0x80
    __u8 gso_type;
    __u16 hdr_len;
    __u16 gso_size;
    __u16 csum_start;
    __u16 csum_offset;
};

flags、csum_start、csum_offset用于checksum offload,当flags为VIRTIO_NET_HDR_F_NEEDS_CSUM时后端从csum_start位置开始计算checksum并填入csum_offset位置处。

gso_type、hdr_len、gso_size用于segmentation offload,gso_type指示分段的类型,hdr_len表示首部的长度(首部是不能分段的部分,每个报文都要携带),gso_size表示分段后的数据长度(不包括首部)。

后端根据上述字段对descriptor链中的报文数据进行offload的功能处理,当然前提是virtio-net初始化时guest和host协商使用了这些offload功能。

virtio-pci:virtio的PCI设备实现

PCI是目前最常用的通用总线,大部分hypervisor都支持了PCI设备的模拟和增加。因此,virtio也提供了基于PCI总线的探测配置接口和实现,从而提供一套完整的设备发现、配置和运行能力。

virtio-pci上的PCI设备ID为1AF4:1000~1AF4:10FF。1AF4是vendor id,由Qumranet提供,一般virtio后端都默认使用这个ID作为virtio设备的vendor id,Linux中的virtio驱动也只支持这个ID的设备。但也有例外,例如阿里云的神龙网卡提供的virtio-net设备,vendor id就是阿里巴巴自己的vendor id(1DED),驱动这些设备时就需要修改网卡驱动中支持的ID列表。

当PCI总线上出现ID在这个范围的设备时,virtio-pci就会认为是virtio设备并为其注册一个virtio_device设备信息到virtio总线上。virtio-pci本身并不需要知道virtio设备到底是什么类型,而是会遍历已经加载的virtio-net、virtio-blk等virtio驱动来找到合适的驱动。virtio总线只是virtio-pci中的逻辑,因此在linux kernel看来,所有的PCI virtio设备的驱动都是virtio-pci。

virtio-pci设备同样需要通过设备IO来协商设备与驱动的特性和配置。IO空间大概是这样的结构:

struct virtio_pci_io
{
    __u32 host_features;
    __u32 guest_features;
    __u32 vring_page_num;
    __u16 vring_ring_size;
    __u16 vring_queue_selector;
    __u16 vring_queue_notifier;
    __u8 status;
    __u8 pci_isr;
    __u8 config[];
}

其中的字段分别用于获取和配置设备特性、vring地址、kick IO地址、设备状态等。这个结构在Russell的论文中只是概念性的定义。Linux内核的实现中已经有了一些改变。在手头的5.9.11内核中,对应的结构为:

/* Fields in VIRTIO_PCI_CAP_COMMON_CFG: */
struct virtio_pci_common_cfg {
	/* About the whole device. */
	__le32 device_feature_select;	/* read-write */
	__le32 device_feature;		/* read-only */
	__le32 guest_feature_select;	/* read-write */
	__le32 guest_feature;		/* read-write */
	__le16 msix_config;		/* read-write */
	__le16 num_queues;		/* read-only */
	__u8 device_status;		/* read-write */
	__u8 config_generation;		/* read-only */

	/* About a specific virtqueue. */
	__le16 queue_select;		/* read-write */
	__le16 queue_size;		/* read-write, power of 2. */
	__le16 queue_msix_vector;	/* read-write */
	__le16 queue_enable;		/* read-write */
	__le16 queue_notify_off;	/* read-only */
	__le32 queue_desc_lo;		/* read-write */
	__le32 queue_desc_hi;		/* read-write */
	__le32 queue_avail_lo;		/* read-write */
	__le32 queue_avail_hi;		/* read-write */
	__le32 queue_used_lo;		/* read-write */
	__le32 queue_used_hi;		/* read-write */
};

字段比上面的更详细,但用途基本是对应的。

小结

上文主要基于Rusty Russell在2008年的virtio论文,介绍了virtio的相关技术原理。virtio技术在这十几年中得到了广泛的应用,但其在linux内核中的驱动实现却和十几年前设计时几乎没有区别,可见virtio设计的通用性、兼容性和可扩展性都非常优秀。

最后我们尝试回答一下开头提出的问题:

1. virtio作为通用的IO虚拟化模型,是如何定义通用的IO控制面和数据面接口的?或者说,基于virtio的网络设备virtio-net和块存储设备virtio-blk,有哪些共通点?

对于控制面,virtio为每个设备封装了virtio_config_ops接口,用于配置和启动设备。

对于数据面,virtio定义了virtqueue抽象传输模型,virtqueue提供了一系列操作接口来完成数据收发和事件通知。virtio_config_ops中的find_vqs接口提供了virtqueue的创建和获取能力。virtqueue具体通过virtio-ring实现,driver向available ring中输入请求,host backend处理请求后向used ring中输入回应。

上述模型和实现是virtio设备通用的,virtio-net和virtio-blk都基于这套模型和接口实现。不同之处只在于使用的virtqueue数量,以及virtqueue/vring中的请求/回应的结构与内容不同,这些都和设备的具体功能和行为密切相关。

2. 在linux内核下,有virtio、virtio-pci、virtio-net、virtio-blk等virtio相关驱动。这些驱动是如何组织的,多个驱动间是什么关系?

linux内核中和virtio相关的驱动主要有:virtio、virtio_ring、virtio_pci、virtio_net、virtio_blk等。其中:

virtio提供了virtio总线和设备控制面的接口。

virtio_ring提供了数据面,也就是virtqueue接口和对应的vring实现。

virtio_pci提供了virtio设备作为PCI设备加载时的通用驱动入口,它依赖virtio和virtio_ring提供的接口。

virtio_net提供了virtio网络设备的标准驱动,它依赖virtio和virtio_ring提供的接口。virtio_net将自己注册为virtio总线的一种设备驱动。

virtio_blk提供了virtio块存储设备的标准驱动,它依赖virtio和virtio_ring提供的接口。virtio_blk将自己注册为virtio总线的一种设备驱动。

3. 一个virtio设备,是如何加入到虚拟机设备模型中,被内核发现和驱动的?

一个virtio PCI设备加载时,内核会尝试所有注册的PCI设备驱动,最后发现可以被virtio_pci驱动。virtio_pci再调用注册到virtio总线上的设备驱动,最后发现可以被virtio_net驱动。virtio_net通过virtio_pci的标准配置接口和host协商设备特性和初始化设备,之后通过virtio_ring提供的接口收发网络数据。

4. virtio-net具体又提供了哪些标准接口?控制面和数据面接口是如何定义的?

virtio设备的控制面和数据面接口都是标准的,只是具体数据格式和含义有区别。virtio-net有自己的feature bit集合,每个virtio-net设备至少使用两个virtqueue用于报文的收和发。virtio-net收发的数据buffer都包括virtio_net_hdr作为头部,用于表示driver和host设置的offload参数。

5. virtio技术为虚拟化而产生,但它能否脱离虚拟化环境使用?例如在普通的容器环境或者物理机环境?

理论上说,virtio设备需要driver和host后端两部分协同完成。在非虚拟化环境下,这个后端可以是内核的vhost模块。vhost模块是在内核中实现的virtio后端功能,是为了进一步提升virtio设备的效率而产生的:

virtio为虚拟IO设备提供了一套标准的接口和实现。同时由于其半虚拟化的特质,virtio驱动在设计和实现时尽可能减少了主要操作路径上会触发host后端操作(vmexit)的指令以提升IO效率。但在执行IO操作时,仍会不可避免的需要触发后端操作。例如virtio-net驱动发包时,在向tx virtqueue写入buffer后必然要kick后端来处理buffer,这个kick就是一个IO写操作。当后端在用户态qemu进程中实现时,这就需要经过guest driver->kvm->qemu->kvm->guest的过程,和普通的虚拟设备驱动是没有区别的,效率仍然低下。为了缩短这个过程,后端实现被放入了内核态,作为一个内核模型/内核线程运行,也就是vhost。有了vhost后,后端操作的流程就变成了guest driver->kvm->vhost->kvm->guest。看似和之前差不多,但是kvm和vhost之间的交互只是一个内核函数调用,性能比之前的kvm和qemu间的用户/内核切换要好的多。同时,使用vhost也提升了后端完成实际IO操作的性能。大部分情况下,后端完成IO操作(例如块设备读写或网络收发)仍然要通过内核接口,例如qemu仍然需要使用文件或socket接口实现,这又需要引入系统调用和状态切换。而使用vhost之后,这些内核能力可以由vhost模块直接调用,又一次减少了状态切换开销。

基于vhost,virtio设备其实不一定需要在虚拟化环境下使用,可以在用户态实现virtio驱动,在初始化时直接与vhost交互完成配置,这样就可以在非虚拟化环境下实现一个用户态的纯虚拟virtio设备。

在下一篇文章中,我们将讨论vhost的原理与实现。

  • 7
    点赞
  • 90
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值