QEMU 网卡模拟与 virtio-net 的发包路径
一、虚拟网卡的发包流程
客户机准备好一个 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 与宿主内核传递这些元数据,具体长度和能力需要协商。发送头与缓冲区整理
| 交互 | e1000 | virtio-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 归还已处理的链。两个环中的项可以循环复用,报文则保存在描述符指向的内存中。描述符表、环槽和报文缓冲区各有自己的地址与生命周期。
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
addr: u64 · len: u32 · flags: u16 · next: u16每项 16 B。驱动填写设备可访问的地址和长度;NEXT 表示沿 next 继续读取链,WRITE 表示设备可写,INDIRECT 表示 addr 指向另一张描述符表。本例为设备只读的直接 TX 链。
flags: u16 · idx: u16 · ring[N]: u16驱动写、设备读。ring 中每项保存链首描述符编号;一个请求即使使用两个描述符,也只发布一个链首。驱动先准备数据和描述符,再写 ring 项,通过内存屏障发布新的 idx。
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 私有队列对象
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 后端。切换接口后,观察客户机内存中的队列、通知路径和完成记录。
e1000 通过写 TDT 寄存器提交;通知选项用于 virtio-net。
客户机
驱动与客户机 RAM
客户机 RAM
buffer_addr · length · EOP · RSDD 状态位- TDH
- 消费位置
- TDT
- 提交位置
QEMU
宿主用户态
qemu_send_packet同一 TAP,vhost=offTAP
宿主内核
后续进入网桥、路由或其他宿主网络配置。
远端接收结果不在本次队列完成状态中。
Split virtqueue · 客户机共享内存
示例:链 0 → 2 已发布,设备尚未回写完成。
箭头分别表示链首引用、next 链接和 addr 缓冲区引用。环图表示循环索引,实际共享内存中的槽是连续数组;默认完整呈现,放大后可横向滚动查看细节。
| 编号 | addr | len | flags | next |
|---|---|---|---|---|
| 0 | 0x1000 | 12 | NEXT (1) | 2 |
| 2 | 0x2000 | 60 | 0 | — |
编号 1、3 未用于本次请求。NEXT 指向描述符编号,addr 指向数据缓冲区;两者相互独立。
Available ring · 驱动写,设备读
avail.idx = 1
设备私有消费位置:last_avail_idx = 0
Used ring · 设备写,驱动读
used.idx = 0
驱动私有回收位置:last_used_idx = 0
本次环槽 = 0 % 4 = 0;链首为 desc[0]
本例没有 INDIRECT,TX 描述符均无 WRITE;一条链只占一个 available 项和一个 used 项。idx 按 16 位回绕,槽号按队列长度取模;回收后旧字段保留,是否有新项由索引判断。
客户机写好报文和描述符后提交队列;QEMU 读取客户机内存并发送到 TAP,随后写回完成状态,客户机驱动回收缓冲区。
- 提交:e1000 驱动填写 TX 描述符并写 TDT;virtio-net 驱动发布描述符链和 avail 项,按通知条件发送 kick。
- 发送:QEMU 消费队列,读取报文,经网络后端写入 TAP。
- 回收:e1000 按 RS 写回 DD;virtio-net 写入 used 项。驱动读取完成记录后回收本地缓冲区。
示例使用一个正常发送的报文、可写的 TAP 和允许的完成中断;e1000 描述符设置 EOP 与 RS,virtio-net 使用 split virtqueue。步骤拆分用于观察状态,播放速度不表示执行耗时。实际驱动常以批处理或轮询减少通知。
1. e1000 的发送路径
- 驱动准备 P1,填写发送描述符并设置
EOP、RS。完成必要的内存顺序操作后,写入新的TDT。 e1000_mmio_write()分派寄存器写操作;TDT 对应的set_tctl()调用start_xmit()。start_xmit()从 TDH 开始读取描述符。process_tx_desc()读取报文内容,并在遇到 EOP 时调用xmit_seg()。- 报文经
e1000_send_packet()和 QEMU 网络接口到达 TAP。正常可写路径最终执行writev()。 - 模型按状态回写条件设置 DD、推进 TDH,并更新中断原因。客户机驱动检查完成记录后回收缓冲区。
这里的“DMA 读取”由 QEMU 软件执行。固定版本的 e1000 模型将报文读入自己的发送缓冲区,并在那里完成分段、校验和等模拟工作;这些工作量会受到客户机选择的功能影响。报文处理与状态回写、TDT 回调表
2. virtio-net 的发送路径
- 驱动准备网络设备头与 P1 的缓冲区,建立设备可读的描述符链,发布 available 项。
- 驱动检查通知条件,必要时通知对应的发送队列。QEMU 的队列处理函数安排发送任务。
virtio_net_flush_tx()调用virtqueue_pop()取出请求。通用 virtio 层验证描述符并建立VirtQueueElement,其中out_sg描述设备需要读取的客户机缓冲区。- QEMU 整理网络头与 iovec,经
qemu_sendv_packet_async()交给 TAP 后端。可写时,TAP 直接接收;暂不可写时,后端排队等待继续发送。 - 同步完成路径或异步完成回调调用
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已处理到索引 7。
last_avail_idx = 7kick 暂时关闭;没有待处理的新请求。
索引是共享提交进度与后端私有消费进度;数值相等表示本例队列已空。演示只显示一个合法交错,不模拟真实线程调度或 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 队列格式。