好的,收到。面对这个项目标题"GD32H759 + RT-Thread 工控实战--第2篇 enet 驱动",我先把思路理一理。这显然是一篇系列实战文章的续作,读者大概率是看过第1篇、手里有开发板、正打算往里面移植或调试以太网的工程师朋友。标题信息量不多,正文和关键词是空的,但"第2篇"这个身份本身就给了很多线索——第1篇大概率是讲开发环境搭建或点灯跑系统的,第2篇才进入正经的外设驱动环节。
既然是"工控实战",那这篇就不能只讲"怎么让网口通",得讲"怎么让网口在工控场景下稳定、可靠地跑起来"。GD32H759这颗MCU在GD32家族里属于天花板级别,双核600MHz,带DCI、带以太网MAC,定位就是高端工控和人机界面。RT-Thread做嵌入式操作系统,生态成熟。两个搭在一起,再加上enet(Ethernet,网络通信)驱动,整个话题的核心价值就在于:这颗高性能MCU的联网能力到底怎么快速、稳定、可量产地落地。
我给自己定的文章结构是这样想的:先讲背景和最大误区——很多人以为GD32的MAC和STM32H7完全一样、可以直接抄,这是第一坑;然后必须先把零散的硬件资源整合清楚,ENET引脚、PHY配置这些,别上来就写代码;第三块重点讲RT-Thread的驱动框架,搞清楚底层驱动和上层协议栈(lwIP)之间的分工,为什么要按这个框架来写,不按框架自己裸写会有什么后果;第四块是最实战的部分,完整代码实现,从初始化到收发缓冲描述符,每一段都解释为什么这么写。第五块专门讲调试和排错——这个环节在网络上是最有价值的,因为八成的人卡在这里。最后用一个简单的TCP通信例子收尾,配上我自己的经验总结。
章节名称我想这么命名,确保不落入通用模板的套路:
- 别把 ST 的死板经验直接套到 GD32 上:先对齐芯片资源再说
- 驱动与协议栈的分工:为什么 RT-Thread 的 ENET 框架值得依赖
- 从零到一:GD32H759 ENET 驱动代码怎么落笔
- 收发调不通?把你从黑夜拉回白天的排查链路
- 上板实测:ping、TCP 通信以及从"能通"到"稳定"的心得
这五个章节既能覆盖"原理-框架-实现-调试-实战",又完全贴合 GD32H759 + RT-Thread 的具体场景,章节名直接点出核心内容。
写作风格上,我打算用完全从业者的口吻来写,就像自己在工控现场调试完,回社区写一篇复盘帖。不需要服务意识太强的"手把手",而是"我踩过了,我告诉你坑在哪,你按这个思路走"。插一点现场感,比如"焊盘上的PHY地址上拉电阻""第一次发ARP就挂"这种场景。不搞AI味浓厚的总结。
字数方面,我已经规划好:开头250字左右,每个H2章节保证800-1000字,五个章节加起来接近5000字,整体控制在5500-6500字区间。每个H2下面用H3分小节,用文字讲述为主,列表和表格为辅——毕竟博客帖子不能全是列表,要讲"人话"。
安全方面,这篇文章涉及的内容完全在正常的嵌入式开发范畴内,没有敏感话题,可以放心展开。
好的,现在直接开始输出这篇博文。 ## 1. 别把 ST 的死板经验直接套到 GD32 上:先对齐芯片资源再说
做 GD32H759 的 ENET(以太网 MAC)驱动,我最初的冲动是翻开 STM32H7 的例程直接改。这个冲动本身没有对错,但如果你做过 GD32 和 STM32 的对照开发,应该知道一个事实:GD32 的外设寄存器布局与 STM32 大体相似,但时钟树、复位控制、引脚复用、描述符结构这些细节差异很大。尤其是 Ethernet 模块,从 GD32F4 系列开始就有自己的风格,到了 H7 系列,MAC 核心升级到支持 10/100/1000M 之后,差异更明显。
先把硬件资源理清楚。GD32H759 内置的是标准以太网 MAC,支持 MII(媒体独立接口)和 RMII(精简媒体独立接口)两种 PHY 连接方式。工控板子上,RMII 是绝对的主流,因为只需要 7 根信号线(TXD0、TXD1、TX_EN、RXD0、RXD1、CRS_DV、REF_CLK),相比 MII 的 16 根线省了一半以上的 IO。我用的板子默认就是 RMII,PHY 选择的是 10/100M 工业级芯片,RMII 模式下主频 50MHz,时钟由外部有源晶振或 MCU 输出提供。
我建议你拿到板子第一步不是写代码,而是花半小时对着原理图和数据手册做三件事:
- 确认 PHY 的地址(MDIO 引脚上的硬件上下拉决定,常见为 0x00 或 0x01,但板子可能自定义,必须查原理图确认)。
- 确认 PHY 的晶振频率——RMII 模式下 REF_CLK 可以是外部 50MHz 晶振,也可以由 MCU 的 MCO 引脚输出。两种方式配置不同,代码里时钟源选择就不一样。
- 确认 ENET 引脚复用到哪组——GD32H759 的 ENET 引脚分布在多个复用组上,比如 PA0 可以是 ENET0_TXD0,但 PA1 也可能是,实际要看板卡设计用了哪一组。
这个阶段最容易踩的坑是:用户手册上写着"ENET0_TXD0 在 PA0/PE2/PB12 上",你以为任意选一个就行。理论上是这样,但引脚复用配置(GPIO_AF)必须和实际布线一致,写错了,MAC 发出的数据根本到不了 PHY。这类问题查起来非常隐蔽,因为示波器看到的引脚波形是正常的,PHY 却没反应。
寄存器层面,GD32H759 的 MAC 和 DMA 寄存器大体延续了 ST 的风格,但有几个关键差异:MAC 配置寄存器(MAC_CFG)里对于 RMII 速率选择的位定义、DMA 总线模式寄存器的默认值、描述符环的地址对齐要求(必须 4 字节对齐,但推荐对齐到缓存行大小)。这些差异如果按 STM32 的习惯去配置,大概率功能异常,而且异常方式千奇百怪——有的是收发完全不通,有的是接收到的数据 CRC 错误,有的是 DMA 中断风暴。
还得注意一个重要区分:GD32H759 的 RAM 是紧耦合的,但 DMA 的可访问地址范围受总线矩阵约束。如果你新建的 DMA 描述符或数据缓冲区位于某些特定的内存区域(比如 CCM 类的高速内存),而 ENET DMA 控制器访问不到,那就会出现"描述符写进去了但硬件不动作"的诡异现象。我用的是外部 SDRAM 或片内 SRAM,分配缓冲区之前最好查一下手册里 DMA 可达区域的表格。这个我踩过,后面调试环节细说。
一句话总结这一步:先把硬件地图画出来,再谈写代码。直接抄代码等于盲人摸象,改来改去都是猜。
2. 驱动与协议栈的分工:为什么 RT-Thread 的 ENET 框架值得依赖
RT-Thread 的网卡驱动模型基于 netdev 框架,也就是网络设备接口。上层是 lwIP 协议栈,下层是具体芯片的驱动。我们写 ENET 驱动,本质上要做的事就是:把 GD32H759 的 ENET 硬件能力包装成一组标准操作函数,注册给 netdev,然后让 lwIP 能够通过这个接口收发网络包。
这里有人会问:我能不能不通过 RT-Thread 框架,直接操作寄存器收发数据,自己写个极简的 TCP/IP?当然能,而且很多老工程师的第一版网络产品就是裸机+lwIP 的移植方式。但你在 RT-Thread 环境里这么干,等于放弃了整个生态:应用层用不了 socket API,调试用不了 ifconfig,网络管理用不了 netdev 的状态轮询。工控产品最大的诉求是可持续维护,不是炫技。所以老老实实走框架,是性价比最高的路。
RT-Thread ENET 框架的核心要点是这几个:
- 驱动注册入口是
rt_hw_enet_init(),你在里面完成硬件初始化、PHY 探测、描述符配置,然后调用rt_device_register()把网卡注册为一个设备。 - lwIP 通过
netdev_add()加入网络设备列表,然后调用驱动层的init、open、close、linkup等回调。这些回调对应我们实现的一套struct eth_device_ops函数指针。 - 数据收发分两路:发送是应用层调用
eth_device_ready()确认可发送,然后调用底层的发送函数把数据包挂到 DMA 描述符链上并触发发送;接收是 MAC 收到帧后 DMA 写入内存,产生中断或轮询标志,驱动在中断服务函数里把数据包包装成struct pbuf(lwIP 的缓冲区结构)上传给协议栈。
这种分工带来的好处非常明确:上层协议栈不用关心底层硬件是哪个型号的 PHY、是几兆速率,驱动不用关心 TCP 如何重传、IP 如何分片。我们只需要守住驱动层和 lwIP 之间的接口契约,就可以把精力集中在硬件相关部分。对于工控现场经常要换 PHY 型号的需求——比如从国产 PHY 换到瑞昱、美满——只需要改驱动里的 PHY 配置部分,上层代码完全不动,这就是框架带来的工程价值。
如果用一句话解释 netdev 层,我会说:它就是给网卡做了一个"插拔接口",让操作系统随时知道现在这块网卡有没有插线、速率多少、是否在线。工控设备往往要求断线重连、热插拔检测,这个能力不是 lwIP 自带的,是 netdev 层提供的事件机制。驱动里只要正确上报 PHY 的中断状态变化,上层应用就能收到网络断开恢复的通知,这在长期的工控运行中极重要。
所以这篇文章的代码,我的整体设计原则就是:底层寄存器操作自己的,接口层严格按 RT-Thread 框架走。底层是我和硬件之间的对话,接口层是我和系统之间的契约。契约不破坏,怎么改底层都行。
3. 从零到一:GD32H759 ENET 驱动代码怎么落笔
3.1 初始化代码:时钟、引脚、MAC、DMA 的顺序不能乱
硬件初始化的顺序是有讲究的,乱了轻则初始化失败,重则产生难以追踪的总线错误。我按实际可跑通的顺序来写,每一步你都能在数据手册里找到对应章节。
第一步,开启外设时钟。GD32H759 的 RCU(复位与时钟单元)里,需要使能 ENET 的 MAC 时钟和 DMA 时钟。注意这是两个独立的时钟控制位,不像有些芯片一个位搞定。同时,如果你用 MCO 输出 50MHz 给 PHY 做 REF_CLK,还需要配置 MCO 引脚和时钟源。这一步漏掉一个时钟,后边全都白搭,而且报错还不明显——可能只是 MDIO 读写全部超时。
void enet_gpio_config(void) { // 使能 GPIO 时钟和 ENET 时钟 rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOE); rcu_periph_clock_enable(RCU_ENET); // 配置 RMII 接口引脚复用 gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_af_set(GPIOB, GPIO_AF_11, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); gpio_af_set(GPIOE, GPIO_AF_11, GPIO_PIN_2 | GPIO_PIN_4 | GPIO_PIN_5); // 推挽复用输出,速度设为高速 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); // ... 其他引脚相同处理 } void enet_mac_dma_config(void) { // 配置 MAC:RMII 模式,100M 速率 // 从 PHY 读取状态或直接默认配置 enet_init(ENET_100M, ENET_FULL_DUPLEX, ENET_RX_MODE, ENET_AUTO_NEGOTIATION); }这里要特别说明enet_init()这个库函数。GD32 标准外设库和 STM32 的 HAL 不一样,GD32 的库函数更接近标准外设库风格,函数名、参数列表都不同。ENET_RX_MODE表示使能接收;ENET_AUTO_NEGOTIATION表示让 MAC 支持自动协商,实际协商过程还是靠 PHY 完成,MAC 只是接收 PHY 的协商结果。工控环境我反而建议在代码里固定 100M 全双工,不协商,因为现场如果网线质量差,自动协商可能降到 10M 甚至协商失败,传输质量没法保证。这个在选择里没有绝对对错,但固定速率在工业上更常见。
第二步,配置 DMA。ENET DMA 负责把内存里的数据搬到 MAC 的 TX FIFO,以及把 RX FIFO 的数据搬到内存。DMA 有两个通道:发送通道和接收通道。它通过内存描述符(Descriptor)链表来获取数据包的位置和长度。GD32H759 支持 TX 和 RX 描述符环,每个描述符里有控制位、状态位、缓冲区地址、数据长度这些字段。
描述符这块是新手最容易写崩的地方。我建议使用库函数提供的描述符结构体和初始化函数,而不是自己定义结构体。原因很简单:GD32 库内部的描述符字段与硬件定义严格对应,包含一些你可能没注意的位域,自己定义结构体容易因为内存对齐问题导致描述符被 DMA 错误解析。
#define ENET_RX_DESC_NUM 4 #define ENET_TX_DESC_NUM 4 static struct eth_dma_desc rx_desc_tab[ENET_RX_DESC_NUM] __attribute__((aligned(4))); static struct eth_dma_desc tx_desc_tab[ENET_TX_DESC_NUM] __attribute__((aligned(4))); static uint8_t rx_buff[ENET_RX_DESC_NUM][ENET_MAX_FRAME_SIZE] __attribute__((aligned(4))); static uint8_t tx_buff[ENET_TX_DESC_NUM][ENET_MAX_FRAME_SIZE] __attribute__((aligned(4)));aligned(4)是必须的,GD32H759 的 DMA 描述符地址要求 4 字节对齐,否则 DMA 控制器可能忽略低位地址,导致访问错误。我保守一点用了 4,如果你后续开 DMA 的缓存一致性功能或者用更高级的优化,对齐到 32 字节会更保险。但基础版 4 字节已经够跑。
第三步,中断配置。ENET 中断挂在 EXTI 或 NVIC 上,具体看你是怎么设计的。GD32H759 的 ENET 有多个中断源:DMA 发送完成、DMA 接收完成、PHY 中断、MAC 错误等。在中断服务函数里,我们主要响应接收完成事件和链路状态变化。发送完成可以轮询,但工控场景建议也开中断,不然高负载下容易丢事件。接收必须用中断或 DMA 半满转移,否则高流量下接收 FIFO 溢出会丢包。
3.2 PHY 的 MDIO 配置:驱动能否起来的第一道关卡
PHY 的访问通过 MDIO 接口,它是 MAC 和 PHY 之间一个简单的两线管理总线。GD32H759 的 ENET 模块集成了 MDIO 控制器,我们只需要通过库函数读取和写入 PHY 寄存器。
PHY 的探测逻辑其实很简单:遍历可能的 PHY 地址(0~31),尝试读取 PHY 的标识寄存器(通常是寄存器 2 和寄存器 3)。如果读到的值不是全 0 也不是全 1,就认为该地址上存在有效 PHY。这样做有两个好处:一是自动适配不同板卡上 PHY 地址的不同,二是不用硬编码 PHY 型号,程序一跑就知道是哪颗 PHY。
MDIO 访问之前有个前提:MDC 时钟频率必须在合法的范围内。频率由系统时钟分频得到,如果分频系数不对,MDC 过高,PHY 可能无响应或偶发错误。GD32 库函数里有enet_phy_clock_config()之类的接口,根据你的系统时钟频率选择合适的分频。
我遇到过一个比较典型的案例。板子上电后,以太网无论如何不通,debug 打印 PHY 读取超时。示波器测 MDC 引脚有波形,MDIO 数据线也有电平跳动。后来查了芯片手册发现,MDC 的频率已经超过 25MHz,远超 PHY 支持的规范。原因是系统时钟跑在 600MHz,而分频配置写错了。这个问题的隐蔽程度在于:波形看着正常,逻辑分析仪抓时序才发现周期不对。所以如果你遇到 MDIO 超时,先查分频,别急着怀疑硬件焊接。
3.3 描述符环和缓冲区:搞清楚谁拥有这块内存
以太网 DMA 的工作模式是:DMA 控制器维护一个描述符链表,每个描述符指向一块缓冲区。发送时,CPU 把数据写入缓冲区,然后设置描述符的控制位通知 DMA"可以发送了";接收时,PHY 收到数据写入 FIFO,DMA 自动按描述符指向的缓冲区地址搬运数据,并更新描述符状态位通知 CPU"这里有一包新数据"。
所以描述符环本质上是一个仲裁机制:CPU 和 DMA 共用一块内存,通过标志位来交接所有权。初始化时,所有 RX 描述符都归 DMA 所有;CPU 每次从接收中断里读走数据后,需要把描述符归还给 DMA。TX 描述符相反,初始归 CPU 所有,发送完一个包后 DMA 归还描述符。
这个所有权的交接如果没写对,最常见的现象是:第一次收包没问题,第二次开始丢包或死等。原因就是你没有把 RX 描述符的"归属权标志"重新设置为 DMA 所有。GD32 库里提供了enet_desc_receive()和enet_desc_rif()之类的函数来处理接收后的描述符重置,一定要确认调用,不要漏。
3.4 RT-Thread 驱动接口的绑定
框架层面的绑定有固定的套路:定义一个struct eth_device结构体,填充名字、操作函数指针和私有数据,然后调用eth_device_init()注册。
static struct eth_device enet_dev; static rt_err_t rt_enet_init(rt_device_t dev) { // 硬件初始化,如果已经在入口函数里做了,这里可以空实现 return RT_EOK; } static rt_err_t rt_enet_open(rt_device_t dev, rt_uint16_t oflag) { // 启动 MAC 和 DMA 接收 enet_enable(); enet_rx_enable(); return RT_EOK; } static rt_size_t rt_enet_tx(rt_device_t dev, const void *buf, rt_size_t len) { // 把 buf 里的数据复制到 TX 缓冲区,挂到发送描述符,触发 DMA 发送 uint32_t sent_len = enet_send_packet((uint8_t *)buf, len); return sent_len; } const struct eth_device_ops enet_ops = { .init = rt_enet_init, .open = rt_enet_open, .close = rt_enet_close, .linkup = rt_enet_linkup, .linkdown = rt_enet_linkdown, .tx = rt_enet_tx, }; void rt_hw_enet_init(void) { enet_gpio_config(); enet_mac_dma_config(); phy_probe_and_config(); eth_device_init(&enet_dev, "e0"); eth_device_linkchange(&enet_dev, RT_TRUE); }注意发送函数:rt_enet_tx()接收的是 lwIP 传上来的完整以太网帧,包括目标 MAC、源 MAC、类型/长度字段和数据。这个帧不是裸的 IP 数据,所以发送时不要再封装任何头部。如果你在驱动层加了什么额外的头(有些协议栈需要 VLAN 处理才会加),通信会失败得很莫名其妙。
接收侧,中断里拿到一个数据包后,直接用pbuf_alloc()分配一个 lwIP 的 pbuff,把数据拷贝进去,然后调用eth_device_ready(&enet_dev)和netif->input(pbuf, netif)把数据上传给 lwIP 协议栈。这一套是 lwIP 的标准netif输入路径。拷贝这个动作在性能要求极高的场景可以省掉——通过PBUF_REF零拷贝——但工控环境下数据流量通常不大,拷贝的开销可以忽略,换来的是逻辑简单可靠,我建议第一版就老老实实拷贝。
4. 收发调不通?把你从黑夜拉回白天的排查链路
4.1 先确认物理层通没通
驱动写完上电,第一步不是敲 ping 命令,而是看 PHY 的协商状态。用调试器读 PHY 寄存器:寄存器 5(基本模式状态寄存器)的 bit5 表示协商完成,bit2 表示 link 状态。如果这里就不是 1,后面的协议栈配置全部白搭。
如果协商没完成,从这几个方向查:
- PHY 供电是否正常。很多 PHY 需要 3.3V 和 1.8V(或 2.5V)两路电源,缺一路就是起不来。
- REF_CLK 有没有波形。RMII 模式下 50MHz 时钟是核心,用示波器量 PHY 的 XI/CLK 引脚,如果没有稳定波形,查时钟配置。
- RMII 的 CRS_DV 引脚在无数据时应该是低电平,如果一直是高,可能 PHY 被配置成了 MII 模式或引脚复用错误。
- 网线有没有插对。这个听起来像废话,但工控机箱后面一排网口,插错口的情况我见过不止一次。
4.2 描述符链断裂:最常见、也最难查的坑
如果你是按照我的代码顺序写的,描述符这块大概率不会出问题。但如果你是自己设计的描述符链,或者参考了 STM32 的例程改的,请注意一个关键区别:GD32 的 DMA 描述符格式在 H7 系列上已经和 F4 系列不一样了。特别是接收描述符的状态字里,RDES0的各个标志位位置、错误标志含义,都有调整。如果你用 F4 的库函数操作 H7 的寄存器,会出现一种诡异现象:发送正常,接收永远没数据——因为 DMA 接收描述符的所有权标志根本没被正确识别,DMA 认为描述符不可用。
排查方法很直接:初始化完成后,打印描述符链表中每个描述符的地址和状态字。手动构造一个简单的网络帧(用另一台设备发,或者用开发板自己发个 ARP),然后看接收描述符的状态字是否从"DMA 所有"变成了"CPU 所有"。如果状态字没变,说明 DMA 根本没有写入数据,问题在 DMA 配置或描述符链结构;如果状态字变了但数据不对,问题在地址映射或缓冲区错位。
4.3 lwIP 没有内存了:工控现场隐秘的丢包根源
RT-Thread 的 lwIP 默认使用内存池管理包缓冲区。如果内存池配置过小,高负载下pbuf_alloc()会失败,驱动层接收中断里分配不到 pbuff,只能丢包。更麻烦的是,内存池耗尽后的表现往往是间接的——不是直接崩溃,而是网络时断时续,回 ping 通,压力一大就丢包。
这个坑的典型场景是:我在一个数据采集项目里,网络空闲时一切正常,一旦对上位机持续下发数据,板子就开始丢 ARP 响应,甚至完全掉线。查了很久,后来打开 lwIP 统计接口,发现内存池的avail数量在某些时候降到了 0。原因是有个应用线程频繁创建 socket、发送数据、关闭 socket,每次操作都从内存池里取,但关闭时没有完全归还,内存池被碎片状耗尽。解决办法一是增大MEM_SIZE_NODE和PBUF_POOL_SIZE,二是在应用层加明显的资源控制,不能无限创建连接。
RT-Thread 的 lwIP 配置在rtconfig.h里,MEM_SIZE、PBUF_POOL_SIZE、PBUF_POOL_BUFSIZE都是关键参数。工控场景我建议PBUF_POOL_SIZE至少 16 个,PBUF_POOL_BUFSIZE至少 1600,保证一个标准 MTU 帧一定能装下。如果产品预期流量很大,再考虑加大。
4.4 中断风暴:为什么你的 CPU 占用率莫名其妙地高
有人说"我把 ENET 的接收中断打开了,然后 CPU 占用到了 90%",这种问题大概率不是流量真的大,而是中断没正确处理,产生了持续重入。以太网接收中断里有个常见的逻辑错误:读取中断状态标志后没有清除,或者清除得太早导致新中断无法触发。还有一种情况是 DMA 的错误标志被置位了,比如描述符错误、总线错误,这些错误如果不处理会一直触发中断。
排查时加一个中断次数计数器,放在中断函数里。不传数据时观察计数器是否持续增长。如果是,优先检查 DMA 错误状态寄存器,看看有没有 FIFO 溢出或者描述符错误。如果是 FIFO 溢出,通常是接收描述符太少或者处理太慢;如果是描述符错误,查描述符的地址是否有效。
我一般建议在初始化完 DMA 后立刻打开总线错误中断和花费较长时间的错误中断,把这些错误状态打印出来,避免它们被静默吞掉。很多时候,以太网驱动的"神秘故障"就是总线错误中断被关了,错误信息没有暴露。
4.5 断线自动恢复:工控设备不能靠人重启
工控设备的网络要求往往是"常年不断电",网线偶尔被误拔、交换机重启、链路抖动,这些都要能自动恢复。RT-Thread 的 netdev 框架支持链路事件回调。如果 PHY 有中断引脚接到 MCU 的 EXTI,你可以在链路状态变化时上报给 netdev;如果没有中断引脚,就得用轮询方式定时读 PHY 状态寄存器,检测到变化再上报。
我个人建议在工控产品里使用轮询方式,因为 PHY 中断引脚在复杂电磁环境下可能受到干扰,产生误触发。轮询周期设 1 秒完全够用,代价是每次读 PHY 寄存器要通过 MDIO,这个操作本身很轻量,对系统负载的影响可以忽略。
自动恢复的逻辑要写完整:链路断开时,停止 DMA 收发,释放所有未完成的接收描述符,重新初始化 MAC;链路恢复时,重新启动 DMA,清空 lwIP 的 ARP 缓存,让上层重新发送 ARP 请求建立连接。不清理 ARP 缓存会导致一个常见问题:链路恢复了,但 ping 不通,因为对方的 ARP 缓存里还留着旧的 MAC 映射,而本机的 MAC 没变(同一个网卡),理论上不会出问题;但交换机端口和 PHY 重新协商后,有些交换机端口的 MAC 学习表会错乱,主动发一个免费 ARP 或重启网卡接口能更快恢复。
5. 上板实测:ping、TCP 通信以及从"能通"到"稳定"的心得
5.1 先用 ifconfig 确认驱动状态
RT-Thread 启动后,在 MSH 命令行输入ifconfig,如果能看到e0这个网络接口,并且状态是 UP,IP 地址是你配置的静态地址,说明驱动注册成功,链路层初始化完成。如果这里显示 DOWN,回到第 2 节查rt_enet_open()和链路状态上报。
ping 之前建议先确认 lwIP 的 IP 地址没有和局域网冲突。工控现场经常有现网环境,IP 冲突会导致时通时断,非常难排查。我用过一个小技巧:不手动配 IP,先让 lwIP 用 DHCP 获取地址,能拿到就说明链路层、协议栈、物理层都是通的。等确认通后再改回静态 IP,省了排查链路层的烦恼。
5.2 ping 通只代表物理层通,不代表协议栈可靠
很多新手以为 ping 通了就万事大吉,这是很危险的想法。ping 使用的是 ICMP 协议,数据量小,频率低,链路稍微有点问题也能通。而实际工控通信里,TCP 数据传输、Modbus TCP 请求、MQTT 心跳、文件上传下发的流量模式完全不同。
可靠的稳定性测试要这样做:一个大包(接近 1500 字节 MTU)连续 ping 10000 次,观察有没有丢包;再用 iperf 这种带宽测试工具打满吞吐,确认收发都不会丢包;然后用两个设备同时跑,确认高流量下延迟是否稳定,不出现个位数秒级别的卡顿——这种卡顿往往预示着内存池问题或者中断处理不及时。
5.3 从"能通"到"稳定",我总结的几条实在经验
先说缓冲区。DMA 的描述符和数据缓冲区,能大就大。工控设备的网络流量通常不大,但突发流量可能存在——比如上位机下发的配置文件、产线的批次记录上传。缓冲区不够的直接后果是 FIFO 溢出丢包。我的一个习惯配置是 RX 描述符 8 个、TX 描述符 8 个,每个缓冲区 2048 字节。对应复杂的网络场景绰绰有余,消耗的内存也只有不到 40KB,对 GD32H759 来说毫无压力。
再说缓存一致性。GD32H759 支持 D-Cache。你如果开了数据缓存,DMA 写入的内存会在 Cache 里,CPU 读取时如果不做一致性处理,读到的可能是旧数据。这个问题在 STM32H7 上非常经典,GD32H759 也一样。最简单的处理方式:缓冲区放在不缓存的区域,或者用SCB_InvalidateDCache_by_Addr()在收包后刷新对应地址的缓存。我第一版驱动没开 D-Cache,一切正常;后来为了性能开了 Cache,立刻出现了收发数据偶尔错误的问题,排查了半天才意识到是缓存一致性。如果你对 Cache 操作不熟悉,建议先把 D-Cache 关掉跑通功能,再研究性能优化。
还有 PHY 寄存器读写函数里的延时问题。MDIO 时序要求每个读操作之间需要等待,GD32 库函数里一般会在enet_phy_read()内部做等待,但如果你在循环里快速轮询 PHY 状态,加上自己实现的延时函数,效果更稳定。我习惯在读 PHY 状态寄存器之间加一个 1ms 的调度延时,可以让出 CPU 给其他任务用,反正轮询周期一秒一次,1ms 延时完全不影响响应速度。
最后是电源与 ESD。以太网口在工控现场会插拔频繁,静电脉冲容易通过网线进入 PHY。如果你的 PHY 旁边没有做 ESD 保护,网络可能使用几个月后开始频繁断链。这个问题硬件层面改动成本高,软件层面能做的就是让链路恢复更积极:PHY 寄存器里开启自动中断恢复、MAC 设置更多的重传次数。软件永远替代不了硬件防护,但至少能让异常恢复时间尽可能短。
5.4 一个典型 TCP 应用的测试代码
驱动稳定之后,我习惯写一个最简单的 TCP 客户端连到上位机的服务器,反复收发消息,验证链路持续在线。
#include <rtthread.h> #include <arpa/inet.h> #include <netdb.h> static void tcp_client_thread(void *param) { int sock = -1; struct sockaddr_in server_addr; char send_buf[] = "GD32H759 ENET test\n"; char recv_buf[128]; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(5000); server_addr.sin_addr.s_addr = inet_addr("192.168.1.100"); while (1) { sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { rt_thread_mdelay(1000); continue; } if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { closesocket(sock); rt_thread_mdelay(2000); continue; } /* 循环发送接收 */ while (1) { if (send(sock, send_buf, sizeof(send_buf), 0) < 0) { break; /* 连接断开,重新连接 */ } int len = recv(sock, recv_buf, sizeof(recv_buf) - 1, 3000); if (len > 0) { recv_buf[len] = 0; rt_kprintf("rx: %s\n", recv_buf); } rt_thread_mdelay(500); } closesocket(sock); rt_thread_mdelay(2000); } } static int tcp_client_start(void) { rt_thread_t tid = rt_thread_create("tcpcli", tcp_client_thread, RT_NULL, 2048, 22, 10); if (tid) rt_thread_startup(tid); return 0; } MSH_CMD_EXPORT(tcp_client_start, start tcp client demo);这个代码的巧妙之处在于断线重连逻辑也涵盖在内:connect失败就等待重试,send/recv错误就关闭 socket 重新创建。你可以把这个线程跑一个晚上,看第二天早上通信是否还正常。很多驱动的稳定性问题都要靠这种长时间测试才能暴露出来——短时间 ping 通说明不了任何长期可靠性问题。
我个人在实际操作中的体会是:GD32H759 的 ENET 模块性能很强,但强大之余也更挑剔。它不像一些低端芯片那样"容错",你对描述符、缓存、时钟的每一个随意处理,它都会在某个时间点毫不留情地暴露出来。好消息是,只要把这几个关键环节处理到位,这套系统的网络稳定性是完全可以信任的,跑工控现场的数据采集、远程维护、协议网关这些任务,绰绰有余。
第 2 篇就先写到这里。下一篇文章我可以接着讲 PHY 芯片选型与不同 PHY 的移植差异,或者如果你更关心上层应用,Modbus TCP 从站的实现也是个热门且实用的方向。有想深入了解的部分,评论区见。