刚接触DPDK的时候,我最大的困惑是:收包吞吐很容易做到线速,但收下来的包往哪里送?内核协议栈你碰不到,因为网卡已经被DPDK接管了,skb根本不会产生,socket更是无从谈起。如果你想把DPDK真正用到业务里,比如做一个四层网关、代理、负载均衡原型,或者只是想在用户态实现一套完全可控的TCP/IP处理逻辑,绕不开一个问题:自己写一个用户态协议栈。
这篇文章要聊的就是这个项目:基于DPDK从零写一个极简的用户态协议栈,支持UDP和TCP的基础收发能力。它不是一个完整的协议栈,不会去实现拥塞控制、多路径、SACK这类复杂机制,但它能把一条UDP数据通路和一条TCP连接从建连到收发的完整流程跑通,能承受iperf3的打流验证,也能让你真正理解DPDK应用的骨架是怎么搭起来的。
如果你已经跑过DPDK的helloworld或者l2fwd,但还不太清楚协议栈内部怎么组织;或者你打算做高性能网络服务,想先搞清楚用户态TCP/IP的实现要领,这篇内容应该能给你省不少时间。下面我会从架构设计、核心代码、踩坑记录三个维度完整拆一遍。
1. 为什么要在用户态重新造一个协议栈
1.1 内核协议栈到底慢在哪
先说个容易被忽略的事实:内核协议栈本身并不慢,它慢在“通用”上。Linux内核的网络路径需要面对所有场景:各种网卡驱动、netfilter钩子、cgroup统计、路由策略、隧道封装、socket层的锁竞争,还有中断和软中断的上下文切换。每收一个包,都要经过硬中断、软中断、skb分配、协议分层处理,最后唤醒用户态进程。这一套流程对延迟和吞吐都不友好。
对于DPDK来说,问题更直接:网卡通过UIO或VFIO映射到用户态后,内核根本不知道这个网卡的存在,自然不会有skb,也不会有tcp/ip协议栈替你解析。你以为绕过了内核就很爽,结果发现协议栈也得自己写。这就是“DPDK用户态协议栈”这个方向的由来。
用生活类比理解一下:内核协议栈像一家综合邮局,什么信件都能寄,但每个环节都要排队、盖章、登记,你无法定制;用户态协议栈则像是你专门给自家公司搭的一条快递流水线,只处理你关心的那几种包裹,路径最短、规则你说了算,代价是得自己把分拣、运输、签收全干了。
1.2 极简协议栈的边界与目标
写协议栈最怕一开始就想“全”。TCP有拥塞控制、重传、选择确认、时间戳、窗口缩放;IPv4有分片重组、选项处理;ARP有过期、探测、冲突检测。全做下来是个巨大的工程。我给自己定的边界很明确:
- 不支持:拥塞控制(固定发送窗口)、TCP选项协商(只回MSS,不处理SACK/WS)、IP分片重组(直接丢弃带偏移的非首片)、多队列RSS、VLAN、IPv6。
- 必须支持:ARP请求/响应、IPv4的UDP收发、TCP三次握手、数据收发、ACK处理、超时重传、连接关闭。
- 目标场景:单核单队列的L4转发、基于UDP的简单服务、TCP代理原型、教学演示。
这套边界不是随意砍的,而是基于一个原则:先把数据通路打通,再去考虑复杂机制。TCP连接能建起来、数据能双向收发、断线能感知,就已经覆盖了70%的真实需求。
2. 整体架构设计:轮询、内存与收发主循环
2.1 核心思路:一根线程把收、解、发串起来
DPDK应用的基本模型是轮询(polling)。不像中断驱动那样“有包才通知”,DPDK的收包线程会持续调用rte_eth_rx_burst去网卡RX队列里取包。这个函数名字里的burst值得注意:它一次可以取多个包,批量处理才是性能关键。
我的极简协议栈在设计上走的是最简单的单线程模型:主lcore负责收包、协议解析、业务处理、发包。不用多线程,也不用跨核通信,因为一旦引入多核,就要处理锁、队列、缓存一致性,复杂度会急剧上升。单线程的好处是逻辑顺序清晰,任何时刻只有一个流程在跑,出了Bug容易排查。
初始化时用rte_eal_init解析EAL参数,然后用rte_lcore_id()判断当前核,主循环里面就是经典的收包-处理-发包三步:
for (;;) { uint16_t nb_rx = rte_eth_rx_burst(port, 0, rx_burst, BURST_SIZE); for (i = 0; i < nb_rx; i++) { process_packet(rx_burst[i]); // 解析并响应 } }2.2 内存池和无锁队列怎么选
DPDK里几乎所有对象都来自内存池(mempool),报文缓冲区也不例外。创建报文池的接口是rte_pktmbuf_pool_create,需要想清楚的参数有三个:数量、缓存大小、每个mbuf的数据区大小。
我在项目里配置的是:8192个mbuf,每个mbuf数据区2048字节,socket为SOCKET_ID_ANY。这个数量怎么定的?主要看两个约束:一是网卡RX队列描述符数量,二是业务吞吐。RX队列我设了1024个描述符,这意味着网卡最多缓存1024个待收包;如果应用处理不过来,新到的包会被网卡丢弃。mbuf池数量设置8192,就是为了给应用处理留出缓冲余量,不至于因为池耗尽而丢包。数据区2048字节是因为标准MTU 1500加上以太网头14字节后,还要留出DPDK头部预留和对齐空间,2048是低成本下最稳的选择。
另外还创建了一个rte_ring,用来做TCP连接的事件队列。rte_ring是无锁环形队列,支持多生产者/单消费者或单生产者/单消费者模型。对于极简实现,我建议只用SPSC模式,也就是单生产者单消费者,这样可以省掉绝大部分原子操作开销。如果你有业务线程需要往协议栈塞数据包,可以用MPSC模式,但要接受一定的CAS竞争损耗。
2.3 主循环:一包一包走完全流程
整个收包处理流程我把它设计成一个严格的分层管道,每一层只做自己该做的事,处理完就交给下一层。从网卡收上来的mbuf,依次经过:
- 以太网头解析:检查目的MAC是否为本机或广播,读取ether_type字段,判断是0x0806(ARP)还是0x0800(IPv4),其他类型一律丢掉。
- ARP处理:如果是ARP请求且目标IP是本机,就回ARP应答;如果是ARP应答,记录到ARP表,更新对应表项的MAC地址。
- IPv4处理:检查版本号、头部长度、总长度、协议字段。校验和如果算出来不对就直接丢弃。
- 传输层分派:协议号为17走UDP,协议号为6走TCP。UDP查socket表后投递,TCP查连接表后走状态机。
发包路径也一样:构造以太网头、IP头、传输层头,然后填充payload,最后rte_eth_tx_burst发出去。两边的共同点是:都直接操作mbuf里的数据区,不打乱原有数据,只在头部预留区做修改。
3. 核心实现细节:UDP和TCP的取舍
3.1 UDP:最干净的一条数据通路
UDP协议本身很薄:源端口、目的端口、长度、校验和,就这四个字段。但真正写代码的时候,校验和和socket表是绕不开的两个细节。
先看校验和。UDP校验和覆盖的是“伪头部+UDP头+载荷”。伪头部包括源IP、目的IP、协议号(17)和UDP长度,这四样东西其实不属于UDP报文本身,而是从IP头借来的。为什么要这么设计?因为TCP/IP协议栈要求传输层校验能发现IP地址被篡改的情况。算校验和的算法不复杂:把所有16位字累加,溢出回卷,最后取反。
static uint16_t calc_checksum(uint16_t *addr, int len) { uint32_t sum = 0; while (len > 1) { sum += *addr++; len -= 2; } if (len == 1) sum += *(uint8_t *)addr; while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16); return (uint16_t)~sum; }这段代码在大小端机器上表现不同,因为网络字节序是大端。如果你的宿主机是小端(x86就是),直接拿内存里的字节序去累加,结果跟按网络序累加是一样的,因为累加本身是按字节序敏感的,而校验和的比较是在同一字节序下进行的。但构造UDP头的时候,端口和长度字段必须用rte_cpu_to_be_16转成网络序。
socket表我用的是最简单的哈希表:用四元组(源IP、源端口、目的IP、目的端口)做key,哈希到数组槽位,冲突就往后线性探测。对极简场景,1024个槽位足够。UDP的bind操作就是在表里注册一条记录,收到UDP包时按四元组查表,命中就投递。
发送UDP包时,填充目标MAC(从ARP表查)、源MAC(本机)、EtherType、IP头、UDP头,算好校验和,然后一个tx_burst发出去。整个过程不涉及连接状态,所以UDP部分的代码量比TCP少一个数量级,这也是建议你从UDP开始写起的原因。
3.2 TCP状态机:把三次握手做对
TCP是这项目的重头戏,也是最容易写崩的部分。先定义状态枚举:
enum tcp_state { TCP_CLOSED, TCP_LISTEN, TCP_SYN_RCVD, TCP_ESTABLISHED, TCP_FIN_WAIT_1, TCP_TIME_WAIT, };对极简协议栈来说,状态可以砍到很少。服务端侧核心路径是LISTEN -> SYN_RCVD -> ESTABLISHED;客户端侧是SYN_SENT -> ESTABLISHED。其他状态比如FIN_WAIT、TIME_WAIT,能实现基本的四次挥手就够用。
三次握手的服务端逻辑可以写成这样:收到SYN包后,为这个四元组创建一个连接结构,状态置为SYN_RCVD,记录对方的初始序列号,然后回一个SYN+ACK包。这个SYN+ACK包里的ACK号是对方的SEQ+1,自己的SEQ是一个随机初始值。等收到对端的ACK包后,把状态改成ESTABLISHED。
有个容易搞错的点位:判断SYN和ACK标志时,不能只看tcp->syn和tcp->ack这两个字段,还要结合当前状态。比如在LISTEN状态下,收到带SYN且不带ACK标志的包,才认为是一次新的连接请求;如果收到带SYN又带ACK的包,那可能是对端在同时建连(TCP simultaneous open),极简实现可以直接忽略。
客户端主动连接更麻烦,因为需要自己维护SYN重传,还要处理SYN+ACK。我建议第一次实现时先做服务端listen模式,用外部工具(比如nc或者你自己写的UDP辅助命令)去触发连接,这样调试路径最清晰。
发送数据前的关键一步是组装TCP头,除了标志位和序号,还要填窗口大小。极简实现直接通告固定窗口,比如65535,不做窗口缩放协商。如果对端发来的报文里带窗口缩放选项,直接忽略,这会降低吞吐,但能保证正确性。
3.3 发送、确认与超时重传的极简实现
TCP可靠性的根基是“发送未确认的数据要留着,收到ACK才能释放”。我在每个连接结构里挂了一个重传队列,队列节点保存的是已发送但未确认的mbuf指针和发送时间戳。
发送逻辑:应用层要发数据时,把数据封装成TCP报文,加入重传队列,然后tx_burst发出去。收到ACK包时,遍历重传队列,凡是序号小于等于ACK号的报文全部释放掉。如果对端一直没有ACK,就靠一个轮询定时器扫描所有连接,把超过RTO(我固定用200ms)的未确认报文重新塞回发送队列。
这个重传设计非常粗糙,没有做RTT估算,也没有指数退避,但足以应付局域网环境下的iperf3测试。如果你要在公网环境用,建议至少把RTO改成动态估算,否则丢包会严重降低吞吐。
接收路径上,极简实现不做乱序重排。收到TCP报文后,先看序号是否等于期望的接收序号,等于才接受并更新窗口,否则直接丢弃,也不回ACK。这种“只收有序数据”的机制,在局域网低丢包环境下完全够用,但遇到网络乱序就会表现为吞吐骤降。
3.4 别忽略的ARP和IP层
很多第一次写协议栈的人会忽略ARP,结果TCP握手总是失败。原因很简单:对端发起TCP连接前,会先发ARP请求询问你的MAC地址,如果协议栈不回ARP,连接根本到不了TCP层。
ARP表我用了一个固定长度的数组,每一项记录IP地址、MAC地址和时间戳,用线性查找。虽然复杂度是O(n),但在表项不超过64个的极简场景下完全够用。收到ARP请求时,查表确认目标IP是本机IP,然后组一个ARP应答包回过去。收到ARP应答时,更新表项并记录时间戳。
IP层的处理,我建议第一次实现直接跳过分片重组。分片的特征是IP头里的fragment offset字段不为0或者MF标志置位。对第一个分片(offset=0且MF置位)可以尝试处理,但分片重组逻辑相当繁琐,先在极简版本里把所有非首片直接丢弃,并打印debug日志。这样遇到分片流量时,你能快速意识到“哦,这里需要实现重组”,而不是被莫名其妙的乱码困惑。
4. 实操过程:从初始化到iperf3验证
4.1 环境搭建和项目文件拆分
开发环境建议直接用Linux + dpdk-devel,网卡最好选Intel的i40e或virtio(虚拟机里测很方便),因为驱动支持最稳。需要先确认网卡没有被内核驱动占用:
dpdk-devbind.py -s # 如果网卡显示在使用中,执行 dpdk-devbind.py -b vfio-pci 0000:00:0a.0项目文件我拆成这样,每个文件职责单一,排查问题方便:
├── main.c # EAL初始化、主循环 ├── eth.c # 网口配置、mac地址读取 ├── arp.c # ARP表、ARP收发 ├── ip.c # IP解析、IP校验 ├── udp.c # UDP socket表、收发 ├── tcp.c # TCP连接表、状态机、重传 ├── checksum.c # 校验和工具 └── app.c # 业务逻辑(echo等)这样拆分的好处是:如果TCP出了问题,你只需要在tcp.c里查状态机逻辑,不用在几千行代码里大海捞针。
4.2 关键代码:初始化、收包分发、UDP发送
初始化代码是整个协议栈的地基,网卡配置错一个参数,后面全白搭。核心流程是这样:
int main(int argc, char *argv[]) { int ret = rte_eal_init(argc, argv); if (ret < 0) rte_exit(EXIT_FAILURE, "EAL init failed\n"); mbuf_pool = rte_pktmbuf_pool_create("mbuf_pool", 8192, 256, 0, 2048, SOCKET_ID_ANY); if (!mbuf_pool) rte_exit(EXIT_FAILURE, "mbuf pool create failed\n"); struct rte_eth_conf port_conf = {0}; port_conf.rxmode.offloads = 0; ret = rte_eth_dev_configure(port, 1, 1, &port_conf); if (ret < 0) rte_exit(EXIT_FAILURE, "dev configure failed\n"); ret = rte_eth_rx_queue_setup(port, 0, 1024, rte_eth_dev_socket_id(port), NULL, mbuf_pool); ret = rte_eth_tx_queue_setup(port, 0, 1024, rte_eth_dev_socket_id(port), NULL); rte_eth_dev_start(port); rte_eth_promiscuous_enable(port); for (;;) { uint16_t nb_rx = rte_eth_rx_burst(port, 0, rx_burst, 64); for (uint16_t i = 0; i < nb_rx; i++) process_packet(rx_burst[i]); } }process_packet函数就是前面说的分层解析。收到包后,先把rte_pktmbuf_mtod转成指针,按位置读取以太网目的MAC、EtherType。具体处理时会有一个细节:rte_pktmbuf_mtod返回的是mbuf数据区起始地址,但mbuf里默认会有一段头部预留空间。DPDK在收包时会把数据区起始对齐到固定偏移,所以直接用偏移量访问没问题。
UDP发送函数是一条比较完整的参考路径,可以做TCP发送的模板:
static void udp_send_pkt(struct rte_mbuf *mbuf, uint32_t src_ip, uint32_t dst_ip, uint16_t src_port, uint16_t dst_port) { struct rte_ether_hdr *eth = rte_pktmbuf_mtod(mbuf, struct rte_ether_hdr *); rte_ether_addr_copy(&local_mac, ð->dst_addr); rte_ether_addr_copy(&local_mac, ð->src_addr); eth->ether_type = htons(0x0800); struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); ip->version_ihl = 0x45; ip->total_length = htons(mbuf->pkt_len - sizeof(*eth)); ip->next_proto_id = IPPROTO_UDP; ip->src_addr = htonl(src_ip); ip->dst_addr = htonl(dst_ip); ip->hdr_checksum = calc_checksum((uint16_t *)ip, sizeof(*ip)); struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip + 1); udp->src_port = htons(src_port); udp->dst_port = htons(dst_port); udp->dgram_len = htons(...); udp->dgram_cksum = calc_udp_checksum(...); }注意把src和dst搞反。我因为把目的MAC填成了本机MAC,导致第一次联调时包发出去没人认。后来养成习惯:写类似函数时第一件事就是对照“谁发给谁”,先填dst再填src。
4.3 编译运行:EAL参数、测试拓扑、iperf3结果
编译用gcc就行,DPDK提供了pkg-config,可以这样:
gcc -O2 -g -o dpdk-ustack \ main.c eth.c arp.c ip.c udp.c tcp.c checksum.c app.c \ $(pkg-config --cflags --libs libdpdk) -lpthread运行时要给EAL传参数,典型命令:
sudo ./dpdk-ustack -l 0 -n 4 -- -p 0x1-l 0指定使用0号核做轮询,-n 4指定内存通道数,这个要跟机器实际配置匹配,不对会警告但一般能跑。后面的-- -p 0x1是传给应用自己的参数,表示使用port 0。
测试拓扑分两种:一是用两台机器背靠背网线直连,另一台跑标准Linux协议栈的iperf3;二是单机双网口,一个口被DPDK接管,另一个口走内核协议栈,通过本地路由转发流量。没有条件的话,用虚拟机配virtio网卡也完全够用。
先测UDP,服务端协议栈绑定9000端口,客户端跑:
iperf3 -u -c 192.168.1.2 -p 9000 -b 1000M -t 30如果协议栈的UDP收发正常,iperf3会报出吞吐数据。你可能会碰到UDP的校验和错误,表现为收包计数增长但业务层收不到数据。这个通常不是算法问题,而是你拿了网卡的校验和卸载能力却没配置对应标志,后面排查章节细说。
TCP测试同理,服务端监听9000端口,客户端直接:
iperf3 -c 192.168.1.2 -p 9000 -t 30我实测的场景里,UDP单核能做到约60万pps的收包解析能力,TCP吞吐受限于固定窗口和简单重传,大约在500Mbps左右。这个数据并不惊艳,但它证明了整个协议栈能转起来,是在正确方向上跑。
5. 常见问题与排查技巧实录
5.1 同样一条网线,tcpdump为什么看不到包
刚做用户态协议栈最容易困惑的是:明明iperf3客户端在发包,服务器上tcpdump却一个包都看不到。这不是你的抓包姿势不对,而是DPDK网卡已经绑定到vfio-pci驱动,内核网络栈根本不认这个网卡,tcpdump在socket层当然看不到任何流量。
想看用户态协议栈收到的包,正确的做法是:
- 在协议栈里加一个debug开关,每个收包都打印以太网头、IP头、TCP头关键字段。
- 用DPDK自带的dpdk-pdump工具做镜像抓包。
- 在物理交换机上配端口镜像,从另一个普通网卡抓包。
我在开发时最常用的是第一种,虽然打印会很频繁,但能直观看到“网卡确实收到包了,卡在哪个解析环节”。
5.2 一直SYN_SENT / 握手失败排查
TCP连不上的原因排行:ARP问题第一,校验和问题第二,状态机标志判断错误第三。
先确认ARP表里有对端IP的MAC映射。最简单做法是协议栈启动时主动发一个ARP请求,或者你直接静态写死一条ARP表项。如果ARP没问题,接着查SYN包的校验和。TCP校验和的计算范围包含伪头部,如果你只算了TCP头加数据,那对端一收到就会静默丢弃,表现就是SYN发出去石沉大海。
还有一个隐蔽问题:收到SYN后回SYN+ACK,但把自己初始序列号填错了。TCP握手过程中,应答包里的ACK号必须是对方的初始SEQ+1,如果少加1,对端就会认为这个包不可接受,直接丢弃不回复。每次排查握手问题,先把这三板斧过完,基本能覆盖80%的情况。
5.3 mbuf池与环的坑:丢包是从这里开始的
跑一段时间后吞吐突然降为零,或者收包数量大跳水,十有八九是mbuf池耗尽。原因是某个环节拿了mbuf却忘了释放。常见泄漏点有两种:一是重传队列里的mbuf没及时释放,二是tx_burst发送失败时没把失败的mbuf重新入队或释放。
建议在所有分支里养成习惯:任何return前先确认持有mbuf的处理完毕。一个简单的巡检方法:定期打印rte_mempool_avail_count,正常情况下应该稳定在一个区间,如果持续下降,说明有泄漏。
rte_eth_tx_burst的返回值也要检查。如果返回值小于你传入的包数量,说明发送队列已满,超出的mbuf不会自动处理。这时候要么把剩余的包缓存下来下次再发,要么暂时丢掉但计数报警。极简实现里我选择了后者,但绝对不能当成理所当然。
5.4 性能调优的几个实用动作
协议栈能跑通之后,性能调优是一个渐进过程。我实际试过且有效的手段有几个:
第一个是批量发送。很多新手习惯每来一个请求就调一次rte_eth_tx_burst发一个包,这会造成严重的函数调用开销。正确的做法是把待发送报文攒到一个数组里,攒够32个或者16个再统一调用tx_burst。同样的吞吐量下,CPU占用能降一半。
第二个是用rte_rdtsc()而不是clock_gettime来做超时判断。协议栈主循环本来就是轮询的,你不需要一个真正的定时器,只需要在每次循环里读取时间戳和上次发送时间比较。rte_rdtsc的精度和开销都比系统调用好太多,唯一的坑是要自己处理TSC频率换算。
第三个是代码里多用rte_likely/rte_unlikely做分支预测提示。收到一个正常数据包的概率远大于遇到错误包的概率,把这个信息告诉编译器,分支预测命中率会提升,整体性能有可感知的改善。
第四个是避免在收包路径上做日志打印。每条报文一个printf,看起来没什么,实际上是巨大的性能杀手。Printf可能是同步写终端甚至等锁,一秒几万个包就能把线程拖垮。调试信息用环形缓冲区或rate-limit方式处理,不要直接打在收包路径上。
最后再分享一点我的体会
做完这个项目,我最大的感受是:用户态协议栈并不神秘,但绝对琐碎。UDP看似简单,校验和伪头部没搞懂就会翻车;TCP一次握手成功不难,但要想在异常场景下不崩溃,就得把状态机每一步边界都想清楚。我的建议是严格分阶段:先跑通UDP echo,再实现TCP listen和accept,最后才碰主动连接和重传。每一步都用小工具验证,不要试图一口气写完再调试,那样你会被几十个Bug同时轰炸。
如果你想在这个基础上继续扩展,方向其实很多:多队列RSS支持、零拷贝发送、checksum硬件卸载、TCP窗口缩放协商、多核流水线架构。任何一个方向深入下去,都够再写一篇很长的文章。极简版本的价值在于它提供了一个可靠的起点,让你知道该往哪里使劲。