在高性能网络编程领域,DPDK (Data Plane Development Kit) 是绕不开的一座大山。它通过绕过操作系统内核(Kernel Bypass),直接在用户态接管网卡,实现了极高的数据包处理效率。
很多初学者配置好环境后,面对复杂的 API 无从下手。今天这篇文章,我们将从零开始,编写一个基于 DPDK 的 用户态协议栈雏形(ustack)。
本文目标: 实现一个程序,能够从网卡捕获 IPv4 下的 UDP 数据包,并解析打印出 Payload 内容。
核心概念:为什么 DPDK 这么快?
在看代码之前,我们需要理解 DPDK 的三个核心,这也是我们代码中各个模块存在的意义:
-
UIO / VFIO(绕过内核) : 传统 socket 编程,数据要从网卡 -> 驱动 -> 内核协议栈 -> 用户空间,发生了多次内存拷贝。DPDK 直接把网卡映射到用户空间,**零拷贝(Zero Copy)**直接读写。
-
Hugepages & Mempool(大页内存与内存池) : 为了避免频繁的
malloc/free造成性能抖动,DPDK 在启动时就申请好一大块内存(大页),切割成固定大小的mbuf放在池子里,随用随取,用完归还。 -
Polling Mode(轮询模式) : 传统网卡使用中断(Interrupt)通知 CPU,高并发下 CPU 会被中断淹没。DPDK 使用
while(1)死循环不断查询网卡:"有包吗?有包吗?",虽然占用了 CPU,但消除了中断开销,吞吐量极大。
代码全解析
我们将代码分为三个部分:环境初始化 、网卡配置 、业务循环。
1. 头文件与宏定义
cpp
#include <stdio.h> // 标准输入输出库,用于 printf
#include <rte_eal.h> // DPDK 核心库:EAL (Environment Abstraction Layer) 环境抽象层
#include <rte_ethdev.h> // DPDK 网卡设备库:提供网卡配置、收发包 API
#include <arpa/inet.h> // 提供网络字节序转换等函数 (如 htons)
// 指定我们要使用的网卡端口 ID,0 代表系统识别到的第一个 DPDK 网卡
int global_portid = 0;
// 内存池大小:申请 4096 个 mbuf (内存块)
// 如果包处理得慢,或者收包太快,这个池子可能会枯竭,导致丢包
#define NUM_MBUFS 4096
// 批量处理大小:每次从网卡"抢" 128 个包
#define BURST_SIZE 128
注意 :BURST_SIZE 设置为 128 或 32 是常见做法。批量处理可以分摊函数调用的开销(类似批发比零售便宜)。
2. 网卡初始化函数 (ustack_init_port)
这个函数负责告诉网卡:"我要怎么用你"。
cpp
// 网卡的基础配置结构体
static const struct rte_eth_conf port_conf_default = {
// 设置接收模式:最大包长度为以太网标准最大长度 (1518字节)
// 注意:旧版本是用 RTE_ETH_MAX_PKT_LEN,新版本用 RTE_ETHER_MAX_LEN
.rxmode = { .max_rx_pkt_len = RTE_ETHER_MAX_LEN }
};
// [函数:初始化网卡端口]
// static 关键字:表示这是内部函数,防止对外暴露符号,避免命名冲突
static int ustack_init_port(struct rte_mempool *mbuf_pool){
// 1. 查询系统里有几个可用的 DPDK 网卡
uint16_t nb_sys_ports = rte_eth_dev_count_avail();
if(nb_sys_ports == 0){
rte_exit(EXIT_FAILURE, "No available ports\n"); // 如果没有网卡,直接报错退出
}
// 2. 配置网卡参数
const int num_rx_queues = 1; // 启用 1 个接收队列 (收货口)
const int num_tx_queues = 0; // 启用 0 个发送队列 (只收不发,做抓包工具)
// 下发配置给 global_portid 号网卡
rte_eth_dev_configure(global_portid, num_rx_queues, num_tx_queues, &port_conf_default);
// 3. 绑定接收队列 (RX Queue) 和 内存池 (Mempool)
// 意思:0 号网卡的 0 号队列,你以后收到的包,都往 mbuf_pool 里的空箱子里装
// 参数 128 是指接收描述符的数量 (Ring Size),即网卡硬件缓冲区的大小
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");
}
// 4. 正式启动网卡 (Link Up)
// 这一步之后,网卡指示灯通常会亮起,开始工作
if(rte_eth_dev_start(global_portid) < 0) {
rte_exit(EXIT_FAILURE,"Could not start\n");
}
return 0; // 初始化成功
}
3. Main 函数:EAL 初始化与内存池
cpp
int main(int argc, char *argv[]){
// [Step 1] EAL 初始化 (Environment Abstraction Layer)
// 这是 DPDK 的入口,负责接管 CPU、内存和网卡硬件
// 如果这一步失败,说明环境没配好 (比如没有大页内存,或者没加载 vfio-pci 驱动)
if(rte_eal_init(argc,argv) < 0){
rte_exit(EXIT_FAILURE, "EAL initialization failed\n");
}
// [Step 2] 创建内存池 (Mempool)
// 名字叫 "mbuf pool",一共有 NUM_MBUFS (4096) 个块
// 这是为了避免频繁 malloc/free,提升性能
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");
}
// [Step 3] 调用上面的辅助函数,初始化端口
ustack_init_port(mbuf_pool);
// ... 进入核心循环 ...
}
4. 核心循环:收包、解析、释放(重点!)
这是程序最繁忙的地方,也是最容易出错(比如内存泄漏)的地方。
cpp
// [Step 4] 进入死循环,开始"干苦力" (收包-处理-释放)
while(1){
// 定义一个指针数组,用来存放收到的一批包
struct rte_mbuf *mbufs[BURST_SIZE] = {0};
// A. [核心收包 API]
// 从 global_portid 的 0 号队列,尝试"抢"最多 BURST_SIZE (128) 个包
// num_recvd 是实际抢到的包数量 (可能为 0,也可能为 128)
uint16_t num_recvd = rte_eth_rx_burst(global_portid, 0, mbufs, BURST_SIZE);
// 理论上不应该超过 BURST_SIZE
if(num_recvd > BURST_SIZE){
rte_exit(EXIT_FAILURE, "Received more packets than burst size\n");
}
// B. 遍历处理抢到的每一个包
int i = 0;
for(i = 0; i < num_recvd; i++){
// [零拷贝解析 - 第 1 层:以太网头]
// rte_pktmbuf_mtod (Message to Data): 把 mbuf 转成以太网头结构体指针
struct rte_ether_hdr *eth_hdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *);
// 过滤:检查以太网类型是否为 IPv4 (0x0800)
// rte_cpu_to_be_16: 因为网络协议是大端序 (Big Endian),x86 CPU 是小端序,需要转换
if(eth_hdr->ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){
// 如果不是 IPv4 (比如是 ARP 或 IPv6),就不处理
rte_pktmbuf_free(mbufs[i]); // 释放内存,把箱子还给池子
continue; // 跳过,看下一个包
}
// [零拷贝解析 - 第 2 层:IP 头]
// mtod_offset: 在开头的基础上,向后偏移 sizeof(ether_hdr) 长度,找到 IP 头
struct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset(mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr));
// 过滤:检查 IP 协议号是否为 UDP (17)
if(iphdr->next_proto_id == IPPROTO_UDP){
// [零拷贝解析 - 第 3 层:UDP 头]
// 这里的指针加法 (iphdr + 1) 实际上是地址向后移动 sizeof(struct rte_ipv4_hdr)
struct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1);
// [打印数据]
// (char*)(udphdr + 1) 指向 UDP 头后面的数据部分 (Payload)
// 注意:如果 Payload 不是以 \0 结尾的字符串,这里可能会打印乱码
printf("udp : %s\n", (char*)(udphdr + 1));
}
// [释放内存]
// 无论刚才是否打印了 UDP,处理完当前包后,必须手动归还内存
// 如果忘了这行,4096 个箱子很快用完,程序就会无法再收包 (内存泄漏)
rte_pktmbuf_free(mbufs[i]);
}
}
完整可运行代码
cpp
#include <stdio.h>
#include <rte_eal.h>
#include <rte_ethdev.h>
#include <arpa/inet.h>
int global_portid = 0;
#define NUM_MBUFS 4096
#define BURST_SIZE 128
static const struct rte_eth_conf port_conf_default = {
.rxmode = { .max_rx_pkt_len = RTE_ETHER_MAX_LEN }
};
static int ustack_init_port(struct rte_mempool *mbuf_pool) {
uint16_t nb_sys_ports = rte_eth_dev_count_avail();
if (nb_sys_ports == 0) {
rte_exit(EXIT_FAILURE, "No available ports\n");
}
const int num_rx_queues = 1;
const int num_tx_queues = 0;
rte_eth_dev_configure(global_portid, num_rx_queues, num_tx_queues, &port_conf_default);
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 (rte_eth_dev_start(global_portid) < 0) {
rte_exit(EXIT_FAILURE, "Could not start\n");
}
return 0;
}
int main(int argc, char *argv[]) {
if (rte_eal_init(argc, argv) < 0) {
rte_exit(EXIT_FAILURE, "EAL initialization failed\n");
}
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");
}
ustack_init_port(mbuf_pool);
while (1) {
struct rte_mbuf *mbufs[BURST_SIZE] = {0};
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++) {
struct rte_ether_hdr *eth_hdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *);
if (eth_hdr->ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) {
rte_pktmbuf_free(mbufs[i]);
continue;
}
struct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset(mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr));
if (iphdr->next_proto_id == IPPROTO_UDP) {
struct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1);
// 建议:实际项目中请使用 HexDump 打印二进制数据
printf("udp payload : %s\n", (char*)(udphdr + 1));
}
// 务必释放 mbuf
rte_pktmbuf_free(mbufs[i]);
}
}
printf("hello world\n");
return 0;
}
总结
通过这个简单的 Demo,我们完成了从网卡抓包到解析 UDP 的全过程。虽然代码短,但它包含了 DPDK 最精髓的思想:EAL 初始化环境、Mempool 管理内存、Rx Burst 批量收包、Zero Copy 指针解析。