
上一回我们把 GD32H759 的工程框架跑起来之后这次直接进入整个工控系统里最关键的一环enet 驱动。做过工控的人都知道串口和 485 在多设备、大数据量的场景里终究有天花板HMI、MES、视觉系统、PLC 之间真正能扛住压力的通信方式还是以太网。GD32H759 自带 10/100M 以太网 MAC 外设配合 RT-Thread 的 lwIP 协议栈这是当前工控板卡上非常主流的组合。但这颗芯片的 enet 外设不是拿来就能跑的MAC 要配置、DMA 描述符要初始化、外部 PHY 要驱动中间任何一个环节出错表现就是 ping 不通、时通时断、发送超时这类让人血压飙升的问题。这篇文章我会把 enet 驱动从硬件对账、工程配置、底层初始化、数据通路到调试方法完整拆开讲适合手里有 GD32H759 开发板、想在 RT-Thread 上把网络跑通的朋友尤其是那些不想只抄例程、想搞清楚 eth 框架到底怎么运作的开发者。1. enet 驱动不是说点灯先把这套硬件和外设关系捋清楚1.1 GD32H759 的 enet 外设到底包含什么GD32H759 的 enet 是一个完整的以太网 MAC 控制器支持 10M/100M 速率可以工作在 MII 和 RMII 两种模式。它自带 DMA 引擎数据搬运不需要 CPU 逐字节参与还有 MDIO 接口用来访问外部 PHY 的寄存器。看到这里你可能觉得“这不就是 PWM 或串口外设的通用套路嘛”实际上差别很大enet 只是一个 MAC 层真正把电信号转成网线上能传的差分信号靠的是外部 PHY 芯片。开发板上那颗 PHY 才是决定你调试难度上限的关键元器件。这意味着驱动要做的事远比初始化一个普通外设多。你要管 GPIO 复用和时钟要初始化 MAC 的帧过滤、流控、校验和功能要建立 DMA 收发描述符队列要通过 MDIO 和 PHY 完成自协商还要在 RT-Thread 的 eth 框架里注册设备。这些工作互相关联但调试时又可以拆开单独验证。我强烈建议你先有一个“分层验证”的思路不要一口气把所有代码写完再上电那样出了问题你根本不知道从哪查起。1.2 这一篇我会交付什么内容我打算从零开始把 enet 驱动涉及的每个环节都写清楚。首先是硬件核对包括开发板的 PHY 型号、PHY 地址、RMII 时钟接法然后是 RT-Thread 工程配置把 lwIP 和 eth 框架的依赖关系理顺接着是底层驱动代码分成初始化、描述符、PHY、收发通路几块来讲最后是调试方法和踩坑记录。每个环节我都会解释为什么这样做以及容易在哪里翻车。这里先说明一下我用的开发板是 GD32H759 核心板加工业底板板载 PHY 是 RTL8201FPHY 地址为 0x00采用 RMII 接口模式RMII 参考时钟由外部 25MHz 晶振经过 PHY 内部倍频后输出 50MHz 给 MCU。如果你的板子用 LAN8720A 或者 YT8512驱动的主体逻辑不变只需要调整 PHY 地址、电源控制时序和个别寄存器位。1.3 开发环境与调试工具清单硬件GD32H759 核心板PHY 使用 RTL8201F配好电源、晶振、网口变压器和 RJ45软件RT-Thread Studio 或 Keil MDK带 GD32H7xx 固件库调试工具USB 转串口模块、逻辑分析仪至少 100MHz 采样率、示波器、网线、路由器或交换机、一台能跑 Wireshark 的电脑逻辑分析仪不是可选项。RMII 接口一共就几个信号出问题时抓一下波形立刻能定位是 PHY 没工作还是 MAC 没发数据。后面我会专门讲怎么用逻辑分析仪抓 RMII 信号。2. 移植前先核对硬件引脚、PHY 地址、RMII 时钟一个都不能错2.1 引脚复用表GPIO 的 AF 值别靠猜GD32H759 的 enet 引脚很多RMII 模式下也有 9 个信号TX_EN、TXD0、TXD1、RXD0、RXD1、RX_DV、REF_CLK、MDC、MDIO。每个引脚都必须复用成以太网功能而 GPIO 的 AF 值在不同系列 MCU 上还不一样。我见过不少人直接在 STM32 的例程上改AF 值照搬结果 TX_EN 永远是低电平。我先给你一个通用的初始化函数里面的 AF 值我写的是我当前 BSP 里能跑通的配置但强烈建议你对着 GD32H759 数据手册的引脚复用表核对一遍void gd32_enet_gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); /* RMII 模式信号引脚AF 值以手册为准 */ gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_1); /* ENET_RX_CLK */ gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_2); /* ENET_MDIO */ gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_7); /* ENET_RX_DV */ gpio_af_set(GPIOB, GPIO_AF_11, GPIO_PIN_11); /* ENET_TX_EN */ gpio_af_set(GPIOB, GPIO_AF_11, GPIO_PIN_12); /* ENET_TXD0 */ gpio_af_set(GPIOB, GPIO_AF_11, GPIO_PIN_13); /* ENET_TXD1 */ gpio_af_set(GPIOC, GPIO_AF_11, GPIO_PIN_4); /* ENET_RXD0 */ gpio_af_set(GPIOC, GPIO_AF_11, GPIO_PIN_5); /* ENET_RXD1 */ gpio_af_set(GPIOC, GPIO_AF_11, GPIO_PIN_1); /* ENET_MDC */ gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_mode_set(GPIOB, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); gpio_mode_set(GPIOC, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_output_options_set(GPIOB, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); gpio_output_options_set(GPIOC, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5); }这里需要注意两点。第一MDIO 信号是双向的有些 PHY 的数据手册要求在 MDIO 线上加上拉电阻如果你的板上没有加软件上的gpio_pull_up_config只能起到很有限的作用最好在硬件上补一个 1kΩ 到 10kΩ 的上拉。第二引脚复用的 AF 号是“一票否决项”。AF 写错整个 enet 模块都不会有任何输出。我建议上电后先用示波器或者逻辑分析仪点一下 TX_EN 引脚如果没有方波先怀疑 AF别去怀疑 MAC 配置。2.2 开发板上的 PHY 型号与地址确认PHY 地址是 MDIO 通信的“门牌号”一般由 PHY 芯片的 PHYAD 引脚硬件决定。RTL8201F 的默认地址是 0x00LAN8720A 是 0x00 或 0x01YT8512 通常是 0x00。但板厂不一定按默认接所以别想当然。最简单的办法是写一个读 PHY 寄存器 2 和寄存器 3 的小函数把读到的值打印出来确认能和 PHY 数据手册对上。后面我会给出这个函数。如果读出来的值是 0xFFFF基本就是 MDIO 通信没建立可能是 PHY 没上电、PHY 地址不对、MDC 频率太高、或者 MDIO 引脚没复用对。千万别急着调 MAC先把这一层打通。2.3 RMII 的 50MHz 参考时钟从哪里来RMII 模式要求参考时钟 50MHz这个时钟有两种接法一种是由 MCU 产生 50MHz 时钟送到 PHY另一种是 PHY 通过 25MHz 晶振倍频产生 50MHz 时钟送给 MCU。我用的板子是第二种PHY 的 REF_CLK 信号接到了 MCU 的 ETH_RX_CLK 引脚上。这两种接法在软件上的差别很大。如果是 PHY 提供 50MHz你只需要确认 GPIO 复用正确不需要软件开 MCO如果是 MCU 提供 50MHz你还要额外配置系统时钟输出让定时器或者 PLL 输出 50MHz 到对应引脚。最典型的问题就是有人把 25MHz 的参考设计照搬结果 REF_CLK 是 25MHzRMII 模式根本跑不起来。因此我拿到板子第一件事就是看原理图找到 PHY 的 XI/XO 晶振引脚接的是 25MHz 还是 50MHz以及 REF_CLK 方向。这个决定了你后面所有配置。拿逻辑分析仪去抓 REF_CLK 引脚能稳定看到 50MHz 的方波才说明时钟链路是通的。2.4 硬件核对结果汇总项目我的板子配置需要重点确认PHY 芯片型号RTL8201F与网口变压器是否匹配PHY 地址0x00PHYAD 引脚电平接口模式RMIIRMII 比 MII 少 6 根信号线REF_CLK 来源PHY 输出 50MHz 给 MCU接线方向和频率MDIO 上拉已带 4.7kΩ 上拉没有上拉时自增MAC 地址自己定义 02:00:xx:xx:xx:xx不能全 0 或全 FF这张表是我每次移植驱动前必查的清单每项都确认过了我才开始写代码这能帮你省下大量在软件里猜问题的时间。3. RT-Thread 工程配置把 lwIP 和 eth 框架的位置摆清楚3.1 用 RT-Thread Studio 建立工程并打开 lwIP 组件如果你是从零建工程在 RT-Thread Studio 里选择 GD32H759 的 BSP 创建项目。创建后打开 RT-Thread Settings勾选 lwIP 组件。这一步会同步在 rtconfig.h 里定义RT_USING_LWIP、RT_LWIP_TCP、RT_LWIP_UDP等宏。内存池大小建议按你项目的实际需求调整我一般把RT_LWIP_PBUF_NUM和RT_LWIP_MEM_NUM稍微放大一点否则 TCP 连接多或者包比较碎的时候容易分配失败。同时还要确认RT_USING_NETDEV已经打开这是 RT-Thread 在 lwIP 外面套的一层网络设备管理框架。很多初学者不知道 netdev 有什么用简单说它负责统一管理网卡接口如果你以后要接无线网卡或者多网卡netdev 能让你用一套 API 操作多个接口。3.2 eth 驱动框架的调用关系RT-Thread 的网络协议栈链路看起来复杂实际路径非常清晰应用程序通过 socket 接口发送数据经过 SAL 层转到 lwIP再由 lwIP 的 netif 接口往下调用 eth 驱动。整个链路的最后一段也就是我们要实现的 eth 驱动其实只做两件事把 lwIP 给的数据包发出去把网卡收到的数据包交给 lwIP。RT-Thread 的 eth 框架定义了一个struct eth_device_ops如果你用过 RT_DEVICE 框架会发现这就是一套典型的设备操作接口struct eth_device_ops { rt_err_t (*init)(struct eth_device *eth); rt_err_t (*open)(struct eth_device *eth); rt_err_t (*close)(struct eth_device *eth); rt_err_t (*read)(struct eth_device *eth, struct pbuf *p); rt_err_t (*write)(struct eth_device *eth, struct pbuf *p); rt_err_t (*control)(struct eth_device *eth, int cmd, void *args); };我们做驱动移植本质上就是实现这几个函数然后通过eth_device_init注册到系统里。注意有些新版本 RT-Thread 改了名字叫rt_eth_device但套路一样我后面代码里沿用更常见的eth_device命名你拿到新版本时做一下类型名替换就行。3.3 驱动文件放在哪里以及如何构建驱动文件建议单独放在 BSP 的 board 目录下命名drv_enet.c和drv_enet.h不要塞进应用代码里。RT-Thread Studio 会根据 SConscript 脚本自动把 board 目录下的源文件加入构建你只要确认 SConscript 里包含了你要编译的 .c 文件即可。还有个小建议把链路检测、PHY 读取这类代码单独写一个函数不要全部塞进 init 里。否则后续排查问题时你得在几百行的 init 里翻逻辑非常痛苦。代码组织清晰一点调试成本直接下降一半。4. 底层驱动实现逐段拆解 GD32H759 enet 初始化4.1 时钟和 GPIO 初始化函数先使能 enet 相关的时钟。GD32 系列里 enet 的时钟通常是三个MAC 主时钟、发送时钟、接收时钟。我这里写的是当前固件库的宏不同版本可能略有区别你以头文件定义为准void gd32_enet_clk_config(void) { rcu_periph_clock_enable(RCU_ENET); rcu_periph_clock_enable(RCU_ENETTX); rcu_periph_clock_enable(RCU_ENETRX); }时钟使能之后再调用前面写的 GPIO 复用函数。GPIO 上电后默认是浮空模式不复用 AF 的话即使 MAC 配置对了信号也出不到引脚上。这里有个细节MDC 和 MDIO 建议设置成推挽输出速度等级高一点避免高速通信时信号边沿太差。TMII 模式下写入数据用的是 TX_EN、TXD0、TXD1这三个信号必须严格同步所以 GPIO 速度等级尽量保持一致不要一个 50MHz 一个 2MHz。4.2 MAC 配置速率、双工、帧过滤、流控GD32 固件库把 MAC 初始化封装成了结构体我们只需要填好参数。下面是核心片段enet_init_struct.enet_mac_mode ENET_AUTO_NEGOTIATION; enet_init_struct.enet_phy_addr PHY_ADDR; enet_init_struct.enet_miim_clock ENET_MIIM_CLOCK_DIV_42; enet_init_struct.enet_checksum_config ENET_CHECKSUM_IPV4_HDR_PAYLOAD_ENABLE; enet_init_struct.enet_forward_feature ENET_FORWARD_FRAME_DISABLE; enet_init_struct.enet_flow_control ENET_FLOW_CONTROL_DISABLE; enet_init(enet_init_struct);enet_mac_mode我直接用了自协商模式让 MAC 和 PHY 自动匹配速率和双工模式。如果你的现场环境固定是 100M 全双工也可以写死成ENET_100M_FULLDUPLEX省掉自协商的时间。但工控现场经常有交换机端口配置不一致的情况自协商更保险。校验和配置这里要注意如果开启了 MAC 硬件校验和那么 IP、TCP、UDP、ICMP 的校验和都由硬件计算CPU 不需要参与。这个功能默认建议开启lwIP 那边也要对应配置成“不计算校验和”否则会出现数据包校验错误导致丢包。我在项目中就遇到过 MAC 和 lwIP 同时计算校验和两边算出来的结果不一样网络时通时断后来把 lwIP 的校验和配置改成CHECKSUM_GEN_IP0之类就好了。帧过滤可以按 MAC 地址过滤单播帧同时决定是否接收广播帧、组播帧和混杂模式。RT-Thread 默认情况下要能接收广播帧因为 DHCP 和 ARP 都依赖广播。如果你需要抓包调试可以在 control 接口里临时打开混杂模式。4.3 DMA 描述符环形队列的建立DMA 是 enet 外设的灵魂而我们和 DMA 打交道的主要媒介是描述符。描述符是放在内存里的一段数据结构每个描述符对应一个缓冲区。发送和接收各自维护一个描述符环DMA 顺着环一个个消费驱动负责往环里放新任务或者回收已完成的任务。这里给出描述符结构体和缓冲区的定义#define ENET_RX_DESC_CNT 8 #define ENET_TX_DESC_CNT 8 #define ENET_RX_BUF_SIZE 1536 #define ENET_TX_BUF_SIZE 1536 static enet_descriptors_struct rx_desc_tab[ENET_RX_DESC_CNT]; static enet_descriptors_struct tx_desc_tab[ENET_TX_DESC_CNT]; static uint8_t rx_buf[ENET_RX_DESC_CNT][ENET_RX_BUF_SIZE] __attribute__((aligned(4))); static uint8_t tx_buf[ENET_TX_DESC_CNT][ENET_TX_BUF_SIZE] __attribute__((aligned(4))); static uint32_t rx_index; static uint32_t tx_index;初始化时用固件库函数把描述符串成环再给每个接收描述符挂上缓冲区enet_descriptors_chain_init(rx_desc_tab, ENET_RX_DESC_CNT); enet_descriptors_chain_init(tx_desc_tab, ENET_TX_DESC_CNT); for (uint32_t i 0; i ENET_RX_DESC_CNT; i) { enet_descriptor_buffer_config(rx_desc_tab[i], rx_buf[i]); }描述符的地址对齐很关键Cortex-M7 要求 GCC 编译时静态变量默认自然对齐但保险起见我加了aligned(4)。如果你的缓冲区用于 DMA有些 DMA 控制器还要求 32 字节对齐具体查芯片手册。我建议在能保证内存充足的情况下直接用aligned(32)省得到时候因为对齐问题出现莫名其妙的丢包。如果你想深入理解 DMA 的运作机制可以这么类比DMA 就像一个自动流水线工人描述符就是工人的工单工单上写着“把这块内存里的数据搬走”或者“把接收到的数据放到这块内存里”。我们驱动做的就是在正确的时机给工人递上正确的工单。4.4 PHY 初始化与自协商PHY 初始化不像 MAC 那样调用一个大函数它主要靠 MDIO 读写寄存器完成。先把 MDIO 通信打通读一下 PHY 的两个 ID 寄存器判断通信正常后再设置基本控制寄存器启动自协商。#define PHY_ADDR 0x00 #define PHY_REG_BCR 0x00 #define PHY_REG_BSR 0x01 #define PHY_REG_ID1 0x02 #define PHY_REG_ID2 0x03 static uint16_t phy_read_reg(uint8_t reg) { uint32_t value 0; enet_phy_write_read(ENET_PHY_READ, PHY_ADDR, reg, value); return (uint16_t)(value 0xFFFF); } static void phy_init(void) { uint16_t id1 phy_read_reg(PHY_REG_ID1); uint16_t id2 phy_read_reg(PHY_REG_ID2); rt_kprintf(PHY ID: 0x%04X 0x%04X\n, id1, id2); /* 写 BCR 触发自协商 */ uint16_t bcr phy_read_reg(PHY_REG_BCR); bcr ~(1 13); /* 100M 速率 */ bcr | (1 8); /* 全双工 */ bcr | (1 12); /* 自协商使能 */ bcr ~(1 15); /* 退出软复位 */ phy_write_reg(PHY_REG_BCR, bcr); }phy_write_reg对应enet_phy_write_read(ENET_PHY_WRITE, PHY_ADDR, reg, value)只是方向不同。这里有个经验PHY 寄存器操作不是瞬时完成的写命令之后要给一点延时尤其软复位后建议等待至少 100ms别立刻去读取状态。我在调试时因为复位后马上读状态读到 0xFFFF还以为是 MDIO 坏了后来发现只是 PHY 还没起来。自协商完成后PHY 的状态寄存器的 bit 5 会被置位。但不同 PHY 的链路状态位位置不一样有的在 BSR 的 bit 2有的在 bit 0。RTL8201F 用的是 bit 2。这个差异是移植时经常踩的坑后面我会再展开。5. 数据通路打通发送、接收、中断与 RT-Thread 对接5.1 发送路径从 lwIP 的 pbuf 到 DMA 描述符lwIP 里数据包全部封装在struct pbuf中一个数据包可能是单段也可能是多段链式结构。eth 驱动要做的就是把链式 pbuf 里的所有数据拼接成一个连续的以太网帧交到 DMA 发送缓冲区然后触发发送。RT-Thread 的 eth 驱动调用的是write操作函数通常收到一个 pbuf我给它起名gd32_eth_writestatic rt_err_t gd32_eth_write(struct eth_device *eth, struct pbuf *p) { struct pbuf *q; uint8_t *pdst; rt_size_t len 0; if (tx_desc_tab[tx_index].status ENET_TDES0_OWN) { return -1; /* 描述符还被 DMA 占用说明上一次发送没完成 */ } pdst tx_buf[tx_index]; for (q p; q ! RT_NULL; q q-next) { memcpy(pdst len, q-payload, q-len); len q-len; } /* 设置描述符的帧大小和 FIRST/LAST 标志 */ tx_desc_tab[tx_index].control_status ((uint32_t)len 16) | ENET_TDES1_FIRST_SEGMENT | ENET_TDES1_LAST_SEGMENT | ENET_TDES1_CHECKSUM_ENABLE; enet_dma_transmit_fd(tx_desc_tab[tx_index]); tx_index (tx_index 1) % ENET_TX_DESC_CNT; return len; }这段代码有几个细节。第一发送前必须检查描述符的 OWN 位。OWN 位为 1 表示 DMA 还拥有该描述符你不能修改它的缓冲区否则可能破坏 DMA 正在搬运的数据。第二我把数据拷贝到了固定发送缓冲区这样最安全。性能更高的做法是直接让描述符指向 pbuf 的内存避免一次拷贝但需要小心 pbuf 在内核中的生命周期一旦 DMA 还没搬完数据而 lwIP 把 pbuf 释放了就会发出去一堆脏数据。新手不建议一上来就搞零拷贝先跑通再优化。5.2 接收路径中断里只做一件事其他交给框架接收路径比发送稍微复杂因为数据是异步进来的。当 PHY 收到一帧数据DMA 会沿着接收描述符环把数据写进缓冲区然后置位接收中断标志。我们的中断服务函数要做的工作就是清理中断标志然后通过eth_device_ready通知 RT-Thread 的 eth 框架“有包到了”具体取包动作由框架回调我们的read操作函数完成。void enet_irq_handler(void) { uint32_t status ENET_DMA_STAT; if (status ENET_DMA_STAT_RI) { ENET_DMA_STAT ENET_DMA_STAT_RI; eth_device_ready(gd32_eth_dev); } if (status ENET_DMA_STAT_TI) { ENET_DMA_STAT ENET_DMA_STAT_TI; } }中断服务函数必须短小精悍不要在中断里做 memcpy、pvPortMalloc、pbuf_alloc 这类耗时操作。中断里越简单系统实时性越好也越不容易和 lwIP 发生资源竞争导致死锁。read操作函数在整个驱动里承担把数据从 DMA 缓冲区搬到 pbuf 的任务static rt_err_t gd32_eth_read(struct eth_device *eth, struct pbuf *p) { rt_err_t result RT_EOK; uint32_t len 0; struct pbuf *rx_p RT_NULL; len (rx_desc_tab[rx_index].control_status 16) 0x3FFF; if (len 0) { rx_p pbuf_alloc(PBUF_RAW, len, PBUF_RAM); if (rx_p ! RT_NULL) { memcpy(rx_p-payload, rx_buf[rx_index], len); } else { result -RT_ENOMEM; } } /* 重新挂上缓冲区并启动下一次接收 */ enet_dma_receive_fd(rx_desc_tab[rx_index]); rx_index (rx_index 1) % ENET_RX_DESC_CNT; if (rx_p ! RT_NULL) { eth_device_ready(gd32_eth_dev); /* 这里按 RT-Thread 框架约定应把 rx_p 传递给上层 */ } return result; }这里我简化了具体的回调方式不同 RT-Thread 版本在 pbuf 上层的传递上有差异有的版本通过eth-netif-input(rx_p, eth-netif)直接注入 lwIP有的版本直接返回 pbuf 让框架处理。你需要参考当前 RT-Thread 各版本驱动例程来对齐。接收路径最容易出的问题是丢包。接收描述符数量太少或者中断处理太慢DMA 没有空闲描述符可用新到的数据帧就会被硬件丢弃。工控现场网络流量一般不大但突发广播包很容易冲垮过小的接收环。我建议接收描述符至少 8 个接收缓冲区按最大以太网帧 1518 字节配。5.3 ops 函数表与设备注册把前面这些函数填进 ops 表然后注册到 RT-Threadstatic struct eth_device_ops gd32_eth_ops { gd32_eth_init, gd32_eth_open, gd32_eth_close, gd32_eth_read, gd32_eth_write, gd32_eth_control }; static struct eth_device gd32_eth_dev; rt_err_t gd32_eth_register(void) { gd32_eth_dev.parent.type RT_Device_Class_NetIf; gd32_eth_dev.ops gd32_eth_ops; eth_device_init(gd32_eth_dev, e0); return RT_EOK; } INIT_DEVICE_EXPORT(gd32_eth_register);注册成功后在 RT-Thread 的 FinSH 控制台执行ifconfig就能看到 e0 这个网卡。如果这里已经能显示出来说明驱动框架这部分已经打通了剩下的就是链路层的问题。open、close函数一般用来处理网卡使能和关闭里面可以做 MAC 地址重置、中断使能、DMA 启动等。control函数负责处理 RT-Thread 发来的控制命令比如设置 MAC 地址、打开混杂模式、查询链路状态等。这些函数不能只留空壳RT-Thread 的 netdev 可能在某处调用它们如果没有实现或者返回错误网卡的状态可能一直不正确。5.4 链路状态变化处理以太网和串口一个很大的不同在于它有“链路状态”的概念——网线插没插、对端设备是否工作、协商速率是多少。这些信息来自 PHY 的状态寄存器。驱动应该周期性地去读 PHY 状态并在链路状态变化时通知上层。我习惯创建一个独立的线程来轮询static void phy_link_monitor_entry(void *param) { while (1) { uint16_t bsr phy_read_reg(PHY_REG_BSR); int link_up (bsr (1 2)) ! 0; if (link_up !gd32_eth_dev.link_status) { eth_device_linkchange(gd32_eth_dev, RT_TRUE); } else if (!link_up gd32_eth_dev.link_status) { eth_device_linkchange(gd32_eth_dev, RT_FALSE); } rt_thread_mdelay(1000); } }链路状态通知很重要尤其是 DHCP 场景。如果网线插拔后不通知 lwIP 链路断开DHCP 客户端可能一直等不来地址或者已经断网了还不去释放 IP。RT-Thread 的 eth 框架收到eth_device_linkchange后会去更新 netdev 状态并通知上层协议栈。这个线程 1 秒轮询一次够用了不需要更快太快的 MDIO 操作反而会占用总线。6. 实测与调试从 ping 不通到跑满带宽6.1 第一轮测试ping 不通排查链路代码写完上电第一件事不是连上位机而是先用串口终端看 RT-Thread 的启动日志确认 e0 网卡注册成功。然后执行ifconfig看网卡的 IP 地址。如果没有 IP可以用 DHCP 自动获取也可以手动ifconfig e0 192.168.1.100。接着用网线把开发板和电脑直连电脑配一个同网段的静态 IP然后开始 ping。如果 ping 不通按下面的顺序排查看网口 LED。RJ45 上的 Link 灯亮不亮Activity 灯闪不闪。LED 不亮说明 PHY 就没有建立链路直接去看 PHY 的自协商状态寄存器。读 PHY 状态。通过 MDIO 读 BSR确认自协商是否完成、链路是否 up。如果 MDIO 读不到数据回头看 GPIO 复用和 PHY 地址。抓 RMII 信号。用逻辑分析仪去抓 RX_DV、RXD0、RXD1看看从电脑发过来的 ping 包有没有到达 PHY 并送到 MCU。如果 RX_DV 有脉冲而 RXD 上有数据说明 PHY 工作正常问题在 MAC 或软件。如果 RX_DV 完全没有脉冲说明 PHY 没把数据送上来问题在 PHY 或网线。看 ARP 是否正常。在电脑上抓包看开发板有没有发出 ARP 响应。如果电脑发的 ARP 请求包在 Wireshark 里能看到但开发板没有响应说明驱动接收已经通了问题出在发送路径或 MAC 地址配置上。第一次跑通整个过程我的经验是 ARP 请求能收到、开发板也能响应之后 ping 自然就通了。如果停在发送这一步去看 TX_EN 信号大部分情况是 GPIO 复用没配置对或者 DMA 描述符挂错了。6.2 通了但吞吐量低ping 通只是开始工控现场经常需要传输几十上百兆的数据这时候吞吐量才是关键指标。测试吞吐量可以用电脑上装 iperf开发板端在 RT-Thread 里跑 iperf 命令。RT-Thread 有 iperf 软件包打开后分别在两端启动服务端和客户端就行。吞吐量偏低一般有这几个原因。第一lwIP 的内存池太小TCP 接收窗口上不去导致吞吐被限制在很低的水平。第二驱动里每收发一包都在中断里做拷贝操作CPU 占用率太高。第三接收描述符太少DMA 来不及处理突发包导致大量重传。我的习惯是把发送描述符和接收描述符都从 4 个调到 8 个然后观察 CPU 占用率。如果吞吐量上去了但 CPU 占用率高得离谱就要考虑在驱动里做 pbuf 的零拷贝或者批量收包。注意零拷贝优化是后面的事先确认传输稳定性再追求性能。6.3 调试工具与抓包思路工控网络调试工具链越完整越好。电脑上 Wireshark 是必备的。把开发板接到交换机上电脑同时接在同一个交换机然后用 Wireshark 抓包能非常直观地看到开发板发出的 ARP、ICMP、TCP 报文。逻辑分析仪在底层调试时价值更大。RMII 模式下抓 REF_CLK、TX_EN、TXD0、TXD1、RX_DV、RXD0、RXD1 这 7 个信号就能完整看到 MAC 和 PHY 之间交换的数据。抓的时候要注意RMII 的数据线只有 2 位字节是被拆成两个 4 位组连续发出来的所以波形看起来是一串 4 位的数据切片别当成并行总线看。我常用的方法是先把 TX_EN 和 RX_DV 两个信号引到逻辑分析仪的通道上看看有没有帧活动。因为这两个信号分别表示发送活动和接收活动。如果 RX_DV 有信号而 TX_EN 没有说明 PHY 收包正常但 MAC/驱动没发出去反过来的情况则是 PC 根本没收到开发板的任何数据。7. 我把遇到的坑记录在这里附完整排查链路7.1 MDIO 读回 0xFFFFPHY 电源不是软件问题我最开始的板子 MDIO 读出来全是 0xFFFF看代码也看不出问题。后来拿万用表一量发现 PHY 的供电引脚电压只有 0.8V原来是底板上的 LDO 焊接不良。这个问题的排查链路很简单但很典型MDIO 读回全高 → 怀疑接线和复用 → 示波器量 MDC 引脚发现有时钟 → MDIO 引脚上也有数据 → 量 PHY 电源发现电压不对 → 补焊后一切正常。排查这类问题的重要原则是先硬件后软件。MDIO 读不到数据先确认 PHY 有没有工作、电源是否正确、复位引脚是否被强制拉低、参考时钟是否稳定全都没问题再回头查软件配置。很多时候你在软件里折腾两天的毛病其实就是一个松了的电阻。7.2 TX_EN 一直低电平GPIO 复用没有生效还有一个印象深刻的坑。MAC 初始化完成自协商也通过编译器没报任何错但 TX_EN 引脚一直是低电平。后来逐行检查发现 GPIO 复用 AF 值配错了。GD32H759 的引脚复用表里这个引脚的 AF 不是 11是 7。配置错一个引脚整个数据发送链路全废。这提醒我一个深刻教训凡是涉及引脚的驱动第一件事永远是对引脚复用表不要靠记忆、不要照搬 STM32 的配置。GD32 和 STM32 内核相同但引脚复用映射不一定一样外设库提供的GPIO_AF_x宏也可能不同。核对一遍只需要几分钟排查这种低级错误却可能要花掉半天。7.3 接收描述符缓冲区指针错位对齐与缓存一致性问题当我把发送接收都调到 8 个描述符后出现了一个非常隐蔽的丢包问题偶尔几包丢失重传能恢复但抓包发现接收缓冲区经常读到半截数据。后来查到一个重要原因就是 DMA 缓冲区的对齐和 Cortex-M7 的 D-Cache 缓存一致性问题。GD32H759 是 Cortex-M7 内核如果使能了 D-CacheDMA 搬运内存和 CPU 读内存看到的可能不是同一份数据。DMA 写入缓冲区后CPU 在读之前需要做 Cache 无效化CPU 写发送缓冲区后DMA 传输之前需要做 Cache 清理。不做这个操作缓冲区数据和实际传输数据不一致就会出现各种诡异现象。解决方案两种一种是在初始化时关掉 D-Cache省事但影响整个系统性能另一种是在收发缓冲区操作前后做SCB_InvalidateDCache_by_Addr和SCB_CleanDCache_by_Addr。我的建议是采用后者同时把缓冲区对齐设为 32 字节这是 Cortex-M7 的 Cache Line 大小。7.4 中断里调用 lwIP API 导致死锁还有一次我在中断服务函数里直接调用了pbuf_alloc当时是为了快速分配 pbuf 来接收数据结果系统运行一段时间后出现死锁。原因在于 lwIP 内部有锁机制中断上下文和非中断上下文对同一资源的访问冲突。RT-Thread 的 eth 框架设计成中断只通知、数据处理放在线程上下文是有道理的。在中断里唯一应该做的事就是把事件标记下来、通知框架然后立刻退出。所有的 pbuf 分配、复制、协议栈处理都放到线程里。这也是为什么 eth 驱动的接收路径看起来多了一层eth_device_ready回调的原因——就是为了把处理工作推到合适的上下文。7.5 一个实用习惯每改一处就重新抓一次 RMII 波形最后分享一个我的工作习惯。每次修改驱动代码后我都不直接看 ping 结果而是先抓 RMII 信号。不是每次都从头抓而是看关键信号有没有变化。比如改了发送描述符控制位就抓 TX_EN 和 TXD0/TXD1改了接收描述符初始化就抓 RX_DV。这种方式的好处是把问题卡在底层不会把 MAC 层错误和上层协议栈错误混在一起。比如有一次我怀疑校验和计算有问题从代码看半天没找到毛病后来抓波形发现发出的报文里IP 头的校验和位段完全是乱的。我这才意识到是 MAC 的硬件校验和使能位没设对导致硬件没算校验和而 lwIP 那边又因为 MAC 配置跳过了计算。这种问题在纯软件层面几乎无法定位但波形一抓就现形。另外一个习惯是每完成一个阶段就git commit一次代码和硬件配置同步记录。工控现场的板子千奇百怪PHY 不一样引脚复用不一样有了版本记录才能快速回溯是哪一步改动引入了问题。