ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GD32H759以太网驱动移植实录:RT-Thread下从PHY到lwIP的完整调试指南

GD32H759以太网驱动移植实录:RT-Thread下从PHY到lwIP的完整调试指南 上一篇把GD32H759那片板子的工程基础框架跑通以后我这几天的精力基本都花在enet上。先动网络不是因为点灯不好玩而是工控设备联网这件事太刚需了。GD32H759这颗M7内核、主频最高600MHz、带2MB Flash和1MB SRAM的芯片在MCU里算是资源非常充裕的级别它自带一个完整的10/100M以太网MAC控制器配合外部PHY芯片就能撑起Modbus TCP、远程参数下发、固件升级、数据上云这些真正常用的工控功能。再叠加RT-Thread这套成熟的RTOS和网络协议栈整个方案的可玩性和工程落地价值都非常高。不过真动起手来enet驱动的坑比我预想的多。时钟配置、GPIO复用、PHY芯片地址、DMA描述符初始化、lwIP对接任何一个环节没接对网口就是死路一条。这篇就把我完整的移植和调试过程拆开讲含踩坑记录。尤其适合手里有GD32H7系列板子、想在RT-Thread上把网络功能跑通的工程师。就算你用的是别的M7/M4内核、带MAC的MCU这套调试思路和排查顺序照样能直接抄。1. 为什么先动enetGD32H759的联网价值1.1 这颗芯片做工控联网的底气先快速过一下我为什么对GD32H759这么上心。除了600MHz主频它最大的亮点是存储和外设配置在这个价位段几乎找不到对手。2MB Flash意味着可以塞下RT-Thread完整组件、lwIP协议栈、文件系统、加密库甚至一段不算小的应用逻辑1MB SRAM在实际工控项目里也基本不用抠内存。以太网MAC、TFT-LCD控制器、硬件加解密、CAN/CAN-FD这些外设摆明了就是给中高端工业应用准备的。以太网MAC这部分是本文主角。芯片手册里它叫ENET支持10M/100M自适应MII/RMII两种接口模式都能用自带DMA内置收发FIFO硬件上还能做CRC校验和VLAN标签过滤。这意味着协议栈以下的脏活累活MAC和DMA几乎全包了CPU只需要在接收中断里把数据搬给协议栈发送时把协议栈的数据挂到描述符上。1.2 第2篇就做网络不是心血来潮很多系列文章跑完点灯就直奔花哨外设我反过来先把网络啃下来。原因很简单工控设备一旦联网后面能做的事情是几何级增加的。远程监控系统需要定期把设备状态推给上位机或者云平台PLC数据采集通过Modbus TCP走以太网比串口效率高一大截设备出厂后固件升级也尽量走网络总不能天天派人扛着调试器去现场。另外还有个很实际的理由RT-Thread对网络的支持太成熟了。内核、设备框架、SAL套接字抽象层、lwIP协议栈、netdev网卡设备这一整条链路都有现成组件。驱动的工作本质上是把GD32的ENET外设“塞”进这个框架里让应用层用标准socket接口就能收发数据。难度的确存在但只要框架理解到位就远比从零手写协议栈要省事。这篇内容适合谁来参考呢第一类是拿到GD32H759开发板、想快速把网络跑起来的人第二类是研究RT-Thread网络驱动框架、想搞懂eth_device和lwIP对接原理的人第三类是手上是其它M7/M4芯片但被以太网驱动折腾得头疼想找一套通用调试思路的人。前两类可以直接照着做第三类建议重点看第2章、第4章和第5章硬件细节不同排查逻辑是通用的。2. 从MAC到PHYGD32H759的ENET硬件链路拆解2.1 MII和RMII选哪个更省事ENET控制器对外有两种物理接口MII和RMII。MII是标准接口用16根数据线时钟25MHz速率10M/100M自适应RMII是精简接口只用7根信号线数据线只有2根时钟固定50MHz。对GD32H759这种144脚甚至176脚封装的芯片来说引脚并不算太紧张但RMII依然是我首选。信号线少了将近一半布线方便对EMC也更友好而且驱动的初始化逻辑其实只差一两个寄存器位。下面这个对照表基本能说明问题特性MIIRMII数据线数量162参考时钟25MHz TX_CLK50MHz REF_CLKPCB布线难度较高低信号线总数16含控制7适用场景对时序要求高的设计大多数主流方案我手里的板子原理图走的就是RMII所以本篇的驱动适配全部基于RMII来展开。2.2 RMII的50MHz参考时钟最容易被忽略的坑RMII接口固定需要一路50MHz的参考时钟这个时钟的默认来源大概率是第一个坑。我遇到的现象非常典型MDIO读PHY寄存器返回的值全是0xFF或者根本读不到PHY ID网卡在系统里连设备都枚举不出来。这个REF_CLK在硬件上可以有三种来源一是板载50M有源晶振直接给PHY和MCU二是由PHY芯片反馈输出一个50MHz时钟给MCU三是由MCU的MCO引脚输出50MHz给PHY。具体用哪种得看板子原理图。软件侧要做的第一件事就是搞清楚REF_CLK到底是谁提供的。如果原理图是MCO输出方案那必须在GPIO初始化里把MCO引脚的复用功能配好否则MAC侧根本没有参考时钟后面一切功能都免谈。2.3 PHY芯片选型与地址确认MAC只负责数据链路层物理层的活全在PHY芯片上。GD32H759的MDIO管理接口支持标准的IEEE 802.3 Clause 22市面主流的PHY基本都能直接接。我这次用的LAN8720A公司之前几个项目里也用过多款PHY常见的有KSZ8081、DP83848、YT8512它们在寄存器层面有差异但0x00到0x05这几个基本寄存器是完全兼容的。其中0x02和0x03是PHY ID寄存器驱动探测PHY时主要就是靠读这两个寄存器来确定芯片型号。PHY的访问地址不是软件随便指定的而是由PHY芯片硬件的PHYAD引脚上下拉决定的。LAN8720A常见的地址是0x00KSZ8081默认可能是0x00DP83848通常是0x01。最稳的做法是枚举地址0x00到0x1F逐个尝试读PHY ID哪个地址能读到合法ID就是它了。我实际调试时长期把PHY地址写死成0x00后来发现换一块板子就不识别改成扫描方式后兼容性立刻好了。2.4 复位引脚和Link状态的那些事PHY芯片几乎都有一个复位引脚低有效硬件上一般会拉一个RC延时电路到MCU的普通GPIO。虽然有些PHY支持只用上电复位但既然硬件已经预留了GPIO控制驱动里最好还是做一次显式复位。要注意的是复位解除后PHY内部还要做上电初始化和自协商准备所以复位后至少要等几十毫秒再开始MDIO访问否则第一次读寄存器大概率是失败的。Link状态检测也值得多说一句。BMSR寄存器地址0x01的bit2是Link状态位但这个位的读取有一个经典陷阱必须连续读两次第二次的值才是当前真实链路状态。原因在于该寄存器的某些状态位是闩锁的第一次读会清除上一次的变化标志所以直接拿第一次读到的结果判断链路十次有七八次是错的。这个细节在大多数中文资料里没人提我是在调试“网线明明插着但系统老是报Link down”时发现的。3. RT-Thread的eth驱动框架一条数据包从网口到socket的完整路径3.1 网络软件栈的分层关系RT-Thread网络这块的分层非常清晰从下往上依次是以太网驱动eth_device、lwIP协议栈、SAL套接字抽象层、应用层socket接口。驱动层只负责和最底层的ENET硬件打交道协议栈负责TCP/UDP/IP这些逻辑SAL层则把标准BSD socket API映射到lwIP上。用一句话解释数据包的走向应用层调用send()发送数据数据进入协议栈协议栈按TCP/IP规则封装成以太网帧最终交给驱动层的eth_tx函数由DMA把数据从内存搬到MAC的FIFO再通过RMII接口送到PHYPHY把并行数据变成差分信号发到网线上。接收方向完全反过来PHY收到差分信号RMII还原成数字信号DMA把帧搬进内存缓冲区并触发中断驱动层把数据封装成lwIP认识的pbuf结构上交协议栈socket接口那边就能read到数据。3.2 eth_device核心回调函数RT-Thread在lwIP下面封装了一层struct eth_device把底层的网络驱动抽象成标准设备。写enet驱动本质上就是填充这些回调函数static rt_err_t eth_net_init(rt_device_t dev); // 初始化MAC、PHY、DMA描述符 static rt_err_t eth_net_open(rt_device_t dev, rt_uint16_t oflag); static rt_err_t eth_net_close(rt_device_t dev); static rt_err_t eth_net_tx(rt_device_t dev, struct pbuf *p); // 发送一帧 static struct pbuf *eth_net_rx(rt_device_t dev); // 接收一帧其中最关键的是eth_init、eth_tx和eth_rx。tx函数接收一个lwIP封装好的pbuf里面是一整个完整的以太网帧rx函数则需要在中断或轮询上下文中检查DMA是否收到了新帧取出数据并包装成新的pbuf然后返回给上层。驱动初始化完成后通过eth_device_init把这个设备注册到系统里设备名通常叫e0。注册成功后再调用eth_device_linkchange告知协议栈链路状态。lwIP的netif接口就会关联到这块网卡上。这里提醒一下版本差异不同RT-Thread版本对eth_device的封装接口命名可能有细微差别新版本更强调netdev组件但底层逻辑完全一致。我手上的版本是RT-Thread 5.x如果你用的是4.x或者更老的版本函数名对不上是很正常的看代码注释和示例就行核心流程参考本篇没有问题。3.3 驱动该做什么、不该做什么很多人在写驱动时容易把协议栈的活也一块干了结果越写越乱。这里我总结一条原则驱动只负责三件事。第一初始化硬件配置时钟、GPIO、MAC控制寄存器、DMA描述符访问PHY完成自协商和链路检测。第二发送把上层给的一整帧数据通过描述符交给DMA发送发送完成不需要等待DMA会自己处理。第三接收DMA收到完整帧后发起接收中断驱动在中断里取出数据封装成pbuf交给上层然后立刻把描述符重新挂接回接收链等待下一个帧。至于TCP确认、重传、分片、IP地址管理这些全部由lwIP负责。驱动层不判断数据内容不对帧做任何业务逻辑处理。层与层的边界划清了代码结构自然就清爽了。4. 驱动适配实战按这个顺序做能少踩一半的坑4.1 先确认组件裁剪和BSP工程RT-Thread官方已经给GD32H759这类芯片提供了BSP工程模板直接在RT-Thread Studio里选对应开发板创建工程即可。创建完成后用menuconfig或者RT-Thread Studio的图形化配置打开网络相关组件。需要确保开启的配置项大致有RT_USING_LWIPlwIP协议栈RT_USING_NETDEV网络接口设备层RT_USING_LWIP_ETH以太网驱动支持对应PHY芯片型号比如LAN8720A的驱动这些配置开启后工程里会加入lwIP相关的源码、netdev组件代码以及网卡驱动的框架文件。很多人的驱动移植失败是因为根本没开全这些配置项结果编译报错或者驱动文件压根没进工程。4.2 时钟和GPIO初始化顺序不能乱接下来是硬件初始化的第一部分时钟、复位、GPIO复用。以下是基于我这次板子的关键代码片段注意不同SDK版本的API名称可能略有变化。/* 使能相关时钟和GPIO时钟 */ rcu_periph_clock_enable(RCU_ENET); rcu_periph_clock_enable(RCU_GPIOC); rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); /* 配置RMII接口引脚复用以PC1等为例 */ gpio_af_set(GPIOC, GPIO_AF_11, GPIO_PIN_1); /* ENET_TX_EN之类 */ gpio_mode_set(GPIOC, GPIO_MODE_AF, GPIO_PUPD_PULLUP, GPIO_PIN_1); gpio_output_options_set(GPIOC, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1); /* PHY复位引脚拉低再拉高完成一次显式复位 */ gpio_bit_reset(GPIOE, GPIO_PIN_8); rt_hw_us_delay(20 * 1000); gpio_bit_set(GPIOE, GPIO_PIN_8); rt_hw_us_delay(50 * 1000);这段代码的要点有三个。第一RMII需要配置复用的引脚不止数据线还包括REF_CLK如果是MCO输出50MHz的方案MCO引脚也应该在这里一并配置。第二PHY复位完成后必须等足够时间再访问PHY20ms是一个保守值多等无妨。第三GPIO的复用功能编号必须对着芯片手册查GD32系列不同封装的引脚复用功能编号可能不同抄别人代码前一定要核对自己的板子原理图。4.3 MAC初始化和DMA描述符的准备PHY那边的底层能通之后就该初始化ENET的MAC和DMA了。顺序很关键先复位MAC、再配置MAC控制寄存器、最后初始化DMA描述符。MAC控制寄存器里需要设置的项包括接口模式RMII还是MII、全双工/半双工、速率10M/100M、接收过滤器允许多播、广播、控制帧等。这些参数的初始值建议优先匹配PHY自协商的结果或者先手动设置成100M全双工等PHY link确认后再动态对齐。DMA描述符是网卡驱动最容易翻车的地方。描述符本质上是DMA搬运数据用的链表节点结构体里包含状态字、缓冲区地址、下一个描述符地址。接收和发送分别用各自的环形链表。初始化时必须保证每个描述符的地址是4字节对齐的缓冲区地址也一样。RT-Thread的pbuf分配器默认会做对齐但如果你自己定义了静态数组当缓冲区就要用ALIGN(n)宏或者在声明处加上对齐属性。/* 描述符结构示意具体以SDK头文件为准 */ struct enet_dma_desc { uint32_t status; /* 状态字/控制字 */ uint32_t control; /* 帧长度/缓冲区长度 */ uint32_t buffer_addr; /* 缓冲区地址 */ uint32_t next_desc; /* 下一个描述符地址 */ }; ALIGN(4) struct enet_dma_desc rx_desc[RX_DESC_NUM]; ALIGN(4) struct enet_dma_desc tx_desc[TX_DESC_NUM];发送描述符的数量可以少一点两个就够接收描述符建议至少四个因为网络流量是突发性的描述符太少直接导致DMA丢帧。配好后把接收描述符链表的首地址写到DMA的接收描述符链表地址寄存器把发送描述符链表的首地址写到发送描述符链表地址寄存器。这个步骤漏了或者地址写错表现就是初始化完全正常但一收包就死机或无响应。4.4 发送和接收路径的实现要点发送函数的逻辑相对直观取一个空闲发送描述符将pbuf的数据地址和长度填入描述符把控制字中的OWN位置1表示该描述符交给DMA处理然后启动DMA发送。static rt_err_t eth_net_tx(rt_device_t dev, struct pbuf *p) { /* 检查是否有空闲的发送描述符 */ if (tx_desc[tx_index].status DESC_OWN) { return RT_ERROR; /* 描述符还在忙说明上一次发送没完成 */ } /* 将pbuf数据地址写入描述符缓冲区 */ tx_desc[tx_index].buffer_addr (uint32_t)p-payload; tx_desc[tx_index].control p-len; /* 置OWN位触发DMA发送 */ tx_desc[tx_index].status | DESC_OWN; enet_dma_tx_poll(); tx_index (tx_index 1) % TX_DESC_NUM; return RT_EOK; }但这里有一个细节直接把pbuf地址交给DMA时要保证这个缓冲区在DMA发送完成之前不会被释放。lwIP的pbuf在调用eth_tx之后会等待驱动处理完才回收不过稳妥的做法仍然是把数据拷贝到描述符自己的缓冲区里。拷贝虽然多了一次内存操作但躲开了很多生命周期管理上的暗坑对初学者更友好。接收路径是性能瓶颈点。DMA收到帧后会写接收描述符的状态字清除OWN位并标记帧长度。驱动在中断里扫描接收描述符发现有新帧就分配一个pbuf把缓冲区里的数据拷贝进去再调用eth_device_ready通知协议栈处理。static struct pbuf *eth_net_rx(rt_device_t dev) { struct pbuf *p NULL; if (rx_desc[rx_index].status DESC_OWN) { return NULL; /* 没有新帧 */ } uint32_t len rx_desc[rx_index].control 0x3FFF; p pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p ! NULL) { memcpy(p-payload, (void *)rx_desc[rx_index].buffer_addr, len); } /* 重新挂接接收描述符交还给DMA */ rx_desc[rx_index].status DESC_OWN; rx_index (rx_index 1) % RX_DESC_NUM; return p; }接收中断的响应时间要尽量短不要在中断里做阻塞操作。拷贝可以放中断里做但复杂处理尽量移交给线程完成。我实际方案是中断里只做两步把帧数据拷贝到pbuf、调用eth_device_ready其余协议栈处理由lwIP线程去完成。4.5 PHY探测注册和link状态上报驱动初始化最后一步是PHY探测。通过MDIO总线扫描地址0到31读取PHY ID寄存器如果能读到合法的厂商ID和型号ID就说明这个地址上有PHY然后把对应的PHY设备注册到系统里。#define PHY_BMCR_REG 0x00 #define PHY_BMSR_REG 0x01 #define PHY_IDR1_REG 0x02 uint16_t id1 enet_phy_read(PHY_BMCR_REG, 0x02); uint16_t id2 enet_phy_read(PHY_BMCR_REG, 0x03); rt_kprintf(PHY ID: 0x%04X 0x%04X\n, id1, id2);如果这里打印的ID全为0xFFFF或者0x0000不要急着怀疑PHY坏了优先检查REF_CLK和复位时序。PHY探测成功后驱动要周期查询BMSR的Link状态位链路从down变up时调用eth_device_linkchange通知协议栈。RT-Thread的PHY框架会自动做这个轮询前提是配置里把对应PHY型号选对。5. 验证与调试三板斧ifconfig、ping、TCP连接5.1 编译下载后先看ifconfig驱动代码写完编译下载到板子后第一件事不是急着ping而是在终端里敲ifconfig。RT-Thread的命令行支持这个命令它会列出当前系统的网卡状态。正常情况下能看到一张名为e0的网卡并且状态显示为UPMAC地址是有效的IP地址如果是DHCP自动获取的还需要几秒钟才能显示出来。如果ifconfig里根本没有e0这张网卡问题大概率出在驱动初始化流程上设备没有注册成功或者PHY探测失败导致链路状态根本没上报。比较常见的情况是ifconfig显示网卡存在但IP地址是0.0.0.0。这说明DHCP客户端还没拿到地址或者网络里根本没有DHCP服务器。这个时候先用静态IP验证驱动是否正常把板子和电脑用网线直连电脑网卡配置成同一网段的静态IP比如板子设192.168.1.2电脑设192.168.1.100。5.2 ping不通时的排查顺序网络问题排查要有先后顺序。我遇到ping不通时从来不会盲目改配置而是按下面这个链路一步一步查现象可能原因检查步骤网卡不在ifconfig里驱动未注册成功、PHY枚举失败看串口日志的PHY ID打印检查REF_CLKifconfig显示Link down网线没插好、PHY链路检测失败读PHY BMSR寄存器连续读两次看bit2Link up但ping不通IP/掩码/网关配置不对板子和电脑都用静态IP关电脑防火墙能ping通但丢包严重DMA描述符太少、内存池不足调大接收描述符数量检查PBUF POOL大小完全不通但Link正常MAC速率/双工模式和PHY协商结果不一致检查MACCR寄存器手动改成100M全双工一个个排下来几分钟内基本能定位问题。我见过很多人一上来就怀疑断点、怀疑编译器然后折腾一晚上最后发现是电脑防火墙把ICMP包拦了。细节全是细节。5.3 TCP通信验证才是真正跑通ping通只说明ICMP层通了TCP真正能传数据才是驱动合格的标志。推荐一个最简单的验证方式板子上用RT-Thread自带的一个TCP服务器例程监听某个端口电脑端用网络调试助手或者Python脚本连接这个端口双向收发数据。import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((192.168.1.2, 8080)) s.send(bhello gd32) print(s.recv(1024)) s.close()如果TCP连接能建立、数据能收发那驱动基本可以认为已经稳定了。这时候再回头补做UDP测试、压力测试、长时间稳定性测试。工控场景对稳定性要求很高所以我会让板子连电脑连续跑一晚上UDP打流第二天看有没有丢包和死机。如果一晚上下来一切正常这个驱动才算真的过关。6. 性能表现与后续优化方向6.1 实测数据参考驱动跑通后我做了简单的性能摸底。板子作为TCP客户端连接电脑用iperf测吞吐100M的MAC实际能跑到70~80Mbps左右。ping延迟稳定在1ms以内正常网络环境下丢包率为0。这个成绩对于MCU级别的以太网方案来说已经很能打了毕竟CPU还要同时跑RT-Thread和协议栈。如果实测吞吐远低于这个数字优先检查两块接收描述符数量够不够以及lwIP的内存池配置是否偏小。lwIP的PBUF_POOL_SIZE直接影响接收并发能力把它从默认值调大一些吞吐往往立竿见影。6.2 优化方向一中断合并与轮询上限DMA中断频繁触发会消耗不少CPU时间。GD32H759的ENET支持中断合并也就是攒够若干帧或者超过一定时间再产生一次中断这样能显著降低中断频率。我在高频小包场景下开启了这个功能CPU占用降了差不多三分之一代价是单帧延迟略微变大。工控场景里如果更关注CPU余量这个方向很值得调。如果业务对延迟敏感也可以反过来做接收中断直接处理不要做任何合并同时把处理函数写得尽量精简中断里只做必要拷贝其余全部丢给协议栈线程。6.3 优化方向二描述符和内存池参数接收描述符数量我最终设成了8个发送描述符4个。对于10/100M网络来说收发各4到8个描述符是性价比较高的配置。再往上加收益越来越小反而会更耗内存。描述符每个需要32字节8个接收加4个发送总共不到400字节对1MB SRAM来说微不足道。lwIP这边rtconfig.h里几个关键参数值得关注PBUF_POOL_SIZE决定了接收缓冲池能容纳多少帧TCP_MSS影响TCP单包传输大小TCP_WND影响吞吐上限。这四个参数配不好驱动写得再好也跑不出速度。6.4 后续还能怎么扩展enet跑通只是第一步RT-Thread生态里可扩展的方向非常多。最常规的是上Modbus TCP把板子当成一个Modbus从站上位机直接通过标准Modbus协议读写寄存器工控集成效率立刻上一个台阶。再进一步是做OTA固件升级利用lwIP的HTTP客户端从服务器下载固件包配合Flash分区管理实现在线升级。还有就是接上mbedTLS把通信链路加密一些有安全需求的工控项目就能直接落地。我个人实际使用下来的感受是enet驱动的价值不在于“把网口点亮”这一下子而在于它把整个系统的网络能力释放出来了。一旦底层稳定上面每新增一个网络应用都是在既有地基上盖楼速度会越来越快。建议你自己拿到板子后先用官方示例把网络跑通再一个一个改驱动细节去验证每个寄存器的行为这样理解最深后续遇到问题也不慌。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进