FreeRTOS与lwIP整合实战:构建稳定TCP/IP通信链路

📅 2026/8/26 2:48:05
FreeRTOS与lwIP整合实战:构建稳定TCP/IP通信链路
开篇先交代一下背景。我最近在做一套基于 FreeRTOS 的工业数据采集网关前期几篇文章分别讲了任务调度、队列、信号量、软件定时器和内存管理算是把 RTOS 的基础轮子都过了一遍。硬件平台是一块 Cortex-M7 内核的 MCU主频跑在 400MHz板载一颗百兆 PHY 芯片软件工程用 CMake 管理配合新唐、ST 或者 NXP 的 SDK 都可以直接编译。这一篇 Part 7 重点聊一个很多朋友私信问我的问题FreeRTOS 怎么和 TCP/IP 协议栈配合把数据发送到互联网上。先说结论单独跑 FreeRTOS 只能帮你把任务调度、资源互斥、中断延迟这些事管好它本身不提供任何网络协议栈能力。要让设备真正“上网”你需要一套 TCP/IP 协议栈配合 FreeRTOS 内核工作。目前主流的方案有三类第一是 lwIP轻量级、开源、专门为嵌入式设计配合 FreeRTOS 的移植方案非常成熟第二是实验室级别的 UDP/TCP 裸机协议栈比如各家芯片厂商 SDK 里自带的第三是商业闭源协议栈比如 InterNiche、EMC 或者某些 RTOS 厂商的商业授权版本。对于个人开发者、产品原型验证、以及大多数工业控制和物联网接入场景lwIP 几乎是最优解。免费、可裁剪、对硬件资源要求不高而且和 FreeRTOS 的配合已经有非常完整的官方移植层。这篇文章不是从零教你写协议栈而是从“怎么把 FreeRTOS 和 TCP/IP 协议栈整合到一套可用的工程里并且让数据稳定地发上互联网”这个角度出发。我会用 lwIP 作为主线详细讲清楚连接建立的过程、内存和中断的处理方式、socket API 的使用细节以及几个我实际踩过的大坑。看完这篇文章你应该能把一块裸机工程改造成 FreeRTOS TCP 设备并跑通 TCP 客户端和服务端通信。1. 内容整体设计与思路拆解1.1 为什么是 lwIP而不是自己写协议栈或者用厂商闭源方案先把方案选型的逻辑理清楚。裸机或 RTOS 环境下做网络通信很多新手第一反应是翻芯片厂商 SDK 自带的以太网例程简单改改寄存器把 PHY 初始化好然后调用几个封装好的函数把数据发出去。这种方案确实能用但有两个问题第一厂商的协议栈往往只覆盖基础功能TCP 重传、拥塞控制、超时管理等处理得比较粗糙设备在上线十分钟之后出现连接不稳定、重传风暴、内存泄漏的概率非常高第二如果以后要换主控芯片协议栈也要跟着换迁移成本大。lwIP 恰好解决了这两个痛点。它在嵌入式领域有接近二十年的应用积累完整实现了 TCP、UDP、ICMP、IGMP、DHCP、DNS、PPP 等协议代码结构清晰所有与硬件和操作系统相关的部分都隔离在sys_arch.c和netif.c这两个文件里。如果你用的是 FreeRTOS社区早就把sys_arch.c的移植模板封装好了你只需要处理网卡驱动的适配也就是把 MAC 控制器和 PHY 芯片的初始化、发送、接收、中断处理这几件事接上协议的复杂逻辑全部交给 lwIP 内核。还有一个重要的考量lwIP 提供了三种 API 模式分别是 raw API、lwip API也被称为 netconn API和 socket API。raw API是回调机制性能和实时性最好但代码写起来容易绕netconn API是线程化的 API把协议处理放到独立的 tcpip 线程应用层通过队列和互斥量来收发数据编码难度适中socket API则是在 netconn 基础上又封装了一层接口风格和桌面 Linux/Windows 下的 socket 编程几乎一致。对大多数做产品搞定的工程师来说socket API 是最容易上手也是代码迁移性最好的。1.2 数据通路从网线到应用任务一个 TCP 报文是怎么流动的理解 FreeRTOS TCP 整条数据通路比单纯记住几个 API 重要得多。我把它拆成四个阶段物理层和链路层。PHY 芯片负责把数字信号通过网线发送到物理介质同时也负责接收对端发来的模拟信号并转换成数字信号。MAC 控制器则在 PHY 之上完成 MAC 帧的组装、地址过滤、CRC 校验等工作。在收到一个正确的以太网帧之后MAC 控制器会通过 DMA 把整包数据搬运到内存中的缓冲区然后触发一个接收中断。中断处理与协议栈线程。在裸机工程里中断服务函数会直接处理网络数据但在 FreeRTOS 下我们会采用一个更稳妥的方式中断服务函数只把这个 DMA 缓冲区挂到一个队列或者交给tcpip_input()函数真正的协议栈处理逻辑放到一个高优先级或者中优先级的tcpip_thread中去执行。原因是 TCP/IP 协议处理耗时较长比如 TCP 段重排、校验和计算、缓冲管理这些操作如果在中断上下文里完成会严重破坏系统的实时性甚至导致更高级别的中断无法响应。让协议栈跑在线程中相当于把耗时操作从硬实时上下文剥离出来这是整个系统能够稳定运行的核心设计思路。TCP 协议栈与 socket。数据进入 tcpip_thread 之后lwIP 内核会根据以太网帧头里的协议字段把数据分发给 ICMP、UDP 或者 TCP 处理模块。如果是 TCP 报文就会经过状态机校验、序号检查、解包负载然后写入对应 socket 的接收缓冲区。应用层的任务通过网络 API 读取数据例如lwip_recv()实际上是做一个阻塞式队列读取数据已经提前被内核拷贝到用户缓冲区了。应用处理与反向发送。应用从 socket 中读出来的是去除 TCP 头、IP 头、以太网帧头之后的应用层数据也就是你在 Modbus TCP 或者 HTTP 协议中真正关心的那部分内容。应用计算出结果调用lwip_send()数据从用户缓冲区拷贝到 lwIP 内核发送队列再经过 TCP 分段、IP 封装、MAC 填充最终写到 DMA 发送描述符由 MAC 控制器把整帧发送出去。我画不出流程图但这个逻辑链路请务必自己理清楚硬件中断、内核线程、应用任务这三个上下文之间靠队列和信号量衔接。这个理解到位了后面排查问题会非常轻松。1.3 对资源占用和实时性的平衡考虑FreeRTOS 本身内核非常小ROM 开销一般在 6~12 KB 左右RAM 开销根据任务数量和队列数量变化。加上 lwIP 之后资源占用会明显增加。我实测过一份裁剪比较充分的 lwIP 配置TCP 协议栈、DHCP、DNS、socket API 全部开启编译出来 ROM 大约占用 45~60KBRAM 静态分配加动态分配总共在 40~80KB 不等。这个量级对目前主流的 Cortex-M3/M4/M7 芯片来说完全不是问题但如果你还在用 64KB Flash、20KB RAM 这种极小资源芯片就需要注意裁剪。实时性和吞吐往往是一对矛盾。lwIP 默认把所有协议处理放在一个线程好处是实现简单不会出现多线程并发访问协议栈内部数据结构导致的竞争问题代价是协议处理的实时性上限就是这个线程的优先级。如果系统的业务逻辑非常复杂比如需要同时处理多个 TCP 连接和大吞吐量的数据转发可以考虑开多核或者将不同协议处理分散到多个线程但这会显著增加复杂度和调试成本不推荐新手上来就做这种优化。综合考虑之后我的建议是初版工程先用官方推荐的单 tcpip_thread 模式配合 socket API把功能跑通。性能和吞吐的问题等产品有明确指标要求之后再优化。2. 核心细节解析与实操要点2.1 FreeRTOS 配置对 TCP/IP 协议栈的影响很多朋友把 lwIP 移植失败归咎于 lwIP 本身实际上问题出在 FreeRTOS 的配置文件上。lwIP 依赖 FreeRTOS 提供的队列、信号量、互斥量和动态内存管理机制如果这些基础能力配置不对协议栈很容易出现卡死、崩溃、连接失败的现象。先说互斥量。lwIP 的 socket API 是线程安全的同一时刻允许多个任务访问不同 socket也允许不同任务访问同一个 socket。这个安全性的底层保障就是 FreeRTOS 的互斥量。在配置文件FreeRTOSConfig.h中必须确保configUSE_MUTEXES和configUSE_RECURSIVE_MUTEXES都设置为 1。递归互斥量可能在你最初用不到但某些 lwIP 版本内部会使用嵌套锁保险起见建议打开。再说信号量和队列。configUSE_COUNTING_SEMAPHORES需要设为 1configUSE_QUEUE_SETS根据你是否使用事件驱动方式处理多个 socket 来决定。如果只做简单的单个 TCP 客户端队列集不是必须的但如果要同时管理多个 TCP 连接或者同时处理 TCP 和 UDP队列集会大大简化事件分发逻辑。然后是堆内存。很多嵌入式开发者会把configTOTAL_HEAP_SIZE设得比较小因为裸机下 RAM 紧张习惯了。但运行 lwIP 之后协议栈的 PCB 控制块、socket 结构体、发送和接收缓冲区都依靠 FreeRTOS 的堆来分配。如果heap_4.c或heap_5.c的堆空间不够最典型的现象表现是lwip_socket()调用失败或者lwip_send()返回内存不足错误。我在调试一个项目时把堆大小从 32KB 调到 80KB 后所有诡异问题瞬间消失。所以当你发现“我用 socket API 连接不上服务器”时第一步不是查网络而是查 FreeRTOS 堆是否够用。最后要检查任务数量和优先级配置。lwIP 官方移植模板通常建议创建如下任务一个tcpip_thread一个ethernet_input_thread一个ethernet_link_thread。tcpip_thread的优先级建议设为高于中等、低于硬实时任务。如果你把协议栈线程的优先级设得比所有业务任务都低在高负载下 TCP 的确认包可能迟迟得不到处理导致对端反复超时重传连接质量巨差。2.2 lwIP 选项裁剪哪些必须开哪些可以关lwIP 有很多配置宏全部写在lwipopts.h这个文件里。新手容易犯的错误是照抄某篇例程的配置结果和需求不匹配或者干脆不加裁剪一股脑全开导致编译大、内存爆。我根据自己的项目经验列几个关键配置项和推荐值配置宏推荐值说明NO_SYS0必须设为 0表示使用操作系统。设为 1 是裸机模式。LWIP_SOCKET1启用 socket API使用 lwip_socket 系列函数。LWIP_NETCONN1启用 netconn APIsocket API 依赖它。LWIP_TCP1启用 TCP 协议。LWIP_UDP1启用 UDP 协议。Modbus UDP 和部分 MQTT 实现需要。LWIP_DHCP1启用 DHCP 客户端适用于接入路由器自动获取 IP。LWIP_DNS1启用 DNS 客户端方便用域名连接服务器。LWIP_NETIF_STATUS_CALLBACK1网卡状态变化回调用于检测网线插拔。LWIP_NETIF_LINK_CALLBACK1链路状态回调配合状态回调使用。MEM_SIZE40960 起协议栈堆内存大小单位字节可根据实际 RAM 调整。MEMP_NUM_NETCONN8 起最大 netconn 数量对应并发连接数。MEMP_NUM_TCP_PCB8 起最大 TCP PCB 数量同时可以打开的 TCP 连接数量。PBUF_POOL_SIZE32 起用于接收数据的 pbuf 池数量调大可以提升突发数据吸收能力。TCP_SND_BUF8192TCP 发送缓冲大小决定单个 socket 的发送吞吐。TCP_WND8192TCP 接收窗口大小决定单个 socket 的接收吞吐。有些配置项则可以根据项目实际情况关闭。比如LWIP_IGMP是组播协议不做组播应用就关掉LWIP_SNMP是简单网络管理协议不涉及网管就关掉LWIP_AUTOIP是自动 IP 地址分配机制绝大多数场景用不到。还有LWIP_NETIF_LOOPBACK本地回环如果不做本机内部通信测试也可以关掉。裁剪的精髓在于每个不开的宏都在帮你省掉一部分 RAM 和 Flash。2.3 网卡驱动与 PHY 芯片的适配很多 FreeRTOS lwIP 的工程移植不成功根源不在软件协议栈而在网卡驱动。lwIP 与网卡驱动之间有一个netif层官方文档叫“网络接口”你可以理解为一个适配器。网卡驱动需要实现netif-outputIP 层输出、netif-linkoutput链路层输出以及初始化时把netif-mtu、netif-hwaddr_len、netif-hwaddr等字段填好。在实际移植时我建议按以下顺序排查网卡驱动这几个点PHY 地址是否正确。大多数 PHY 芯片可以通过MDIO/MDC引脚配置地址典型地址是 0、1、4、31 这些值。你的 MAC 控制器初始化代码里phy_address参数必须与实际硬件上的 PHY 地址一致。用逻辑分析仪抓 MDIO 通信波形是排查此类问题最快速的手段。PHY 复位时序。部分 PHY 芯片需要硬件复位引脚控制复位信号至少保持 10ms然后再等待时钟稳定。如果初始化顺序不对PHY 可能根本不会进入正常模式。这也是一个经典问题点。DMA 描述符和缓冲区分配。MAC 控制器的 DMA 描述符需要与 lwIP 提供的 pbuf 结构协同工作。有些驱动实现用的是静态数组作为接收缓冲区然后传递给 lwIP这可以提高效率有些驱动则需要从 lwIP 的 pbuf 池中获取缓冲区。无论哪种方式都需要保证 DMA 访问的内存区域在物理上是连续的。如果 MCU 有 D-Cache 且开启了 Cache还需要注意 DMA 缓冲区的一致性维护否则收发数据会随机出错。中断优先级。MAC 接收中断和 PHY 事件中断的 NVIC 优先级要设置合理建议低于系统节拍时钟SysTick和任何硬实时关键中断但高于普通任务。如果在中断里调用tcpip_input()还需要注意 FreeRTOS 的configMAX_SYSCALL_INTERRUPT_PRIORITY限制确保中断优先级数值大于等于该宏否则在中断里调用 API 会导致断言失败。在驱动代码上我用过厂商生成的 HAL 库和直接寄存器编程两种方式。厂商 HAL 库上手快但中断处理流程封装层次多不容易看到底层细节寄存器编程代码量更多但可控性强出了问题能一眼看出来。我的经验是如果时间充裕尽量把网卡驱动里的接收中断、发送完成中断、链路状态中断这三个核心函数单独拉出来自己写一遍逻辑不要完全依赖自动生成代码。3. 实操过程与核心环节实现3.1 基础工程搭建把 FreeRTOS 和 lwIP 放进同一个工程这里以一个 STM32H750 平台为例NXP、GD32、瑞萨等平台操作类似芯片内部 RAM 512KB外部通过 SDRAM 扩展 16MBPHY 芯片是 LAN8720A。我采用 STM32CubeMX CMake 的方式管理工程。CubeMX 用于硬件时钟、GPIO、以太网 MAC、DMA 的初始化工程构建则由 CMake 完成结构清晰方便跟踪源码。第一步是确保 FreeRTOS 基础工程能正常跑起来。CubeMX 的 Middleware 里面可以直接勾选 FreeRTOS注意版本通常为 CMSIS-RTOS v1 或 v2。我建议使用 CMSIS-RTOS v1 接口因为 lwIP 官方移植包对 v1 支持最成熟。如果你的工程已经存在裸机以太网驱动可以直接复用不必重复生成。第二步是下载并添加 lwIP 源码。官方的 lwIP 稳定版本建议使用 2.1.3 或 2.2.0代码目录结构如下lwip/ ├── contrib/ # 移植示例 ├── src/ │ ├── api/ # netconn API 和 socket API │ ├── core/ # 协议栈核心实现 │ ├── include/ # 头文件 │ ├── netif/ # 网卡抽象层 │ └── apps/ # 应用层组件如 tftp、httpd、sntp └── system/ # 部分版本的系统移植文件在添加源码时请务必把src/api、src/core和src/netif下的所有.c文件添加到编译列表。src/apps按需添加我只添加了sntp和httpd其他删除。你可以根据自己的需求选择比如要用 MQTT 自研客户端再单独加入代码。第三步是添加sys_arch.c和sys_arch.h。这两个文件是 lwIP 与 FreeRTOS 对接的关键一般可以在 lwIP 的 contrib 包里找到。sys_arch.c需要重新实现 lwIP 内核换用的信号量、互斥量和邮箱机制底层依赖 FreeRTOS API。如果你从网上下载的移植模板不适配你的 lwIP 版本需要检查这几个函数的接口是否对应得上err_t sys_sem_new(sys_sem_t *sem, u8_t count); void sys_sem_free(sys_sem_t *sem); u32_t sys_arch_sem_wait(sys_sem_t *sem, u32_t timeout); void sys_sem_signal(sys_sem_t *sem);邮箱机制的实现相对复杂一点它是由一个 FreeRTOS 队列加上内部管理结构完成的。这部分直接抄官方模板一般不会有问题但要注意队列的消息大小必须与你传递的数据结构大小一致。如果队列消息大小定义错误发送和接收都会异常。3.2 网卡注册与相关回调的实现在main()函数中系统初始化完成后需要创建一个 lwIP 初始化任务调用lwip_init()。这个函数会完成整个协议栈的初始化之后注册网卡和启动链路检测任务。注册网卡的关键代码如下我会结合自己的工程代码来拆解#include lwip/netif.h #include netif/ethernet.h #include lwip/tcpip.h struct netif g_netif; struct netif g_netif_loopback; // 按需使用 static void netif_status_changed(struct netif *netif) { if (netif_is_up(netif)) { // 通知业务层网络恢复 system_network_state NETWORK_STATE_UP; } else { system_network_state NETWORK_STATE_DOWN; } } void lwip_network_init(void) { tcpip_init(NULL, NULL); netif_add(g_netif, NULL, NULL, NULL, NULL, ethernetif_init, // 网卡初始化回调 tcpip_input); // 报文输入入口 netif_set_default(g_netif); netif_set_status_callback(g_netif, netif_status_changed); netif_set_link_callback(g_netif, ethernet_link_change); // 链路变化回调 netif_set_up(g_netif); }这段代码里需要注意几个细节netif_add()的参数中中间两个 NULL 分别是 IP 地址、子网掩码、网关的结构体指针。如果是 DHCP 模式初次添加时传 NULL等 DHCP 分配后再更新。如果你先手动指定了一个静态 IP 再启 DHCPDHCP 成功后会覆盖掉这些值但最好还是让它们保持一致避免出现调试信息里 IP 一会是静态地址、一会是 DHCP 地址的混乱情况。ethernetif_init是网卡驱动初始化的回调函数它由驱动实现者提供。在这个函数里你要完成 MAC 控制器 DMA 初始化、PHY 初始化、读取 PHY 的 link status、注册以太网硬件接收函数并且把发数据的出口关联起来。返回值应该返回ERR_OK否则网卡注册失败。tcpip_input是 lwIP 提供的一个函数接收到一个以太网帧后网卡接收中断或轮询函数应该调用它把数据交给 tcpip_thread 处理。注意千万不要在中断里直接调用tcpip_input除非你明确知道你的 FreeRTOS 中断优先级配置允许这样做。标准做法是在网卡驱动的接收函数里该函数运行在ethernet_input_thread上下文调用它。3.3 TCP 通信的完整示例从连接建立到数据传输以最常见的场景为例FreeRTOS 设备作为 TCP 客户端主动连接一个远程服务器发送一个 JSON 数据帧等待服务器回应然后断开。开始时先初始化 lwIP 和网卡确保已经获得了 IP 地址。如果是 DHCP 模式需要等待状态回调函数里设置的条件变量确认netif的 IP 地址不是 0.0.0.0。然后应用任务通过网络 API 创建 socket#include lwip/sockets.h #define REMOTE_IP_ADDR 192.168.1.100 #define REMOTE_PORT 8080 void tcp_client_task(void *param) { int sock -1; struct sockaddr_in server_addr; char send_buf[128] {\cmd\:\read_temp\,\id\:\dev01\}; char recv_buf[256]; while (1) { // 如果网络未就绪则挂起等待 while (system_network_state ! NETWORK_STATE_UP) { vTaskDelay(pdMS_TO_TICKS(1000)); } sock lwip_socket(AF_INET, SOCK_STREAM, 0); if (sock 0) { // 创建失败多半是内存不足或者 PCB 数量不足 LOG_ERROR(socket create failed); vTaskDelay(pdMS_TO_TICKS(5000)); continue; } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(REMOTE_PORT); server_addr.sin_addr.s_addr inet_addr(REMOTE_IP_ADDR); int ret lwip_connect(sock, (struct sockaddr *)server_addr, sizeof(server_addr)); if (ret 0) { LOG_ERROR(connect failed, ret%d, ret); lwip_close(sock); sock -1; vTaskDelay(pdMS_TO_TICKS(5000)); continue; } LOG_INFO(connected to server %s:%d, REMOTE_IP_ADDR, REMOTE_PORT); ret lwip_send(sock, send_buf, strlen(send_buf), 0); if (ret 0) { LOG_INFO(send %d bytes, ret); } else { LOG_ERROR(send failed, ret%d, ret); } // 接收回包超时 10 秒 struct timeval timeout; timeout.tv_sec 10; timeout.tv_usec 0; lwip_setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, timeout, sizeof(timeout)); memset(recv_buf, 0, sizeof(recv_buf)); ret lwip_recv(sock, recv_buf, sizeof(recv_buf) - 1, 0); if (ret 0) { recv_buf[ret] \0; LOG_INFO(received %d bytes: %s, ret, recv_buf); } else { LOG_WARN(recv timeout or error); } lwip_close(sock); sock -1; // 每隔指定周期执行一次请求 vTaskDelay(pdMS_TO_TICKS(30000)); } }这段代码是基础中的基础重点强调三个容易出错的地方第一inet_addr()返回的地址已经是网络字节序而sin_port必须通过htons()进行调换。很多人只注意了端口号的字节序却把 IP 地址也做了一次转换导致 connect 的目标地址完全错误。第二lwip_setsockopt的协议级别是SOL_SOCKET这和 Linux 保持一致但如果你设置 TCP 专用的选项比如TCP_NODELAY则协议级别应该是IPPROTO_TCP。第三lwip_recv在超时后会返回 -1错误码通常设置为EWOULDBLOCK或EAGAIN判断超时要看errno而不是把 -1 一概当作网络错误。3.4 数据接收的两种模式轮询和事件驱动在实际项目中一个 TCP 连接往往同时承担多条业务处理比如设备既要定时上报数据又要随时响应服务器的查询指令。如果简单地在一个任务里用一个阻塞的lwip_recv来等服务器指令上报数据的时机就会不准确。我推荐两种处理方式第一种方式把阻塞接收放到独立任务中周期上报放在另一个任务中两者共享同一个 socket。但 FreeRTOS 里的 socket 访问虽然是线程安全的两个任务同时调用lwip_send和lwip_recv时内部的互斥器能保证不出现竞争但如果你一个任务发一个任务收数据缓冲区是同一个可能会发生逻辑上的混乱。所以如果使用共享 socket一定要明确约定这个 socket 的接收操作由专门的任务负责发送操作也由同一任务或者通过队列转给该任务。第二种方式使用select模型。lwIP 的 socket API 支持lwip_select()它可以同时监控多个 socket 的读、写和异常事件。这种模型在 TCP 服务端需要同时管理多个客户端连接时非常有用。伪代码思路是这样的while (1) { fd_set read_fds; FD_ZERO(read_fds); FD_SET(listen_sock, read_fds); for (i 0; i max_clients; i) { if (client_sock[i] 0) { FD_SET(client_sock[i], read_fds); } } int max_fd ...; ret lwip_select(max_fd 1, read_fds, NULL, NULL, NULL); if (ret 0) { if (FD_ISSET(listen_sock, read_fds)) { // 有新的连接请求 } for (i 0; i max_clients; i) { if (FD_ISSET(client_sock[i], read_fds)) { // 处理某个 socket 上的可读事件 } } } }select模型适合并发连接数几十个以内的场景一对一或一对多的 TCP 应用都够用且代码可读性好。如果你有几百个并发连接那应该考虑使用 lwIP 的事件回调机制或者采用 PSoC、多线程 多路复用等更复杂的手段但那已经超出本篇文章的范畴了。3.5 DHCP 和静态 IP 的切换网关类产品通常希望设备插上网线就能用这时候 DHCP 是最好的选择。但如果是工业现场设备网络环境往往是没有 DHCP 服务器的直连网络这时候又需要用到静态 IP。lwIP 同时支持这两种模式切换的核心点在于停止并重新启动 DHCP 客户端的过程。下面给出一个比较稳妥的切换思路void set_network_ip_mode(bool dhcp_enable, const char *ip, const char *mask, const char *gw) { // 先关闭网卡 netif_set_down(g_netif); // 如果是静态 IP先设置 IP 地址 if (!dhcp_enable) { ip4_addr_t ip_addr, netmask, gw_addr; inet_aton(ip, ip_addr); inet_aton(mask, netmask); inet_aton(gw, gw_addr); netif_set_addr(g_netif, ip_addr, netmask, gw_addr); } // 重新使能网卡 netif_set_up(g_netif); #if LWIP_DHCP if (dhcp_enable) { dhcp_start(g_netif); // 等待 DHCP 分配完成或者设置一个最长等待时间 uint32_t timeout 15000; while (timeout 0 g_netif.dhcp-state ! DHCP_STATE_BOUND) { vTaskDelay(pdMS_TO_TICKS(100)); timeout - 100; } } #endif }这里有一个小坑需要提醒dhcp_start(g_netif)在之前如果已经调用过需要先调用dhcp_stop(g_netif)并且确保网卡状态是 down 的状态再重新启动。否则 DHCP 的状态机可能处于异常状态导致迟迟无法获取到地址。这个我在项目里踩过一次后来仔细阅读了 lwIP 源码中dhcp_start的注释才发现要求必须放在网卡 down 状态下调用。4. 常见问题与排查技巧实录4.1 设备连接不上服务器网络通了却连不上这个问题是我收到私信最多的一类现象往往是设备能 ping 通路由器也能 ping 通服务器但 TCP connect 一直失败。排查思路按顺序来确认服务器地址和端口是否可达。在 PC 上用 Telnet 或网络调试工具连接一下服务器排除是服务端问题还是设备端问题。确认设备所连接的网络是否允许出站 TCP 到目标端口。部分路由器或防火墙会限制非标口出站连接比如 80、443 以外的高端口。短时间测试可以先把服务器端口设为常见端口排除这种外部限制。检查 socket 的 TCP 选项。如果用了SO_KEEPALIVE之类的选项需要注意 lwIP 对 keepalive 的支持是有限的配置不当会导致连接在空闲期间被对端断开。检查lwip_connect的返回值。如果是-1并且errno为ETIMEDOUT说明 TCP SYN 段发出后一直没有收到 SYN-ACK。这种情况有可能是服务器的回包规则问题也可能是 MTU 小于网络路径上的某个值导致大包被丢弃。前者需要去服务端抓包后者建议先测试小包长度是否能建立连接。对于最后这种情况比较实用的验证办法是把发送数据长度改为 100 字节以内的短包看看连接是否正常。如果短包正常、大包失败基本可以确定是 MTU 问题需要在 lwIP 中调小netif-mtu比如从 1500 改为 1400并检查是否有 IP 分片的需求。4.2 连接建立成功后数据发送偶尔失败症状是设备刚上电时与服务器连接正常但运行一段时间后lwip_send返回错误或者卡在发送上久久不返回。优先怀疑内存问题。lwIP 在协议栈运行时为每个 socket 分配发送缓冲区缓冲区大小由TCP_SND_BUF决定。如果业务任务发送速度大于网络带宽或者对端接收窗口很小发送缓冲区会逐渐被填满lwip_send会进入阻塞等待。如果同时存在多个 socket每个 socket 都占用了大量发送缓冲总内存就会爆掉。此时可以看一下这个现象lwip_send卡住之后是否过一段时间自己恢复。如果是说明是流量整形问题通过调大TCP_SND_BUF和TCP_WND可以缓解如果不是需要检查是否在 close 之后没有正确释放资源。我见过有人把一个 socket close 了但还继续往里面 sendlwIP 在 close 之后把 socket 标记为不可用send 会一直返回 -1但如果不看返回值或者不处理关闭事件业务层会认为网络一直正常。我另外发现一个比较隐蔽的问题lwIP 的tcpip_thread默认栈空间如果设计得不够大在数据量较大时会造成栈溢出进而引发随机崩溃。tcpip_thread的栈大小建议至少 2048 字节如果开启了 socket API 并且处理大量数据帧建议 4096 字节。这个通过 FreeRTOS 的栈高水位标记函数uxTaskGetStackHighWaterMark()可以在运行中精确测量。4.3 TCP 连接被对端主动断开设备没有及时发现很多设备在长时间运行后会出现“假连接”状态服务器已经把 TCP 连接关闭了但设备端仍然认为连接存在直到下一次发送数据时才收到 RST 包。这里最好是开启 TCP KeepAlive 机制。lwIP 支持SO_KEEPALIVE选项但默认周期比较长默认配置下往往几分钟甚至更久才发送一次探测包。如果你需要更及时的检测机制可以设置 keepalive 相关参数int keepalive 1; lwip_setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, keepalive, sizeof(keepalive)); #if LWIP_TCP_KEEPALIVE int idle 10; // 空闲 10 秒后开始探测 int intvl 3; // 探测间隔 3 秒 int cnt 3; // 连续 3 次失败则判断连接断开 lwip_setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, idle, sizeof(idle)); lwip_setsockopt(sock, IPPROTO_TCP, TCP_KEEPINTVL, intvl, sizeof(intvl)); lwip_setsockopt(sock, IPPROTO_TCP, TCP_KEEPCNT, cnt, sizeof(cnt)); #endif注意使用SO_KEEPALIVE要求LWIP_TCP_KEEPALIVE宏为 1这个宏在 lwipopts.h 中必须显式启用。如果不启用即使代码里设置了SO_KEEPALIVE实际上也不生效。4.4 常见问题速查表我把实际调试过程中遇到的一些现象和解决方案整理成表格方便对照排查。现象可能原因排查与解决设备无法获取 IP 地址网线未插好、PHY 初始化失败、DHCP 服务端未运行检查 link 状态回调确认网线插拔能触发用逻辑分析仪抓 MDIO临时切换静态 IP 验证能 ping 通但 connect 不上TCP 端口被防火墙屏蔽、MTU 问题临时用 80/443 端口测试换短包测试调小 mtu连接一段时间后自动断开KeepAlive 未开启、服务器主动断开开启 SO_KEEPALIVE 并设置合理参数应用层增加心跳包lwip_send长时间不返回发送缓冲区满、对端接收窗口为 0调大 TCP_SND_BUF检查服务器是否读取数据用 Wireshark 抓包确认 TCP 窗口设备崩溃在 tcpip 相关代码tcpip_thread 栈溢出、内存不足用uxTaskGetStackHighWaterMark量栈调大 MEM_SIZE多客户端连接时某个客户端断连后其他客户端卡住资源未释放、poll 模型未正确处理确认 close 后清空 fd 集合确认 select 返回值后重新获取数据发送正常但接收一直超时接收超时设置不合理、数据被其他任务抢先读取检查 SO_RCVTIMEO 值确认只有单一任务读取该 socket4.5 独家避坑技巧中断回调里绝对不能做的事最后分享一个我踩过很多次、花费了大量时间才排查清楚的陷阱。在网络中断回调ethernetif_isr中千万不要直接调用任何 lwIP 的 API。如果你在中断里调用了tcpip_input()并且你的 FreeRTOS 中断优先级配置允许调用系统 API表面上看可能没问题但实际上一旦tcpip_input()内部触发了锁争用则会发生死锁。lwIP 官方文档明确说明tcpip_input只能从线程上下文调用。正确的做法是中断里只做三件事——读取中断状态寄存器、清除中断标志、将接收到 buffer 的指针加入一个由高优先级任务轮询的队列然后触发信号量或直接调用BaseType_t xHigherPriorityTaskWoken机制唤醒ethernet_input_thread。同样链路状态变化中断比如网线拔插触发 PHY 中断也不要在中断里直接处理应该查询 PHY 的寄存器然后使用vTaskNotifyGiveFromISR通知一个专门处理链路状态的任务在该任务中调用netif_set_link_up/down和netif_set_status_callback。这个技巧对产品稳定性的提升非常明显。我的第一版固件在高压测试时每运行几十个小时就会出现一次莫名其妙的假死后来定位到是中断里调用lwipAPI 造成的锁竞争死锁改成通知任务后连续运行七天七夜都没有再出现一次异常。5. 从 TCP 迈向互联网应用协议与后续扩展有了基础的 TCP socket设备理论上就已经接入互联网了但真正要把数据变成可用的业务还需要处理应用层的协议。这里简单说几个我在实际项目里用得比较多的协议层实现。HTTP 是最容易上手的。lwIP 自带的httpd组件可以快速实现一个嵌入式 Web 服务器用于设备本地配置管理比如修改 IP 地址、查看传感器状态等。如果你是把设备当作 HTTP 客户端向云端上报数据可以自己定义一个简单的 POST 请求用lwip_send发一段完整的 HTTP 报文再用lwip_recv读取服务器响应。对很多云端 API 来说JSON over HTTP 已经足够。Modbus TCP 在工业设备中用得非常多。Modbus TCP 是在 TCP 协议之上定义的一种请求/响应模型报文格式是 MBAP 头 PDU。你完全可以在 FreeRTOS lwIP 的基础上实现 Modbus TCP 从站或主站功能。注意Modbus TCP 的报文长度是有限制的一般单帧报文不会超过 260 字节所以无需很大的 TCP 窗口。如果你在设备上同时跑 Modbus RTU 和 Modbus TCP需要注意两者共用寄存器区的内存一致性建议加一个互斥量来保护共享的保持寄存器。MQTT 在物联网场景中更偏向云平台对接比如阿里云 IoT、腾讯云 IoT 或者自建的 MQTT Broker。MQTT 协议本身是构建在 TCP 之上的长连接协议特点是省流量、支持遗嘱消息、支持 QoS 等级。lwIP 自带的apps/mqtt提供了基础的 MQTT 客户端但封装程度比较低使用起来不太顺手。我之前做的一个项目是自己在 FreeRTOS 里封装了一层轻量 MQTT 客户端底层 socket 调用 lwIP 的 socket API上层实现 CONNECT、PUBLISH、SUBSCRIBE 等报文。这样既能熟悉协议细节又能灵活控制重连行为。5.1 多任务共享 socket 时的互斥设计很多设备里会有多个任务会同时使用同一个 socket比如一个任务负责定期上报另一个任务负责响应服务器查询。这种情况下必须设计互斥机制我推荐的做法是不直接让多个任务调用lwip_send和lwip_recv而是构建一个“网络管理任务”它拥有对 socket 的唯一访问权。其他业务任务通过网络管理任务提供的消息队列把待发送的数据包发过来网络管理任务统一调用lwip_send发送数据。数据接收后网络管理任务根据数据包头的协议类型把数据分发到对应的业务消息队列中。这种架构的好处是网络模块的边界清晰不依赖某一家协议栈的线程安全特性后续换协议栈或改造成多路复用模型都比较容易。缺点是会增加一次消息队列的拷贝开销但以嵌入式网络的速率来说这个开销完全可以接受。5.2 断线重连的策略设备接入互联网后断线重连是最常见的需求。TCP 连接断开的原因千奇百怪路由器重启、服务器重启、运营商切换、Wi-Fi 信号干扰如果是无线网关。我建议在应用层设计重连策略时不要用死循环立刻重连而是采用退避策略初次连接失败或连接断开后等待 1~2 秒重试。连续失败 5 次后退避到 5 秒。连续失败 20 次后退避到 30 秒。持续失败超过 10 分钟后重置退避计数器重新快速尝试。这个策略可以避免在网络故障时设备疯狂发起 TCP SYN 包占用资源和网络带宽。另外重连时一定要先检查底层网卡状态。如果网线都拔了netif的 link 状态是 down那就没必要去尝试 connect直接等待链路恢复回调再触发重连。5.3 增加远程日志和调试通道当设备真正接入互联网后远程调试需求就来了。我常用的手段是在设备里增加一个简单的 UDP 日志输出通道把调试日志打包成 UDP 数据包发送到局域网内的日志服务器。UDP 不保证可靠但对于调试日志来说足够了好处是不影响主业务而且不像 TCP 那样需要维护连接状态。如果需要远程查看设备状态可以运行一个轻量的 HTTP 页面或者定期把状态数据 POST 到公网接口。用公网接口时尽量加上鉴权和加密避免设备接口暴露在公网上被利用。网络产品上线前安全问题不可忽视。6. 一些写在最后的实操心得FreeRTOS 和 TCP/IP 的配合本质上是一个“多任务实时内核 网络协议栈 应用业务”三者的协同问题。我早期做这块的时候总觉得把 lwIP 源码拖进来编译过就万事大吉了结果在实际调试中花了大量时间在内存、中断、优先级这些基础环境问题上。后来我总结出一条经验凡是遇到网络相关的诡异问题先按“FreeRTOS 配置 → 内存分配 → 驱动适配 → 协议配置 → 应用代码”这个顺序排查往往比盯着 Wireshark 抓包更高效。再分享一个小技巧在调试阶段可以把 FreeRTOS 的configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS打开用vTaskList()和vTaskGetRunTimeStats()来观察各任务的栈使用率和 CPU 占用率。lwIP 相关的线程如果 CPU 占用率总是接近 100%那多半是接收中断太频繁或者处理逻辑有死循环如果栈高水位很小则要考虑调大栈空间。这些工具在正式发布版本中一定要关闭否则会造成额外的性能开销和代码体积膨胀。最后再说一下调试工具的选择。物理链路层优先用网络抓包工具无论是 Wireshark 抓 PC 侧流量还是用以太网抓包器抓网线上的数据都能快速定位问题。应用层优先开启设备端日志用串口输出关键步骤的状态码。我自己的习惯是给每个网络相关函数都加上日志包括 socket 创建、connect、send、recv、close 的返回值。日志不能只打“成功”或“失败”一定要打具体数字比如 errno 值这样排查问题时会省很多时间。FreeRTOS 和 TCP/IP 的这条路看起来内容多其实核心就在那几个点内存给够、中断处理好、线程模型想清楚、应用层多加日志。把这几个基本盘做扎实了后面的应用开发就会顺畅很多。在实际产品里我还遇到过不少奇怪的问题比如路由器 DHCP 分配慢、PHY 芯片在高温环境下偶尔 link down、TCP 长时间空闲被运营商设备回收等每一个问题都需要结合具体的网络环境和硬件特性单独分析。不过没关系基础的框架搭稳了这些问题都只是时间问题。希望这篇 Part 7 对你有实际帮助下一篇我打算写 FreeRTOS lwIP 在低功耗场景下的实现比如用 MQTT-SN 代替 MQTT、通过 LwIP 的电源管理接口控制网卡睡眠到时候我们继续唠。