简介:这是一份基于 STM32CubeMX 开发的 STM32F407 以太网 TCP Client 程序源码,面向使用 HAL 库与 LWIP 协议栈的嵌入式开发者,可作为 STM32 以太网通信的参考模板。工程以 STM32F407VET6 为主控,搭配 LAN8720A 物理层收发器,开发板作为 TCP 客户端、PC 端作为 TCP 服务端,完成双向发送与接收验证;配套 Keil MDK_ARM_5.32 工程,编译后即可烧录运行。压缩包共 342 个文件,以 HAL 库头文件(225 个 .h)和 C 源码(109 个 .c)为主,涵盖 LWIP 协议栈的 TCP 核心处理、内存管理以及 STM32 以太网外设驱动,另有 uvprojx、ioc、mxproject 等 CubeMX/Keil 配置工程文件,方便二次开发、按需修改与快速移植。整体压缩包仅 1.75MB,文件组织清晰,目录层级合理,已有 599 人学习。借助该工程,读者可以系统学习 F407 以太网外设初始化、PHY 芯片驱动接入、LWIP TCP 通信流程以及 CubeMX 图形化配置方法,从底层驱动到上层协议均有完整代码支撑,非常适合正在调试以太网通信的工程师和嵌入式学习者。
1. 一块F407开发板把TCP client跑起来,比想的简单
拿到一块带以太网口的STM32F407开发板,想让它作为TCP客户端主动连接服务器上报数据,常被“ETH + LwIP + TCP client”这几个词吓住。实际上F407自带MAC控制器,配上外置PHY芯片(最常见的是LAN8720A),再用STM32CubeMX生成初始化代码,整个链路在HAL库时代已经相当成熟。真正花时间的不是连接本身,而是搞清楚PHY地址、RMII时钟、LwIP内存池和netconn API的组织方式。
本文以LAN8720A作为PHY芯片,演示从CubeMX配置到TCP client主动建连、发送、接收的完整流程,重点放在CubeMX的参数设置、netconn API的编码模型以及那些不经提醒就卡半天的细节。适合手里有F407开发板、想用以太网替代串口做数据上报,或者需要把设备接入局域网做简单IoT demo的工程师。跟着步骤走,从新建工程到client跑通需要半天以内。
2. STM32F407的ETH外设与CubeMX初始化配置
2.1 F407的MAC、PHY与RMII接口基本关系
STM32F407内部集成了符合IEEE 802.3-2002标准的以太网MAC,支持10/100Mbit/s速率,但MAC不能直接连接网口变压器,必须外接物理层芯片PHY完成编码、电平转换和链路协商。F407的ETH外设支持MII和RMII两种接口模式,实际项目里几乎都选RMII,因为它只需7根信号线(TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、REF_CLK),比MII的16根省一大半引脚,刚好避开F407引脚紧张的矛盾。
RMII的关键在于50MHz参考时钟REF_CLK的来源。如果使用LAN8720A,常见做法是用F407的MCO1引脚(PA8)输出25MHz时钟给PHY的XI引脚,由PHY内部PLL倍频到50MHz再回送REF_CLK。也可以用外部50MHz有源晶振直接接到PHY的REF_CLK,但CubeMX默认生成的代码走的是MCO1输出25MHz这条路,初次接触时不要随意改动这条时钟链路。
2.2 STM32CubeMX中ETH引脚、时钟树与LAN8720A地址配对
在STM32CubeMX中配置F407的ETH外设,步骤不多但每步都有讲究。先看引脚分配,RMII模式下CubeMX会自动分配以下引脚:
| 信号 | 引脚 | 说明 |
|---|---|---|
| ETH_RMII_REF_CLK | PA1 | PHY返回的50MHz参考时钟 |
| ETH_RMII_CRS_DV | PA7 | 载波侦听/数据有效 |
| ETH_RMII_RXD0 | PC4 | 接收数据位0 |
| ETH_RMII_RXD1 | PC5 | 接收数据位1 |
| ETH_RMII_TX_EN | PB11 | 发送使能 |
| ETH_RMII_TXD0 | PB12 | 发送数据位0 |
| ETH_RMII_TXD1 | PB13 | 发送数据位1 |
| ETH_MDC | PC1 | 管理接口时钟 |
| ETH_MDIO | PA2 | 管理接口数据 |
这些引脚在F407上大多是固定的,不需要像模拟I2C那样随便映射到任意GPIO。PHY地址则由硬件决定,LAN8720A的地址由PHYAD0引脚(对应芯片的RXER引脚)上下拉决定,通常开发板上已经拉低,地址为0x00。CubeMX中需要确认Eth Mode选择“RMII”,PHY Address填0x00,PHY芯片供应商选“LAN8720A”或“User PHY”——选User PHY时还需手动填PHY的寄存器配置。
时钟树部分,F407的ETH挂在AHB1总线上,HCLK为168MHz时默认配置即可直接工作。但MCO1输出要单独打开,在Clock Configuration里找到MCO1,选择PLLCLK并保证输出25MHz,这个时钟最终送到LAN8720A作为参考源。
使用时需注意,如果PHY芯片不是LAN8720A,CubeMX自动生成的PHY初始化代码可能会因寄存器地址不同而失效。例如DP83848的PHY ID寄存器、状态寄存器地址都与LAN8720A有差异,此时应改用“User PHY”选项,自行实现PHY读取函数。
2.3 LwIP中间件参数配置与内存池计算
ETH外设使能后,还要在Middleware and Software Packs中使能LwIP。这里有几个参数直接决定TCP client能否稳定运行,必须提前规划好。
Operation Mode选“RTOS”还是“No RTOS”与主工程是否使用FreeRTOS有关。裸机工程选No RTOS,使用回调函数机制;带OS工程选RTOS,LwIP跑在独立线程中。在STM32CubeMX中使能FreeRTOS时选RTOS模式更合理,否则LwIP的sys_arch层缺适配代码。
常见配置项如下表:
| 参数 | 建议值 | 说明 |
|---|---|---|
| Memory Type | Heap | 使用堆内存管理 |
| MEM_SIZE | 1600 | 内存池总大小,过小会导致pbuf分配失败 |
| TCP_MSS | 1460 | 单段TCP数据最大字节数,对应以太网MTU 1500 |
| TCP_WND | 4 * TCP_MSS | 接收窗口至少4倍MSS,太低影响吞吐 |
| PBUF_POOL_SIZE | 8~16 | 接收缓冲区pbuf数量,视数据量调整 |
| LWIP_NETCONN | Enabled | netconn API开关,TCP client必须打开 |
| LWIP_SOCKET | Disabled | 若使用netconn API,可禁用socket以省内存 |
PBUF_POOL_SIZE乘上PBUF_POOL_BUFSIZE就是接收路径占用的内存总量,F407有192KB RAM,但LwIP默认的内存池是静态数组,不会从FreeRTOS的堆里分。修改这些参数后CubeMX会重新生成lwipopts.h,手动改过这个文件再重新生成代码时改动会被覆盖,建议把自定义配置集中放在Private.h中或记住改完不再回头点LwIP配置页。
生成代码后先编译一版,确认没有报错再进入TCP client编码。常见的一个坑是忘记使能LwIP的netconn API而只打开socket,结果在代码里找不到netconn_connect函数的原型。检查lwipopts.h中LWIP_NETCONN是否为1。
3. TCP Client程序源码设计与连接断开处理
3.1 netconn API与socket API怎么选
LwIP提供三种编程接口:raw API(回调型)、netconn API(线程化)、socket API(标准伯克利套接字)。F407作为TCP client主动连接服务器,裸机环境下用netconn API最省心。raw API需要实现各种回调函数,状态机复杂且排错不直观;socket API在LwIP上功能完整但依赖文件描述符表,内存开销大。
netconn API的核心概念是连接句柄struct netconn,配合netconn_new、netconn_connect、netconn_write、netconn_recv这些函数,调用过程接近阻塞式socket。它在内部自动处理pbuf分配和协议栈锁,不需要用户关心底层细节。对于需要周期上报数据的场景,把TCP client逻辑封装成独立函数,在主循环中定时调用即可。
下面是在F407上基于netconn API建立TCP client连接并发送数据的最小代码结构,不依赖操作系统:
static struct netconn *tcp_client_conn; // 建立TCP连接到指定服务器 int8_t tcp_client_connect(uint32_t server_ip, uint16_t server_port) { ip_addr_t server_addr; // 将32位整数形式的IP地址填入ip_addr_t结构体 IP_ADDR4(&server_addr, (server_ip >> 24) & 0xFF, (server_ip >> 16) & 0xFF, (server_ip >> 8) & 0xFF, server_ip & 0xFF); // 创建TCP连接对象 tcp_client_conn = netconn_new(NETCONN_TCP); if (tcp_client_conn == NULL) { return -1; // 创建失败,通常是内存不足 } // 向服务器发起连接请求(阻塞模式,内部带超时) err_t err = netconn_connect(tcp_client_conn, &server_addr, server_port); if (err != ERR_OK) { netconn_delete(tcp_client_conn); tcp_client_conn = NULL; return -2; // 连接失败 } // 可选:设置发送超时和接收超时 netconn_set_sendtimeout(tcp_client_conn, 2000); netconn_set_recvtimeout(tcp_client_conn, 5000); return 0; } // 发送一组数据到已连接的服务器 int8_t tcp_client_send(uint8_t *data, uint16_t len) { if (tcp_client_conn == NULL) { return -1; // 连接尚未建立 } err_t err = netconn_write(tcp_client_conn, data, len, NETCONN_COPY); if (err != ERR_OK) { // 写失败说明连接可能已断开 tcp_client_close(); return -2; } return 0; } // 关闭TCP连接并释放资源 void tcp_client_close(void) { if (tcp_client_conn != NULL) { netconn_close(tcp_client_conn); netconn_delete(tcp_client_conn); tcp_client_conn = NULL; } }这段代码有几个设计点需要注意。netconn_new的NETCONN_TCP参数表示创建一个TCP类型的连接对象,返回的指针保存在全局变量中,方便发送和关闭函数共用。IP_ADDR4这个宏是CubeMX生成代码中常用的IPv4地址填充方式,按A.B.C.D的顺序拆分整数地址,避免手动复制ip_addr_t结构体字段。
netconn_write的最后一个参数NETCONN_COPY告诉协议栈拷贝一份数据到内部pbuf中再发送。如果传入的是临时缓冲区、调用结束后就要释放,必须用NETCONN_COPY;如果数据在函数返回后仍然有效且不希望多拷一次,可以用NETCONN_NOCOPY节省一次内存复制,但要确保数据在发送完成前不被改写,否则会出现脏数据。初学者一律用COPY模式,吞吐量要求高时再改成NOCOPY并搭配信号量完成握手。
3.2 接收数据处理与断线重连的完整闭环
TCP client做数据上报的场景,除了主动发送,还经常需要接收服务器下发的命令或配置。netconn_recv的用法与socket的recv类似,但收到的是一个netbuf结构体,里面可能包含一个或多个pbuf:
// 接收并处理服务器下发数据,在主循环中周期性调用 int8_t tcp_client_poll_recv(void) { struct netbuf *rx_buf = NULL; // 尝试接收数据,recvtimeout为0时表示非阻塞模式 err_t err = netconn_recv(tcp_client_conn, &rx_buf); if (err != ERR_OK) { return 0; // 没有数据或超时 } // 从netbuf中取出用户数据区 void *data_ptr; u16_t data_len; netbuf_data(rx_buf, &data_ptr, &data_len); // 在这里处理收到的一帧指令,data_ptr指向数据起始地址 process_server_command((uint8_t *)data_ptr, data_len); // 释放netbuf,这一行漏掉会直接导致内存池耗尽 netbuf_delete(rx_buf); return data_len; } // 断线重连:每次主循环检查一次连接状态 void tcp_client_keepalive(uint32_t server_ip, uint16_t server_port) { if (tcp_client_conn == NULL) { // 连接指针为空,说明之前已释放,尝试重新建立连接 tcp_client_connect(server_ip, server_port); return; } // 主动探测连接是否仍然有效 // 发送一个0字节数据,若连接断开会返回错误 err_t err = netconn_write(tcp_client_conn, NULL, 0, NETCONN_COPY); if (err != ERR_OK) { tcp_client_close(); } }netbuf_recv返回的data_ptr直接指向LwIP内部pbuf的数据区,处理完必须调用netbuf_delete归还,否则pbuf池会越用越少,最终表现为连接正常但接收不到新数据。如果一次recv的数据小于一帧完整指令,需要自行做粘包处理,把剩余数据暂存到用户缓冲区,等下一帧到达再拼接。
断线重连是TCP client场景中被问到最多的点。一个可靠的思路是:每次发送前先检查netconn的写操作返回值,写失败说明连接已断开,立即释放原连接对象,把tcp_client_conn置NULL,然后主循环看到NULL就自动重新connect。这样避免在PHY链路恢复后客户端还傻等一个已经死掉的socket。
3.3 内存管理:避免频繁连接断开造成内存碎片
LwIP的netconn接口在每次连接建立时动态分配TCP控制块、PCB结构体、发送/接收缓冲区,断开时释放。如果应用层每秒重连一次,长时间运行会导致内存碎片累积,最终netconn_new返回NULL但系统RAM还剩大量空间。
降低碎片化的常用做法是:客户端连接成功后不轻易断开,通过应用层心跳包维持连接。服务器主动关闭连接时,客户端不要立刻重连,等待1~3秒退避。具体实现可以在keepalive函数中加一个时间戳,只有距离上次断开超过设定间隔才触发重连,避免服务器还在重启时客户端就疯狂撞门。
另一个做法是预先创建netconn对象并复用,断开后不清除指针,只调用netconn_close。但netconn_close后能否再次connect取决于LwIP版本,F407的LwIP 2.x支持关闭后重新connect同一对象,旧版本必须delete后重建。为兼容性考虑最稳妥的方案是delete + 延迟,这也是多数开源modbus TCP从机代码的实际写法。
4. ETH传输中断与PHY芯片调试的关键细节
4.1 发送失败与PHY状态不对的排查顺序
实际调试中以太网部分报错最多的一个点,是控制台周期性出现eth transmit frame faild这样的错误,伴随的现象是TCP client连接不上服务器,或者连上后发不了数据。这个错误的根源多数不在MAC,而在PHY状态异常。
遇到这类问题,按顺序查三件事。第一,确认PHY芯片的链接状态寄存器是否表示链路up。LAN8720A的寄存器1(BCSR)位2是link status,值为1表示链路正常。用一个简单的调试函数先打印这个寄存器值:
// 读取LAN8720A寄存器1,检查bit2链路状态 uint16_t lan8720_link_status(void) { uint16_t reg_val = 0; // 通过HAL的MDIO接口读取PHY寄存器 HAL_ETH_ReadPHYRegister(&heth, PHY_ADDR, 1, ®_val); // 返回值bit2为1表示物理链路已建立 return (reg_val & 0x0004) >> 2; }如果这个函数返回0,说明PHY根本没感知到网线连接或对端设备,此时排查网线、对端交换机端口、PHY供电。特别常见的情况是PHY芯片需要独立稳压源,LAN8720A的VDDCR引脚如果悬空,芯片会进入低功耗模式,MDIO通信正常但物理链路起不来,表现为link status始终为0。
第二个排查点是REF_CLK时钟。用示波器量PHY的CLKOUT引脚,或者F407的PA1引脚,确认有50MHz方波。没有时钟时PHY完全无法工作,但CubeMX初始化代码不会报错,只会在后续收发时表现为全错。
第三个排查点是RMII引脚配置错误。有的开发板为了方便布线,把TX_EN或TXD0改到其他引脚,此时必须手动修改CubeMX的引脚分配,PANEL上标注的引脚未必与芯片默认映射一致。
4.2 HAL_ETH_Transmit_Frame的返回值与重传策略
CubeMX生成的代码中,LwIP底层通过HAL_ETH_Transmit_Frame发送数据帧。这个函数返回HAL_OK代表数据已提交给MAC发送FIFO,并不代表对端收到了。F407的MAC发送FIFO只有2KB,当上层TCP短时间内塞入多帧,FIFO满时该函数会返回HAL_ERROR或直接卡住,这是eth transmit frame faild报错的另一个来源,与PHY无关。
遇到发送拥塞,常见的处理是检查发送描述符是否被占用。HAL库使用DMA描述符链表管理发送缓冲,每个描述符有own bit标记是否空闲。在HAL_ETH_GetTxDataFreeDescNum中可以看到当前空闲描述符数量。TCP源码层面如果出现连续发不出去的情况,应在应用层增加重传计数,超过阈值就断开重连,而不是无限等待。
LwIP本身的TCP重传机制会处理丢包,但前提是网卡底层不丢弃数据。若MAC层报告发送失败,TCP协议栈并不知道,它只会一直等待ACK超时,表现就是连接挂着但数据收发停滞。这种情况下主动重启链路更快。建议在LWIP网卡发送回调的error分支里加一个全局标志位,主循环发现该标志置位就执行PHY软复位和重新初始化。
4.3 网口变压器与LAN8720A的常见硬件误区
LAN8720A是RMII接口的PHY,其TXD/RXD引脚可以直接连接带网络变压器的RJ45座子,也可以外接分离式变压器。很多开发板为了省成本使用不带变压器的RJ45,这时必须外接网络变压器芯片(如HR911105A内置变压器则不需要额外处理),否则信号幅度不够,出现“能识别到PHY但ping不通或经常丢包”的情况。
硬件上另一个容易忽略的细节是LED引脚。LAN8720A的LED0和LED1不仅指示Link/Activity,还兼任PHY地址配置。LED0对应PHYAD0在复位时被采样,如果外部上拉,PHY地址会变成1,与CubeMX的0x00不匹配,MDIO通信异常但PHY本身工作正常。碰到怎么调软件都没法读写PHY寄存器的情况,先查LED引脚的复位电平和PHY地址。
5. 用Wireshark验证TCP client行为与抓包定位
以太网开发有一个远优于串口调试的特点:可以用抓包工具直接观察协议交互。TCP client在F407上跑通,最直接的验证方式不是看单片机日志,而是让PC上运行的Wireshark监听网卡,观察TCP三次握手和数据传输过程。
将F407的网口与电脑直连,或通过交换机连接。电脑上先运行一个TCP服务器程序,监听一个固定端口,然后用Wireshark选择对应的网卡开始抓包。F407上电执行tcp_client_connect时,Wireshark应能立即捕获到SYN包的发送。注意观察三个地方。
第一,SYN包中的源IP是否为CubeMX中配置的静态IP地址。若源IP为0.0.0.0或169.254.x.x,说明lwIP初始化时静态地址配置未生效,检查lwip.c中的IP_ADDR0宏定义。F407使用netconn API连接时目标IP是手动填入代码的,但本机地址由lwip配置决定,两者不冲突但必须确保在同一网段。
第二,服务器确认收到的客户端数据内容是否与代码中netconn_write的数据一致。若数据内容错乱,优先怀疑PHY的RMII接口TXD0/TXD1是否接反,TX_EN是否接错,这类硬件错误会导致字节顺序错位但CRC校验仍然通过(因为MAC和PHY之间的MII接口不携带CRC信息)。
第三,观察TCP连接的保活与断开行为。服务器主动断开后,F407侧应能感知到连接关闭并触发重连逻辑。此时Wireshark会看到客户端重新发SYN包。若没有任何反应,检查tcp_client_keepalive中的轮询间隔是否过长,或者netconn_close误删除后没有及时重新建连。
直连场景下,电脑网卡常使用DHCP自动获取IP,但F407的lwIP配置的是静态IP。两者不同网段无法通信。建议把PC网卡手动设置为与F407同网段的IP,例如F407为192.168.1.30,PC设为192.168.1.10,掩码255.255.255.0,然后关闭防火墙或对公网/专用网络放行相应的TCP端口。
直连时还容易遇到网卡自适应问题。F407的RMII接口运行在100Mbps或10Mbps由PHY自动协商决定,但部分老旧PC网卡可能协商失败导致链路起不来。观察Wireshark最底部的PHY链路状态,或直接看开发板RJ45座子的Link灯,灯亮再抓包,不亮就先解决物理链路问题,节省排查时间。
TCP client若需要长时间稳定运行,建一个定时器定期发送心跳帧,服务器据此判断设备是否在线。F407端的心跳间隔建议30~60秒,发送一个固定长度的数据包,携带设备ID和当前时间戳。服务器收到心跳后回复一个确认帧,客户端用netconn_recv接收到确认就认为链路正常,连续N次没有收到确认才主动断开重连,这种双向校验比单方面发送可靠得多。
本文还有配套的精品资源,点击获取