先说结论:那次让我熬了两个通宵的“TCP WINDOW FULL、TCP ZeroWindow”,最后根因并不是 lwIP 的内存管理出了 Bug,而是接收链路上“消费速度”和“协议栈配置尺寸”不匹配。我起初是冲着内存管理去查的——毕竟你打开 lwIP 源码,mem.c、memp.c、pbuf.c 这一大片,加上 FreeRTOS 的 heap_4,谁看了都会本能地怀疑是不是堆不够、池不足、泄漏了。但把分配/释放计数、剩余堆、pbuf 池余量全拉出来打点之后,事实毫不犹豫地告诉我:内存这边是清白的。
这篇文章不是给 lwIP 源码做注释,而是记录一次典型的“TCP 接收窗口假死”排查过程,从抓包现象、源码链路一直到修复参数。如果你正在用 STM32 + lwIP 做网关、采集终端或任何需要高频 TCP 收发的嵌入式设备,又正好被 ZeroWindow、Window Full 这种表现困扰,那这轮排查思路应该能帮你少走不少弯路。文里涉及的具体配置我按 lwIP 2.1.x 和 STM32 HAL 驱动来写,但整体的排查顺序换到其它芯片平台也一样适用。
1. 现场还原:设备一切正常,抓包却显示 TCP ZeroWindow
1.1 故障表象与测试环境
先交代一下设备背景:网关主控是 STM32F407,PHY 用的 LAN8720A,协议栈 lwIP 2.1.2,跑 FreeRTOS。网关一侧通过 RS485 采集 Modbus 设备数据,另一侧向上位机软件提供 TCP 服务。平时小流量读写一切正常,问题出在对端周期性批量读取的场景:上位机一次请求里带大量点位,返回的报文体很大,数据在几百毫秒内集中涌向网关。
故障表象有三个特征,缺一不可:
- Wireshark 抓包可以看到网关侧 ACK 包里的 Window size 一路从 65535 掉到 0,然后出现一串
TCP ZeroWindow通告。 - 客户端视角则是另一个名字:
TCP Window Full。说明发送方已经把手里的发送额度用完了,在等网关把窗口打开。 - 现象不是直接死机,而是“慢性死亡”:应用层命令响应变得很慢,重传率飙升,偶尔还会触发上位机主动断开重连。但 MCU 的 CPU 占用率不到 40%,Ping 网关也完全通。
这种故障最让人难受的地方就在这里:你敲开任何一层看,系统都像活着,但业务连接就是奄奄一息。Ping 通是因为 ICMP 处理路径极短,根本不进 TCP 接收队列;应用超时才是真正的警报。如果只盯着 CPU 占用率和内存余量,很容易被表象带偏。
1.2 ZeroWindow 和 Window Full 到底谁在“卡脖子”
先把这个概念捋清楚,后面所有排查都建立在这上面。TCP 头部里的 Window 字段是接收方向发送方宣告的“我还能收多少字节”。它本质上是流量控制:发送方在窗口内可以自由发送,超过窗口就必须停下,等接收方在 ACK 里更新窗口。
TCP ZeroWindow是接收方视角,意思是接收方通告的窗口已经归零:我的接收缓冲区满了,你暂时别发了。这时候发送方没有额度可用,只能周期性地发一个 Zero Window Probe(通常 1 字节)去试探窗口是否恢复。
TCP Window Full是发送方视角,意思是本端已经把对端允许的窗口额度全部用完了,手里还有数据要发,但发送不出去,只能等新的窗口更新。
这两个词是同一枚硬币的两面:网关的接收队列占满导致它通告窗口为 0,于是客户端侧看到的就是“窗口已经满了,发不动”。TCP 本身的机制没有错,它只是在忠实地执行“你满了我就停”的约定。问题在于:为什么网关的接收缓冲区会频繁被填满,以及填满之后为什么迟迟得不到释放。
2. 第一轮排查:从“内存管理”这个最可疑的嫌疑人查起
2.1 为什么我的第一反应是查内存管理
说句实话,lwIP 这名字本身就带“内存管理”的基因。协议栈里跑数据全靠 pbuf,pbuf 的内存来自两个地方:一个是 mem.c 管理的堆(heap),一个是 memp.c 管理的对象池(pool)。尤其是接收路径,数据要先落到 PBUF_POOL 类型的 pbuf 上,再由协议栈层层往上送。如果 pool 耗尽,新到的报文就申请不到 pbuf,只能被协议栈丢弃。丢包之后发送方重传,重传又占更多 pbuf,最终接收侧就“卡死了”。这症状听起来和 ZeroWindow 简直一模一样。
所以我第一轮排查的动作很标准:
- FreeRTOS 的
xPortGetFreeHeapSize()长时间打点,数值稳定,没有持续下降。 - 给 pbuf 的分配和释放各加统计计数器,跑一整夜,alloc 总数和 free 总数对得上,没有泄漏。
- 打开 lwIP 的
LWIP_STATS_DISPLAY,观察MEMP_STATS里的 PBUF_POOL 可用数,高峰期仍然有富余。 - 再例行检查一遍经典泄漏点:recv 回调里忘了
pbuf_free、把 pbuf 指针存到全局变量导致释放时机错乱、tcp_recv传参里的 p 指针被业务代码覆盖……全部排除。
这一套做完,结论已经比较明确了:内存管理这边大概率是清白的。堆没少、池没满、分配释放计数平账。我当时甚至在调试器里跑到过接收处理的高峰时段,单步看 pbuf pool 的 avail 值,从来没有归零过。
2.2 血泪教训:内存没泄漏,不代表没有资源瓶颈
这里有个非常容易误导人的地方,必须单独拿出来说:TCP 窗口的“额度”和 RAM 的“剩余空间”根本不是一个变量。
RAM 不足时,表现是 pbuf 分配失败、报文被丢弃,抓包能看到大量重传和乱序;而窗口归零是另一条路径:报文没有丢,它们全都成功进入了接收队列,只是这个队列一直没有被应用层消费掉。pbuf 还在池子里正常分配着,内存也没多占多少,但 TCP 状态里的rcv_wnd却在一节一节地被扣减,直到扣成 0。
打个比方:内存是仓库空间,窗口是“收货确认额度”。仓库明明还有空位,但你的打单员迟迟不把货物登记入库、不向供应商发出收货确认,供应商那边看到的额度就会一直不释放,最后直接判定“你没有收货能力了,先停货”。
这个认知是我整个排查过程的关键转折点。从这一刻起,我把注意力从“分配”转到了“消费”上:不是内存不够,而是数据进了队列之后没人及时取走。
3. 顺着 LWIP 源码链路,找到窗口被吃掉的最短路径
3.1 一条 TCP 段进入网关后的完整旅程
要搞清窗口是怎么被吃掉的,就得先把 lwIP 接收路径的每一步都摆出来。以 lwIP 2.1.x + FreeRTOS 的常见结构为例,一条 TCP 段从网线到应用,大致要经过这么几道门:
- ETH 的 DMA 控制器把帧写入接收描述符指向的 buffer,然后置位接收中断。
- 中断或主循环里调用
ethernetif_input(),从 HAL 驱动里取出帧,申请一个 PBUF_POOL 类型的 pbuf,把数据拷进去。 - 如果启用了
tcpip_thread,这个 pbuf 会被投递到协议栈线程的邮箱,由tcpip_thread调用tcp_input()。 tcp_input()经过分流入 TCP,进入tcp_process()和tcp_receive()。在tcp_receive()里,校验通过的段会被挂到 PCB 的接收队列(recv_data),同时执行一步关键操作:把pcb->rcv_wnd减去这段数据的长度。- 协议栈觉得数据可以交付给应用了,就通过
tcp_recv注册进来的回调函数把 pbuf 交给应用。 - 应用处理完数据后,必须主动调用
tcp_recved(len),把窗口额度加回来;同时通过pbuf_free()把 pbuf 释放回去。
第 4 步和第 6 步之间的时间,就是窗口的“失血期”。数据每进队列一次,窗口就掉一块;应用每调用一次tcp_recved,窗口才回一口血。如果应用消费得慢,这个失血期就会无限拉长。
3.2 窗口归还的源码逻辑:tcp_recved 才是唯一钥匙
窗口不会自己恢复,这一点在源码里表现得非常直白。lwIP 的tcp.c里,tcp_recved()函数的核心逻辑大致是这样的:
err_t tcp_recved(struct tcp_pcb *pcb, u16_t len) { // 归还之前被扣减的接收窗口额度 pcb->rcv_wnd += len; if (pcb->rcv_wnd > TCP_WND) { pcb->rcv_wnd = TCP_WND; } // 根据队列余量和窗口恢复情况,决定是否立刻补发 ACK 通告新窗口 if (pcb->rcv_queuelen < ... || pcb->rcv_wnd > TCP_WND / 2) { tcp_ack_now(pcb); } ... }而在接收路径的tcp_receive()里,窗口的扣减几乎是无条件执行的,只要段被接受并入队:
tcplen = TCP_TCPLEN(seg); pcb->rcv_wnd -= tcplen;所以整条链路理解起来就是一句话:数据进来先扣窗口,应用调用tcp_recved才还窗口。这就好比信用卡,刷卡时银行立刻冻结额度,你还了款才恢复额度。如果持卡人一直不还款,额度迟早被刷爆,银行再也不会让你继续透支。
再往深一层看,lwIP 还有rcv_queuelen这个队列深度计数,它和窗口是配合使用的。tcp_recved()里既恢复rcv_wnd,也同步扣减rcv_queuelen。如果应用从来不调用tcp_recved(),这两个值都会停在“扣完没还”的状态,下一个 ACK 通告出去的窗口就是 0。
3.3 我真正踩的坑:业务处理时间全部算进了窗口的“失血期”
排查到这里,我已经把源码链路摸得很清楚了,接下来就是回到自己的代码里对号入座。结果一看,问题比我预想的还典型:我在 recv 回调里直接做了业务处理。
当时写在tcp_recv回调里的逻辑大概是:收到一个 pbuf 之后,先做 Modbus 帧解析、CRC 校验、字节序转换,再把数据扔到写 Flash 的任务队列里,甚至在某些情况下直接触发了一次 Flash 页擦除。这些操作本身单独看不重,但它们全都阻塞在协议栈那个执行流里。
lwIP 的 recv 回调是同步调用的:协议线程把 pbuf 交给你,你的回调函数不返回,协议线程就卡在这里,后续所有 TCP 段都进不了下一步,已经入队的段也没法被及时tcp_recved归还窗口。我实测了一下,回调函数一次驻留时间平均在几十毫秒,而 Flash 擦除期间甚至能到 20 毫秒以上。
对端一次批量写 64KB 数据,按 MSS=1460 拆分大约三四十个段。段是快速到达的,队列和 DMA 环很快就堆满了,而我的应用侧只能一个段一个段地慢慢啃。当对端把窗口额度用完之后,后续每一个包都触发一次 ZeroWindow Probe,窗口依旧不恢复,重传越积越多,最终连接进入假死状态。
这个坑之所以典型,是因为大多数人都会想当然地认为:回调函数就是给我处理业务用的,我在这里写点逻辑怎么了?但实际上,在 lwIP 的线程模型里,recv 回调就是协议栈线程本身。你在这里干任何耗时的事,等于让整个协议栈停摆。
4. 根因确认:不是单点 Bug,而是“配速不匹配”
4.1 抓包和源码联手,把证据链条拼完整
到这一步,根因已经基本浮出水面,但我不喜欢凭感觉下结论,还是把证据链整理完整了。三组数据同时对照,基本可以实锤:
第一组是抓包窗口走势。Wireshark 里看网关回给客户端的 ACK 包,Window size 不是突然跳零的,而是呈阶梯式下降:从 65535 掉到 50000 左右,再掉到 30000,再掉到几千,最后变成 0。这个形态说明窗口是随着每个段的入队被持续扣减的,中间偶尔有小幅回升,但只要对端发送一加速、应用处理一减慢,窗口就快速透支。
第二组是rcv_queuelen打点。我在代码里临时加了一个计数器,每收一个段就记录接收队列深度。结果发现队列深度长期稳定在 30~60 之间,而我当时的PBUF_POOL_SIZE只配了 32。虽然还没到 pool 完全耗尽的程度,但已经说明输入速率和输出速率之间存在巨大缺口。
第三组是简单的数学算账。网关峰值接收速率大约 800KB/s,按照 MSS=1460 拆分,大约是每秒 550 个段。而我应用侧一个段处理要 30ms,一秒只能处理 33 个段。550 对 33,差了快 20 倍。任何缓冲池都经不起这种输入输出速度差的长期冲刷。
单独看任何一组数据都有争议,但三组数据指向同一个方向,就没法再和稀泥了:问题不在内存管理,在于应用消费速度跟不上协议栈的接收速度,导致窗口额度长期得不到归还。
4.2 TCP_WND、PBUF_POOL、ETH_RXBUFNB 之间的数量关系
既然问题出在配速不匹配,那配置参数就得重新审视。我当时把核心参数拉了一张表,逐一对了一遍:
| 参数 | 含义 | 我当时配置 | 问题分析 |
|---|---|---|---|
TCP_WND | 接收窗口上限,决定单次通告给对端的在途额度 | 默认 65535 | 窗口大,意味着对端可以同时发来大量数据;消费慢时反而加剧队列积压 |
TCP_MSS | 最大报文段长度,受 MTU 限制 | 1460 | 正常值,不属于问题点 |
PBUF_POOL_SIZE | PBUF_POOL 池中 pbuf 数量,接收数据主要靠它 | 16~32 | 队列积压时,pool 容易被占用,实际上是在替应用“垫资” |
PBUF_POOL_BUFSIZE | 每个 pool pbuf 的 buffer 大小 | 1524 | 需要能容纳一整个 MSS 加上 IP/TCP 头 |
ETH_RXBUFNB | STM32 ETH DMA 接收描述符个数 | 默认 5 | 数量太少时,DMA 环快速占满,硬件层面先开始丢包 |
这张表里最容易让人犯错的,就是TCP_WND。很多人遇到吞吐率不够,脑子里第一反应是“把窗口调大,让对端多塞点数据过来”。但在消费速度不变的前提下,窗口调大只会让数据更快地涌进队列,ZeroWindow 来得更猛。
这里可以用水龙头来类比:发送端是水源,TCP_WND是水池容量,PBUF_POOL 是管道缓冲,应用层是下水道。水池容量再大,如果下水道只有一根细管子,水池迟早会被灌满。决定系统稳态吞吐的不是水池有多大,而是下水道每秒能排走多少。窗口参数的意义不是让水龙头开更大,而是控制“水池最多允许多少水同时涌进来”,从而给下水道争取处理时间。
5. 修复方案:应用、配置、驱动三处一起动手
5.1 应用层整改:回调只做搬运,业务退出协议栈线程
修复的第一步也是最关键的一步,是把业务逻辑从 recv 回调里彻底挪出去。原则很简单:回调里只允许出现微秒级的操作。
我的做法是在应用层建了一个接收环形队列,recv 回调里只做三件事:把 pbuf 里的数据拷贝到环形队列、调用tcp_recved归还窗口、调用pbuf_free释放 pbuf。Modbus 解析、CRC 校验、写入 Flash 全部移到独立的业务任务里处理。
改造后的回调代码大致是这个样子:
static err_t my_recv_cb(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p == NULL) { // 对端关闭连接,做资源清理 tcp_close(pcb); return ERR_OK; } // 把 pbuf 里的数据完整拷进应用环形队列 // 注意 pbuf 可能是链式的,需要遍历所有节点 uint16_t offset = 0; struct pbuf *cur = p; if (ringbuf_free_space(&app_rx_queue) >= p->tot_len) { while (cur != NULL) { memcpy(ringbuf_write_ptr(&app_rx_queue) + offset, cur->payload, cur->len); offset += cur->len; cur = cur->next; } ringbuf_commit_write(&app_rx_queue, offset); } else { // 队列满,直接丢弃并统计。不要在这里等! rx_drop_count++; } // 先归还窗口,让对端可以继续发 tcp_recved(pcb, p->tot_len); // 再释放 pbuf,归还协议栈内存 pbuf_free(p); // 通知业务任务处理新收到的数据 osSemaphoreRelease(rx_sem); return ERR_OK; }这里有两个细节需要提醒:第一,一定要先处理数据、再tcp_recved,最后pbuf_free,顺序不能乱。第二,如果环形队列满了,宁可丢弃并计数,也不能在回调里阻塞等待空间,否则等于把协议栈线程又拖死了。
整改之后我单独测了回调驻留时间:从几十毫秒降到了 5 微秒以内。抓包结果显示,ZeroWindow 彻底消失,窗口始终维持在一个稳定的高位。
5.2 lwipopts.h 参数重新核算并统一
应用层消费速度解决了,参数还是要跟着调,否则两者还是各干各的。我最终没有继续用默认的 65535 大窗口,而是基于“应用消费速度”主动把窗口调到了一个小而稳的数值。
当时算的原则是:窗口只要覆盖应用在最短处理周期内的在途数据量就够了。在网关这类低吞吐、强实时性要求的设备上,窗口并不需要很大。窗口太大的唯一结果是让对端一次塞过来更多数据,把队列压力转嫁给内存。最终我采用的参数是:
#define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) // 5840,主动控制单次在途量 #define TCP_SND_BUF (8 * TCP_MSS) // 发送缓冲 #define TCP_SND_QUEUELEN (2 * TCP_SND_BUF / TCP_MSS) // 发送队列段数 #define PBUF_POOL_SIZE 32 // 接收 pbuf 池数量 #define PBUF_POOL_BUFSIZE (TCP_MSS + 40 + 16) // 对齐到 1536 字节 #define MEM_SIZE 40960 // 协议栈堆大小TCP_WND为什么取 4 个 MSS?因为我算过应用从环形队列里取数据到完成解析落盘的典型耗时,这段时间窗口内最多允许对端再发 4 个段,正好能覆盖在途数据,又不会造成队列深压。PBUF_POOL_SIZE取 32 是为了覆盖窗口峰值加上驱动层的瞬时到达包数,同时兼顾 RAM 成本:32 个 pbuf 加上 buffer 大约占 48KB 左右,在 F407 这类芯片上完全可接受。如果 RAM 更紧张,把TCP_WND减到 2 个 MSS、PBUF_POOL_SIZE降到 16 也是可行的,稳定性依然有保障。
这里再次强调那个核心认知:配置参数永远要服务于应用消费速度,而不是越大越好。
5.3 STM32 ETH DMA 描述符和 Cache 一致性检查
参数和应用层都改了以后,我还顺手把驱动层也过了一遍,因为在同类问题上驱动层经常是隐藏帮凶,虽然这个案例最终没有在这里栽跟头,但值得写进排查清单。
首先是ETH_RXBUFNB。STM32 HAL 的以太网驱动里,接收描述符数量如果太少,DMA 接收环会很容易占满。一旦环满,新到的帧连硬件层面都写不进来,等于在驱动层先丢了一波包。我当时默认配置只有 5 个,最后调整到了 8~10 个。每个接收描述符对应一个 1536 字节的 buffer,多配 5 个也就是 7.5KB 的 RAM,成本不高,但能显著缓解突发流量下的“入口拥堵”。
其次是ethernetif_input的调度频率。如果网卡接收是基于主循环轮询的,而主循环里又恰好有 Flash 擦除之类的长耗时操作,DMA 环就会在这个等待期间持续积累帧,直到溢出。这种场景应该把ethernetif_input放到更高优先级的任务里,或者干脆在中断服务里处理。
最后是 Cache 一致性。如果芯片带 D-Cache(比如 Cortex-M7 内核的芯片),DMA 把数据写进 buffer 后,CPU 有可能从 cache 里读到旧数据,导致 TCP 校验失败、数据错乱、重传堆积,最终症状和 ZeroWindow 几乎一样。遇到这种平台,要么在读取前做 cache invalidate,要么直接用 MPU 把 DMA buffer 区域配置成 non-cacheable:
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buffer, RX_BUFFER_SIZE);我的这套场景用的是 F407(不带 D-Cache),所以没有遇到这个问题,但如果你用的是 H7 或者其它带 cache 的 MCU,这一项必须排在驱动检查的前列,因为它的隐蔽性极高,不一定每次都能复现。
6. 排查工具、复现思路与经验沉淀
6.1 Wireshark 过滤表达式速查
排查这类问题的整个过程中,Wireshark 帮了我大忙,这里把最常用的几个过滤表达式整理出来,方便以后一键定位:
# 只看 ZeroWindow 通告(接收方视角) tcp.window_size == 0 # 直接看专家分析模块里的零窗口事件 tcp.analysis.zero_window # 发送方视角:窗口被占满,发不出去 tcp.analysis.window_full # 配合观察重传情况 tcp.analysis.retransmission # 观察窗口恢复(Window Update)包 tcp.analysis.window_update实际操作时,建议打开 Wireshark 的“TCP 时序图(Stevens)”或者“Time-Sequence Graph”,窗口的阶梯下降、平台期、回弹会非常直观地呈现成一条锯齿状曲线。窗口持续下降的周期越长,说明队列积压和消费延迟越严重。
另外一个容易迷惑的点:tcpdump里打印的是 “TCP WINDOW FULL”,Wireshark里专家分析显示的是 “TCP Window Full”,其实是一回事,只是不同工具对同一现象的叫法不同,别被绕晕了。抓包位置建议放在客户端侧或者交换机镜像口,这样双向都能抓到。如果只能抓单侧,客户端侧也能看到对端通告回来的 ZeroWindow,足够判断问题方向了。
6.2 如果再排查一遍,我会按这样的顺序出手
这套问题从头到尾排查完,我给自己总结了一套固定的出手顺序,现在遇到类似的 TCP 通信异常都是先照着做:
- 先看 recv 回调驻留时间。在回调入口和出口处用 DWT 计数器(
DWT->CYCCNT)打点,算出驻留周期。这一步成本最低,但中招率最高。 - 观察窗口和队列的“扣还差”。打开
LWIP_DEBUGF(TCP_DEBUG),或者直接在调试器里盯pcb->rcv_wnd和pcb->rcv_queuelen。如果扣得多还得少,消费侧一定有问题。 - 把配置和流量模型摆到一起算账。按“峰值速率 / MSS × 平均处理耗时”估算需要多少缓冲,拿结果对比
TCP_WND、PBUF_POOL_SIZE的实际配置,很快能看出配速是否匹配。 - 再查驱动层。
ETH_RXBUFNB数量、ethernetif_input的调度间隔、cache 一致性,这些通常排在协议栈参数之后,因为它们是凑巧出问题,协议栈参数是必出问题。 - 最后才动内存相关代码。统计 pbuf 分配/释放、打开
MEMP_STATS,确认没有泄漏,再怀疑 mem/memp 层的 Bug。大多数情况下,内存都是无辜的。
如果现场急需止损,还有一个临时手段:在应用层检测到“窗口持续为 0 超过 N 秒”时,主动断开并重建 TCP 连接,同时把对端的单次读块大小调小,从源头上减小突发量。这不能根治,但能保住连接不掉线,给后排争取时间。
整套问题解决之后,我最大的体会是:lwIP 这类协议栈里,每一个数字背后都对应一个调用时机,窗口不是靠配置堆出来的,是靠应用层一次一次调用tcp_recved还回来的。以后再遇到 ZeroWindow,我建议你先别急着动内存参数,把“数据从 DMA 到应用回调再到 tcp_recved”这条调用链完整画一遍,答案基本就在里面了。