STM32F407+LAN8720A实现lwIP TCP client:从CubeMX配置到实战调试
2026/9/11 23:23:35 网站建设 项目流程

简介:这份资源是基于STM32CubeMX开发的STM32F407以太网TCP客户端程序源码,面向嵌入式开发者,特别是使用HAL库与LWIP协议栈进行网络通信的工程师。压缩包共342个文件,以225个h头文件与109个c源文件为主,并含3个txt说明、IOC工程配置文件及KEIL工程文件,整体约1.75MB,方便直接打开与编译。目前已有599人学习下载。工程实现了F407开发板作为TCP client、PC端作为TCP server的双向收发通信实验,PHY采用LAN8720A,基于MDK-ARM 5.32环境编写。源码清晰涵盖以太网底层驱动、LWIP协议栈的socket调用与HAL库外设初始化,目录结构完整,便于快速定位代码;既适合快速搭建以太网通信实验,也适合作为入门LWIP TCP开发的参考,可在此基础上扩展多客户端连接或自定义应用层协议。

1. 一块 F407 板子跑通 TCP client,卡你的往往不是代码

STM32F407VET6 通过 LAN8720A 做 100M 以太网通信,很多人拿到源码第一反应是打开 KEIL 直接编译下载,结果卡在ETH transmit frame faild或者连接建立后又立刻断开。问题很少出在tcp.ctcp_in.c这些 lwIP 协议栈文件上,而是集中在 CubeMX 生成的 ETH 参数、PHY 地址、时钟树和内存池配置这几处。这个工程的核心价值在于:它在 KEIL MDK 5.32 环境下把 STM32 HAL 库、lwIP 协议栈、LAN8720A 驱动和 TCP client 代码完整串了起来,F407 作为客户端主动连接 PC 端 server,双向收发,可以直接作为 Modbus TCP 上报、设备远程运维、数据采集网关的底层模板。适合已经会用 CubeMX 生成基础工程、但没跑通过以太网 TCP 的开发者和做产品原型验证的工程师。要想让它一上电就连上 PC,得先把 RMII 引脚映射、PHY 地址、lwIP 内存参数和 raw API 回调逻辑这四层掰开看。

2. TCP client 的 raw API 实现与连接状态机

2.1 从 CubeMX 生成的 ETH 驱动到 lwIP 回调

CubeMX 生成代码时,ethernetif.c已经完成了底层驱动与 lwIP 的对接,MX_LWIP_Init()里会调用lwip_init()netif_add()。真正要动手写的是应用层——TCP client 的连接建立、数据收发、断线重连。lwIP 的 raw API 是基于回调的,连接建立后,协议栈会回调你注册的tcp_connect回调函数,之后收数据进tcp_recv回调,连接关闭进tcp_polltcp_err回调。这种模式没有阻塞等待,所有事件都在tcpip_thread上下文中触发,所以回调函数里不能做耗时操作,只能把数据拷贝出来或者置标志位。

连接发起一般放在main()里调用MX_LWIP_Init()之后,写一个独立函数:

void tcp_client_connect_start(void) { if (tcp_client_pcb != NULL) return; tcp_client_pcb = tcp_new(); if (tcp_client_pcb == NULL) return; ip_addr_t server_ip; IP_ADDR4(&server_ip, 192, 168, 1, 10); tcp_client_pcb->remote_port = 8080; tcp_client_pcb->local_port = 5000; tcp_arg(tcp_client_pcb, NULL); tcp_recv(tcp_client_pcb, tcp_client_recv); tcp_connect(tcp_client_pcb, &server_ip, 8080, tcp_client_connected); }

tcp_new分配一个 TCP 控制块,如果返回 NULL,说明MEM_SIZE内存池不足,需要回 CubeMX 调大。IP_ADDR4宏把点分十进制字符串形式的 IP 转成 lwIP 内部的ip_addr_t结构体,remote_port是 PC 端 server 监听端口,local_port是本地端口,不设置时内核自动分配。tcp_connect最后一个参数是连接成功后的回调函数,连接失败或超时不会走到这里,而是触发tcp_err回调。这里有个隐藏细节:tcp_connect函数名容易让人误以为它是阻塞的,实际上它只是发起 SYN 报文发送后就立即返回,返回值ERR_OK只代表控制块状态正确。

2.2 连接成功与数据接收回调

连接成功后,lwIP 会回调tcp_client_connected,在这个回调里可以注册接收回调并发送初始数据:

err_t tcp_client_connected(void *arg, struct tcp_pcb *pcb, err_t err) { if (err == ERR_OK) { tcp_recv(pcb, tcp_client_recv); tcp_nagle_disable(pcb); connected_flag = 1; tcp_client_send_data(pcb, (uint8_t *)"hello server\r\n", 14); return ERR_OK; } return err; }

tcp_recv注册数据接收回调,tcp_nagle_disable(pcb)关闭 Nagle 算法。Nagle 算法会把多个小包合并成一个 TCP 段发送,对于交互式客户端来说会增加几十毫秒延迟,因为要等对端 ACK 才能发下一段。关闭后小包立即发送,实时性更好。connected_flag是全局标志,主循环用它判断连接状态。err参数如果非ERR_OK,说明连接已经断开,这时候要做清理。

接收回调是整个 TCP 数据链路的入口:

err_t tcp_client_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (err == ERR_OK && p != NULL) { tcp_recved(pcb, p->tot_len); uint8_t buffer[512] = {0}; uint16_t len = p->tot_len > 511 ? 511 : p->tot_len; pbuf_copy_partial(p, buffer, len, 0); pbuf_free(p); tcp_client_send_data(pcb, buffer, len); printf("recv: %s\r\n", buffer); } else if (err == ERR_OK && p == NULL) { tcp_close(pcb); tcp_client_pcb = NULL; connected_flag = 0; printf("server closed\r\n"); } else { pbuf_free(p); } return ERR_OK; }

tcp_recved(pcb, p->tot_len)这行必须调用,它告诉 lwIP 协议栈接收缓冲区已经释放了len字节,可以继续接收新数据。如果不调用,接收窗口会逐渐减小到 0,对端发数据会触发零窗口探测,表现为 TCP 连接建立成功但数据传不出去。pbuf_copy_partial把协议栈的 pbuf 链表拷贝到线性缓冲区,偏移量从 0 开始,长度限制为 min(数据长度, 511) 是为了防止缓冲区溢出。p == NULL是 lwIP 判断对端关闭连接的标志,此时tcp_close释放资源。

2.3 数据发送函数与内存管理

tcp_client_send_data是应用层唯一的数据出口:

err_t tcp_client_send_data(struct tcp_pcb *pcb, uint8_t *data, uint16_t len) { if (pcb == NULL || data == NULL || len == 0) { return ERR_ARG; } err_t err = tcp_write(pcb, data, len, 1); if (err == ERR_OK) { err = tcp_output(pcb); } return err; }

tcp_write把数据拷贝到协议栈内部发送缓冲,1TCP_WRITE_FLAG_COPY,表示立即拷贝,调用返回后原数据缓冲区可以复用。tcp_output触发实际的报文发送,如果没有这句话,数据要等协议栈轮询到才发,延迟不可控。TCP_WRITE_FLAG_COPY如果不设置,lwIP 会零拷贝引用你的数据缓冲区,此时缓冲区必须保持有效直到发送完成,否则指针悬空导致数据错乱。

主循环里配合连接状态管理:

while (1) { MX_LWIP_Process(); if (connected_flag == 0) { tcp_client_connect_start(); HAL_Delay(500); } }

MX_LWIP_Process()是 lwIP 协议栈的时间轮询函数,TCP 重传、ARP 超时、DHCP 续租都依赖它。500ms 的重连间隔避免频繁tcp_new导致内存碎片化,connected_flag用 volatile 修饰,因为它在回调函数(中断上下文)和主循环之间共享。

3. LAN8720A PHY 初始化与 CubeMX 时钟树配置

3.1 引脚映射与非复用冲突

LAN8720A 通过 RMII 接口与 STM32F407 连接,RMII 只需要 7 根信号线加 2 根管理线。CubeMX 中使能 ETH 外设后选 RMII 模式,自动分配引脚:PA1 接 REF_CLK,PA2 接 MDIO,PA7 接 CRS_DV,PC1 接 MDC,PC4/PC5 接 RXD0/RXD1,PG11 接 TX_EN,PG13/PG14 接 TXD0/TXD1。同一时刻,这些引脚上如果还有其他外设复用,比如 PA1 同时被 TIM2_CH2 占用,CubeMX 会直接在引脚图里报红色冲突。

3.2 PHY 地址选择

LAN8720A 的 PHY 地址由 PHYAD0 引脚决定,多数模块上电默认地址是 0,也有部分开发板硬件拉高到地址 1。CubeMX 里ETH配置页的PHY Address参数必须和硬件一致,填错后HAL_ETH_Init能成功,但HAL_ETH_ReadPHYRegister读不到 ID 寄存器,ethernetif.c里检测链路时会一直返回超时。查看你的开发板原理图,找 LAN8720A 的 PHYAD0 引脚接的电阻,如果是一个上拉到 3.3V,地址是 1;如果下拉到地或悬空,地址是 0。

3.3 RMII 参考时钟 50MHz 生成

LAN8720A 的 RMII 接口需要 50MHz 参考时钟,来源有两个:外部 50MHz 晶振,或者从 STM32F407 的 MCO1 引脚输出 PLL 时钟。F407 的 MCO1 最大能输出 50MHz,CubeMX 的RCC配置页里选MCO1HSEPLL,然后在Clock Configuration页把 MCO1 分频设成 50MHz。如果你用的是带 50MHz 晶振的 LAN8720A 模块,MCO1 可以不配置;但大部分模块板载的是 25MHz 晶振,需要 MCU 输出 50MHz。时钟不对时,PHY 的寄存器能读写,但 RMII 信号完全乱掉,现象是tcp_connect返回ERR_RTE或者连接后立刻超时。

3.4 lwIP 内存分配与 TCP_MSS 对齐

CubeMX 生成 lwIP 时,默认内存配置偏小,需要针对 F407 调整。MEM_SIZE(堆大小)至少 1600 字节,PBUF_POOL_SIZE至少 8,PBUF_POOL_BUFSIZE设 1500 字节对 MTU 1500 的以太网帧。TCP 相关的TCP_MSS设 1460,TCP_WND设 4380,TCP_SND_BUF设 4380。注意这里的坑:TCP_WNDTCP_SND_BUF单位是字节,如果接收窗口小于你单次发送的数据量,tcp_write会因为发送缓冲满返回ERR_MEM,但数据本身没有丢失,lwIP 内部缓冲会等对端 ACK 后继续发。

ethernetif.c里的low_level_output函数返回错误码时,eth_transmit_frame_faild往往是 DMA 描述符链耗尽或 TX buffer 被占满,代码里循环等待ETH_DMA_Transmit时没有超时保护,导致死循环。这类问题在eth.c的 HAL 库里表现为HAL_ETH_TransmitFrame返回HAL_BUSY,实际原因要么是上一次发送还没完成,要么是描述符被错误释放。

4. 联调环境中 PC 端 server 配置与抓包验证

4.1 用 Python 起一个标准 TCP server

PC 端作为 TCP server,可以用 Python 快速验证:

import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('0.0.0.0', 8080)) server.listen(5) print('listening on 8080...') while True: conn, addr = server.accept() print('connected from', addr) while True: data = conn.recv(1024) if not data: break print('recv:', data) conn.sendall(b'ack: ' + data) conn.close()

SO_REUSEADDR允许端口在 TIME_WAIT 状态下重新绑定,避免 STM32 重连时端口被占用报错。listen(5)的 5 是未 accept 队列长度,STM32 重连间隔短时,队列满会触发 RST,表现为连接建立失败。片段里的conn.recv(1024)阻塞等待,STM32 心跳包每 1 秒发一次,所以不会超时。PC 防火墙要允许 8080 端口入站,否则 STM32 的 SYN 包到达后直接被防火墙丢掉,表现是tcp_connect返回ERR_TIMEOUT

4.2 Wireshark 过滤条件与常见异常

抓包时过滤tcp.port == 8080,可以看到完整的握手过程:STM32 发 SYN,PC 回 SYN+ACK,STM32 发 ACK。如果只有 SYN 没有回应,查网络中是否有其他设备占用了相同 IP;如果 SYN 不断重传,查 PC 防火墙。连接建立后周期性看到 PSH+ACK,说明数据在正常收发。

常见问题里,STM32 发送send数据但 PC 收不到,抓包却发现 TCP 层没有重传——这是应用层逻辑问题,比如tcp_client_send_data被调用时tcp_client_pcb还是 NULL,发送函数直接返回ERR_ARG。还有一种情况是 PC 端recv能收到数据但 STM32 串口打印乱码,这不是网络问题,而是串口波特率或 DMA 接收中断没配对。

4.3 断线检测与自动重连

物理拔掉网线后,lwIP 不会立刻感知链路断开,TCP 层要等 3-5 秒才发现重传超时。工程里处理方式是让 STM32 周期性检测 PHY 链路状态:

void check_link_status(void) { uint32_t phy_basic_status = 0; HAL_ETH_ReadPHYRegister(&heth, PHY_BSR, &phy_basic_status); if (phy_basic_status & PHY_LINKED_STATUS) { link_ok = 1; } else { link_ok = 0; connected_flag = 0; tcp_client_pcb = NULL; } }

PHY_BSR是 PHY 的基础状态寄存器,PHY_LINKED_STATUS是 bit 2,检测到网线拔出后立即把tcp_client_pcb置空加connected_flag = 0,主循环就会进入重连逻辑。这个调用频率控制在 1 秒一次,因为HAL_ETH_ReadPHYRegister走 MDIO 协议,太频繁会占用总线影响收发。

重连时注意tcp_connect返回值:如果上次连接的控制块没有被完全释放,tcp_new会失败,所以拔线后要多等一个TCP_MSL(约 2 秒)再重新分配。工程里可以在connected_flag == 0tcp_client_pcb == NULL两个条件都满足时才执行tcp_new,避免重复分配。

5. 进阶调试:DMA 描述符对齐与 PTP 时间戳对吞吐的影响

5.1 DMA 描述符 4 字节对齐的必要性

ethernetif.c中定义了DMA_RX_DESC_TABDMA_TX_DESC_TAB两个数组,作为 DMA 描述符。F407 的 ETH 外设要求描述符地址 4 字节对齐,CubeMX 生成的代码默认放在普通全局区,RAM 起始地址天然对齐,但如果工程里自定义了分散加载文件,描述符被放到非对齐地址,DMA 会直接访问错误内存,表现为接收死掉或数据错乱。

函数里新建缓冲区时同样要注意__attribute__((aligned(4)))

__attribute__((aligned(4))) uint8_t tx_buffer[2048]; __attribute__((aligned(4))) uint8_t rx_buffer[2048];

如果你把tx_buffer直接传给tcp_write且没有带TCP_WRITE_FLAG_COPY,dma 会跳过用户缓冲区直接描述符指向它,此时对齐不对会导致底层 DMA 传输失败,HAL_ETH_TransmitFrame返回错误码。整个工程里用tcp_write时建议始终带TCP_WRITE_FLAG_COPY,虽然多一次拷贝,但对齐问题全规避掉。

5.2 串联两个板卡做透传测试的时钟同步问题

工程中经常会出现两块 F407 板通过同一交换机互连,一块跑 TCP client,另一块跑 TCP server。这个时候sys_check_timeoutstcp_slowtmr的周期分别是 250ms 和 500ms,不能乱改。lwIP 的 TCP 超时重传靠这两个定时器驱动,如果你在MX_LWIP_Process()里加了额外的HAL_Delay,会直接导致 RTO 计算失真,表现为吞吐下降但抓包看不到丢包。

正确做法是保持MX_LWIP_Process()while(1)循环里以最高优先级轮询,业务处理放到HAL_GetTick()的时间片里做。如果需要扩展时延,用tcp_poll回调替代HAL_Delay

void tcp_client_poll(void *arg, struct tcp_pcb *pcb) { static uint32_t last_send = 0; if (HAL_GetTick() - last_send >= 1000) { last_send = HAL_GetTick(); tcp_client_send_data(pcb, (uint8_t *)"heartbeat\r\n", 11); } }

然后在连接回调里tcp_poll(pcb, tcp_client_poll, 1),1 表示每隔 1 个 TCP 慢超时周期调用一次,慢超时是 500ms,所以心跳间隔就是 500ms 的整数倍。这种方式比主循环里HAL_Delay精度高得多,不会干扰 RTO 计算。

5.3 PC 端并发连接过多导致 SYN 丢弃

listen(5)的 backlog 如果太小,STM32 重连太频繁时,PC 的 accept 队列塞满后内核会丢弃新 SYN。这不是 lwIP 的问题,是 PC 端 TCP 协议栈行为。可以用ss -lntp看 PC 监听队列的 Drop 数量,如果 Drop 持续增长,把 Python 脚本的listen(5)改成listen(128)。STM32 侧不需要改,重连逻辑会自动等到成功后退出。

Wireshark 抓包时如果看到 STM32 发 SYN 但 PC 没有任何响应,先用tcpdump -i eth0 tcp port 8080在 PC 上抓一次,确认包是否到达网卡。包到了网卡但没回应,大概率是防火墙把收到的 SYN 当非法流量处理了;包没到网卡,查路由器是否做了端口隔离或风暴抑制,部分交换机默认开启 BPDU 防护,STP 会学习 STM32 网卡的 MAC 地址但阻止非管理帧。

6. 工程移植到其他 F407 板卡时的三个陷阱

6.1 PIN 复用与 GPIO 初始化顺序

不同厂商的 F407 开发板引脚分布不同,CubeMX 工程复制到新板卡后,优先检查HAL_ETH_MspInit里 GPIO 初始化是否被整体注释掉。CubeMX 重新生成代码时,eth.c里的HAL_ETH_MspInit包含时钟和 GPIO 配置,如果你手动改过引脚,必须在 CubeMX 的Pinout视图里重新选择并验证。常见错误是 GPIO 初始化顺序写在ETH_Init之后,导致 PHY 复位后 MDIO 引脚还没配好。

6.2 PHY 特殊寄存器对中断的影响

LAN8720A 的寄存器 31 是中断源寄存器,默认屏蔽所有中断。有些代码会在ethernetif.clow_level_init里开 PHY 中断,此时 F407 的 ETH 全局中断会持续触发,HAL_ETH_IRQHandler进入后没有可处理事件,连接建立可以,但主循环被中断反复打断导致发送超时。解决方式是检查ethernetif.c里 PHY 初始化部分是否主动写寄存器 31,如果工程不需要 PHY 中断,直接注释掉。

6.3 同时跑串口 DMA 和 ETH DMA 时 SRAM 空间分配

F407VET6 的 RAM 是 128KB,ETH 的 DMA 描述符加上 lwIP 内存池占约 20KB,如果串口 DMA 也开了大缓冲,链接时可能报region RAM overflowed。此时优先调小PBUF_POOL_SIZE到 4,PBUF_POOL_BUFSIZE保持 1500 不变;如果还不够,把MEM_SIZE从 4096 降到 2048。TCP_SND_BUFTCP_WND不能跟着降,否则tcp_write返回ERR_MEM频率会显著提升,影响大部分正常业务。

链路建立后可以用tcp_sndbuf检查发送缓冲剩余空间:

u16_t free_space = tcp_sndbuf(tcp_client_pcb); if (free_space < len) { // 需要等待协议栈 ACK 释放缓冲 }

这个检查比直接tcp_write后判断返回值更可控,当剩余空间不足时,可以选择缓存到应用层自己的 ring buffer,而不是贸然丢弃数据。多路复用工程里,lwIP 的紧凑内存模型最大的敌人是碎片化,tcp_new频繁分配再释放,堆碎片会逐步累加,运行几天后连接失败率上升,此时唯一办法是周期性重启 MCU 或者在上位机侧设计重连退避策略。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询