
做工控这块的兄弟应该都有同感设备能不能联网直接影响整个项目方案怎么定。上篇把GD32H759 RT-Thread的环境、时钟、串口这些地基搞定了这篇咱们就啃最硬的骨头——以太网驱动enet。说实话以太网驱动在所有外设驱动里算是最绕的不仅涉及MAC控制器、PHY芯片、DMA描述符还要挂到RT-Thread的网络协议栈上任何一个环节没配对现象都是千奇百怪。这篇文章我会把自己从零移植enet驱动的完整思路、关键代码逻辑、以及调试时踩过的坑全部梳理一遍希望能帮正在用GD32H759做工控联网的兄弟少走弯路。这篇适合两类人一是刚接触RT-Thread网络开发想搞清楚网卡驱动到底怎么挂到系统上的二是已经在裸机上把enet跑通但不太理解DMA描述符、PHY管理、中断收包这些机制想系统理一遍的。内容不会只给结论会把“为什么这么做”也讲清楚毕竟驱动这种东西不懂原理就只能靠试试错成本太高了。1. 项目整体设计与选型思考1.1 为什么选GD32H759跑工控以太网GD32H759这颗芯片在兆易创新的产品线里属于高性能旗舰Cortex-M7内核、600MHz主频、2MB Flash、1MB SRAM板上直接集成10/100M以太网MAC还能接外部PHY走MII或RMII。这个配置放在工业控制器、边缘采集网关、协议转换器这类场景里性能是足够的没必要上Linux主控MCU方案从成本、启动速度、生态上都有优势。之前我做过一个项目需要在一个设备上同时采集现场传感器数据、跑Modbus TCP从站、还要通过MQTT把数据上报到上位机。如果选普通M4芯片以太网虽然也能跑但遇到大流量加协议栈开销CPU占用会偏高选A系列应用处理器开发复杂度又上来了光系统移植和驱动适配就要多花几周时间。GD32H759正好卡在中间M7内核单核主频高SRAM大到可以给每个DMA描述符预留专门的缓存区RT-Thread的实时性又能保证数据采集和协议处理在确定时间内完成这就是选型逻辑。1.2 驱动到底该写在RT-Thread的哪个层级很多刚从裸机转过来的朋友会困惑我在裸机上写了一个en以太网驱动怎么在RT-Thread里就找不着北了其实核心问题在于任何RTOS下做驱动都需要先搞清楚“你的驱动要服务给谁”。RT-Thread对网络设备有一个标准抽象层网卡驱动不直接跟应用通信而是先注册成一个以太网设备再挂在lwIP协议栈下面。lwIP负责TCP/IP协议处理应用层我调用socket接口收发数据底层实际由网卡驱动完成数据包的搬移。所以整体结构是这么几层应用层一般用RT-Thread的SAL层统一socket接口不关心底层是lwIP还是其他协议栈。协议栈层lwIP向上提供协议接口向下调用以太网设备抽象层。设备层实现RT-Thread的eth_device接口包括初始化、打开/关闭、收发函数、链路状态查询等。底层驱动真正操作GD32H759的寄存器和DMA描述符以及通过MDIO总线配置PHY。写驱动时重点其实在底层驱动这一层但必须把上层接口的语义搞清楚。比如RT-Thread里的eth_device_ops会有init、open、close、read、write、control等函数我需要把这些函数一一对应到GD32H759的硬件操作上而不是自己裸调寄存器。2. enet硬件机制拆解从MAC到PHY的完整链路2.1 一次以太网数据发送数据到底是怎么走的要写好驱动先得把以太网控制器的工作机制理清。以太网数据链路分成两大部分MAC控制器和PHY芯片。MAC控制器在芯片内部负责数据帧的组装/解析、地址过滤、CRC校验以及对外提供MII/RMII接口信号。PHY芯片则是物理层收发器负责把数字信号变成模拟电信号发到网线上同时负责链路协商、速度/双工模式检测。这样说有点抽象我打个比方。MAC相当于一个负责包装和拆包的仓库管理员PHY相当于送货的快递员。我要发货仓库管理员MAC把货物装入标准箱子并贴上地址添加以太网帧头和CRC交给快递员PHY快递员用汽车/飞机电信号把箱子送到目的地。收件时反过来快递员把箱子交给仓库管理员管理员验货、拆包、交给我。RMII接口是MAC和PHY之间的通信协议相比完整MII接口RMII引脚少总共才7根信号线左右TXD0/TXD1、TX_EN、RXD0/RXD1、CRS_DV、REF_CLK。GD32H759开发板上一般默认用RMII因为10/100M速度下RMII完全够用而且省IO。需要注意RMII要求一个50MHz参考时钟这个时钟可以由外部晶振独立提供也可以由PHY芯片产生后反向给MAC具体看硬件设计。2.2 DMA描述符环驱动效率的根本GD32H759的enet有一个内部DMA控制器不占用CPU逐个拷贝数据帧。CPU只需要在RAM里准备一块“描述符表”每个描述符记录一个数据缓冲区地址、长度、状态标志位。描述符之间通过指针链成环形DMA会自动按顺序访问。一个发送描述符大致包含发送缓冲区地址、发送字节数、帧校验、中断标志、所有权位。所有权位特别关键它决定描述符当前归CPU还是归DMA所有。初始化时描述符归CPU所有我准备好数据后把所有权交给DMADMA发完数据后再把所有权还给CPU。接收方向相反DMA收到数据后先把数据写入接收缓冲区再把所有权交还给CPU并置位中断标志位通知我来处理。这里的高效点在于如果描述符环足够大DMA可以在CPU处理旧数据的同时继续接收新数据形成流水线。工控设备经常要处理突发的报警帧描述符环配小了就容易丢包。我在项目里接收描述符一般配16个发送描述符配8个每个缓冲区128字节凑足一个缓存池够大多数工控场景用了。2.3 MDIO总线驱动与PHY通信的桥梁MAC除了收发数据还要通过MDIO总线管理PHY。MDIO只有两根线MDC时钟线、MDIO数据线。MDC由MAC驱动频率一般控制在2.5MHz以内。MDIO线上可以挂多个PHY每个PHY有独立的5位物理地址所以最多挂32个器件。实际应用中我至少要做两件事读PHY的基本ID确认PHY型号读/写PHY的控制寄存器和状态寄存器。比如PHY的寄存器0控制寄存器里可以配置自动协商、软复位、速度双工模式寄存器1状态寄存器里有协商结果、链路状态、远端是否有支持的能力。检测网线是否插好就是周期读这个状态位。有的驱动会把MDIO操作做成统一函数比如mdio_read(phy_addr, reg)和mdio_write(phy_addr, reg, val)底层是模拟GPIO或使用MAC的MDIO控制器硬件时序。GD32H759的MAC自带MDIO控制器操作寄存器就能完成时序不需要软件模拟省了很多事。3. RT-Thread enet驱动移植实操3.1 驱动文件怎么组织、从哪里抄一般情况下我不会完全从零写驱动文件。GD32官方固件库已经提供了基于HAL风格的enet驱动RT-Thread官方bsp里也往往有对应芯片的驱动模板我的经验是先复制一份官方模板再根据自己板子的连接方式改。RT-Thread工程里我会在board目录下单独建一个enet文件夹放这几个文件gd32h7xx_enet.c官方固件库的enet底层驱动主要操作MAC和DMA寄存器。drv_eth.c负责把底层接口封装成RT-Thread的eth_device。phy_xxx.c自己的PHY芯片驱动提供复位、协商、读取状态函数。关键是要清楚哪些地方需要手动改。官方模板一般把引脚复用、PHY地址、DMA描述符数量定义在一个配置头文件里我会在board.h或者eth_config.h里统一改。3.2 第一步引脚复用和RMII初始化我用的板子PHY连接方式是RMII引脚大概这样分配ETH_RMII_TXD0、ETH_RMII_TXD1发送数据ETH_RMII_TX_EN发送使能ETH_RMII_RXD0、ETH_RMII_RXD1接收数据ETH_RMII_CRS_DV载波监听/数据有效ETH_RMII_REF_CLK50MHz参考时钟ETH_MDC、ETH_MDIOMDIO管理接口PHY_RESET复位引脚一般是普通GPIO控制配置引脚复用在GD32标准外设库里大概是这样void enet_gpio_config(void) { /* 使能相关GPIO时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); rcu_periph_clock_enable(RCU_AF); /* 以PA口对应RMII信号为例 */ gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); /* ETH_RMII_REF_CLK等 */ 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复位引脚先拉低再拉高完成复位 */ gpio_bit_reset(PHY_RST_PORT, PHY_RST_PIN); delay_ms(50); gpio_bit_set(PHY_RST_PORT, PHY_RST_PIN); delay_ms(50); }这里有个坑不同开发板PHY接的RMII参考时钟可能不同有的PHY自己输出50MHz给MCU有的需要外部有源晶振。初始化顺序不对会导致PHY工作不稳定。我试过先把PHY复位拉低、再配置MCU引脚结果RMII时钟老是对不上排查了很久后来按PHY上电、复位、稳定参考时钟、再初始化MAC这个顺序才稳。3.3 第二步DMA描述符环的初始化DMA描述符环是驱动里最核心的数据结构必须保证在内存中连续且对齐。最好的做法是定义成全局数组放在BSS段里然后手动检查地址对齐。#define ENET_RX_DESC_NUM 16 #define ENET_TX_DESC_NUM 8 #define ENET_RXBUF_SIZE 128 #define ENET_TXBUF_SIZE 128 static struct enet_desc rx_desc[ENET_RX_DESC_NUM]__attribute__((aligned(4))); static struct enet_desc tx_desc[ENET_TX_DESC_NUM]__attribute__((aligned(4))); static uint8_t rx_buffer[ENET_RX_DESC_NUM][ENET_RXBUF_SIZE]__attribute__((aligned(4))); static uint8_t tx_buffer[ENET_TX_DESC_NUM][ENET_TXBUF_SIZE]__attribute__((aligned(4)));初始化描述符的要点就是把每个描述符的next指针串起来最后一个描述符的next指向第一个形成环。每个接收描述符的buffer_addr指向rx_buffercontrol字段设置接收缓冲区大小和所有权位。void enet_desc_init(void) { uint32_t i; for (i 0; i ENET_RX_DESC_NUM; i) { rx_desc[i].status ENET_RX_DESC_OWNED_BY_DMA; rx_desc[i].control ENET_RX_DESC_BUFFER_SIZE(ENET_RXBUF_SIZE) | ENET_RX_DESC_CHAIN_MODE; rx_desc[i].buffer_addr (uint32_t)rx_buffer[i]; rx_desc[i].next_addr (uint32_t)rx_desc[(i 1) % ENET_RX_DESC_NUM]; } for (i 0; i ENET_TX_DESC_NUM; i) { tx_desc[i].status ENET_TX_DESC_OWNED_BY_CPU; tx_desc[i].control ENET_TX_DESC_CHAIN_MODE; tx_desc[i].buffer_addr (uint32_t)tx_buffer[i]; tx_desc[i].next_addr (uint32_t)tx_desc[(i 1) % ENET_TX_DESC_NUM]; } }这里要特别提醒M7内核带D-Cache描述符结构和缓冲区在RAM里会被CPU缓存。DMA控制器不经过缓存直接访问RAM如果描述符被缓存在CPU中而DMA又同时在改它数据就会出现“新老版本不一致”。针对这个问题GD32官方有的例程会建议关闭D-Cache或把内存配置为不缓存但这会损失性能。更优雅的做法是在关键描述符操作前做Cache Clean操作后做Cache Invalidate。RT-Thread在某些bsp里会做统一封装。我建议不管你的驱动模板有没有处理先确认一下。3.4 第三步MAC初始化和PHY自动协商MAC初始化主要是配置帧过滤、全双工、100M速度、硬件CRC校验、MAC地址等。帧过滤这块工控场景值得多说一句MAC可以设置接收所有帧promiscuous mode也可以根据目的MAC地址过滤。工控从站通常只需要接收发给自己或广播的帧我一般不开混杂模式避免无关帧大量占用DMA描述符。void enet_mac_config(void) { enet_mac_deinit(); enet_mac_speed_config(ENET_MAC_SPEED_100M); enet_mac_duplex_config(ENET_MAC_FULL_DUPLEX); /* 设置MAC地址例如 00:80:E1:xx:xx:xx */ enet_mac_address_set(ENET_MAC_ADDR0, mac_addr); /* 使能MAC发送、接收 */ enet_mac_frame_filter_config(ENET_FRAME_FILTER_NONE); enet_mac_update_config(ENET_RX_MODE_BROADCAST, ENET_RX_MODE_MULTICAST, ENET_RX_MODE_UNICAST); }PHY配置这里我最常用的是PHY自动协商模式。让PHY和交换机/对端设备自动协商速度与双工。启动自动协商后需要等待几秒让PHY稳定再读取状态寄存器确认链路已经up。void phy_init(void) { phy_reset(); delay_ms(100); /* 配置PHY控制寄存器开启自动协商使能全双工和100M能力 */ uint16_t reg phy_read(PHY_REG_CONTROL); reg | (1 12); /* 自动协商使能 */ reg | (1 13); /* 软复位 */ phy_write(PHY_REG_CONTROL, reg); /* 等待协商完成 */ uint32_t timeout 10000; while (timeout--) { uint16_t status phy_read(PHY_REG_STATUS); if ((status (1 5)) (status (1 2))) /* 链接up且协商完成 */ break; rt_thread_mdelay(1); } }这里要注意PHY寄存器位定义各厂商不统一比如LAN8720和YT8512的寄存器0x1位定义就有差异。确定PHY后一定要对着数据手册核对。3.5 第四步把驱动挂到RT-Thread上底层函数写完接下来就是做RT-Thread的适配层。通过eth_device结构体注册流程大概是static struct eth_device enet_dev; static rt_err_t eth_init(rt_device_t dev) { enet_mac_config(); enet_desc_init(); phy_init(); return RT_EOK; } static rt_err_t eth_open(rt_device_t dev, rt_uint16_t oflag) { enet_dma_enable(); return RT_EOK; } static rt_err_t eth_close(rt_device_t dev) { enet_dma_disable(); return RT_EOK; } static rt_size_t eth_tx(rt_device_t dev, const void *buf, rt_size_t len) { rt_uint32_t i; struct enet_desc *desc tx_desc tx_index; memcpy((void *)desc-buffer_addr, buf, len); desc-control ENET_TX_DESC_BUFFER_SIZE(len) | ENET_TX_DESC_OWNED_BY_DMA; /* 触发DMA发送 */ enet_dma_transmit_trigger(); /* 等待发送完成这里可以加超时 */ while (!(desc-status ENET_TX_DESC_OWNED_BY_CPU)) ; tx_index (tx_index 1) % ENET_TX_DESC_NUM; return len; } static rt_size_t eth_rx(rt_device_t dev, void *buf, rt_size_t len) { struct enet_desc *desc rx_desc rx_index; /* 如果描述符还归DMA所有说明没有新到数据 */ if (desc-status ENET_RX_DESC_OWNED_BY_DMA) return 0; uint32_t pkt_len desc-status ENET_RX_DESC_FRAME_LEN_MASK; memcpy(buf, (void *)desc-buffer_addr, pkt_len); /* 将描述符还给DMA */ desc-status ENET_RX_DESC_OWNED_BY_DMA; rx_index (rx_index 1) % ENET_RX_DESC_NUM; return pkt_len; }注册部分类似这样eth_device_init(enet_dev, e0); enet_dev.parent.ops-init eth_init; enet_dev.parent.ops-open eth_open; enet_dev.parent.ops-close eth_close; enet_dev.parent.ops-read eth_rx; enet_dev.parent.ops-write eth_tx; rt_device_control(enet_dev.parent, NIOCTL_GADDR, mac_addr); eth_device_ready(enet_dev);需要特别说明的是上面的实现是“一个描述符一个等待轮询”的简化版本实际工控环境中如果丢包频繁最好改成中断加信号量通知的机制。RT-Thread的eth_device驱动通常在接收中断里调用eth_device_ready(enet_dev)通知lwIP线程来调用read函数取包。3.6 第五步中断处理与接收通知以太网DMA支持多种中断比如接收完成、发送完成、接收缓冲区溢出、总线错误。我实际用到的主要是接收完成中断和发送完成中断。void ENET_IRQHandler(void) { uint32_t status enet_dma_status_get(); if (status ENET_DMA_STATUS_RB) /* 接收缓冲可读 */ { eth_device_ready(enet_dev); } if (status ENET_DMA_STATUS_TB) /* 发送完成 */ { /* 这里可以释放信号量通知发送线程 */ } enet_dma_status_clear(status); }这里我又要强调cache问题接收中断触发后lwIP线程调用read函数把数据从rx_buffer拷贝出去。但rx_buffer是DMA写的CPU从D-Cache里读出来很可能是旧数据。正确做法是在read之前对rx_buffer做invalidate。有的底层实现把描述符和缓冲区的内存配置成不带cache的简化问题但代价是性能。具体怎么取舍看你的工控项目对实时性的要求。4. 常见问题与排查实录4.1 网线插上后状态始终是down这个现象我遇到得最多。首先怀疑PHY的复位问题PHY复位时间不够寄存器和状态都没准备好。建议先读PHY的ID寄存器能读到预期值说明MDIO通PHY基本活着。读不到先查MDIO引脚复用和PHY地址。RMII时钟缺失也能导致网络起不来。有的设计里REF_CLK由外部晶振提供晶振没焊好或者没起振整个PHY就无法工作。我排查时会用一个简单方法把板子放在耳边听有时晶振振会有微弱声音更多时候还是用示波器测PHY的REF_CLK引脚确认有没有50MHz方波。4.2 能ping通但丢包严重能ping通说明协议栈和驱动主路径是通的丢包原因通常在以下几点接收描述符太少流量稍大就溢出DMA没有空闲缓冲区硬件丢弃新包。RX缓冲区太小大于缓冲区超长帧被截断或丢弃。10/100M以太网最大帧1518字节我把接收缓冲区设成128字节必须开启链式描述符或者用完整缓冲区否则大包根本收不下。Cache不一致描述符状态更新了但CPU看到一个旧值导致处理了重复数据包或错误忽略新包。MTU和缓冲区配置不匹配TCP层分片与驱动缓冲区大小不对。处理思路先把接收描述符加到32个缓冲区大小调到最大以太网帧1536字节然后关掉D-Cache做对照实验。如果关cache后正常那基本就是cache一致性问题再去用clean/invalidate解决而不是一直关cache。4.3 收包一段时间后死机多半是描述符环被破坏或者内存越界。排查时最有效的手段是看DMA的中断状态寄存器里面会有总线错误、描述符错误等标志。如果打印出的DMA错误位指向描述符不可用八成是某个描述符的next地址不对或者描述符在内存中被其他任务意外踩掉。还有一种隐蔽情况RT-Thread线程栈分配太小协议栈处理时栈溢出破坏了相邻内存里的描述符。这种情况下建议先开RT-Thread的栈溢出检查再观察是不是收发大包时死机。4.4 实测调试工具与流程我调试enet驱动时常用的工具顺序是第一步串口打印PHY寄存器值确认MDIO链路通不通PHY型号对不对协商速度和双工是否匹配。第二步在lwIP配置里打开调试宏比如RT_LWIP_TCP、RT_LWIP_ETH查看协议栈收到的包数量确认驱动上送给协议栈的数据是否正常。第三步用PC的Wireshark抓包分析板子发送的包格式是否正确MAC地址、IP地址是否配置正确。第四步如果板上能跑shell用ifconfig命令看e0的up/down状态用ping命令测通断。排查顺序严格从底层到上层物理链路link状态→ MAC寄存器计数→ 驱动收发中断、描述符→ 协议栈lwIP调试→ 应用。我见过很多人一丢包就怀疑协议栈结果问题出在物理层白白浪费很多时间。5. 性能优化与后续扩展思路5.1 中断均衡与CPU占用率驱动跑通只是第一步工控项目里CPU占用率直接影响其他任务的实时性。如果进入中断的频率太高比如每收一个小包就触发一次中断CPU大部分时间都在进出中断RT-Thread调度会受影响。GD32H759的enet DMA支持中断合并/节流功能可以把一组接收完成中断合并成一次再通知lwIP。代价是延迟变高。工控场景里Modbus TCP这类协议通常强调响应时间而不是吞吐量所以中断合并幅度要适度。另一个思路是收包流程中不用lwIP线程的workqueue而是在中断上下文直接用信号量唤醒专用网络线程。在RT-Thread里实际通过eth_device_ready触发接收回调链路调度比较稳定。5.2 从裸机思维转到RTOS驱动思维最后我想强调一个思维转变写裸机驱动时一切代码都是在一个循环里跑收发过程天然串行。到了RT-Thread驱动是给内核服务的收发操作会在不同线程上下文被调用中断处理、线程调度、锁竞争都成了新问题。刚开始移植时我把所有状态都放到全局变量里想着简单结果几个线程同时操作描述符出现竞态丢包和死循环都出现了。后来把所有描述符操作都加锁确保同一时刻只有一个线程在操作描述符环问题才解决。具体到性能指标我在这个项目上实测过100M速率、TCP收发压力测试下GD32H759跑RT-Thread加lwIPCPU占用率可以控制在20%以内丢包率为0在小包长时段测试对整个工控采集任务毫无影响。这个结果说明选型策略和驱动实现是可行的。5.3 下一步可以怎么扩展驱动稳定后就可以在这个基础上做工业协议了。比如Modbus TCP从站RT-Thread社区有FreeModbus移植案例MQTT上报用RT-Thread的webclient或mbedTLS加密通道如果对接PLC和上位机还可以研究PROFINET、EtherNet/IP这类工业实时协议。我也在考虑做一套双网口冗余方案GD32H759本身只带一个MAC但结合外部PHY和交换机芯片可以实现主备链路切换。在电力、轨道交通这类对可靠性要求高的场景链路冗余是刚需。这部分后面有空再单独写一篇先把enet驱动这个地基讲透大家有什么问题欢迎留言交流。