[网络编程] 基于 DPDK 的 UDP 报文收发实现

在传统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 个部分

  1. 头文件与全局变量

  2. 端口初始化(ustack_init_port

  3. UDP 报文封装(ustack_encode_udp_pkt

  4. 主循环 RX → 解析 → TX

  5. 程序入口 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_hdrrte_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 创建的内存池是实现高性能的关键。所有的数据包(无论是接收的还是发送的)都存放在这个池子里,避免了反复申请和释放内存带来的开销。

0voice · GitHub

相关推荐
..过云雨1 小时前
HTTP 协议深度解析:请求/响应、报头、正文的核心原理与实战
网络·网络协议·tcp/ip·计算机网络·http
wechat_Neal2 小时前
车载以太网技术全景-网络基础理论篇
网络
水境传感 张园园2 小时前
便携式光透过率检测仪:如何成为安全“守门人”?
网络
Mintopia3 小时前
🚀 HTTP/2 多路复用技术全透视
网络协议·http·https
做萤石二次开发的哈哈3 小时前
萤石开放平台 萤石可编程设备 | 设备 Python SDK 使用说明
开发语言·网络·python·php·萤石云·萤石
nvd113 小时前
从 SSE 到 Streamable HTTP:MCP Server 的现代化改造之旅
网络·网络协议·http
小蜗的房子4 小时前
Oracle 19C RAC Public IP单网卡改为bond模式操作指南
运维·网络·数据库·sql·tcp/ip·oracle·oracle rac
无忧智库4 小时前
国家级算力枢纽节点(东数西算)跨区域调度网络与绿色节能数据中心建设:深度解析“数字新基建”的战略落地
网络
网络工程师_ling4 小时前
【阿里云多地域混合云网络架构】
网络·阿里云·架构