做工控这块的兄弟应该都有同感:设备能不能联网,直接影响整个项目方案怎么定。上篇把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所有,我准备好数据后把所有权交给DMA,DMA发完数据后再把所有权还给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_CLK:50MHz参考时钟
- ETH_MDC、ETH_MDIO:MDIO管理接口
- 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_buffer,control字段设置接收缓冲区大小和所有权位。
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加lwIP,CPU占用率可以控制在20%以内,丢包率为0(在小包长时段测试),对整个工控采集任务毫无影响。这个结果说明选型策略和驱动实现是可行的。
5.3 下一步可以怎么扩展
驱动稳定后,就可以在这个基础上做工业协议了。比如Modbus TCP从站,RT-Thread社区有FreeModbus移植案例;MQTT上报,用RT-Thread的webclient或mbedTLS加密通道;如果对接PLC和上位机,还可以研究PROFINET、EtherNet/IP这类工业实时协议。
我也在考虑做一套双网口冗余方案:GD32H759本身只带一个MAC,但结合外部PHY和交换机芯片,可以实现主备链路切换。在电力、轨道交通这类对可靠性要求高的场景,链路冗余是刚需。这部分后面有空再单独写一篇,先把enet驱动这个地基讲透,大家有什么问题欢迎留言交流。