前面几节,我们讲了CPU和内存的虚拟化。我们知道,完全虚拟化是很慢的,而通过内核的KVM技术和EPT技术,加速虚拟机对于物理CPU和内存的使用,我们称为硬件辅助虚拟化。

对于一台虚拟机而言,除了要虚拟化CPU和内存,存储和网络也需要虚拟化,存储和网络都属于外部设备,这些外部设备应该如何虚拟化呢?

当然一种方式还是完全虚拟化。比如,有什么样的硬盘设备或者网卡设备,我们就用qemu模拟一个一模一样的软件的硬盘和网卡设备,这样在虚拟机里面的操作系统看来,使用这些设备和使用物理设备是一样的。当然缺点就是,qemu模拟的设备又是一个翻译官的角色。虽然这个时候虚拟机里面的操作系统,意识不到自己是运行在虚拟机里面的,但是这种每个指令都翻译的方式,实在是太慢了。

另外一种方式就是,虚拟机里面的操作系统不是一个通用的操作系统,它知道自己是运行在虚拟机里面的,使用的硬盘设备和网络设备都是虚拟的,应该加载特殊的驱动才能运行。这些特殊的驱动往往要通过虚拟机里面和外面配合工作的模式,来加速对于物理存储和网络设备的使用。

virtio的基本原理

在虚拟化技术的早期,不同的虚拟化技术会针对不同硬盘设备和网络设备实现不同的驱动,虚拟机里面的操作系统也要根据不同的虚拟化技术和物理存储和网络设备,选择加载不同的驱动。但是,由于硬盘设备和网络设备太多了,驱动纷繁复杂。

后来慢慢就形成了一定的标准,这就是virtio,就是虚拟化I/O设备的意思。virtio负责对于虚拟机提供统一的接口。也就是说,在虚拟机里面的操作系统加载的驱动,以后都统一加载virtio就可以了。

在虚拟机外,我们可以实现不同的virtio的后端,来适配不同的物理硬件设备。那virtio到底长什么样子呢?我们一起来看一看。

virtio的架构可以分为四层。

virtio使用virtqueue进行前端和后端的高速通信。不同类型的设备队列数目不同。virtio-net使用两个队列,一个用于接收,另一个用于发送;而 virtio-blk仅使用一个队列。

如果客户机要向宿主机发送数据,客户机会将数据的buffer添加到virtqueue中,然后通过写入寄存器通知宿主机。这样宿主机就可以从virtqueue 中收到的buffer里面的数据。

了解了virtio的基本原理,接下来,我们以硬盘写入为例,具体看一下存储虚拟化的过程。

初始化阶段的存储虚拟化

和咱们在学习CPU的时候看到的一样,Virtio Block Device也是一种类。它的继承关系如下:

static const TypeInfo device_type_info = {
    .name = TYPE_DEVICE,
    .parent = TYPE_OBJECT,
    .instance_size = sizeof(DeviceState),
    .instance_init = device_initfn,
    .instance_post_init = device_post_init,
    .instance_finalize = device_finalize,
    .class_base_init = device_class_base_init,
    .class_init = device_class_init,
    .abstract = true,
    .class_size = sizeof(DeviceClass),
};

static const TypeInfo virtio_device_info = {
    .name = TYPE_VIRTIO_DEVICE,
    .parent = TYPE_DEVICE,
    .instance_size = sizeof(VirtIODevice),
    .class_init = virtio_device_class_init,
    .instance_finalize = virtio_device_instance_finalize,
    .abstract = true,
    .class_size = sizeof(VirtioDeviceClass),
};

static const TypeInfo virtio_blk_info = {
    .name = TYPE_VIRTIO_BLK,
    .parent = TYPE_VIRTIO_DEVICE,
    .instance_size = sizeof(VirtIOBlock),
    .instance_init = virtio_blk_instance_init,
    .class_init = virtio_blk_class_init,
};

static void virtio_register_types(void)
{
    type_register_static(&virtio_blk_info);
}

type_init(virtio_register_types)

Virtio Block Device这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK的父类是TYPE_VIRTIO_DEVICE,TYPE_VIRTIO_DEVICE的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT。到头了。

type_init用于注册这种类。这里面每一层都有class_init,用于从TypeImpl生产xxxClass。还有instance_init,可以将xxxClass初始化为实例。

在TYPE_VIRTIO_BLK层的class_init函数virtio_blk_class_init中,定义了DeviceClass的realize函数为virtio_blk_device_realize,这一点在CPU那一节也有类似的结构。

static void virtio_blk_device_realize(DeviceState *dev, Error **errp)
{
    VirtIODevice *vdev = VIRTIO_DEVICE(dev);
    VirtIOBlock *s = VIRTIO_BLK(dev);
    VirtIOBlkConf *conf = &s->conf;
......
    blkconf_blocksizes(&conf->conf);
    virtio_blk_set_config_size(s, s->host_features);
    virtio_init(vdev, "virtio-blk", VIRTIO_ID_BLOCK, s->config_size);
    s->blk = conf->conf.blk;
    s->rq = NULL;
    s->sector_mask = (s->conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;
    for (i = 0; i < conf->num_queues; i++) {
        virtio_add_queue(vdev, conf->queue_size, virtio_blk_handle_output);
    }
    virtio_blk_data_plane_create(vdev, conf, &s->dataplane, &err);
    s->change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);
    blk_set_dev_ops(s->blk, &virtio_block_ops, s);
    blk_set_guest_block_size(s->blk, s->conf.conf.logical_block_size);
    blk_iostatus_enable(s->blk);
}

在virtio_blk_device_realize函数中,我们先是通过virtio_init初始化VirtIODevice结构。

void virtio_init(VirtIODevice *vdev, const char *name,
                 uint16_t device_id, size_t config_size)
{
    BusState *qbus = qdev_get_parent_bus(DEVICE(vdev));
    VirtioBusClass *k = VIRTIO_BUS_GET_CLASS(qbus);
    int i;
    int nvectors = k->query_nvectors ? k->query_nvectors(qbus->parent) : 0;

    if (nvectors) {
        vdev->vector_queues =
            g_malloc0(sizeof(*vdev->vector_queues) * nvectors);
    }
    vdev->device_id = device_id;
    vdev->status = 0;
    atomic_set(&vdev->isr, 0);
    vdev->queue_sel = 0;
    vdev->config_vector = VIRTIO_NO_VECTOR;
    vdev->vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);
    vdev->vm_running = runstate_is_running();
    vdev->broken = false;
    for (i = 0; i < VIRTIO_QUEUE_MAX; i++) {
        vdev->vq[i].vector = VIRTIO_NO_VECTOR;
        vdev->vq[i].vdev = vdev;
        vdev->vq[i].queue_index = i;
    }
    vdev->name = name;
    vdev->config_len = config_size;
    if (vdev->config_len) {
        vdev->config = g_malloc0(config_size);
    } else {
        vdev->config = NULL;
    }
    vdev->vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,
                                                     vdev);
    vdev->device_endian = virtio_default_endian();
    vdev->use_guest_notifier_mask = true;
}

从virtio_init中可以看出,VirtIODevice结构里面有一个VirtQueue数组,这就是virtio前端和后端互相传数据的队列,最多VIRTIO_QUEUE_MAX个。

我们回到virtio_blk_device_realize函数。接下来,根据配置的队列数目num_queues,对于每个队列都调用virtio_add_queue来初始化队列。

VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size,
                            VirtIOHandleOutput handle_output)
{
    int i;
    vdev->vq[i].vring.num = queue_size;
    vdev->vq[i].vring.num_default = queue_size;
    vdev->vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;
    vdev->vq[i].handle_output = handle_output;
    vdev->vq[i].handle_aio_output = NULL;

    return &vdev->vq[i];
}

在每个VirtQueue中,都有一个vring,用来维护这个队列里面的数据;另外还有一个函数virtio_blk_handle_output,用于处理数据写入,这个函数我们后面会用到。

至此,VirtIODevice,VirtQueue,vring之间的关系如下图所示。这是在qemu里面的对应关系,请你记好,后面我们还能看到类似的结构。

qemu启动过程中的存储虚拟化

初始化过程解析完毕以后,我们接下来从qemu的启动过程看起。

对于硬盘的虚拟化,qemu的启动参数里面有关的是下面两行:

-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1

其中,第一行指定了宿主机硬盘上的一个文件,文件的格式是qcow2,这个格式我们这里不准备解析它,你只要明白,对于宿主机上的一个文件,可以被qemu模拟称为客户机上的一块硬盘就可以了。

而第二行说明了,使用的驱动是virtio-blk驱动。

configure_blockdev(&bdo_queue, machine_class, snapshot);

在qemu启动的main函数里面,初始化块设备,是通过configure_blockdev调用开始的。

static void configure_blockdev(BlockdevOptionsQueue *bdo_queue, MachineClass *machine_class, int snapshot)
{
......
    if (qemu_opts_foreach(qemu_find_opts("drive"), drive_init_func,
                          &machine_class->block_default_type, &error_fatal)) {
.....
    }
}

static int drive_init_func(void *opaque, QemuOpts *opts, Error **errp)
{
    BlockInterfaceType *block_default_type = opaque;
    return drive_new(opts, *block_default_type, errp) == NULL;
}

在configure_blockdev中,我们能看到对于drive这个参数的解析,并且初始化这个设备要调用drive_init_func函数,这里面会调用drive_new创建一个设备。

DriveInfo *drive_new(QemuOpts *all_opts, BlockInterfaceType block_default_type, Error **errp)
{
    const char *value;
    BlockBackend *blk;
    DriveInfo *dinfo = NULL;
    QDict *bs_opts;
    QemuOpts *legacy_opts;
    DriveMediaType media = MEDIA_DISK;
    BlockInterfaceType type;
    int max_devs, bus_id, unit_id, index;
    const char *werror, *rerror;
    bool read_only = false;
    bool copy_on_read;
    const char *filename;
    Error *local_err = NULL;
    int i;
......
    legacy_opts = qemu_opts_create(&qemu_legacy_drive_opts, NULL, 0,
                                   &error_abort);
......
    /* Add virtio block device */
    if (type == IF_VIRTIO) {
        QemuOpts *devopts;
        devopts = qemu_opts_create(qemu_find_opts("device"), NULL, 0,
                                   &error_abort);
        qemu_opt_set(devopts, "driver", "virtio-blk-pci", &error_abort);
        qemu_opt_set(devopts, "drive", qdict_get_str(bs_opts, "id"),
                     &error_abort);
    }

    filename = qemu_opt_get(legacy_opts, "file");
......
    /* Actual block device init: Functionality shared with blockdev-add */
    blk = blockdev_init(filename, bs_opts, &local_err);
......
    /* Create legacy DriveInfo */
    dinfo = g_malloc0(sizeof(*dinfo));
    dinfo->opts = all_opts;

    dinfo->type = type;
    dinfo->bus = bus_id;
    dinfo->unit = unit_id;

    blk_set_legacy_dinfo(blk, dinfo);

    switch(type) {
    case IF_IDE:
    case IF_SCSI:
    case IF_XEN:
    case IF_NONE:
        dinfo->media_cd = media == MEDIA_CDROM;
        break;
    default:
        break;
    }
......
}

在drive_new里面,会解析qemu的启动参数。对于virtio来讲,会解析device参数,把driver设置为virtio-blk-pci;还会解析file参数,就是指向那个宿主机上的文件。

接下来,drive_new会调用blockdev_init,根据参数进行初始化,最后会创建一个DriveInfo来管理这个设备。

我们重点来看blockdev_init。在这里面,我们发现,如果file不为空,则应该调用blk_new_open打开宿主机上的硬盘文件,返回的结果是BlockBackend,对应我们上面讲原理的时候的virtio的后端。

BlockBackend *blk_new_open(const char *filename, const char *reference,
                           QDict *options, int flags, Error **errp)
{
    BlockBackend *blk;
    BlockDriverState *bs;
    uint64_t perm = 0;
......
    blk = blk_new(perm, BLK_PERM_ALL);
    bs = bdrv_open(filename, reference, options, flags, errp);
    blk->root = bdrv_root_attach_child(bs, "root", &child_root,
                                       perm, BLK_PERM_ALL, blk, errp);
    return blk;
}

接下来的调用链为:bdrv_open->bdrv_open_inherit->bdrv_open_common.

static int bdrv_open_common(BlockDriverState *bs, BlockBackend *file,
                            QDict *options, Error **errp)
{
    int ret, open_flags;
    const char *filename;
    const char *driver_name = NULL;
    const char *node_name = NULL;
    const char *discard;
    QemuOpts *opts;
    BlockDriver *drv;
    Error *local_err = NULL;
......
    drv = bdrv_find_format(driver_name);
......
    ret = bdrv_open_driver(bs, drv, node_name, options, open_flags, errp);
......
}

static int bdrv_open_driver(BlockDriverState *bs, BlockDriver *drv,
                            const char *node_name, QDict *options,
                            int open_flags, Error **errp)
{
......
    bs->drv = drv;
    bs->read_only = !(bs->open_flags & BDRV_O_RDWR);
    bs->opaque = g_malloc0(drv->instance_size);

    if (drv->bdrv_open) {
        ret = drv->bdrv_open(bs, options, open_flags, &local_err);
    } 
......
}

在bdrv_open_common中,根据硬盘文件的格式,得到BlockDriver。因为虚拟机的硬盘文件格式有很多种,qcow2是一种,raw是一种,vmdk是一种,各有优缺点,启动虚拟机的时候,可以自由选择。

对于不同的格式,打开的方式不一样,我们拿qcow2来解析。它的BlockDriver定义如下:

BlockDriver bdrv_qcow2 = {
    .format_name        = "qcow2",
    .instance_size      = sizeof(BDRVQcow2State),
    .bdrv_probe         = qcow2_probe,
    .bdrv_open          = qcow2_open,
    .bdrv_close         = qcow2_close,
......
    .bdrv_snapshot_create   = qcow2_snapshot_create,
    .bdrv_snapshot_goto     = qcow2_snapshot_goto,
    .bdrv_snapshot_delete   = qcow2_snapshot_delete,
    .bdrv_snapshot_list     = qcow2_snapshot_list,
    .bdrv_snapshot_load_tmp = qcow2_snapshot_load_tmp,
    .bdrv_measure           = qcow2_measure,
    .bdrv_get_info          = qcow2_get_info,
    .bdrv_get_specific_info = qcow2_get_specific_info,

    .bdrv_save_vmstate    = qcow2_save_vmstate,
    .bdrv_load_vmstate    = qcow2_load_vmstate,

    .supports_backing           = true,
    .bdrv_change_backing_file   = qcow2_change_backing_file,

    .bdrv_refresh_limits        = qcow2_refresh_limits,
......
};

根据上面的定义,对于qcow2来讲,bdrv_open调用的是qcow2_open。

static int qcow2_open(BlockDriverState *bs, QDict *options, int flags,
                      Error **errp)
{
    BDRVQcow2State *s = bs->opaque;
    QCow2OpenCo qoc = {
        .bs = bs,
        .options = options,
        .flags = flags,
        .errp = errp,
        .ret = -EINPROGRESS
    };

    bs->file = bdrv_open_child(NULL, options, "file", bs, &child_file,
                               false, errp);
    qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &qoc));
......
}

在qcow2_open中,我们会通过qemu_coroutine_enter进入一个协程coroutine。什么叫协程呢?我们可以简单地将它理解为用户态自己实现的线程。

前面咱们讲线程的时候说过,如果一个程序想实现并发,可以创建多个线程,但是线程是一个内核的概念,创建的每一个线程内核都能看到,内核的调度也是以线程为单位的。这对于普通的进程没有什么问题,但是对于qemu这种虚拟机,如果在用户态和内核态切换来切换去,由于还涉及虚拟机的状态,代价比较大。

但是,qemu的设备也是需要多线程能力的,怎么办呢?我们就在用户态实现一个类似线程的东西,也就是协程,用于实现并发,并且不被内核看到,调度全部在用户态完成。

从后面的读写过程可以看出,协程在后端经常使用。这里打开一个qcow2文件就是使用一个协程,创建一个协程和创建一个线程很像,也需要指定一个函数来执行,qcow2_open_entry就是协程的函数。

static void coroutine_fn qcow2_open_entry(void *opaque)
{
    QCow2OpenCo *qoc = opaque;
    BDRVQcow2State *s = qoc->bs->opaque;

    qemu_co_mutex_lock(&s->lock);
    qoc->ret = qcow2_do_open(qoc->bs, qoc->options, qoc->flags, qoc->errp);
    qemu_co_mutex_unlock(&s->lock);
}

我们可以看到,qcow2_open_entry函数前面有一个coroutine_fn,说明它是一个协程函数。在qcow2_do_open中,qcow2_do_open根据qcow2的格式打开硬盘文件。这个格式官网就有,我们这里就不花篇幅解析了。

总结时刻

我们这里来总结一下,存储虚拟化的过程分为前端、后端和中间的队列。

课堂练习

对于qemu-kvm来讲,qcow2是一种常见的文件格式。它有精妙的格式设计,从而适应虚拟化的场景,请你研究一下这个文件格式。

欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。