ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LWIP TCP窗口置零排查:从OOSEQ到内存池的机制分析

LWIP TCP窗口置零排查:从OOSEQ到内存池的机制分析 板子不大问题倒是一点不小。我手里那台跑着LWIP的小网关和上位机通过TCP传文件传到一半速度直接掉到零Wireshark里一抓满屏都是“TCP WINDOW FULL”紧跟着就是“TCP ZeroWindow”。我第一反应也是很多嵌入式老哥能想到的LWIP内存管理出问题了呗PBUF池炸了呗。结果源码从头翻到尾发现事情没这么简单——内存确实被耗光了但耗光它的不是泄漏也不是应用层不读数据而是一个很隐蔽的机制问题。这篇文章就把这次从现象到源码再到最终修复的过程完整捋一遍。1. 现象复盘和“这一定是内存管理问题”的直觉1.1 复现流程与抓包特征先说现场环境主控是STM32F407外扩了一块LAN8720跑的是LWIP 2.1.2的Netconn API系统用的FreeRTOSLWIP作为独立任务跑在tcpip_thread里。上位机是Windows下自己写的一个小工具通过TCP连接到网关然后开始下载网关里存储的一段日志文件大小大概2MB左右。复现步骤很稳定连接建立后前几十KB传输速度很快每包几乎都是满MSS发送到某个时刻上位机发送速率突然降下来抓包里开始出现服务器网关返回的ACK带win0接下来每个包都这样。Wireshark在上位机视角会给这些包打上[TCP ZeroWindow]标记而网关这边收到的数据包则会显示[TCP WINDOW FULL]。连着几百毫秒都恢复不了整个会话基本等于卡死。我当时脱口而出“内存管理出问题”是因为LWIP在动态内存不足时确实会表现为接收窗口越来越小最后缩到0。而且我翻过mem.c和memp.c看到pbuf_pool的分配失败计数确实在涨。看起来证据链非常完整。1.2 先别急着改代码把两个标志认清楚这里必须先说清楚一个很容易被忽略的点TCP WINDOW FULL和TCP ZeroWindow严格来说都不是协议栈的报错而是抓包软件的提示。TCP WINDOW FULL的意思是对端告诉本端“我的接收窗口已经满了”所以本端不能再发更多数据TCP ZeroWindow则是后续对端通告的窗口字段直接变成0。那么问题来了LWIP里的什么条件会导致它通告win0从协议栈内部来看只有一种情况——当前接收窗口rcv_wnd已经小于或等于0并且接收路径上已经没有能力再为新数据腾出缓冲。这里“没有能力”又分两种一种是应用层迟迟不读走数据接收队列里的pbuf占着不放另一种是内核想要分配pbuf去接收新数据或发送ACK但内存池已经空了。我最初的假设是第一种但很快发现应用层一直在正常netconn_recv数据明明在持续取走。于是焦点就落在第二种。2. LWIP接收路径是怎么决定把窗口置零的2.1 窗口通告更新的触发逻辑LWIP里接收窗口不是“每个包都强制更新”的代码在tcp_in.c的tcp_receive()里维护pcb-rcv_wnd。每收到一个合法数据段先把数据放进接收队列再根据pcb-rcv_nxt和已接收数据长度去推进rcv_ann_wnd、rcv_ann_right_edge这些状态。真正向对端通告窗口是在发送ACK时由tcp_out.c里的tcp_output()根据当前可用的接收空间重新计算窗口字段。这里有一个关键细节LWIP默认接收窗口的更新是“懒”的。它不会因为应用层刚读走一包数据、腾出了一些缓冲就立刻在下一个ACK里把窗口开回去。通常要等到接收缓冲腾出足够空间比如达到一个TCP_MSS或者一定比例之后才会在某个ACK里把rcv_wnd恢复。如果腾出的缓冲不足对端看到的窗口就会一直是0或者一个很小的值。看源码时我还在tcp_receive()里看到了专门处理接收窗口溢出的逻辑如果当前接收队列的长度已经不能容纳下一个TCP段代码会把rcv_wnd置为0同时在报文的ACK里明确告诉对端“现在别发了”。这个逻辑本身没有bug但它的前提是“接收队列的长度不能容纳下一个TCP段”这件事必须真实反映系统内存状态。2.2 内存不足和窗口为0之间隔着一层“缓冲分配”LWIP接收数据不是直接落在应用层缓冲里的而是先由网卡驱动把数据搬运到pbuf中再挂到TCP控制块的接收队列recv_q上。应用层通过netconn_recv或socket读数据时才把这些pbuf从队列中摘走最终由应用层释放。也就是说TCP接收窗口的大小本质上是“接收队列里pbuf可用量”的对外表现。窗口调大前提是内核有足够pbuf去容纳这些数据窗口收缩到0说明内核已经没有多余的pbuf去填充接收队列。我在lwipopts.h里配的PBUF_POOL_SIZE是16个理论上每个pbuf 1512字节总共大约24KB的缓冲池。说实话这个配置对2MB量级的数据传输是偏小的但还不至于像现场这样一两百KB就卡死。真正让我意外的是检查lwip_stats时pbuf_pool_free_cnt持续为0可同时recv_q里挂着的pbuf数量却不到池子总量的一半。说明有一部分pbuf被占着但根本不在接收队列里。那它们去哪了顺着这个线索我才开始认真翻源码里那些平时不怎么看的角落。3. 源码级排查从mem到ooseq3.1 打开 LWIP 的 DEBUG 输出这种问题不能光靠猜得让协议栈自己说话。我先把LWIP的调试开关都打开改lwipopts.h里的配置#define LWIP_DEBUG LWIP_DBG_ON #define TCP_DEBUG LWIP_DBG_ON #define MEM_DEBUG LWIP_DBG_ON #define MEMP_DEBUG LWIP_DBG_ON #define PBUF_DEBUG LWIP_DBG_ON #define TCPIP_DEBUG LWIP_DBG_ON #define LWIP_STATS 1 #define LWIP_STATS_DISPLAY 1注意在正式项目里不建议长期全开DEBUG光是串口打印就能吃掉不少CPU。但排查阶段可以开开完以后跑一次复现流程重点看串口里有没有pbuf分配失败、memp分配失败之类消息。我这次复现时串口输出里果然看到了PBUF_POOL: out of memory的报错而且每秒钟出现很多次。不过这里有个陷阱PBUF池报“out of memory”不代表池子真的被耗尽到不可恢复。LWIP在多个地方会申请pbuf比如发送ACK、发送数据重传、转发到应用层等待队列等。如果一次突发流量里同时申请好几次临时耗尽是完全可能的。所以我没急着加池子大小而是决定先去定位到底是谁在反复分配、分配了又不释放。3.2 重点看过的几个关键函数我先后排查了mem.c的mem_malloc、memp.c的memp_malloc、pbuf.c的pbuf_alloc、tcp_in.c的接收处理、tcp_out.c的发送处理。这轮排查下来最具迷惑性的点在于全网关闭内存泄漏嫌疑。所有pbuf都能找到对应的释放路径mem_stats里heap的损耗也很稳定。然后我把注意力放到tcp_in.c中一个很容易被忽略的函数路径——乱序报文处理。LWIP有一个配置项叫TCP_QUEUE_OOSEQ默认是1也就是支持乱序队列。代码逻辑大致是当一个数据段的序列号不是期望的rcv_nxtLWIP不会立刻丢掉它而是先根据对应的控制块检查能否插入乱序队列pcb-ooseq并尝试按序列号排序。如果队列里有连续的数据它可以合并后再提交给接收队列。这个机制本身是好的它能在网络乱序时减少重传。可问题也恰恰出在它身上插入ooseq的pbuf会长期滞留在内核里既不进入用户接收队列也不会因为应用层读取而释放只能等序列号补齐、数据整理完才释放。如果网络持续乱序每来一个乱序包就会扣掉池子里的一个pbuf而且是不占用recv_q统计的那种占用。我对照lwip_stats里的pbuf_pool_used_cnt和tcp统计项时发现未释放的pbuf几乎都挂在ooseq队列上面。4. 真正把问题钉死的证据链4.1 排除“应用层读得慢”这个假设很多帖子讲TCP ZeroWindow都会先怀疑应用层。我一开始也这么干在任务里把接收循环改成“有多少读多少”的密集模式甚至直接把接收到的数据丢弃不写SD卡排除存储瓶颈问题依旧。应用层每次netconn_recv都能立刻返回数据说明内核确实有数据可读而且读得走那问题就不在应用层。我是怎么确认这点的在接收任务里加了个计数器统计每次netconn_recv之间间隔和单次拿到的字节数。实测单次拿到的数据大多是整包MSS间隔只有几百微秒从未出现“等待超过几十毫秒才有新数据”的现象。如果应用层拖后腿recv_q会越积越长但我们看到recv_q一直处于低位。所以真正堵住的入口是pbuf池而占用池子的正是那几个挂在ooseq上、迟迟不能合并的乱序包。4.2 乱序队列为什么会一直累积为了复现乱序我在抓包里看到了TCP序号跳变和重传请求上位机发的某一段数据进了网关之后因为当时另一个高优先级中断导致tcpip_thread处理延迟后面的包先被驱动收进来并顺手投递给了协议栈而前一个包还卡在中断里没处理完。就这么一个极短暂的窗口几个包的序列号在协议栈看来就“乱了”。如果网络环境稳定乱序包会很快被后面的数据补齐ooseq队列清空一切恢复。但现场的问题是高优先级中断频繁抢占tcpip_thread导致协议栈处理数据的节奏很不均匀乱序包不断进来ooseq几乎一直挂着两三个包。这几包数据别看只有几KB可它们占用的pbuf恰恰是发送ACK时也要用的同一类缓冲。当池子里可用的pbuf数量降到一个临界值以下任何ACK的分配都会被放弃窗口自然就刷不动了。4.3 机制缺陷而不是单纯的内存容量不够到这里我才确定根子不是PBUF_POOL_SIZE配小了而是LWIP在内存压力大时对“立即释放乱序队列”这件事处理得太保守。理论上它完全可以把ooseq里这些包扔掉让对端重传先把ACK发出去保住接收窗口。但默认代码不会这么做因为TCP_QUEUE_OOSEQ就暗示协议栈“尽量保留乱序包”。所以你就算把PBUF_POOL_SIZE从16加到64也只是把崩溃点往后推了几百KB卡死一次还是会发生而且占用的内存会白白多出好几倍。我试过这个方案实测就是把零窗口出现的时间从传输200KB后延到了传输800KB后本质没变。5. 修复方案和参数调整5.1 关闭OOSEQ的取舍想明白机制之后最直接的修复就是关掉TCP_QUEUE_OOSEQ。把它在lwipopts.h里设为0#define TCP_QUEUE_OOSEQ 0关闭之后LWIP遇到乱序包会直接丢弃并期待对端超时重传。这样做会牺牲一点极端弱网环境下的吞吐但换来的是内存使用变得非常可预测每个接收到的数据包都会立刻进入recv_q能被应用层及时读走和释放。在STM32这类MCU上内存可预测性比那一点点重传效率重要得多。我调整之后重新跑同一套复现流程连续传十几个2MB文件都没再出现零窗口抓包里偶尔能看到快速重传但窗口通告一直维持在正常水平。这个方向我认为是对的嵌入式TCP应该优先保证“不把系统拖死”而不是在内存紧张时还硬撑着维持乱序队列。5.2 稳定的配置模板这次问题也逼着我重新梳理了LWIP的内存相关参数现在团队里所有同类项目都按这个模板起步参数推荐值说明PBUF_POOL_SIZE32按TCP_WND / (TCP_MSS - 40)估算留1.5倍余量TCP_MSS1460标准以太网MSSTCP_WND4 *TCP_MSS窗口不用刻意开大够用即可TCP_QUEUE_OOSEQ0小内存设备强烈建议关闭TCP_SND_BUF8 *TCP_MSS发送缓冲要稍大于窗口避免写阻塞MEMP_NUM_TCP_SEG32发送段队列节点数和发送缓冲匹配LWIP_WND_SCALE0不开窗口缩放降低内存压力LWIP_STATS1建议默认开方便排查内存类问题这套参数用在一块只有几十KB可用内存的MCU上实测既不牺牲连接稳定性也留下了充足的余量。如果项目实在需要大窗口、高吞吐建议直接换带内部SRAM更大的主控别在小内存设备上硬开大窗口。5.3 调整后的实测效果改完参数后我把传输测试跑了好几轮包括故意往网络里注入丢包和乱序的极端场景。结果如下2MB文件连续传20次零窗口出现次数降为0TCP窗口全程维持在win5840左右。抓包里偶尔有少量重传但都是快速重传协议栈处理响应非常快。lwip_stats里的pbuf_pool_free_cnt长期稳定在20以上再也没出现过清零。CPU占用率相比之前略有下降因为不再频繁处理乱序插入和队列整理逻辑。对这个项目来说这个结果是我能接受的。牺牲了一点弱网乱序下的极限吞吐换来了整个系统的确定性。6. 避坑清单与排查速查表6.1 再遇到类似问题按这个顺序走第一步永远是抓包。Wireshark看两样东西窗口通告曲线和序号跳变。如果零窗口之前出现大量乱序和重复ACK可以直接怀疑OOSEQ如果窗口是逐步减小的重点看应用层读取速度如果窗口一下直接砸到0重点看pbuf池是否瞬间被占光。第二步是看LWIP内部计数。调用一次LWIP_STATS_DISPLAY()或者在tcpip_thread里周期性打印观察pbuf_pool_free_cnt和pbuf_pool_alloc_err的变化趋势。如果alloc_err一直在涨确认是缓冲分配跟不上如果涨一阵停一阵说明是突发性占用。第三步才是改参数。不要上来就把PBUF_POOL_SIZE翻倍每改一个参数都验证一次避免因为“内存池加大”碰巧掩盖了真正的机制问题。6.2 几个容易犯的错误把TCP_WND调到很大却不同步加大pbuf池。窗口大只是协议层面的上限底层没有缓冲撑着ACL还是会零窗口。只查mem_malloc的堆分配失败忽略pbuf池。LWIP接收路径多数pbuf来自专用池PBUF_POOL堆和池是两个独立体系。应用层没有及时释放netbuf。用Netconn API时netbuf_delete忘掉会造成接收缓冲只借不还这个问题表现和OOSEQ很像但排除起来很快。在弱网环境测试时没开统计直接看现象导致误判。凡是内存类问题建议测试阶段一律打开LWIP_STATS数据永远比感觉可靠。我自己在这次排障里学到的最大一课是看到TCP ZeroWindow别急着骂内存管理。LWIP的每个内存策略背后都有取舍先把取舍逻辑弄清楚再决定动手改哪里。现在回头看如果当初直接加大pbuf池这个坑以后迟早还会用另一种方式踩回来。
返回列表