资讯动态

STM32 LwIP TCP Client长数据发送完整指南:从ERR_MEM到稳定传输

发布时间:2026/10/5 12:05:46 来源:尧图企业网站定制
用STM32跑LwIPTCP Client发送短数据毫无问题一旦要发送几百字节甚至几十KB的长数据就开始各种“作妖”tcp_write返回ERR_MEM、发到一半卡死、对端收到的数据缺头少尾、速度慢得像龟爬——这些问题我在F407时代踩到H7时代几乎每个接手以太网项目的同事都会遇到一遍。这篇文章就基于实际调试经验把LwIP TCP Client长数据发送的完整方案捋清楚包括用最新版STM32CubeMX配置ETH和LwIP时哪些参数必须改、发送API到底该调哪个、应用层怎么配合以及我调试中遇到的典型坑和排查方法。不管你是刚用CubeMX生成第一个以太网工程还是已经在产品上被长数据折磨了两周这篇都值得看完。1. 先把“长数据发送难”这件事拆开看1.1 你遇到的到底是哪类问题长数据发送的“困扰”其实有很多种我在不同项目里见到过四种典型现象。第一种应用层调用tcp_write后直接返回ERR_MEM数据根本没进协议栈而且不管你重试多少遍都是这个错误。第二种数据写入成功了但发送到一半就停下来对端等了半天只收到前两包数据之后再也没有后续。第三种数据看起来全部发完了但对端接收后校验不过原因是某些分片丢失或重排后没有正确处理。第四种最隐蔽——发送本身不报错但实测吞吐量只有几KB/s10KB数据要传两秒这在固件升级、日志回传这种场景里完全没法用。这些现象表面上是不同问题根子却都指向同一个方向LwIP的发送缓冲机制和管理方式。很多人遇到ERR_MEM第一反应是去调内存池调完之后问题还在原因就是没搞懂LwIP发送长数据时数据究竟怎么流动的。1.2 一条数据从应用到网线的完整旅程要理解LwIP长数据发送的瓶颈先得知道一条数据从应用层到网线那端的TCP栈里发生了什么。以底层API为例应用层调用tcp_write()时协议栈默认会把应用缓冲区里的数据拷贝到内核发送队列中TCP_WRITE_FLAG_COPY然后调用tcp_output()尝试把这些数据按MSS切片封装成TCP段发出去。以太网MTU是1500字节减掉IP头20字节和TCP头20字节MSS一般就是1460字节。也就是说一个10KB的缓冲区至少要被拆成7个TCP段依次发出去。关键问题在于协议栈不可能一次性把10KB全部塞进发送队列因为要受两个条件限制一是内核内存池MEM_SIZE / PBUF_POOL能容纳多少数据二是每个TCP连接的发送缓冲区上限TCP_SND_BUF。只要当前未确认的数据量达到TCP_SND_BUF的上限后续tcp_write就只会返回ERR_MEM哪怕你的单片机和内核剩余内存还很充足。还有一个容易被忽略的点tcp_write成功不等于数据已经交给网卡更不等于对端收到了。TCP有ACK机制发送队列里的数据必须等到对端确认之后协议栈才会释放对应的缓冲。如果对端处理得慢、接收窗口不断缩小即使你写入了大批数据内核缓冲也会被占满最终表现为发送停滞。所以长数据发送能不能顺畅本质上取决于“水池容积”和“排水速度”的配合水池就是TCP_SND_BUF排水速度就是对端的接收窗口和ACK频率。1.3 为什么短数据从来没事短数据比如几十字节的控制指令一次tcp_write就塞进去了整个内核覆盖毫无压力所以大多数入门教程里的TCP Client示例都跑得好好的。但这也给了很多人一种错觉LwIP的TCP Client很好用。直到某天固件升级要传128KB文件或者传感器要连续回传几千个浮点突然就崩了。说到底短数据考验的是协议栈能不能收发长数据考验的是整个系统——内核内存配置、发送缓冲上限、应用层发送节奏、对端接收能力——能不能协同配合。2. 环境准备用最新CubeMX把ETH和LwIP配置到位2.1 ETH外设与PHY的配置细节不管是STM32F4还是H7用STM32CubeMX生成ETH和LwIP工程都是最常见的起步方式。最近几版CubeMX6.9之后的版本界面虽然有些小调整但核心配置项还是一样。打开ETH外设后首先选择RMII接口不要选MII。MII需要16根数据线一般开发板几乎都用RMII好处是引脚少缺点是时钟要求更严格——RMII必须要有50MHz参考时钟。这个50MHz参考时钟是第一个大坑。常见两种接法第一种PHY板载25MHz晶振MCU通过MCO引脚输出50MHz给PHY这种在CubeMX时钟树里要把RCC的MCO或ETH时钟源选对第二种PHY自己带50MHz有源晶振MCU这边直接用外部时钟。很多板子为了省事用的是第一种但如果你在CubeMX里没把时钟树配置成“Ethernet RMII时钟由MCO1提供”PHY的TX/RX就会完全不通。我调试时见过有人拿逻辑分析仪抓不到数据最后发现是时钟树里差了一步。还有一个很隐蔽的坑是PHY地址和PHY芯片型号。CubeMX的ETH配置里会让你填PHY Address这个不是随便写的要看板载PHY芯片的地址引脚。比如LAN8720的地址通常是0DP83848的地址通常是0x1F具体以原理图为准。填错了PHY寄存器读写就全错LwIP初始化时会卡在读PHY ID或者Link状态上。新版CubeMX在LwIP配置里还可以选择PHY Device主要是影响Link状态检测的寄存器读取方式务必选成和自己板子一致的那颗芯片。2.2 LwIP中间件参数在CubeMX里怎么填工程里使能“Middleware and Software Packs”里的LwIP后界面会多出一堆参数。很多人直接保持默认生成代码就开始跑长数据发送大概率翻车。在NO_SYS模式下裸机运行CubeMX会把lwipopts.h中的一些关键宏生成到代码里你可以直接在配置界面改也可以生成后再手动改头文件。我建议第一步先把运行模式选对是否使用RTOS。如果只是裸机NO_SYS保持1如果用了FreeRTOSCubeMX会自动生成带OS支持的方式但底层发送逻辑要更注意临界区保护。IP地址建议先用静态IP比如192.168.1.100开发调试阶段别开DHCP不然每次上电IP可能变排查网络问题时你会怀疑人生。接下来是长数据发送最核心的几个参数MEM_SIZE协议栈内存堆大小、PBUF_POOL_SIZEpbuf池数目、TCP_SND_BUFTCP发送缓冲上限、TCP_WNDTCP接收窗口、TCP_MSSTCP最大分段。我见过太多工程默认值就是TCP_SND_BUF 4 * TCP_MSS相当于最多排队5840字节你一次传10KB必然卡死。参数含义和推荐值我放在下一节说这里先记住一个原则如果要稳定传长数据这几个值必须按应用层最大单次发送量的两倍以上来预留。3. 关键参数与发送API的正确姿势3.1 lwipopts.h里那些必须调大的宏CubeMX生成的LwIP配置最终会落在lwipopts.h或lwip.c对应的宏定义中。手动修改时我一般先看这几个值宏定义默认典型值长数据场景推荐值作用说明MEM_SIZE约12KB40KB~64KB协议栈堆内存tcp_write分配内存主要靠它PBUF_POOL_SIZE6~1016~32pbuf池数量影响收包和部分发送路径TCP_MSS14601460一般保持不要为了调小去改TCP_SND_BUF4 * TCP_MSS16 * TCP_MSS 或更大单连接发送缓冲上限直接决定能排队多少数据TCP_WND4 * TCP_MSS16 * TCP_MSS接收窗口如果对端接收能力强就开大TCP_QUEUE_OOSEQ1保持1乱序队列关闭能省RAM但丢包重传时影响大MEM_SIZE和TCP_SND_BUF是长数据发送的“水位线”。举个例子如果你要一次发送10KB数据而TCP_SND_BUF只有5.8KB那么即使你把全部数据都调用了tcp_write第二次调用就会因为发送缓冲饱和返回ERR_MEM。如果应用层把这个当作错误并丢掉剩余数据对端就只能收到一半。我习惯把TCP_SND_BUF设成“应用层单次最大消息长度”的两倍MEM_SIZE则保证至少能同时容纳几个连接的发送队列加接收队列。在STM32F407这种RAM有192KB的平台上这点开销完全承受得起如果是F103这类RAM紧张的平台就得在功能和内存之间另做平衡。TCP_MSS虽然一般不调但要注意它要和网卡的MTU匹配。以太网MTU默认1500MSS就是1460。如果不小心把TCP_MSS改成了1200那只是每包少装点数据效率下降一点问题不大但如果改成超过1460的值反而会让IP分片增多对端重组压力变大这是很多“数据对不上”问题的隐藏来源。3.2 用对APItcp_write、tcp_output和tcp_sent的配合LwIP发送数据有两条路一条是Netconn API也就是netconn_write/lwip_send适合RTOS环境内部会帮你处理等待另一条是Raw API也就是tcp_write/tcp_output/tcp_sent回调适合裸机或者对收发时机有精细控制需求的场景。长数据发送我强烈建议用Raw API自己掌控节奏。裸机NO_SYS模式下用lwip_send一个大缓冲区它内部虽然也会循环写但一遇到ERR_MEM就返回应用层很容易丢数据。Raw API的核心模式是“写一点、发一点、等确认、再写一点”。tcp_write只是把数据拷进内核发送队列tcp_output才是真正把排队数据封装成包发出去。而tcp_sent回调则是在收到对端ACK后释放了发送缓冲时被调用。正确的长数据发送流程应该是应用层先把待发数据放进自己的缓冲区然后循环调用tcp_write写入尽可能多的数据当写入量达到tcp_sndbuf()的上限时停下来等tcp_sent回调通知“缓冲可以继续容纳新数据了”再接着写下一批。很多新手忽略的是tcp_output的调用时机。我见过有人只在tcp_write之后调一次tcp_output结果遇到Nagle算法合并小包导致数据发送延迟很大。长数据场景下每次tcp_write成功后就主动调用tcp_output能让协议栈尽快把MSS大小的数据段推出去整体吞吐会明显改善。还有一点窗口很小的情况下一次tcp_write返回的len可能小于你传入的len吗不会。tcp_write要么成功写入你指定的len要么失败返回错误但tcp_try_write是“能写多少写多少”返回值可能小于请求长度。理解了这两者的区别你就知道为什么在持续性大流量发送时有人更爱用tcp_try_write循环去“蹭剩余缓冲”而不是用tcp_write去赌一把。两种方式都能用但逻辑要写对不能假设一次调用就把所有数据写完。3.3 发送状态机别在回调函数里硬怼大循环长数据发送还容易踩一个雷在tcp_sent回调里直接写一个while循环拼命调用tcp_write直到所有数据发完。如果数据量小这很爽但如果数据量大回调函数占用时间过长会导致其他协议栈事件得不到处理轻则丢包重传重则WDT超时复位。我在实际项目中推荐用一个“应用层缓冲区 主循环发送”的状态机结构。具体思路是这样应用层不管什么时候需要发数据先把数据拷贝到自己维护的一块缓冲里标记为“待发送”。主循环或者专门的发送函数里查看当前tcp_sndbuf()还剩多少空间能写多少就写多少写不进去了就退出。tcp_sent回调只做一件事置一个标志位让主循环知道“缓冲释放了可以继续发送”。这样整个发送过程就不会阻塞协议栈也不会出现重入问题。#define APP_TX_BUF_LEN 65536 #define MIN_TX_CHUNK 1460 static struct tcp_pcb *g_tcp_pcb; static uint8_t g_tx_buf[APP_TX_BUF_LEN]; static uint32_t g_tx_len; static uint32_t g_tx_off; static volatile uint8_t g_tx_busy; static volatile uint8_t g_tx_sent_flag; static void app_tcp_send_next(void) { if (g_tcp_pcb NULL || g_tx_off g_tx_len) { return; } while (g_tx_off g_tx_len) { u16_t sndbuf tcp_sndbuf(g_tcp_pcb); if (sndbuf 0) { break; } u16_t len sndbuf; if (len (g_tx_len - g_tx_off)) { len (u16_t)(g_tx_len - g_tx_off); } err_t err tcp_write(g_tcp_pcb, g_tx_buf[g_tx_off], len, TCP_WRITE_FLAG_COPY); if (err ! ERR_OK) { break; } g_tx_off len; tcp_output(g_tcp_pcb); } if (g_tx_off g_tx_len) { g_tx_busy 0; g_tx_off 0; g_tx_len 0; } } static err_t app_tcp_sent_cb(void *arg, struct tcp_pcb *tpcb, u16_t len) { g_tx_sent_flag 1; return ERR_OK; } void app_tcp_send_data(const uint8_t *data, uint32_t len) { if (len APP_TX_BUF_LEN) { len APP_TX_BUF_LEN; } while (g_tx_busy) { /* 上一次发送还没完成这里可以加超时或直接丢弃策略 */ return; } memcpy(g_tx_buf, data, len); g_tx_len len; g_tx_off 0; g_tx_busy 1; app_tcp_send_next(); } /* 在main循环里调用 */ void app_tcp_poll(void) { if (g_tx_sent_flag) { g_tx_sent_flag 0; app_tcp_send_next(); } }这段代码看起来简单但已经把长数据发送最重要的逻辑都包含了不假设一次能发完、看tcp_sndbuf决定一次写多少、tcp_sent回调只置标志、主循环继续接力。真要在产品里用还可以加超时重传、进度上报、分块校验之类的外围逻辑但核心框架就是这样一个“边发边等”的循环。3.4 内存模型与零拷贝的取舍LwIP里tcp_write的第四个参数有TCP_WRITE_FLAG_COPY表示协议栈会拷贝数据到内核pbuf。如果不加这个标志数据不会被拷贝协议栈直接引用你的应用缓冲地址这要求你的缓冲区必须保持有效且不被修改直到对端ACK。这听起来省了一次拷贝好像性能更好但在长数据发送场景里风险很大。因为你不知道内核什么时候才能真正发完这段数据如果应用层复用了这个缓冲区数据就乱了。所以在长数据发送中我几乎都用TCP_WRITE_FLAG_COPY牺牲一次拷贝换来稳定和简单。拷贝的代价是内存和CPU。一块64KB的数据每14.6KB就要申请一个新的pbuf申请和释放都会访问MEM_SIZE堆。如果MEM_SIZE不够大tcp_write就会返回ERR_MEM。这也是为什么MEM_SIZE和TCP_SND_BUF要同步调的深层原因——一个决定“能不能申请到内存”一个决定“最多能队列多少数据”。两者是不同维度的瓶颈。4. 实操过程一个能跑通的TCP Client长数据发送流程4.1 TCP连接的建立与回调注册长数据发送的前提是TCP连接先建立起来。用Raw API连接服务器的流程并不复杂创建tcp_new()拿到PCB设置远程IP和端口调用tcp_connect()发起连接连接成功后会触发tcp_connected回调。在连接建立的回调里要注册tcp_sent、tcp_recv、tcp_err这些后续事件的处理函数。很多人只关心发送不注册tcp_err结果连接断了都不知道还在死命tcp_write最后只能靠系统超时才反应过来。static err_t app_tcp_connected_cb(void *arg, struct tcp_pcb *tpcb, err_t err) { if (err ERR_OK) { tcp_sent(tpcb, app_tcp_sent_cb); tcp_recv(tpcb, app_tcp_recv_cb); tcp_err(tpcb, app_tcp_err_cb); } else { /* 连接失败处理 */ } return ERR_OK; } void app_tcp_client_start(void) { ip_addr_t server_ip; IP4_ADDR(server_ip, 192, 168, 1, 10); g_tcp_pcb tcp_new(); if (g_tcp_pcb ! NULL) { tcp_arg(g_tcp_pcb, NULL); tcp_connect(g_tcp_pcb, server_ip, 8080, app_tcp_connected_cb); } }连接过程中还有一个小细节服务器端没监听、网络不通时tcp_connect会导致PCB进入错误状态。此时如果没有tcp_err回调把指针置空并释放PCB后面再调用tcp_write就可能访问非法内存。所以在app_tcp_err_cb里要把g_tcp_pcb置为NULL防止发送逻辑拿到野指针。4.2 发送流程的完整动作这里把前面状态机的逻辑串成一个完整流程。假设你有一个128KB的固件升级文件要发给服务器应用层可能不会一次性把128KB全塞进g_tx_buf而是分块读取。每一块比如4KB调用app_tcp_send_data()后函数把块拷贝进发送缓冲区然后立刻尝试发送。主循环里轮询g_tx_sent_flag保证后续块能继续发。块和块之间可以不做间隔因为TCP流式协议本身会把它们当成连续字节流对端只会按顺序收到数据。但要注意如果文件有校验需求比如每个块带一个CRC那你不能简单地把分块拼接得毫无边界。对接时要约定好帧格式长度字段 校验字段 数据体。TCP是流式的应用层必须自己解决“封包”问题。很多做串口转以太网的人把这个坑踩穿——串口有帧的概念TCP没有你不封帧对端就不知道自己收到了多少个完整包。4.3 实测结果与参数调整前后的对比我在一块STM32F407 LAN8720的板子上测过打开上面的方案发送100KB随机数据到PC端TCP Server。未调整参数时MEM_SIZE是12KBTCP_SND_BUF是5840字节发送5KB数据就会出现ERR_MEM。把参数调整为MEM_SIZE 64KBTCP_SND_BUF 64KBPBUF_POOL_SIZE 24后100KB数据从开始写入缓冲区到对端完整校验通过耗时大约110ms平均吞吐量在6~8MB/s左右对普通嵌入式应用完全够用。如果还想压榨速度可以启用MCO DMA加速、调高CPU主频到168MHz以上都能看到进一步改善。4.4 进阶如果数据量超过内存怎么办有些应用场景一次要传的数据超过几MB单片机的RAM根本放不下完整数据。这时就不能把整个文件先拷贝到应用缓冲区了而要走“边读边发”的方式从Flash、SD卡或者外部Flash读一小块发一小块发完确认后再读下一块。发送状态机可以改成应用层收到“开始发送文件”的命令打开文件句柄设置剩余长度。主循环里检查tcp_sndbuf()有空间就从文件读取不超过剩余长度的一块比如2KB调用tcp_write。写完立即tcp_output更新剩余长度。文件发完后发送应用层约定好的结束帧比如4字节“DONE”。这种方式避免了大缓冲区的需求但引入了文件系统读取速度、Flash擦写时间等新的影响因素。读取慢的话发送节奏会被拉低但对端不会出错只是速度慢点。我在一个产品上就用这种方式传几十MB的语音记录数据一边从SD卡读一边发稳定跑了很久。核心原则仍然是发送端永远不要盲目地“一次把能发的都发完”而是“一边看一边发”。5. 常见问题与排查技巧实录5.1 tcp_write返回ERR_MEM怎么定位是哪里的内存不够这个问题我被问得最多。先说结论ERR_MEM可能来自MEM_SIZE堆分配失败也可能来自TCP_SND_BUF达到上限还可能在极端情况下来自pbuf池耗尽。最简单的区分办法是看错误出现的时间点如果第一包tcp_write就失败基本是MEM_SIZE太小如果连续写了几包之后才失败往往是TCP_SND_BUF满了。解决方向也不一样前者加大MEM_SIZE后者加大TCP_SND_BUF——但记住TCP_SND_BUF变大最终也会消耗MEM_SIZE因为数据包还是要从堆里申请内存。调试时可以临时加一个统计变量每申请一次pbuf就记录打印出协议栈内存使用峰值这样调参就不是拍脑袋了。CubeMX的调试信息如果打开LWIP_DEBUG可以直接看到内存不足时是哪个函数返回的错误这个开关虽然刷屏但排查时很好用。5.2 发送到一半停止对端一直收不到后续数据遇到这种情况先抓一下网卡有没有持续发包。如果发送循环里tcp_write一直返回ERR_MEM而且一直没有新的ACK回来说明对端可能没在读数据接收窗口被占满。这时候要检查对端应用看它是否真的把TCP数据读走了。很多TCP Server测试工具默认缓冲区很小或者测试脚本没有及时读取就会造成窗口满。另一点是检查tcp_sent回调有没有被正确注册如果没注册你就永远等不到“继续发送”的时机发送状态机直接停摆。还有一种很隐蔽的情况你用了TCP_WRITE_FLAG_COPY但应用层在拷贝之后又修改了源缓冲区。由于LwIP的tcp_write在非COPY模式或某些路径下可能引用原缓冲区一旦数据被修改对端CRC就会失败。排查方法是对比发送端缓存里的数据和接收端收到的数据看是不是第N字节开始出现差异。如果是检查所有调用tcp_write的地方确认都用了TCP_WRITE_FLAG_COPY或者保证缓冲区在ACK之前不被修改。5.3 调大参数后内存不够用怎么办调大MEM_SIZE和TCP_SND_BUF最直接的后遗症就是RAM吃紧。STM32F407这种还好说如果是RAM只有64KB的芯片64KB的发送缓冲几乎不可能。这时候就要折中分块发送每次只提交4KB到协议栈而不是急着把整个文件都塞进去。换句话说用更小的事务粒度让缓冲区和应用缓冲区都保持在可控范围。千万不能想当然地认为“内存不够就把数据拆小”有时候拆小反而更依赖发送状态机的正确性一旦某次tcp_write失败进度没保存又得从头开始。5.4 CubeMX升级后生成代码有差异新版CubeMX在ETH和LwIP整合上做过几次变化主要体现在中断处理和主循环调用方式上。有的版本要求用户在while(1)里调用MX_LWIP_Process()有的版本通过网卡中断加信号量触以太网输入。如果你从旧工程升级代码直接对不上的情况很常见。这时候不要急着改代码先看生成的ethernetif.c和lwip.c里输入函数是怎么被调用的。裸机模式下我一般把ethernetif_input放到定时中断或主循环高频调用保证收包链路不堵。还有一个容易忽视的点中断优先级设置不合理导致Rx中断饿死或频繁打断tcp_write流程也会让长数据发送出现随机性卡顿这个可以放在排查清单里最后查。6. 最后说点不写在文档里的体会长数据发送的坑归根到底不是LwIP本身的bug而是“协议栈的内存模型”和“应用层的发送习惯”不匹配。大多数人带着串口或者裸buffer编程的习惯来写TCP总觉得“我把一包数据交给协议栈就完事了”但TCP是一个流式、有确认、有窗口的协议你不能只管“送”还必须管“排队、等待、重试”。把发送流程改成一个由tcp_sndbuf和tcp_sent回调驱动的状态机之后几乎所有长数据问题都迎刃而解。我个人在实际项目里的习惯是开工第一天就按应用层最大消息的两倍去预留TCP_SND_BUF和MEM_SIZE不要等联调时再一个个调。宁可让协议栈多吃一点RAM也不要让“开始时能跑、数据一长就崩”这种问题留到发布前。另外调试太久找不到问题的时候我通常会打印三样东西当前tcp_sndbuf()剩余值、tcp_unacked()未确认字节数、MEM_STATS里的内存峰值。把这几个数贴出来问题往往一眼就能看出来。希望这篇能帮你把长数据发送这条路走顺少踩几个我已经替你踩过的坑。

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

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

免费获取报价 →
↑