我从一个真实场景说起一块STM32板子通过网口连服务器平时上传传感器数据每包一百多字节一切正常。直到某天需要把Flash里缓存的历史数据一次性补传上去我随手拼了个4KB的buffer调用lwip_send往socket里一扔结果对端收不全甚至整包丢失。排查了很久查过网线、查过服务器、查过抓包最后问题回到LwIP的发送机制上。这个经历让我花了整整两天去读源码、翻文档最后重新设计了发送逻辑才彻底解决。这篇博文就围绕“LwIP TCP Client长数据发送”这件事展开把我踩过的坑、看过的源码、实测过的参数都写清楚。适合正在用STM32CubeMX LwIP做以太网项目、尤其是需要传输大块数据的开发者参考也适合刚接触LwIP、对发送缓冲区机制一知半解的初学者。1. 先把问题想清楚LwIP发送链路到底卡在哪很多人遇到长数据发送失败第一反应是“缓冲区太小”然后跑去把TCP_SND_BUF改大改完发现偶尔好转偶尔还是出问题。这是因为没搞明白LwIP发送链路的完整逻辑。1.1 三个关键参数不是独立存在是联动关系LwIP里TCP发送相关的参数有三个很多人只认识其中一个TCP_MSS单个TCP报文段能携带的最大应用数据长度。以太网环境下默认1460字节这个值跟网卡的MTU1500直接相关刨掉IP头20字节和TCP头20字节就是1460。TCP_SND_BUF发送缓冲区总大小内核为每个TCP连接分配的发送队列上限。TCP_WND接收窗口大小影响对端能连续发多少数据而不用等待ACK。这三个参数的联动关系是TCP_SND_BUF必须大于TCP_MSS否则一次只能发一个段就阻塞而有效的在途数据量理论上受限于TCP_WND和TCP_SND_BUF的较小值。CubeMX默认配置下TCP_SND_BUF往往是2 * TCP_MSS约2920字节。这意味着你一次性往里塞4KB数据已经超出缓冲区了。对于非阻塞socketlwip_send会返回一个小于请求长度的值或者直接返回错误。对于阻塞socket应用层会一直等但协议栈内部因为缓冲区不足根本收不下你的数据表现出来就是“卡死”。1.2 应用层看到的send和协议栈实际发送不是一回事很多人的直觉是我调一次send这包数据就完整地发出去了。但在LwIP里lwip_send做的事情是把用户数据拷贝到内核的pbuf队列里真正的网络发送由tcpip_thread在后台完成还会依赖对端ACK窗口的推进。所以长数据发送要解决的问题本质上不是“怎么一次发更多”而是“怎么把应用层的大块数据分批次塞进发送缓冲区并且保证顺序”。我后来在代码里打印过lwip_send的返回值4KB的buffer在默认配置下经常返回1460、1460、剩余部分偶尔一次全返回。这个返回值波动本身就说明问题你必须按返回值处理剩余数据而不能假设一次调用全部完成。1.3 常见的错误做法和它们的后果我在社区里见过几类典型操作也在自己项目里踩过一部分把TCP_SND_BUF调到 64KB觉得“缓冲区大就稳妥”。结果RAM占用暴涨在一个内存只有几十KB的单片机上直接导致分配失败系统跑飞。用tcp_write带TCP_WRITE_FLAG_COPY标志一次性写入几十KB数据。这种操作在数据超过发送缓冲区时函数直接返回ERR_MEM而很多人没检查返回值数据就这么丢了。调用tcp_output强制发送不理解这个函数只是“提醒协议栈赶紧把队列里的数据发出去”如果窗口满了调用再多也没用。这些问题的根源都是把LwIP当成了“文件系统一样可以随便写”的接口。实际上它是一个有窗口、有流量控制、有缓冲区上限的协议栈应用层必须按照它的节奏来配合。2. 最小环境搭建CubeMX里配置ETH和LwIP的实测步骤在深入改造发送逻辑之前先把环境跑通。我用的是STM32F407 LAN8720A PHY这也是目前最常见的一套组合。CubeMX版本6.x以后ETH和LwIP的配置界面变化不小有些细节不留意会卡很久。2.1 PHY地址和时钟配置最容易被忽略的两个点LAN8720A的PHY地址是0如果原理图上把地址引脚拉高了那就是1。CubeMX里生成代码后会有一个eth_phy相关的宏定义版本不同位置不同新版一般在ethernetif.c里直接写死。我就吃过这个亏板子明明用的LAN8720代码里却是DP83848的地址结果link状态始终不对。时钟方面STM32F407的MAC需要50MHz的RX时钟这个时钟来自PHY的CLK_OUT。LAN8720需要外部50MHz晶振或者由MCU提供50MHz时钟。CubeMX配置时如果选错了时钟源PHY能初始化但link死活起不来。查这个问题的通用手段是用示波器量PHY的CLK_OUT引脚没有50MHz输出基本就是时钟配置问题。/* 检查PHY链接状态的代码片段适用于LAN8720 */ uint8_t ETH_Link_Status(void) { uint32_t reg; ETH_ReadPHYRegister(ETH_PHY_ADDR, PHY_BSR, reg); return (reg PHY_LINKED_STATUS) ? 1 : 0; }2.2 LwIP配置项里这几个必须手工调CubeMX生成的LwIP配置有一些默认值偏保守。我的建议是重点关注这几个MEM_SIZE堆大小默认可能在几十KB如果后续要用tcp_write的TCP_WRITE_FLAG_COPY模式这个值要适当调大否则ERR_MEM频繁出现。PBUF_POOL_SIZE这个影响接收路径接收大流量时太小会丢包。TCP_SND_BUF调到4 * TCP_MSS到8 * TCP_MSS之间太大会浪费RAM。TCP_WND建议至少4 * TCP_MSS和发送缓冲区匹配否则对端发过来的数据会被窗口限制。有一点需要注意CubeMX生成的lwipopts.h里很多选项是条件编译的有些配置在GUI界面改了实际代码里可能没生效因为有#ifndef保护。我习惯生成代码后直接去lwipopts.h里核对一遍这也是排查“改了配置但没效果”的第一步。2.3 用CubeMX生成的代码搭建一个能跑的TCP ClientCubeMX会生成lwip_init()调用但不会帮你创建socket。一个最小可用的TCP Client逻辑包括以下几步等待网卡link upnetif_is_up()返回真。调用dhcp_start()获取IP地址或者用netif_set_addr()设置静态IP。创建socketsocket(AF_INET, SOCK_STREAM, 0)。连接服务器connect()返回0表示成功。进入发送/接收循环。这里有一个新手容易踩的坑CubeMX默认开启了DHCP但如果你的开发板没有接路由器而是直连电脑DHCP永远获取不到地址程序就卡在等待IP上。调试阶段我通常直接设置静态IP省掉这类干扰。struct netif *g_netif; void tcp_client_init(void) { ip_addr_t ip, mask, gw; IP_ADDR4(ip, 192, 168, 1, 50); IP_ADDR4(mask, 255, 255, 255, 0); IP_ADDR4(gw, 192, 168, 1, 1); netif_set_addr(g_netif, ip, mask, gw); netif_set_up(g_netif); }3. 长数据发送的核心改造分块发送框架与窗口管理环境跑通以后再回头处理长数据发送就清晰多了。核心思路只有一条不要试图一次把所有数据交给协议栈而是把数据切成多个不超过发送缓冲区容量的分块按窗口推进节奏持续发送。3.1 分块发送比调大缓冲区更可靠的三个原因首先缓冲区再大也有上限。RAM是单片机上的稀缺资源为了偶尔一次的4KB补传常驻一个64KB缓冲区性价比太低。分块发送只需要很小的常驻内存。其次大缓冲区不解决窗口问题。即使发送缓冲区有64KB对端接收窗口可能只有8KB协议栈还是会因为窗口不足而暂停发送你的应用层如果还继续往里写就会阻塞或者报错。最后分块发送天然有更好的错误处理粒度。一包16KB的数据如果中间断网重传时要重新传全量分成16个1KB的块各自有独立的确认机制断点续传的逻辑清晰很多。3.2 基于tcp_write的分块发送框架用raw API还是socket API在这个问题上的解法不太一样。socket API相对简单适合业务逻辑不复杂的场景raw API更灵活能精确控制每个数据块的发送节奏。我这里先讲基于raw API的方案这也是我最终采用的方式。核心代码逻辑struct tcp_client_state { struct tcp_pcb *pcb; const uint8_t *send_data; uint16_t send_len; uint16_t send_offset; uint8_t sending; }; err_t tcp_client_send_data(struct tcp_client_state *state) { err_t err; uint16_t available; uint16_t chunk; if (state-sending) { return ERR_INPROGRESS; } while (state-send_offset state-send_len) { available tcp_sndbuf(state-pcb); if (available 0) { /* 发送缓冲区满等sent回调再继续 */ break; } chunk state-send_len - state-send_offset; if (chunk available) { chunk available; } if (chunk TCP_MSS) { chunk TCP_MSS; } err tcp_write(state-pcb, state-send_data state-send_offset, chunk, TCP_WRITE_FLAG_COPY); if (err ! ERR_OK) { return err; } state-send_offset chunk; tcp_output(state-pcb); } if (state-send_offset state-send_len) { return ERR_OK; /* 全部写入协议栈 */ } return ERR_INPROGRESS; }这个代码的关键点有两个每次写入大小取发送缓冲区剩余容量和TCP_MSS的较小值保证不超MSS也利用满窗口。写入后立即调用tcp_output推动协议栈尽快把数据发出。但这里有个问题如果缓冲区写满了循环退出时还剩数据没写完怎么办这就要靠sent回调接力。3.3 用sent回调驱动剩余数据形成可持续发送链路sent回调是LwIP通知应用层“内核已经确认收到部分数据缓冲区腾出来了”的机制。我在这里维护一个状态标志只要发送尚未完成sent回调就继续调用发送函数直到所有数据全部进入协议栈。err_t tcp_client_sent(void *arg, struct tcp_pcb *pcb, u16_t len) { struct tcp_client_state *state (struct tcp_client_state *)arg; if (state-send_offset state-send_len) { tcp_client_send_data(state); } else { state-sending 0; } return ERR_OK; }连接建立后首次发送的入口err_t tcp_client_connected(void *arg, struct tcp_pcb *pcb, err_t err) { struct tcp_client_state *state (struct tcp_client_state *)arg; if (err ERR_OK) { state-sending 1; tcp_client_send_data(state); } return ERR_OK; }这样整个发送流程就变成应用层准备好数据块调用发送入口协议栈不断通过sent回调拉取剩余数据。应用层无需等待、无需轮询也不会因为缓冲区不足而卡死。3.4 socket API场景下的对照实现如果你不想碰raw API用socket API也能做到类似效果核心逻辑是循环send直到数据全部发送完成每次send之间根据返回值判断是否需要延时。int send_all(int sock, const uint8_t *buf, int len) { int sent 0; int ret; while (sent len) { ret send(sock, buf sent, len - sent, 0); if (ret 0) { return -1; } sent ret; } return sent; }这个实现的隐患在于当send返回一个小于请求的值时如果你立刻再次调用协议栈可能还没腾出缓冲区调用会阻塞。实际项目中我一般加个osDelay(1)或者用select做超时避免在阻塞模式下卡死。对比一下两种APIraw API的sent回调是事件驱动CPU占用低实时性好适合做复杂协议socket API代码简单直观但控制粒度粗长数据发送时容易在阻塞与超时之间反复横跳。如果项目对发送性能有要求我建议直接上raw API初期学习成本多一点后面省心很多。4. 实测对比调参前后三组数据的发送表现理论分析再充分也要用实测数据说话。我专门搭了一个测试场景STM32F407 通过LAN8720连接路由器PC上跑一个Python TCP服务器接收数据并记录接收时长和顺序。4.1 测试方法板子主动连接PC服务器建立TCP连接。分别发送4KB、16KB、64KB三组数据。每组数据使用两种方式一次lwip_send整包发送改造后的分块发送。服务器端记录接收完成时间、数据完整性客户端记录发送耗时。TCP_SND_BUF设为4 * TCP_MSS4 × 1460 ≈ 5.7KBTCP_WND同步设为4 * TCP_MSS。4.2 测试结果数据量发送方式发送端表现接收端表现4KB一次send整包返回长度正常耗时约8ms数据完整4KB分块发送2-3个块耗时约7ms数据完整16KB一次send整包send返回错误或只发出部分数据收到不完整数据连接被重置16KB分块发送12-14个块耗时约28ms数据完整顺序正确64KB一次send整包应用层直接卡死看门狗复位无数据或部分数据64KB分块发送50个块左右耗时约110ms数据完整顺序正确整包发送16KB时因为缓冲区只有5.7KBtcp_write在写入第三个块时就返回ERR_MEM但我没有检查返回值数据直接丢弃对端收到的序号不连续触发协议栈的乱序检测最终连接重置。分块发送还有另一个好处就是错开大流量造成的瞬时缓冲区压力。从测试耗时来看64KB数据约110ms平均速率接近580KB/s对于STM32F407 LwIP这个组合已经是不错的成绩。4.3 调参后的效果验证我试着把TCP_SND_BUF和TCP_WND同时提升到8 * TCP_MSS64KB数据集的总耗时降到约90ms。代价是RAM增加约12KB对于F407来说还能接受但如果是F103这种资源紧张的芯片就要谨慎了。抓包确认是排查长数据发送问题的重要手段。Wireshark里重点看几个信息TCP窗口大小是否一直在变化如果长时间接近0说明对端接收能力不足。是否有大量重传重传次数多说明网络质量差或者MSS配置有问题。是否存在乱序乱序多说明发送端节奏太快或者接收缓冲区太小。我在实测中发现LwIP在长时间高压发送时偶尔会出现一个RST包导致连接断开。进一步排查发现是PBUF_POOL_SIZE偏小接收路径上的pbuf分配失败协议栈主动断开了连接。把池子从默认的16调整到32后这个现象消失。5. 进阶讨论实时性、吞吐量与内存占用的平衡前面讲的分块发送方案能解决“发得出去”的问题但实际项目中还会遇到“发得稳不稳”“发得快不快”“会不会拖垮系统”这三类考量这里把我在工程里的取舍逻辑展开说说。5.1 发送任务放RTOS线程还是放在tcpip_thread里执行LwIP默认把所有协议栈操作放在tcpip_thread里而你的业务代码运行在另一个任务中。直接的tcp_write调用虽然线程安全但如果业务任务高频率、大流量地往协议栈灌数据tcpip_thread可能处理不过来导致整个TCP/IP协议栈响应变慢。更合理的做法是业务任务只负责把数据准备好、放入应用层队列然后通过消息邮箱或信号量通知发送任务。发送任务按前面讲的分块逻辑逐步发送每次发送完毕再等待sent回调唤醒而不是连续多次调用。我在项目里用的是FreeRTOS LwIP的组合发送任务优先级设置为中等。如果发送任务优先级太高会抢占其他业务逻辑太低则可能导致数据堆积延迟上升。实测下来略高于主业务任务、低于中断处理相关的任务效果比较理想。5.2 零拷贝思想什么时候值得用pbuf直接发送LwIP的tcp_write支持TCP_WRITE_FLAG_COPY和TCP_WRITE_FLAG_MORE等标志。带COPY标志时数据会被拷贝到内核缓冲区应用层可以立即释放自己的内存不带COPY标志时内核直接引用应用层提供的数据指针发送完成前该内存不能释放。对一些超大数据块零拷贝能省一次拷贝的时间。但要注意如果你的数据是在函数栈上的局部变量不带COPY标志会导致严重问题——函数返回后栈空间被释放协议栈还在引用这块内存数据就乱了。我的经验是小数据几百字节无脑用COPY省心安全大数据几KB以上且内存生命周期可控的可以考虑用tcp_write 不带COPY 发送完成回调里释放内存的方式。这个优化能把64KB发送的耗时再降10%左右但代码复杂度明显上升是否值得取决于你的性能瓶颈到底在哪里。5.3 内存使用评估一个简单的估算方法LwIP内存占用主要体现在三个地方全局堆MEM_SIZE、pbuf池PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE、每个TCP连接的发送缓冲TCP_SND_BUF和接收缓冲TCP_WND。估算公式总内存占用字节≈ MEM_SIZE PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE TCP_SND_BUF TCP_WND netif缓冲相关配置按上一节的调参方案TCP_SND_BUF约11.7KBTCP_WND也是11.7KBPBUF_POOL32 × 1.5KB约48KBMEM_SIZE建议至少20KB。合计接近100KB。F407有192KB RAM勉强够用如果还要跑显示缓冲、文件系统就要重新权衡了。5.4 长连接与断线重传的设计要点长数据发送项目通常都是长连接场景断线重传是个绕不开的话题。我这里的做法是应用层维护一个数据包序号每次发送的数据块带上序号和总长度服务器端根据序号判断是否缺失缺失则请求重传。协议层方面tcp_poll回调可以定期检查连接状态超时未收到ACK就主动断开重连。重连后不要盲目从头传而是根据服务器确认的序号接着传这样能大大减少重复流量。还有一个细节重连后TCP的序列号会重置应用层的包序号和你使用的TCP序列号是两回事不要搞混。我在代码里把应用层序号放在数据头里不依赖TCP序列号做业务逻辑这样就算连接断开重连也不影响数据完整性。写在最后的实操体会这个项目做完以后我对LwIP的发送机制有了完全不同的理解。以前总觉得它是“调API发数据”的黑盒现在明白了它本质上是一个有窗口、有缓冲区上限、有流量控制逻辑的有限状态机。长数据发送的困扰根源不在“数据太长”而在应用层的发送方式与协议栈的工作方式不匹配。如果有机会重新做一遍我会在一开始就做三件事按TCP_MSS把应用层数据切块、用sent回调驱动发送节奏、给每个数据块做应用层序号。这三点在执行上都不复杂但能从一开始就避开大部分长数据发送的坑。最后分享一个小技巧调试发送问题时在协议栈里临时加一个对tcp_write返回值的打印看它返回ERR_OK还是ERR_MEM的次数比例能快速判断是缓冲区不够还是代码逻辑有问题。这个手段帮我在实际项目中节省了不少排查时间比反复改动参数去试要高效得多。