先说个背景:这是 GD32H759 + RT-Thread 工控实战系列的第二篇。上一篇把最小系统、时钟树、串口控制台这些基本盘验证完之后,这一篇集中啃 enet 驱动。很多朋友问,为什么不先把 PWM、ADC、定时器这些外设调完,而是急着碰网络?答案其实很直接:工控设备如果连不上网络,后面上位机、协议解析、远程升级、状态监控这些东西全部无从谈起。在 RT-Thread 这种 RTOS 上,网络远比点灯复杂,它串起了 MAC、DMA、PHY、lwIP、设备驱动框架好几层,任何一个环节没对上,板子就是“能亮灯但进不了网”的废状态。
这篇内容对下面几类人应该有大用:一是手里有 GD32H759 或者同类 GD32H7 板子,想快速把以太网驱动跑起来的嵌入式工程师;二是在 RT-Thread 上做过串口、GPIO 这类简单外设,但第一次碰网络框架的人;三是搞工控整机,想从零搞清楚“为什么我的板子 ping 不通、经常断线、速度不对”的现场工程师。我会把从硬件接线、RT-Thread 组件配置、驱动注册、代码拆解到实际调试踩坑的过程完整写一遍,最后再补一段工控场景下驱动可靠性加固的经验。这不是官方手册搬运,是我实际在这个平台上调 enet 驱动时真正走过的路。
1. 我为什么把 enet 排在工控底板的第二优先级
很多工程师开发 MCU 的习惯是先把 UART 打通,打印日志,等所有外设都调完,最后才碰网络。这个顺序在个人项目、学习 demo 里没问题,但放到工控整机上会吃大亏。工业设备里的网络承担着配置下发、状态采集、远程运维、协议转换这些任务,几乎所有的“设备智能化”都建立在网口上面。串口的调试价值再高,它也很难当产品级的数据通道,尤其遇到对接上位机平台、Modbus TCP、MQTT、OTA 升级这些需求时,网络必须是第一个跑通的核心外设。
在这套 GD32H759 方案里,我把 enet 驱动排在了外设移植清单的前列。GD32H759 这颗芯片本身定位就是高性能、大内存,跑 RT-Thread 和 TCP/IP 协议栈有富余,不把网络打通,这颗芯片的处理器性能和内存优势根本发挥不出来。另一个现实原因是,以太网驱动在整个 BSP 移植里属于难度高、耦合多的模块,越早碰越能暴露出板级设计、时钟树、内存布局这些底层问题。早踩坑,后面给应用层填功能时才不会反复推倒重来。
1.1 从调试配角到数据主通道:网口不是最后接的,是最先通的
如果只说最终目标,就一句话:上电后 PHY 自协商成功,RT-Thread 下 eth0 注册成功,主机能 ping 通,能正常跑 TCP 和 UDP,并且在网线插拔、丢包干扰、异常复位这些现场操作下不会自己挂掉。这个目标的难点不在“能 ping 通一次”,在于稳定性和可恢复性。工控设备经常部署在配电柜、机床旁边,电磁环境复杂,网线也可能被维护人员反复插拔,驱动没有 link down 检测和自恢复机制,现场就会频繁出现“设备死机但看门狗没复位”的诡异故障。
1.2 这一篇的明确范围:先吃透 enet,不展开应用层协议
写之前先把边界说清楚:这一篇集中在 MAC/DMA/PHY 驱动和 RT-Thread 网络框架的衔接上,应用层协议比如 Modbus TCP、MQTT、OTA 这些后续再单独写。弄清楚了底层的收发路径,上面那些协议其实都是水到渠成的事情。很多人一上来就盯着应用层调,结果发现网络时通时不通,最后查到底层驱动根本没有正确对接 RT-Thread 的 eth 设备框架,白白浪费大量时间。
2. 动手前先把 GD32H759 的 MAC、DMA 和 PHY 分工捋清楚
驱动移植最怕的就是一上来就写代码,寄存器没搞清楚,出了问题还得回头翻手册。所以先花一点时间把以太网硬件框架说透,这部分理解了,后面调任何 MCU 的 enet 驱动都能复用。
2.1 MAC、DMA、PHY 各管什么:把网络模块拆成物流仓库
MAC 在 GD32H759 芯片内部,负责以太网帧的封装、CRC 校验、地址过滤、流控这些脏活累活。DMA 是 MAC 的数据搬运工,把内存里准备好的数据搬到 MAC 发送缓冲区,或者把 MAC 收到的数据搬进协议栈能读的缓冲区。PHY 在芯片外部,通过差分线连接网口变压器和 RJ45,负责物理层电平转换、编码解码、自动协商。
打个比方,MAC 是仓库管理员,DMA 是叉车,PHY 是货运车队。叉车把货从仓库搬到月台,车队负责把货运到目的地。调驱动的时候,如果遇到“发不出去、收不到、回包乱”这类问题,第一件事就是定位是哪一环出了问题,而不是盲目改代码。很多时候是 PHY 没协商好,但你在 MAC/DMA 里找半天,永远找不到原因。
2.2 用 RMII 还是 MII:工业板上我选 RMII
GD32H759 的 enet 控制器支持 MII 和 RMII 两种接口,我在这个项目里用的是 RMII。RMII 信号线少,只有 TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、REF_CLK 这几根,再加上管理口 MDC/MDIO,整体占用引脚少很多,PCB 布线压力也小一些。工控主板往往还要兼顾多路串口、CAN、DI/DO,引脚资源紧张,RMII 是更合理的平衡点。
| 信号 | 方向 | 说明 |
|---|---|---|
| TXD[1:0] | 输出 | 发送数据 |
| TX_EN | 输出 | 发送使能 |
| RXD[1:0] | 输入 | 接收数据 |
| CRS_DV | 输入 | 载波/数据有效指示 |
| REF_CLK | 双向/输入 | 50MHz 参考时钟 |
| MDC | 输出 | 管理时钟 |
| MDIO | 双向 | 管理数据 |
关于 REF_CLK 有一个非常关键的坑:RMII 模式下所有信号都以这个 50MHz 时钟为基准,REF_CLK 可以由 PHY 用自己的晶振产生后输出给 MCU,也可以由 MCU 内部 PLL 分频后输出给 PHY。我在板子上用的是“PHY 自带 50MHz 晶振,CLKOUT 输出给 MCU”的方式,实测比 MCU 输出时钟更稳,因为现场电磁干扰对内部 PLL 分配时钟的影响还是不容忽视的。如果板子设计成 MCU 输出时钟,一定要在初始化里先保证时钟稳定,再初始化 MAC。
2.3 DMA 描述符机制:收发数据是怎么倒腾的
DMA 不是直接把数据搬到 MAC,而是靠描述符(Descriptor)队列来调度。描述符是内存中的一组结构体,每个描述符绑定一个缓冲区,里面记录缓冲区地址、数据长度、状态位等。最关键的是 OWN 位,这个位是软件和 DMA 硬件的交接锁:硬件置 OWN=1 时表示缓冲区归硬件,软件碰不得;硬件处理完数据后清除 OWN 位,软件才能读写这个缓冲区。发送方向恰好相反,软件准备好数据后置 OWN=1 交给硬件。
描述符在初始化时排成一个环形队列,DMA 从当前描述符开始处理,处理完后自动跳到下一个,绕一圈回到起点。驱动要做的事情,就是保证队列里每个描述符状态正确、缓冲区地址有效,并且在中断里及时回收硬件处理完的缓冲区。这里我直接使用固件库里现成的描述符初始化接口,把描述符数组和缓冲区地址传进去就行,但理解这个机制对后续调 bug 极其有帮助,否则你看到寄存器里几组奇怪的状态位根本不知道发生了什么。
2.4 确认 PHY 型号和地址:别让驱动输在起跑线
MDIO 是一套标准的管理接口,但不同厂家的 PHY 在掉电模式、时钟输出、中断极性、地址引脚上都有差异。拿到一块新板子,第一件事不是写代码,而是打开原理图确认三件事:PHY_ADDR 的上下拉是多少,REF_CLK 由谁提供,PHY 的复位引脚接到了 MCU 哪个 GPIO 上。PHY 地址通常由硬件上拉下拉决定,常见的是 0 或 1,如果你在程序里写死了地址,MDIO 读出来的全是 0xFFFF,后面的配置自然也全错。LAN8720A、KSZ8081、DP83848 这些常见型号我都用过,它们的寄存器标准大体一致,但 PHY ID、特性和些微时序并不相同,选择 Kconfig 里的对应型号还是很必要的。
3. 把 enet 驱动挂进 RT-Thread 网络组件:配置与注册
RT-Thread 的网络不是裸机那种自己写个 MAC 初始化和轮询收包就能玩的,它有一整套组件栈:lwIP 是协议栈,SAL 是 socket 抽象层,netdev 是网络设备抽象,eth 是底层网卡驱动框架。驱动要做的,就是把 enet 硬件接进 eth 框架,上面的事情 RT-Thread 都帮你处理好了,前提是你得正确注册。
3.1 menuconfig 里哪些开关必须打开
我用的 RT-Thread 版本网络组件路径大致在Networking -> lwIP/SAL下面,至少要把这几项打开:
RT_USING_LWIP,协议栈本体RT_USING_SAL,socket 抽象层,应用层才能用标准 socket 接口RT_USING_NETDEV,网络设备管理,ifconfig、ping这些命令依赖它RT_USING_ETH,以太网驱动框架RT_USING_PHY,PHY 管理框架- 对应的 PHY 型号,比如
PHY_USING_LAN8720A,如果你的 PHY 不在列表里,就需要自己加一个 phy 驱动文件
生成 rtconfig.h 并重新编译后,系统初始化链里会自动包含 eth 框架相关代码。这里提醒一下,如果你用的是 RT-Thread Studio,图形化配置界面的菜单层级可能和 Env 略有差异,但选项名大同小异,按关键字搜索LWIP和PHY就能找到。
3.2 驱动框架要注册什么:一组设备操作接口 + eth_device
RT-Thread 的以太网驱动框架要求驱动实现一组设备操作接口,然后通过eth_device_init之类的方式把设备挂进系统。核心接口包括 init、open、close、send、recv、control,逻辑上并不复杂。
static struct eth_device g_enet_dev; static rt_err_t enet_eth_init(rt_device_t dev) { enet_gpio_config(); enet_mac_init(); enet_phy_init(); return RT_EOK; } static rt_err_t enet_eth_open(rt_device_t dev, rt_uint16_t oflag) { enet_dma_start(); return RT_EOK; } static rt_size_t enet_eth_recv(rt_device_t dev, rt_off_t pos, void *buffer, rt_size_t size) { return enet_rx_packets(buffer, size); } static rt_size_t enet_eth_send(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size) { return enet_tx_packets(buffer, size); }不同 RT-Thread 版本里eth_device结构体字段和注册接口名可能略有差异,不用死抠函数名,重点是把“协议栈收包时调用 recv、发包时调用 send”这个关系弄清楚。我最早移植时就是没搞明白 recv 和 send 的职责,在接收中断里直接调了 lwIP 接口,结果中断上下文里访问协议栈锁,偶尔死锁,后面花了很长时间才定位到这个问题。
3.3 PHY 框架接入:让 RT-Thread 帮你管链路协商
RT-Thread 的 PHY 框架只需要提供底层 MDIO 读写接口,上面的事情比如自动协商、链路状态轮询、link 变化回调都由框架完成。如果你的板子用的 PHY 正好在组件列表里有,直接打开对应配置即可。如果列表里没有,就需要在components/drivers/phy或者 BSP 里新增一个文件实现phy_read/phy_write两个函数,然后注册到框架。PHY 地址不要硬编码,建议做成宏定义放在 board.h 里,方便不同批次板子切换。我在项目里就是这样,A 版贴的是默认地址 0,B 版因为某个电阻改过变成了 1,只改一个宏就能适配,没必要为这这点小事动代码结构。
4. 核心驱动代码逐段拆解:初始化、收发路径和中断
这一节是全文的实操核心,我把 enet 驱动从零开始的具体流程拆开讲。代码层面以 GD32H759 固件库的接口来写,因为不同库版本字段名会有点差异,大家看思路,不要纠结某个函数的完整签名。
4.1 时钟、GPIO、PHY 复位:先保证硬件环境正常
驱动初始化第一步不是配 MAC,而是把时钟、引脚、复位这些硬件环境准备好。GD32H759 的内部外设时钟要打开,RMII 对应引脚要复用成 enet 功能,PHY 复位引脚要给出可靠的复位时序。这里给一段伪代码:
static void enet_gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); rcu_periph_clock_enable(RCU_ENET); /* 按板子原理图实际引脚设置 AF 复用 */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_af_set(GPIOB, GPIO_AF_7, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); /* 配置模式为 AF_PP,输出速度 HIGH */ gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); }PHY 复位部分要注意时序,尤其是上电后必须给 PHY 足够长的低电平时间,常见 PHY 至少要求 10ms 以上的复位低电平宽度。我习惯写成这样:
#define PHY_RST_GPIO GPIOH #define PHY_RST_PIN GPIO_PIN_3 gpio_bit_reset(PHY_RST_GPIO, PHY_RST_PIN); rt_thread_mdelay(20); gpio_bit_set(PHY_RST_GPIO, PHY_RST_PIN); rt_thread_mdelay(50);这里有个容易被忽略的点:复位完成后不要立刻操作 MDIO,很多 PHY 硬件复位完成后需要一小段“稳定时间”,一般 30ms 到 50ms,等内部晶振起振稳定后 MDIO 才能正确读写。我在这里踩过坑,复位后马上读 PHY ID,读回来是 0xFFFF,一度怀疑焊接问题,加了个延时后一切正常。
4.2 MAC 和 DMA 初始化:先复位再配置,顺序不能乱
GD32 固件库对 enet 的封装程度不低,我并没有每个寄存器手写,而是先enet_deinit彻底复位,再初始化工作模式和 DMA 描述符。核心参数参考下表:
| 参数 | 我的取值 | 说明 |
|---|---|---|
| 工作速度 | 100Mbps | 自动协商结果 |
| 双工模式 | 全双工 | 自动协商结果 |
| CRC 生成 | 硬件生成 | 减少 CPU 干预 |
| 帧过滤 | 广播+组播 | 按产品需求可调 |
| RX 描述符数 | 8 | 保证高负载不丢包 |
| TX 描述符数 | 8 | 足够工控小包场景 |
代码流程大致如下:
enet_deinit(); enet_parameter_struct eparam; memset(&eparam, 0, sizeof(eparam)); eparam.mode = ENET_AUTO_NEGOTIATION; eparam.speed = ENET_SPEED_100M; eparam.duplex = ENET_DUPLEX_FULL; eparam.enet_int = ENET_INT_RX | ENET_INT_TX | ENET_INT_ERR; enet_init(&eparam);描述符初始化这一步,固件库一般会提供链式或者环状初始化函数,把描述符数组和缓冲区的首地址传进去即可。但有两个硬性要求要自己保证:描述符数组必须按 DMA 要求的字节对齐,通常 32 字节对齐;缓冲区地址必须是 DMA 可正常访问的地址。如果 GD32H759 内部 RAM 不够用,放到外部 SDRAM,那还要处理缓存一致性问题,这个我在下一节专门说。
4.3 收发缓冲区和描述符的常规定义
如果自己维护描述符和缓冲区,典型做法是定义成静态数组,再指定对齐属性:
#define RX_DESC_NUM 8 #define TX_DESC_NUM 8 #define ENET_RX_BUF_SIZE 1520 #define ENET_TX_BUF_SIZE 1520 static enet_desc_t rx_desc[RX_DESC_NUM] __attribute__((aligned(32))); static enet_desc_t tx_desc[TX_DESC_NUM] __attribute__((aligned(32))); static rt_uint8_t rx_buf[RX_DESC_NUM][ENET_RX_BUF_SIZE] __attribute__((aligned(32))); static rt_uint8_t tx_buf[TX_DESC_NUM][ENET_TX_BUF_SIZE] __attribute__((aligned(32)));缓冲区大小 1520 字节可以装下标准 1518 字节以太网帧加一点余量,如果你的应用要跑 VLAN 或者带额外标签的帧,缓冲区要再放大一点。描述符数据结构在固件库里一般已经定义好了,常见的是 4 个 32 位寄存器组成,包括状态、控制、缓冲区地址、扩展字段。具体布局参考芯片参考手册,但理解 OWN 位在哪里比死记偏移量更重要。
4.4 中断处理:中断里只做轻量级通知
以太网接收中断是驱动性能的关键。我在写中断处理时遵循一个原则:中断上下文里只清中断标志、标记状态、调用框架的 ready 通知接口,绝对不碰协议栈内部结构。驱动框架收到通知后,会在自己的线程上下文里调用 recv 回调来取数据。
void ENET_IRQHandler(void) { rt_interrupt_enter(); if (enet_interrupt_flag_get(ENET_INT_RX)) { enet_interrupt_flag_clear(ENET_INT_RX); eth_device_ready(&g_enet_dev); } if (enet_interrupt_flag_get(ENET_INT_TX)) { enet_interrupt_flag_clear(ENET_INT_TX); } rt_interrupt_leave(); }如果发现有 DMA 错误中断,有两个选择:一是赶紧记录下来并尝试复位 DMA,二是先把错误标志清了,延迟到主循环里处理。我推荐后者,因为错误中断里做太多操作很容易叠加新的问题。错误处理逻辑放在一个低优先级的线程里面会更从容。
4.5 发送路径的实现要点
发送比接收简单一些。协议栈把完整的以太网帧交给驱动后,驱动要做的事情是找一个空闲的 TX 描述符,把数据拷贝到 TX 缓冲里,设置长度和状态位,然后交给 DMA。这里容易忽略的是:发送路径也要考虑多线程并发。RT-Thread 协议栈发数据时会保证一次只调用一个发送请求,但如果你在主循环里手动调用了同一个发送函数,就可能出现两个任务同时抢描述符的问题。我习惯在找描述符这一段加个临界保护,用rt_hw_interrupt_disable/enable包住,代码量不多,但能避免很多偶发问题。
5. 实测踩坑记录:ping 不通、协商半双工和缓存一致性
跑通驱动的过程从来不是一帆风顺的,我把这次调 GD32H759 enet 驱动遇到的几个最有代表性的问题列出来。每个问题的排查链路我都尽量写清楚,因为“知道答案”没太多价值,掌握“怎么一步步定位到它”才是以后能独立解决问题的关键。
5.1 ping 不通:先看链路、再看中断、最后查描述符
如果上电后主机 ping 不通板子,我先会按下面顺序快速过一遍:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| PHY 链路灯不亮 | PHY 供电、复位、晶体问题 | 量 PHY 电源电压,量 CLKOUT 是否有 50MHz |
| 链路灯亮但 ping 不通 | MAC/DMA 初始化顺序问题 | 打开 RX/TX 错误中断,看 DMA 状态寄存器 |
| ping 有时通有时不通 | D-Cache 一致性问题 | 关缓存或做 Clean/Invalidate 测试 |
| 能收到包但回不了包 | 发送描述符或 TX_EN 引脚配置错误 | 示波器量 TX_EN 是否随发包有电平翻转 |
最笨但最有效的办法,是把 RX、TX、错误中断全打开,在错误中断里加一个计数器,通过串口打印出来。一旦有计数,说明 DMA 在某个环节报告了异常,再拿着异常类型去手册里面找,方向就对了。我这次的问题是 TX 描述符初始化时,缓冲区地址传错了,DMA 一直报描述符错误,修正后立刻通畅。
5.2 协商成半双工或者 10Mbps,问题往往不在 PHY 芯片
有一次我把驱动调通了,但 PHY 协商结果始终是 10M 半双工,无论怎么换 PHY 寄存器配置都一样。排查到最后,问题出在 RMII 的 REF_CLK 上。那批板子为了省一颗 50MHz 晶振,REF_CLK 从 MCU 内部 PLL 分频输出,现场示波器看波形幅度和抖动都还行,但某些 PHY 对 REF_CLK 的稳定度很敏感,协商不出来 100M 全双工。
遇到这种情况,不要一上来就怀疑 PHY 芯片,先看参考时钟源。如果硬件已经定型不能改,软件上的补救办法是把速率和双工模式固定成 100M 全双工,不依赖自动协商。工控内部网络环境通常是可控的,固定 100M 全双工在现场并不是问题,反而还能减少协商时间。我在这个项目里最终就采用了固定 100M 全双工的方式,因为现场一旦出现协商问题,维护成本太高了。
5.3 Cortex-M7 的 D-Cache 导致接收数据错乱
Cortex-M7 内核带 D-Cache,这是它性能强的原因之一,但也是以太网 DMA 最容易踩的坑。DMA 不经过 CPU 缓存,它直接读写内存,而 CPU 可能已经把这块内存读进了缓存。接收方向,DMA 写进内存的新数据,CPU 读到的却是缓存里的旧数据,表现就是 ping 能通但数据内容随机出错;发送方向,CPU 写了 buffer 但 DMA 拿到的是缓存还没来得及回写的旧内容,表现是发出去的包带上错误的负载。
处理办法有两条路。一条是推荐做法:用 MPU 把 enet 描述符和缓冲区所在内存配成 non-cacheable 区域,这样 DMA 和 CPU 都直接操作内存数据,不需要在代码里频繁刷缓存。另一条是在关键位置手动调用缓存维护函数:
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf[0], sizeof(rx_buf)); SCB_CleanDCache_by_Addr((uint32_t *)tx_buf[0], sizeof(tx_buf));我必须强调,如果你像我一样把缓冲区放在外部 SDRAM 里,而 SDRAM 又被 MPU 配置成了 cacheable,这个问题几乎百分百会出现。我第一次跑 ping 是通的,觉得没什么事,结果拿去做大包 UDP 测试,数据错乱到怀疑人生,最后把所有网络相关内存区域全部配置成 non-cacheable,问题才彻底根治。
5.4 RT-Thread 中断优先级和 lwIP 锁的配合
还有一个非常隐蔽的坑是中断优先级。RT-Thread 的 lwIP 内部有锁机制,如果以太网中断优先级太高,在中断处理里调用了某些框架接口,可能会抢占正在持有 lwIP 锁的任务,造成优先级反转甚至死锁。我调驱动初期在中断里做了一些多余操作,结果每隔几分钟系统就卡死一次,完全随机,极难复现,最后用rt_hw_interrupt_enter/leave和中断线程化的思路才把问题稳定住。
个人经验,enet 中断优先级不要顶到最高那一档,留一点余量给系统调度,并且中断里只做标志置位和通知。在百兆工控载荷下,这种方式完全够用,也不会有性能瓶颈。
6. 工控场景下的可靠性加固:比“能 ping 通”更重要的事
驱动能 ping 通只是开始,和产品化还差得远。工控设备可能在配电柜里一待就是几年,网线被人踢掉、对端交换机掉电重启、现场强电磁干扰导致链路瞬断,这些情况没处理好的话,设备运行几个月后就会出现“只能断电恢复”的臭名昭著问题。我在这个项目里专门给 enet 驱动加了几个保险措施。
6.1 链路监测与自动重连
RT-Thread 的 PHY 框架自带链路状态检测,但我还是单独开了一个低优先级线程,每 500ms 读一次 PHY 的 BSR 寄存器,判断 link 是否在位。如果连续多次都读到链路断开,就主动调用 PHY 软件复位或者硬件复位,强制重新协商。这个保险的意义在于,有些 PHY 在受到强脉冲干扰后,内部状态机会卡住,单纯靠协议栈重发不可能恢复,必须从物理层重新初始化。
static void eth_link_monitor(void *param) { uint32_t fail_cnt = 0; while (1) { if (phy_link_status() == RT_FALSE) { fail_cnt++; if (fail_cnt >= 6) { phy_soft_reset(); phy_renegotiate(); fail_cnt = 0; } } else { fail_cnt = 0; } rt_thread_mdelay(500); } }有一点要注意,不要在链路掉线后马上做循环复位,否则现场如果交换机断电了十几秒,你的板子就会不停重启 PHY,每次重启都会产生网络风暴,反而影响整个局域网。设置一个合理的失败阈值和反射次数很关键。
6.2 PHY 软件复位也不能太乐观
软件复位不是写一个寄存器位就完事,要先准备好 MDC/MDIO,再写 BMCR 的复位位,然后轮询复位完成,最后等待自动协商结束。如果 PHY 的状态机已经跑飞,软件复位未必可靠,所以我驱动里同时保留了硬件复位接口,通过 GPIO 控制 PHY_RST 引脚拉低再拉高。这个接口平时不调用,只在链路长时间无法恢复时才启用。工控现场能多一分恢复手段,往往就少一次上门服务。
6.3 描述符数量和缓冲区冗余
很多例程里收发描述符只配 4 个甚至 2 个,调试时没问题,一到高负载就出问题。工控场景看起来数据量不大,但上位机往往会集中轮询,一瞬间可能涌进来很多小包。如果 RX 描述符配得太少,DMA 来不及处理,新进来的帧就直接被硬件扔掉,SDK 里可能连个提示都没有。我这边 RX/TX 都配了 8 个描述符,跑常见的 Modbus TCP 轮询和状态上传场景,压力测试下来没有丢包。
缓冲区大小也建议直接按标准以太网帧上限配,不要抠抠搜搜省那几十字节。嵌入式里内存确实紧张,但为了省 200 字节导致后面定位一个稀有丢包 bug,成本完全不对等。
6.4 把异常计数器做成可观测接口
最后建议在驱动里维护几个全局计数:RX 描述符不可用次数、DMA 错误中断次数、发送超时次数、PHY 复位次数。然后在 RT-Thread 的 FinSH 控制台里加一个自定义命令,能把这些值打印出来。量产之后,如果现场反馈“设备偶尔断线”,让运维人员跑一下这个命令,马上就能判断驱动底层是否发生了异常,再结合链路监测线程的复位次数,定位方向会非常清晰。这一步很不起眼,但在实际项目里节省的时间远比写代码的时间多。
按这套思路把 enet 驱动调通之后,我再往上面套 Modbus TCP、远程升级、状态上报这些应用层功能,基本不用回头再碰驱动。后面如果继续写这个系列,我会重点聊聊 GD32H759 和 RT-Thread 在协议栈裁剪、内存池调优以及工控现场长时间运行稳定性方面的经验,希望这一篇能帮到正在和 enet 驱动搏斗的人。