
前阵子帮人调一块基于LWIP协议栈的板子TCP服务端监听在54321端口上位机怎么连都握手失败。抓包一看SYN发出去了然后就没有然后了。当时我第一反应就是状态机卡死了。做嵌入式网络开发的人都知道LWIP把整个TCP/IP协议栈压进几十KB内存的MCU里靠的就是一套极其紧凑的状态机设计。TCP连接状态机不是孤立的RFC概念它在LWIP里直接决定了pcb的创建、链表移动、回调触发、超时重传甚至内存回收的时机。你只要把状态机的迁移路径、触发条件和卡死点吃透调起网络来效率完全不同。这篇文章我把自己的调试经验和理解整理出来从LWIP的tcp_pcb结构、状态字段、三次握手和四次挥手的完整路线到典型连接卡死问题的排障方法全部拆开讲。适合刚接触LWIP的嵌入式工程师也适合被TCP连接问题反复折磨的项目维护者。1. 为什么LWIP必须靠状态机才能撑起TCP连接1.1 TCP连接的骨头就是状态迁移表TCP不是简单地把数据扔出去就完事。一个连接从无到有、从有到无中间要经历严格的握手、确认、重传、关闭流程。这些流程本质上是通信双方对连接状态的不断协商和更新。比如客户端发SYN进入SYN_SENT服务端收到后进入SYN_RCVD并回SYNACK客户端收到后进入ESTABLISHED再回最后一个ACK。这个过程中的每一步都依赖双方维护一套状态记录。如果某一端状态记错了后面所有数据段的处理逻辑全会乱套。拿快递场景类比客户下单后处于待发货仓库备货后变成待揽收快递员取件后变成运输中签收后变成已完成。每个环节都有明确的身份变化事件触发顺序固定。TCP状态机干的就是这个事只是它的状态更多、事件更多而且要在一个CPU上连续处理多个连接。LWIP里这套状态并不是散落各处的变量而是收敛在一个叫做tcp_pcb的结构体里。PCB是Protocol Control Block的缩写每个TCP连接都对应一个独立的tcp_pcb实例。状态机所有信息——当前状态、发送序号、接收序号、未确认队列、重传计时器——全部挂在它名下。1.2 轻量化协议栈的状态机设计取向LWIP和我们平时用的Linux内核协议栈不一样。Linux里一个TCP连接有一整套复杂的软硬件资源管理逻辑甚至每个连接都有独立的套接字缓冲区和内核线程上下文。LWIP面向的是MCU、物联网网关这类资源受限环境内存通常只有几十到几百KB没有MMU很多时候跑的还是单线程裸机或者一个RTOS任务。所以在LWIP里面TCP状态机的实现走的是事件驱动路线外部网络包到达后触发tcp_input()应用层调用tcp_write()/tcp_close()也会触发状态检查系统定时器到期则触发超时重传或轮询回调。所有状态迁移都在这几个函数里顺序完成没有复杂的锁竞争也不需要为每个连接分配内核线程。这种设计带来的好处是确定性极高。你在精心设计的临界区里调用tcp_input状态迁移就是原子的不会出现并发访问同一pdb的撕裂问题。代价则是任何一步状态迁移漏做了比如该重传的没重传、该关闭的没关闭连接就卡死在那里直到协议栈主动超时判定失败。理解了这一点再看后面的状态迁移路线和排障方法就完全对得上号了。2. tcp_pcb控制块状态机落地的五线谱2.1 state字段与协议状态枚举LWIP的tcp_pcb里有一个核心字段state它就是TCP连接当前所处状态的唯一权威记录。LWIP对TCP状态的定义基本照搬RFC 793但在枚举写法上做了精简/* lwip源文件中关于TCP状态的枚举节选并简化过 */ enum tcp_state { CLOSED 0, LISTEN 1, SYN_SENT 2, SYN_RCVD 3, ESTABLISHED 4, FIN_WAIT_1 5, FIN_WAIT_2 6, CLOSE_WAIT 7, CLOSING 8, LAST_ACK 9, TIME_WAIT 10 };你只要在某处通过调试串口看到某个pcb的state等于7就知道它正处于CLOSE_WAIT然后顺藤摸瓜去找对端FIN之后应用层为什么没关闭连接。这就是状态机调试的入口也是所有排障的第一落点。值得一提的是LWIP里这些状态的pcb并不都挂在同一个链表上。LISTEN状态的pcb挂在tcp_listen_pcbs上SYN_SENT和人后建立的连接的pcb挂在tcp_active_pcbs上TIME_WAIT状态的pcb则可能被挂到tcp_tw_pcbs。这些链表分类本身就是状态机的一部分协议栈要遍历某个状态集合时不用扫描所有pcb直接从对应链表出发即可。2.2 状态之外的幕后变量只看state字段还不够。一个TCP连接能真正完成数据传输还必须依靠一大堆辅助变量来记录发送/接收的进度和可靠性信息。字段作用和状态机的关系snd_nxt下一个要发送的字节序号决定发送窗口SYN/FIN也占序号rcv_nxt期望收到的下一个字节序号决定是否发送ACK、能否丢弃乱序包snd_una最早未被确认的字节序号判断ACK是否有效丢弃重复ACKunsent队列待发送的数据段状态为ESTABLISHED才能正常发送unacked队列已发出但没收到ACK的数据段超时重传时从这里取数据rto重传超时时间推动SYN_SENT等状态的自愈timer/nrtx当前重传计数超过阈值直接ABRT连接这些变量看起来和数据发送关系更大但它们和状态机的推进是拧在一起的。举个例子收到一个ACK时协议栈先检查snd_una确认这是不是有效的ACK再检查状态是FIN_WAIT_1还是LAST_ACK决定收下这个ACK后直接进入FIN_WAIT_2还是回到CLOSED。如果不理解这些变量你想搞清楚为什么FIN_WAIT_1半天不跳到FIN_WAIT_2就会很吃力。2.3 状态机不是死板的if-else而是输入事件分派LWIP对TCP输入处理的核心函数tcp_input()里有一个按state分支处理的大逻辑比如SYN_SENT状态下收到SYNACK和收到纯RST的处理完全不同ESTABLISHED状态收到带数据的包和带FIN的包也走不同分支。我建议大家不要把LWIP里的状态机理解成一张静态转移表而应该理解成输入段 事件 当前状态 → 下一个状态 动作的处理函数集合。以收到一个数据段为例它会依次检查这个段是否在当前接收窗口范围内携带的数据是否被accept回调取走段尾是否带有ACK是否带有FIN是否触发了快速重传。每检查一项可能都会导致状态微调。比如收到FIN时如果当前是ESTABLISHED就先把状态改成CLOSE_WAIT然后通知应用层数据已经收完但此时发送方向仍然可以发数据。这就是半关闭语义在LWIP里的具体实现。3. 连接建立与断开的完整状态迁移路线图3.1 主动打开SYN_SENT的今生来世客户端接口一般长这样err_t tcp_connect(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected);tcp_pcb创建后先被设为SYN_SENT同时协议栈会给pcb分配一个初始序号发送SYN段并挂入unacked队列然后pcb被挂到tcp_active_pcbs链表上。这里的初始序号一般不是0LWIP会基于时间或随机种子生成目的就是防止旧连接的重叠数据段混入新连接。SYN发出后状态机等待SYNACK。如果网络丢包LWIP会走TCP超时重传逻辑重传SYNnrtx计数加一。重传间隔会按指数退避增长从1秒、2秒、4秒这样往上涨。直到nrtx超过TCP_MAXRTX协议栈直接关闭pcb释放内存并通过err回调返回ERR_ABRT。这个链条是状态机自愈能力的关键。当SYNACK到达协议栈会先检查ACK序号是否正好等于snd_nxt再检查对端通告的窗口大小并把状态迁移到ESTABLISHED。紧接着它会把SYN前占据的序号空间从unacked队列清除然后调用你注册的connected回调。到这一步三次握手才算真正完成应用层才能开始读写数据。3.2 被动打开LISTEN到ESTABLISHED的两跳服务端场景下你需要先调用tcp_new_ip_type()创建pcb再调用tcp_bind()绑定端口之后调用tcp_listen()把pcb状态变成LISTEN。此时pcb会从tcp_active_pcbs迁移到tcp_listen_pcbs同时要注册accept回调。当SYN段到达时LWIP的处理很有意思它不会直接用LISTEN状态的pcb去建立连接而是先分配一个新的tcp_pcb把这条新连接的状态设为SYN_RCVD然后让新pcb回SYNACK。原来的LISTEN pcb依旧留在监听链表上继续处理新的连接请求。这条监听pcb只负责监听、新pcb只负责处理连接的设计是状态机的一个关键边界。你要是忘记给新pcb注册accept回调即使三次握手完成应用层也感知不到新连接的存在。我见过不少项目在服务端卡死其实就是这个回调没接好。等到客户端发来最后一个ACK新pcb的状态从SYN_RCVD跳成ESTABLISHED协议栈同时遍历listen链表找到监听的pcb调用它的accept回调把新连接交给应用层。如果应用层在accept里又把该pcb的状态改成CLOSED那就是主动拒绝如果一直不处理数据也会先进入接收缓冲区只是窗口通告越来越小最终对端发不进来。3.3 正常关闭的四种路径TCP关闭比握手更复杂因为双方都要关闭各自的发送方向而且关闭过程中还有数据没发完的可能。常见的关闭路径有四条场景主动方状态变化被动方状态变化主动关闭且对端配合FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAITCLOSE_WAIT → LAST_ACK → CLOSED主动关闭对端也同时关闭FIN_WAIT_1 → CLOSING → TIME_WAIT同样进入CLOSING主动关闭后数据未发完FIN_WAIT_1 → 无变化直到数据发完CLOSE_WAIT期间可继续接收被动关闭后应用层再主动关ESTABLISHED → CLOSE_WAIT需要应用层主动close先看最常见的客户端主动关闭。应用层调用tcp_close()后如果发送缓冲区里还有未发完数据LWIP不会立刻发出FIN而是先继续发送数据等unsent和unacked队列全部清空才在最后一个数据段的尾部带上FIN。这个行为特别重要很多人误以为close就是立即发FIN结果发现对端还在收数据状态却卡在FIN_WAIT_1。FIN发出后状态变成FIN_WAIT_1。收到对端ACK到该FIN后状态跳到FIN_WAIT_2之后对端如果也关闭会发来它的FIN此时状态变成TIME_WAIT。TIME_WAIT状态需要在收到对端最后一个ACK后再保留2MSL时间这是为了避免旧连接的重复数据段干扰新连接。被动方收到FIN的瞬间如果处于ESTABLISHED状态则跳到CLOSE_WAIT。此时发送方向仍然是开放的你还可以调用tcp_write()发数据但对端已经不再发送新数据了。应用层在CLOSE_WAIT状态必须主动调用tcp_close()否则连接就一直悬着。3.4 TIME_WAIT与2MSL的坑TIME_WAIT在嵌入式项目里很让人头疼。主动关闭的一方要等2MSL也就是最大报文段生存时间的两倍这段时间里那个pcb的内存不会立刻释放。如果新连接恰好使用相同的四元组源IP源端口目标IP目标端口协议栈在PC上会强拆但在LWIP里处理得更简单直接。LWIP对TIME_WAIT的处理策略也和版本、配置有关。你可以在移植层调整TIME_WAIT持续时间比如通过tcp_time_wait_reset_time之类的宏或者保持TIME_WAIT状态的pcb最长时间也可以选择对TIME_WAIT状态做更激进的回收。但我不建议无脑设为0否则可能出现旧连接的迟达数据段被当成新连接的数据收进来数据错乱的概率明显上升。实操中最稳妥的折中方案是如果服务和客户端都跑在自己可控设备上可以考虑通过socket选项禁用Nagle、调整TIME_WAIT抑制等策略但不要直接从状态机里删TIME_WAIT。如果内存确实吃紧把2MSL缩短到可接受的分钟级或者支持端口复用是更值得做的优化。4. 状态机跑偏时的排障现场连接卡死问题这样定位4.1 抓包第一代码第二先确认状态卡在哪一侧每次遇到连接异常我第一步永远不是去看LWIP源码而是抓包。PC端用抓包工具嵌入式端用串口或者逻辑分析仪配合协议分析实在不行就PC上用抓包软件看本机网卡上的流量。抓包能快速回答三个问题SYN到底有没有发出去对端有没有回SYNACK最后的ACK有没有丢很多状态机卡死本质上是抓包就能看出来的网络层问题而不是代码问题。比如我前面提到那个54321端口连不上的案例抓包发现SYN反复重传对端毫无响应。于是去查对端服务有没有监听、防火墙有没有放行、IP是否可达最后发现是对端服务没起来。这不是状态机的问题但表现出来就是SYN_SENT状态迟迟不变因为LWIP只在本地pcb上记着状态它无法感知对端服务不可用只能靠重传超时推导失败。4.2 卡在SYN_SENT状态的排查清单如果抓包发现SYN一直重传而对端确实在监听那就要按下面这条清单逐项自查检查项可能问题解决方向本地IP和路由板子没有配置默认网关或目标IP不在同一网段检查netif配置、IP地址绑定ARP解析对端MAC地址没解析出来SYN无法封装成以太网帧发出检查ARP缓存确认对端可达源端口冲突主动连接绑定的源端口被其它连接占用且大量处于TIME_WAIT换连接策略或调整TIME_WAIT回收对端TCP参数对端半连接队列满SYN被丢弃调对端队列或降低连接频率本端源IP选择多网卡绑定错误SYN从错误的网卡发出检查tcp_connect时传入的源地址尤其最后一点在双网卡设备上特别坑。你的应用代码写的目标IP没问题但LWIP的pcb里绑的是某一个netif的IP如果路由选择把包从另一个netif发出SLIP或者点对点链路还好以太网上直接就是哈希到错误网卡。解决方法是明确调用tcp_bind_netif或者在tcp_connect前确认netif已up。4.3 卡在CLOSE_WAIT十个有九个是应用层没关服务端最容易踩的坑是CLOSE_WAIT堆积。客户端主动断开发来FINLWIP状态机一路跳到CLOSE_WAIT然后啪一下什么都不动了。原因很简单应用层在recv回调里收到了ERR_OK或收到FIN后的剩余数据却没有调用tcp_close()。LWIP只是协议栈它没有权限替应用层决定这个连接结束了我帮你关掉。所以它把球踢给应用层CLOSE_WAIT状态等你主动关闭你一直不关它就永远挂着。我见过最夸张的一次设备开了100多个连接全部卡在CLOSE_WAIT内存被挤爆新连接无法建立。排查到最后就是一个回调函数里漏了一行tcp_close()。更隐蔽的情况是应用层确实调了tcp_close()但协议栈认为发送缓冲区里还有未确认数据FIN一直发不出去状态停在CLOSE_WAIT后转入LAST_ACK也慢。这时你要看unacked队列是不是一直有数据以及retransmission的nrtx有没有到上限。建议在所有涉及断开连接的回调里都养成一个习惯收到ERR_OK或FIN后先判断是否还有数据要发送没有就立即调用tcp_close()有就先把数据处理完再关。另外可以定期把tcp_close后的pcb状态打印出来至少能尽早发现泄漏。4.4 写一个状态机调试助手把pcb状态打印出来LWIP没有现成的netstat命令但你可以自己写一个非常简单的状态巡览函数在空闲任务或某个cycle定时器里跑一跑。void tcp_dump_pcbs(void) { struct tcp_pcb *pcb; /* 遍历活动连接 */ for (pcb tcp_active_pcbs; pcb ! NULL; pcb pcb-next) { LWIP_DEBUGF(TCP_DEBUG, (state%d local%s:%d remote%s:%d unsent%d unacked%d\n, pcb-state, ipaddr_ntoa(pcb-local_ip), pcb-local_port, ipaddr_ntoa(pcb-remote_ip), pcb-remote_port, (pcb-unsent ! NULL) ? pcb-unsent-len : 0, (pcb-unacked ! NULL) ? pcb-unacked-len : 0)); } }把这个函数串口输出里一挂每个pcb的当前状态、本地/远端端口、有没有积压数据、有没有未确认数据全都清清楚楚。状态机上任何卡住的位置你都能直接看出问题所在比从头看网络抓包更快。调试的时候还可以在哪里增加日志LWIP自带的LWIP_DEBUGF宏里TCP_DEBUG、TCP_STATE_DEBUG这些都可以在lwipopts.h打开。建议在连接的err回调里加上打印因为err回调是连接异常关闭的唯一可信通知比如超时重传超过阈值时协议栈会回调ERR_ABRT通过err回调你能第一时间知道连接已被协议栈强制回收而不是继续干等。4.5 一个容易遗漏的细节poll回调与状态确认LWIP还提供了tcp_poll回调机制周期性触发。很多开发者没用过它但在状态机调试里它很管用。当你怀疑某个连接因为应用层不响应而卡死时可以在poll回调里检查pcb-state如果连续N次都是同一个状态且不符合预期就主动调用tcp_abort()强制关闭避免连接资源被占死。我记得有次调试上位机软件崩溃后没有正常关闭socket导致下位机的CLOSE_WAIT持续了半小时。后来就是在poll回调里加了状态判断超过一定时间就abort掉问题立刻缓解。这种策略在生产环境尤其重要设备长期运行绝对不能允许半开连接无限堆积。写在最后的小习惯调试LWIP状态机一年多我最大的体会是状态机是结果不是原因。连接卡死只是表象真正的问题往往在网络参数、应用回调、内存管理或者对端设备上。你越能把状态机里的每一个状态和pcb字段对应到具体调试信息上定位速度就越快。最后再分享一个我自己的收尾动作正式版本里保留一个轻量状态打印函数用串口命令或者调试寄存器触发。现场设备出了网络问题我不用复现场景直接调这个状态打印出来几分钟就能判断是协议栈性能问题还是应用层泄漏。这个习惯帮我省了多次出差排查强烈推荐做嵌入式网络的人都加上。