侧边栏壁纸
  • 累计撰写 53 篇文章
  • 累计收到 3 条评论

P5-03 · virtio 与 vhost-user:虚拟化的数据面协议

PacketForge
2026-08-18 / 0 评论 / 0 阅读 /
对应路线图:Phase 5 (Day 141-168) | DPDK 24.11.6 LTS | 内核对照 v6.6
代码出处:source/linux-stable/drivers/net/virtio_net.c、source/linux-stable/drivers/vhost/net.c、source/dpdk-stable-24.11.6/lib/vhost/vhost_user.c
上一篇:P5-02 VFIO | 下一篇:P5-04 vDPA

一句话定位

虚拟机没有真网卡,于是 virtio 造了台"纸面网卡":guest 里跑一个标准驱动,host 上跑一个后端,中间用三段共享内存队列(virtqueue)通信。协议只有两个动作——guest 往 avail ring 塞条目并 kick,后端往 used ring 写结果并注入中断。vhost-user 则是把这个后端从内核挪进 DPDK 用户态进程。

协议骨架(virtio 规范,公开知识)

Split virtqueue 三段(全在 guest 内存里,GPA 对 host 可见):Descriptor Ring(guest 描述每段缓冲的 addr/len/flags)、Avail Ring(guest 公布"哪些描述符可用")、Used Ring(后端公布"哪些已完成")。单方向通信靠下标(idx)推进,双向通知靠 kick(guest→host)和中断注入(host→guest)。packed virtqueue 把三段压成一个环,用环内标志位代替 avail/used 两段,省 cache 访问。

真实源码精读

① Guest 侧:收包靠"预填空 buffer"(drivers/net/virtio_net.c:2044-2071)

static bool try_fill_recv(struct virtnet_info *vi, struct receive_queue *rq,
              gfp_t gfp)
{
    int err;
    bool oom;

    do {
        if (vi->mergeable_rx_bufs)
            err = add_recvbuf_mergeable(vi, rq, gfp);
        else if (vi->big_packets)
            err = add_recvbuf_big(vi, rq, gfp);
        else
            err = add_recvbuf_small(vi, rq, gfp);

        oom = err == -ENOMEM;
        if (err)
            break;
    } while (rq->vq->num_free);
    if (virtqueue_kick_prepare(rq->vq) && virtqueue_notify(rq->vq)) {
        // ...省略统计计数 ...
    }

    return !oom;
}

和 P1-05 的 ixgbe refill 一个哲学:buffer 是提前挂进队列的。收包路径 virtnet_receive(:2140)只做"从 used ring 取条目 → 交给协议栈",然后在空闲过半时补货:

    // virtio_net.c:2166-2173,节选
    if (rq->vq->num_free > min((unsigned int)budget, virtqueue_get_vring_size(rq->vq)) / 2) {
        if (!try_fill_recv(vi, rq, GFP_ATOMIC)) {
            spin_lock(&vi->refill_lock);
            if (vi->refill_enabled)
                schedule_delayed_work(&vi->refill, 0);
            spin_unlock(&vi->refill_lock);
        }
    }

注意这个和 DPDK PMD 的区别:virtio-net 是软中断驱动(virtnet_poll 在 :2218),受 budget 约束;它只是"和真网卡驱动长得一样"的前端驱动,真正的包搬运在后端。

② 内核后端 vhost-net:数据面线程 + 双模式(drivers/vhost/net.c:973-997)

static void handle_tx(struct vhost_net *net)
{
    struct vhost_net_virtqueue *nvq = &net->vqs[VHOST_NET_VQ_TX];
    struct vhost_virtqueue *vq = &nvq->vq;
    struct socket *sock;

    mutex_lock_nested(&vq->mutex, VHOST_NET_VQ_TX);
    sock = vhost_vq_get_backend(vq);
    if (!sock)
        goto out;

    if (!vq_meta_prefetch(vq))
        goto out;

    vhost_disable_notify(&net->dev, vq);
    vhost_net_disable_vq(net, vq);

    if (vhost_sock_zcopy(sock))
        handle_tx_zerocopy(net, sock);
    else
        handle_tx_copy(net, sock);

out:
    mutex_unlock(&vq->mutex);
}

TX 方向(guest 发包)从 avail ring 取描述符,把数据拷进 host 的内核 socket(handle_tx_copy,:774)或走 zerocopy(:864)。注意"先 disable notify 再干活":批处理期间关掉 guest 的 kick 通知,防止每包一次 VM exit。RX 方向 handle_rx(:1120)先 peek socket 队列长度(:1167),凑齐 vhost_hlen + sock_hlen(:1159-1160)后 get_rx_bufs 拿 guest 的空 buffer 填包。两条路径都是内核线程在忙,每包要过一次内核 socket。

③ vhost-user:后端搬到用户态,直接映射 guest 内存(lib/vhost/vhost_user.c:841-895,节选)

translate_ring_addresses(struct virtio_net **pdev, struct vhost_virtqueue **pvq)
{
    // ...省略 log_guest_addr 处理 ...
    if (vq_is_packed(dev)) {
        len = sizeof(struct vring_packed_desc) * vq->size;
        vq->desc_packed = (struct vring_packed_desc *)(uintptr_t)
            ring_addr_to_vva(dev, vq, vq->ring_addrs.desc_user_addr, &len);
        if (vq->desc_packed == NULL ||
                len != sizeof(struct vring_packed_desc) *
                vq->size) {
            // ...省略错误日志 ...
            return;
        }
        // ...省略 numa_realloc ...
        len = sizeof(struct vring_packed_desc_event);
        vq->driver_event = (struct vring_packed_desc_event *)
                    (uintptr_t)ring_addr_to_vva(dev,
                    vq, vq->ring_addrs.avail_user_addr, &len);
        // ...省略校验与 device_event ...

QEMU 通过 vhost-user 协议把三段 ring 的 guest 物理地址发给 DPDK 进程,DPDK 侧把 GPA 翻译成自己的虚拟地址(ring_addr_to_vva),之后直接读写——没有 copy,没有内核 socket。协议消息的处理入口是 vhost_user_msg_handler(:3105-3146):read_vhost_message(:3133)读 fd,按 request 编号查 vhost_message_handlers 表(:3139-3143)分发;表里 SET_MEM_TABLE / SET_VRING_NUM / SET_VRING_ADDR 三个消息的注册在 :75-79。

virtio 与 vhost 架构

🐧 内核对照:vhost-net vs vhost-user

维度内核 vhost-netDPDK vhost-user
后端位置内核线程(handle_tx/handle_rx,net.c:973/1120)用户态 lcore 直接读写 ring
数据路径copy 进内核 socket(net.c:774 起)零拷贝,GPA→VVA 后直接操作
kick 处理vhost 线程被唤醒eventfd → 用户态轮询
每包成本内核 socket 出入栈与 DPDK 收发包同量级
中断注入内核负责DPDK 写 used ring 后 kick eventfd,QEMU 注入

同一份 virtio 协议,内核实现守"通用",用户态实现冲"线速"。vhost-user 是 OVS-DPDK 云网络的默认形态,也是理解 DPU 上 virtio 卸载的起点。

面试 3 问

  1. virtqueue 为什么是"三段式"而不是一个环? → desc 环描述缓冲(多段 scatter-gather),avail 环让 guest 批量公布可用描述符(可以攒批再 kick),used 环让后端批量回写结果(攒批再中断)——三段分离是批量化和双向通知的结构基础。
  2. vhost-user 怎么做到零拷贝? → QEMU 只传 GPA(SET_VRING_ADDR),DPDK 用 ring_addr_to_vva 翻译成进程内地址直接读写(vhost_user.c:861-883);对比内核 vhost-net 要走 socket copy。
  3. 为什么 handle_tx 开头要先 vhost_disable_notify? → 批处理期间避免 guest 每提交一个包就 kick 一次(每次 VM exit 都是成本),处理完再恢复通知(net.c:987-988)。

🔧 动手实验

# 1. 启动 DPDK vhost-user 后端(testpmd 起一个 vhost 端口)
./testpmd -c 0xf -n 2 --vdev 'net_vhost0,iface=/tmp/vhost-user.sock' -- -i
testpmd> start

# 2. QEMU 启动 guest,后端走 vhost-user socket
qemu-system-x86_64 -netdev vhost-user,id=n1,chardev=c1,queues=1 \
  -chardev socket,id=c1,path=/tmp/vhost-user.sock,server=off ...

# 3. guest 内 iperf3 打流,对比两个后端:
#    a) 同规格 QEMU 换成 -netdev tap + vhost=on(内核 vhost-net)
#    b) 关闭 vhost(QEMU 纯软件模拟)
#    记录三种后端的吞吐/CPU

# 4. testpmd 里 show port stats 0,看 virtqueue 方向流量

数据落点:perf-data/P5/03-vhost-user.md(三种后端 iperf3 吞吐对比表、testpmd 端口统计、CPU 占用)。J3455 上跑 QEMU 会比较吃力,重点看相对差异而不是绝对值。


下一篇:P5-04 · vDPA 框架:把 virtqueue 卸载进硬件

0

评论 (0)

取消