在传统Linux网络栈中,一个UDP包从网卡到应用层,通常要经历:网卡 → 中断 → 内核协议栈 → Socket → 用户态。 这条路径 层级深、拷贝多、上下文切换频繁,在高性能场景(如高并发、低时延、网络测量、用户态协议栈)下会成为瓶颈。
DPDK(Data Plane Development Kit)的核心思想是:绕过 Linux 内核协议栈,直接在用户态接管网卡也就是说:
网卡不再把包交给内核
而是直接 DMA 到用户态的内存
应用程序自己解析 Ethernet / IP / UDP
本文将通过一段完整可运行的代码 ,讲解如何:使用 DPDK 接收 UDP 报文 → 解析 → 打印内容 → 再封装并发送。
一、整体功能说明
一句话总结:
程序直接从网卡"抢"UDP 包,打印 payload,并把包原样回发
具体流程如下:
bash
网卡
↓
DPDK RX 队列
↓
解析 Ethernet → IPv4 → UDP
↓
打印 UDP payload
↓
重新构造 Ethernet/IP/UDP
↓
DPDK TX 队列发回
二、核心概念解释
1、Mbuf 是什么?
DPDK 中 所有报文都存放在 mbuf 中:
-
mbuf 是一个固定大小的内存块
-
启动时一次性分配(避免 malloc)
-
RX / TX 都围绕 mbuf 操作
cpp
struct rte_mbuf *mbuf;
2、RX / TX Queue 是什么?
-
RX Queue:网卡 DMA 把包写到这里
-
TX Queue:DPDK 把包从这里发给网卡
一个端口(port)可以有多个 RX/TX 队列。
3、rte_eth_rx_burst / tx_burst
DPDK 的核心 API:
cpp
rte_eth_rx_burst(); // 一次收一批包
rte_eth_tx_burst(); // 一次发一批包
这就是 DPDK 高性能的关键。
三、代码整体结构概览
代码可以分为 5 个部分:
-
头文件与全局变量
-
端口初始化(
ustack_init_port) -
UDP 报文封装(
ustack_encode_udp_pkt) -
主循环 RX → 解析 → TX
-
程序入口
main
四、代码解析
第一部分:头文件与全局变量
这部分引入了 DPDK 的核心库,并定义了用于存储网络信息的全局变量。
cpp
#include <stdio.h>
#include <rte_eal.h> // DPDK 环境抽象层 (EAL) 头文件
#include <rte_ethdev.h> // DPDK 网卡设备 API
#include <arpa/inet.h> // 标准网络地址转换函数 (如 inet_ntoa)
#include <rte_ip.h> // DPDK IP 协议头部结构定义
#include <rte_udp.h> // DPDK UDP 协议头部结构定义
int global_portid = 0; // 指定使用的网卡端口 ID,通常 0 代表第一个网卡
#define NUM_MBUFS 4096 // 内存池中的 mbuf 数量
#define BURST_SIZE 128 // 每次从网卡收包的最大数量 (批量处理)
#define ENABLE_SEND 1 // 发送功能开关宏
#if ENABLE_SEND
// 定义全局变量,用于在"收包"时保存对方的地址信息,以便在"发包"时填入
uint8_t global_smac[RTE_ETHER_ADDR_LEN]; // 源 MAC 地址
uint8_t global_dmac[RTE_ETHER_ADDR_LEN]; // 目的 MAC 地址
uint32_t global_sip; // 源 IP 地址
uint32_t global_dip; // 目的 IP 地址
uint16_t global_sport; // 源端口
uint16_t global_dport; // 目的端口
#endif
// 默认的网卡配置结构体
static const struct rte_eth_conf port_conf_default = {
.rxmode = { .max_rx_pkt_len = RTE_ETHER_MAX_LEN } // 设置最大接收包长为以太网标准长度
};
板块功能解释:
-
依赖引入 :引入了 DPDK 处理以太网、IP、UDP 数据包所需的结构体定义(如
rte_ether_hdr、rte_ipv4_hdr)。 -
状态保存 :定义了
global_smac,global_sip等变量。这是一个简化的设计,程序在收到包时,会把对方的 IP、MAC、端口存到这里,然后在发送回包时直接使用这些变量作为目标地址。
第二部分:端口初始化
这部分负责启动网卡设备,配置收发队列,是数据通路建立的基础。
cpp
// 初始化端口函数:绑定队列、分配内存、启动网卡
static int ustack_init_port(struct rte_mempool *mbuf_pool){
// 获取系统当前可用的 DPDK 绑定网卡数量
uint16_t nb_sys_ports = rte_eth_dev_count_avail();
if(nb_sys_ports == 0){
rte_exit(EXIT_FAILURE, "No available ports\n"); // 如果没有网卡则退出
}
struct rte_eth_dev_info dev_info;
// 获取指定端口(global_portid)的硬件信息
rte_eth_dev_info_get(global_portid, &dev_info);
// 配置收发队列的数量
const int num_rx_queues = 1; // 1个接收队列
#if ENABLE_SEND
const int num_tx_queues = 1; // 1个发送队列
#else
const int num_tx_queues = 0;
#endif
// 配置网卡:设置队列数量和端口配置参数
rte_eth_dev_configure(global_portid, num_rx_queues, num_tx_queues, &port_conf_default);
// 设置 RX 队列:绑定到内存池 mbuf_pool,用于存放接收到的数据包
if(rte_eth_rx_queue_setup(global_portid, 0, 128, rte_eth_dev_socket_id(global_portid), NULL, mbuf_pool) < 0){
rte_exit(EXIT_FAILURE, "Could not setup RX queue\n");
}
#if ENABLE_SEND
// 获取默认的 TX 配置
struct rte_eth_txconf txq_conf = dev_info.default_txconf;
txq_conf.offloads = port_conf_default.rxmode.offloads;
// 设置 TX 队列:用于发送数据包
if(rte_eth_tx_queue_setup(global_portid, 0, 512, rte_eth_dev_socket_id(global_portid), &txq_conf) < 0){
rte_exit(EXIT_FAILURE, "Could not setup TX queue\n");
}
#endif
// 正式启动网卡设备,开始工作
if(rte_eth_dev_start(global_portid) < 0) {
rte_exit(EXIT_FAILURE,"Could not start\n");
}
return 0;
}
板块功能解释:
-
硬件抽象 :通过
rte_eth_dev_configure告诉网卡我们需要几个收发队列。 -
内存绑定 :
rte_eth_rx_queue_setup非常关键,它将网卡的硬件接收队列与我们在内存中开辟的mbuf_pool关联起来。网卡收到数据后,会直接通过 DMA(直接内存访问)将数据写入这个内存池,无需 CPU 参与拷贝。 -
设备启动 :
rte_eth_dev_start相当于把网卡的物理连通性打开。
第三部分:UDP 报文封装
这部分实现了手动构建网络协议头的功能。因为绕过了操作系统内核,我们需要自己在这个函数里一层一层地"拼装"数据包。
cpp
// 封装 UDP 数据包:传入内存缓冲区 msg,负载数据 data,和总长度
static int ustack_encode_udp_pkt(uint8_t *msg, uint8_t *data, uint16_t total_len){
// 1. 封装以太网头 (L2)
struct rte_ether_hdr *eth = (struct rte_ether_hdr *)msg;
// 目的 MAC:填入之前保存的 global_dmac (即发包给我们的那台机器的 MAC)
rte_memcpy(eth->d_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN);
// 源 MAC:填入 global_smac (本机 MAC)
rte_memcpy(eth->s_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN);
// 类型:IPv4
eth->ether_type = htons(RTE_ETHER_TYPE_IPV4);
// 2. 封装 IP 头 (L3)
struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth +1); // 指针偏移,指向以太网头之后
ip->version_ihl = 0x45; // 版本号4,头部长度5个32位字
ip->type_of_service = 0;
// 总长度 = 总包长 - 以太网头长度 (需要转为网络字节序 htons)
ip->total_length = htons(total_len - sizeof(struct rte_ether_hdr));
ip->packet_id = 0;
ip->fragment_offset = 0;
ip->time_to_live = 64; // TTL
ip->next_proto_id = IPPROTO_UDP; // 下一层协议是 UDP
ip->src_addr = global_sip; // 源 IP (本机)
ip->dst_addr = global_dip; // 目的 IP (对方)
// 计算 IP 校验和
ip->hdr_checksum = 0;
ip->hdr_checksum = rte_ipv4_cksum(ip);
// 3. 封装 UDP 头 (L4)
struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip + 1); // 指针偏移,指向 IP 头之后
udp->src_port = global_sport; // 源端口
udp->dst_port = global_dport; // 目的端口
// UDP 长度 = 总长度 - 以太网头 - IP 头
uint16_t udp_len = total_len - sizeof(struct rte_ether_hdr) - sizeof(struct rte_ipv4_hdr);
udp->dgram_len = htons(udp_len);
// 4. 拷贝负载数据
uint16_t payload_len = udp_len - sizeof(struct rte_udp_hdr);
rte_memcpy((uint8_t *)(udp + 1), data, payload_len); // 将数据拷贝到 UDP 头之后
// 计算 UDP 校验和
udp->dgram_cksum = 0;
udp->dgram_cksum = rte_ipv4_udptcp_cksum(ip, udp);
return 0;
}
板块功能解释:
-
层级封装 :代码通过指针运算
(eth + 1)、(ip + 1)依次移动内存地址,分别填充以太网头、IP 头和 UDP 头。 -
手动校验 :必须手动调用
rte_ipv4_cksum等函数计算校验和,否则对方网卡收到包后会因为校验失败而丢弃。 -
字节序转换 :使用了
htons(Host to Network Short) 将主机字节序(通常是小端)转换为网络字节序(大端)。
第四部分:主循环 RX → 解析 → TX
这是程序的核心业务逻辑,对应代码中 main 函数里的 while(1) 循环。
cpp
while(1){ // 死循环,不断轮询网卡
struct rte_mbuf *mbufs[BURST_SIZE] = {0}; // 定义指针数组存放收到的包
// RX: 批量从网卡接收数据包
// 返回值 num_recvd 是实际收到的包数量
uint16_t num_recvd = rte_eth_rx_burst(global_portid, 0, mbufs, BURST_SIZE);
if(num_recvd > BURST_SIZE){
rte_exit(EXIT_FAILURE, "Received more packets than burst size\n");
}
int i = 0;
// 遍历处理每一个收到的包
for(i = 0; i < num_recvd; i++){
// 解析: 获取以太网头指针 (将 mbuf 转换为 ether_hdr 指针)
struct rte_ether_hdr *eth_hdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *);
// 过滤: 如果不是 IPv4 协议,释放内存并跳过
if(eth_hdr->ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){
rte_pktmbuf_free(mbufs[i]);
continue;
}
// 解析: 获取 IP 头指针 (偏移以太网头大小)
struct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset(mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr));
// 过滤: 如果是 UDP 协议
if(iphdr->next_proto_id == IPPROTO_UDP){
struct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1);
#if ENABLE_SEND
// --- 准备回包逻辑 ---
// 1. 保存收到的包的地址信息(交换源/目的)
// 对方发给我的 D_MAC,变成我要发给它的 S_MAC
rte_memcpy(global_smac, eth_hdr->d_addr.addr_bytes, RTE_ETHER_ADDR_LEN);
// 对方的 S_MAC,变成我要发给它的 D_MAC
rte_memcpy(global_dmac, eth_hdr->s_addr.addr_bytes, RTE_ETHER_ADDR_LEN);
// 交换 IP 和 端口
rte_memcpy(&global_sip, &iphdr->dst_addr, sizeof(uint32_t));
rte_memcpy(&global_dip, &iphdr->src_addr, sizeof(uint32_t));
rte_memcpy(&global_sport, &udphdr->dst_port, sizeof(uint32_t));
rte_memcpy(&global_dport, &udphdr->src_port, sizeof(uint32_t));
// 打印调试信息
struct in_addr addr;
addr.s_addr = iphdr->src_addr;
printf("sip %s:%d --> ", inet_ntoa(addr),ntohs(udphdr->src_port));
addr.s_addr = iphdr->dst_addr;
printf("dip %s:%d --> ", inet_ntoa(addr),ntohs(udphdr->dst_port));
// 2. 申请一个新的 mbuf 用于发送
uint16_t length = ntohs(udphdr->dgram_len);
// 计算回包总长度 (Headers + Data)
uint16_t total_len = length + sizeof(struct rte_ether_hdr) + sizeof(struct rte_ipv4_hdr);
struct rte_mbuf *mbuf = rte_pktmbuf_alloc(mbuf_pool);
if(!mbuf){
rte_exit(EXIT_FAILURE, "Could not allocate mbuf\n");
}
// 设置 mbuf 的数据长度
mbuf->pkt_len = total_len;
mbuf->data_len = total_len;
// 获取新 mbuf 的数据指针
uint8_t *msg = rte_pktmbuf_mtod(mbuf, uint8_t *);
// 3. 调用第三部分的函数,填充报文内容 (Echo: 把收到的数据原样发回去)
ustack_encode_udp_pkt(msg, (uint8_t*)(udphdr + 1), total_len);
// TX: 发送数据包
rte_eth_tx_burst(global_portid, 0, &mbuf, 1);
#endif
// 打印收到的数据内容
printf("udp : %s\n", (char*)(udphdr + 1));
}
// 释放接收到的那个 mbuf (因为数据已经拷贝到新 mbuf 发送了,或者不需要了)
rte_pktmbuf_free(mbufs[i]);
}
}
板块功能解释:
-
轮询模式 (Polling) :这是 DPDK 高性能的核心。它不等待中断,而是 CPU 死循环主动去网卡队列里"捞"数据 (
rte_eth_rx_burst),没有任何上下文切换的开销。 -
零拷贝解析 :
rte_pktmbuf_mtod(Message TO Data) 只是简单地把内存地址转换一下,让 CPU 能够读取包头,没有发生任何数据拷贝。 -
回声 (Echo) 逻辑 :收到 A 发来的包,程序提取 A 的 MAC/IP/Port 存为
global_dip等,将本机的存为global_sip,然后申请一个新包,填入这些交换后的地址,再把数据封装进去,最后通过rte_eth_tx_burst发射出去。
第五部分:程序入口 main
注:按照逻辑顺序,main 函数先执行,包含初始化和死循环。这里先讲初始化的部分,即 while(1) 之前的内容。
cpp
int main(int argc, char *argv[]){
// 1. 初始化 EAL (Environment Abstraction Layer)
// 解析命令行参数,配置大页内存,接管 PCI 设备等
if(rte_eal_init(argc,argv) < 0){
rte_exit(EXIT_FAILURE, "EAL initialization failed\n");
}
// 2. 创建内存池 (Mbuf Pool)
// DPDK 核心机制:预先申请一大块内存,分割成 4096 个 mbuf。
// 收发包时直接从这里拿,不使用 malloc,速度极快。
struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create("mbuf pool", NUM_MBUFS,0,0,RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id());
if(mbuf_pool == NULL){
rte_exit(EXIT_FAILURE, "Could not create mbuf pool\n");
}
// 3. 调用第二部分的端口初始化函数
ustack_init_port(mbuf_pool);
// ... (后续进入 while 循环)
板块功能解释:
-
环境建立 :
rte_eal_init是所有 DPDK 程序的起点,它负责"欺骗"操作系统,让程序获得对硬件的直接控制权。 -
零拷贝基础 :
rte_pktmbuf_pool_create创建的内存池是实现高性能的关键。所有的数据包(无论是接收的还是发送的)都存放在这个池子里,避免了反复申请和释放内存带来的开销。