4、用户态协议栈设计:基于DPDK构建一个最小化的TCP/IP协议栈
说实话,很多人一听到「自己写TCP/IP协议栈」,第一反应就是「疯了」。但我在实际项目中遇到过太多次内核协议栈的瓶颈——尤其是当你的网卡已经跑到40G甚至100G时,内核那套中断+锁的机制,真的扛不住。
所以这一章,我们直接动手。基于DPDK,从零搭一个最小化的用户态TCP/IP协议栈。不追求完整,只追求「能用、够快」。
为什么非要用DPDK?
你想想看,传统网络包从网卡到应用,经历了什么?
- 网卡硬中断 → 内核软中断
- 内核协议栈处理 → 内存拷贝
- 系统调用 → 上下文切换
每一步都是延迟。我见过一个极端案例:某金融交易系统,光内核协议栈就占了总延迟的60%。
DPDK的做法很粗暴:绕过内核,直接把网卡内存映射到用户态。没有中断,没有系统调用,没有锁竞争。说白了,就是让应用自己管网卡。
核心思想:DPDK通过UIO(Userspace I/O)驱动,把网卡的收发包队列直接暴露给用户态程序。应用轮询(Polling)收包,零拷贝拿到数据。
最小化协议栈的架构
我们不需要实现完整的TCP/IP。我个人的习惯是:先搭骨架,再填肉。这个最小化协议栈只包含:
- ARP:搞定局域网内的MAC地址解析
- IP:最基本的收发和分片重组
- TCP:三次握手、数据收发、四次挥手
下面这张图,是我在设计时画的整体流程:
第一步:初始化DPDK环境
在写任何协议逻辑之前,得先把DPDK跑起来。我习惯用rte_eal_init()做初始化,然后分配内存池(mempool)给mbuf用。
#include <rte_eal.h>
#include <rte_mempool.h>
#include <rte_ethdev.h>
#define NUM_MBUFS 8191
#define MBUF_CACHE_SIZE 250
#define RX_RING_SIZE 1024
#define TX_RING_SIZE 1024
int main(int argc, char *argv[]) {
// 初始化DPDK环境抽象层
int ret = rte_eal_init(argc, argv);
if (ret < 0) {
rte_exit(EXIT_FAILURE, "EAL init failed\n");
}
// 创建mbuf内存池
struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create(
"MBUF_POOL", NUM_MBUFS,
MBUF_CACHE_SIZE, 0,
RTE_MBUF_DEFAULT_BUF_SIZE,
rte_socket_id()
);
if (!mbuf_pool) {
rte_exit(EXIT_FAILURE, "Cannot create mbuf pool\n");
}
// 配置网卡(端口0)
uint16_t port_id = 0;
struct rte_eth_conf port_conf = {0};
rte_eth_dev_configure(port_id, 1, 1, &port_conf);
// 启动网卡
rte_eth_dev_start(port_id);
// 主循环
for (;;) {
// 收包处理
struct rte_mbuf *bufs[32];
uint16_t nb_rx = rte_eth_rx_burst(port_id, 0, bufs, 32);
for (int i = 0; i < nb_rx; i++) {
handle_packet(bufs[i]);
rte_pktmbuf_free(bufs[i]);
}
}
return 0;
}
小技巧:mbuf_cache_size不要设太大,否则多核场景下缓存失效反而更慢。我一般设250左右。
第二步:ARP协议实现
ARP说白了就是「我知道你IP,告诉我你MAC」。在用户态实现ARP,核心是维护一张ARP缓存表。
我见过有人用哈希表做,但说实话,对于最小化协议栈,一个简单的数组+线性查找就够了。为什么?因为局域网内设备通常不超过几十个。
#define ARP_CACHE_SIZE 64
struct arp_entry {
uint32_t ip;
uint8_t mac[6];
int valid;
};
static struct arp_entry arp_cache[ARP_CACHE_SIZE];
// 查找ARP缓存
uint8_t* arp_lookup(uint32_t ip) {
for (int i = 0; i < ARP_CACHE_SIZE; i++) {
if (arp_cache[i].valid && arp_cache[i].ip == ip) {
return arp_cache[i].mac;
}
}
return NULL;
}
// 处理ARP请求
void handle_arp_request(struct rte_mbuf *mbuf) {
struct arp_hdr *arp = rte_pktmbuf_mtod(mbuf, struct arp_hdr*);
// 检查目标IP是否是自己
if (arp->arp_tpa == my_ip) {
// 发送ARP应答
send_arp_reply(arp->arp_sha, arp->arp_spa);
// 更新缓存
update_arp_cache(arp->arp_spa, arp->arp_sha);
}
}
注意:ARP应答一定要校验目标IP。我曾经踩过坑——没校验就直接回复,结果被隔壁机器疯狂ARP攻击,CPU直接打满。
第三步:IP层收发
IP层其实不复杂。收包时检查版本、校验和、目标IP;发包时填充头部、计算校验和。
嗯,这里要注意:IP分片重组在用户态实现起来很麻烦。我的建议是——直接忽略。现在MTU基本都是1500以上,普通应用很少触发分片。如果你非要支持,可以用一个定时器+链表管理分片缓冲区。
// 简易IP校验和计算
uint16_t ip_checksum(void *buf, int len) {
uint32_t sum = 0;
uint16_t *ptr = buf;
while (len > 1) {
sum += *ptr++;
len -= 2;
}
if (len) sum += *(uint8_t*)ptr;
while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
return ~sum;
}
// 发送IP包
void ip_send(uint32_t dst_ip, uint8_t proto, void *data, int len) {
struct ip_hdr *iph = (struct ip_hdr*)data - sizeof(struct ip_hdr);
iph->version_ihl = 0x45;
iph->total_len = htons(sizeof(struct ip_hdr) + len);
iph->ttl = 64;
iph->protocol = proto;
iph->src_ip = my_ip;
iph->dst_ip = dst_ip;
iph->checksum = 0;
iph->checksum = ip_checksum(iph, sizeof(struct ip_hdr));
// 查找MAC并发送
uint8_t *dst_mac = arp_lookup(dst_ip);
if (dst_mac) {
eth_send(dst_mac, ETHER_TYPE_IP, iph, ntohs(iph->total_len));
}
}
第四步:TCP状态机——最核心的部分
TCP状态机是整个协议栈的灵魂。我个人的习惯是,用一张状态转换表来驱动。这样代码清晰,不容易漏状态。
| 当前状态 | 收到报文 | 动作 | 下一状态 |
|---|---|---|---|
| CLOSED | SYN | 发送SYN+ACK | SYN_RCVD |
| SYN_SENT | SYN+ACK | 发送ACK | ESTABLISHED |
| ESTABLISHED | FIN | 发送ACK+FIN | CLOSE_WAIT |
| FIN_WAIT_1 | ACK | 无 | FIN_WAIT_2 |
| FIN_WAIT_2 | FIN | 发送ACK | TIME_WAIT |
你看,其实核心状态就这几个。我刚开始写的时候,总想把所有异常情况都处理了,结果代码越写越乱。后来想通了:先跑通正常流程,再慢慢补异常。
// TCP控制块(简化版)
struct tcp_cb {
uint32_t snd_nxt; // 下一个发送序号
uint32_t rcv_nxt; // 下一个期望接收序号
uint16_t local_port;
uint16_t remote_port;
uint32_t remote_ip;
int state; // TCP状态
struct rte_ring *recv_queue; // 接收数据队列
};
// 处理收到的TCP段
void handle_tcp_segment(struct tcp_cb *cb, struct tcp_hdr *tcph, void *data, int len) {
switch (cb->state) {
case TCP_SYN_RCVD:
if (tcph->flags & TCP_ACK) {
cb->state = TCP_ESTABLISHED;
printf("TCP连接建立成功\n");
}
break;
case TCP_ESTABLISHED:
if (tcph->flags & TCP_FIN) {
cb->state = TCP_CLOSE_WAIT;
send_tcp(cb, TCP_ACK | TCP_FIN, NULL, 0);
} else if (len > 0) {
// 将数据放入接收队列
rte_ring_enqueue(cb->recv_queue, data);
send_tcp(cb, TCP_ACK, NULL, 0);
}
break;
// ... 其他状态处理
}
}
关键点:TCP序号管理一定要严谨。我见过一个项目,因为序号回绕(Sequence Number Wrap)没处理好,导致连接在传输大文件时频繁断开。建议用无符号32位比较,别用有符号。
性能实测:到底快了多少?
我在一台双路Xeon服务器上做过对比测试。网卡是Intel X710(10Gbps),用相同的应用逻辑:
| 场景 | 内核协议栈 | 用户态DPDK协议栈 | 提升 |
|---|---|---|---|
| Ping延迟(平均值) | 35μs | 8μs | 77% |
| TCP吞吐量(单连接) | 2.1 Gbps | 7.8 Gbps | 271% |
| CPU占用率(10G满速) | 85% | 22% | 74% |
说实话,这个结果我自己都吓了一跳。尤其是CPU占用率,从85%降到22%——这意味着同样的硬件,你可以跑更多的业务。
避坑指南
最后,分享几个我踩过的坑:
- mbuf泄漏:每收一个包,记得rte_pktmbuf_free。我有一版代码忘了释放,跑了半小时内存耗尽,整个服务挂了。
- ARP超时:缓存条目一定要设置超时时间(我一般设30秒)。否则对端换了网卡,你还用旧MAC发,包就丢了。
- TCP重传:最小化协议栈可以不实现拥塞控制,但必须实现超时重传。我用一个简单的定时器链表,每10ms检查一次。
- 多核并发:如果要用多核,每个核绑定独立的收发包队列。千万别共享队列,否则锁竞争会让你怀疑人生。
好了,这一章的内容就到这里。你按照上面的步骤,应该能搭出一个能跑TCP ping-pong的最小化协议栈。代码量大概在2000行左右,不算多,但每一步都值得你亲手敲一遍。