
1. 为什么要在国产 MCU 上折腾 LwIP第一次把 LwIP 往国产 MCU 上搬是在一个工业网关项目里。客户指定用 GD32F4 系列要求带一路以太网能跑 Modbus TCP 和 MQTT成本还得压到最低。当时团队里有人提议直接上带硬件协议栈的芯片或者干脆用 Linux 方案但成本和功耗都过不了关。最后绕了一圈还是回到 LwIP 这条路上。LwIP 这个名字做嵌入式网络的人基本都听过。全称是 Lightweight IP一个开源的轻量级 TCP/IP 协议栈最初由瑞典计算机科学院的 Adam Dunkels 开发。它的核心卖点就一个在资源极其有限的嵌入式设备上实现一套功能相对完整的网络协议。RAM 占用可以压到几十 KBROM 占用几十 KB 到一百多 KB这个量级放在国产 MCU 上刚好能接受。但“能跑”和“跑得稳”是两码事。我在 GD32、兆易创新、华大、极海这几家的 MCU 上都做过 LwIP 的移植踩过的坑能写满一个笔记本。国产 MCU 和 ST 的芯片在以太网外设上虽然寄存器层面高度相似但时钟树配置、DMA 描述符对齐、中断优先级分组这些细节上差异足以让一个跑通的工程在换芯片后直接罢工。更别说 LwIP 本身的配置参数默认值在 PC 上没问题放到 MCU 上就是各种超时、丢包、内存耗尽。这篇内容适合两类人看一类是刚接触嵌入式网络、准备在国产 MCU 上跑 LwIP 的开发者另一类是在 LwIP 上已经跑通但被稳定性问题折磨的工程师。我会从协议栈的裁剪配置讲起一直讲到网络稳定性的实战调优中间穿插大量我在实际项目里验证过的参数和排查方法。所有内容都基于真实项目经验不是从文档里抄来的理论。2. LwIP 协议栈在 MCU 上的裁剪与配置策略2.1 内存池与内存堆的取舍逻辑LwIP 的内存管理有两套机制内存池Pool和内存堆Heap。这两个东西用不好系统跑几分钟就死给你看。内存池是预分配的固定大小块每种大小的块有一个独立的池。比如 PBUF_POOL 用于网络数据包的 pbuf 结构MEMP_NUM_PBUF 控制池里有多少个块。内存池的优点是分配和释放都是 O(1) 操作不会产生碎片缺点是大小固定用不完就浪费。内存堆则是动态分配灵活但会产生碎片在长时间运行的设备上是隐患。我的经验是所有 LwIP 内部用到的结构全部走内存池只有应用层需要动态申请的大块数据才考虑用内存堆而且最好自己管理不要频繁 malloc/free。具体到 lwipopts.h 里的配置几个关键参数必须根据实际场景算#define MEMP_NUM_PBUF 16 #define MEMP_NUM_UDP_PCB 6 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_PCB_LISTEN 4 #define MEMP_NUM_TCP_SEG 32 #define MEMP_NUM_REASSDATA 4 #define MEMP_NUM_ARP_QUEUE 8 #define PBUF_POOL_SIZE 24 #define PBUF_POOL_BUFSIZE 1528PBUF_POOL_BUFSIZE 设成 1528 是有讲究的。以太网帧最大 1518 字节加上 LwIP 内部可能需要的对齐和头部空间1528 是一个经过验证的安全值。设小了会导致大包被拆分到多个 pbuf增加处理开销设大了浪费 RAM。MEMP_NUM_TCP_SEG 这个参数最容易被忽略。它控制同时存在的 TCP 分段数量。如果你发现 TCP 吞吐量上不去或者大量重传先检查这个值。计算公式大致是(TCP_SND_BUF / TCP_MSS) * 并发连接数 * 1.5。比如 TCP_SND_BUF 设 5840TCP_MSS 是 1460单个连接需要 4 个分段两个并发连接就是 8 个留 50% 余量就是 12 个。我一般直接设 32给足余量。2.2 国产 MCU 的以太网外设适配要点国产 MCU 的以太网外设寄存器层面基本是参考 ST 的 ETH 模块设计的但有几个地方必须手动确认。第一是 DMA 描述符的对齐。ST 的 HAL 库默认把描述符放在普通 RAM 里但很多国产 MCU 的以太网 DMA 要求描述符地址 4 字节对齐甚至有些要求 8 字节对齐。我遇到过 GD32F4 上描述符没对齐导致 DMA 传输随机失败的情况现象是 ping 通几包就断断了又自己恢复。解决办法是在链接脚本里给描述符单独分配一个对齐的段或者用__attribute__((aligned(4)))强制对齐。第二是时钟配置。国产 MCU 的 ETH 时钟来源可能和 ST 不同。STM32F4 的 ETH 时钟来自 PLL而某些国产型号需要单独使能 ETH 时钟分频器。如果 RMII 接口的 50MHz 参考时钟不对PHY 芯片根本不会 link up。我一般先用示波器量 REF_CLK 引脚确认是 50MHz 且占空比接近 50%再往下调。第三是中断优先级。LwIP 的收发依赖中断如果以太网中断优先级低于其他高频中断会出现接收溢出。在 Cortex-M4 上我通常把 ETH 中断设为抢占优先级 5数值越小优先级越高子优先级 0。不要设成最高否则会影响系统 tick 和其他关键中断。2.3 lwipopts.h 里必须改的十几个参数LwIP 的默认配置是给桌面环境用的直接拿来在 MCU 上跑RAM 瞬间就爆了。下面这些参数是我在每个项目里都会重新审视的参数默认值推荐值说明MEM_LIBC_MALLOC00用 LwIP 自带内存管理MEMP_MEM_MALLOC00内存池不用 malloc 实现MEM_ALIGNMENT44Cortex-M 是 4 字节对齐MEM_SIZE16004096~8192内存堆大小按需调整TCP_SND_BUF2562920~5840发送缓冲区影响吞吐TCP_WND20482920~5840接收窗口影响吞吐TCP_MSS5361460最大分段大小LWIP_NETIF_TX_SINGLE_PBUF01单 pbuf 发送减少拷贝CHECKSUM_GEN_IP11硬件校验和可关CHECKSUM_GEN_TCP11同上LWIP_STATS10发布版本关掉统计LWIP_DEBUG10发布版本关掉调试TCP_MSS 设 1460 的前提是你的网络 MTU 是 1500且中间没有 PPPoE 之类的额外封装。如果设备要过路由器有些路由器 MTU 是 1492这时候 TCP_MSS 要相应降到 1452。我一般先用 1460如果发现大包传输有问题再往下调。LWIP_NETIF_TX_SINGLE_PBUF 设成 1 之后LwIP 会尽量把要发送的数据放在一个 pbuf 里减少 DMA 描述符的数量。这个选项在国产 MCU 上特别有用因为有些芯片的 DMA 描述符数量有限多 pbuf 发送容易触发描述符耗尽。3. 从零搭建 LwIP 工程的完整实操流程3.1 硬件确认与 PHY 芯片选型在写代码之前先把硬件确认清楚。国产 MCU 的以太网接口一般是 RMII 模式需要外接 PHY 芯片。常用的 PHY 有 LAN8720、DP83848、YT8512 等。LAN8720 最便宜但要注意它的 REF_CLK 输出需要配置有些模块默认是输入模式需要改电阻。我一般按这个顺序检查硬件量 MCU 的 ETH_REF_CLK 引脚确认有 50MHz 时钟。量 PHY 的复位引脚确认上电后有复位脉冲。读 PHY 的 BCR 寄存器地址 0x00确认能读到 0x1140 之类的值。读 PHY 的 BSR 寄存器地址 0x01确认 link 状态和速度。如果第 3 步读不到值先查 MDIO 和 MDC 两根线。MDC 频率不能超过 2.5MHz我一般设到 1.5MHz 左右。MDIO 需要上拉电阻通常是 1.5kΩ 到 4.7kΩ。3.2 以太网外设初始化与 DMA 描述符配置以太网初始化的核心是 DMA 描述符环的建立。下面是我在 GD32F4 上用的描述符定义typedef struct { volatile uint32_t Status; uint32_t ControlBufferSize; uint8_t *Buffer1Addr; uint8_t *Buffer2NextDescAddr; } ETH_DMADescTypeDef; __attribute__((aligned(4))) ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB]; __attribute__((aligned(4))) ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB]; __attribute__((aligned(4))) uint8_t Rx_Buff[ETH_RXBUFNB][ETH_MAX_PACKET_SIZE]; __attribute__((aligned(4))) uint8_t Tx_Buff[ETH_TXBUFNB][ETH_MAX_PACKET_SIZE];ETH_RXBUFNB 和 ETH_TXBUFNB 我一般设 4 和 4。设多了浪费 RAM设少了容易丢包。ETH_MAX_PACKET_SIZE 是 1524。描述符初始化的时候要把 Rx 描述符的 Buffer1Addr 指向 Rx_Buff 对应的行把 Status 设成 ETH_DMARXDESC_OWN。Tx 描述符类似但 OWN 位在发送时才置位。这里有个坑国产 MCU 的 DMA 描述符在 Cache 使能的情况下需要做 Cache 一致性维护。如果你的 MCU 有 D-Cache在读取 Rx 描述符状态之前要 invalidate 对应的 Cache 行在填充 Tx 描述符之后要 clean 对应的 Cache 行。我见过一个项目因为没做 Cache 维护ping 通但 TCP 传文件全是乱码。3.3 LwIP 的初始化顺序与网络接口注册LwIP 的初始化顺序不能乱我一般按这个流程// 1. 初始化 LwIP 内核 lwip_init(); // 2. 初始化以太网外设和 DMA ETH_Init(); // 3. 配置网络接口 struct netif gnetif; ip4_addr_t ipaddr, netmask, gw; IP4_ADDR(ipaddr, 192, 168, 1, 100); IP4_ADDR(netmask, 255, 255, 255, 0); IP4_ADDR(gw, 192, 168, 1, 1); // 4. 添加网络接口 netif_add(gnetif, ipaddr, netmask, gw, NULL, ethernetif_init, ethernet_input); // 5. 设置为默认接口 netif_set_default(gnetif); // 6. 启动接口 netif_set_up(gnetif); // 7. 启动 DHCP如果需要 // dhcp_start(gnetif);ethernetif_init 这个函数是移植的重点它要完成 low_level_init 的调用设置 netif 的 flags、output 回调、linkoutput 回调等。low_level_init 里要初始化以太网外设、创建信号量、启动接收线程。我习惯把 LwIP 的收发放在一个独立的线程里优先级比应用线程高一级。接收线程用信号量等待以太网中断释放然后调用 ethernetif_input 处理数据包。3.4 中断处理与数据收发的线程模型以太网中断里只做最少的事清除中断标志释放信号量。真正的数据包处理放在线程里做。这样做的原因是 LwIP 的 ethernetif_input 可能会调用上层回调如果在中断里执行栈空间和中断延迟都不可控。void ETH_IRQHandler(void) { if (ETH_GetDMAFlagStatus(ETH_DMA_FLAG_R) SET) { ETH_DMAClearITPendingBit(ETH_DMA_IT_R); // 释放信号量通知接收线程 osSemaphoreRelease(sem_rx); } if (ETH_GetDMAFlagStatus(ETH_DMA_FLAG_T) SET) { ETH_DMAClearITPendingBit(ETH_DMA_IT_T); // 释放信号量通知发送完成 osSemaphoreRelease(sem_tx); } }接收线程的伪代码void ethernetif_thread(void *arg) { while (1) { if (osSemaphoreWait(sem_rx, osWaitForever) osOK) { ethernetif_input(gnetif); } } }这里有个细节ethernetif_input 里会调用 low_level_input 从 DMA 描述符里取数据然后封装成 pbuf再调用 netif-input。如果 netif-input 返回 ERR_MEM说明 LwIP 内存不够这时候要释放 pbuf 并记录错误。4. 网络稳定性问题的排查与调优实录4.1 丢包与超时问题的定位思路网络不稳定第一件事是确认问题出在哪一层。我一般按这个顺序排查物理层用示波器看 RMII 的 TX_EN、TXD0、TXD1 波形确认发送时有时钟和数据。如果波形畸变严重检查 PCB 走线是否等长终端电阻是否匹配。MAC 层读以太网外设的错误计数器看是否有 CRC 错误、对齐错误、溢出错误。GD32 的 ETH_MMCRFCECR 寄存器记录 CRC 错误数ETH_MMCRFAECR 记录对齐错误数。如果这两个数在涨说明物理层有问题。协议栈层打开 LwIP 的统计功能看 memp 的 err 计数、pbuf 的 alloc 失败计数。如果 pbuf alloc 失败频繁说明 PBUF_POOL_SIZE 太小。应用层用 ping 测试基础连通性用 iperf 测试吞吐量用长时间 ping 测试稳定性。我一般会跑一个 12 小时的 ping看丢包率是否低于 0.1%。4.2 内存耗尽与 pbuf 泄漏的典型场景pbuf 泄漏是 LwIP 上最常见的稳定性问题。典型现象是设备运行几小时后网络卡死重启后恢复。用 LwIP 的 stats 功能可以看到 pbuf 的 used 计数一直涨最终达到 PBUF_POOL_SIZE 后分配失败。泄漏的原因通常有几个第一接收路径上没有释放 pbuf。比如在应用层回调里收到数据后没有调用 pbuf_free。我见过一个项目TCP 接收回调里把数据拷贝到自己的缓冲区后忘了释放 pbuf结果每收一包泄漏一个 pbuf。第二发送失败后没有释放 pbuf。tcp_write 返回 ERR_MEM 时pbuf 还在应用层手里需要自己释放。如果直接丢弃就泄漏了。第三ARP 队列溢出。当目标 IP 的 MAC 地址还没解析出来时LwIP 会把数据包挂在 ARP 队列上。如果队列满了新包会被丢弃但已经挂上去的包如果一直解析不出来就会一直占着 pbuf。MEMP_NUM_ARP_QUEUE 设大一点可以缓解但根本解决办法是确保 ARP 能正常解析。我的做法是在应用层加一个 pbuf 使用计数定期打印。如果发现计数只涨不跌就重点检查接收和发送路径。4.3 长时间运行下的连接保持策略工业设备要求 7x24 小时运行TCP 连接可能几天都不动。这时候需要处理几个问题Keepalive 配置。LwIP 支持 TCP keepalive在 lwipopts.h 里打开#define LWIP_TCP_KEEPALIVE 1 #define TCP_KEEPIDLE_DEFAULT 7200000 // 2小时 #define TCP_KEEPINTVL_DEFAULT 75000 // 75秒 #define TCP_KEEPCNT_DEFAULT 9但 keepalive 只能检测连接是否还活着不能防止中间路由器断开空闲连接。我一般会在应用层加心跳包每 30 秒发一次确保 NAT 表项不过期。重连机制。如果连接断了应用层要能自动重连。我一般用状态机实现连接断开后等待 5 秒重连连续失败 3 次后等待 30 秒再失败等待 60 秒最多退避到 5 分钟。内存碎片防护。长时间运行后内存堆可能产生碎片。如果应用层频繁 malloc/free 不同大小的块碎片会越来越严重。我的做法是应用层尽量用静态分配或者用固定大小的内存池。4.4 常见问题速查表现象可能原因排查方法解决措施ping 不通PHY 未 link读 BSR 寄存器检查时钟、复位、网线ping 通但丢包严重DMA 描述符不足看 ETH 错误计数器增加 RXBUFNBTCP 传大文件卡死TCP_SND_BUF 太小看 tcp sndbuf 统计增大 TCP_SND_BUF运行几小时后死机pbuf 泄漏看 pbuf used 计数检查释放路径连接频繁断开keepalive 未开抓包看是否有 RST开启 keepalive吞吐量只有几 MbpsTCP_WND 太小计算带宽时延积增大 TCP_WND换芯片后不工作时钟配置不同量 REF_CLK调整时钟树中断里死机栈溢出看 MSP 值增大中断栈5. 性能优化与吞吐量提升的实战技巧5.1 零拷贝发送的实现方法LwIP 默认的发送路径会做一次数据拷贝应用层的数据先拷贝到 LwIP 的 pbuf再拷贝到 DMA 缓冲区。两次拷贝在高速传输时很浪费 CPU。零拷贝的思路是让应用层的数据直接进入 DMA 缓冲区。LwIP 提供了netif-output的自定义实现可以绕过 pbuf 拷贝。具体做法是应用层申请一个 pbuf类型是 PBUF_REF指向自己的数据缓冲区。调用 tcp_write 时用这个 pbuf。在 low_level_output 里直接把 pbuf 的数据地址填到 DMA 描述符里。但 PBUF_REF 有个限制数据缓冲区在发送完成前不能被修改。所以应用层需要等发送完成信号才能复用缓冲区。我一般用双缓冲两个缓冲区交替使用一个在发送时另一个在填充。5.2 TCP 窗口与缓冲区大小的计算TCP 吞吐量的理论上限是窗口大小 / RTT。假设 RTT 是 10ms窗口是 5840 字节吞吐量上限就是 5840 / 0.01 584KB/s约 4.7Mbps。如果要跑到 50Mbps窗口需要 50Mbps * 10ms / 8 62.5KB。但 MCU 的 RAM 有限不可能开 64KB 的窗口。这时候需要权衡要么降低 RTT用局域网要么接受较低的吞吐量。我的经验是在 100Mbps 局域网里TCP_WND 设 5840 到 11680 之间吞吐量能到 20 到 40Mbps足够大多数工业场景。UDP 没有窗口限制但要注意接收缓冲区。如果接收速度跟不上发送速度UDP 包会被丢弃。我一般把 UDP 接收缓冲区设成 4 到 8 个包的大小。5.3 中断与轮询的混合模式纯中断模式下每个数据包都触发一次中断高吞吐时中断开销很大。纯轮询模式又浪费 CPU。混合模式的做法是低负载时用中断高负载时切换到轮询。具体实现在接收中断里判断如果连续收到 N 个包比如 10 个就关闭接收中断切换到轮询模式。轮询线程处理完所有包后再打开中断。这样在高吞吐时减少中断次数低吞吐时不影响实时性。我在 GD32F4 上实测混合模式比纯中断模式的吞吐量高 15% 左右CPU 占用低 10%。5.4 实测数据与调优前后对比在一个 GD32F450 平台上我用 iperf 做了对比测试配置TCP 吞吐量UDP 吞吐量CPU 占用默认配置8.2 Mbps12 Mbps45%调优后38 Mbps65 Mbps62%零拷贝混合模式52 Mbps78 Mbps55%调优的主要改动TCP_WND 从 2048 增到 11680TCP_SND_BUF 从 2560 增到 11680PBUF_POOL_SIZE 从 16 增到 32开启 LWIP_NETIF_TX_SINGLE_PBUF启用混合中断轮询。CPU 占用上升是因为吞吐量上去了处理的数据包多了。零拷贝模式下 CPU 占用反而下降因为减少了拷贝开销。6. 国产 MCU 平台差异与移植避坑指南6.1 GD32 与 STM32 的以太网外设差异GD32F4 的以太网外设和 STM32F4 高度相似但有几个寄存器地址和位定义不同。最典型的是 DMA 状态寄存器的位定义GD32 的 ETH_DMASR 寄存器和 ST 的略有差异。直接拿 ST 的 HAL 库往 GD32 上搬可能会在中断处理里读不到正确的标志位。我的做法是不直接用 ST 的 HAL 以太网驱动而是自己写一个薄层把寄存器操作封装起来。这样换芯片时只需要改薄层LwIP 部分不动。另外GD32 的 ETH 时钟使能位在 RCU 寄存器里和 ST 的 RCC 不同。这个在初始化时要注意。6.2 华大与极海 MCU 的适配经验华大 HC32F4A0 的以太网外设也是 RMII 接口但它的 DMA 描述符要求 8 字节对齐比 ST 的 4 字节更严格。我一开始没注意描述符只做了 4 字节对齐结果 DMA 传输随机失败。后来在链接脚本里加了. ALIGN(8);才解决。极海 APM32F4 的以太网外设和 ST 基本兼容但 PHY 地址默认是 1不是 0。如果 PHY 芯片的地址引脚没接对MDIO 读不到。我一般先用扫描的方式遍历 0 到 31 的 PHY 地址找到能读回正确 ID 的那个。6.3 移植过程中的编译与链接问题LwIP 的源码在国产 MCU 的编译器上编译可能会遇到几个问题第一__packed关键字。LwIP 里有些结构体用了PACK_STRUCT_BEGIN和PACK_STRUCT_END在 Keil 和 IAR 里需要正确定义。我一般在 lwipopts.h 里加上#define PACK_STRUCT_USE_INCLUDES 1然后在 cc.h 里包含编译器的 packed 定义。第二字节序。Cortex-M 是小端LwIP 默认也是小端一般不用改。但如果你的 MCU 是大端需要定义BYTE_ORDER为BIG_ENDIAN。第三链接脚本。LwIP 的 pbuf 池和内存堆需要放在 RAM 里如果 RAM 不够可以把不常用的段放到外部 RAM。但要注意外部 RAM 的访问速度DMA 描述符最好放在内部 RAM。6.4 工具链与调试手段的选择调试 LwIP 网络问题光靠 printf 不够。我一般用这几个工具Wireshark在 PC 上抓包看 MCU 发出的包是否正常。如果 MCU 发的包有 CRC 错误Wireshark 会标红。LwIP 的 stats打开 LWIP_STATS定期打印 memp、pbuf、tcp 的统计信息。这个在排查内存泄漏时特别有用。J-Link 的 RTT用 SEGGER RTT 输出日志比串口快不占用 UART。我一般在关键路径上加 RTT 打印记录每个数据包的处理时间。逻辑分析仪抓 RMII 的时序确认时钟和数据的关系。如果 TX_EN 和 TXD 的建立时间不够PHY 会收到错误数据。7. 项目实战中的经验沉淀7.1 一个工业网关项目的完整复盘去年做的一个工业网关用的是 GD32F450 LAN8720跑 LwIP 2.1.2上面跑 Modbus TCP 服务器和 MQTT 客户端。项目要求 7x24 小时运行丢包率低于 0.01%。开发过程中遇到的最大问题是设备运行 4 到 6 小时后MQTT 连接断开重连失败。抓包发现断开时 MCU 发出的 TCP 包序号乱了像是发送缓冲区被破坏。排查了三天最后定位到是 TCP_SND_BUF 和 MEM_SIZE 的配置冲突。TCP_SND_BUF 设了 5840但 MEM_SIZE 只有 4096LwIP 在分配发送缓冲区时内存不够导致部分数据没发出去序号就乱了。把 MEM_SIZE 增到 8192 后问题解决。这个坑的教训是LwIP 的各个内存参数是有关联的不能单独调。TCP_SND_BUF 加上 TCP_WND 加上其他开销不能超过 MEM_SIZE 的 70%。7.2 从失败案例中总结的配置原则经过多个项目的积累我总结了几条 LwIP 配置原则原则一内存池优先。所有 LwIP 内部结构用内存池应用层数据用静态分配或固定大小内存池。原则二参数联动。TCP_SND_BUF、TCP_WND、MEM_SIZE、PBUF_POOL_SIZE 要一起调调完跑压力测试。原则三留足余量。所有计数类参数MEMP_NUM_*至少留 50% 余量防止突发流量导致分配失败。原则四发布版本关调试。LWIP_DEBUG 和 LWIP_STATS 在发布版本里关掉省 ROM 和 RAM。原则五压力测试不可少。至少跑 24 小时 ping 和 iperf观察内存和错误计数。7.3 后续可扩展的方向LwIP 跑通之后还可以往几个方向扩展IPv6 支持。LwIP 支持 IPv6但需要额外的 RAM。如果项目有需求可以打开 LWIP_IPV6配置地址和路由。TLS 加密。用 mbedTLS 和 LwIP 配合实现 HTTPS 或 MQTT over TLS。但 TLS 握手需要大量 RAM国产 MCU 上要谨慎评估。多网口支持。有些国产 MCU 有两个以太网接口LwIP 可以同时管理多个 netif实现冗余或分流。低功耗优化。在电池供电场景下可以让 LwIP 在空闲时进入低功耗模式收到包再唤醒。这需要和 MCU 的低功耗模式配合。我在实际项目里最深的体会是LwIP 在国产 MCU 上跑通不难难的是跑稳。而跑稳的关键不在于协议栈本身而在于你对内存、中断、DMA 这些底层机制的理解。每次出问题最后查到的原因都是某个参数没配对或者某个资源没释放。所以与其花时间研究 LwIP 的源码不如先把 lwipopts.h 里的每个参数搞清楚把内存和中断的账算明白。