【C/C】从 DPDK 收包到用户态 TCP/IP 协议栈一个 ustack 示例拆解1. 先把边界说清楚DPDK 不是协议栈Linux 内核网络路径大致是电信号 - 网卡 - 网卡驱动 - TCP/IP 协议栈 - POSIX API - APPDPDK 做的事情是让应用绕过内核协议栈直接从网卡收发包。它更像“高速收包/发包工具箱”不是完整 TCP/IP 协议栈。真正的以太网、ARP、IP、UDP、TCP 解析和 socket 语义仍然要由用户态程序自己实现。这个ustack示例的分层可以理解成网卡队列 - DPDK PMD - rte_mbuf - ring - 协议解析 - UDP/TCP/KNI - 应用 API2. DPDK 初始化EAL、mempool、网卡队列用户态协议栈启动时第一步不是socket()而是初始化 DPDK 的运行环境和 mbuf 内存池。代码片段来自ustack-main/tcp.cif(rte_eal_init(argc,argv)0){rte_exit(EXIT_FAILURE,Error with EAL init\n);}structrte_mempool*mbuf_poolrte_pktmbuf_pool_create(mbuf pool,NUM_MBUFS,0,0,RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id());if(mbuf_poolNULL){rte_exit(EXIT_FAILURE,Could not create mbuf pool\n);}这里的rte_mbuf可以类比内核协议栈里的sk_buff它不是单纯的数据指针而是“报文数据 元信息”的载体。DPDK 收到包后应用拿到的不是裸字节数组而是一个个struct rte_mbuf *。网卡端口初始化的核心逻辑如下staticvoidng_init_port(structrte_mempool*mbuf_pool){uint16_tnb_sys_portsrte_eth_dev_count_avail();if(nb_sys_ports0){rte_exit(EXIT_FAILURE,No Supported eth found\n);}constintnum_rx_queues1;constintnum_tx_queues1;structrte_eth_confport_confport_conf_default;rte_eth_dev_configure(gDpdkPortId,num_rx_queues,num_tx_queues,port_conf);rte_eth_rx_queue_setup(gDpdkPortId,0,1024,rte_eth_dev_socket_id(gDpdkPortId),NULL,mbuf_pool);rte_eth_tx_queue_setup(gDpdkPortId,0,1024,rte_eth_dev_socket_id(gDpdkPortId),txq_conf);rte_eth_dev_start(gDpdkPortId);}这段代码做了三件关键事检查当前系统是否有 DPDK 可用网卡。配置 1 个 RX 队列和 1 个 TX 队列。把 RX 队列挂到前面创建的mbuf_pool上。如果要把性能扩展到多核后面通常会改成多 RX/TX 队列让不同 lcore 轮询不同队列。这也是为什么虚拟机环境里经常建议使用vmxnet3这类更适合多队列的虚拟网卡。3. 主循环网卡收包进 in ringout ring 再发回网卡示例代码没有把所有逻辑都堆在主循环里而是用两个 ring 做解耦ring-in主循环从网卡收到包后塞进去。ring-out协议栈或应用构造好回包后塞进去。pkt_process另一个 lcore 从in ring拿包做协议分发。主循环代码节选while(1){structrte_mbuf*rx[BURST_SIZE];unsignednum_recvdrte_eth_rx_burst(gDpdkPortId,0,rx,BURST_SIZE);if(num_recvd0){rte_ring_sp_enqueue_burst(ring-in,(void**)rx,num_recvd,NULL);}structrte_mbuf*tx[BURST_SIZE];unsignednb_txrte_ring_sc_dequeue_burst(ring-out,(void**)tx,BURST_SIZE,NULL);if(nb_tx0){rte_eth_tx_burst(gDpdkPortId,0,tx,nb_tx);for(unsignedi0;inb_tx;i){rte_pktmbuf_free(tx[i]);}}}这个结构很适合教学主循环只管高速搬运协议处理线程只管理解报文。后续要做 DDOS 检测、ACL、防火墙、用户态 TCP都可以挂在中间的协议处理层。4. 协议分发Ethernet - IPv4 - UDP/TCP/KNIpkt_process()是用户态协议栈真正开始“像协议栈一样工作”的地方。代码节选staticintpkt_process(void*arg){structrte_mempool*mbuf_pool(structrte_mempool*)arg;structinout_ring*ringringInstance();while(1){structrte_mbuf*mbufs[BURST_SIZE];unsignednum_recvdrte_ring_mc_dequeue_burst(ring-in,(void**)mbufs,BURST_SIZE,NULL);for(unsignedi0;inum_recvd;i){structrte_ether_hdr*ehdrrte_pktmbuf_mtod(mbufs[i],structrte_ether_hdr*);if(ehdr-ether_typerte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){structrte_ipv4_hdr*iphdrrte_pktmbuf_mtod_offset(mbufs[i],structrte_ipv4_hdr*,sizeof(structrte_ether_hdr));ng_arp_entry_insert(iphdr-src_addr,ehdr-s_addr.addr_bytes);if(iphdr-next_proto_idIPPROTO_UDP){udp_process(mbufs[i]);}elseif(iphdr-next_proto_idIPPROTO_TCP){ng_tcp_process(mbufs[i]);}else{rte_kni_tx_burst(global_kni,mbufs,num_recvd);}}}udp_out(mbuf_pool);ng_tcp_out(mbuf_pool);}}这段逻辑很直观先看二层ether_type只处理 IPv4。取出三层rte_ipv4_hdr。顺手把源 IP 和源 MAC 写入 ARP 表。根据next_proto_id分发到 UDP 或 TCP。不认识的包交给 KNI让内核继续处理。KNI 这一层在教学里很有价值不是所有协议都必须用户态自己写。你可以先实现 UDP/TCP 主路径不关心的报文交回内核降低调试难度。5. UDP 入应用缓冲从报文变成 recv 能读的数据UDP 的接收路径体现了“协议栈”和“应用 API”之间的分界。代码节选staticintudp_process(structrte_mbuf*udpmbuf){structrte_ipv4_hdr*iphdrrte_pktmbuf_mtod_offset(udpmbuf,structrte_ipv4_hdr*,sizeof(structrte_ether_hdr));structrte_udp_hdr*udphdr(structrte_udp_hdr*)(iphdr1);structlocalhost*hostget_hostinfo_fromip_port(iphdr-dst_addr,udphdr-dst_port,iphdr-next_proto_id);if(hostNULL){rte_pktmbuf_free(udpmbuf);return-3;}structoffload*olrte_malloc(offload,sizeof(structoffload),0);ol-dipiphdr-dst_addr;ol-sipiphdr-src_addr;ol-sportudphdr-src_port;ol-dportudphdr-dst_port;ol-protocolIPPROTO_UDP;ol-lengthntohs(udphdr-dgram_len);ol-datarte_malloc(unsigned char*,ol-length-sizeof(structrte_udp_hdr),0);rte_memcpy(ol-data,(unsignedchar*)(udphdr1),ol-length-sizeof(structrte_udp_hdr));rte_ring_mp_enqueue(host-rcvbuf,ol);pthread_mutex_lock(host-mutex);pthread_cond_signal(host-cond);pthread_mutex_unlock(host-mutex);rte_pktmbuf_free(udpmbuf);return0;}这里发生了一次语义转换rte_mbuf 中的完整以太网帧 - 提取 IPv4/UDP 头 - 找到绑定了目标 IP/端口的 localhost - 拷贝 payload 到 offload - 放入 host-rcvbuf - 唤醒正在 nrecvfrom 等待的应用线程这就是用户态协议栈实现recvfrom()的基础。应用看到的是“某个 socket 收到了数据”协议栈内部看到的是“某个 UDP 报文被解析并投递到对应接收队列”。6. TCP 更复杂它不只是多了一个头UDP 只要找到端口把 payload 投递到接收队列即可。TCP 不一样TCP 要维护连接状态LISTENSYN_RCVDESTABLISHEDCLOSE_WAITLAST_ACK示例代码里用struct ng_tcp_stream作为 TCBstructng_tcp_stream{intfd;uint32_tdip;uint16_tdport;uint16_tsport;uint32_tsip;uint32_tsnd_nxt;uint32_trcv_nxt;NG_TCP_STATUS status;structrte_ring*sndbuf;structrte_ring*rcvbuf;pthread_cond_tcond;pthread_mutex_tmutex;};这里最重要的不是字段多少而是含义sip/dip/sport/dport确定一条连接。snd_nxt/rcv_nxt维护序列号和确认号。status决定当前收到 SYN、ACK、PSH、FIN 时该怎么处理。sndbuf/rcvbuf把协议栈线程和应用线程隔离开。所以实现 TCP 的难点不在“能不能读出 TCP header”而在状态机、重传、窗口、乱序、拥塞控制等一整套行为。这个示例先实现了教学主干适合用来理解 TCP 协议栈的骨架。7. VMware DPDK 环境几个实战注意点结合目录里的笔记实验环境最容易卡在这些地方# 查看网卡和队列/中断线索ip-brlinkcat/proc/interrupts|grepens# DPDK 19.08.2 常见环境变量exportRTE_SDK/root/dpdk-stable-19.08.2/exportRTE_TARGETx86_64-native-linux-gcc# 编译老版本 DPDK 时新的 GCC 可能需要makeEXTRA_CFLAGS-fcommon-j$(nproc)虚拟机网络模式建议桥接虚拟机和物理网络同网段但桥接 WLAN 时二层帧不一定稳定。NAT适合上网不一定适合从主机直接构造二层包打进 DPDK。Host-only主机和 VM 在同一个干净的虚拟二层网络里配静态 ARP 后更适合先跑通实验。Windows 主机添加静态 ARP 的例子netsh interface ipv4 add neighbors 10 192.168.0.7 00-0C-29-5A-7A-0F如果使用 VFIO而 VMware 没暴露 IOMMU可以临时打开 no-IOMMU 模式做实验sudomodprobe vfioecho1|sudotee/sys/module/vfio/parameters/enable_unsafe_noiommu_modesudomodprobe vfio-pci这只建议在实验环境里使用。生产环境绕过 IOMMU 的安全隔离是不合适的。8. 总结这个ustack示例最值得看的地方是它把用户态协议栈拆成了几层很清楚的结构DPDK 收发包 - rte_mbuf 承载报文 - ring buffer 解耦线程 - Ethernet/IP/ARP/UDP/TCP 解析 - rcvbuf/sndbuf 承接应用 API - KNI 兜底交回内核写用户态协议栈时DPDK 解决的是高速 I/O真正决定系统行为的是协议栈本身ARP 表怎么维护UDP 怎么投递TCP 状态机怎么推进应用线程怎么被唤醒。把这些边界分清楚后面再看 mTCP、F-Stack、VPP、lwIP就不会只停留在“绕过内核很快”这一句口号上。学习链接: https://github.com/0voice
【C/C++】从 DPDK 收包到用户态 TCP/IP 协议栈:一个 ustack 示例拆解
【C/C】从 DPDK 收包到用户态 TCP/IP 协议栈一个 ustack 示例拆解1. 先把边界说清楚DPDK 不是协议栈Linux 内核网络路径大致是电信号 - 网卡 - 网卡驱动 - TCP/IP 协议栈 - POSIX API - APPDPDK 做的事情是让应用绕过内核协议栈直接从网卡收发包。它更像“高速收包/发包工具箱”不是完整 TCP/IP 协议栈。真正的以太网、ARP、IP、UDP、TCP 解析和 socket 语义仍然要由用户态程序自己实现。这个ustack示例的分层可以理解成网卡队列 - DPDK PMD - rte_mbuf - ring - 协议解析 - UDP/TCP/KNI - 应用 API2. DPDK 初始化EAL、mempool、网卡队列用户态协议栈启动时第一步不是socket()而是初始化 DPDK 的运行环境和 mbuf 内存池。代码片段来自ustack-main/tcp.cif(rte_eal_init(argc,argv)0){rte_exit(EXIT_FAILURE,Error with EAL init\n);}structrte_mempool*mbuf_poolrte_pktmbuf_pool_create(mbuf pool,NUM_MBUFS,0,0,RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id());if(mbuf_poolNULL){rte_exit(EXIT_FAILURE,Could not create mbuf pool\n);}这里的rte_mbuf可以类比内核协议栈里的sk_buff它不是单纯的数据指针而是“报文数据 元信息”的载体。DPDK 收到包后应用拿到的不是裸字节数组而是一个个struct rte_mbuf *。网卡端口初始化的核心逻辑如下staticvoidng_init_port(structrte_mempool*mbuf_pool){uint16_tnb_sys_portsrte_eth_dev_count_avail();if(nb_sys_ports0){rte_exit(EXIT_FAILURE,No Supported eth found\n);}constintnum_rx_queues1;constintnum_tx_queues1;structrte_eth_confport_confport_conf_default;rte_eth_dev_configure(gDpdkPortId,num_rx_queues,num_tx_queues,port_conf);rte_eth_rx_queue_setup(gDpdkPortId,0,1024,rte_eth_dev_socket_id(gDpdkPortId),NULL,mbuf_pool);rte_eth_tx_queue_setup(gDpdkPortId,0,1024,rte_eth_dev_socket_id(gDpdkPortId),txq_conf);rte_eth_dev_start(gDpdkPortId);}这段代码做了三件关键事检查当前系统是否有 DPDK 可用网卡。配置 1 个 RX 队列和 1 个 TX 队列。把 RX 队列挂到前面创建的mbuf_pool上。如果要把性能扩展到多核后面通常会改成多 RX/TX 队列让不同 lcore 轮询不同队列。这也是为什么虚拟机环境里经常建议使用vmxnet3这类更适合多队列的虚拟网卡。3. 主循环网卡收包进 in ringout ring 再发回网卡示例代码没有把所有逻辑都堆在主循环里而是用两个 ring 做解耦ring-in主循环从网卡收到包后塞进去。ring-out协议栈或应用构造好回包后塞进去。pkt_process另一个 lcore 从in ring拿包做协议分发。主循环代码节选while(1){structrte_mbuf*rx[BURST_SIZE];unsignednum_recvdrte_eth_rx_burst(gDpdkPortId,0,rx,BURST_SIZE);if(num_recvd0){rte_ring_sp_enqueue_burst(ring-in,(void**)rx,num_recvd,NULL);}structrte_mbuf*tx[BURST_SIZE];unsignednb_txrte_ring_sc_dequeue_burst(ring-out,(void**)tx,BURST_SIZE,NULL);if(nb_tx0){rte_eth_tx_burst(gDpdkPortId,0,tx,nb_tx);for(unsignedi0;inb_tx;i){rte_pktmbuf_free(tx[i]);}}}这个结构很适合教学主循环只管高速搬运协议处理线程只管理解报文。后续要做 DDOS 检测、ACL、防火墙、用户态 TCP都可以挂在中间的协议处理层。4. 协议分发Ethernet - IPv4 - UDP/TCP/KNIpkt_process()是用户态协议栈真正开始“像协议栈一样工作”的地方。代码节选staticintpkt_process(void*arg){structrte_mempool*mbuf_pool(structrte_mempool*)arg;structinout_ring*ringringInstance();while(1){structrte_mbuf*mbufs[BURST_SIZE];unsignednum_recvdrte_ring_mc_dequeue_burst(ring-in,(void**)mbufs,BURST_SIZE,NULL);for(unsignedi0;inum_recvd;i){structrte_ether_hdr*ehdrrte_pktmbuf_mtod(mbufs[i],structrte_ether_hdr*);if(ehdr-ether_typerte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){structrte_ipv4_hdr*iphdrrte_pktmbuf_mtod_offset(mbufs[i],structrte_ipv4_hdr*,sizeof(structrte_ether_hdr));ng_arp_entry_insert(iphdr-src_addr,ehdr-s_addr.addr_bytes);if(iphdr-next_proto_idIPPROTO_UDP){udp_process(mbufs[i]);}elseif(iphdr-next_proto_idIPPROTO_TCP){ng_tcp_process(mbufs[i]);}else{rte_kni_tx_burst(global_kni,mbufs,num_recvd);}}}udp_out(mbuf_pool);ng_tcp_out(mbuf_pool);}}这段逻辑很直观先看二层ether_type只处理 IPv4。取出三层rte_ipv4_hdr。顺手把源 IP 和源 MAC 写入 ARP 表。根据next_proto_id分发到 UDP 或 TCP。不认识的包交给 KNI让内核继续处理。KNI 这一层在教学里很有价值不是所有协议都必须用户态自己写。你可以先实现 UDP/TCP 主路径不关心的报文交回内核降低调试难度。5. UDP 入应用缓冲从报文变成 recv 能读的数据UDP 的接收路径体现了“协议栈”和“应用 API”之间的分界。代码节选staticintudp_process(structrte_mbuf*udpmbuf){structrte_ipv4_hdr*iphdrrte_pktmbuf_mtod_offset(udpmbuf,structrte_ipv4_hdr*,sizeof(structrte_ether_hdr));structrte_udp_hdr*udphdr(structrte_udp_hdr*)(iphdr1);structlocalhost*hostget_hostinfo_fromip_port(iphdr-dst_addr,udphdr-dst_port,iphdr-next_proto_id);if(hostNULL){rte_pktmbuf_free(udpmbuf);return-3;}structoffload*olrte_malloc(offload,sizeof(structoffload),0);ol-dipiphdr-dst_addr;ol-sipiphdr-src_addr;ol-sportudphdr-src_port;ol-dportudphdr-dst_port;ol-protocolIPPROTO_UDP;ol-lengthntohs(udphdr-dgram_len);ol-datarte_malloc(unsigned char*,ol-length-sizeof(structrte_udp_hdr),0);rte_memcpy(ol-data,(unsignedchar*)(udphdr1),ol-length-sizeof(structrte_udp_hdr));rte_ring_mp_enqueue(host-rcvbuf,ol);pthread_mutex_lock(host-mutex);pthread_cond_signal(host-cond);pthread_mutex_unlock(host-mutex);rte_pktmbuf_free(udpmbuf);return0;}这里发生了一次语义转换rte_mbuf 中的完整以太网帧 - 提取 IPv4/UDP 头 - 找到绑定了目标 IP/端口的 localhost - 拷贝 payload 到 offload - 放入 host-rcvbuf - 唤醒正在 nrecvfrom 等待的应用线程这就是用户态协议栈实现recvfrom()的基础。应用看到的是“某个 socket 收到了数据”协议栈内部看到的是“某个 UDP 报文被解析并投递到对应接收队列”。6. TCP 更复杂它不只是多了一个头UDP 只要找到端口把 payload 投递到接收队列即可。TCP 不一样TCP 要维护连接状态LISTENSYN_RCVDESTABLISHEDCLOSE_WAITLAST_ACK示例代码里用struct ng_tcp_stream作为 TCBstructng_tcp_stream{intfd;uint32_tdip;uint16_tdport;uint16_tsport;uint32_tsip;uint32_tsnd_nxt;uint32_trcv_nxt;NG_TCP_STATUS status;structrte_ring*sndbuf;structrte_ring*rcvbuf;pthread_cond_tcond;pthread_mutex_tmutex;};这里最重要的不是字段多少而是含义sip/dip/sport/dport确定一条连接。snd_nxt/rcv_nxt维护序列号和确认号。status决定当前收到 SYN、ACK、PSH、FIN 时该怎么处理。sndbuf/rcvbuf把协议栈线程和应用线程隔离开。所以实现 TCP 的难点不在“能不能读出 TCP header”而在状态机、重传、窗口、乱序、拥塞控制等一整套行为。这个示例先实现了教学主干适合用来理解 TCP 协议栈的骨架。7. VMware DPDK 环境几个实战注意点结合目录里的笔记实验环境最容易卡在这些地方# 查看网卡和队列/中断线索ip-brlinkcat/proc/interrupts|grepens# DPDK 19.08.2 常见环境变量exportRTE_SDK/root/dpdk-stable-19.08.2/exportRTE_TARGETx86_64-native-linux-gcc# 编译老版本 DPDK 时新的 GCC 可能需要makeEXTRA_CFLAGS-fcommon-j$(nproc)虚拟机网络模式建议桥接虚拟机和物理网络同网段但桥接 WLAN 时二层帧不一定稳定。NAT适合上网不一定适合从主机直接构造二层包打进 DPDK。Host-only主机和 VM 在同一个干净的虚拟二层网络里配静态 ARP 后更适合先跑通实验。Windows 主机添加静态 ARP 的例子netsh interface ipv4 add neighbors 10 192.168.0.7 00-0C-29-5A-7A-0F如果使用 VFIO而 VMware 没暴露 IOMMU可以临时打开 no-IOMMU 模式做实验sudomodprobe vfioecho1|sudotee/sys/module/vfio/parameters/enable_unsafe_noiommu_modesudomodprobe vfio-pci这只建议在实验环境里使用。生产环境绕过 IOMMU 的安全隔离是不合适的。8. 总结这个ustack示例最值得看的地方是它把用户态协议栈拆成了几层很清楚的结构DPDK 收发包 - rte_mbuf 承载报文 - ring buffer 解耦线程 - Ethernet/IP/ARP/UDP/TCP 解析 - rcvbuf/sndbuf 承接应用 API - KNI 兜底交回内核写用户态协议栈时DPDK 解决的是高速 I/O真正决定系统行为的是协议栈本身ARP 表怎么维护UDP 怎么投递TCP 状态机怎么推进应用线程怎么被唤醒。把这些边界分清楚后面再看 mTCP、F-Stack、VPP、lwIP就不会只停留在“绕过内核很快”这一句口号上。学习链接: https://github.com/0voice