开篇先交代一下背景。我最近在做一套基于 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~60KB,RAM 静态分配加动态分配总共在 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需要设为 1,configUSE_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_SYS | 0 | 必须设为 0,表示使用操作系统。设为 1 是裸机模式。 |
LWIP_SOCKET | 1 | 启用 socket API,使用 lwip_socket 系列函数。 |
LWIP_NETCONN | 1 | 启用 netconn API,socket API 依赖它。 |
LWIP_TCP | 1 | 启用 TCP 协议。 |
LWIP_UDP | 1 | 启用 UDP 协议。Modbus UDP 和部分 MQTT 实现需要。 |
LWIP_DHCP | 1 | 启用 DHCP 客户端,适用于接入路由器自动获取 IP。 |
LWIP_DNS | 1 | 启用 DNS 客户端,方便用域名连接服务器。 |
LWIP_NETIF_STATUS_CALLBACK | 1 | 网卡状态变化回调,用于检测网线插拔。 |
LWIP_NETIF_LINK_CALLBACK | 1 | 链路状态回调,配合状态回调使用。 |
MEM_SIZE | 40960 起 | 协议栈堆内存大小,单位字节,可根据实际 RAM 调整。 |
MEMP_NUM_NETCONN | 8 起 | 最大 netconn 数量,对应并发连接数。 |
MEMP_NUM_TCP_PCB | 8 起 | 最大 TCP PCB 数量,同时可以打开的 TCP 连接数量。 |
PBUF_POOL_SIZE | 32 起 | 用于接收数据的 pbuf 池数量,调大可以提升突发数据吸收能力。 |
TCP_SND_BUF | 8192 | TCP 发送缓冲大小,决定单个 socket 的发送吞吐。 |
TCP_WND | 8192 | TCP 接收窗口大小,决定单个 socket 的接收吞吐。 |
有些配置项则可以根据项目实际情况关闭。比如LWIP_IGMP是组播协议,不做组播应用就关掉;LWIP_SNMP是简单网络管理协议,不涉及网管就关掉;LWIP_AUTOIP是自动 IP 地址分配机制,绝大多数场景用不到。还有LWIP_NETIF_LOOPBACK本地回环,如果不做本机内部通信测试,也可以关掉。裁剪的精髓在于:每个不开的宏,都在帮你省掉一部分 RAM 和 Flash。
2.3 网卡驱动与 PHY 芯片的适配
很多 FreeRTOS + lwIP 的工程移植不成功,根源不在软件协议栈,而在网卡驱动。lwIP 与网卡驱动之间有一个netif层,官方文档叫“网络接口”,你可以理解为一个适配器。网卡驱动需要实现netif->output(IP 层输出)、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 扩展 16MB,PHY 芯片是 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 再启 DHCP,DHCP 成功后会覆盖掉这些值,但最好还是让它们保持一致,避免出现调试信息里 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 了,但还继续往里面 send,lwIP 在 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 值;确认只有单一任务读取该 socket |
4.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 的电源管理接口控制网卡睡眠,到时候我们继续唠。