4. LwIP协议栈移植与裁剪

在STM32上跑通以太网,光有MAC和PHY还不够,真正让TCP/IP通信可用的是LwIP协议栈。LwIP(Lightweight IP)是一套为嵌入式系统设计的开源TCP/IP协议栈,它把ARP、IP、ICMP、UDP、TCP、DHCP、DNS等协议压缩到几十KB的RAM和ROM占用内,同时保留完整的socket和raw API接口。本节围绕“怎么把LwIP装进STM32工程并让它稳定工作”展开,重点讲清CubeMX配置、网卡驱动对接、内存池划分和线程模型四个环节。

一、CubeMX中的LwIP配置要点

在STM32CubeMX中启用ETH外设后,Middleware栏会出现LwIP选项。配置时最关键的几项是:

  • PHY地址与接口模式:RMII模式下PHY地址通常为0或1,需与硬件原理图一致;LAN8720常用地址0,DP83848常用地址1。
  • DHCP与静态IP:调试阶段建议先用静态IP(如192.168.1.100/255.255.255.0/192.168.1.1),避免DHCP超时导致启动阻塞;产品阶段再启用DHCP。
  • 内存配置:MEM_SIZE决定堆大小,默认约1600字节,跑TCP服务端时建议调到4KB~8KB;PBUF_POOL_SIZE决定pbuf池数量,默认4~16,高吞吐场景需增大。
  • 协议裁剪:只保留实际用到的协议。例如仅做UDP采集时,可关闭TCP、DNS、SNMP,节省ROM和RAM。

CubeMX生成的代码会调用MX_LWIP_Init(),它完成三件事:初始化网卡驱动、初始化LwIP内核、启动网络接口。理解这三步的先后顺序,是后续排查问题的关键。

二、网卡驱动对接:ethernetif.c的角色

LwIP本身不直接操作STM32的ETH寄存器,中间靠一个网卡驱动层ethernetif.c衔接。它实现了LwIP要求的netif结构体,核心是两个函数:

  • low_level_init():设置netif的MAC地址、MTU、flags,并启动ETH外设和DMA描述符链。
  • low_level_output():把LwIP的pbuf数据拷贝到ETH发送DMA缓冲区,触发发送。

接收方向则由ETH中断或轮询触发,在ethernetif_input()中把DMA收到的帧封装成pbuf,再交给netif->input()送入LwIP内核。这里最容易出问题的是DMA描述符的对齐和Cache一致性:在带D-Cache的STM32F7/H7上,发送前要SCB_CleanDCache_by_Addr(),接收后要SCB_InvalidateDCache_by_Addr(),否则会出现“ping通但TCP数据错乱”的典型现象。

LwIP在STM32上的数据流与内存/线程关系 应用任务 socket / raw API LwIP内核 tcpip_thread / 协议处理 ethernetif.c low_level_output/input ETH MAC DMA 内存池(lwipopts.h) MEM_SIZE:堆大小,TCP服务端建议 4~8KB PBUF_POOL_SIZE:pbuf池数量,默认 4~16 MEMP_NUM_TCP_PCB:TCP控制块数量 线程模型 tcpip_thread:LwIP内核线程,优先级中 应用任务:调用socket API,优先级低 ETH中断:触发接收,优先级高 关键约束: 1. 应用任务不能直接调用low_level_output,必须经tcpip_thread或加锁; 2. 带D-Cache的MCU需在DMA前后做Cache维护,否则数据错乱; 3. 内存池过小会导致pbuf分配失败,表现为连接建立后立即断开。

三、内存池与lwipopts.h裁剪

LwIP的内存管理有两套机制:内存堆(mem heap)和内存池(memp pool)。堆用于变长分配,如pbuf数据区;池用于定长结构体,如TCP PCB、UDP PCB。CubeMX生成的lwipopts.h是裁剪的核心文件,几个关键参数如下:

参数默认值作用调整建议
MEM_SIZE1600内存堆总大小TCP服务端调至4096~8192
PBUF_POOL_SIZE16pbuf池数量高吞吐场景调至32
MEMP_NUM_TCP_PCB5同时活跃TCP连接数按最大并发连接数设置
TCP_SND_BUF2560TCP发送缓冲区大文件传输调至5840
TCP_WND2048TCP接收窗口与TCP_SND_BUF匹配

裁剪的原则是“按需保留”。例如一个只做Modbus TCP从站的设备,只需要TCP、IP、ICMP、ARP,可以关闭DHCP、DNS、SNMP、PPP,ROM占用能从约60KB降到35KB左右。但要注意:关闭LWIP_NETCONN后就不能用socket API,只能使用raw API,这会改变应用层的编程方式。

四、线程模型:tcpip_thread与应用的协作

LwIP默认采用tcpip_thread单线程模型:所有协议处理都在这个线程内完成,应用任务通过消息队列与它通信。这种设计避免了协议栈内部加锁,但要求应用任务不能直接操作netif或pbuf。具体协作方式有两种:

  • Socket API(NETCONN):应用任务调用netconn_new()、netconn_recv()等函数,这些函数内部通过消息队列把请求发给tcpip_thread,并阻塞等待结果。编程简单,但每次调用都有一次上下文切换开销。
  • Raw API:应用直接注册回调函数(如tcp_recv()),回调在tcpip_thread上下文中执行。效率高,但回调中不能调用阻塞函数,否则会卡死整个协议栈。

实际工程中,常见的做法是:创建一个优先级低于tcpip_thread的应用任务,用socket API处理业务逻辑;ETH中断只负责触发接收信号量,不做任何协议处理。这样既保证了实时性,又避免了竞态。

常见陷阱:在raw API回调中调用HAL_Delay()或等待信号量,会导致tcpip_thread阻塞,表现为ping延迟骤增甚至断网。回调中只做数据拷贝和标志置位,耗时操作交给应用任务。

五、一个可复现的配置示例

假设目标是用STM32F407+LAN8720做一个TCP Echo服务器,静态IP 192.168.1.100,端口7。CubeMX配置如下:

/* lwipopts.h 关键片段 */
#define MEM_SIZE                (6*1024)
#define PBUF_POOL_SIZE          24
#define MEMP_NUM_TCP_PCB        8
#define TCP_SND_BUF             (4*TCP_MSS)
#define TCP_WND                 (4*TCP_MSS)
#define LWIP_NETCONN            1
#define LWIP_SOCKET             0
#define LWIP_DHCP               0
#define LWIP_DNS                0
#define LWIP_ICMP               1
#define LWIP_UDP                1
#define LWIP_TCP                1

应用任务中创建socket并监听:

int sock = lwip_socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(7);
addr.sin_addr.s_addr = INADDR_ANY;
lwip_bind(sock, (struct sockaddr*)&addr, sizeof(addr));
lwip_listen(sock, 2);
/* 后续 accept/recv/send 循环 */

验证时,先在PC上ping 192.168.1.100,确认ARP和ICMP正常;再用telnet 192.168.1.100 7或nc发送字符串,观察是否原样返回。如果ping通但TCP连不上,优先检查MEMP_NUM_TCP_PCB是否耗尽、MEM_SIZE是否过小导致pbuf分配失败。如果TCP能连但数据错乱,检查D-Cache维护是否遗漏。

整个移植过程的核心逻辑是:CubeMX负责生成框架和默认参数,ethernetif.c负责硬件对接,lwipopts.h负责资源裁剪,线程模型决定应用如何安全地使用协议栈。把这四件事理清,LwIP在STM32上的运行就从一个黑盒变成了可控的工程组件。