ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

lwip 协议栈深度解析:内存管理、pbuf 引用计数与 TCP 收发链路实战

lwip 协议栈深度解析:内存管理、pbuf 引用计数与 TCP 收发链路实战

1. 从“能通就行”到“心里有底”:为什么我决定重啃 lwip

做嵌入式网络开发的人,几乎绕不开 lwip 这个轻量级 IP 协议栈。我最早接触它是在一个工业数据采集项目里,当时的需求很简单:设备通过网口把传感器数据发到上位机,用 TCP 就行。我照着例程把ethernetif_input和tcp_write拼起来,跑通了,数据能收发,任务就算完成。那时候我对 lwip 的全部认知就是“一个能跑 TCP 的库”,至于内存怎么分配、pbuf 怎么流转、超时重传怎么触发,一概不知。

后来项目升级,设备要同时维持几十路 TCP 连接,还要跑 Modbus TCP 和 HTTP 服务,问题就来了。运行几个小时之后,连接开始随机断开,内存池报mem_malloc: out of memory,偶尔还会整个系统卡死。我拿着调试器看了半天,发现 pbuf 的引用计数乱得一塌糊涂,有的地方pbuf_free调了两次,有的地方该释放的没释放。那一刻我才意识到,之前那种“能通就行”的用法,本质上是在赌——赌连接数不多、赌数据量不大、赌不会长时间运行。一旦这些前提被打破,协议栈内部的问题就会全部暴露出来。

所以这次我决定把 lwip 从头到尾认真啃一遍。不是看 API 手册那种啃,而是把源码拉下来,从pbuf、memp、mempool这些基础结构开始,一路看到tcp_input、tcp_output、tcp_slowtmr,把数据怎么进来、怎么排队、怎么发出去、怎么回收这条链路彻底搞清楚。这篇文章就是我这段时间的学习记录,我会把 lwip 的核心机制拆开讲,重点放在那些“文档里不写、但实际会坑人”的地方。如果你也在用 lwip,不管是刚上手还是已经踩过一些坑,希望这些内容能帮你少走点弯路。

需要说明的是,lwip 的配置项非常多,不同版本之间也有差异。我下面讲的内容主要基于 lwip 2.x 的稳定版本,涉及具体参数时会说明它的作用和常见取值,但你在实际项目里还是要以自己用的版本和lwipopts.h为准。另外,我尽量用生活化的类比来解释协议栈里的概念,毕竟这些机制本质上就是一套“收发快递”的规则,只是细节多了点。

2. 内存管理:lwip 的“仓库”是怎么运转的

2.1 mem、memp、mempool 三套机制的分工

lwip 的内存管理是理解整个协议栈的第一步,因为它决定了数据包能不能存下来、能存多少、存多久。很多人一上来就看 TCP 状态机,结果遇到内存分配失败就懵了,其实问题往往出在最底层。

lwip 提供了三套内存管理机制,分别是mem、memp和mempool。它们的分工可以用一个仓库来类比:mem是通用堆内存,就像仓库里一块可以随意切割的大空地,适合分配大小不固定的内存块;memp是固定大小的内存池,就像仓库里一排排规格统一的货架,每种货架只放特定类型的货物;mempool则是把多个固定大小的内存块组合成一个池子,适合需要批量分配同规格内存的场景。

先看mem。它通过mem_malloc和mem_free提供动态内存分配,底层实现有几种策略,最常见的是MEM_LIBC_MALLOC(直接用 C 库的 malloc)和 lwip 自带的堆管理。自带的堆管理用一个空闲链表来维护内存块,分配时从链表里找足够大的块,释放时把块还回去并尝试合并相邻空闲块。这里有个关键点:mem分配的内存块前面会有一个小的头部,记录块的大小和是否被占用,所以实际占用的内存比你申请的多几个字节。如果你在资源紧张的 MCU 上频繁用mem_malloc分配小对象,碎片化会很快出现,最后即使总空闲内存够,也找不到一块连续的大内存。

再看memp。它针对协议栈里那些大小固定的结构体,比如pbuf、tcp_pcb、udp_pcb、netbuf等,预先分配好一组固定大小的内存块,用链表串起来。分配时直接从链表取一个,释放时放回链表,速度极快,而且不会产生碎片。memp的每种类型在lwipopts.h里都有对应的数量配置,比如MEMP_NUM_PBUF、MEMP_NUM_TCP_PCB、MEMP_NUM_TCP_SEG等。这些数量决定了协议栈能同时处理多少个对象,配少了会在高负载时分配失败,配多了会浪费 RAM。

mempool则是更灵活的固定大小内存池,你可以自定义块大小和数量。它和memp的区别在于,memp是 lwip 内部预定义好的几种类型,而mempool允许你自己创建。比如你要实现一个自定义的缓冲区管理,就可以用mempool来分配一组固定大小的块,避免mem的碎片问题。

提示:在资源受限的平台上,优先使用memp和mempool,把mem留给那些确实需要动态大小的场景。很多“内存不足”的问题,根源就是该用固定池的地方用了动态堆。

2.2 pbuf 的结构与引用计数:数据包的“快递盒”

pbuf是 lwip 里最核心的数据结构,所有网络数据都以pbuf的形式在协议栈里流转。你可以把它理解成一个快递盒,里面装着数据,盒子上贴着一些标签说明这盒数据从哪来、到哪去、有多大。

pbuf的结构里几个关键字段需要重点理解。next指向下一个pbuf,因为一个完整的数据包可能由多个pbuf串成链表,比如一个大的 TCP 段被拆成多个pbuf存储。payload指向实际数据区的起始位置,len是当前pbuf里数据的长度,tot_len是从当前pbuf到链表末尾所有数据的总长度。type表示pbuf的类型,常见的有PBUF_RAM(数据存在mem分配的 RAM 里)、PBUF_POOL(数据存在memp的池子里)、PBUF_ROM(数据在 ROM 里,不占用 RAM)、PBUF_REF(数据在别处,pbuf只引用)。ref是引用计数,这是最容易出错的地方。

引用计数的逻辑是:每当有新的地方要使用这个pbuf,就把ref加一;用完之后调用pbuf_free,它会把ref减一,只有减到零时才真正释放内存。这个机制的目的是让多个模块可以安全地共享同一个pbuf,而不用拷贝数据。比如 TCP 收到数据后,应用层可能还在处理,同时协议栈可能因为重传需要保留一份,这时候引用计数就保证了数据不会被提前释放。

但引用计数也是 bug 的重灾区。我见过最常见的问题有两种:一是该加的时候没加,导致pbuf被提前释放,应用层读到的是已经回收的内存,数据错乱;二是不该加的时候加了,或者释放时多调了一次pbuf_free,导致ref变成负数或者内存被重复释放。调试这类问题的办法是,在pbuf_free和pbuf_alloc里加日志,记录每次分配和释放的pbuf地址、类型、引用计数变化,然后对照数据流分析。如果日志量太大,可以只在ref接近零的时候打印,缩小排查范围。

还有一个细节:pbuf_alloc的第三个参数是layer,它决定了pbuf的头部预留多少空间。比如PBUF_TRANSPORT会预留传输层头部空间,PBUF_IP预留 IP 头部空间,PBUF_LINK预留链路层头部空间。如果你在发送数据时发现头部被覆盖了,很可能就是layer选错了,导致预留空间不够。

2.3 内存配置的实战取舍:数量、大小与碎片

配置 lwip 内存参数时,很多人是照抄例程,或者凭感觉填。结果要么 RAM 浪费严重,要么高负载时分配失败。我自己的做法是,先估算最坏情况下的并发需求,再留一定余量。

以 TCP 为例,MEMP_NUM_TCP_PCB决定了能同时存在多少个 TCP 控制块,也就是能同时维持多少条 TCP 连接。如果你要支持 20 条连接,这个值至少是 20,但考虑到连接建立和关闭过程中可能短暂存在多个状态,建议留到 25 到 30。MEMP_NUM_TCP_SEG决定了能同时缓存多少个 TCP 段,这个和发送窗口、接收窗口有关。如果发送窗口是 4 个 MSS,那至少需要 4 个段,但重传队列里可能还有旧段,所以实际要更多。PBUF_POOL_SIZE决定了PBUF_POOL类型pbuf的数量,接收数据时每个网卡驱动通常会分配一个PBUF_POOL的pbuf来装数据,如果这个数量不够,网卡驱动就会丢包。

PBUF_POOL_BUFSIZE是每个PBUF_POOL块的大小,它应该至少能装下一个最大长度的数据帧。以太网 MTU 是 1500,加上各种头部,通常设成 1520 到 1600 之间。如果设小了,一个大数据包会被拆成多个pbuf,增加处理开销;设大了会浪费内存。

碎片问题主要出在mem上。如果你发现系统运行一段时间后mem_malloc开始失败,但mem_free显示还有不少空闲内存,那基本就是碎片化了。解决办法有两个:一是把频繁分配释放的小对象改成用memp或mempool;二是调整mem的分配策略,比如启用MEM_USE_POOLS,让mem从预分配的池子里取块,减少碎片。不过MEM_USE_POOLS也有代价,它需要额外配置一组池子,而且分配大块时可能不如纯堆灵活。

3. 数据从网线到应用层:接收链路的完整拆解

3.1 网卡驱动与 ethernetif:第一道关口

数据包从网线进来,最先接触的是网卡驱动。在 lwip 的移植里,这部分通常实现在ethernetif.c里,核心函数是low_level_input和ethernetif_input。low_level_input负责从网卡硬件读取一帧数据,把它封装成pbuf;ethernetif_input则把pbuf交给netif->input,也就是ethernet_input,正式进入协议栈。

这里有几个容易忽略的细节。第一,low_level_input分配pbuf时,通常用PBUF_POOL类型,因为接收路径对速度要求高,池子分配比堆分配快。但如果你的PBUF_POOL_SIZE不够,或者PBUF_POOL_BUFSIZE小于帧长度,就会分配失败或产生多个pbuf链。第二,网卡驱动和协议栈可能运行在不同的上下文里,比如驱动在中断里收包,协议栈在主循环里处理,这时候需要做好缓冲和同步。常见做法是在中断里只把数据读出来放到队列,然后在主循环里调用ethernetif_input处理。第三,netif->input的返回值要检查,如果返回ERR_OK说明协议栈接管了pbuf,驱动不能再碰它;如果返回错误,驱动需要自己释放pbuf。

我踩过的一个坑是,在low_level_input里分配了pbuf,然后ethernetif_input因为某种原因返回了错误,但我没有释放那个pbuf,结果内存慢慢泄漏。后来我在ethernetif_input的调用处加了判断,只要返回值不是ERR_OK,就手动pbuf_free。这个逻辑看起来简单,但在调试初期很容易漏掉。

3.2 ethernet_input 与 ip4_input:剥开层层头部

ethernet_input拿到pbuf后,先检查以太网头部。如果类型是ETHTYPE_IP,就把pbuf的payload指针向后移动以太网头部长度,然后调用ip4_input。如果是ETHTYPE_ARP,就交给 ARP 模块处理。这里的关键操作是移动payload指针,而不是拷贝数据,这样效率高,但也意味着pbuf里的数据区包含了从以太网头部开始的所有内容,只是payload指向了当前层的起始位置。

ip4_input收到pbuf后,会做几件事:校验 IP 头部(版本、长度、校验和),检查目的 IP 是否是自己或者需要转发,然后根据协议类型分发给上层。如果是 TCP,调用tcp_input;如果是 UDP,调用udp_input;如果是 ICMP,调用icmp_input。在分发之前,ip4_input还会处理分片重组,如果收到的 IP 包是分片,会先放到重组队列里,等所有分片到齐后再拼成一个完整的包。

这里有个性能相关的点:ip4_input里会检查pbuf的len和 IP 头部里的总长度是否一致。如果不一致,可能是网卡驱动多读了填充字节,或者pbuf链有问题。我遇到过一种情况,网卡驱动在收包时把 FCS(帧校验序列)也读进来了,导致pbuf的len比实际数据多了 4 个字节,ip4_input校验失败直接丢包。后来在驱动里把 FCS 去掉就正常了。所以如果你发现所有包都被丢弃,可以检查一下驱动读进来的数据长度对不对。

3.3 tcp_input 的状态机与数据入队

tcp_input是接收链路里最复杂的部分,它要处理 TCP 状态机、序号检查、确认处理、数据入队、窗口更新等一系列逻辑。我把它拆成几个关键步骤来看。

第一步是查找对应的 TCP 控制块。tcp_input会根据源 IP、源端口、目的 IP、目的端口在tcp_active_pcbs、tcp_tw_pcbs、tcp_listen_pcbs等链表里查找匹配的tcp_pcb。如果找不到,会发送 RST 或者忽略。这里要注意,tcp_active_pcbs是双向链表,查找时是从头遍历,如果连接数很多,查找开销会比较大。lwip 默认没有哈希表,所以连接数特别多的时候,tcp_input可能成为瓶颈。

第二步是序号检查。TCP 用序号来保证数据有序和去重。tcp_input会检查收到的段序号是否在接收窗口内,如果不在,可能丢弃或者发送 ACK。如果序号正好是期望的下一个,就接收数据并更新rcv_nxt;如果序号大于期望值,说明中间有丢包,会把数据放到tcp_seg队列里等待重传补齐。这个队列的管理很关键,如果队列满了或者处理不当,会导致内存泄漏或者连接卡死。

第三步是数据入队。接收到的数据会被挂到tcp_pcb的recv_data队列上,等待应用层调用tcp_recv回调来取。如果应用层处理慢,队列会越来越长,占用内存。lwip 通过接收窗口来控制发送方不要发太快,但如果应用层一直不取,窗口会逐渐缩小到零,发送方就暂停发送。这时候如果应用层还不处理,连接就会僵住。所以应用层收到数据后要尽快处理并调用tcp_recved来更新窗口。

第四步是发送 ACK。TCP 收到数据后要确认,tcp_input会根据情况决定是立即发送 ACK 还是延迟发送。延迟 ACK 可以减少 ACK 数量,提高效率,但延迟时间太长会导致发送方超时重传。lwip 里通过TCP_ACK_DELAY相关的配置来控制,默认是收到两个段或者延迟一定时间后发送 ACK。

3.4 应用层回调与 tcp_recved 的配合

应用层通过tcp_recv注册回调函数,当有数据到达时,tcp_input会调用这个回调,把pbuf传进来。回调函数处理完数据后,必须调用pbuf_free释放pbuf,并且调用tcp_recved告诉协议栈“我处理了多少字节,可以更新接收窗口了”。

这里最常见的错误是忘记调用tcp_recved。如果只释放了pbuf但不更新窗口,协议栈会认为应用层还没处理,接收窗口不会扩大,发送方发着发着就发不动了。另一个错误是在回调里做了耗时操作,比如写 Flash、等信号量,导致tcp_input被阻塞,影响其他连接的处理。正确的做法是在回调里只做数据拷贝或者入队,把耗时处理放到单独的任务里。

还有一个细节:tcp_recv回调里拿到的pbuf可能是链表,因为一个 TCP 段可能由多个pbuf组成。你需要遍历整个链表,把所有数据都处理完,然后逐个释放。如果只处理了第一个pbuf就返回,后面的数据会丢失,而且pbuf会泄漏。

4. 数据从应用层到网线:发送链路的排队与重传

4.1 tcp_write 的入队逻辑与拷贝策略

应用层要发数据,调用tcp_write。这个函数并不直接把数据发出去,而是把数据拷贝到协议栈内部的发送队列里,然后由tcp_output在合适的时机发送。tcp_write的第三个参数是flags,常见的有TCP_WRITE_FLAG_COPY和TCP_WRITE_FLAG_MORE。如果指定了TCP_WRITE_FLAG_COPY,lwip 会把数据拷贝一份存到自己的pbuf里;如果不指定,lwip 会直接引用应用层的数据指针,这就要求应用层的数据在发送完成之前不能被修改或释放。

拷贝策略的选择是个权衡。拷贝更安全,但消耗内存和 CPU;不拷贝更高效,但应用层要负责数据生命周期。我一般建议在数据量不大或者对安全性要求高的场景用拷贝,在数据量大且应用层能保证数据不被改动的情况下用零拷贝。但零拷贝有个坑:如果tcp_write返回成功,但数据还在队列里没发出去,应用层就修改了那块内存,发出去的数据就错了。所以用零拷贝时,最好配合tcp_sent回调,等协议栈确认数据已发送或已确认后再释放应用层的内存。

tcp_write还有一个长度限制,它不能超过当前发送窗口和拥塞窗口的最小值。如果窗口不够,tcp_write会返回ERR_MEM,表示暂时写不进去。这时候应用层不能死等,应该把数据缓存起来,等窗口更新后再试。lwip 提供了tcp_sndbuf函数来查询当前可用的发送缓冲区大小,应用层可以根据这个值来决定一次写多少。

4.2 tcp_output 的发送时机与 Nagle 算法

tcp_output是真正把数据推出去的地方。它会被多个地方调用:tcp_write之后、收到 ACK 之后、定时器超时之后。tcp_output会检查当前是否有可发送的数据,如果有,就构造 TCP 段,填上序号、确认号、窗口大小等字段,然后调用ip_output一路发到网卡。

这里涉及 Nagle 算法。Nagle 算法的目的是减少小包数量,提高网络效率。它的规则是:如果还有未确认的数据,就先把小数据攒起来,等收到 ACK 或者攒够一个 MSS 再发。lwip 里通过tcp_pcb的flags里的TF_NODELAY来控制,如果设置了TF_NODELAY,就禁用 Nagle,有数据就发。对于实时性要求高的场景,比如远程控制,通常要禁用 Nagle;对于文件传输这类吞吐优先的场景,可以启用 Nagle。

但 Nagle 和延迟 ACK 一起用的时候可能出问题。延迟 ACK 让接收方等一会儿再确认,Nagle 让发送方等 ACK 再发下一段,两边都在等,就可能出现短暂的死锁,直到延迟 ACK 定时器超时。这个现象叫“Nagle 延迟 ACK 互锁”,在交互式应用里表现为偶尔的卡顿。解决办法是禁用 Nagle,或者调整延迟 ACK 的时间。

4.3 重传队列与超时计算:RTO 是怎么算出来的

TCP 是可靠传输,发出去的数据如果没收到 ACK,就要重传。lwip 用tcp_seg来管理已发送但未确认的段,这些段挂在tcp_pcb的unacked队列上。每次发送新数据,都会创建一个tcp_seg放到队列尾部;收到 ACK 后,把确认号之前的段从队列里移除并释放。

重传的超时时间 RTO 是根据往返时间 RTT 动态计算的。lwip 里用tcp_slowtmr定时器来检查每个连接的 RTO,如果超时了,就重传队列里最早的段。RTO 的计算涉及平滑 RTT 和 RTT 方差,具体公式在 RFC 6298 里有定义,lwip 的实现基本遵循这个标准。初始 RTO 通常是 3 秒,然后根据测量到的 RTT 调整,最小一般不小于 200 毫秒,最大不超过 60 秒。

这里有个实际经验:如果网络环境好,RTT 稳定,RTO 会收敛到一个较小的值,重传很快;如果网络抖动大,RTO 会变大,重传变慢。如果你发现重传特别频繁,可能是 RTO 设得太小,或者网络确实丢包严重。可以通过tcp_pcb里的rttest、sa、sv等字段观察 RTT 的测量值,判断是不是网络问题。

重传次数也有限制,lwip 默认是 12 次左右,超过就认为连接不可达,会关闭连接。这个次数可以通过TCP_MAXRTX配置。如果应用层需要更长的重传容忍度,可以适当调大,但也要考虑资源占用。

4.4 发送窗口与拥塞控制的配合

TCP 的发送不仅受接收窗口限制,还受拥塞窗口限制。接收窗口是接收方告诉发送方“我还能收多少”,拥塞窗口是发送方自己估计的“网络还能承受多少”。实际能发多少,取两者的最小值。

lwip 支持慢启动、拥塞避免、快速重传、快速恢复这些拥塞控制机制。慢启动时,拥塞窗口从 1 个 MSS 开始,每收到一个 ACK 就加 1 个 MSS,指数增长;达到慢启动阈值后进入拥塞避免,每个 RTT 加 1 个 MSS,线性增长。如果检测到丢包,拥塞窗口会减半,然后重新进入慢启动或拥塞避免。

这些机制在 lwip 里是自动运行的,但你可以通过tcp_pcb的cwnd、ssthresh等字段观察当前状态。如果发现发送速度上不去,可以检查是不是拥塞窗口太小,或者ssthresh被降得太低。在局域网这种低丢包环境里,拥塞控制的影响不大;但在广域网或者无线环境里,拥塞控制对性能影响很大。

5. 那些文档里不写、但一定会踩的坑

5.1 pbuf 引用计数错误的排查方法

前面提过引用计数是重灾区,这里展开讲一下怎么排查。最直接的办法是在pbuf_free里加断言,检查ref是否大于零,如果等于零或者负数,说明有重复释放或者未初始化。但断言只能抓到释放时的错误,如果是该释放没释放,断言不会触发,只会表现为内存泄漏。

更系统的做法是记录每个pbuf的生命周期。可以在pbuf_alloc里把pbuf的地址、类型、分配位置(用__FILE__和__LINE__)记到一个表里,在pbuf_free里把对应记录删掉。运行一段时间后,如果表里还有记录,说明这些pbuf没被释放。这个方法需要额外的内存和代码,但在调试阶段非常有效。

还有一种情况是pbuf被多个模块共享,引用计数加得不对。比如tcp_input收到数据后,把pbuf传给应用层回调,同时自己可能还需要保留一份用于发送 ACK 或者重传。如果tcp_input在调用回调前没有增加引用计数,回调里释放了pbuf,tcp_input后面再访问就是野指针。正确的做法是,在把pbuf交给其他模块之前,先pbuf_ref增加引用计数,用完之后再pbuf_free。

5.2 内存池耗尽与连接数超限的连锁反应

内存池耗尽往往不是孤立问题,它会引发连锁反应。比如MEMP_NUM_TCP_PCB用完了,新的连接请求就无法建立,tcp_accept回调不会被调用,客户端看到的是连接超时。如果PBUF_POOL_SIZE用完了,网卡驱动收包时分配不到pbuf,就会丢包,表现为网络时通时断。

更麻烦的是,内存池耗尽后,协议栈可能会进入一种“半死不活”的状态:已有的连接还在,但新的数据发不出去,因为分配不到tcp_seg;接收也处理不了,因为分配不到pbuf。这时候如果没有监控和恢复机制,设备就只能重启。

我的做法是在关键分配点加计数器,记录每种内存池的使用峰值和当前使用量。如果某个池子的使用量持续接近上限,就提前告警或者采取限流措施。比如当MEMP_NUM_TCP_PCB使用超过 80% 时,暂时拒绝新的连接请求,等旧连接释放后再接受。这样虽然牺牲了一点可用性,但避免了整个系统崩溃。

5.3 零拷贝发送时应用层缓冲区的生命周期管理

零拷贝发送能省一次内存拷贝,在高速数据传输里很有价值。但它对应用层缓冲区的生命周期要求很严格。tcp_write不带TCP_WRITE_FLAG_COPY时,lwip 会把应用层缓冲区的指针记到pbuf里,这个pbuf会一直留在发送队列里,直到数据被确认。在这期间,应用层绝对不能修改或释放这块缓冲区。

我见过一个典型的错误:应用层用一个全局数组做发送缓冲,每次要发数据就填进去,然后调用tcp_write零拷贝发送。第一次发送成功,数据还在队列里没确认,应用层又填了新的数据到同一个数组,结果第一次要发的数据被覆盖了,接收方收到的是错乱的数据。解决办法是给每次发送分配独立的缓冲区,或者用拷贝模式,或者等tcp_sent回调确认后再复用缓冲区。

如果确实要用零拷贝,建议配合tcp_sent回调。tcp_sent会在数据被确认后调用,参数里会告诉你哪些数据已经被确认。你可以在回调里释放对应的缓冲区,或者把缓冲区标记为可复用。这样既能享受零拷贝的效率,又能保证数据安全。

5.4 定时器与主循环的协作:tcp_tmr 的调用节奏

lwip 的很多功能依赖定时器,比如重传、延迟 ACK、连接超时、ARP 老化等。这些定时器由tcp_tmr、etharp_tmr等函数驱动,通常需要每 250 毫秒调用一次tcp_tmr,每 1 秒调用一次etharp_tmr。如果调用不及时,重传会延迟,连接可能被误判为超时。

在裸机环境里,通常用硬件定时器产生 tick,然后在主循环里检查 tick 是否到了,到了就调用相应的tmr函数。这里要注意,tcp_tmr内部会调用tcp_slowtmr和tcp_fasttmr,tcp_slowtmr每 500 毫秒执行一次,tcp_fasttmr每 250 毫秒执行一次。所以tcp_tmr的调用周期应该是 250 毫秒,这样两个定时器都能按时触发。

如果主循环里还有其他耗时任务,比如 Flash 擦写、大量数据计算,可能会阻塞tcp_tmr的调用。这时候可以考虑把tcp_tmr放到定时器中断里,但要注意中断上下文里不能做太复杂的事情,而且tcp_tmr可能会访问协议栈的数据结构,和主循环里的协议栈处理产生竞争。更稳妥的做法是用一个独立的低优先级任务来跑协议栈和定时器,保证定时器不会被其他任务长时间阻塞。

6. 把 lwip 用稳的几个配置与调试习惯

6.1 lwipopts.h 里最该关注的几个参数

lwipopts.h是 lwip 的配置中心,参数非常多,但真正影响稳定性和性能的就那么几个。我按重要性排一下。

MEM_SIZE是mem堆的总大小,如果用了mem_malloc,这个值要足够大。但前面说过,频繁用mem_malloc容易碎片化,所以更好的做法是尽量用memp和mempool,把MEM_SIZE留给真正需要动态分配的场景。

MEMP_NUM_PBUF、MEMP_NUM_TCP_PCB、MEMP_NUM_TCP_SEG、PBUF_POOL_SIZE这几个决定了协议栈能同时处理多少对象,要根据实际并发量来配。我一般会先估算最坏情况,然后留 20% 到 30% 的余量。

TCP_MSS是 TCP 最大段大小,它影响每个 TCP 段能携带多少数据。以太网 MTU 是 1500,减去 IP 头和 TCP 头,MSS 通常是 1460。如果设小了,每个段带的数据少,头部开销占比大,吞吐量上不去;设大了可能超过 MTU,导致分片。一般保持默认的 1460 就行,除非你的网络环境有特殊限制。

TCP_SND_BUF和TCP_WND分别是发送缓冲区和接收窗口的大小。TCP_SND_BUF决定了能缓存多少未确认的发送数据,TCP_WND决定了能接收多少未处理的数据。这两个值越大,吞吐量越高,但占用内存也越多。在内存紧张的平台上,可以适当调小,但不要小于一个 MSS,否则会影响效率。

TCP_QUEUE_OOSEQ控制是否缓存乱序到达的段。如果启用,乱序段会被缓存起来,等缺失的段到达后一起交给应用层;如果禁用,乱序段会被丢弃,依赖发送方重传。启用能提高效率,但增加内存占用。在内存充足且网络有乱序的情况下,建议启用。

6.2 用统计计数和日志定位问题

lwip 提供了一些统计计数器,比如lwip_stats结构体里的memp、pbuf、tcp等字段,记录了分配失败次数、丢包数、重传数等。这些计数器是定位问题的第一手资料。比如lwip_stats.memp[i].err如果持续增长,说明对应的内存池经常分配失败,需要调大数量;lwip_stats.tcp.drop增长说明有 TCP 包被丢弃,可能是序号问题或者内存不足。

除了内置统计,我还会在关键路径加自定义日志。比如在tcp_recv回调里打印收到的数据长度和当前rcv_nxt,在tcp_sent回调里打印确认的字节数,在pbuf_free里打印释放的pbuf地址和引用计数。这些日志在调试阶段能快速定位问题,但在生产环境要关掉或者降低频率,避免影响性能。

日志的输出方式也有讲究。如果通过串口打印,要注意串口速度,太慢会阻塞协议栈。可以用一个环形缓冲区把日志存起来,然后在空闲时慢慢输出。如果通过网口输出,要注意不要和业务数据抢带宽,最好用独立的调试通道。

6.3 压力测试与长时间运行的观察点

lwip 的问题往往在长时间运行或者高负载下才暴露,所以压力测试是必不可少的。我一般会做几类测试:一是并发连接测试,同时建立几十上百条 TCP 连接,看连接建立和关闭是否正常,内存是否泄漏;二是大数据量传输测试,持续发送大量数据,看吞吐量是否稳定,重传是否频繁;三是异常测试,比如拔网线、模拟丢包、快速反复连接断开,看协议栈能否正确处理异常并恢复。

长时间运行时,重点观察几个指标:内存池的使用量是否随时间增长(如果有增长,说明有泄漏);重传率是否稳定(如果持续上升,说明网络或协议栈有问题);连接数是否稳定(如果只增不减,说明连接关闭逻辑有问题)。我一般会跑至少 24 小时,有时候跑一周,确保没有累积性问题。

还有一个容易被忽略的点是温度。有些芯片在高温下网络性能会下降,或者内存出错率增加。如果设备工作在恶劣环境,最好在高温下也做一轮测试。

6.4 从“能跑”到“跑得稳”的心态转变

最后说点心态上的东西。我刚开始用 lwip 的时候,总觉得“能通就行”,出了问题就重启,反正设备重启一次也就几秒钟。但后来设备部署到现场,重启意味着数据丢失、业务中断,这时候就不能再靠重启解决问题了。

从“能跑”到“跑得稳”,需要做几件事:一是理解协议栈的内部机制,知道每个配置项的作用,而不是照抄;二是建立监控和告警,能在问题发生前发现苗头;三是做好异常处理,比如内存分配失败时不是直接崩溃,而是优雅地降级或者重试;四是做充分的测试,覆盖各种边界情况。

lwip 是一个很优秀的协议栈,它在资源受限的环境里提供了完整的 TCP/IP 功能。但它的“轻量”也意味着很多地方需要使用者自己把握,配置不当或者用法不对,就会出问题。把上面这些机制搞清楚,再结合自己项目的实际需求去调优,基本就能让 lwip 稳定运行了。我在实际项目里踩过的坑远不止这些,但核心思路是一样的:不要把它当黑盒,要把它当一套需要理解和调校的工具。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进