1. 传统 Linux 网络栈的收包流程
1.1 网卡接收(NIC DMA)
当数据包到达网卡:
- 网卡的 PHY/MAC 硬件 从物理线路上接收到以太网帧,并暂存在网卡内部的短时缓冲(on-NIC buffer)中;
- 同时,网卡根据驱动提前设置好的 RX 描述符(Descriptor Ring) ,找到主机内存中可用的缓冲区地址,为后续的 DMA 写入 做准备;
- 这一步仍然 没有触碰 CPU,数据尚未写入主机内存,只是完成了接收和 DMA 目标的准备工作。
DMA(Direct Memory Access) :允许外设(如网卡)在不经过 CPU 的情况下直接读写主机内存。网卡会在接收完成后,通过 DMA 将包内容搬运至这些缓冲区中。
1.2 中断通知(Interrupt)
包到达后,网卡触发一个 硬件中断 (IRQ) ,告诉 CPU:
"嘿,有新包到了,快来处理!"
内核会暂停当前运行的任务,切换到中断上下文,调用对应的网卡驱动函数进行处理。
但这一步非常昂贵,因为:
- 要从用户态切换到内核态;
- 要保存/恢复寄存器和上下文;
- 频繁中断时会让 CPU 负担极重。
于是后来 Linux 引入了 NAPI(New API) ,通过"中断 + 轮询混合"来减少中断风暴,简单来讲就是,平时靠中断唤醒,繁忙时改为轮询收包。
1.3 驱动层:把数据交给内核协议栈
驱动层会把 DMA 缓冲区里的包,交给内核的网络协议栈。
通常会放入 skb
(socket buffer)结构中,供上层协议处理。
在这一步中发生:
- 数据拷贝 :从驱动缓冲区 →
skb
; - 内存分配 :为每个包分配
skb
; - 软中断(softirq) :触发
NET_RX_SOFTIRQ
,进入协议栈流程。
这里的"拷贝"是第一个主要性能瓶颈。
1.4 协议栈处理:从 L2 到 L4
接下来,数据进入内核网络协议栈:
arduino
Ethernet → IP → TCP/UDP → Socket
每一层都要:
- 检查协议头;
- 计算校验和;
- 修改元数据;
- 调用不同的处理函数。
这些操作需要锁保护、缓存同步、以及跨 CPU 核的数据结构访问(尤其在多核系统上)。
这一步的延迟,通常比前面的 DMA + 中断更高。
1.5 Socket 层:复制到用户空间
最终,当内核处理完协议栈逻辑后,
通过系统调用(如 recvfrom()
/ read()
)把数据从内核缓冲复制到用户态缓冲区。
这就是第二次拷贝。
第一次拷贝:DMA → 内核缓冲
第二次拷贝:内核缓冲 → 用户缓冲
每次系统调用都要切换上下文,并触发 CPU 缓存失效。
我们真的需要让每个数据包都经过内核协议栈、系统调用和两次拷贝吗?如果我们能让应用程序直接访问网卡数据,绕过这些环节,是否就能让收发更快?
这正是 DPDK(Data Plane Development Kit)诞生的初衷。
2. DPDK极致的优化思路
2.1 初始化阶段:用户态直接接管硬件
DPDK 应用启动时,首先会做三件事:
-
申请一大块巨页内存(HugePage)
这块内存是物理连续的,通常以 2MB 或 1GB 为单位。
DPDK 会把它划分成一个个固定大小的缓冲块(
mbuf
),用来接收网卡 DMA 写入的数据。巨页的意义在于:
- 提升 TLB 命中率(减少页表查找);
- 提供连续的物理内存供 DMA 使用;
- 减少内存碎片与锁竞争。
-
通过 VFIO/UIO 映射网卡寄存器
这一步绕过内核驱动,让用户态能直接操作网卡的寄存器空间。
换句话说,DPDK 直接和硬件"对话",不再通过
ethX
这种内核抽象。 -
建立收包队列与内存池的映射
每个 RX 队列有若干个 描述符(Descriptor) ,描述一个 DMA 可写入的缓冲地址(来自上面的 mbuf pool)。
当网卡准备接收数据时,会直接写入这些缓冲区。
这一阶段的结果是:
用户态程序已经预先准备好一批可写的内存,并且网卡知道这些物理地址。
一旦包到来,网卡就能直接把数据写进去,而不需要内核过问。
2.2 网卡接收与 DMA 写入
当一个数据包从线缆进入网卡时,流程如下:
-
MAC 层接收
PHY 接收电信号,MAC 层解析以太网帧头。包数据暂存在网卡内部的短时缓存(on-NIC buffer)中。
-
DMA 准备
网卡根据 RX 描述符中的物理地址,找到可以写入的主机内存区域(即 mbuf)。
-
DMA 传输
网卡启动 DMA 引擎,直接把包数据写入那块用户态可访问的巨页内存中。
这一步没有 CPU 参与,也没有拷贝。
数据的"第一次落地"就已经在用户态内存中了。
-
更新描述符状态
网卡写完后,会把该描述符标记为"Done",等待软件读取。
当应用调用:
ini
nb_rx = rte_eth_rx_burst(port_id, queue_id, rx_pkts, MAX_BURST);
发生的事情非常简单:
- 驱动只是读取 RX Ring 中的描述符;
- 把其中的 mbuf 指针(指向 Hugepage 内的物理内存)直接交还给用户;
- 应用程序直接访问
rx_pkts[i]->buf_addr
,这是 DMA 写入的原始数据地址。
没有任何拷贝、没有 memcpy、没有 socket 缓冲。
到这里,一个包已经安全地从线缆进到了应用的内存池中。
没有 skb
、没有中断、也没有上下文切换------这是 DPDK 零拷贝哲学的核心。
2.3 轮询代替中断:CPU 亲自取包
传统驱动靠中断通知 CPU:
"嘿,有新包来了!"
但 DPDK 不这么做。
DPDK 使用 Poll Mode Driver(PMD) ,让一个或多个固定的逻辑核心(lcore)持续轮询网卡 RX 队列:
scss
while (1) {
nb_rx = rte_eth_rx_burst(port_id, queue_id, bufs, BURST_SIZE);
process_packets(bufs, nb_rx);
}
这段循环做了两件事:
- 读取网卡描述符,判断哪些缓冲区里已经有新数据;
- 直接把这些
mbuf
指针交给上层应用处理。
CPU 不再陷入中断上下文,不再切换任务,而是专职收包 。
虽然轮询意味着 CPU 时刻在跑,但它的缓存局部性极好,延迟可预测,PPS 能成倍提升。
💡 换个角度:
DPDK 是"用 CPU 的持续忙碌,换取网络的极致低延迟"。
2.4 CPU 亲和与 NUMA 优化:让数据不跨区
DPDK 对性能的另一大优化,是利用 CPU 亲和(Affinity) 与 NUMA 感知(NUMA-aware allocation) 。
- 每个收包核心(lcore)会绑定到固定 CPU 核;
- 该核心使用的内存池(mempool)会优先分配在该 CPU 所属的 NUMA 节点上;
- 对应的 RX 队列,也映射到同一 NUMA 节点的网卡接口上。
这样,包从网卡 DMA 到 CPU 处理,全程都在同一个 NUMA 区域内完成,
避免了跨节点内存访问带来的 50~100ns 延迟差距。
类比:
像在一个工厂中,仓库(内存池)、装配线(RX 队列)和工人(CPU 核)都在同一栋楼内,效率自然最高。
2.5 多队列与多核并行:吞吐的关键
现代网卡几乎都支持 RSS(Receive Side Scaling) 。
DPDK 可以让网卡基于五元组(源/目的 IP、端口、协议)把不同流量分发到不同 RX 队列:
- 每个 RX 队列绑定一个 lcore;
- 每个 lcore 独立轮询、处理;
- 无需加锁、无缓存竞争。
这种天然的多核并行,让 DPDK 的吞吐可以随 CPU 核心数线性扩展。
2.6 总结
通过前面的分析,我们看到 DPDK 收包的路径极为简洁:
网卡 → DMA 写入用户态缓冲 → 用户态轮询 → 应用处理
没有内核协议栈、没有系统调用、没有中断,也没有多余拷贝。每一环节都为了一个目标:最大化吞吐,最小化延迟。
这让我联想到操作系统的设计哲学:
- 宏内核(Monolithic Kernel) :内核负责所有服务,像 Linux 传统网络栈一样,每一个包都要经过内核协议栈、系统调用和多层拷贝。优点是功能完备、兼容性高;缺点是路径长、开销大,性能不可预测。
- 微内核(Microkernel) :内核只保留最基本功能,服务在用户态运行,内核只做最小调度。优点是灵活、可控,缺点是上下文切换和 IPC 开销。
DPDK 可以被看作是一种极端微内核化思想的网络延伸:
- 它把传统内核的网络协议栈"搬到用户态",甚至把驱动逻辑也搬到用户态;
- CPU 不再频繁切换,应用程序直接与网卡对话;
- 巨页、轮询、多队列、NUMA 亲和等技术则保证了用户态处理的效率和可扩展性。
换句话说,DPDK 的设计哲学是:
舍弃通用性和安全性,用极致的用户态控制换取网络数据平面性能。
如果把 Linux 内核比作"功能齐全的高速公路",DPDK 就像一条专门为赛车设计的直达高速通道 :
没有红绿灯、没有拥堵、没有多余环节,数据包从网卡直接驶入用户态,飞快地抵达应用。