做嵌入式以太网通信,第一步往往是移植协议栈。我记得第一次在GD32F407上跑通LwIP的时候,内心其实是有点复杂的——因为网上教程大半都基于STM32,GD32的例程零零散散,抄过来改过去,不是PHY不通就是内存报错。这个项目折腾了我将近两个星期,踩了不少坑,也把整个LwIP的移植路径彻底摸透了。如果你想在GD32F407上实现以太网通信,又不想被各种“哥德巴赫式”的报错折磨,这篇文章值得你认真看完。
讲下这个项目能做什么:它把LwIP协议栈完整跑在GD32F407上,实现了标准TCP/IP通信能力,包括DHCP动态获取IP、PING响应、TCP服务器与客户端收发数据,以及一个简单的HTTP网页服务。硬件上使用GD32F407VET6加一颗PHY芯片,走RMII接口,跑100Mbps。内容覆盖硬件连接、MAC驱动编写、LwIP裁剪配置、FreeRTOS系统封装层实现,以及调试阶段会遇到的各种坑。
适用人群分两类:一是已经能跑GPIO、串口、定时器,想进阶搞网络通信的单片机开发者;二是用STM32做过以太网、想迁移到GD32平台的工程师。前者可以完整学习整套移植链路,后者主要看我在GD32与STM32差异上踩过的坑,能帮你省掉大量对照手册查寄存器的痛苦。
1. 项目整体设计与移植思路拆解
1.1 为什么是GD32F407,而不是直接抄STM32的工程
GD32F407最吸引人的地方是主频200MHz,比同定位的STM32F407高了32MHz,而且内置了10/100M以太网MAC控制器,支持MII和RMII两种介质独立接口。这颗MAC和ST的解决方案在IP层面上同源,都是基于Synopsys DesignWare 3504-0,所以理论上ST的驱动代码可以移植过来用。但这里的重点在“理论上”三个字。
实际踩坑后的结论是:GD32的MAC寄存器和ST大体兼容,但不是完全兼容,尤其是在DMA描述符的管理细节上存在差异。直接拿ST官方的以太网驱动编译到GD32上,大概率是能编过去但在线调试时发现收发异常。更好的做法是以GD32官方固件库自带的以太网驱动为骨架,把LwIP的通用层挂上去,这样外设层的寄存器操作全部走GD32库函数,协议栈层面则是通用的LwIP代码,两边各管各的,移植思路最清晰。
GD32F407的另一个特色是内置了强大的DMA控制器,以太网DMA描述符支持环形和链式两种结构。我这次用的是环形结构,后面会详细讲为什么这么选。
1.2 整个系统的数据流:从网线到TCP socket要经过几层
先画一张逻辑图在脑子里,数据到底是怎么从物理网线流到应用层socket的。
物理层:网线上的模拟信号经过PHY芯片(我用的是LAN8720A)解调,变成数字比特流,通过RMII接口传给GD32F407的MAC控制器。MAC控制器负责以太网帧的组装与解析,包括前导码、CRC校验、帧间隙处理这些脏活累活。MAC和DMA之间通过DMA描述符交互——DMA把收到的数据写入内存里的缓冲区,然后置位描述符的状态位通知CPU处理。
数据链路层之上就是LwIP的地盘了。LwIP从netif底层接口收包,经过IP层解包,再交给TCP或UDP协议处理,最后通过socket或RAW API送达应用层。发送路径反过来走一遍。
理解数据通路特别重要,因为后面所有调试都是围绕这条链路逐层排查的。ping不通,可能是PHY没协商上,也可能是MAC没收包,还可能是LwIP的回包路径有问题。没有一个全局视角,排查起来会非常痛苦。
2. 硬件连接与PHY选型:RMII这根“窄路”怎么接才稳
2.1 PHY选型:为什么多数方案都选LAN8720A
GD32F407的MAC只是二层协议处理引擎,它不直接驱动网线,必须外挂一颗PHY芯片。PHY负责编解码、时钟恢复、线路驱动、自动协商这些模拟域的工作。选PHY考虑几个因素:接口类型、供电复杂度、外围元件数量、资料丰富度。
目前市面上和MCU搭配最多的是LAN8720A,它是RMII接口的低功耗10/100M以太网PHY,通过25MHz晶振配合内部PLL可以产生通信所需的50MHz参考时钟,外围电路非常精简。另一颗常见的是DP83848,走MII或RMII都行,但它需要独立的50MHz时钟源,外围BOM会多几个器件。还有国产的IP101GRI也能用,文档相对少一些,对新手不太友好。
我用的是LAN8720A,原因很简单:资料多、例程多、外围少,而且3.3V单电源供电,不用额外做1.2V内核电压。这颗芯片的地址可以通过外部引脚配置为0或1,设计时直接硬件拉低固定为0,软件读寄存器时用地址0去访问。
2.2 RMII信号连接与REF_CLK的三种时钟方案
RMII全称Reduced Media Independent Interface,与MII相比最大的优势是信号线少。MII需要16根数据线,RMII把数据位宽减半到2位,同时用50MHz时钟替代MII的25MHz,整体只需要7根信号线加MDIO管理接口。具体连接是这样的:
| 功能 | MCU引脚方向 | 连接到LAN8720A | 说明 |
|---|---|---|---|
| TX_EN | 输出 | TX_EN | 发送有效指示,高电平表示正在发送数据 |
| TXD[1:0] | 输出 | TXD[1:0] | 2位发送数据 |
| RXD[1:0] | 输入 | RXD[1:0] | 2位接收数据 |
| CRS_DV | 输入 | CRS_DV | 载波侦听/数据有效 |
| REF_CLK | 双向/输入 | REF_CLK | 50MHz参考时钟,由PHY或外部提供 |
| MDC | 输出 | MDC | 管理接口时钟,最高2.5MHz |
| MDIO | 双向 | MDIO | 管理接口数据线 |
这里最关键的坑就是REF_CLK。GD32F407的RMII接口要求MAC侧有一个50MHz参考时钟,这个时钟的来源有三种方案:
方案一:由LAN8720A产生。给PHY接一个25MHz晶振,PHY内部PLL倍频出50MHz,从CLK_OUT引脚输出给MCU的ETH_RMII_REF_CLK引脚。这种方案最省事,也是我实际采用的方案,两个芯片在时钟树上天然同步。
方案二:由MCU产生。用GD32F407的MCO引脚输出50MHz时钟给PHY。理论上可行,但我实测下来时钟抖动偏大,在一些环境温度变化大的场景下容易偶发丢包,不太推荐。
方案三:外部50MHz有源晶振同时供给两边。信号质量最好,但BOM成本最高,而且需要额外的电源滤波处理。
调试的时候就卡在时钟上过一回:第一次打样REF_CLK走线太长,过孔太多,信号质量差,10Mbps能通但100Mbps完全不行。后来把走线缩短、控制在同一层、远离其他高速信号,问题立刻消失。RMII对信号质量非常敏感,这是硬件上必须重视的问题。
2.3 原理图上的几个容易踩的坑
LAN8720A的复位电路值得特别强调。它的复位引脚低电平有效,要求至少25ms的复位脉冲,而且复位释放后PHY芯片内部还需要一段时间做初始化,通常建议复位释放后再等1秒左右再去访问PHY寄存器,否则MDIO读回来的数据全是0xFF。
我最初的设计就是偷懒,用MCU的GPIO直接拉了一下复位引脚就立刻去读PHY ID,结果读了一整天都是0xFFFF,百思不得其解。后来查了LAN8720A的数据手册才发现上面明确写了上电稳定时间,真的是学费级教训。
另一个坑是PHY地址配置。LAN8720A的PHYAD[0]引脚和RXER引脚复用,硬件上用一个10K电阻下拉接地,PHY地址就是0。如果这个引脚悬空或配置不对,MDIO通信就会失败。原理图评审时需要对一下这个引脚状态。
还有一点,LAN8720A的LED引脚可以配置为状态输出模式,一个是link/activity,一个是speed指示。调试时接上这两个LED能帮你第一时间判断物理链路是否建立,别省这个钱。
3. LwIP移植的具体步骤:从裸机到带操作系统的完整链路
3.1 先跑通不带协议栈的MAC回环:验证硬件基础
很多人的惯常做法是直接把LwIP整个工程拷过来编译下载,然后发现ping不通,就开始满天排查。我强烈建议分步走,第一步先验证硬件链路。
不带协议栈的情况下,只初始化GD32的MAC控制器、DMA和PHY,然后向网络上发一个自定义的以太网帧。用Wireshark抓包看能不能收到。如果抓不到,先检查PHY的Link状态寄存器有没有置位,再查RMII接口的信号。
这一步通过率其实没那么高,尤其是自己做板子的人。硬件设计问题在这一步就会全部暴露,而不会混进协议栈的问题里。这也是为什么我建议在跑LwIP之前花半天时间把这块验证掉,性价比极高。
3.2 sys_arch操作系统封装层的实现要点
LwIP本身是一个协议栈,不依赖操作系统也能跑,但移植到带RTOS的项目里,最好是启用操作系统模式。这样TCP/IP处理跑在独立的tcpip线程里,应用线程通过API和它交互,整个架构更清晰,也不会因为协议处理阻塞应用逻辑。
启用操作系统模式需要实现sys_arch层的几个关键接口,包括信号量、互斥锁、邮箱和线程创建。这些接口是操作系统和LwIP之间的桥梁。
以我用的FreeRTOS为例,信号量和互斥锁可以直接映射到FreeRTOS的SemaphoreHandle_t和MutexHandle_t。邮箱适合用FreeRTOS的队列实现,但要注意LwIP的邮箱机制实际上可以基于信号量加环形缓冲来实现。考虑到FreeRTOS队列本身就是拷贝式的,直接把LwIP需要传递的指针放进队列里就行,这样不涉及大量数据拷贝,效率更高。
线程创建则通过sys_thread_new接口调用FreeRTOS的xTaskCreate实现。需要特别注意给tcpip_thread分配足够的栈空间,我实测下来默认的256字(1KB)根本不够,HTTP服务稍微跑一点数据就栈溢出。实际配置在1536字到2048字之间比较稳妥。
另一个非常关键但容易被忽略的接口是sys_check_timeouts,它负责驱动LwIP的定时器系统。在FreeRTOS里,可以创建一个低优先级的任务,循环调用sys_check_timeouts。这个任务不能太频繁调用,否则浪费CPU,但也不能间隔太久,一般10ms调用一次就能满足TCP重传定时器的精度要求。
3.3 底层netif驱动的三个核心函数怎么写
netif层是LwIP与硬件驱动之间的纽带,核心要实现的函数就三个。
首先是low_level_init,这个函数负责初始化硬件,包括设置MAC地址、配置DMA描述符环、使能接收。还有一个隐藏任务:把网卡的硬件最大传输单元MTU填到netif结构体里,LwIP会根据这个值来拆分上层数据。
其次是low_level_output,负责把LwIP交给你的pbuf链表里的数据发送出去。这里有个效率与稳定性的权衡:pbuf在协议栈里组织成链表,每个节点的数据在内存里可能不连续,而DMA发送描述符指向单个连续缓冲区。一种做法是逐节点把数据拷贝到一个连续的DMA缓冲区,好处是简单可靠,坏处是每次发送都多一次内存拷贝。另一种做法是让每个描述符指向对应pbuf的payload,实现零拷贝发送,但要求内存布局完全可控。首次移植建议先用第一种方式跑通,后面再优化性能。
最后是low_level_input,在接收中断服务程序里被调用。它先检查DMA接收描述符的状态位,确认有数据到达,然后从描述符指向的缓冲区把数据封装成pbuf,调用netif->input函数把包送进协议栈,再把描述符重新交还给DMA。这里有一个细节:描述符的缓冲区地址必须按4字节对齐,因为DW3504 MAC用地址的低两位标记所有权和状态。
中断服务程序的写法也值得讲究。每次进接收中断就关闭全局中断,处理完再打开,这种方法在低频场景没问题,但一旦网络流量大了就会丢包。更好的做法是在中断里批量处理完所有已收到的包再退出,这样中断关闭的时间窗口更短。
3.4 用官方例程还是从头写
这里必须重点说一下GD32和STM32在以太网驱动上的差异。GD32F407的官方固件库(GigaDevice GD32F4xx Firmware Library)里提供了以太网MAC、DMA和PHY的驱动实现,封装成了库函数。这套驱动底层寄存器操作和ST风格类似,但不是一模一样。
我的做法是:协议栈层(LwIP核心代码)保持通用,通过netif结构体访问底层接口;底层接口再调用GD32官方库函数操作MAC和DMA。如果选择直接移植ST的ETH驱动,编译可能不出错,但运行起来会有一些隐蔽的问题,比如DMA描述符状态位的判断方式可能与GD32的硬件实现有细微差别。既然GD32官方已经给出了经过验证的驱动,没必要舍近求远。
从STM32H723用CubeMX生成的LwIP工程迁移到GD32也是类似思路:CubeMX帮你生成的LwIP核心代码可以直接复制过来,但底层的ethernetif.c中调用的HAL库函数需要替换成GD32库对应的实现。重点检查stm32_eth_init、HAL_ETH_TransmitFrame这些函数的替换是否完整。
4. lwipopts.h裁剪配置与内存规划:决定系统能不能长期稳定跑
4.1 关键宏参数怎么定
lwipopts.h是LwIP的配置总开关,移植工作的一半其实是在调这个文件里的参数。参数调不好,最常见的结果就是内存不足编译失败,或者运行时频繁断言。
几个最重要参数的取值逻辑:
MEM_SIZE决定堆内存的大小,协议栈运行时的动态分配都从这个堆里来。太小会频繁分配失败,太大浪费RAM。GD32F407VET6有192KB SRAM,我给了12KB。
PBUF_POOL_SIZE决定接收缓冲区池的数量。每个池缓冲默认大小1518字节,刚好容纳一个最大以太网帧。这个值太小,在突发流量下会丢包,我配置了16个,总共约24KB内存。
TCP_WND是TCP接收窗口大小,决定了接收方最多能缓冲多少未确认数据。按照TCP规范,接收窗口至少应该是4倍MSS(最大报文段大小),TCP_MSS设为1460字节,所以窗口至少5840字节,我给了8192字节。
TCP_SND_BUF是发送缓冲区大小,我同样配置为8192字节。这个值决定了TCP一次最多能缓存多少应用层待发送数据。
LWIP_DHCP和LWIP_DNS都打开,分别用于动态获取IP和域名解析。对于需要固定IP的工业场景,可以关掉DHCP省一点代码空间,但开发调试阶段最好开着,方便接入不同网络环境。
4.2 DMA描述符环与缓冲区要分配在哪块内存
GD32F407的片上SRAM分好几块,普通SRAM可以被DMA访问,但CCM SRAM不行。以太网DMA使用的发送和接收缓冲区,以及描述符链表本身,都必须放在普通SRAM区域。
描述符怎么分配特别重要。我使用环形结构,接收方向配置4个描述符,每个描述符关联一个1520字节的缓冲区;发送方向也配置4个描述符。这样接收和发送各有4个数据缓冲槽位在轮转。
配置描述符时有一个容易踩的坑:描述符本身的内存地址需要按4字节对齐,并且缓冲区地址同样要4字节对齐。LwIP的pbuf在分配内存时默认已经做了对齐处理,但你在初始化描述符时传递的缓冲区地址如果是裸数组,必须确保编译器把它放在了正确的边界上。
经验做法是定义描述符时使用一个联合体强制对齐:
typedef union { eth_dma_desc_t desc; uint32_t align[4]; } eth_desc_align_t; __ALIGN_BEGIN static eth_desc_align_t rx_desc_tab[ETH_RX_DESC_CNT] __ALIGN_END;这样无论编译器默认对齐策略是什么,描述符本身都满足硬件要求。
4.3 校验和用硬件还是软件
以太网帧的IP和TCP/UDP校验和可以由LwIP软件计算,也可以交给MAC硬件计算。GD32F407的MAC支持发送方向的IP、TCP、UDP校验和自动填充,也支持接收方向的校验和检查。
我第一次移植图省事,把校验和全部交给硬件处理,然后在lwipopts.h里关闭软件校验宏。结果发现TCP通信一直有偶发性的数据错误。排查了很久才发现是接收方向的IP层校验和检查配置没对,导致某些报文被硬件误判为坏包丢弃。
建议第一次移植时校验和走软件路径,就是让LwIP自己计算和校验,代码上不做任何裁剪。等整个链路稳定了,再考虑开启硬件校验加速。这样即便出问题,排查范围也小很多。
5. 实测与排障:把前期遇到的问题一次性说清楚
5.1 移植成功后的标准测试流程
代码编译烧录后,不建议直接开搞HTTP服务器,而是按照下面的阶梯式测试流程来验证每一步:
第一步:串口打印PHY寄存器信息,确认MDIO通信正常,PHY的ID寄存器能读出0x0007,Link状态寄存器显示网线已连接且协商为100M全双工。这一步能验证硬件设计和驱动初始化是否正确。
第二步:通过DHCP获取IP地址。成功的话打印出IP、网关和子网掩码,并用路由器管理页面确认设备已经接入局域网。如果DHCP失败,先手动配置静态IP再测,缩小排查范围。
第三步:用PC的ping命令测试ICMP回显功能。ping通了说明IP层和底层收发的路径基本没问题。
第四步:建立一个TCP服务器监听的端口,用PC上的网络调试工具连接,互发数据验证TCP建链、数据传输和断开重连。
第五步:开启HTTP服务器功能,用浏览器访问设备IP,能看到网页说明整个协议栈和应用层的配合是正常的。
5.2 常见问题速查表
| 症状 | 排查方向 | 解决办法 |
|---|---|---|
| PHY寄存器读取全0xFF | PHY复位时序、MDIO引脚配置 | 拉低复位引脚至少25ms后延时1秒再访问;检查PHYAD引脚电平 |
| PING不通但DHCP正常 | 回包路径问题 | 检查ARP表是否正常,抓包看ICMP请求有没有到设备,再用Wireshark看回包是否发出 |
| 100Mbps协商失败只能10M | REF_CLK信号质量差 | 缩短REF_CLK走线,检查是否和高速信号并行走线产生串扰 |
| TCP传输一段时间后断开 | 内存不足或描述符耗尽 | 调大TCP_WND/PBUF_POOL_SIZE,检查发送描述符是否有耗尽后未回收的情况 |
| HTTP网页刷新非常慢 | tcpip线程栈不足或优先级过低 | 增大线程栈,适当提高tcpip线程优先级 |
| 偶发死机 | 中断优先级配置不当 | 确保以太网DMA中断优先级高于协议栈中可能关中断的临界区,但低于临界区禁止的高优先级中断阈值 |
| RXDV信号不稳定 | PHY和MCU共地不良 | 检查地平面完整性,避免PHY和MCU在板内被分割地平面隔开 |
除了表里的问题,还有一个排查链路问题时的通用技巧:学会看PHY寄存器。LAN8720A的寄存器1(基本状态寄存器)的bit2是链接状态,bit5是自动协商完成标志。调试时把这两个位打印出来,配合LED指示灯,能快速判断物理层是否就绪。
5.3 从LwIP移植延伸到其他协议栈移植的思路
这次做完LwIP移植之后,我明显感觉到协议栈移植这件事是有方法论可循的。后来接触J1939协议栈时,虽然一个是TCP/IP栈,一个是CAN总线高层协议,但移植路径惊人地相似:搞清楚底层硬件的收发机制,把协议栈需要的数据接口抽象出来,然后配置内存与缓冲策略,最后逐层验证。
如果你做完这个GD32F407加LwIP的项目,再去移植其他协议栈,思路会很顺。核心就是先摸清硬件能力边界,再把协议栈和硬件之间的薄薄一层适配写好,剩下的交给时间打磨。
最后再分享一点个人体会
经过这个项目,我最深的感受是:LwIP移植难的不是代码本身,而是对整条数据通路要有清晰的认知。每次你解决一个问题,都会对这条链路多一层理解。当你能从PHY寄存器状态一路追溯到TCP窗口大小配置,从一个奇怪的板级现象快速定位到时钟信号质量时,才算真正吃透了这套网络通信方案。
还有一件事想特别提醒:遇到问题时别急着改代码,先用Wireshark抓包、多看官方库的例程、把硬件和协议栈的边界画清楚。我花在排查和读手册上的时间,远多于写代码的时间,但这些时间绝对没有白费。希望这篇实战记录能帮你少走一些弯路,在GD32F407上顺利跑起自己的以太网应用。