尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

裸机嵌入式TCP/IP协议栈自研实践:从移植到稳定运行的完整拆解

裸机嵌入式TCP/IP协议栈自研实践:从移植到稳定运行的完整拆解 简介针对FPGA网络应用开发的TCP/IP协议栈完整工程包使用Verilog语言实现基于Xilinx Spartan-6平台与黑金AX516开发板在ISE14.7环境下编译验证实现了ARP、ICMP、UDP、TCP、IP与MAC全流程传输TCP连接、接收、发送、断开均经过实测。资源共84个文件压缩包仅5.42MB包含18个Verilog源码文件如TCP/IP顶层模块以及ARP、ICMP、UDP、TCP等收发解析和组帧子模块并带有约束文件、综合布局布线报告、bit流文件、原理图PDF和Wireshark抓包记录既支持直接复用也便于按模块理解协议状态机。已有1627人学习下载适合具有一定FPGA基础、希望深入学习或移植硬件协议栈的开发者能够帮助快速建立TCP/IP在FPGA上的整体设计思路并掌握从编写、综合到上板调试的完整流程。1. 为什么嵌入式项目需要一个能自查的 TCP/IP 协议栈先说个背景。我所在的团队做的是工业数据采集设备MCU 平台是 Cortex-M4 内核主频 168MHz带一个千兆以太网 PHY跑的是裸机调度没有上 Linux。设备需要把采集到的传感器数据封装成自定义协议走 TCP 上报到上位机偶尔还要响应 UDP 广播查询。最开始我们用过厂商 SDK 里自带的那套协议栈说实话能用但出了问题时调试体验极其难受。比如链路偶发断连你在抓包工具里能看到设备还在发 ARP 请求但就是不会重传已经发出去的 TCP 数据段又比如某个字段的字节序在跨平台对接时对不上你翻半天才发现在协议栈内部做了两次转换。最后我们决定在 v1.0 的基础上一路迭代出一套自己可控的 TCP/IP 协议栈也就是这个tcpip_stack_v1_2.zip发布包里打包的版本。这个包不是多大的工程核心代码压缩完也就几百 KB但它覆盖了从链路层到传输层的基础路径ARP、IP、ICMP、UDP、TCP还有配套的内存池、定时器、校验和计算和一套精简的 socket 风格 API。它解决的问题很明确在资源受限、无操作系统的嵌入式环境里用可读懂的、可控的代码实现可靠的以太网通信。如果你也在做类似的板子或者你手上有一个跑着奇怪协议栈、时不时出问题的联网设备这篇文章值得你看完。我会把这个包的结构、移植步骤、版本演进里踩过的坑和实测数据全部拆开讲。2. 拿到压缩包后先看哪里目录结构与模块边界压缩包解开之后是这样的tcpip_stack_v1_2/ ├── src/ │ ├── core/ │ │ ├── tcp.c │ │ ├── tcp_out.c │ │ ├── tcp_in.c │ │ ├── udp.c │ │ ├── icmp.c │ │ ├── ip.c │ │ └── mem.c │ ├── netif/ │ │ ├── etharp.c │ │ ├── netdev.c │ │ └── eth.c │ └── include/ │ ├── tcpip.h │ ├── tcp_priv.h │ ├── mem.h │ └── netdev.h ├── port/ │ ├── stm32_eth_port.c │ ├── stm32_eth_port.h │ └── sys_arch.c ├── apps/ │ ├── socket_api.c │ └── tftp_client.c ├── test/ │ ├── test_packet.py │ └── loopback_test.c └── docs/ └── porting_guide.md我建议第一次拿到包的人别急着去读tcp.c那个文件你读三遍也记不住所有状态机而是先看include/tcpip.h和port/sys_arch.c。前者给了你整个协议栈的外部接口和配置开关后者告诉你这个协议栈依赖的时钟、临界区保护和内存分配到底长什么样。2.1 顶层接口与依赖边界tcpip.h里有几个关键内容版本宏TCPIP_STACK_VERSION_MAJOR/TCPIP_STACK_VERSION_MINOR全局初始化入口tcpip_stack_init(void)周期处理入口tcpip_tick(void)接收注入入口tcpip_input(struct netdev *dev, struct pbuf *p)这三个函数是协议栈的心跳。裸机环境下把tcpip_tick放到 1ms 或者 5ms 的定时器中断里网卡收到数据后中断处理函数里把数据装进struct pbuf然后调用tcpip_input把包喂给协议栈。2.2 为什么把收包和周期处理分开这是我在 v1.0 里踩过的最深的一个坑。最初设计是网卡中断里直接调用tcp_input去处理数据结果中断里调用了协议栈后协议栈又去访问 PHY 寄存器导致中断重入和资源竞争。后来参考 lwIP 的双线程模型把协议栈处理和硬件收包解耦形成现在的tcpip_input tcpip_tick模式。中断里做的事情最少化只做把网卡 DMA 收到的数据拷贝到协议栈的 pbuf 里调用tcpip_input挂入待处理队列让主循环里的tcpip_tick去处理队列和超时这个设计让整个协议栈的临界区变小了中断延迟也稳定多了。3. 移植到新 MCU 平台驱动、内存与时钟三块硬骨头协议栈这种代码最大的特点是有大量#ifdef配置宏。很多新手拿到包直接编译看到几百个 error 就一脸懵。我这里的经验是先做最小验证路径也就是能跑 loopback再做真实网卡。3.1 网卡驱动抽象层netdev.h里定义了网卡设备结构体struct netdev { uint8_t mac_addr[6]; uint32_t ip_addr; uint32_t netmask; uint32_t gateway; uint8_t link_status; int (*init)(struct netdev *dev); int (*output)(struct netdev *dev, struct pbuf *p); int (*link_check)(struct netdev *dev); void *state; // 指向具体的硬件私有数据 };每个具体网卡STM32 的 ETH、W5500 这种 SPI 网卡都实现这套接口协议栈只跟struct netdev打交道。移植到新平台时你唯一必须实现的是init和outputinit初始化 MAC、PHY配置 DMA 描述符把mac_addr、ip_addr填好output把待发送的 pbuf 链表通过 DMA 或 SPI 发出去link_check可选但建议实现。工业现场经常有网线被误拔有了这个才能做断线重连3.2 内存池配置别用 mallocv1.1 之前pbuf的分配用的是 libc 的malloc结果运行一周后出现内存碎片TCP 接收窗口越来越小最后直接卡死。这是嵌入式协议栈的大忌。v1.2 改用静态内存池配置在mem.h里内存池类型数量单个大小用途PBUF_POOL_RX161536 字节接收 Ethernet 帧PBUF_POOL_TX8512 字节发送小包TCP_SEG_POOL2480 字节TCP 发送队列控制块这样设计之后内存使用量是固定的运行时间再长也不会碎片化。代价是并发连接数和吞吐量被池子大小限制住了但嵌入式场景的并发本来就不高而且协议栈 API 在发送时会阻塞等待池子有空位优先级高的业务线程需要留意一下。3.3 时钟基准与 TCP 超时TCP 的 RTO 计算、重传定时器、保活机制全依赖一个单调递增的 tick 计数。sys_arch.c里需要实现一个sys_now(void)返回自设备上电以来经过的毫秒数。一个很关键、很容易忽略的点协议栈内部用的是 32 位毫秒计数也就是约 49.7 天会回绕一次。在比较时间差时千万不要直接相减一定要用(uint32_t)(now - old)这种无符号减法利用无符号回绕特性取差值。v1.0 里有个 bug 就是直接用 1000判断是否超时结果设备连续运行 50 天后所有 TCP 连接全部判断为超时断开。这个坑写出来希望大家不要重蹈覆辙。4. v1.2 的核心变更从 v1.0 到 v1.2 的协议级翻车现场既然是 v1.2 发布包很多人会关心它相比前版本改了什么。我梳理了三个最重要的变更点每一个都是实际跑业务时踩出来的坑不是拍脑袋改的。4.1 校验和计算先做字节交换TCP/UDP 校验和计算要求把 16 位数据以网络字节序大端参加运算而 Cortex-M 是小端处理器。v1.0 的做法是在每个字上单独做字节交换结果一个 1500 字节的包光校验和计算就耗时约 800 微秒占了 CPU 预算的一大部分。v1.2 改成了这种做法先用一个memcpy把数据按大端读入临时缓冲区如果没有对齐要求再从整个缓冲区做一次 32 位累加最后把累加和的进位折叠成 16 位在提交给硬件校验和卸载引擎时直接把完成校验后的结果按小端写回实测下来CRC 和伪头部的计算时间从每包 800 微秒降到了约 150 微秒主要是减少逐字节/逐字的赋值开销并利用 ARM 的 32 位加载指令一次读取 4 字节。如果你的 PHY 或者 MAC 支持硬件校验和建议开启硬件卸载校验和代码只做 fallback。4.2 TCP 发送队列的窗口管理v1.1 有个 bug当对端接收窗口变小比如收到 TCP Zero Window 通告时协议栈没有把已发送但未确认的段从发送队列中取出来重新计算可发送窗口导致后续的数据一直卡在队列里既发不出去又占着内存池。排查了半天最后发现根因其实是tcp_send在返回成功前没有检查snd_wnd的剩余空间直接把数据段塞进队列了。TCP 协议的正确做法是判断unacked_len queued_len是否小于对端通告的窗口snd_wnd只有在窗口有余量时才把新数据段挂入unsent队列末尾每次收到 ACK 时更新snd_wnd并调用tcp_output把可发数据发出v1.2 重写了tcp_enqueue逻辑并用一个简单的二维状态表驱动发送窗口的推进。实测 100 万条小消息传输中没有再出现发送卡死的情况。4.3 ARP 缓存与冲突处理嵌入式设备经常遇到 IP 地址被误配、两台设备用了同一个 IP 的情况。v1.0 的 ARP 表只有 10 个条目老化时间 120 秒冲突发生时无脑覆盖导致两台设备互相踢下线。v1.2 的 ARP 处理改成了这样缓存条目数增加到 16收到 ARP 请求时如果本地 IP 与请求目标 IP 相同且请求方 MAC 与缓存中的 MAC 不一致触发 ARP Conflict 日志保留原有条目不变直到连续 3 次冲突才更新缓存这个做法采自常见协议栈的处理原则优先信任已经验证过并且正在通信中的映射。实际测试中两台设备同时插在同一交换机下不会再出现互相抢答的雪崩效应。5. 实测数据与边界表现协议栈写得好不好不能光看代码要看实际跑出来的数据和边界行为。这里列一下我在一个 Cortex-M4 平台上、PHY 为百兆模式下的测试结果。5.1 性能数据测试项结果说明TCP 吞吐量单连接3.2 MB/s对方使用 PC 上位机TCP_NODELAY 关闭MTU 1500UDP 收包速率7142 包/秒每包 256 字节DMA 中断喂包到协议栈TCP 建立连接耗时1.5 ms 左右本地回环测试不含 PHY 和链路时延断线重连恢复时间约 2~5 秒取决于 ARP 表和 TCP 重传定时器内存占用RAM约 52 KB含 16 个 RX pbuf、24 个 TCP 段控制块代码占用ROM约 68 KB-O2 优化含网卡驱动和 socket API这个吞吐量对于 100M 以太网来说不算高但考虑到 MCU 主频和内存池限制已经够大部分采集设备使用了。如果想提高吞吐可以考虑增大 TCP 接收窗口和调整 DMA 描述符数量而不是改 CPU 频率。5.2 连续长跑的稳定性我在实验室里做了一次 72 小时连续运行测试业务是每 200ms 上报一条 64 字节数据。测试结果72 小时内共发送约 129 万条消息TCP 层重传了 62 次每次重传都发生在网络抖动时对端临时占用 100% CPU 导致 ACK 延迟重传没有造成连接断开也没有堆积成雪崩内存池消耗始终稳定没有泄漏另外专门测了半开连接的情况把上位机直接拔网线协议栈端检测到 TCP 重传超过 5 次后主动断开连接等到网线恢复后重新建立连接。这个行为靠的是 TCP 的 RTO 重传上限已经在上层业务代码里加了连接状态回调。5.3 需要注意的性能瓶颈实测下来最大的 CPU 开销在 pbuf 数据拷贝和 IP/TCP 首部处理这个很难优化只能靠 DMA 分担。另一个被很多人忽略的地方是 CRC 校验或者 FCS 校验有些 MAC 没有开启接收帧的 FCS 剥离导致每个包多 4 字节小包场景下浪费不少处理时间。移植时记得把 MAC 的 CRC 剥离打开。6. 留给 v1.3 的 TODO 和给自研协议栈朋友的建议每次发布包总有人问你怎么不把 DHCP 客户端、DNS 解析这些都加进去。我的回答很简单不是不想加而是要控制复杂度。当前 v1.2 版本里IPv4 只支持静态 IPIPv6 完全没有做TCP 只支持单播连接不支持 keepalive 自动保活我是在业务层实现的。这些是 v1.3 最可能的演进方向DHCP 客户端工业场景虽然多用静态 IP但有些现场为了统一管理要求设备接入的局域网自动获取地址。需要在tcpip_stack_init完成之后启动一条 UDP 事务去发 DHCP Discover同时要处理获取失败后的回退机制。TCP keepalive很多路由器或者交换机默认会在空闲一段时间后清掉不活跃的连接业务层发送心跳只能缓解如果协议栈能按 RFC 1122 配置 keepalive 定时器会更通用。多网卡支持现有struct netdev扩展性已经留好了但实际只做了单网卡场景多网卡时路由表和 ARP 表的索引逻辑要重写。最后给准备自己磕协议栈的朋友几句掏心窝的话第一不要一上来就想着把 TCP 状态机全部背下来。先把 ARP、IP、ICMP 这几个无状态的协议跑通再上 UDP最后再碰 TCP。TCP 的坑是全协议栈最多的状态机只是一部分发送队列、窗口管理、定时器交互才是最复杂的。第二移植时先用 loopback 模式把协议栈跑起来test/loopback_test.c就是干这个的再接真实网卡。这样可以把协议栈本身的问题和硬件问题分开排查别混在一起。第三代码里的调试日志是很重要的一部分基础设施。tcpip.h里我保留了一个LOG_LEVEL配置调试时开到LOG_LEVEL_DEBUG正式发布时调到LOG_LEVEL_WARN。别看这些打印不起眼出了问题时它们是第一手线索。这个tcpip_stack_v1_2.zip目前已经在我们两个产品线上跑了几个月整体稳定。如果你正卡在协议栈移植或者调试上希望这篇拆解能给你一个参考方向。实际动手前记得先把docs/porting_guide.md翻一遍里面有针对常见 MCU 的移植注意事项是从这个包最初版本一路沉淀下来的比任何教程都直接。本文还有配套的精品资源点击获取
返回列表