资讯动态

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

发布时间:2026/10/17 10:11:56 来源:尧图企业网站定制
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负责从网卡硬件读取一帧数据把它封装成pbufethernetif_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_COPYlwip 会把数据拷贝一份存到自己的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在调用回调前没有增加引用计数回调里释放了pbuftcp_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_fasttmrtcp_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 稳定运行了。我在实际项目里踩过的坑远不止这些但核心思路是一样的不要把它当黑盒要把它当一套需要理解和调校的工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑