简介:这是一份面向嵌入式软件工程师、物联网产品开发者和进阶学习者的 lwip-2.0.3 轻量级 TCP/IP 协议栈资源包。lwip 由瑞典计算机科学院的 Adam Dunkels 发起,设计目标是在 RAM 紧缺的微控制器环境中提供高效网络能力,以太网、PPP、Wi-Fi 等接口均可接入;协议栈完整支持 IPv4/IPv6、TCP、UDP、ICMP、DHCP、DNS,其中 TCP 实现了拥塞控制、滑动窗口与重传机制,UDP 则面向低延迟实时场景。zip 压缩包约 2.94MB,内容以协议栈核心源码、示例程序、配置模板与 API 文档为主,用户可依据项目需求裁剪协议、调整内存池大小和最大连接数;多任务模型与动态内存管理也让并发连接处理更灵活。已有 160 人学习,适合物联网终端、智能家居、工业自动化等场景,既能直接集成网络通信能力,也能帮助开发者深入理解 lwip 的移植路径与调优方法。 干了这么多年嵌入式网络开发,lwip-2.0.3 这个版本算是我接触最多、踩坑也最深的协议栈之一。直到现在,不少 STM32 项目、尤其是基于 CubeMX 自动生成代码的工程,底子依然是它。所以今天想把这个老伙计的移植、配置和常用玩法从头到尾梳理一遍,重点放在实操层面——包括 CubeMX 下怎么和 FreeRTOS 搭配、lwip 数据格式怎么理解、cJSON 怎么集成、串口调试怎么搞。这篇东西适合正在做以太网设备、或者准备在单片机上调通网络功能的朋友,无论是刚接触协议栈的初学者,还是被各种疑难杂症折磨过几轮的开发者,应该都能找到点有用的东西。
1. lwip-2.0.3 到底是什么来头
1.1 这版协议栈的核心能力
lwip(lightweight IP)是一个开源的轻量级 TCP/IP 协议栈,专门为资源受限的嵌入式系统设计。2.0.3 属于 2.0 系列的一个稳定小版本,它支持完整的 IPv4 和 IPv6 双栈,TCP、UDP、ICMP、IGMP 这些常用协议全覆盖,还带 netconn 和 socket 两种 API 接口。对于那些需要跑 HTTP、MQTT、Modbus TCP 等应用的 MCU 设备来说,这已经足够撑起绝大多数业务了。
版本号看着不起眼,但 2.0.3 里已经包含了不少让嵌入式开发省心的重要特性。比如它支持校验和硬件卸载(CHECKSUM_CHECK/CHECKSUM_GEN 配合网卡驱动),有内存池(memp)和内存堆(mem)两种分配方式可切换,还支持零拷贝,能让数据在协议栈和应用层之间少做一次复制。在 Cortex-M7 内核的 STM32H7 上跑,如果时钟和 DMA 配置到位,双向吞吐做到几十 Mbps 是没问题的。
1.2 为什么工控和物联网项目还在用它
你要说新协议栈,2.1.x、2.2.x 甚至 3.x 都有了,为什么还有这么多项目锁死在 2.0.3?我自己的体会就一个字:稳。2.0.3 的代码路径非常成熟,网上能找到的教程、驱动和案例基本都是围绕这个版本写的,踩坑记录也丰富,真出问题不会孤立无援。
另一个关键原因是工具链的绑定。STM32CubeMX 的早期 H7/F7 固件包内置的正是 lwip 2.0.3。很多公司从老产品上继承下来的代码,底层驱动、PHY 芯片初始化、中断处理跟这个版本的协议栈是深度耦合的,贸然升级到 2.1.x 要改不少接口。所以对于商业项目来说,“能稳定跑、没人愿意动”就是最大的选型理由。
2. 拿到源码之后,先别急着开编译器
2.1 源码目录里都有啥,哪些必须自己改
lwip 2.0.3 的源码目录结构很清晰。src/core/是协议栈核心代码,包括 TCP/IP 协议实现、内存管理、超时处理等,这部分基本不用改。src/api/是 netconn 和 socket API 的实现。src/netif/是网络接口层,里面有一个ethernet.c负责以太网帧的输入输出,还有个loopif.c是回环接口,调试时偶尔用得上。真正需要你动手的,是src/port/或者你自己的 port 目录,这里要提供操作系统抽象层和网卡驱动。
如果你是从 STM32CubeMX 生成的工程里看到的 lwip,通常代码会被放到Middlewares/Third_Party/LwIP/下面。这个目录里已经集成了适配 STM32 的网卡驱动(在src/netif/ethernet.c和src/netif/etharp.c配合下),以及基于 FreeRTOS 的 sys_arch 实现。即便这样,你还是需要确认 PHY 芯片的地址、中断引脚、复位引脚这些配置对不对。
2.2 对照 lwipopts.h 和 opt.h 理清配置思路
lwip 的配置分层很有意思。opt.h是协议栈自己的默认配置,位于src/include/lwip/opt.h。你自己工程里的lwipopts.h则通过宏覆盖默认值,优先级更高。这个机制一定要搞明白——有些人改了opt.h半天不生效,其实是被lwipopts.h里的宏顶掉了。
配置里最核心的是这几个:
NO_SYS:决定要不要跑操作系统。配合 FreeRTOS 时设为 0,表示使用多线程模式;裸机跑就设为 1,此时 netconn 和 socket API 不可用,只能用 raw API。MEM_SIZE:整个协议栈使用的堆内存大小,单位是字节。TCP 传输大数据量时,这个值太小会导致内存分配失败。MEMP_NUM_TCP_SEG:TCP 分段的数量,影响同时能缓存多少个 TCP 段。发送窗口较大时,这里也需要跟着加大。TCP_WND:TCP 接收窗口大小,决定了接收方一次能通告给对端的缓存能力。在 H7 这种内存大的 MCU 上,可以适当调大,比如 64KB,能明显提升吞吐。CHECKSUM_GEN_IP/CHECKSUM_GEN_UDP/CHECKSUM_GEN_TCP:如果网卡驱动里做了硬件校验和生成,就把这些宏关掉,否则校验和逻辑会重复计算,白耗 CPU 时间。
在 CubeMX 生成的工程里,这些配置通常集中在lwipopts.h的一个大段注释下面。我的习惯是先建立一个小型测试工程,用默认配置跑通 DHCP,然后再逐步调大内存去压吞吐,这样排查问题的时候能快速缩小范围。
3. 在 STM32H7 上把 lwip-2.0.3 真正跑起来
3.1 CubeMX 里的版本锁定和固件包问题
用 CubeMX 生成 lwip 工程,第一步其实是选对固件包版本,这一点很多人会忽略。CubeMX 左侧的“Firmware Package Version”会决定集成的 lwip 是哪个版本。同一个 MCU 型号,有的固件包内置 2.0.3,有的内置 2.1.3,版本不一致会导致sys_arch.h和arch.h的实现方式有差异。所以当你发现网上代码和自己工程对不上时,先检查固件包版本,别急着改代码。
在 H7 系列上,我强烈建议第一次直接生成一个最小工程:开启 ETH 外设、LAN8720A PHY(大部分 H7 开发板都配这个)、配置中断引脚、接好 RMII 接口,然后在 Middleware 里勾选 lwip,并且把操作系统那一栏选择为 CMSIS_V2 或 FreeRTOS。CubeMX 会自动把网卡驱动和 lwip 串起来,生成ethernetif.c,里面已经实现了网卡发送、接收和底层初始化接口,你只需要保证 PHY 地址和时钟没问题。
3.2 网卡初始化与中断模型的细节
STM32H7 的以太网 MAC 使用 DMA 描述符来收发数据,CubeMX 生成的代码里默认有两个 RX 描述符和两个 TX 描述符。这里有一个非常容易踩的坑:如果你在lwipopts.h里把PBUF_POOL_SIZE调小了,但 RX 描述符数量是 4、8,那么接收缓存的实际数量可能比描述符还少,最终导致丢包。我的习惯是让PBUF_POOL_SIZE和 RX 描述符数量保持一致,或者更大。
中断处理上,Ethernet_IRQHandler触发后,会调用HAL_ETH_IRQHandler,然后通过osSemaphoreRelease释放一个信号量,唤醒ethernetif_input线程。这个线程调用netif->input(pbuf)把数据交到协议栈。发送方向则由应用层线程或 socket 层直接调用网卡发送函数。
这种“中断唤醒 + 线程处理”的模型之所以是主流,是因为它把数据接收从 ISR 上下文搬到了线程上下文,协议栈可以在处理 TCP 分段、滑动窗口、重传这些逻辑时自由调用会阻塞的操作。如果你在裸机上跑,就得改成轮询模式或者在主循环里不断调用ethernetif_check,那样实时性和 CPU 占用会差一些。
3.3 和 FreeRTOS 集成时最容易被忽略的配置
lwip 2.0.3 在 CubeMX 中默认和 FreeRTOS 的 CMSIS_V1 或 CMSIS_V2 配合。你需要确认sys_arch.c里创建了几个线程:通常有tcpip_thread(协议栈核心线程)、ethernetif_input(网卡接收线程)、dhcp_thread(动态获取 IP)等。每个线程的栈大小直接关系到稳定性。tcpip_thread默认栈可能只有 1KB 到 2KB,如果同时跑 HTTP server 和 MQTT,很容易栈溢出。
排查线程栈问题时,除了看现象(死机、HardFault、TCP 连接反复断开),还可以在 FreeRTOS 的vApplicationStackOverflowHook里挂一个断点。这个钩子函数会被调用,说明确实有线程把栈用穿了。我的经验是:tcpip_thread和ethernetif_input的栈至少给 2KB 以上,LWIP_TCPIP_CORE_LOCKING如果开启,还要小心并发访问对共享资源的同步问题。
4. 数据格式、串口调试与 cJSON 集成
4.1 从网口到串口:把数据包变成可观测的调试流
开发网络设备时,最大的痛点之一就是“看不见网络数据”。在 PC 上我们可以开 Wireshark,但在单片机上,最方便的手段就是把串口变成调试透传口,把 lwip 收到的原始帧或者协议解析结果打印出来。
最简单直接的方式是在ethernetif_input里把struct pbuf *p的数据内容打印出来。pbuf可能是一个链表,所以要用pbuf_copy_partial把数据拷贝到本地缓冲区,或者直接遍历 pbuf 链逐个打印。注意千万不要直接用p->payload去 printf,因为一个 pbuf 往往只是一段数据的一部分,真正的载荷跨越了多个 pbuf。
“把数据包装成服务 lwip 的数据格式,通过串口发出”这个需求,我实际做过的方案是:在串口接收中断里把整帧数据收到一个环形缓冲区,然后在主循环或独立线程中,把这块数据复制到一个struct pbuf *,再用netif->output发送出去。这样做的本质是在单片机上实现了一个串口转以太网的网关。数据格式上需要手动构造以太网头(目的 MAC、源 MAC、类型字段 0x0800),再加上 IP 头、TCP/UDP 头。这个工作听起来麻烦,但借助网卡驱动已有的接口,实际做完之后对协议理解的帮助非常大。
4.2 用 cJSON 在 lwip 上解析和构造应用数据
当一个嵌入式设备要接入 IoT 平台或提供 HTTP API 时,JSON 几乎成了事实标准。lwip 本身不管 JSON,我们一般会引入 cJSON 这个单文件库。cJSON 非常轻量,只有一个.c文件和一个.h文件,可以直接塞进工程里,编译完全不折腾。
在 socket 或 netconn API 上集成 cJSON 很简单。比如设备收到一个 HTTP POST 请求,body 里是 JSON 数据。先用netconn_recv拿到netbuf,拷贝成以\0结尾的字符串,然后用cJSON_Parse解析。如果解析失败,用cJSON_GetErrorPtr()定位是哪个位置出了问题。返回数据时,用cJSON_CreateObject、cJSON_AddNumberToObject这些函数构造一个 JSON 对象,再cJSON_PrintUnformatted转成字符串,走netconn_write或者send发送回去,最后cJSON_Delete释放内存。
这里有几个容易踩的坑:一是 cJSON 在堆上分配内存,如果lwipopts.h里MEM_SIZE和MEMP_NUM_NETBUF不够,JSON 对象构造到一半会返回空指针;二是 JSON 打印出来的字符串长度是不固定的,发送前一定要用strlen重新算长度,别用之前分配的缓冲区大小;三是在多线程环境下,cJSON 默认用的是malloc/free,而嵌入式工程里malloc未必线程安全,最好把cJSON_hooks配置成 lwip 的mem_malloc/mem_free。
5. 常见问题排查与避坑实录
5.1 我实际遇到的几个典型故障
现象一:能 ping 通,但 TCP 连接打不开。这种问题通常出在端口监听和防火墙之外,最可能是 PCB 布局或 PHY 配置问题,但也有可能是 lwip 配置太保守。我的排查顺序是:先确认 DHCP 是否拿到地址(看netif->ip_addr),再检查本机是否能 telnet 通,最后抓包。如果 ping 响应正常但 TCP 超时,重点看TCP_MSS和TCP_WND的配置。H7 上以太网 MTU 是 1500,TCP_MSS如果被设成 1024 而你知道抓包显示对端通告了 1460,那就要注意分片和 MSS 协商的差异。
现象二:DHCP 一直拿不到地址。PHY 芯片的 link 状态没起来是最常见的。LAN8720A 的地址配置错(有的开发板是 0x00,有的是 0x01),或者晶振频率不对(LAN8720A 必须用 50MHz 外部有源晶振),都会导致 PHY 无法完成自协商。调试时先读 PHY 寄存器 1 的 bit2,看 link 状态位是否为 1。另外,lwip 的 DHCP 有时需要等一段时间,不要一启动就急着看 IP。
现象三:系统在长时间跑之后发生 HardFault。这种情况十有八九是内存泄漏或者栈溢出。lwip 的内存泄漏通常是因为pbuf_free没被正确调用,或者应用层把netconn打开后没关闭。排查时可以在lwipopts.h里打开LWIP_STATS和LWIP_STATS_DISPLAY,用stats_display()看memp的可用计数。如果发现PBUF_POOL的 used 数不断增长且不回落,那就是哪里有 pbuf 没释放。
5.2 调试经验与工程习惯总结
用 lwip 这几年,我最大的体会是:协议栈本身代码很成熟,出问题基本都在驱动、内存和线程这三个外围因素上。所以我的调试经验基本围绕这三方面展开。
第一,移植网卡驱动时,第一件做的事是先把HAL_ETH_ReadPHYRegister和HAL_ETH_WritePHYRegister调通。用 JTAG 或串口直接读 PHY 的 ID 寄存器,比如 LAN8720A 应该是 0x0007。如果读不到,检查电源、时钟、地址引脚,再谈协议栈。
第二,在ethernetif_input里临时加一个计数器变量,每收到一帧就加一,再配合串口打印。这个方法能在不引入调试器的情况下快速判断是“没收到包”还是“收到了但处理失败”。
第三,多线程下注意信号量的初始化和超时。CubeMX 生成的sys_arch里,信号量默认是 1 个计数。中断频繁触发时,如果信号量被多次释放,内核会丢失部分唤醒,但因为有 pbuf 队列,数据本身不会丢,只是处理延迟稍微变大。对大多数应用来说没问题,但如果你做的是高实时性控制,就需要改造成带计数上限的信号量或加一个环形队列。
第四,开启LWIP_DEBUG时要注意,调试信息输出会极大占用 CPU 和串口带宽。千万别在产品上开着全量调试跑。我一般只在问题复现阶段临时打开TCP_DEBUG和ETHARP_DEBUG,定位完立刻关掉。
第五,如果你要用固定 IP,别在lwipopts.h里直接硬编码到netif结构体里,而是通过 CubeMX 的Static IP Address配置项生成,这样重生成代码后配置还在,不会被覆盖。
最后分享一个我一直在用的小技巧:在串口调试终端里同步打印 lwip 的stats,每次 HTTP 请求完成后打印一次MEM_STATS和MEMP_STATS。一段时间后拉出日志,如果发现某个内存池的 max used 明显低于配置值,那就说明配置有余量,可以适当调低;如果无限接近配置值,趁早加大。用数据说话,比拍脑袋定 buffer 大小靠谱得多。
本文还有配套的精品资源,点击获取