☰
STM32H743以太网多PHY兼容方案:基于lwIP的TCP/UDP模式切换设计
2026/9/25 1:18:29 网站建设 项目流程

简介:面向STM32H743高性能MCU的以太网开发资源,以CubeMX生成的裸机工程为基础,完成YT8512C、LAN8742、LAN8720三种PHY芯片的驱动适配,并支持TCP客户端、TCP服务器、UDP三种通讯模式,适合需要快速搭建STM32H7网络应用的嵌入式开发者。包体共1851个文件,压缩包约165.51MB,以C源码、H头文件为主,包含HAL库驱动、LWIP协议栈配置、MDK工程文件(uvprojx)及编译生成的目标文件、列表文件等,目录结构清晰,便于直接查看修改。已有895人学习使用。资源在CubeMX自动生成代码基础上,针对PHY芯片差异重点调整了MII/RMII配置与时钟参数,同时封装了三种通讯模式的底层选择逻辑,读者可直接对照实际电路选用对应PHY,并结合LWIP接口快速实现数据收发,节省底层驱动调试时间,适合中高级嵌入式开发者参考与二次开发。 最近在调一个基于STM32H743的以太网项目,遇到了一个很现实的问题:同一套核心板在不同产品线上分别用了LAN8720、LAN8742和YT8512C三颗PHY芯片,而CubeMX默认只针对一颗PHY生成驱动,换一颗板子就要重新配工程、改代码、做回归测试,非常被动。所以这次我把PHY识别和TCP/UDP通讯模式做了统一抽象,让三颗PHY全部兼容,并且底层可以一键切换TCP客户端、TCP服务器、UDP三种模式。这篇文章就把完整的改造思路和踩坑过程记录下来,供正在做类似工作的朋友参考。

1. 为什么非要兼容三颗PHY:项目背景与整体改造思路

先说说这个需求是怎么来的。我们的产品线对以太网口的需求是一样的,但不同批次、不同定位的产品在对成本、供货稳定性、温度范围上的要求不一样。LAN8720是老牌高性价比方案,资料多、用量大;LAN8742是ST自家评估板上的常用PHY,CubeMX默认支持最完善;YT8512C则是国产PHY,成本和供货在特定环境下有优势。硬件那边为了灵活选型,这三年把三颗PHY轮着用了一遍,最终导致固件这边必须面对“一块代码跑三颗PHY”的兼容问题。

CubeMX默认生成的裸机工程,在处理PHY时并不会自动识别芯片型号。它会在HAL_ETH_Init()中读取PHY ID,然后根据ID去匹配内置的PHY驱动分支。问题是:同一版本HAL库通常只针对一到两款PHY做了完整适配,老的CubeMX工程默认适配LAN8742,换到LAN8720或YT8512C时,驱动匹配分支可能直接走默认路径,速率协商、link状态检测都会出问题。

我的改造思路分了两层:底层是PHY驱动适配层,负责识别PHY型号、复位、协商、link检测,把三颗PHY的差异全部封装在几个函数里;上层是通讯模式选择层,负责TCP客户端、TCP服务器、UDP三种模式的配置宏切换。两层互不干扰,换PHY只改底层,换通讯模式只改宏,即使以后加第四颗PHY,也不至于把整个工程翻个底朝天。

做这个改造之前,我其实纠结过要不要直接给STM32H743上RTOS然后用lwIP的multi-thread模式,但项目对实时性要求不能有调度抖动,而且整个产品代码只要一个裸机循环就能跑完,所以最终保留了lwIP的NO_SYS模式。这个决定在后面做TCP/UDP模式抽象时带来了一些约束,但也有好处:代码逻辑全在主循环里,调试时单步跟踪特别直观。

2. CubeMX生成裸机以太网工程:配置细节与关键坑点

2.1 引脚、时钟与RMII的底层配置

STM32H743的以太网MAC支持RMII和MII两种外部接口,我的板子上用的是RMII,因为它只需要7根信号线,比MII少了将近一半。RMII模式下,关键信号包括:ETH_RMII_REF_CLK、ETH_RMII_CRS_DV、ETH_RMII_RXD0、ETH_RMII_RXD1、ETH_RMII_TX_EN、ETH_RMII_TXD0、ETH_RMII_TXD1,再加上MDIO和MDC两根管理接口线。

这里第一个容易踩的坑是REF_CLK的来源。RMII要求50MHz的参考时钟,这颗时钟可以由MCU自己产生,也可以由PHY提供,取决于板子硬件设计。常见做法有外接50MHz有源晶振给PHY,PHY再把REF_CLK回传给MCU;也有用MCU的MCO1引脚输出50MHz给PHY。用CubeMX配置时,如果板子上PHY的REF_CLK输入来自外部晶振、PHY再回传REF_CLK给MCU,那么在CubeMX的ETH配置里就要把RMII的时钟源设成外部PHY时钟;如果MCU侧MCO输出50MHz给PHY,则需要在RCC配置里先打开MCO1并设置分频系数。这个配置一旦和硬件对不上,现象非常诡异——PHY ID能读到,link也能起来,但收发的数据全是乱的,或者干脆发不出去。

CubeMX里选择RMII之后,系统会自动把对应引脚复用拉出来,但在实际工程里我还手动检查过一遍:PA1是ETH_RMII_REF_CLK,PA2是MDIO,PC1是MDC,PA7是CRS_DV,PC4和PC5是RXD0/RXD1,PG11是TX_EN,PB11和PB12是TXD0/TXD1。不同封装的H743引脚映射略有差异,CubeMX生成的MX_GPIO_Init()如果和你板子实际走线不一样,板卡是点不亮的。

2.2 lwIP保持NO_SYS模式

裸机工程里要跑TCP/UDP,唯一现实可行的方案就是lwIP的NO_SYS模式。CubeMX在Middleware栏开启lwIP后,默认就是NO_SYS=1,不需要额外开系统时钟节拍或创建线程。但有一个细节必须注意:NO_SYS模式下,lwIP的tcpip_thread是不存在的,所有协议栈处理都要在主循环里调用MX_LWIP_Process()来驱动,这个函数内部会调用sys_check_timeouts()处理TCP超时重传,并且轮询网络接口的数据收发。

CubeMX生成的ethernetif.c会把网卡驱动接入lwIP,它实现了low_level_init()、low_level_output()、low_level_input()这几个关键函数。其中low_level_init()里会调用HAL_ETH_Init(),之后再HAL_ETH_Start()。也就是说,PHY相关的初始化在这个阶段就完成了。如果PHY驱动适配不对,HAL_ETH_ReadPHYRegister可能返回超时错误,这会直接导致HAL_ETH_Init失败,网络接口始终无法up。

2.3 最容易被忽视的RAM区问题

STM32H743和F4系列有个非常大的区别:ETH的DMA无法访问DTCM RAM。H7系列内部的DTCM和ITCM是紧耦合存储器,CPU访问速度快,但DMA控制器访问不到。CubeMX默认生成的链接脚本,如果工程基地址是从0x20000000开始,变量很可能被分配到DTCM。此时以太网描述符和收发缓冲区一旦被分配到DTCM,DMA传输直接失败,表现出来就是网口link能起来,但收发的数据总是丢、ping不通、lwIP的Netif状态异常。

解决办法是把以太网描述符和缓冲区显式放到AXI SRAM,也就是0x24000000地址区域。我在实际工程里,在ethernetif.c文件里这样声明:

__ALIGN_BEGIN ETH_DMADescTypeDef DMARxDscrTab[ETH_RX_DESC_CNT] __ALIGN_END __attribute__((section(".ARM.__at_0x24000000"))); __ALIGN_BEGIN ETH_DMADescTypeDef DMATxDscrTab[ETH_TX_DESC_CNT] __ALIGN_END __attribute__((section(".ARM.__at_0x24000000"))); __ALIGN_BEGIN uint8_t Rx_Buff[ETH_RX_DESC_CNT][ETH_RX_BUF_SIZE] __ALIGN_END __attribute__((section(".ARM.__at_0x24000000"))); __ALIGN_BEGIN uint8_t Tx_Buff[ETH_TX_DESC_CNT][ETH_TX_BUF_SIZE] __ALIGN_END __attribute__((section(".ARM.__at_0x24000000")));

Keil下用section(".ARM.__at_0x24000000")可以强制把数组放到AXI SRAM起始地址,IAR或GCC下写法略有不同,但思路一样。判断是不是这个坑的方法也很简单:在调试器里看heth.RxDesc指向的地址,如果落在0x20000000到0x2001FFFF之间,基本就是中招了。

3. 三颗PHY的寄存器差异:识别、复位与协商的兼容层

3.1 三颗PHY的ID和地址差异

所有标准PHY芯片都遵循IEEE 802.3定义的寄存器规范,寄存器0是控制寄存器,寄存器1是状态寄存器,寄存器2和3是PHY ID寄存器。所以底层逻辑不需要为每颗PHY单独写一套MDIO通信代码,差异主要集中在ID值、地址、复位时序和某些扩展寄存器上。

PHY芯片寄存器2/3组合ID默认PHY地址备注
LAN87200x0007C0F10老牌高性价比,RMII不支持MII
LAN87420x0007C1320ST开发板常用,CubeMX完善支持
YT8512C0x0000DF01由PHYAD引脚决定,常见为0国产芯片,需实测确认ID

这里有个细节:大部分PHY芯片的地址可以由外部引脚上下拉配置。LAN8720和LAN8742的默认地址通常是0,YT8512C则有几个配置引脚,不同模组出厂默认值可能不一样。如果直接写死PHY地址0,遇到YT8512C地址不是0的情况,读ID就会失败。所以我在兼容层里做了一个地址扫描,初始化时从1到31依次尝试读寄存器2,只要读回来的值不是0x0000和0xFFFF,就认为该地址上存在PHY。这个方法很笨,但非常有效,避免了硬件改版时还要改固件地址。

PHY ID的识别函数这样写:

uint32_t phy_detect_id(uint8_t phy_addr) { uint16_t idr1 = 0, idr2 = 0; if (HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0x02, &idr1) != HAL_OK) { return 0; } if (HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0x03, &idr2) != HAL_OK) { return 0; } return (((uint32_t)idr1) << 16) | idr2; }

初始化的主流程里,我先扫描地址,再读ID,然后根据ID去调用对应的PHY初始化函数。这个流程保证了无论板子上焊的是哪一颗PHY,固件都能自己适配,不需要在编译时指定。

3.2 复位时序与软件复位

三颗PHY的复位逻辑也有差异。LAN8720和LAN8742都有NRST引脚,硬件复位需要拉低至少一段时间再释放,这个时序由硬件电路决定,固件里只要确保上电后延时足够。YT8512C在部分模组上把复位引脚直接悬空或者引出也不方便控制,这种情况下就只能用软件复位。

软件复位通过寄存器0的bit15实现,写1后PHY会执行内部复位,复位期间寄存器读出来可能无效,所以复位后需要加延时。我建议在HAL_ETH_Init()之前至少延时100ms,让PHY完成上电和内部复位。很多人在换PHY后遇到“读ID正常但协商不成功”的问题,往往就是没有给PHY足够的复位恢复时间。

实际使用中,我封装了一个phy_reset()函数,先尝试通过GPIO控制硬件复位,如果硬件复位引脚没有配置(PHY_RST_GPIO_Port为NULL),则回退到软件复位:

void phy_reset(void) { uint16_t bcr = 0; if (phy_rst_gpio_port != NULL) { HAL_GPIO_WritePin(phy_rst_gpio_port, phy_rst_pin, GPIO_PIN_RESET); HAL_Delay(50); HAL_GPIO_WritePin(phy_rst_gpio_port, phy_rst_pin, GPIO_PIN_SET); HAL_Delay(50); } else { HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0x00, &bcr); bcr |= 0x8000; HAL_ETH_WritePHYRegister(&heth, phy_addr, 0x00, bcr); HAL_Delay(100); } }

3.3 速率协商与link检测

三颗PHY在自动协商机制上都符合标准,正常情况下不需要手动干预。但如果现场网络环境不理想,或者对方设备强制百兆半双工,自动协商可能失败。我的做法是先等待自动协商完成,如果超过3秒协商不成功,就将PHY强制配置为100M全双工。

强制百兆全双工的关键是修改寄存器0(BCR)的速度位和双工位,同时关闭自动协商使能位。这个逻辑对三颗PHY通用,因为寄存器0的定义是标准统一的。

uint8_t phy_wait_link(uint32_t timeout_ms) { uint16_t bsr = 0; while (timeout_ms--) { if (HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0x01, &bsr) == HAL_OK) { if (bsr & 0x0004) { return 1; } } HAL_Delay(1); } return 0; } void phy_force_100m_full(void) { uint16_t bcr = 0; HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0x00, &bcr); bcr &= ~0x1000; bcr |= 0x2000; bcr |= 0x0100; HAL_ETH_WritePHYRegister(&heth, phy_addr, 0x00, bcr); }

简单说,状态寄存器BSR的bit2是Link Status位,读1表示链路建立。如果自动协商完成后链路没有建立,就强制执行百兆全双工。这个兜底策略实测对三颗PHY都有效,特别是YT8512C在对接某些老式交换机时很管用。

3.4 YT8512C的特殊处理

YT8512C是这次改造中最费精力的一颗PHY。它本身在寄存器层面兼容LAN8720的大部分行为,但CubeMX老版本HAL库内置的stm32h7xx_hal_eth.c里并没有专门的YT8512C分支。如果你在用比较老的HAL库,要么升级到新版本,要么手动在ETH_PHY_Config()函数里补上YT8512C的ID判断分支。

另外一个容易踩的坑是YT8512C的某些配置寄存器地址和LAN8720不完全一致。比如LAN8720有专门的中断源寄存器,YT8512C的寄存器布局虽然接近,但在扩展配置上可能有差异。如果只是做基础的协商和收发,这个问题不大;但要使用PHY中断检测link变化,就要先读一下YT8512C的数据手册确认中断寄存器地址。我在项目里没有依赖PHY中断,而是用1秒周期的轮询检测link状态,这样反而避免了很多兼容问题。

4. 底层通讯模式抽象:TCP客户端、TCP服务器、UDP的一键切换

4.1 三选一的配置宏

裸机环境下,代码里所有逻辑都是顺序执行的,所以通讯模式的切换不能像RTOS那样动态创建任务,最简单可靠的办法是在编译期用宏定义选择模式。我在工程里新建了一个app_net_config.h,把模式选择和关键参数全部收敛到这个文件里:

#ifndef APP_NET_CONFIG_H #define APP_NET_CONFIG_H #define NET_MODE_TCP_SERVER 0 #define NET_MODE_TCP_CLIENT 1 #define NET_MODE_UDP 2 #ifndef APP_NET_MODE #define APP_NET_MODE NET_MODE_TCP_SERVER #endif #define APP_TCP_LOCAL_PORT 5000 #define APP_TCP_REMOTE_PORT 5000 #define APP_UDP_LOCAL_PORT 6000 #define APP_UDP_REMOTE_PORT 6000 #define APP_TCP_REMOTE_IP "192.168.1.10" #endif

如果要切换通讯模式,只改APP_NET_MODE这个宏,然后重新编译即可。这个设计看起来简单,但实际使用中非常舒服:产线烧录时只需要编译出三种固件,分别对应三种模式,不用维护三套代码。

4.2 统一的应用层接口

我在应用层封装了两个函数,一个是初始化,一个是周期轮询:

void app_net_init(void) { #if (APP_NET_MODE == NET_MODE_TCP_SERVER) tcp_server_init(); #elif (APP_NET_MODE == NET_MODE_TCP_CLIENT) tcp_client_init(); #elif (APP_NET_MODE == NET_MODE_UDP) udp_app_init(); #endif } void app_net_poll(void) { MX_LWIP_Process(); #if (APP_NET_MODE == NET_MODE_TCP_CLIENT) tcp_client_poll(); #endif }

app_net_init()在系统初始化末尾调用,app_net_poll()放在主循环里,每轮循环至少调用一次。这样做的好处是业务层完全不感知底层是TCP还是UDP,只管周期轮询即可。

4.3 TCP服务器的关键逻辑

TCP服务器模式下,核心是建立监听、接受连接、处理接收数据。lwIP在NO_SYS模式下是事件驱动的,所有回调函数都运行在MX_LWIP_Process()的上下文中,所以回调里不能做耗时操作,否则会阻塞协议栈处理。

static struct tcp_pcb *server_pcb; static err_t server_recv_cb(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p != NULL) { tcp_recved(pcb, p->tot_len); pbuf_free(p); } else if (err == ERR_OK) { tcp_close(pcb); } return ERR_OK; } static err_t server_accept_cb(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, server_recv_cb); return ERR_OK; } void tcp_server_init(void) { server_pcb = tcp_new(); tcp_bind(server_pcb, IP_ADDR_ANY, APP_TCP_LOCAL_PORT); server_pcb = tcp_listen(server_pcb); tcp_accept(server_pcb, server_accept_cb); }

这段代码里最容易忽略的是tcp_recved()的调用。lwIP的TCP接收窗口是根据tcp_recved()的调用动态释放的,如果收到数据后不调用它,接收窗口会越来越小,最后客户端会发现发数据越来越慢甚至发不进去。我当时第一次写服务器回调时忘了这一句,结果客户端连续发送几十包后就被卡住了,排查了很久。

4.4 TCP客户端的断线重连机制

TCP客户端模式和服务器模式有一个本质区别:客户端要主动发起连接,而在裸机lwIP中,连接是异步的,不能阻塞等待。连接成功后,tcp_connect回调会被触发;连接失败时,回调会收到错误码。

断线重连是客户端模式最容易忽视的问题。板子在以太网现场可能遇到服务器重启、网线松脱、对端主动断开等情况,如果没有自动重连机制,固件就只能手动复位。我在tcp_client_poll()里实现了一个简单的定时重连:如果当前没有有效连接,就每隔3秒尝试连接一次。

static struct tcp_pcb *client_pcb; static uint32_t last_connect_time; static err_t client_connected_cb(void *arg, struct tcp_pcb *pcb, err_t err) { if (err == ERR_OK) { tcp_recv(pcb, client_recv_cb); } else { tcp_abort(pcb); client_pcb = NULL; } return ERR_OK; } static void tcp_client_connect(void) { ip_addr_t server_ip; client_pcb = tcp_new(); IP4_ADDR(&server_ip, 192, 168, 1, 10); tcp_connect(client_pcb, &server_ip, APP_TCP_REMOTE_PORT, client_connected_cb); } void tcp_client_poll(void) { if (client_pcb == NULL) { if (HAL_GetTick() - last_connect_time > 3000) { last_connect_time = HAL_GetTick(); tcp_client_connect(); } } }

注意tcp_connect失败或连接被对端关闭时,client_pcb指针要置空,否则轮询函数会认为连接还在。这个指针管理在做状态机时特别关键,一旦出现悬空指针,整个网络协议栈都会崩掉。

4.5 UDP的接收回调实现

UDP在lwIP里比TCP简单得多,不需要维护连接状态,绑定端口后设置接收回调即可。回调函数里拿到数据包后,用完必须调用pbuf_free()释放pbuf,否则内存泄漏会逐渐耗尽lwIP的内存池。

static struct udp_pcb *udp_pcb; static void udp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p != NULL) { /* 在这里处理收到的UDP数据 */ pbuf_free(p); } } void udp_app_init(void) { udp_pcb = udp_new(); udp_bind(udp_pcb, IP_ADDR_ANY, APP_UDP_LOCAL_PORT); udp_recv(udp_pcb, udp_recv_cb, NULL); }

UDP发送也很直接,用udp_sendto()指定目标IP和端口即可。我在项目中把UDP模式作为默认的调试模式使用,因为UDP不需要建立连接,上位机发个包就能看到板子的响应,排查链路问题特别快。

5. 实测记录:从link up到数据收发,附完整排查清单

5.1 实测流程与结果

板子回来后,我用三颗PHY分别做了完整的链路测试。第一步先看串口日志里打印的PHY ID,确认识别正确:LAN8720打印0x7C0F1,LAN8742打印0x7C132,YT8512C打印0xDF01。第二步看link状态,三颗PHY接同一个交换机都能在2秒内完成协商。第三步用PC上的网络调试助手测试TCP和UDP收发,TCP客户端和服务器模式下都做了100MB文件传输测试,无丢包;UDP模式下以100包每秒发送,每包128字节,持续发送10万包,接收端零丢失。

5.2 实测中遇到的五个坑

现象可能原因解决办法
PHY ID读出来是0xFFFFPHY地址不对或MDIO时序不对使用PHY地址扫描,确认PHYAD引脚状态
网口link不up复位时序不对或协商失败增加复位延时,协商超时后强制百兆全双工
link up但ping不通描述符或缓冲区在DTCM RAM用section属性把缓冲区放到AXI SRAM
ARP请求有去无回MAC地址全零或配置错误检查CubeMX生成的MAC地址是否生效
TCP连上后收发卡死接收回调里没有调用tcp_recved在收到数据后立即调用tcp_recved

这五个坑里,最让我头疼的是第二个和第三个同时出现的情况。YT8512C这颗PHY在第一次上电时,因为硬件复位引脚没有接,PHY内部状态不确定,读ID偶尔会失败。后来我在HAL_ETH_Init()之前加了一次软件复位加100ms延时,问题才稳定消除。这个细节如果你不做多颗PHY兼容测试,很难发现。

5.3 性能观察与优化空间

实测下来,H743的以太网MAC在RMII百兆模式下,裸机lwIP的TCP吞吐量大约能达到70~80Mbps,UDP吞吐量因为协议栈开销更小,可以跑到接近满速。这个性能对绝大多数工业应用完全够用。如果你需要更高吞吐,可以考虑把lwIP内存池调大,或者把接收描述符数量从默认的4个增加到8个,减少高负载下的丢包概率。

另外还有一个优化点:MX_LWIP_Process()在主循环里的调用频率直接影响网络性能。实测中,主循环一旦有超过10ms的阻塞,TCP吞吐量就会明显下降。所以我在所有业务逻辑里都避免长阻塞操作,UART接收、Flash写入等耗时操作全部改成状态机分片处理。

我在实际使用中还有一个习惯:每次拿到新硬件,第一步先打印PHY ID,确认识别正确后再调上层。这样能把“PHY驱动问题”和“应用逻辑问题”隔离开,排查速度快很多。在工程代码上,我建议把PHY相关信息统一放进一个独立的phy_bsp.c,不要散落在各个模块里。这样后续加新PHY时,扩展起来会顺手很多。

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

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

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

立即咨询