virt-report虚拟化社区动态

QEMU 网卡模拟与 virtio-net 的发包路径

本文由 OpenAI Codex (GPT-6-Astra) 辅助编写。

一、虚拟网卡的发包流程

客户机准备好一个 60 B 以太网帧后,需要把三件事告诉设备:数据放在哪里、现在可以读取,以及读完后何时归还缓冲区。本文沿“准备 P1 → 发布队列 → QEMU 读取 → TAP 接收 → 驱动回收”追踪同一个请求,比较 e1000 寄存器接口与 virtio-net 共享队列。

客户机 RAM 保存报文和共享队列,QEMU 进程读取这些内存,TAP 连接宿主网络。virtio-net 用描述符记录缓冲区地址和长度,用 available ring 发布请求,再用 used ring 归还请求。kick 面向 QEMU,完成中断面向客户机,两者都提示接收方检查队列。

本文分析 x86 / KVM 下的 QEMU 用户态网卡路径:e1000 与 modern virtio-net PCI 设备使用相同的 TAP 后端,关闭 vhost;virtio-net 采用 split virtqueue。示例只发送一个普通以太网帧,省略校验和与分段卸载,不配置客户机 IOMMU。涉及 DMA 地址的实际系统,还需按平台配置完成地址转换。

源码版本:QEMU 10.2.0、Linux 6.18。

二、接口选择:e1000 寄存器与 virtio 队列

网络发送包含三类交互:客户机在内存中准备报文和队列元数据,通过设备接口提交工作,再从完成记录中判断哪些缓冲区可以回收。报文内容、提交通知和完成通知承担不同职责,追踪时应分别观察。

1. e1000:实现硬件定义的接口

e1000 驱动在客户机内存中分配 TX 描述符环。描述符记录缓冲区地址、长度以及命令和状态位;EOP标记一个报文的结束,RS请求状态回写。设备寄存器 TDBAL/TDBAH保存环地址,TDLEN保存长度,TDH和 TDT表示消费位置与驱动提交的尾位置。

驱动填好描述符后写 TDT,QEMU 的 e1000 模型就按硬件接口约定读取客户机内存。这里已经存在描述符环和 DMA 语义:软件模型通过 pci_dma_read()、pci_dma_write()完成设备视角的内存访问。描述符环本身无需复制到某个“宿主设备专属内存”才能使用。e1000 发送实现

2. virtio-net:通用队列与网络设备格式

split virtqueue 把队列划分为描述符表、available ring 和 used ring。驱动填写描述符链,将链首编号放入 available ring;设备读取链上的缓冲区,完成后把链首编号写入 used ring。avail.idx与 used.idx分别记录已发布和已完成的队列项数量,按协议规定的整数宽度回绕。

virtio-net 在帧前携带 virtio_net_hdr,用于描述网络卸载等信息。该头属于客户机与设备间的协议元数据;它不会作为以太网帧的一部分发送到远端。TAP 支持的 vnet_hdr还允许 QEMU 与宿主内核传递这些元数据,具体长度和能力需要协商。发送头与缓冲区整理

交互e1000virtio-net / split ring
描述工作TX 描述符及硬件定义的命令位通用描述符链及网络设备头
发布请求填写描述符后写 TDT写 available 项并更新 avail.idx
通知设备TDT 寄存器写入触发发送处理按通知抑制条件决定是否 kick
记录完成按 RS 等条件回写 DD发布 used 项并更新 used.idx
本例处理位置QEMU 用户态设备模型,经相同的 TAP 进入宿主网络

两种接口的差异主要在驱动与设备的约定。virtio 把队列机制从具体硬件寄存器模型中独立出来,便于不同设备类型复用;客户机仍需要相应驱动,QEMU 仍需检查描述符、访问内存和完成后端 I/O。

三、共享结构:描述符、两个环与报文缓冲区

split virtqueue 使用三个共享区域:描述符表保存缓冲区位置,available ring 发布可用的描述符链,used ring 归还已处理的链。两个环中的项可以循环复用,报文则保存在描述符指向的内存中。描述符表、环槽和报文缓冲区各有自己的地址与生命周期。

一条 TX 请求在 split virtqueue 中的关联
本例队列长度为 4,图示请求已完成、驱动尚未回收。环槽 3 保存链首 0,描述符链为 0 → 2,实际数据位于各自的客户机缓冲区;used.len 为 0 表示设备没有向这条发送链写入数据。
本例队列长度为 4,图示请求已完成、驱动尚未回收。环槽 3 保存链首 0,描述符链为 0 → 2,实际数据位于各自的客户机缓冲区;used.len 为 0 表示设备没有向这条发送链写入数据。 打开原图
Mermaid 源码
flowchart TB
    accTitle: Split virtqueue 中的一条发送链
    accDescr: 长度为四的队列中,可用环编号为三的槽保存链首编号零,描述符零通过 next 指向描述符二。两个描述符分别指向网络头和以太网帧;完成环以 id 零归还整条链,报文数据不存放在两个环中。
    A["可用环:驱动写、设备读<br/>avail.ring[3] = 0<br/>avail.idx = 4"]
    D0["desc[0]<br/>addr = 0x1000 · len = 12<br/>flags = NEXT · next = 2"]
    D2["desc[2]<br/>addr = 0x2000 · len = 60<br/>flags = 0 · 链结束"]
    H["客户机缓冲区 0x1000<br/>virtio-net 网络头 · 12 B"]
    P["客户机缓冲区 0x2000<br/>以太网帧 P1 · 60 B"]
    U["已用环:设备写、驱动读<br/>used.ring[3] = {id: 0, len: 0}<br/>used.idx = 4"]
    A -->|链首编号 0| D0
    D0 -->|next = 2| D2
    D0 -->|addr 指向数据| H
    D2 -->|addr 指向数据| P
    U -. id = 0:归还链首 .-> D0
    classDef ring fill:#e4f1ec,stroke:#438575,color:#173e34
    classDef desc fill:#eaf0f6,stroke:#8198ac,color:#263c4c
    classDef data fill:#f5f4f1,stroke:#a6aaa7,color:#3c4542
    class A,U ring
    class D0,D2 desc
    class H,P data
描述符表 · desc[N]
addr: u64 · len: u32 · flags: u16 · next: u16

每项 16 B。驱动填写设备可访问的地址和长度;NEXT 表示沿 next 继续读取链,WRITE 表示设备可写,INDIRECT 表示 addr 指向另一张描述符表。本例为设备只读的直接 TX 链。

可用环 · avail
flags: u16 · idx: u16 · ring[N]: u16

驱动写、设备读。ring 中每项保存链首描述符编号;一个请求即使使用两个描述符,也只发布一个链首。驱动先准备数据和描述符,再写 ring 项,通过内存屏障发布新的 idx。

已用环 · used
flags: u16 · idx: u16 · ring[N]: { id: u32, len: u32 }

设备写、驱动读。id 是原链首编号,len 是设备向该链的缓冲区写入的字节数。本例 TX 的 len 为 0,驱动仍可据此回收整条链。

实际数据缓冲区
desc[0].addr → 网络头;desc[2].addr → 报文 P1

链中的描述符编号可以不连续。驱动提交后保持描述符与数据有效,设备发布 used 后由驱动回收;设备私有的 last_avail_idx 和驱动私有的 last_used_idx 分别记录消费与回收进度。

上述字段展示共享格式的主体。启用 VIRTIO_F_RING_EVENT_IDX 时,可用区末尾还有 used_event,已用区末尾还有 avail_event,用于控制完成通知和 kick 的触发位置。未使用该特性时,通过对应 flags 抑制通知。modern virtio 的多字节共享字段使用小端序。Linux 的 vring 结构

1. 索引、环槽与描述符编号

idx 是 16 位递增计数器,访问数组时再计算 idx % N。对于长度为 4 的环,提交位置 3 对应 ring[3],下一次位置 4 对应 ring[0];计数器达到 65535 后回到 0。ring[3] 中可以保存链首编号 0,链首 0 又可以通过 next 指向描述符 2,三种数字不能互换。

操作共享状态私有进度与缓冲区
准备链 0 → 2填写两个 desc,网络头和 P1 就绪驱动保留这两个描述符,暂不允许复用
发布一个请求avail.ring[旧 idx % N] = 0,屏障后推进 avail.idx设备通过 avail.idx 与自己的消费位置判断新增项
设备取出请求按链首与 NEXT 读取描述符,used.idx 暂未变化设备推进 last_avail_idx;缓冲区继续由本次请求占用
设备发布完成used.ring[旧 idx % N] = {id: 0, len: 0},再推进 used.idx设备已用完缓冲区,驱动尚未回收
驱动回收不要求清零旧环项推进 last_used_idx,把链 0 → 2 归还空闲集合

生产与消费索引的差表示待处理数量,旧环项可以保留在数组里。回绕时按 16 位计算索引差,驱动同时跟踪尚未回收的描述符,保留设备仍在使用的请求。下面的演示可选择起始索引 0、3 或 65535,分别观察普通递增、环槽回绕与计数器回绕。

2. 两侧私有对象指向同一组共享内存

Linux 用 struct vring_virtqueue 管理一条队列,内部的 split.vring 保存 desc、avail、used 的客户机内核指针,其他字段记录空闲描述符和回收进度。这个对象也位于客户机 RAM 中,但属于驱动自己的管理数据。设备接口交给 QEMU 的是三个共享区域的地址,QEMU 按这些地址访问协议字段。

QEMU 在宿主进程中另建 VirtQueue,其中 VRing 保存共享区域的设备地址及访问缓存,handle_output 保存本队列的处理入口。两个私有对象采用各自的结构布局和地址视图,通过同一组 desc、avail、used 交换工作。Linux 私有队列对象、QEMU 私有队列对象

两侧私有队列对象与共享区域
图中箭头表示本例请求和完成的主要读写方向。Linux 的管理对象与共享队列都位于客户机 RAM;QEMU 的 VirtQueue 位于宿主进程内存,通过地址映射访问共享区域。通知抑制字段另按协议读写。
图中箭头表示本例请求和完成的主要读写方向。Linux 的管理对象与共享队列都位于客户机 RAM;QEMU 的 VirtQueue 位于宿主进程内存,通过地址映射访问共享区域。通知抑制字段另按协议读写。 打开原图
Mermaid 源码
flowchart TB
  accTitle: Linux 与 QEMU 的私有队列对象及共享区域
  accDescr: Linux 的 vring_virtqueue 位于客户机 RAM,记录驱动私有状态。共享描述符表、可用环和已用环也位于客户机 RAM。QEMU 的 VirtQueue 位于宿主进程内存,保存共享区域地址及设备私有状态。驱动发布描述符和可用项,设备读取后发布已用项;双方各自保存消费索引。
  subgraph guest[客户机 RAM]
    direction TB
    linux["Linux 私有 vring_virtqueue\nlast_used_idx:下一个待回收位置\nsplit.avail_idx_shadow:已发布进度"]
    subgraph shared[共享协议区域]
      direction LR
      request["desc 与 avail\n请求地址、长度与链头\navail.idx:发布进度"]
      completion["used\n完成链头与写入长度\nused.idx:完成进度"]
    end
    linux -->|填写描述符并发布请求| request
    completion -->|读取完成并回收| linux
  end
  qemu["QEMU 私有 VirtQueue · 宿主进程内存\nVRing:共享区域地址与访问缓存\nlast_avail_idx:下一个待取位置\nused_idx:已完成进度"]
  request -->|读取并消费请求| qemu
  qemu -->|写入完成| completion

消费进度由接收方私下保存:QEMU 的 last_avail_idx 记录下一个待取的 available 位置,Linux 的 last_used_idx 记录下一个待回收的 used 位置。生产方也保留本地进度,分别是 Linux 的 split.avail_idx_shadow 和 QEMU 的 used_idx,发布时更新共享 idx。沿用图中的 P1,若 avail.idx = last_avail_idx = used.idx = 4,而 last_used_idx = 3,后端已经处理完这条请求,驱动仍有一个完成待回收。Linux 发布 available、Linux 回收 used、QEMU 推进消费位置

3. 从共享描述符到 QEMU 本地对象

virtqueue_split_pop() 取到链首后检查描述符编号、链长度和可访问范围,将设备可读的段整理到 VirtQueueElement.out_sg,设备可写的段整理到 in_sg。元素的 index 保留链首,用于完成时写回 used.id。这些 iovec 是 QEMU 的宿主内存视图;它们与客户机共享的 desc[] 是不同对象。split 队列消费、VirtQueueElement 定义

本例把 modern virtio-net 的 12 B 网络头放在 desc[0],把 60 B 以太网帧放在 desc[2];两项均为设备可读。实际驱动也可把网络头与报文合并在一个缓冲区,或用更多描述符组织分散的数据。描述符链长度、报文数量和通知次数应分别计数。

四、逐步发送:发布 P1、后端读取与驱动回收

下图以报文 P1 为例,展示客户机准备缓冲区、设备处理和驱动回收的全过程。两种网卡使用相同的 TAP 后端,图中的变化集中在队列和通知机制。

一次发包的提交与完成

两种网卡都由 QEMU 处理报文,再写入同一个 TAP 后端。切换接口后,观察客户机内存中的队列、通知路径和完成记录。

客户机

驱动与客户机 RAM

e1000 驱动准备报文,提交发送队列
客户机 RAM
报文缓冲区
TX 描述符环buffer_addr · length · EOP · RS
完成记录DD 状态位
TDH
消费位置
TDT
提交位置

QEMU

宿主用户态

通知入口MMIO 寄存器回调
e1000 网卡模型读取描述符与报文,处理发送
网络后端接口qemu_send_packet同一 TAP,vhost=off

TAP

宿主内核

接收以太网帧

后续进入网桥、路由或其他宿主网络配置。

远端接收结果不在本次队列完成状态中。

Split virtqueue · 客户机共享内存

示例:链 0 → 2 已发布,设备尚未回写完成。

一个发送请求在 split virtqueue 中的完整关系 左侧可用环发布描述符头编号 0,中间描述符 0 的 next 指向描述符 2;addr 分别指向右侧网络头和报文。QEMU 读取这些缓冲区后向已用环写回 id 0、len 0。圆形表示数组循环使用,内存中的环槽实际连续排列。当前步骤和索引随演示更新。 客户机 virtio-net 驱动准备数据 → 发布 avail → 回收 used Available ring驱动写 · 设备读 slot 0head=0 slot 1— slot 2— slot 3— avail.idx1 下一槽 0 → 1 → 2 → 3 → 0 描述符表 · desc[4] 编号addrlenflagsnext 00x100012NEXT2 1———— 20x2000600— 3———— NEXT 客户机报文缓冲区 0x1000 · 12 Bvirtio-net header 0x2000 · 60 BP1 · 以太网帧数据不存放在环槽中 head=0 Used ring设备写 · 驱动读 slot 0— slot 1— slot 2— slot 3— used.idx0 下一槽 0 → 1 → 2 → 3 → 0 QEMU virtio-net 后端 · 本例 vhost=off读 avail → 解析链 → 读取数据 → TAP缓冲区用完后:used.id = 0,used.len = 0等待发布请求 读取链读取 P1 归还链 kick / call eventfd 只承载通知;描述符和报文保存在共享内存 环槽按数组顺序循环使用,链首编号与环槽号相互独立

箭头分别表示链首引用、next 链接和 addr 缓冲区引用。环图表示循环索引,实际共享内存中的槽是连续数组;默认完整呈现,放大后可横向滚动查看细节。

描述符表 · desc[4]2 / 4 个描述符可分配
编号addrlenflagsnext
00x100012NEXT (1)2
20x2000600—

编号 1、3 未用于本次请求。NEXT 指向描述符编号,addr 指向数据缓冲区;两者相互独立。

0x1000 · 网络头 · 12 Bdesc[0].addr → virtio-net header
0x2000 · P1 · 60 Bdesc[2].addr → 以太网帧
Available ring · 驱动写,设备读

avail.idx = 1

ring[0]head=0
ring[1]—
ring[2]—
ring[3]—

设备私有消费位置:last_avail_idx = 0

Used ring · 设备写,驱动读

used.idx = 0

ring[0]—
ring[1]—
ring[2]—
ring[3]—

驱动私有回收位置:last_used_idx = 0

本次环槽 = 0 % 4 = 0;链首为 desc[0]

本例没有 INDIRECT,TX 描述符均无 WRITE;一条链只占一个 available 项和一个 used 项。idx 按 16 位回绕,槽号按队列长度取模;回收后旧字段保留,是否有新项由索引判断。

客户机写好报文和描述符后提交队列;QEMU 读取客户机内存并发送到 TAP,随后写回完成状态,客户机驱动回收缓冲区。

  1. 提交:e1000 驱动填写 TX 描述符并写 TDT;virtio-net 驱动发布描述符链和 avail 项,按通知条件发送 kick。
  2. 发送:QEMU 消费队列,读取报文,经网络后端写入 TAP。
  3. 回收:e1000 按 RS 写回 DD;virtio-net 写入 used 项。驱动读取完成记录后回收本地缓冲区。

示例使用一个正常发送的报文、可写的 TAP 和允许的完成中断;e1000 描述符设置 EOP 与 RS,virtio-net 使用 split virtqueue。步骤拆分用于观察状态,播放速度不表示执行耗时。实际驱动常以批处理或轮询减少通知。

1. e1000 的发送路径

  1. 驱动准备 P1,填写发送描述符并设置 EOP、RS。完成必要的内存顺序操作后,写入新的 TDT。
  2. e1000_mmio_write()分派寄存器写操作;TDT 对应的 set_tctl()调用 start_xmit()。
  3. start_xmit()从 TDH 开始读取描述符。process_tx_desc()读取报文内容,并在遇到 EOP 时调用 xmit_seg()。
  4. 报文经 e1000_send_packet()和 QEMU 网络接口到达 TAP。正常可写路径最终执行 writev()。
  5. 模型按状态回写条件设置 DD、推进 TDH,并更新中断原因。客户机驱动检查完成记录后回收缓冲区。

这里的“DMA 读取”由 QEMU 软件执行。固定版本的 e1000 模型将报文读入自己的发送缓冲区,并在那里完成分段、校验和等模拟工作;这些工作量会受到客户机选择的功能影响。报文处理与状态回写、TDT 回调表

2. virtio-net 的发送路径

  1. 驱动准备网络设备头与 P1 的缓冲区,建立设备可读的描述符链,发布 available 项。
  2. 驱动检查通知条件,必要时通知对应的发送队列。QEMU 的队列处理函数安排发送任务。
  3. virtio_net_flush_tx()调用 virtqueue_pop()取出请求。通用 virtio 层验证描述符并建立 VirtQueueElement,其中 out_sg描述设备需要读取的客户机缓冲区。
  4. QEMU 整理网络头与 iovec,经 qemu_sendv_packet_async()交给 TAP 后端。可写时,TAP 直接接收;暂不可写时,后端排队等待继续发送。
  5. 同步完成路径或异步完成回调调用 virtqueue_push()发布 used 项,再检查是否需要通知客户机。驱动据此回收链和报文缓冲区。

使用 iovec 可以避免为每个请求都先拼成一块连续的用户态报文。实际复制次数还取决于客户机内存映射、网络头转换、TAP 和宿主协议栈等实现,不能仅凭 virtio 或 scatter-gather 接口推算端到端零拷贝。virtio-net 发送与异步完成

五、通知边界:kick、完成记录与中断

1. 发布队列与发送 kick

驱动先发布可用缓冲区,后端才能安全地消费。通知只提示设备检查队列;多个请求可以共享一次 kick,设备正在处理队列时也可以抑制新的 kick。队列发布需要正确的内存屏障,通知抑制还需要防止“设备准备休眠时恰好新增请求”的竞态。仅删除通知操作,会让后端失去唤醒机会。

设 avail.idx = last_avail_idx = 7,设备暂时关闭 kick,准备结束本轮处理。如果它检查空队列之后直接恢复通知并等待,驱动可能已在这两步之间发布第 8 项,却因为当时通知关闭而省略 kick。队列中有请求,事件循环却没有新通知可等。

新请求恰好遇到后端结束处理

正确顺序:恢复通知 → 屏障 → 复查队列 → 有请求则继续处理。

客户机驱动

准备发布新请求。

avail.idx = 7
QEMU 后端

已处理到索引 7。

last_avail_idx = 7

kick 暂时关闭;没有待处理的新请求。

索引是共享提交进度与后端私有消费进度;数值相等表示本例队列已空。演示只显示一个合法交错,不模拟真实线程调度或 CPU 内存模型。

安全路径先恢复通知,以必要的内存屏障约束顺序,再复查队列。第 8 项早到,复查会发现它;晚到,驱动按新的通知条件发出 kick,事件通知保持就绪,等待端继续处理。两端都需要遵守发布与检查规则,单独给某个计数器加一次屏障并不能替代完整协议。这里用 split 队列的通知开关演示;协商 EVENT_IDX 后,通知条件改用事件索引,重新启用与复查的要求仍然存在。QEMU 通知设置与顺序约束、发送处理的通知重启与复查

在普通 MMIO 路径中,客户机写 virtio PCI 通知地址,QEMU 的 virtio_pci_notify_write()找到队列后调用通知函数。启用 ioeventfd 时,QEMU 预先注册通知地址与 eventfd 的对应关系;KVM 处理匹配的写入并触发 eventfd,QEMU 的事件处理再调度该队列。这减少了通过 KVM_RUN返回用户态模拟此次 MMIO 的往返。CPU 从客户机执行切换到 KVM 的硬件退出仍可能发生,二者应分开计数。ioeventfd 注册、QEMU 事件处理、KVM_IOEVENTFD

2. 完成记录与完成中断

used 项先成为可见状态,再根据驱动配置决定是否发出中断。virtio_notify()内部会检查通知条件,调用它并不保证每个报文都对应一次中断。发送路径可以主动回收先前完成的请求,也可以在需要时使用中断;这为批处理提供了空间。完成通知检查。MSI-X 消息、虚拟 LAPIC 和驱动响应见virtio 完成通知与 KVM 中断注入。

完成记录界定的是缓冲区生命周期。virtio-net 发送链的 used.len可以为 0,因为设备没有向发送缓冲区写入数据;它仍然是一条有效的完成记录。驱动据此回收本地内存,远端报文接收和应用处理则由相应网络协议确认。

后端拥塞会暴露两种实现的细节差异。virtio-net 在异步排队后保留当前元素,等待发送回调再发布完成;e1000 模型已将客户机内容读入内部缓冲区,描述符处理与后端队列排空不必同步完成。此外,发送错误或丢弃路径也可能回收请求。因此观察 DD 或 used 时,还要结合设备统计和宿主网络状态判断发送结果。异步回调、TAP 暂不可写处理

六、源码路径:从网卡接口到 TAP

两条调用链在 QEMU 网络后端处汇合。阅读时可以分别标记“寄存器或队列状态”“客户机缓冲区访问”“后端发送”三个部分:

e1000
  e1000_mmio_write(TDT)
    set_tctl → start_xmit
      pci_dma_read(desc)
      process_tx_desc → pci_dma_read(packet)
        xmit_seg → e1000_send_packet
          qemu_send_packet → TAP
      txdesc_writeback → DD;推进 TDH

virtio-net(QEMU 后端)
  MMIO 通知或 ioeventfd 事件
    virtio_net_handle_tx_bh / virtio_net_handle_tx_timer
      virtio_net_flush_tx
        virtqueue_pop → VirtQueueElement.out_sg
        qemu_sendv_packet_async → TAP
        立即完成或 virtio_net_tx_complete
          virtqueue_push → used ring
          virtio_notify → 按条件通知客户机

TAP 后端
  tap_receive / tap_receive_iov
    tap_write_packet → writev(tap_fd, iov, iovcnt)

virtqueue_pop()中的 pop表示取出一个请求,客户机缓冲区仍须保持有效;到设备发布完成后,驱动才能回收。virtqueue_push()则包括写 used 项和更新索引,后者之前有相应的内存顺序保证。split ring 消费、完成发布

TAP 文件描述符连接宿主网卡接收路径

继续沿 P1 向下看:QEMU 的 tap_fd 是打开的字符设备文件句柄,宿主网络栈中的 TAP 网卡由另一个 net_device 对象表示。这里取 QEMU 通过 /dev/net/tun 创建普通 TAP、未启用 XDP 的路径。打开文件时,内核分配 tun_file 并放入 file->private_data;随后 TUNSETIFF 找到或创建接口,将这个文件与 tun_struct 关联,后者的 dev 指向 TAP 的 net_device。QEMU 打开与配置 TAP、文件与网卡对象、字符设备打开

P1 经过的位置此处完成的交接
QEMU:writev(tap_fd, iov, iovcnt)通过已打开的文件,把帧及已协商的 vnet 头交给宿主内核
字符设备:tun_chr_write_iter()从 file 找到 tun_file,再取得关联的 tun_struct,调用 tun_get_user()
TAP 接收:tun_get_user()解析元数据、形成 skb;以 tun->dev 作为接收网卡处理以太网头
宿主网络栈:netif_receive_skb()普通非 NAPI 路径经 tun_rx_batched() 到达这里,开始宿主侧的报文接收处理
QEMU 用户态发送 P1
  writev(tap_fd) → tun_chr_write_iter → tun_get_user
    tun_file → tun_struct → TAP net_device
    skb → tun_rx_batched → netif_receive_skb
      按宿主配置:桥接到其他端口 / 交给本机协议栈及路由

因此,客户机的 TX 在宿主 TAP 接口上表现为 RX。TAP 若加入 Linux bridge,报文可继续被桥转发;后续是否经过物理网卡取决于拓扑、目的地址和转发规则。固定版本中,QEMU 的普通 TAP 创建代码不设置 IFF_NAPI,上述 x86 路径经 tun_rx_batched() 调用 netif_receive_skb()。预先配置 NAPI 的 TAP 可以进入另一条排队与 NAPI/GRO 路径;外部传入 FD 或 /dev/tap<ifindex> 接口也需单独确认。writev 对应的写入口、接收分支、普通 TAP 的宿主接收入口

追踪本例时,先确认客户机设备型号、QEMU 网络后端和 vhost 开关。vhost 关闭时,可以关联 QEMU 的 virtio_queue_notify、virtqueue_pop和 virtio_notify等 trace 事件,观察通知、取请求与完成通知的顺序。启用 vhost 后,消费进度要到对应后端观察。报文数量可与这些事件分别计数,用来判断一次通知承接了多少工作。

七、队列格式与后端位置的后续优化

本例的 P1 经历三类成本:描述符与环的内存访问,通知与线程调度,以及向 TAP 交付数据。后续优化分别作用于这些位置,客户机接口、队列格式与后端执行者需要分开比较。

变化针对的成本新增约束
Split → Packed将发布与完成集中到描述符环,调整元数据访问局部性双方协商格式,正确处理 wrap、完成 ID 与事件位置
QEMU → vhost-net内核消费队列,减少 QEMU 逐包处理和交接内核工作项、队列与 TAP 的配置和调度
QEMU → vhost-user由外部进程安排专用的数据处理路径共享内存、后端断连及迁移时的状态交接

具体机制分别见Packed 队列、vhost-net 与 vhost-user。使用 DPDK 的后端还会把 P1 转换为 mbuf,形成客户机缓冲区与物理网卡缓冲区的两次交接,见DPDK 转发与缓冲区管理。这些改变都不自动保证零拷贝或更低尾延迟,仍需核对实际数据路径。

八、瓶颈定位与优化效果验证

回到 P1:一次成功处理依次推进 avail.idx、后端消费位置、used.idx 和驱动回收位置。四个位置停在哪里,给出了排查起点;只观察报文吞吐或中断次数,无法区分队列等待与后端拥塞。

现象解释与检查改进与代价
avail 增长,后端消费落后检查 kick 是否到达、QEMU 发送任务调度和 CPU 占用批处理、ioeventfd 或独立后端可以减少调度往返;批量过大也会延后单个报文处理
已取出请求,used 迟迟不推进检查 TAP 可写状态、异步发送回调与后端排队增加出口能力或调整排队策略;仅增加 virtqueue 深度会占用更多内存并延长等待
used 已推进,客户机没有回收检查完成通知条件、客户机中断或主动回收路径通知合并减少中断,轮询降低唤醒等待;两者都要兼顾空闲 CPU 消耗与尾延迟

e1000 的兼容性便于使用已有驱动,virtio 的通用队列便于扩展和替换后端。性能比较应同时记录每秒报文数、吞吐、尾延迟、宿主 CPU 和丢包,固定包长、队列数与卸载配置;某一项开销下降,仍需确认它没有转移到另一个线程或增加排队时间。

九、参考资料

  • QEMU 10.2.0:e1000.c,寄存器访问、TX 描述符与状态回写;virtio-net.c,用户态发送路径、网络头与异步完成。
  • QEMU 10.2.0:virtio.c,队列消费、完成发布与通知;virtio-pci.c,PCI 通知区及 ioeventfd。
  • QEMU 10.2.0:tap.c,TAP 数据提交与暂不可写处理;net.c,网络队列及发送分派。
  • Linux 6.18 tun.c:TAP 字符设备、tun_file 与 net_device 的关联,以及用户态写入到宿主接收路径的交接。
  • Linux 6.18 KVM_IOEVENTFD:客户机 PIO / MMIO 写与 eventfd 的关联。
  • Linux 6.18 virtio_ring.h:描述符、可用环、已用环及 packed 格式;virtio_ring.c:驱动发布、通知检查与回收。
  • VIRTIO 1.2 规范:split virtqueue、驱动通知和网络设备发送约定;VIRTIO 1.1 规范:split 与 packed 队列格式。