对应路线图: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。
🐧 内核对照:vhost-net vs vhost-user
| 维度 | 内核 vhost-net | DPDK 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 问
- virtqueue 为什么是"三段式"而不是一个环? → desc 环描述缓冲(多段 scatter-gather),avail 环让 guest 批量公布可用描述符(可以攒批再 kick),used 环让后端批量回写结果(攒批再中断)——三段分离是批量化和双向通知的结构基础。
- vhost-user 怎么做到零拷贝? → QEMU 只传 GPA(SET_VRING_ADDR),DPDK 用
ring_addr_to_vva翻译成进程内地址直接读写(vhost_user.c:861-883);对比内核 vhost-net 要走 socket copy。 - 为什么 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 会比较吃力,重点看相对差异而不是绝对值。
评论 (0)