STM32H743以太网通讯实战:三种PHY兼容与LWIP裸机实现 简介面向STM32H743嵌入式开发者的以太网通信工程包基于CubeMX生成的裸机代码重点解决YT8512C、LAN8742、LAN8720三种PHY芯片的适配问题并集成LWIP协议栈支持TCP客户端、TCP服务器、UDP三种通讯模式适合工业网关、数据采集等需要快速搭建有线网络的项目也适合用于学习STM32H7以太网底层驱动和LWIP移植。资源共1851个文件压缩包约165.51MB以709个C源代码文件、396个头文件为主体另含启动文件、链接脚本、库文件、工程配置以及各类编译中间文件可免去首次编译的部分等待便于直接加载工程进行调试。代码涉及MII/RMII接口配置、PHY芯片寄存器操作、时钟与链接速度调整、LWIP移植及TCP/UDP事件处理等细节对常见PHY芯片的差异做了适配可有效降低调试门槛。目前已有895人学习下载对从事嵌入式网络开发或LWIP协议栈应用的学习者具有不错的参考价值。 最近在做一块基于STM32H743的板卡网络通信这块活让我折腾了整整三天。CubeMX生成裸机工程、适配YT8512C/LAN8742/LAN8720三种PHY芯片、再在底层把TCP客户端、TCP服务器、UDP三种通讯模式全部打通中间踩了不少坑也积累了一套可以直接抄作业的方案。这篇把完整思路、关键代码和排错经验整理出来给后面要做H743以太网通讯的朋友省点弯路。无论你是刚开始接触STM32以太网还是已经在用CubeMX但被PHY芯片搞到头大这篇都值得看完。1. 项目拆解三种PHY兼容和裸机通讯这事的核心难点1.1 这个需求到底在解决什么STM32H743内置了10/100M以太网MAC但MAC不能直接接网线外面必须挂一颗PHY物理层芯片负责编码、解码、电平转换这些脏活累活。问题是PHY芯片厂家一大堆寄存器定义虽然大体遵循IEEE 802.3标准但在地址、复位时序、自协商行为上各有脾气。CubeMX默认只对特定PHY配置做生成直接把CubeMX默认的LAN8742工程拿去接YT8512C或LAN8720最常见的现象就是HAL_ETH_Init卡住、Link状态永远Down、网络调不通。原因基本就两个PHY地址不匹配或者自协商流程没走通。更麻烦的是项目里可能要兼容不同批次的板卡这批用YT8512C下批换LAN8720如果代码里把PHY信息写死每次换料都要改代码重新编译。这块改造的核心就是把PHY相关操作抽象出来让一套代码能自动适配三种PHY。1.2 三种PHY的核心差异三颗芯片我分别说明一下。YT8512C是裕太微的百兆PHY国产芯片性价比高近期很多国产板卡都在用。默认PHY地址一般是0x00但在某些板子上硬件设计师会把ADDR引脚上拉改成其他地址所以不能默认它一定是0。LAN8742和LAN8720都是Microchip的PHY前者是标准RMII接口后者因为把CRS_DV和RX_ER做了合并处理硬件设计上略有不同。它们俩的PHY ID寄存器读出来居然是一样的ID10x0007ID20x020F这意味着光靠读ID没法区分这两颗芯片必须靠硬件地址或者板卡配置来区分。另外在RMII信号定义上LAN8720没有单独的RX_ER引脚输出而YT8512C和LAN8742是有的。这意味着同样的STM32 RMII引脚配置在不同PHY上硬件连接方式会不一样软件上遇到的现象也不同。1.3 裸机模式下的设计约束CubeMX生成以太网工程时LWIP协议栈可以选择带RTOS或者裸机NO_SYS。裸机模式下关键约束有两个。第一LWIP进入NO_SYS模式后netconn和socket API基本用不了只能用raw API也就是回调函数那一套。很多第一次用的人在这里卡住以为和带系统一样能直接调用netconn_connect实际编译就报错。第二裸机模式下没有线程协议栈靠主循环不断轮询驱动。在main的while(1)里要反复调用MX_LWIP_Process()让LWIP有机会处理接收数据、发送数据、维护TCP定时器。如果主循环被其他耗时操作占住网络延迟会明显变大甚至触发TCP超时重传。2. CubeMX配置RMII时钟、PHY地址和LWIP裸机参数2.1 RMII时钟链路怎么配才稳以太网RMII接口要求50MHz的REF_CLK参考时钟。这颗时钟的来源有三种常见方案外部50M有源晶振、STM32的MCO2引脚输出50MHz、或者由PHY芯片自身产生。我在H743上用的方案是MCO2输出。External 25MHz晶振给STM32提供HSE系统时钟跑到480MHzPLL1Q输出100MHz再2分频得到50MHz送到MCO2引脚PC9再连接到PHY的REF_CLK和STM32的ETH_RMII_REF_CLK引脚PA1。在CubeMX的Clock Configuration里把MCO2选成PLL1Q/2即可引脚分配页面会自动把PC9复用为MCO2。注意PA1必须被复用为ETH_RMII_REF_CLK如果引脚分配里没自动配手动补上。提醒一下如果使用的开发板没有把PC9和PA1以及PHY的REF_CLK连在一起而是用了外部50M有源晶振那MCO2就不用配置。动手前一定先看原理图搞清楚时钟源是谁提供的不然软件怎么配都是白搭。2.2 以太网外设参数与PHY地址CubeMX里打开Connectivity - ETHMode选择RMII。Parameters页面需要关注这几个参数。PHY Address这一项很关键。CubeMX默认生成的是0如果板子上LAN8720的PHYAD0引脚拉了高电平实际地址是1那HAL_ETH_Init直接过不了自协商初始化失败。这个地址在生成的stm32h7xx_hal_conf.h里对应ETH_PHY_ADDRESS宏也可以在CubeMX图形界面里直接改。MAC地址建议手动指定一个比如02:00:11:22:33:44。第一个字节最低位要置0单播地址最高位保证不是广播02开头是本地管理地址避免和全球唯一MAC冲突这在局域网里很实用。2.3 LWIP裸机参数选择打开Middleware - LWIPMode选择Disabled旁边的选项后关键是把RTOS那一栏选成No RTOS对应NO_SYS1。之后在Key Options里确认LWIP_NETCONN和LWIP_SOCKET这两个选项默认是0只有这样才能保证裸机模式一致。IP地址配置有两种选择静态IP最简单适合板卡和设备直连调试DHCP也可以开但要确保路由器能分配地址。开发阶段我建议用静态IP省得每次都去路由器后台查地址。其他参数比如MEM_SIZE默认的1600左右就够用PBUF池大小也保持默认等跑通之后再按实际业务调整。CubeMX生成完代码后lwip.c里的MX_LWIP_Init()会完成协议栈初始化和网卡注册main函数里再MX_LWIP_Init()一次即可。3. PHY兼容层改造地址识别、Link检测与自协商处理3.1 用一段代码把所有PHY自动识别出来既然三种PHY地址不固定最简单粗暴的兼容办法就是上电后扫描一遍常见地址读出PHY ID寄存器确认哪颗芯片在哪个地址上。MDIO读操作对不存在的地址不会造成硬件损伤最多返回0xFFFF所以遍历扫描是安全的。我封装了一个扫描函数。/* bsp_phy.c */ #include main.h #define PHY_REG_BCR 0x00U /* Basic Control Register */ #define PHY_REG_BSR 0x01U /* Basic Status Register */ #define PHY_REG_ID1 0x02U /* PHY ID High */ #define PHY_REG_ID2 0x03U /* PHY ID Low */ extern ETH_HandleTypeDef heth; uint8_t phy_scan_address(uint8_t *addr, uint16_t *id1, uint16_t *id2) { uint8_t candidates[] {0x00U, 0x01U, 0x03U, 0x1FU}; uint16_t r1, r2; for (uint8_t i 0; i sizeof(candidates); i) { heth.Init.PhyAddress candidates[i]; if (HAL_ETH_ReadPHYRegister(heth, PHY_REG_ID1, r1) ! HAL_OK) { continue; } if (HAL_ETH_ReadPHYRegister(heth, PHY_REG_ID2, r2) ! HAL_OK) { continue; } if ((r1 ! 0xFFFFU) (r1 ! 0x0000U)) { *addr candidates[i]; *id1 r1; *id2 r2; return 1; } } return 0; }扫描得到的id1和id2对于LAN8720和LAN8742来说都是0x0007和0x020FYT8512C则会返回另一组值。实际调试时先把这两个变量打印出来记录一下当前板子的实际值。有一点值得说明我这段代码在运行时直接改heth.Init.PhyAddress并不会破坏MAC的初始化结果因为PHY地址只在MDIO读写时生效不影响已经配好的MAC。但换地址后不要重新调用HAL_ETH_Init否则会重新走一遍自协商流程。3.2 PHY地址不一致时HAL_ETH_Init直接失败的坑在HAL_ETH_Init内部HAL会向PHY寄存器0写入自协商启动命令然后等待寄存器1的自协商完成位。这个流程依赖PHY地址正确如果地址不对读回来的寄存器值是0xFFFF自协商完成位永远等不到初始化就会超时返回错误。所以顺序很重要先用CubeMX的默认地址通常是0把代码烧进去如果HAL_ETH_Init报错第一步不是查硬件而是确认板子上PHY的实际地址。最容易出错的是LAN8720它的PHYAD0引脚如果上拉地址是1。很多开发板原理图上这颗引脚默认下拉地址是0但你自己画的板子不一定。MDC时钟频率也有影响。HAL里MDCClockRange可以选择分频系数如果MDC频率太高比如超过2.5MHz部分PHY的MDIO接口会不稳定读回来的值就是乱码。H743主频高APB时钟也高尽量选分频系数大一点的档位比如ETH_MDC_CLOCK_CK_256这种稳定优先。3.3 自协商和Link状态检测要处理好的细节自协商让PHY自动选择10M还是100M、半双工还是全双工一般不用软件干预。但几种PHY对复位时序的敏感度不同LAN8720上电后需要较长时间才能完成内部初始化YT8512C也有类似问题。稳妥的做法是在HAL_ETH_Init之前先拉一次PHY复位引脚如果有硬件复位脚然后延时至少100ms再初始化。如果硬件没有单独接PHY复位脚就在上电后主函数里加一个HAL_Delay(200)等PHY稳定。Link状态检测在HAL里是读PHY寄存器1的bit2。这个位为1表示链路已连接。LWIP底层的ethernetif_poll函数会周期性做这个检测PHY地址正确后Link状态检测自然就正常。实际测试中网线插上后Link状态翻转大概需要1到2秒这是PHY硬件检测到协商完成的正常时间不是程序卡住。如果等了很久状态仍是Down按第5章的排查思路处理。4. 通讯层实现TCP客户端、TCP服务器、UDP一键切换4.1 裸机lwIP的主循环轮询机制CubeMX生成LWIP裸机工程后main函数里长这样。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ETH_Init(); MX_LWIP_Init(); user_eth_mode_init(); while (1) { MX_LWIP_Process(); user_eth_mode_poll(); } }MX_LWIP_Process内部会调用ethernetif_poll去DMA接收描述符里取数据包交给LWIP协议栈处理同时调用sys_check_timeouts检查TCP定时器。裸机下这个函数必须在主循环高频执行不能放在被阻塞的代码段里。如果主循环里有耗时操作比如Flash擦写、屏幕刷新网络性能会肉眼可见地下降。解决办法要么缩短耗时操作要么把轮询提到中断里做但中断里处理协议栈对裸机来说容易引入重入问题我更建议保持轮询把主循环做成轻量状态机。4.2 TCP服务器模式实现TCP服务器模式适合设备被动等待上位机连接比如数据采集设备等PC端软件来读取数据。使用raw API实现完整代码如下。/* bsp_eth_mode.c */ #include bsp_eth_mode.h #include lwip/tcp.h static struct tcp_pcb *server_pcb; static err_t tcp_srv_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p ! NULL) { tcp_recved(pcb, p-len); /* 这里是收到数据后的回调可以按业务处理 */ tcp_write(pcb, p-payload, p-len, 1); pbuf_free(p); } else { /* 对端关闭连接 */ tcp_close(pcb); server_pcb NULL; } return ERR_OK; } static err_t tcp_srv_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, tcp_srv_recv); return ERR_OK; } static void tcp_server_init(void) { server_pcb tcp_new(); if (server_pcb ! NULL) { err_t err tcp_bind(server_pcb, IP_ADDR_ANY, 8080); if (err ERR_OK) { server_pcb tcp_listen(server_pcb); tcp_accept(server_pcb, tcp_srv_accept); } } }核心流程是tcp_new创建PCBtcp_bind绑定端口tcp_listen进入监听状态tcp_accept注册接受连接的回调。有客户端连进来时tcp_srv_accept被调用在回调里注册接收回调之后数据到达就会走进tcp_srv_recv。这里回调里的tcp_recved必须调用它告诉LWIP协议栈数据已经被应用层消费了可以释放接收窗口。漏掉这一句客户端发几包数据后就会因为窗口满而卡住。这个坑我当时排查了很久。4.3 TCP客户端模式实现TCP客户端模式适合设备主动连接服务器比如设备上报数据到上位机。/* bsp_eth_mode.c */ #include lwip/tcp.h #include lwip/ip4_addr.h static struct tcp_pcb *client_pcb; static err_t tcp_cli_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p ! NULL) { tcp_recved(pcb, p-len); /* 处理服务器下发的数据 */ pbuf_free(p); } else { tcp_close(pcb); client_pcb NULL; } return ERR_OK; } static err_t tcp_cli_connect(void *arg, struct tcp_pcb *pcb, err_t err) { if (err ERR_OK) { tcp_recv(pcb, tcp_cli_recv); /* 连接建立成功后可以主动发送数据 */ } return ERR_OK; } static void tcp_client_init(void) { ip_addr_t server_ip; client_pcb tcp_new(); if (client_pcb ! NULL) { IP4_ADDR(server_ip, 192, 168, 1, 100); tcp_connect(client_pcb, server_ip, 8080, tcp_cli_connect); } }tcp_connect是异步操作连接是否成功在tcp_cli_connect回调里判断。这里最容易踩的坑是服务器没监听或IP不对时连接会超时超时后会触发错误回调。但在上述简化代码里没有注册错误回调超时后PCB可能泄漏。实际项目建议用tcp_err注册错误处理函数超时或者对端拒绝时清理PCB。static void tcp_cli_error(void *arg, err_t err) { if (client_pcb ! NULL) { tcp_close(client_pcb); client_pcb NULL; } }4.4 UDP收发实现UDP是无连接的不需要建链适合实时性要求高但可以容忍丢包的场景比如设备状态广播、控制指令下发。/* bsp_eth_mode.c */ #include lwip/udp.h static struct udp_pcb *udp_pcb; static void udp_recv_cb(void *arg, struct udp_pcb *upcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { /* 收到UDP数据后回发给源地址 */ udp_sendto(upcb, p, addr, port); pbuf_free(p); } static void udp_init(void) { udp_pcb udp_new(); if (udp_pcb ! NULL) { udp_bind(udp_pcb, IP_ADDR_ANY, 8080); udp_recv(udp_pcb, udp_recv_cb, NULL); } }UDP不用维护连接状态代码比TCP简单很多。注意udp_recv回调里拿到源IP和源端口回数据时用udp_sendto指定目标。每次udp_sendto调用前LWIP会自动分配一个临时端口作为源端口如果不想每次变可以提前用udp_bind固定端口。4.5 底层模式切换怎么设计我将三种模式封装成一个独立的模块通过一个宏切换。/* bsp_eth_mode.h */ #ifndef __BSP_ETH_MODE_H #define __BSP_ETH_MODE_H typedef enum { ETH_MODE_TCP_SERVER 0, ETH_MODE_TCP_CLIENT 1, ETH_MODE_UDP 2 } eth_mode_t; #define ETH_MODE ETH_MODE_TCP_SERVER /* 需要换模式时改上面这一行即可 */ /* #define ETH_MODE ETH_MODE_TCP_CLIENT */ /* #define ETH_MODE ETH_MODE_UDP */ extern void user_eth_mode_init(void); extern void user_eth_mode_poll(void); #endif/* bsp_eth_mode.c */ #include bsp_eth_mode.h static void tcp_server_init(void); static void tcp_client_init(void); static void udp_init(void); void user_eth_mode_init(void) { #if (ETH_MODE ETH_MODE_TCP_SERVER) tcp_server_init(); #elif (ETH_MODE ETH_MODE_TCP_CLIENT) tcp_client_init(); #elif (ETH_MODE ETH_MODE_UDP) udp_init(); #endif } void user_eth_mode_poll(void) { /* 裸机模式下可添加心跳、断线重连等逻辑 */ }这种设计的好处是业务层只需要调用user_eth_mode_init和user_eth_mode_poll两个接口切模式只需改一个宏底层实现完全隔离。如果需要运行时可配置可以把ETH_MODE改成变量配合串口AT指令动态初始化。5. 排错实录Link不上、ping不通、收发卡顿5.1 PHY Link不上的排查顺序Link不上是这类工程最常遇到的问题。我在实际调试中总结了一套固定排查流程。先看PHY地址。在HAL_ETH_Init前后加打印看返回值和PHY寄存器读回值。如果读到的ID1是0xFFFF说明MDIO没有和PHY对上优先查PHY地址和MDIO上拉电阻。MDIO引脚通常需要1.5k到10k的上拉电阻到3.3V如果原理图漏了MDIO通信就是不稳定。再看50MHz REF_CLK。用示波器量PA1或者PHY的REF_CLK引脚必须看到稳定的50MHz方波。如果这里是0Hz或者频率不对后面所有问题都不用看了。之前遇到一块板子外部有源晶振虚焊导致无时钟Link灯死活不亮查了好久才发现。最后看PHY复位。确认PHY的复位引脚在上电后没有被长期拉低。部分板子用MCU的GPIO控制PHY复位需要初始化时先拉低再拉高延时至少10ms再访问PHY。5.2 ping不通但Link状态正常的排查Link已经Up说明物理层通了但ping不通问题多半在MAC或协议栈。先用最简单的方式验证把电脑IP改成和板卡同一网段直接ping。如果ping不通先ping板卡IP看ARP是否解析。用抓包工具看板卡有没有回应ARP请求。如果板卡收不到ARP检查MAC地址是不是全零。有些工程在ethernetif.c的low_level_init里用随机数生成MAC每次重启都在变也会造成ARP缓存混乱。其次查LWIP的IP配置是否和上位机在同一个网段。子网掩码、网关错了也会导致通讯异常这种问题表面上看起来像硬件故障实际就是配置笔误。5.3 TCP收发卡顿和丢包的实战分析裸机模式下TCP收发卡顿八成是主循环调用MX_LWIP_Process()的频率不够。TCP协议栈有重传定时器如果主循环被一个死等延时的HAL_Delay(1000)堵住ACK就发不出去对方自然重传。我在工程里把所有长延时都改成非阻塞状态机效果立竿见影。还有一个比较容易忽略的点是发送缓冲。tcp_write只是把数据拷贝到发送缓冲区并不保证立刻发到网络上。如果连续多次tcp_write而不检查返回值缓冲区满了会返回ERR_MEM。裸机模式下建议每次tcp_write后主动调用tcp_output并在发送回调里检查发送完成。UDP丢包则更宽容一些没有重传机制如果应用层做了应答设计还好否则丢包只能从降低主循环耗时入手。调试工具方面我强烈建议用Wireshark抓包配合板卡打日志。看TCP的三次握手有没有完成看ACK序号是否在正常递增。配合串口打印LWIP的调试信息排查效率会高很多。最后分享一个我自己用着很顺手的技巧本文还有配套的精品资源点击获取