"骐骥一跃,不能十步;驽马十驾,功在不舍。" ------ 荀子
DPDK高性能的原理
众所周知,我们用 Java、Go 或 Python 编写的应用都跑在用户态。但网络数据包的'旅程'却很曲折:无论是 REST 还是其他请求,数据都得先过网卡驱动进入内核态,再由内核'摆渡'给用户态程序。这种内核/用户态的反复横跳不仅带来了昂贵的上下文切换开销,还涉及多次数据拷贝。在高频 IO 场景下,这些'看不见'的系统损耗往往就是拖慢程序响应的元凶,下图是数据包进入用户态应用程序的流程图。
DPDK 框架通过 UIO (Userspace I/O) 或 VFIO 驱动屏蔽了标准的内核协议栈,将网卡控制权直接接管至用户态。利用 DMA(直接内存访问) 技术配合大页内存(Hugepages) ,DPDK 实现了真正的**零拷贝(Zero-copy)**机制。数据包无需经过内核协议栈的层层拆解,而是直接从物理网卡投送至预分配的用户态内存空间,从而彻底消除了上下文切换与数据搬运的性能损耗。流程图如下:
虽然数据包通过上述方式直接进入到了用户态节省了很多开销,但是由于没有进入内核没法使用内核的function比如listen 、accept 、recv 和send,并且由于是直接进入的内核态这意味着程序员需要自己实现协议栈的交互例如arp的request、reply;ICMP或者TCP协议栈,甚至为了高性能还得维护自己的arp table和路由表,这让开发成本变得困难起来。
Java、Go 等语言的 HTTP 框架之所以好用,是因为内核帮我们扛下了所有:epoll 监听、TCP 三次握手、数据重组,我们只需要 recv 和 send 纯净的业务数据。但如果老板要求换成 DPDK,那情况就完全不同了。由于绕过了内核,你面对的是网卡直接甩过来的原始二进制流(Raw Packets)。你得像个'协议栈架构师'一样,亲手写代码去拆解以太网头、处理 TCP 序列号、重组分片,甚至连 HTTP 的 Keep-Alive 都要自己管理。这相当于从'开现成的跑车'变成了'手搓一台发动机。
DPDK 并非孤岛,它支持通过虚拟网口与内核通信。如果你已经搞定了 TCP 状态机,但在回复客户端时不想折腾复杂的底层路由发现,完全可以'偷个懒':把包封好后,直接甩给内核网口。内核会像往常一样帮你贴上 IP 头和以太网头,完成最后的递送。这种**'用户态处理核心业务,内核态兜底基础协议'**的模式,极大地降低了高性能网关的开发门槛,但是这种方式也会造成一些开销。
DPDK代码初始化
这里我们不展开DPDK的安装,默认各位已经部署好了dpdk环境,本文以及后续都采用19.11.14版本的dpdk框架去实现功能
初始化EAL
inr ret = rte_eal_init(argc, argv)
首先我们需要通过rte_eal_init(argc, argv)函数去初始化DPDK的环境
检查网卡
c
if (rte_eth_dev_count_avail() == 0) {
rte_exit(EXIT_FAILURE, "No available ports\n");
}
当环境初始化好之后,调用rte_eth_dev_count_avail查看目前多少网口被dpdk接管,这里可以针对rte_eth_dev_count_avail的返回做一个遍历为每个网口设置一个编号用于后续指定从哪个网口接受或发送数据包。
初始化网卡硬件
C
struct rte_eth_dev_info dev_info;
rte_eth_dev_info_get(portid, &dev_info);
定义rte_eth_dev_info类型结构体变量,调用rte_eth_dev_info_get第一个参数是网口id
打印硬件信息
一般获取完成网口信息后,我都习惯性的打印出来,以下代码是采用print方式打印,后续可以改成dpdk的rte_log方式打印,这样不与内核交互,开销会减小
C
struct rte_ether_addr mac_addr;
rte_eth_macaddr_get(port_id, &mac_addr);
printf("\n============================================\n");
printf("检测到网卡端口 ID: %d\n", port_id);
printf("驱动名称: %s\n", dev_info->driver_name);
printf("PCI 地址: %s\n", dev_info->device->name);
printf("MAC 地址: %02X:%02X:%02X:%02X:%02X:%02X\n",
mac_addr.addr_bytes[0], mac_addr.addr_bytes[1],
mac_addr.addr_bytes[2], mac_addr.addr_bytes[3],
mac_addr.addr_bytes[4], mac_addr.addr_bytes[5]);
printf("配置队列: RX=%u, TX=%u\n", rx_q, tx_q);
printf("============================================\n\n");
定义队列
什么是多队列
简单来说,多队列就是给网卡开了'多扇大门'。以前所有流量都走一个门,CPU 根本忙不过来。在 DPDK 里,我们可以为每个网口配置多个收发队列,让不同的 CPU 核心各自负责一个队列,'互不干扰'。数据包进来时,网卡会自动做个 Hash 计算 ,把同一个连接的包送到同一个队列(即 RSS 机制)。这样不仅能利用多核并行处理,还能保证数据包的顺序性,不会让 TCP 连接因为乱序而崩溃。
网卡的队列数量不是程序员自定定义的,在初始化网卡硬件时我们获取了指定网卡的信息,struct rte_eth_dev_info中有两个成员属性分别是max_rx_queues和max_tx_queues,这是该网卡支持的最大接受和发送的队列数量
当我们知道队列数量后,我们就可以去遍历max_rx_queues和max_tx_queues然后使用rte_eth_rx_queue_setup和rte_eth_tx_queue_setup为每一个队列去设置参数,代码如下:
C
for (uint16_t i = 0; i<self->nb_rx_queue; i++) {
if (rte_eth_rx_queue_setup(port_id, i, nb_rxd, rte_eth_dev_socket_id(port_id), NULL, self->mp)<0){
rte_exit(EXIT_FAILURE, "rte_eth_rx_queue_setup setup faild");
break;
};
}
for (uint16_t i = 0; i<self->nb_tx_queue; i++) {
if (rte_eth_tx_queue_setup(port_id, i, nb_rxd, rte_eth_dev_socket_id(port_id), NULL)<0){
rte_exit(EXIT_FAILURE, "rte_eth_tx_queue_setup setup faild");
break;
};
}
为什么 DPDK 在设置队列时非要调用 rte_eth_dev_socket_id?简单来说,这是为了适配 NUMA 架构 。网卡插在主板上,其实是离某个 CPU 核心更'近'的。通过这个函数,我们能找到网卡所在的 CPU 插槽号,然后把该队列的内存也申请在同一个插槽对应的内存条上。这样 CPU 读写数据包就像'在家门口办事',不用跑去远处的内存条拿数据,性能自然提升了一大截。因此在开发时程序员还需要去确认cpu的数量以及网卡所在哪一个cpu插槽
配置网卡
C
struct rte_eth_conf port_conf = {0};
int ret = rte_eth_dev_configure(port_id, rx_q, tx_q, &port_conf);
if (ret < 0) {
rte_exit(EXIT_FAILURE, "Cannot configure device: err=%d, port=%u\n", ret, port_id);
}
简单来说,struct rte_eth_conf 是你给网卡下的'任务书'。比如你告诉它:'我要开启 RSS 分流,把流量均匀切分'。然后通过 rte_eth_dev_configure 把这套配置应用到具体的网口 ID 上。在 NUMA 多核环境下,这招非常管用------网卡负责把包'平摊'到不同的 CPU 队列,每个核心只需要管好自己那份活儿,大家各司其职并行处理,性能上限瞬间就被拉开了。
创建内存池
C
struct rte_mempool *mp = rte_pktmbuf_pool_create(
poolname, mbuf_number, 256, 0,
RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id()
);
如果说网口是门,那么 rte_pktmbuf_pool_create 创建的内存池就是用来装快递的'托盘堆栈'。在 DPDK 里,我们不再动态申请内存,而是启动时就一把梭申请好几万个 mbuf 备用,这里需要注意的是如果是numa架构且存在多张网卡,socket_id不能使用rte_socket_id()传递,而是遍历网口并使用rte_eth_dev_socket_id(port_id)传递
开启硬件接受
当上述配置都配置完毕后,即可使用int rte_eth_dev_start(uint16_t port_id);接受数据包,注意如果存在多个网口也需要遍历网口接受数据包。
以上就算初始化的步骤,我们可以自定义一个device类去封装方法,这样显得更加优雅,如何封装就不展开了,直接贴代码
c
/* device.h */
#ifndef DEVICE_H
#define DEVICE_H
#include <stdint.h>
#include <rte_ethdev.h>
typedef struct Device Device;
struct Device{
uint16_t port_id;
struct rte_mempool *mp;
uint16_t nb_rx_queue;
uint16_t nb_tx_queue;
};
// methods
// 第一个参数传入 self 指针,模拟 C++ 的 this
Device* device_create(struct rte_eth_dev_info *dev_info,const char* poolname,unsigned int mbuf_bumber,int port_id);
void queue_setup(Device *self,int port_id,uint16_t nb_rxd);
#endif
c
/* device.c */
#include <rte_ethdev.h>
#include "device.h"
Device* device_create(struct rte_eth_dev_info *dev_info, const char* poolname, unsigned int mbuf_number, int port_id) {
Device *self = malloc(sizeof(struct Device));
// 1. 确定队列数量
uint16_t rx_q = 4;
if (rx_q > dev_info->max_rx_queues) rx_q = dev_info->max_rx_queues;
uint16_t tx_q = 4;
if (tx_q > dev_info->max_tx_queues) tx_q = dev_info->max_tx_queues;
// 2. 获取并打印网卡硬件信息
struct rte_ether_addr mac_addr;
rte_eth_macaddr_get(port_id, &mac_addr);
printf("\n============================================\n");
printf("检测到网卡端口 ID: %d\n", port_id);
printf("驱动名称: %s\n", dev_info->driver_name);
printf("PCI 地址: %s\n", dev_info->device->name);
printf("MAC 地址: %02X:%02X:%02X:%02X:%02X:%02X\n",
mac_addr.addr_bytes[0], mac_addr.addr_bytes[1],
mac_addr.addr_bytes[2], mac_addr.addr_bytes[3],
mac_addr.addr_bytes[4], mac_addr.addr_bytes[5]);
printf("配置队列: RX=%u, TX=%u\n", rx_q, tx_q);
printf("============================================\n\n");
// 3. 配置网卡
struct rte_eth_conf port_conf = {0};
int ret = rte_eth_dev_configure(port_id, rx_q, tx_q, &port_conf);
if (ret < 0) {
rte_exit(EXIT_FAILURE, "Cannot configure device: err=%d, port=%u\n", ret, port_id);
}
// 4. 创建内存池
self->mp = rte_pktmbuf_pool_create(
poolname, mbuf_number, 256, 0,
RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id()
);
self->nb_rx_queue = rx_q;
self->nb_tx_queue = tx_q;
if (self->mp == NULL) {
rte_exit(EXIT_FAILURE, "rte_pktmbuf_pool_create failed: %s\n", rte_strerror(rte_errno));
}
return self;
}
void queue_setup(Device *self,int port_id,uint16_t nb_rxd){
for (uint16_t i = 0; i<self->nb_rx_queue; i++) {
if (rte_eth_rx_queue_setup(port_id, i, nb_rxd, rte_eth_dev_socket_id(port_id), NULL, self->mp)<0){
rte_exit(EXIT_FAILURE, "rte_eth_rx_queue_setup setup faild");
break;
};
}
for (uint16_t i = 0; i<self->nb_tx_queue; i++) {
if (rte_eth_tx_queue_setup(port_id, i, nb_rxd, rte_eth_dev_socket_id(port_id), NULL)<0){
rte_exit(EXIT_FAILURE, "rte_eth_tx_queue_setup setup faild");
break;
};
}
}
接受数据包
网卡启动后,我们就得写一个'死循环'不停地去巡视。调用 rte_eth_rx_burst 就像是拿着筐去指定的网口和队列'收快递'。你给它一个数组(rx_pkts)和期望收到的上限(nb_pkts),它会立刻告诉你这次实际装满了多少个(count)。如果没有包,它会瞬间返回 0 而不是在那傻等。这种'快进快出'的批量模式,是 DPDK 能够处理每秒千万级数据包的秘密武器。
C
#define BURST_SIZE 32
struct rte_mbuf *bufs[BURST_SIZE];
uint16_t nb_rx = rte_eth_rx_burst(0, 0, bufs, BURST_SIZE);
解析数据包
拿到 mbuf 之后,我们就得开始'剥洋葱'了。首先,你要调用 rte_pktmbuf_mtod 把这一块二进制数据强制转换成以太网结构体。接着看它是 IPv4 还是 IPv6,然后指针往后挪 14 个字节(以太网头长度),去解析 IP 头;再根据协议号往后挪,去摸 TCP 或 UDP 头。这就像是在内存里寻宝,你必须精准掌握每一层协议的字节长度,错位一个字节,后面的数据就全是乱码了。
地址转换 (mtod) : 这是第一步,将 mbuf 转换成你可以操作的结构体指针。
c
struct rte_ether_hdr *eth_hdr = rte_pktmbuf_mtod(m, struct rte_ether_hdr *);
字节序转换 (Endianness) : 网络传输用的是大端序(Big-Endian) ,而你的 CPU(通常是 x86)是小端序(Little-Endian) 。解析端口号或 IP 地址时,千万别忘了用 rte_be_to_cpu_16() 等函数转换,否则数值会翻天覆地。
层级跳转逻辑:
- L2 (Ethernet) : 固定 14 字节(如果不带 VLAN)。
- L3 (IP) : 长度不固定(看
ihl字段),通常是 20 字节。 - L4 (TCP/UDP) : TCP 头长度也不固定(看
data offset)。
硬件加速提示 : 如果你不想手动算偏移,DPDK 的网卡硬件通常会提供 packet_type。你可以直接读取 m->packet_type 来判断它是 IPv4 还是 UDP,这样能省掉好几次内存读取。
c
#include <rte_ether.h>
#include <rte_ip.h>
#include <rte_udp.h>
#include <rte_tcp.h>
void process_package(struct rte_mbuf *m) {
// 1. 定位以太网头 (L2)
// rte_pktmbuf_mtod 会把 mbuf 的数据起始地址强制转换为结构体指针
struct rte_ether_hdr *eth_hdr = rte_pktmbuf_mtod(m, struct rte_ether_hdr *);
// 检查是否为 IP 报文 (注意字节序转换:网络序转主机序)
if (rte_be_to_cpu_16(eth_hdr->ether_type) != RTE_ETHER_TYPE_IPV4) {
return;
}
// 2. 跳过以太网头,定位 IP 头 (L3)
// 指针偏移:起始地址 + 以太网头长度 (14字节)
struct rte_ipv4_hdr *ipv4_hdr = (struct rte_ipv4_hdr *)(eth_hdr + 1);
// 打印源 IP 和 目的 IP (使用网包自带的大端序转为字符串)
// 提示:生产环境建议用硬件分流,此处仅演示逻辑
uint32_t src_ip = rte_be_to_cpu_32(ipv4_hdr->src_addr);
uint32_t dst_ip = rte_be_to_cpu_32(ipv4_hdr->dst_addr);
// 3. 根据 IP 协议号定位传输层 (L4)
if (ipv4_hdr->next_proto_id == IPPROTO_UDP) {
// 计算 UDP 头地址:IP 头地址 + IP 头长度
// 注意:IP 头长度是动态的,通常为 (ihl & 0x0f) * 4
uint16_t ip_hdr_len = (ipv4_hdr->version_ihl & 0x0f) * 4;
struct rte_udp_hdr *udp_hdr = (struct rte_udp_hdr *)((unsigned char *)ipv4_hdr + ip_hdr_len);
uint16_t src_port = rte_be_to_cpu_16(udp_hdr->src_port);
uint16_t dst_port = rte_be_to_cpu_16(udp_hdr->dst_port);
printf("收到 UDP 包: %u.%u.%u.%u:%u -> %u.%u.%u.%u:%u\n",
(src_ip >> 24) & 0xFF, (src_ip >> 16) & 0xFF, (src_ip >> 8) & 0xFF, src_ip & 0xFF, src_port,
(dst_ip >> 24) & 0xFF, (dst_ip >> 16) & 0xFF, (dst_ip >> 8) & 0xFF, dst_ip & 0xFF, dst_port);
} else if (ipv4_hdr->next_proto_id == IPPROTO_TCP) {
uint16_t ip_hdr_len = (ipv4_hdr->version_ihl & 0x0f) * 4;
struct rte_tcp_hdr *tcp_hdr = (struct rte_tcp_hdr *)((unsigned char *)ipv4_hdr + ip_hdr_len);
// 逻辑同上...
}
// 4. 处理完后记得释放 mbuf,还回内存池
rte_pktmbuf_free(m);
}
我觉得这里的解封装逻辑可以完全交给AI来写,AI解包逻辑严谨不出错,我们可以根据不同的数据包来封装一个对象实现优雅解封装报文,这里就不展开说了。
发送数据包
发送数据包就像是'填表发货'。你先从内存池里领一个空白的 mbuf 托盘,把业务数据放进去,然后像剥洋葱的反向操作一样,一层层套上 TCP 头、IP 头和以太网头。最后,你把这一堆装好的托盘交给 rte_eth_tx_burst。网卡会通过 DMA 自动把这些包'吸'走并打到光纤上。记住,DPDK 发包也是'批发模式',一次发 32 个包比一个一个发要快得多,因为这样能均摊掉函数调用的成本。"
c
struct rte_ether_hdr *eth_h = rte_pktmbuf_mtod(m, struct rte_ether_hdr *);
struct rte_arp_hdr *arp_h = rte_pktmbuf_mtod_offset(m, struct rte_arp_hdr *, sizeof(struct rte_ether_hdr));
// 1. 获取网卡 MAC
struct rte_ether_addr my_mac;
rte_eth_macaddr_get(DPDKPORTID, &my_mac);
// 2. 交换以太网层地址
rte_ether_addr_copy(ð_h->s_addr, ð_h->d_addr); // 原源地址变为目标地址
rte_ether_addr_copy(&my_mac, ð_h->s_addr); // 本网卡 MAC 变为源地址
// 3. 修改 ARP 报文内容
arp_h->arp_opcode = rte_cpu_to_be_16(RTE_ARP_OP_REPLY);
// 暂存请求方的 IP 和 MAC
uint32_t req_src_ip = arp_h->arp_data.arp_sip;
struct rte_ether_addr req_src_mac = arp_h->arp_data.arp_sha;
// 填充 Sender 信息 (我方)
rte_ether_addr_copy(&my_mac, &arp_h->arp_data.arp_sha);
arp_h->arp_data.arp_sip = arp_h->arp_data.arp_tip; // 把请求的目标IP作为响应的源IP
// 填充 Target 信息 (对方)
rte_ether_addr_copy(&req_src_mac, &arp_h->arp_data.arp_tha);
arp_h->arp_data.arp_tip = req_src_ip;
// 4. 发送
if (rte_eth_tx_burst(DPDKPORTID, 0, &m, 1) < 1) {
rte_pktmbuf_free(m);
}
封装报文代码亦可交给AI,省时省力。
并行处理
我们刚刚说到,当开启网卡接收后我们需要开启循环去处理每一个数据包抽象成mbuf后的逻辑,那么就存在很严重的同步问题,每个数据包都只能排队处理接收、解析、封装、发送逻辑,当业务有数百万并发时,哪怕每个包只耽误 1ns,成千上万个包排起队来也会让网卡瞬间'爆仓',所以dpdk框架提供了生产消费模型来并行处理数据。
DPDK 框架通过高性能的 无锁环形队列(rte_ring) 实现了核心间的极速通信,并将物理 CPU 抽象为具备特定职能的逻辑单元。以 8 核处理器为例,我们可以构建一套精密的流水线体系:Rx_Core 负责高频收包并进行初步校验;Sched_Core(调度核心) 作为中枢,根据负载均衡算法将报文分发至不同的 Worker_Core(工作核心) 。核心间的数据传递完全依赖于 rte_ring 建立的异步通道------例如,当 Rx_Core 捕获报文后,将其指针压入 Ring 队列,调度核心随即弹出并分发给空闲的工作核心进行深度解析。这种基于'生产者-消费者'模型的解耦设计,利用缓存局部性和无锁同步,支撑起了大规模并发下的复杂业务处理。
创建ring
为了实现核心间的解耦通信,我们需要通过 rte_ring_create 预先构建不同职能的无锁环形队列。为了便于调试与维护,Ring 的命名规范至关重要:
- RX_TO_SCHED:用于接收核心(Rx_Core)将原始报文批量移交给调度核心(Sched_Core)。
- SCHED_TO_WORK[i] :调度核心根据负载均衡算法,将报文分发至对应的第
i个工作核心(Worker_Core)的专属环。
在创建时,socket_id 应严格对齐网卡所在的 NUMA 节点。同时,建议根据具体的"生产-消费"关系设置 flags:若只有一个核心写、一个核心读,开启 RING_F_SP_ENQ (单生产者)和 RING_F_SC_DEQ(单消费者)模式,可以进一步压榨出环形队列的极致性能。
启动线程与主核心运行
在资源配置就绪后,我们需要利用 DPDK 的 EAL(环境抽象层) 启动多核任务。与传统的多线程开发不同,DPDK 采用的是**核心亲和性绑定(Lcore Affinity)**模式,确保任务在指定的物理核心上"永不迁徙",从而规避了上下文切换带来的开销。
1. 派发任务给从核心 (Slave Cores)
我们使用 rte_eal_remote_launch 函数将任务"推"送到指定的从核心上。该函数接收三个参数:运行函数指针 、传递给函数的参数 、以及目标逻辑核心 ID。
c
unsigned int lcore_id;
unsigned int slave_cnt = 0;
/* 遍历所有从核心进行'排班' */
RTE_LCORE_FOREACH_SLAVE(lcore_id) {
if (slave_cnt == 0) {
/* 将'调度主管'任务发射到第一个从核心 */
rte_eal_remote_launch(p_instance->lcore_scheduler, p_instance, lcore_id);
}
else if (slave_cnt <= p_instance->num_workers) {
/* 将'计件工人'任务发射到后续核心,并传入工号 idx */
int idx = slave_cnt - 1;
p_instance->worker_ids[idx] = idx;
rte_eal_remote_launch(p_instance->lcore_worker, &p_instance->worker_ids[idx], lcore_id);
}
slave_cnt++;
}
2. 主核心亲自下场 (Main Core)
这里是关键:rte_eal_remote_launch 只能启动从核心 。主核心(通常是运行 main 函数的那个核)不需要被"发射",它只需要在派发完所有任务后,直接调用自己的处理函数即可。
在我们的流水线中,主核心通常承担最前线的 Rx_Core(收包官) 职责。
c
/* * 此时从核心们已经在后台的 Ring 队列旁待命了。
* 主核心直接调用收包函数,进入死循环,开始疯狂收包并压入 RX_TO_SCHED 环。
*/
p_instance->lcore_main_rx(p_instance);
/* * 只有当主核心的死循环结束(比如收到退出信号)时,
* 才会执行到这里,等待所有从核心完成手头工作并归队。
*/
rte_eal_mp_wait_lcore();