简介:这是一份基于STM32H743的以太网通信TCP客户端实现工程,由STM32CubeMX初始化生成,并在IAR环境下完成编译与调试,适合正在学习H7系列高性能MCU网络应用开发的工程师与嵌入式爱好者。工程在默认生成代码基础上额外加入DCache相关处理,对内核缓存一致性进行了针对性配置,可有效规避以太网收发场景中因缓存未及时刷新导致的数据错乱等典型问题。压缩包内共1064个文件,以C源文件、头文件为主体,搭配IAR链接脚本、ICF配置、启动汇编、静态库及完整工程配置文件,整体14.67MB,目录结构清晰,便于直接打开工程对照理解。已有4239人学习下载,结合作者博客的详细步骤,读者既能快速搭建H743与PHY芯片的TCP客户端通信,也能从中掌握STM32CubeMX配合IAR进行网络项目开发的整体流程。
1. STM32H743 以太网 TCP 客户端:先解决链路,再谈连接
STM32H743 以太网 TCP 客户端实现(IAR),这几个词拆开看都不陌生,凑到一起就是嵌入式开发者最容易耗掉一周的那件事:MAC 是芯片自带的,PHY 要另外接,LwIP 要裁剪移植,最后还要用 IAR 把整个工程构建出来。我在某项目里用 H743 做一个采集终端,目标就是主动连上一台服务器,把设备状态每秒送一次。结果网线插上去之后,PHY 中断脚没信号、MDIO 读 ID 全是 FF、自以为写好的 tcp_connect 回调一直停在 SYN 状态。最后问题不在协议栈,而在 RMII 的 REF_CLK 上。
这篇笔记不会只贴一遍初始化代码了事,而是把从硬件连接到 TCP 连接建立的完整路径讲清楚。适合已经把 CubeMX 代码生成过一遍、却不知道 IAR 工程里该放哪些文件的人;也适合想直接用 raw API 做 TCP 客户端、在意掉线重连的人。核心只有一句话:先确认 PHY 在物理层是通的,再去调 TCP 层,否则所有排查都是玄学。
2. 在 IAR 里把 H743 的以太网口当外设用:硬件接线与最小工程
2.1 先搞清楚三条线:RMII 数据线、MDIO/MDC 与 PHY 复位
H743 自带的是以太网 MAC 控制器,不包含 PHY,所以板上一定有一颗独立 PHY 芯片。绝大多数 H743 板卡走 RMII 模式,因为需要的引脚少。RMII 模式下信号分成三组:数据线(RXD0/RXD1、TXD0/TXD1、TX_EN、CRS_DV)、时钟线 REF_CLK,以及管理接口 MDIO/MDC。REF_CLK 必须是 50MHz,来源有两个:板载 50MHz 晶振直接给 PHY,或者由 MCU 的 MCO 引脚输出时钟。许多翻车案例的根源都在这条时钟上,它没起振,后面所有寄存器读写都是空的。
MDIO 是串行管理口,用来读写 PHY 寄存器。上电后第一步就是读 PHY ID 寄存器,确认地址对不对。常见板卡 PHY 地址通常为 0,但这不是绝对的,PHY 芯片的 PHYAD 引脚电平决定实际地址,必须在原理图上确认。MDC 时钟一般由 MAC 提供,频率不能太高,常见配置在 2.5MHz 左右,太高了 PHY 不响应。
PHY 复位引脚也要单独确认。有的板子把 PHY RST 和 MCU 复位绑在一起,上电后 PHY 跟随复位,这种最简单,但没法软件复位;有的板子用 GPIO 控制,那就需要在初始化里先拉低再拉高。调试时我习惯把复位做成单独函数,遇到 PHY 无响应先调用一次再重新读寄存器,能省很多时间。建议按下面的表逐项核对,每一行都是此前实际遇到过的故障点。
| 检查项 | 常见信号 / 引脚 | 问题表现 |
|---|---|---|
| RMII 数据线 | RXD0 RXD1 TXD0 TXD1 TX_EN CRS_DV | 错位、虚焊、复用配置少一个 |
| 时钟 | REF_CLK 50MHz | PHY ID 读 0xFFFF,寄存器无响应 |
| 管理接口 | MDC MDIO | 读回全 1,或 MDIO 波形只有毛刺 |
| PHY 地址 | PHYAD[2:0] 电位 | 地址配置错,读错 PHY 寄存器 |
| 复位 | PHY_RST 引脚 | 一直拉低,PHY 没启动 |
2.2 IAR 工程里哪些文件决定了你调得动 H743 以太网
IAR 工程和 CubeIDE 不同,文件不会自动帮你添加。一个能编译的 H743 以太网工程,至少要有这几类东西:启动文件、系统时钟初始化、以太网驱动程序、LwIP 协议栈,以及链接脚本.icf。启动文件和系统文件从芯片固件包里拿,LwIP 源码和网卡驱动ethernetif.c从协议栈移植包里拿,缺一个都会在链接阶段报错。
工程选项里几个关键设置会直接影响网络代码行为。优化等级建议先设成 Low 或 Medium 调试链路,确认 TCP 通了再切 High。H743 带双精度 FPU,IAR 的浮点选项要选硬件 FPU,否则 printf 打印浮点状态会很慢。链接脚本里堆栈大小也要留意,尤其用了 LwIP 的默认内存池,栈太小会在协议栈启动时卡死。经验值是把 Heap 至少给到 8KB,具体大小看 MEM_SIZE 配置。
IAR 编译以太网工程还有一个容易忽略的点:C99 支持。LwIP 源码大量使用指定初始化器,IAR 默认 C99 没问题,但如果工程是从老项目复制来的,C 标准还停在 C89,会出现一堆语法报错。要在项目选项里的 Language 设置里确认 C99 或 C11 打开。
2.3 最小初始化序列:时钟、GPIO、MAC 与 PHY 复位
用 HAL 库写一个最小初始化,顺序很重要,GPIO 必须在 MAC 使能之前配好,PHY 复位要放在最后并留足延时。下面这段代码是我一般在 IAR 工程里最先验证链路用的,不依赖任何图形配置工具,直接在 main 开头调用。
static void eth_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_ETH1MAC_CLK_ENABLE(); __HAL_RCC_ETH1TX_CLK_ENABLE(); __HAL_RCC_ETH1RX_CLK_ENABLE(); gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Alternate = GPIO_AF11_ETH; /* 把 RMII 所有信号脚都按这个参数初始化 */ HAL_GPIO_Init(ETH_RXD0_PORT, &gpio); HAL_GPIO_Init(ETH_RXD1_PORT, &gpio); HAL_GPIO_Init(ETH_TX_EN_PORT, &gpio); HAL_GPIO_Init(ETH_TXD0_PORT, &gpio); HAL_GPIO_Init(ETH_TXD1_PORT, &gpio); HAL_GPIO_Init(ETH_CRS_DV_PORT, &gpio); HAL_GPIO_Init(ETH_REF_CLK_PORT, &gpio); }这段代码的作用是把 RMII 信号引脚全部切到以太网复用功能。GPIO_AF11_ETH 是 H743 上以太网外设对应的复用号,如果工程是从别的芯片移植过来,这一个宏最容易出错。GPIO_SPEED_FREQ_HIGH 用于 50MHz 信号已经够,不要用 VERY_HIGH,速率过高反而引入噪声。
MAC 初始化用 HAL_ETH_Init,需要把 RMII 模式、PHY 地址、MAC 地址和 DMA 描述符地址都填对。PHY 地址必须和 2.1 节确认的一致,否则读到的寄存器全不是想要的。MAC 地址的第一个字节最低位是单播位,设置成 0x02 可以保证是本地管理地址,不会被广播包干扰。
static void eth_mac_init(void) { ETH_HandleTypeDef heth = {0}; heth.Instance = ETH; heth.Init.AutoNegotiation = HAL_ETH_AUTONEGO_ENABLE; heth.Init.Speed = HAL_ETH_SPEED_100M; heth.Init.DuplexMode = HAL_ETH_MODE_FULLDUPLEX; heth.Init.MediaInterface = HAL_ETH_RMII_MODE; heth.Init.PhyAddress = 0; heth.Init.RxDesc = ETH_RXDMA_DESC; heth.Init.TxDesc = ETH_TXDMA_DESC; heth.Init.RxBuffLen = 0x600; heth.Init.MACAddr[0] = 0x02; heth.Init.MACAddr[1] = 0x00; heth.Init.MACAddr[2] = 0x11; heth.Init.MACAddr[3] = 0x22; heth.Init.MACAddr[4] = 0x33; heth.Init.MACAddr[5] = 0x44; if (HAL_ETH_Init(&heth) != HAL_OK) { Error_Handler(); } }MAC 初始化完成后,PHY 复位必须保留足够的延时。PHY 内部有上电自检和自协商过程,延时太短,MDIO 读写会失败。我一般拉低复位脚 10ms,再拉高等 200ms 以上才继续,这个时间可以覆盖绝大多数 PHY 的启动时序。
static void phy_reset(void) { HAL_GPIO_WritePin(PHY_RST_PORT, PHY_RST_PIN, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(PHY_RST_PORT, PHY_RST_PIN, GPIO_PIN_SET); HAL_Delay(200); }PHY 复位这段代码在调试时非常好用:如果 MDIO 读到 0xFFFF,反复调用一次再读,可以快速区分是 PHY 未启动还是接线问题。链路没有确认之前,不要往下走 LwIP,否则你会在协议栈里排错,而问题其实在物理层。
3. LwIP 集成:TCP 客户端依赖的协议栈配置与网卡初始化
3.1 raw API、netconn 与 socket:H743 上到底该选哪个
LwIP 提供三种 API 层次:raw API、netconn API 和 socket API。raw API 直接在 tcpip 线程上下文里跑回调,没有数据拷贝,RAM 占用最小,但代码结构比较绕;netconn API 是阻塞式封装,代码直观,但每个连接都要额外线程;socket API 是给从 POSIX 迁过来的人用的,最占资源。
做 H743 的 TCP 客户端,我建议直接用 raw API。H743 虽然内存比 F4 大很多,但网络应用一旦跑起来,协议栈的内存池、DMA 描述符和收发缓冲都会吃 RAM,能省的地方尽量省。raw API 没有线程阻塞,回调模型天然适合主动连接加事件收发的场景。
| API | 依赖 RTOS | 拷贝次数 | 内存开销 | 适用场景 |
|---|---|---|---|---|
| raw API | 否 | 少 | 低 | 嵌入式主动连接、协议定制 |
| netconn API | 是 | 较多 | 中 | 需要阻塞读写的业务 |
| socket API | 是 | 最多 | 高 | 代码迁移动成本优先 |
代码工程采用 LwIP 2.1.x 系,RTOS 和 LwIP 的桥接文件sys_arch.c建议直接从官方移植包拿,不要自己写。IAR 工程里把 lwip 源码所有.c文件加进去之后,只需要改lwipopts.h和ethernetif.c,就能跑通基础数据通路。
3.2 lwipopts.h 裁剪:TCP 客户端相关的必改项
lwipopts.h 是整个 LwIP 的配置总闸。默认配置是为 PC 平台准备的,直接用到 H743 上会严重浪费内存。下面这组配置是我在 IAR 工程里验证过的 TCP 客户端最小集,每一行都值得细看。
#define NO_SYS 0 #define LWIP_SOCKET 0 #define LWIP_NETCONN 0 #define LWIP_TCP 1 #define LWIP_DHCP 0 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (8 * TCP_MSS) #define LWIP_STATS 1 #define LWIP_TCP_KEEPALIVE 1 #define MEM_ALIGNMENT 4 #define MEM_SIZE 32768 #define PBUF_POOL_SIZE 8 #define PBUF_POOL_BUFSIZE 1524 #define CHECKSUM_GEN_IP 1 #define CHECKSUM_GEN_UDP 1 #define CHECKSUM_GEN_TCP 1 #define CHECKSUM_CHECK_IP 0 #define CHECKSUM_CHECK_UDP 0 #define CHECKSUM_CHECK_TCP 0这段配置把 LwIP 裁剪成一个只跑 TCP 的轻量协议栈。NO_SYS 设为 0,配合 RTOS 的 tcpip_thread 运行,让协议栈事件在一个确定线程里消化,避免裸机轮询带来的时序不确定性。LWIP_SOCKET 和 LWIP_NETCONN 都关掉,只保留 raw API,编译出来的代码体积明显小。
TCP_MSS 由以太网 MTU 决定,标准 1500 字节以太帧去掉 IP 头和 TCP 头就是 1460。TCP_WND 和 TCP_SND_BUF 是吞吐量的核心,接收窗口太小会导致对端发送变慢,发送缓冲太小则 tcp_write 会频繁返回 ERR_MEM。CHECKSUM 相关的宏,生成校验和必开,校验关闭可以省 CPU,适合嵌入式自己管理链路的场景。
3.3 netif_add 与底层驱动:让协议栈认识你的网卡
网卡驱动ethernetif.c负责把 DMA 描述符里收到的数据交给协议栈。协议栈初始化时要调用 netif_add,把 IP 地址、子网掩码、网关和网卡初始化函数绑定在一起。这里最关键的是最后一个参数 tcpip_input,它通过 tcpip_thread 的邮箱把收包请求送进协议栈核心。
struct netif gnetif; ip_addr_t ipaddr; ip_addr_t netmask; ip_addr_t gw; void lwip_netif_init(void) { IP4_ADDR(&ipaddr, 192, 168, 1, 100); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, ðernetif_init, &tcpip_input); netif_set_default(&gnetif); netif_set_up(&gnetif); }netif_add 做了三件事:绑定协议栈 netif 结构、调用 ethernetif_init 初始化底层 DMA、设置输入回调。ethernetif_init 里会注册 MAC 地址、设置接收描述符,并把 netif 的 output 指针指向 etharp_output,这样上层发 IP 包时会自动做 ARP。netif_set_up 之后,协议栈逻辑上认为网卡已就绪。
网卡数据需要周期喂给协议栈。带 RTOS 的工程里,我一般单独建一个低优先级任务,持续调用 ethernetif_input 和 sys_check_timeouts。前者收包,后者驱动 TCP 重传超时和 keepalive 定时器,漏掉后者会导致 TCP 状态机永远不推进。
void eth_poll_task(void *arg) { for (;;) { ethernetif_input(&gnetif); sys_check_timeouts(); osDelay(1); } }这个任务只负责把以太网帧从 DMA 描述符搬到 LwIP,不处理业务逻辑。osDelay(1) 是为了给其他任务留出执行时间,也让网卡驱动不至于占满 CPU。如果发现 TCP 连接建立慢,先检查这个任务的优先级是不是被业务任务占死了。
3.4 检查协议栈是否活着的三个标志
协议栈集成完了,先别急着写 TCP 客户端,验证基础连通性更重要。最直接的方法是分配一个固定 IP,用 PC 去 ping H743 的 IP。能 ping 通说明 ARP、IP、ICMP 都正常,TCP 客户端才有可能建立连接。ping 不通时,看三个地方:gnetif.flags 有没有 NETIF_FLAG_UP、PHY 的 BSR 寄存器有没有 link up、ethernetif_input 有没有被周期调用。
4. TCP 客户端实现:连接回调、数据收发和断线重连
4.1 连接的本质:TCP_PCB 与回调上下文
LwIP 的 raw API 里,每个 TCP 连接对应一个 tcp_pcb 结构体,协议栈通过回调函数通知你状态变化。tcp_new 创建 PCB,tcp_connect 发起连接,连接结果通过 connected 回调返回,数据到达通过 recv 回调返回,对端关闭通过 recv 收到 NULL 表示。理解回调模型是写对 TCP 客户端的前提。
PCB 存在于 tcpip_thread 的上下文里,所有回调都在 tcpip_thread 中执行。应用业务线程如果直接调用 tcp_write,需要自己做核心锁保护,否则可能踩到协议栈正在处理收包的临界区。我一般不在业务线程直接操作 PCB,而是把发送请求包装一下,投递到 tcpip_thread 里执行。
4.2 发起连接:服务器地址、端口与连接回调
TCP 客户端的第一步是创建 PCB 并发起连接。LwIP 的 tcp_connect 会自动分配本地端口,不需要手动 bind。连接回调里要判断 err 是否为 ERR_OK,失败时 PCB 由协议栈释放,不能再碰。
#define SERVER_PORT 9000 static struct tcp_pcb *g_tcp_pcb; static uint8_t g_conn_state; static err_t tcp_client_connected(void *arg, struct tcp_pcb *pcb, err_t err) { if (err == ERR_OK) { g_conn_state = 1; /* 连接已建立 */ } else { g_conn_state = 0; tcp_abort(pcb); /* 失败时主动终止,避免半开连接 */ } return ERR_OK; } void tcp_client_start(void) { ip_addr_t server_addr; IP4_ADDR(&server_addr, 192, 168, 1, 20); g_tcp_pcb = tcp_new(); tcp_arg(g_tcp_pcb, NULL); tcp_recv(g_tcp_pcb, tcp_client_recv); tcp_sent(g_tcp_pcb, tcp_client_sent); tcp_err(g_tcp_pcb, tcp_client_err); g_conn_state = 0; tcp_connect(g_tcp_pcb, &server_addr, SERVER_PORT, tcp_client_connected); }tcp_client_start 里注册了四个回调,其中 err 回调最容易忽略。TCP 连接在传输过程中断开时,协议栈不会给你任何返回值,只有 err 回调会通知。err 回调触发时,PCB 已经被协议栈释放,g_tcp_pcb 要置空,否则后续重连时会出现野指针。
连接失败的业务处理也很重要。如果服务器没开机,tcp_connect 会重传 SYN 多次,这个过程可能持续十几秒,期间不能阻塞主流程。我一般把重连放到一个软定时器里,失败后间隔 1 秒、2 秒、4 秒递增重试,最大间隔 60 秒,避免对端恢复后你还在等待重试。
4.3 接收数据与窗口回收:tcp_recved 必须调用
接收回调传进来的 pbuf 是一段链表,可能有多个 pbuf 节点组成一个完整 TCP 段。处理完数据后要调用 tcp_recved,告诉协议栈这段数据已经被应用层消费,可以扩大接收窗口了。漏掉这一步是 TCP 客户端最常见的卡顿原因。
static err_t tcp_client_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { struct pbuf *q; if (p == NULL) { /* 对端关闭连接,PCB 可以释放 */ tcp_close(pcb); g_tcp_pcb = NULL; return ERR_OK; } q = p; while (q != NULL) { /* 在这里处理 q->payload,长度 q->len */ q = q->next; } tcp_recved(pcb, p->tot_len); pbuf_free(p); return ERR_OK; }这段代码先用 p 是否为 NULL 判断对端关闭,再遍历 pbuf 链表逐段处理。tcp_recved 要在数据真正处理完之后调用,不能在回调入口就调,否则窗口回收后新数据涌进来,而你的业务还没来得及消费,造成丢包。pbuf_free 是必须的,协议栈分配的这个 pbuf 不释放,内存池会慢慢耗尽。
4.4 业务层发送:tcp_write 与 tcp_output 的配合
发送数据用 tcp_write 把应用数据拷贝进协议栈发送缓冲区,再调用 tcp_output 推动实际发包。TCP_WRITE_FLAG_COPY 表示协议栈会拷贝数据,可以安全地在发送函数返回后释放应用层缓冲,代价是多一次内存拷贝。不设置这个标志,协议栈只保存指针,数据缓冲必须保持有效直到 sent 回调。
static err_t client_send_cb(void *arg) { struct send_req *r = (struct send_req *)arg; err_t err = tcp_write(g_tcp_pcb, r->buf, r->len, TCP_WRITE_FLAG_COPY); if (err == ERR_OK) { tcp_output(g_tcp_pcb); } return err; } err_t tcp_client_send(const uint8_t *buf, uint16_t len) { struct send_req req; req.buf = buf; req.len = len; /* 把发送动作投递到 tcpip_thread 执行 */ return tcpip_callback(client_send_cb, &req); }tcpip_callback 是 raw API 跨线程投递的标准入口,这里把发送函数包装成回调,让它在 tcpip_thread 上下文里执行,避开了核心锁竞争问题。客户端发送频率高时,要检查 tcp_write 的返回值,返回 ERR_MEM 表示发送缓冲满了,需要等 sent 回调后再发下一批,不能硬塞。
4.5 断线重连:先用 tcp_abort 还是 tcp_close
断线重连是 TCP 客户端最需要做对的部分。服务器主动断开时,recv 回调会收到 p 为 NULL,此时调用 tcp_close 是正常的四次挥手流程;如果是网络异常中断,err 回调触发,PCB 已经被协议栈处理,你只需要重新调用 tcp_client_start。
重连时最怕的是旧连接还挂在 TIME_WAIT 状态。TIME_WAIT 最长要等两个 MSL,默认可能几十秒,期间同一端口无法立即复用。因此主动重连时不要用 tcp_close,应该用 tcp_abort 直接终止,让协议栈立即回收 PCB 资源。这个选择直接影响设备断电重启后能不能快速恢复连接。
5. IAR 调试中的五个连接坑:从链接失败到断线重连
5.1 IAR 链接提示段溢出,RAM 不够放协议栈
现象是编译能过,链接时报.bss或.data段溢出,地址越界。原因通常是 lwipopts.h 里的内存池太大,加上 DMA 描述符数组,撞上了 H743 的 RAM 区域上限。解决方法是先开优化等级,让编译器剪掉未用代码;再逐步缩小 MEM_SIZE、PBUF_POOL_SIZE 和 TCP_SND_BUF,直到链接通过。
但缩小内存池要谨慎,TCP_SND_BUF 小于 4 倍 MSS 时,吞吐会明显下降。如果确认代码逻辑没问题,优先检查 IAR 链接脚本里的 RAM 区域分配,H743 有多个 RAM 域,默认堆栈段可能落在容量较小的那个域上。链接脚本里把堆放到 512KB 的大 RAM 域,链接错误立刻消失。
5.2 MDIO 读 PHY ID 全是 0xFFFF,PHY 像没接一样
现象是上电后读 PHY 寄存器,所有值都是 0xFFFF,MDIO 波形只有毛刺没有正确时钟。原因按出现频率排列:RMII REF_CLK 没有 50MHz 时钟、PHY 复位脚被拉低、MDC 频率太高。我曾在一个项目里排查了一整天,最后发现是 PHY 的 50MHz 晶振虚焊,示波器一量 REF_CLK 只有十几毫伏。
这类问题不要在协议栈里浪费时间。先用示波器量 REF_CLK 频率,确认 50MHz 稳定;再量 PHY_RST 引脚电平,正常启动后应该为高;最后用 IAR 的 Live Watch 读 PHY 的 BSR 寄存器,看 bit 2 是否置位表示 link up。按这个顺序排,基本十分钟内能定位。
5.3 tcp_connect 一直停在 SYN_SENT,服务器收不到 SYN
现象是客户端状态一直是 SYN_SENT,服务器端抓包看不到任何数据。原因集中在 IP 网络不可达和服务器端口错误。PC 能 ping 通 H743 不代表 TCP 能连上,服务器防火墙可能屏蔽了非默认端口,或者服务器监听的是内网地址而客户端连的是公网地址。
排查时先确认服务器端口能被外部访问,用 PC 上的调试工具在同样网络环境里连一次。如果 PC 能连而 H743 连不上,查 H743 的 ARP 表,客户端发的 SYN 要找到服务器 MAC 地址才能送上网络,ARP 解析失败时 SYN 只会卡在协议栈里。在 IAR 里看 gnetif 的 ARP 缓存,能直接确认这层问题。
5.4 数据能通但乱序,发送快接收慢时丢包明显
现象是建立了连接,但服务器收到的数据顺序不对,或者收发频率一高就丢包。原因多半是接收窗口没有及时释放,TCP 的滑动窗口机制要求在 recv 回调里尽快消费数据并调用 tcp_recved。如果业务处理在主线程里排队,接收回调处理过慢,窗口收缩到零后对端停止发送,整体表现为连接“卡死”。
也可以在 lwipopts.h 里临时开 LWIP_STATS,通过 IAR 的 Live Watch 查看 tcp 重传统计。如果重传次数持续增长,说明链路丢包严重,检查 TCP_SND_BUF 是否过小导致被迫频繁重发。先解决窗口回收顺序,再考虑增大缓冲。
5.5 掉线后不能恢复,新连接一直建立不起来
现象是设备运行一段时间后和服务器断开,重启设备又能恢复。原因大概率是重连逻辑里没有把旧 PCB 清理干净。err 回调触发后 g_tcp_pcb 已经无效,如果代码继续用这个指针调用 tcp_write,协议栈行为未定义,连接永远无法重建。另外主动断开时用了 tcp_close 进入 TIME_WAIT,同一四元组短时间内无法复用。
解决方法是 err 回调里立即把 g_tcp_pcb 置空,重连只用新建的 PCB;需要主动断开时用 tcp_abort 代替 tcp_close。重连间隔要加退避,连续失败时从 1 秒逐步涨到 60 秒,否则服务器恢复后,客户端可能还陷在快速重试的循环里。
6. 进阶技巧:用抓包和统计验证 TCP 客户端健康度
工程跑通后,建议从调试器窗口拔出来,用 PC 端的抓包工具验证整个链路。抓包时过滤 tcp port 9000,能清楚看到三次握手、数据段推送、ACK 确认和断开挥手全过程。一个健康的 TCP 客户端连接,握手完成后的数据流应该呈现“客户端发 PSH/ACK,服务器立刻回 ACK”的交替模式,如果看到大量 TCP Dup ACK 或 Retransmission,物理层或缓冲配置一定有问题。
keepalive 是 TCP 客户端感知服务器掉线的关键。LwIP 的 raw API 支持在 PCB 上直接开启,设置探测间隔和次数,服务器异常断电时能在一分钟内让客户端进入 err 回调,而不是等 TCP 重传超时耗尽后才上报。
#if LWIP_TCP_KEEPALIVE tcp_keepalive(g_tcp_pcb); tcp_keepidle(g_tcp_pcb, 5 * 1000); tcp_keepintvl(g_tcp_pcb, 3 * 1000); tcp_keepcnt(g_tcp_pcb, 3); #endif这段配置意思是没有数据交互 5 秒后开始发 keepalive 探测包,每 3 秒发一次,连续 3 次没有回应就判定连接断开。keepalive 不能替代业务心跳,但比心跳更早发现链路断开,适合服务器掉线后需要快速重连的场景。开 keepalive 时 lwipopts.h 里 LWIP_TCP_KEEPALIVE 必须为 1,否则相关宏不生效。
吞吐调优方面,TCP_WND 和 TCP_SND_BUF 决定上下行能同时存多少数据。我调过最明显的案例是设备上报周期数据,每秒 10KB,默认 TCP_WND 只有 2 倍 MSS,服务器发送稍快就窗口零停。改成 8 倍 MSS 后,发送和接收都顺了,代价是 RAM 多了十几 KB。对 H743 来说完全值得。
最后说一个血泪教训:最初我调 TCP 客户端时,从协议栈一路查到底层驱动,最后发现是 PHY 的 REF_CLK 没起来。从此我给自己定了个规矩,任何网络问题先从 PHY 的 link 状态和 MDIO 寄存器开始排,再进协议栈。你会发现网络调试的玄学问题,九成都是物理层的坑。希望帮到你。
本文还有配套的精品资源,点击获取