手里有一块正点原子的探索者或者战舰开发板,又想在板子上把 lwIP 协议栈跑起来,配合 FreeRTOS 做一个真正的网络应用,那这应该是你绕不开的一份功课。正点原子官方例程里其实已经给了不少现成代码,但真正到自己做项目时,光会抄是不够的——你得理解 lwIP 为什么这样设计、FreeRTOS 怎么跟它咬合、数据到底怎么从网线一步步流到你写的 socket 里。这篇内容我按自己做项目时走过的路来写,从移植前的架构思路、工程初始化、核心机制接线,到 TCP/UDP/MQTT 应用层开发,再到排查问题和性能微调,一次讲透,适合正在用正点原子板卡入门 lwIP、又不想只停留在“下载例程烧进去能 ping 通”这个阶段的开发者。
1. 移植前必须想清楚的几件事:lwIP 与 FreeRTOS 的搭配思路
1.1 为什么要用“操作系统版”lwIP,而不是 NO_SYS 模式
lwIP 最开始的定位就是给嵌入式系统用的轻量协议栈,所以它天生支持两种工作模式:一种是完全裸机跑,把NO_SYS宏设为 1,所有协议处理都在你的主循环里轮流执行;另一种是把NO_SYS设为 0,让 lwIP 内部创建自己的tcpip_thread线程,所有协议栈的数据处理都集中在这个线程里,应用层再通过 socket API 或者 netconn API 独立访问。
我强烈建议你在 FreeRTOS 上选择带操作系统的模式,也就是NO_SYS=0。原因很简单:MCU 上跑完 TCP、UDP、ARP、ICMP、DHCP 这些协议后,协议栈内部的回调关系已经比较复杂了,如果继续用裸机轮询方式,不仅实时性难保证,而且你做应用层任务时动不动就要让位给协议栈,代码耦合度高,后面想加个按键扫描、LED 反馈都是牵一发动全身。FreeRTOS 版 lwIP 则是协议栈一个线程、应用层若干线程,你想开几个 socket 服务就开几个任务,互相独立,这才是物联网设备常见的软件形态。
1.2 正点原子系列板卡的网络硬件路径:从 PHY 到 MAC
正点原子探索者 F407、Apollo 等板卡一般用的 PHY 芯片是 LAN8720A,接在 STM32 的 ETH 模块上,接口模式是 RMII。RMII 相比 MII 少了一半数据线,只需要 TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、REF_CLK 这些信号,对 MCU 引脚占用更友好,实际布线也简单。LAN8720A 的默认 PHY 地址是 0x00,这个要注意,因为有些 PHY 芯片默认地址是 0x01,如果你参考别的开发板例程,地址填错直接会导致HAL_ETH_Init无法读到 PHY ID。
正点原子例程里常见的是用 STM32F407ZGT6 的 MAC,外接 50MHz 的 REF_CLK 提供给 LAN8720A,也有板子采用 PHY 提供时钟给 MCU 的方式。这块在配置 CubeMX 时有一个容易踩坑的点:RMII 的 REF_CLK 必须稳定在 50MHz,而且要保证 MAC 和 PHY 用的是同一个时钟源,否则数据收发偶尔会丢包或者完全不通。
1.3 先规划好在 FreeRTOS 里怎么组织网络任务
在写代码前,你先得想清楚整个软件框架。我的建议是,把任务大致分成三类:
- 协议栈管理任务:由
tcpip_init()创建,负责 ARP、IP、TCP 等协议处理; - 网络数据流任务:从网卡接收数据后,通过 netif 的 input 回调丢给协议栈,这个在 STM32 上通常由以太网中断或者一个专门的服务线程来搬数据;
- 业务逻辑任务:TCP 服务器、TCP 客户端、MQTT 客户端、状态上报等具体业务。
正点原子官方例程中的做法通常是在main里先初始化 MAC 和 PHY,然后调用lwip_init(),等任务调度启动后再启动通信任务。我的经验是,PHY 复位和 MAC 初始化可以放在启动调度器之前完成,但tcpip_init()和 DHCP 这类需要依赖任务调度的逻辑,最好放在 FreeRTOS 任务里做,不然时序上容易出问题,尤其是当你后续要动态获取 IP 的时候。
2. 工程搭建与初始化配置:CubeMX、lwIP 源文件与关键宏开关
2.1 CubeMX 配置 ETH 与 FreeRTOS 的基本流程
如果你习惯用 STM32CubeMX 生成底层代码,可以先把 ETH、FreeRTOS 都勾选上。ETH 的配置里需要重点关注几个地方:
- PHY Address:根据板卡原理图填写,正点原子探索者 F407 使用 LAN8720A,填 0;
- PHY 时钟源:选择外部 PHY 或者由 MCO 提供,正点原子探索者板子常用的是
MCO2输出 50MHz 给 PHY,如果你不在 CubeMX 里打开 MCO2 引脚设置,板子大概率起不来; - RMII:确认 CubeMX 生成的引脚与板卡一致,许多移植问题都是引脚对不上造成的;
- MAC 地址:CubeMX 会生成一个随机或者默认的 MAC,可以用
0x00:0x80:0xE1:0x00:0x00:0x00这类的本地管理地址,但注意同一个局域网里不要和其他设备冲突。
FreeRTOS 方面,我建议打开USE_NEWLIB_REENTRANT的可以先不开,除非你用了大量标准库并发调用。Heap 大小先给足,比如 64KB 或更大,后面再按实际裁剪。系统节拍用默认的 SysTick 没问题,但要注意 STM32 HAL 库的HAL_GetTick也依赖 SysTick,如果两者混用,需要把HAL_InitTick()改成其他定时器,否则 lwIP 里一些通过 HAL 延时的地方会不准确。
2.2 lwIP 源码的加入与几个不得不改的宏
正点原子例程里会自带一份裁剪过的 lwIP,路径一般从lwip/src和lwip/src/include引入头文件和源文件。我比较推荐直接用 lwIP 2.1.2 或 2.1.3 版本,因为它的 API 更稳定,DHCP 和 socket 的兼容性也比 1.4.1 更好。你把src/core、src/core/ipv4、src/netif、src/api这些目录下的 c 文件添加进工程就行。
关键宏配置集中在lwipopts.h和arch/cc.h里。最基本的几个我会这么设:
#define NO_SYS 0 // 带操作系统 #define LWIP_SOCKET 1 // 使用 socket API #define LWIP_NETCONN 1 // 使用 netconn API #define LWIP_DHCP 1 // 开 DHCP #define LWIP_DNS 1 // 开 DNS #define LWIP_NETIF_STATUS_CALLBACK 1 // 网卡状态回调,DHCP获取到IP后可以提示 #define MEM_ALIGNMENT 4 #define MEM_SIZE 40960 // lwIP 内部堆 #define PBUF_POOL_SIZE 20 // pbuf 池数目 #define PBUF_POOL_BUFSIZE 1520 // 每个 pbuf 的大小,覆盖一个以太网帧 #define TCP_MSS 1460 // 最大报文段长度 #define TCP_WND (4 * TCP_MSS) // 接收窗口 #define LWIP_RAND() ((u32_t)rand())这里的MEM_SIZE和PBUF_POOL_SIZE决定了协议栈可以同时处理多少网络数据,你如果做的是 TCP 下载类应用,TCP_WND可以继续调大,但代价是内存占用上升。正点原子 F407 的 RAM 有 192KB,一般把 lwIP 的堆设到 40KB~60KB 是不成问题的,但如果同时跑 FreeRTOS 还要给各个业务任务预留栈,就需要统一计算,不能无脑加大。
2.3 内存与中断优先级:两套系统的“磨合期”
我们把 FreeRTOS 和 lwIP 放在一起时,最别扭的其实是两部分:内存分配和中断优先级。
FreeRTOS 有自己的一套 heap 实现(heap_4.c),lwIP 也有自己的内存管理(内存池和mem_malloc),两者是独立的。你在自己的应用任务里调用pvPortMalloc分配的,和 lwIP 内部mem_malloc分配的,并不会互相影响。但问题往往出在“谁在什么时候申请了多大内存”,如果 FreeRTOS 堆太小,任务栈开多了会创建失败;如果 lwIP 的MEM_SIZE太小,高负载时pbuf_alloc会失败,直接丢包。我的习惯是开工前先预估:每个任务栈 512~1024 字(4字节),六个任务大约 12KB~24KB;业务缓冲区另算;剩下的 RAM 再分给 lwIP 堆。
中断优先级也是移植重灾区。Cortex-M 内核上用 FreeRTOS,一般要求中断优先级分组为 4,也就是所有中断都支持抢占。ETH 中断和 DMA 中断的抢占优先级,不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY对应的数值,否则你在中断里调用BaseType_t xHigherPriorityTaskWoken这样以 FromISR 结尾的 API 会出问题。正点原子例程里一般把 ETH 中断的抢占优先级设置为5或6,系统节拍SysTick优先级为最高,这是一个比较稳的组合。
3. 核心机制实操:从驱动到协议栈的数据流动
3.1 网卡驱动与 netif 注册:让协议栈认识你的以太网接口
lwIP 里用struct netif表示一个网卡接口,你要给 lwIP 提供三个核心回调:初始化、输入、输出。初始化函数负责底层 MAC 和 PHY 的设置,输出函数负责把 IP 层的数据包搬到 DMA 描述符上发送,输入函数则负责从网卡接收数据并转交给协议栈。
移植时的大致流程是:先做好 MAC 初始化,然后调用netif_add()添加网卡,设置netif->input为tcpip_input,最后netif_set_up()和netif_set_default()。下面是典型的初始化代码,我加了一些注释方便你理解:
struct netif g_netif; extern err_t ethernetif_init(struct netif *netif); void lwip_network_init(void) { ip4_addr_t ip, netmask, gateway; IP4_ADDR(&ip, 0, 0, 0, 0); IP4_ADDR(&netmask, 0, 0, 0, 0); IP4_ADDR(&gateway, 0, 0, 0, 0); tcpip_init(NULL, NULL); netif_add(&g_netif, &ip, &netmask, &gateway, NULL, ethernetif_init, tcpip_input); netif_set_default(&g_netif); netif_set_up(&g_netif); /* 如果启用 DHCP,则调用 dhcp_start */ dhcp_start(&g_netif); }ethernetif_init里面要做的事情很明确:把底层描述符初始化好,设置 PHY 链接状态,把netif->output绑定为etharp_output,netif->linkoutput绑定为发送函数。这样 lwIP 发出的 ARP 请求、IP 数据,都会走你的发送函数进入网络。
3.2 sys_arch 移植层:lwIP 与 FreeRTOS 的“翻译官”
lwIP 自身不依赖任何操作系统,但它定义了一组sys_arch接口:信号量、互斥锁、消息邮箱、线程创建、获取时间等。你只有在sys_arch.c里用 FreeRTOS 的队列、信号量、任务接口把这些函数实现好,lwIP 的tcpip_thread才能跑起来。
我做移植时优先实现的几个函数:
u32_t sys_now(void) { return (u32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); } err_t sys_sem_new(sys_sem_t *sem, u8_t count) { *sem = xSemaphoreCreateCounting(0xFF, count); return ERR_OK; } void sys_sem_signal(sys_sem_t *sem) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if (xPortIsInsideInterrupt()) { xSemaphoreGiveFromISR(*sem, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } else { xSemaphoreGive(*sem); } } u32_t sys_arch_sem_wait(sys_sem_t *sem, u32_t timeout) { TickType_t ticks = (timeout == 0) ? portMAX_DELAY : pdMS_TO_TICKS(timeout); if (xSemaphoreTake(*sem, ticks) == pdTRUE) { return 0; } else { return SYS_ARCH_TIMEOUT; } }socket内层用到的消息邮箱sys_mbox,我一般用 FreeRTOS 队列实现。注意队列长度要足够容纳收发缓冲,比如设为LWIP_MBOX_SIZE,每个队列元素是一个指针,占的空间不大。如果队列深度太小,协议栈在突发数据包时会把消息丢弃,造成性能下降。
线程创建就简单了,sys_thread_new直接对应xTaskCreate,但需要注意 lwIP 内部创建的tcpip_thread优先级最好比普通业务任务高一点,否则高负载下应用任务长时间占用 CPU,协议栈数据处理不及时,表现为 ping 延迟高、TCP 吞吐上不去。
3.3 一个最小可用的网络任务骨架
移植完成之后,我建议先写一个最简单的测试任务,不做业务,只验证网络通路:
void network_test_task(void *arg) { lwip_network_init(); while (1) { if (g_netif.ip_addr.addr != 0) { printf("IP address acquired: %s\n", ip4addr_ntoa(&g_netif.ip_addr)); break; } vTaskDelay(pdMS_TO_TICKS(200)); } /* 到这里说明 DHCP 成功,你可以接着创建 TCP 服务器等业务任务 */ while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); } }这里的g_netif.ip_addr在 DHCP 成功后会从 0.0.0.0 变成实际地址。你可以通过串口观察打印,网线插上之后一般几秒钟内能获取到地址,然后就可以用电脑 ping 板子验证了。先用这个骨架确认底层通,再往上面加业务,排查问题时思路会清晰很多。
4. TCP/UDP 应用开发实战:从 socket 到业务逻辑
4.1 基于 socket API 的 TCP 服务器实现
FreeRTOS 上接 lwIP,写应用层最舒服的方式就是用 BSD socket 风格的 API。网络任务里一个典型的 TCP Server 大概是这样:
static void tcp_echo_server(void *arg) { int sock, conn_fd; struct sockaddr_in local_addr; char buf[512]; sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { printf("socket create failed\n"); vTaskDelete(NULL); } local_addr.sin_family = AF_INET; local_addr.sin_addr.s_addr = INADDR_ANY; local_addr.sin_port = htons(8080); bind(sock, (struct sockaddr *)&local_addr, sizeof(local_addr)); listen(sock, 4); while (1) { conn_fd = accept(sock, NULL, NULL); if (conn_fd >= 0) { int len = recv(conn_fd, buf, sizeof(buf) - 1, 0); if (len > 0) { buf[len] = '\0'; send(conn_fd, buf, len, 0); } closesocket(conn_fd); } } }这个模型只适合连接少、数据量小的场景。如果你要支持多个 TCP 客户端,不要在accept后面直接循环处理,可以这样做:每收到一个连接就创建一个小任务去处理这个客户端,处理完再删除自己;或者使用非阻塞 socket +select模型。在 lwIP 上,select是支持做超时和多路复用的,实际嵌入式设备里我更常用select,因为它的资源开销比“一个客户端一个任务”小得多。
4.2 UDP 组播与广播场景:更适合状态上报和局域网发现
做局域网设备发现时,UDP 广播很好用。设备上电后可以周期向255.255.255.255:6000发送一段 JSON、二进制状态包,上位机只要监听 6000 端口就能发现设备。实现上比 TCP 简单:
int sock = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in dest_addr; dest_addr.sin_family = AF_INET; dest_addr.sin_port = htons(6000); dest_addr.sin_addr.s_addr = INADDR_BROADCAST; sendto(sock, "online", 6, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));注意几个细节:UDP socket 默认可能不允许多次绑定端口,多个任务不要共用一个 socket;广播地址发送后,同一个局域网里所有设备都会收到,注意不要频繁发送;接收 UDP 数据时,recvfrom可以获取来源地址和端口,方便做回包。
4.3 将 MQTT 协议栈嵌进 FreeRTOS 应用
现在的物联网项目里,MQTT 已经成了标配协议。在 lwIP 上做 MQTT,主流做法有两种:一是用 lwIP 自带的lwip/apps/mqtt,二是移植 Eclipse Paho MQTT C 库。正点原子例程里也有 MQTT 相关的实验,但不太通用,我更建议直接用 Paho 的MQTTClient-C作为客户端库,它底层只依赖 socket 相关接口,和 lwIP 兼容得很干净。
Paho 嵌入的例子大致流程:
- 实现网络结构体里的
mqtt_net_connect、mqtt_net_read、mqtt_net_write等函数,内部就是对 lwIP socket 的封装; - 调用
MQTTClientInit初始化客户端; - 用
MQTTConnect连接 Broker; - 用
MQTTSubscribe订阅主题,用MQTTPublish发布消息。
这里要特别提醒:MQTT 的心跳包(Keep Alive)建议设成 60 秒,这个值既是跟 Broker 的约定,也是断线重连的触发依据。如果你板子长期没有消息流量,协议栈会卡在recv/MQTTYield等待中,一旦网络断开,要花一点时间才能感知到。如果你希望断线后尽快重连,可以把KeepAlive调短,或者单独开一个任务周期性执行重连判断。
5. 常见故障速查与性能微调:踩坑经验全记录
5.1 网络不通:由物理层到链路层逐级排查
我见过最多的问题就是“例程烧进去,网线也插了,但电脑 ping 不通”。这时候不要直接去网上搜代码,按下面的顺序排查,基本十分钟内能定位。
第一步看灯。正点原子板卡上 LAN8720A 会带 link/activity 指示灯,如果插上网线后灯不亮,问题几乎都在 PHY 或 RMII 引脚配置上。检查 PHY 复位引脚是否拉高,检查 MCO 输出是否为 50MHz,用示波器量 REF_CLK 很方便。
第二步看串口打印。如果你在ethernetif_init里调用了HAL_ETH_Init,它内部会读取 PHY ID,如果打印出MII is not ready,说明 MCU 和 PHY 之间通信有问题,重点查 MDIO/MDC 两个引脚和 PHY 地址。
第三步是裸网卡回环测试。先用一个最简单的任务调用etharp_snarp或者其他主动发包函数,看发送描述符是否正常消费。也可以不开协议栈,直接把 MAC 回环打开,HAL_ETH_Start后看 DMA 中断是否频繁触发。
5.2 内存优化与稳定性调优
当基本通信正常后,你很快会发现一个问题:板子跑久了可能不再响应,重启后恢复正常。这大概率不是“Wi-Fi 不稳定”的玄学,而是嵌入式设备特有的内存碎片、任务栈溢出或者 pbuf 泄漏问题。
排查手段我建议按三步走:
- 在 FreeRTOS 里开启
configCHECK_FOR_STACK_OVERFLOW,并在任务结束钩子和溢出钩子里打日志; - 把 lwIP 的
LWIP_STATS设为 1,打印mem_stats、pbuf_stats,如果某个计数持续增长,就说明有内存没有释放; - 在 TCP 收发的路径上尽量使用
pbuf_free,如果自己写了 raw pbuf 处理,千万不要直接调用free。
另外,大MEM_SIZE并不一定是好事,尤其当你还保留了 FreeRTOS 的 heap 时。lwIP 的内存堆和 pbuf 池是静态数组,必须同时考虑。我自己的项目里,TCP 服务器 + 多个客户端同时在线,MEM_SIZE用到 40KB 就够,再大只是浪费。
5.3 我常用的几个小技巧
最后分享几个我自己长期实践下来觉得很有用的习惯,不一定写在文档里,但确实能省事:
- 串口日志分级:调试网络时,把“DHCP 获取成功”、“TCP 连接建立”、“TCP 连接断开”这类事件单独打日志,方便快速判断链路状态。
- 任务命名规范:每个 FreeRTOS 任务创建时都给它起个清晰的名字,
tcpip、netif、mqtt、app_main,这样你在调试器里看线程列表一目了然,不用猜。 - 默认网关与静态 IP 的预留逻辑:如果你产品要在固定网络里使用,先把 DHCP 失败时的静态 IP 兜底写好,否则现场设备上电后拿不到地址,就成了哑设备。
- 不要在主循环里刷屏打印:串口打印本身很慢,尤其在低优先级任务里,频繁打印会拖垮整个系统的实时性,调试时打印频率尽量控制在 10Hz 以下。
这个移植过程确实有点绕,尤其是第一次接触 lwIP 源码结构的时候,会觉得自己在跟一堆回调函数搏斗。但只要你把底层 netif、sys_arch 和数据流走向理清了,后面加任何网络应用都不会太费劲。按照上面这套思路,从 CubeMX 配置到 TCP 服务器跑通,一个下午基本就能搞定,之后再根据自己的项目往上堆协议和应用就行。