做工控的,逃不过联网这一步。就算你做的只是一台电机控制器或者数据采集盒子,现在甲方也普遍希望能在办公室电脑上直接看到现场数据。我手头这个基于 GD32H759 搭 RT-Thread 的项目就是这样,前期环境、时钟、串口和 GPIO 都理顺了,下一步必须把以太网跑起来。这个系列的第 1 篇讲了工程搭建和基础外设,这一篇就专门啃 ENET 驱动,也就是 GD32H759 的以太网 MAC 外设,配上外部 PHY 芯片,再接入 RT-Thread 的 lwIP 协议栈,最终目标是板子一上电就能拿 IP、能 ping 通、能跑 TCP/UDP 业务。
说实话,ENET 驱动在 GD32 的整棵外设树里算是比较复杂的模块,涉及引脚复用、时钟树、MAC 控制器、DMA 描述符、PHY 管理、协议栈对接这几层。很多朋友卡在“明明是照着参考代码写的,为什么就是不通”这个阶段,本质是这几层中间有细节被忽略。这篇文章我就从我的实际调试过程出发,把驱动拆开讲清楚,尽量把容易踩的坑都摆出来。适合正在做 GD32H759 + RT-Thread 以太网开发的同学参考,也适合想从裸机以太网过渡到 RTOS 以太网的朋友。
1. 项目整体拆解:这一篇到底在做什么
1.1 为什么是 GD32H759 + RT-Thread 这个组合
先交代一下背景。GD32H759 是 Cortex-M7 内核,主频能跑到比较高的水平,片上资源很丰富,尤其是带硬件以太网 MAC,这对工控设备来说太关键了。很多工控场景不光是采集 IO 和控制电机,还需要把设备接入现场总线或者工厂局域网,以太网几乎是标配。用 RT-Thread 是因为它成熟、组件丰富,而且 lwIP 协议栈的集成度很高,不用自己从头去写 TCP/IP 那套复杂的东西。
这个组合最吸引我的一点是,GD32H759 的 ENET 外设支持 RMII 和 MII 两种接口模式,配合外部 PHY 芯片,可以用很少的引脚实现 10M/100M 以太网通信。在工控主板上,引脚资源很紧张,RMII 模式只需要 7 根信号线加 2 根管理线,比 MII 省了一半。所以这篇文章我会重点以 RMII 模式为例。
1.2 ENET 驱动要解决的三件事:MAC、PHY、协议栈对接
在动手写代码之前,一定要把一个概念理清楚:GD32H759 片上的 ENET 模块只是 MAC 控制器,它本身不能直接收发网络信号,必须通过 MII/RMII 接口连接一颗外部 PHY 芯片,再接网口变压器和 RJ45。所以“ENET 驱动”严格来说包含三部分工作:
第一是 MAC 配置。需要初始化 GD32H759 的 ENET 控制器,设置工作模式(全双工/半双工、速率)、MAC 地址、帧过滤规则、流控策略,以及 DMA 传输模式。这部分寄存器比较多,但流程很固定。
第二是 PHY 管理。MCU 通过 MDIO/MDC 两根线访问 PHY 芯片的内部寄存器,读取链路状态、配置自协商、获取速度和双工模式。不同 PHY 的寄存器布局大同小异,但要特别注意 PHY 地址和复位时序。
第三是协议栈对接。RT-Thread 的 lwIP 组件跑在 MAC 之上,驱动要提供“收包上送”和“发包下发”的接口。也就是说,网卡收到数据要通知 lwIP,协议栈要发送数据时驱动能把帧塞进 DMA 描述符发出去。
这三个部分环环相扣,任何一个环节断掉,表现都是“网络不通”。所以调试的时候也要按这个链路分层排查,不要一上来就怀疑协议栈。
1.3 这篇内容适合谁看
如果你是刚接触 GD32 以太网开发,这篇文章可以帮你把整个驱动框架搭起来,并且理解每个初始化步骤为什么会存在。如果你已经在用 RT-Thread 做项目,但网络一直不稳定,那第 4 章的排查实录和速查表应该能帮你少走不少弯路。如果你用的是其他型号的 MCU,只要也是 Cortex-M 内核加 RMII 接口,这套思路同样可以平移。
2. enet 驱动移植前,先把环境和硬件看明白
2.1 确认硬件连接与 PHY 选型
这一步看着基础,但很容易被忽略。我见过不止一个朋友代码写得没问题,结果发现是板子上的 PHY 芯片地址和代码里写的不一致。不同 PHY 芯片的上电默认地址不一样,比如有的芯片把地址引脚拉高拉低可以配出不同地址,你要从原理图上确认硬件到底把 PHY 地址设置成了多少。
以我用的板子为例,外部接的 PHY 芯片地址是 0x01。这个地址通常由 PHY 芯片的地址配置引脚决定,比如 LAN8720A 的默认地址可以通过 PHYAD0 引脚配置,硬件上拉就是 0x01,下拉就是 0x00。所以代码里mdio_read(phy_addr, reg)的第一个参数,不是随便写的,必须和原理图对应。
另外要看清楚 RMII 的时钟供给方式。RMII 模式需要一个 50MHz 的参考时钟,这个时钟可以由外部有源晶振提供,也可以由 PHY 芯片自己产生并输出给 MCU,还有少数设计是从 MCU 输出时钟给 PHY。不同方案下,代码里时钟树的配置逻辑完全不同。我这边板子的设计是从 PHY 芯片引 REF_CLK 到 MCU,所以 GD32H759 的 ENET 引脚里,那个和 REF_CLK 相关的引脚要配置成输入模式,而不是输出时钟。
2.2 引脚复用与时钟树配置
GD32H759 的 ENET 引脚复用比较讲究,因为同一组外设功能可能分布在多个引脚上,你需要对照数据手册的 AFIO 映射表逐一确认。在 RMII 模式下,典型的引脚分配大概是:
- ETH_MDC:MDIO 管理时钟,一般是 PA2
- ETH_MDIO:MDIO 管理数据,一般是 PA3
- ETH_REF_CLK:参考时钟 50MHz,一般是 PA1
- ETH_CRS_DV:载波检测/数据有效,一般是 PA7
- ETH_RXD0:接收数据位 0,一般是 PC4
- ETH_RXD1:接收数据位 1,一般是 PC5
- ETH_TXD0:发送数据位 0,一般是 PG13
- ETH_TXD1:发送数据位 1,一般是 PG14
- ETH_TX_EN:发送使能,一般是 PG11
不同封装、不同板子会有差异,千万不要照抄。我的习惯是在原理图上把每个网络标号对应的 MCU 引脚画出来,再对照芯片手册的复用表,一条一条核对。引脚复用配置错了,后续所有调试都白搭。
时钟树方面,除了 RMII 需要的 50MHz 参考时钟,ENET 外设的 APB 时钟也要打开。GD32H759 的以太网 MAC 和 DMA 挂在不同的时钟总线上,具体要看参考手册。我踩过的坑是只开了 MAC 时钟,忘了开 DMA 时钟,结果初始化时读写 DMA 寄存器全无反应,一度以为是芯片坏了。
2.3 软件工程准备
软件这边,我在第 1 篇里用 RT-Thread Studio 建好了基础工程,这一步就是在这个工程基础上,加上 ENET 相关的驱动文件。如果你用的是 Keil + RT-Thread Env,流程也类似:先把 lwIP 组件和 eth 驱动组件加进来,再把 GD32 的 ENET 库文件添加到工程。
需要注意,RT-Thread 的 lwIP 组件对内存的需求会比裸机大不少。默认的堆大小可能要调大,尤其是后面要跑 TCP 多连接的时候。我习惯把RT_LWIP_TCP_SND_BUF和RT_LWIP_TCP_WND这些宏稍微调大一点,但也不能无限大,因为 GD32H759 的 RAM 虽多,还要留给业务逻辑和 DMA 描述符缓冲。具体的平衡建议我放到第 5 章讲。
3. 驱动核心实现:MAC/DMA/PHY 三条线逐个打通
3.1 MAC 初始化与工作模式配置
ENET 驱动的第一步是初始化 MAC 控制器,让它知道“我工作在什么模式”、“用什么速率”、“怎么收发数据”。这里我建议按下面的顺序来,不要乱跳:
先给 ENET 外设和相关的 DMA 时钟使能,再把 PHY 芯片的复位引脚拉低再拉高,给 PHY 一个可靠的复位。PHY 复位后需要等待一段时间,通常是几毫秒到几十毫秒,具体看 PHY 数据手册,我一般延时 10ms 以上。
接着配置 MAC 的帧过滤寄存器(一般叫 MACFFR),决定接收哪些帧。比如单播地址过滤要开,广播帧要接收,组播根据需求决定。如果工控设备需要被任意主机访问,那可能还要开启混杂模式,不过生产环境不建议长期开着。
然后是 MAC 控制寄存器(MACCR)。这里要设置接口模式是 RMII,还要设置全双工还是半双工、速度是 100M 还是 10M。如果你打算让 PHY 自协商,那 MAC 这边最好也保持和自协商结果一致。实际驱动里,一般是在 PHY 自协商完成后,再把 MACCR 里的速度和双工位更新成 PHY 上报的结果。
这里有个很关键的细节:MACCR 里的软件复位位(SWR)在初始化时一定要先置位,然后等待硬件自动清零,这代表 MAC 内部状态机复位完成。如果这个等待超时,说明外设时钟或者总线配置有问题,要继续检查时钟树。
3.2 DMA 描述符设计与缓存一致性处理
ENET 驱动里最容易被绕晕的就是 DMA 描述符。GD32H759 的 ENET 发送和接收各使用一个描述符链表,每个描述符包含 4 个 32 位字,分别是控制/状态、缓冲区地址、缓冲区地址扩展等。驱动要做的就是维护一个发送描述符环形队列和一个接收描述符环形队列。
接收链路的工作流程是这样:初始化时把每个接收描述符指向一个缓冲区,并设置所有权位(OWN 位)为硬件所有。当 PHY 收到数据并通过 DMA 写入缓冲区后,硬件会清掉 OWN 位,然后触发中断或者状态标志。驱动发现接收描述符的 OWN 位被清除,就说明有新包到了,可以把缓冲区里的数据交给协议栈,然后重新分配缓冲区,再把 OWN 位设置回去,让硬件继续使用这个描述符。
发送链路相反:驱动要发数据时,把数据地址填到发送描述符里,设置数据长度和 OWN 位,然后告诉 DMA“可以发送了”。硬件发送完成后会清除 OWN 位,驱动收到完成标志后回收缓冲区。
这里我重点提醒一个新手特别容易忽略的地方:GD32H759 是 Cortex-M7 内核,有 D-Cache。如果你的 DMA 缓冲区位于可缓存的 RAM 区域,DMA 写入的数据可能还停留在内存里没有刷新到实际物理内存,或者 CPU 读到的可能是 Cache 里的旧数据。这会导致一个非常诡异的现象:寄存器配置全是对的,但收到的数据总是乱码,或者发出去的数据总是不对。
解决办法有两个方向。一是把 DMA 缓冲区放在非 Cache 的 RAM 区域,有些芯片有多块 RAM,其中某块不支持 Cache,可以直接使用。二是对缓冲区做 Cache 维护,接收数据前 invalidate,发送数据前 clean,发送完成后 invalidate。我在 GD32H759 上用的是第二种,配合描述符缓冲区的 8 字节对齐要求,能稳定跑满速。
3.3 PHY 管理接口与自协商轮询
PHY 管理接口通过 MDIO 总线和 PHY 芯片通信。GD32H759 的 ENET 外设提供 MAC MII 管理寄存器,你只需要按寄存器配置发起一次 MDIO 读或者写操作,然后等待完成标志即可。底层时序由硬件完成,驱动要关心的主要是读写的目标地址和寄存器地址。
PHY 的自协商流程建议放在一个单独的线程或者状态机里轮询,不要阻塞在初始化函数中。因为自协商通常需要一两秒,如果放在 MAC 初始化后面,会导致系统启动很慢,甚至触发看门狗。我的做法是:先完成 MAC 和 DMA 描述符的初始化,注册好设备接口,然后开一个 PHY 监控线程,每隔几百毫秒读一次 PHY 状态寄存器,检测链路是否建立,速度和双工模式是否有变化。
PHY 状态寄存器里最重要的几个位是:链路建立状态位、自协商完成位、速度位、双工位。不同 PHY 芯片这些位的位置不一样,要对着数据手册看。比如链路状态位就有高有效和低有效两种风格,用错了就会永远读不到 Link Up。
自协商完成后,把协商出来的速度(100M 还是 10M)和双工模式(全双工还是半双工)同步到 MACCR。这一步一定要做,否则 MAC 和 PHY 的工作模式不匹配,可能出现能 link 上但数据传输一直出错的情况。
3.4 对接 RT-Thread 的 eth 框架与 lwIP
硬件层跑通之后,剩下的工作就是把驱动挂到 RT-Thread 的网络框架上。RT-Thread 的 lwIP 协议栈提供了标准的以太网驱动接口,你只要实现几个关键函数,然后注册一个网卡设备,协议栈就会通过回调来收发数据。
我这边实现的 eth 接口大致是:初始化函数负责构造 eth 设备结构体,把 ops 指针指向自己实现的接口(比如 init、open、close、link_change、tx、rx 相关的回调),然后调用rt_ether_device_register把网卡注册为 eth0。注册完成后,RT-Thread 的 netdev 组件会自动为这个网卡创建网络接口,可以通过 DHCP 或者静态 IP 配置地址。
收包路径可以走中断加信号量,也可以直接收,但更推荐中断加线程的方式。以太网中断里只做一件事:把接收事件通过信号量通知给一个专用的收包线程。收包线程里遍历接收描述符,把数据打包成 lwIP 的struct pbuf结构,然后调用netif->input交给协议栈处理。这样处理的好处是,协议栈里的耗时操作不会阻塞中断上下文,系统响应更稳定。
发送路径要简单一点,lwIP 要发包时会调用驱动提供的tx回调,驱动把 pbuf 中的数据复制到发送缓冲区,或者直接让 DMA 读取 pbuf 的数据内存,然后描述符交给硬件发送。最理想的是做零拷贝,但 pbuf 的数据可能是不连续的,实际调试中直接复制到连续缓冲区更简单稳妥,牺牲一点性能换稳定性。
4. 联调记录与问题排查速查
4.1 第一次上电:PHY link 不上
我最初移植 ENET 驱动时,第一个遇到的问题就是网口灯不亮,ping网关也不通,代码逻辑翻来覆去看都觉得没问题。后来用调试器读 PHY 的 Basic Status 寄存器,发现链路状态位一直是 Down,才知道根本没到协议栈那一步。
排查链路问题时,我一般先量 PHY 的主时钟是否起振。RMII 模式必须有 50MHz 参考时钟,如果时钟没起来,PHY 完全无法工作。其次是复位引脚,很多 PHY 的复位是低有效,如果硬件上没有正确连接或者驱动没有正确拉高,PHY 会一直处于复位状态。
再就是 MDIO 通信是否正常。可以读 PHY 的 ID 寄存器,如果能读出一个非零的值,说明 MDIO 通路没问题;如果读出来全是 0 或者 0xFFFF,那要检查 MDC/MDIO 引脚配置和 PHY 地址是否匹配。我遇到过地址写错的情况,读 ID 读到的是旁边另一颗芯片的值,排查了好久才反应过来。
这里有个实用的排查技巧:用示波器或者逻辑分析仪抓 MDIO 引脚的波形,看有没有读写动作。如果波形一直静默,说明 MCU 根本没发起访问,问题在 MCU 这边;如果有波形但 PHY 没有回应,问题大概率在 PHY 的地址或者电源上。
4.2 能 link 但 ping 不通或丢包
链路起来了,灯也亮了,但ping的时候要么超时要么丢包,这是第二个高频问题。这种情况说明 MAC 和 PHY 之间基本通信正常,问题往往出在 DMA 描述符、缓冲区管理或者 MAC 过滤配置上。
我遇到过一次典型场景:板子能收到 PC 发来的数据(PC 端 ARP 请求能触发 MCU 中断),但 MCU 发的数据 PC 收不到。排查后发现是发送描述符的缓冲区地址没有做 Cache clean。因为 lwIP 构造的报文写在 Cache 里,DMA 去内存读数据时读到的还是旧数据,PC 收到的自然是垃圾帧。
还有一种情况是丢包率很高,接收描述符队列被耗尽。RTOS 环境下,收包线程如果优先级太低,可能长时间被其他任务抢占,导致中断里虽然有信号量通知,但收包线程迟迟得不到执行,硬件就把后面的包丢了。我的调整思路是把收包线程优先级提高,并且把接收描述符数量从默认的 4 个增加到 8 个或者 16 个,缓冲深度够,容错性就好很多。
如果 PC 端ping第一次通,后面全部超时,还要考虑是不是 MAC 地址没有正确配置。有些驱动初始化时 MAC 地址全是 0,导致网卡发出的请求无人应答。我习惯在驱动里把 MAC 地址写死成一个合法的单播地址,并且确认 lwIP 拿到的是这个地址,而不是随机生成的一个地址。
4.3 HardFault 与描述符异常
以太网驱动是 DMA 密集型外设,最容易触发 HardFault 的地方就是缓冲区越界和描述符指针错乱。我调试时曾经遇到过跑几分钟突然 HardFault,中断里查看现场,发现接收描述符的缓冲区地址已经变成一个异常值,猜测是入包太频繁,某个描述符被重复使用,缓冲区被覆盖了。
这种问题很难靠肉眼看出逻辑错误,我的排查办法是:先保证描述符初始化时所有字段都清零,再确保每个描述符的缓冲区都分配了足够的空间。GD32H759 的 DMA 接收描述符要求缓冲区按字节对齐,具体对齐要求查手册,但一般至少 4 字节对齐。如果你分配的缓冲区是普通数组,编译器默认对齐可能不够,最好使用 RT-Thread 的对齐宏来分配。
还有一个经验是,接收缓冲区的长度一定要留够。以太网最大帧长是 1518 字节,加上 VLAN 标签可能到 1522,如果缓冲区只给到 1518 字节,碰到大包就会溢出。我一般直接给到 1600 字节以上,宁可浪费一点内存,也不要踩越界的坑。后面通过性能测试再慢慢调整到紧凑值。
4.4 问题速查表
| 故障现象 | 可能原因 | 排查/解决建议 |
|---|---|---|
| 网口灯不亮,PHY 寄存器读不到值 | PHY 复位引脚未释放/时钟未起振/MDIO 地址不对 | 检查复位时序,量 50MHz 时钟,用调试器读 PHY ID 寄存器 |
| 能 link,ping 超时 | MAC 地址全 0/DMA 缓存一致性问题/过滤配置错误 | 设置合法 MAC 地址,检查 Cache clean/invalidate,核对 MAC 过滤寄存器 |
| 高负载时丢包 | 接收描述符数量不足/收包线程优先级太低 | 增加描述符数量,提高收包线程优先级 |
| 随机 HardFault | 缓冲区越界/描述符被覆盖/对齐不满足 | 缓冲区长留余量,描述符和 buf 严格对齐,查 DMA 中断状态 |
| 收发速率只有 10M | PHY 自协商结果没同步到 MAC | 自协商完成后更新 MACCR 速度位和双工位 |
| DHCP 拿不到 IP | 协议栈到驱动链路未打通/收包回调没调用 | 确认网卡已注册成功,抓包确认 ARP 是否发出 |
5. 性能验证与稳定运行建议
5.1 回环测试与外网测速
跑通 ping 只是第一步,工控设备长期运行不能只看“能通”,还要看“稳不稳”。我的做法是先做板子到 PC 的压力测试,用 iperf 或者简单的 socket 测试工具打流量。PC 端开一个 TCP 服务器,板子作为客户端持续发送数据,观察吞吐率有没有掉坑,丢包率是否为零。
如果吞吐率明显低于理论值,就要检查是不是轮询收包的方式太占 CPU,还是发送路径上做了不必要的数据复制。我实测下来,把中断收包 + 独立线程处理的方式改成中断 + 信号量唤醒线程后,吞吐率有了明显改善,CPU 占用也降了下来。如果你对实时性要求特别高,可以再针对 DMA 描述符做零拷贝优化,但前提是先保证数据正确性。
然后是长时间稳定性测试。我会让设备连续跑 72 小时,每小时记录一次丢包率和内存使用情况。以太网驱动常见的内存泄漏点在收包路径,如果每次收到包都没有正确释放 pbuf,长期运行会慢慢吃光内存。这个只能靠反复压测和调阅 RT-Thread 的内存统计接口去发现。
5.2 内存与线程资源规划
GD32H759 虽然 RAM 资源相比普通 Cortex-M4 芯片宽裕不少,但 lwIP 本身是个“吃内存大户”。我建议在工程里给 lwIP 单独规划内存池,而不是和其他业务共用一套堆。RT-Thread 的 lwIP 默认配置里 PBUF 池和内存堆的大小要按实际流量估算,如果板子只是做 Modbus TCP 这种低频小包协议,内存池可以给小一点,把资源让给业务;如果要跑文件传输或者日志上传,就要把发送缓冲和接收窗口调大。
线程资源方面,以太网驱动至少会引入收包线程、PHY 监控线程,加上 lwIP 自己的 tcpip 线程。这几个线程的栈大小要合理分配,尤其是收包线程,如果栈太小,处理大包时可能会溢出。我给收包线程的栈一般是 2048 字节,PHY 监控线程 1024 字节足够,tcpip 线程按 lwIP 默认配置来。
5.3 从 ping 通到业务落地
驱动跑到这一步,底层收发链路已经通了,剩下的就是业务层面的活了。对我来说,这个 ENET 驱动打通之后,设备才有了真正的“工控联网”能力。后面可以在这个基础上做 Modbus TCP 从站、MQTT 数据上报、OTA 升级,甚至预留一个简单 HTTP 配置页面。
有一个小建议:保留一条稳定的调试通道。以太网调试不像是串口那么直观,有时候代码改坏了,网络直接断开,你连日志都看不到。我习惯在 ENET 驱动里加一个开关,能通过串口命令开启或关闭 lwIP 的调试输出,这样就算网口挂了,还能通过串口摸清状态,不用反复插拔调试器。
我个人在实际项目中的体会是:ENET 驱动移植,难的不是某个寄存器,而是整套链路里各层组件之间的配合。尤其从裸机思维切换到 RTOS 思维后,中断、线程、缓存一致性这些问题都会浮出水面。把这篇里的三层架构(MAC、DMA、PHY)和协议栈对接捋清楚,后面再遇到网络问题基本都能快速定位。等到第 3 篇我准备把 Modbus TCP 从站挂上去,到时候再把应用层和这个驱动怎么配合的经验整理出来。