1. 项目缘起与整体设计思路
1.1 为什么要在RT-Thread上折腾W5500这颗老网卡芯片
W5500这颗芯片在嵌入式圈子里算是老面孔了。它是一颗硬件TCP/IP协议栈的以太网控制器,SPI接口,内置32KB收发缓存,支持8个独立Socket同时工作。我第一次接触它是在一个工业数据采集项目上,主控用的是GD32F103,需要把现场传感器的数据通过有线网络上传到本地服务器。当时选型的时候对比过几种方案:用STM32F103自带的MAC加外部PHY、用W5500这种硬件协议栈芯片、或者干脆上带以太网的MCU。最终选W5500的原因很直接——它把TCP/IP协议栈用硬件实现了,MCU端只需要通过SPI读写寄存器就能收发网络数据,不需要跑LwIP这种软件协议栈,对RAM和Flash的占用极小,GD32F103的64KB Flash和20KB RAM完全扛得住。
但问题也随之而来。W5500的驱动代码在不同项目之间复用性很差,每次换主控或者换RTOS都要重新适配一遍。这次我决定把它做成一个规范的硬件抽象层,跑在RT-Thread上,让驱动和硬件平台彻底解耦。这个项目标题里的“从零到一”不是噱头,是我确实从寄存器手册第一页开始翻,把W5500的SPI时序、寄存器映射、Socket状态机全部重新梳理了一遍。
这个内容适合谁看?如果你正在用STM32F103或者GD32F103这类Cortex-M3内核的MCU,想通过SPI挂W5500实现有线网络通信,并且希望驱动代码能在不同平台之间平滑迁移,那这篇经验分享应该能帮你省下不少翻手册和调试的时间。即使你用的是其他主控,硬件抽象层的设计思路也是通用的。
1.2 硬件抽象层到底抽象了什么
很多人对“硬件抽象层”的理解停留在“把寄存器操作封装成函数”这个层面,但真正做过跨平台驱动的人知道,HAL的核心价值在于定义一套稳定的接口契约,让上层应用完全不关心底层用的是哪颗MCU、哪个SPI外设、甚至哪个RTOS。
在这个项目里,我把HAL分成了三层。最底层是硬件接口层,负责SPI的初始化、片选控制、读写时序,这一层直接跟GD32F103或STM32F103的SPI外设打交道。中间层是W5500寄存器操作层,封装了W5500所有寄存器的读写函数,包括通用寄存器、Socket寄存器、收发缓存区。最上层是网络功能层,提供Socket打开、连接、发送、接收、关闭这些面向应用的功能。
这样分层的好处是,当你从GD32F103换到STM32F103的时候,只需要重写最底层的SPI初始化和片选控制,中间层和上层完全不用动。我实测过,从GD32F103移植到STM32F103,底层改动不超过50行代码,半天就能跑通。
1.3 为什么选RT-Thread而不是裸机
裸机跑W5500当然可以,主循环里轮询Socket状态就行。但实际项目里往往还有其他任务要处理,比如串口命令解析、传感器数据采集、LED状态指示。裸机轮询的方式会导致网络响应不及时,特别是TCP连接需要重连的时候,轮询间隔太长会丢包。
RT-Thread的线程机制和IPC(线程间通信)正好解决这个问题。我给W5500驱动单独开一个线程,优先级设在中等级别,线程里阻塞等待Socket事件。当有数据到达或者连接状态变化时,通过邮箱或者事件集通知应用线程。这样网络处理和其他任务互不干扰,实时性也有保障。
另外RT-Thread的rt_device框架和rt_pin框架让驱动可以注册成标准设备,应用层用rt_device_find和rt_device_open就能操作,代码风格统一,后期维护方便。
2. W5500硬件设计与SPI通信细节
2.1 W5500参考电路的关键设计点
W5500的参考电路看起来简单,但有几个地方如果处理不好,调试的时候会让你怀疑人生。我先把核心电路拆开说。
晶振电路:W5500需要一颗25MHz的晶振,负载电容 typically 是18pF到22pF。我遇到过一块板子晶振不起振,查了半天发现是负载电容选了33pF,导致振荡裕度不够。后来换成18pF就正常了。晶振的两个引脚各接一个负载电容到地,走线尽量短,包地处理。
复位电路:W5500的RESET引脚是低电平复位,内部有上拉。我一般外接一个10K上拉到3.3V,再并一个100nF电容到地,复位引脚直接接到MCU的一个GPIO上,方便软件控制复位。注意复位低电平持续时间至少要500us,我一般延时10ms确保稳定。
SPI接口:W5500的SPI支持模式0和模式3,我习惯用模式0(CPOL=0,CPHA=0)。SCLK、MOSI、MISO、CS四根线,CS由MCU的GPIO控制。这里有个坑:W5500的SPI时钟最高支持80MHz,但实际跑起来受限于PCB走线和MCU的SPI外设能力。我在GD32F103上跑SPI时钟设到18MHz很稳,再高就开始丢数据。STM32F103的SPI2最高18MHz,SPI1最高36MHz,但W5500这边建议不要超过30MHz,留点裕量。
网络变压器和RJ45:W5500的差分信号输出需要经过网络变压器再到RJ45座。变压器选型要注意匝比和共模抑制比,我一般用H1102或者HR911105A这类集成变压器的RJ45座,省事。TX+/-和RX+/-的差分走线要等长,阻抗控制在100欧姆左右。
电源去耦:W5500有多个电源引脚,每个引脚旁边都要放一个100nF电容,整体再并一个10uF的钽电容。我见过有人只在总电源放了一个电容,结果网络通信时断时续,查了好久才发现是电源纹波太大。
2.2 GD32F103与STM32F103的SPI差异
GD32F103和STM32F103在SPI外设上基本兼容,但有几个细节需要注意。GD32F103的SPI时钟树配置和STM32F103略有不同,GD32的APB2时钟默认是108MHz,SPI1挂载在APB2上,分频系数要重新算。STM32F103的APB2默认72MHz,SPI1最高36MHz。
我在两个平台上都跑过W5500驱动,SPI初始化代码几乎一样,只是时钟分频参数不同。GD32F103上我设SPI时钟为APB2的8分频,得到13.5MHz;STM32F103上设APB2的4分频,得到18MHz。实测两个速率下W5500都能稳定工作。
还有一个差异是GPIO的驱动能力。GD32F103的GPIO翻转速度比STM32F103稍快,但在SPI应用里这点差异可以忽略。片选信号的建立时间和保持时间要满足W5500的要求,我一般会在片选拉低后延时1us再发时钟,发送完最后一个字节后延时1us再拉高片选。
2.3 SPI通过DMA方式读取数据的实现
当W5500接收缓存里有大量数据时,用SPI轮询方式读取会占用大量CPU时间。STM32F103的SPI支持DMA传输,可以大幅降低CPU占用。我实测过,用DMA读取2KB数据,CPU占用从轮询方式的约30%降到不到5%。
配置DMA的步骤是这样的:先初始化DMA通道,设置外设地址为SPI数据寄存器,内存地址为接收缓冲区,传输方向为外设到内存,数据宽度为字节。然后使能SPI的DMA接收请求。在W5500的接收函数里,先发送读缓存区命令和地址,然后启动DMA接收,等待DMA传输完成标志。
这里有个关键点:W5500的SPI帧格式是“地址段+控制段+数据段”,地址段和控制段必须用轮询方式发送,只有数据段可以用DMA。所以实际流程是:拉低片选,轮询发送3字节(2字节地址+1字节控制),然后启动DMA接收N字节数据,等待DMA完成,拉高片选。
GD32F103的DMA配置和STM32F103类似,但寄存器名称和位定义有差异。我在两个平台上分别写了DMA初始化函数,通过宏定义切换。
3. RT-Thread下的驱动框架与实操过程
3.1 在RT-Thread Studio中搭建工程
RT-Thread Studio是我常用的开发环境,基于Eclipse,集成了RT-Thread的配置工具。新建工程的时候选择“基于芯片”或者“基于开发板”,我一般选基于芯片,然后手动选GD32F103或STM32F103。
工程建好后,第一步是在RT-Thread Settings里使能SPI设备驱动。RT-Thread的SPI框架会自动注册SPI总线,我只需要在board.h里定义SPI引脚和片选引脚。以STM32F103为例,我用SPI1,引脚是PA5(SCLK)、PA6(MISO)、PA7(MOSI),片选用PA4。在board.h里添加宏定义:
#define BSP_USING_SPI1 #define BSP_SPI1_SCK_PIN GET_PIN(A, 5) #define BSP_SPI1_MISO_PIN GET_PIN(A, 6) #define BSP_SPI1_MOSI_PIN GET_PIN(A, 7)片选引脚我单独定义,因为W5500的片选需要手动控制,不走SPI框架的自动片选。
#define W5500_CS_PIN GET_PIN(A, 4) #define W5500_RESET_PIN GET_PIN(A, 3)然后在drv_spi.c里确认SPI初始化代码已经适配。RT-Thread的STM32系列驱动包已经包含了SPI驱动,GD32系列可能需要手动添加或者从STM32移植。
3.2 W5500驱动层的代码结构
我的W5500驱动代码分成四个文件:w5500.h定义寄存器地址和数据结构,w5500.c实现寄存器读写和Socket操作,w5500_port.c实现SPI底层接口,w5500_config.h存放用户配置。
w5500.h里我定义了W5500的所有寄存器地址。W5500的寄存器地址是16位的,分为通用寄存器和Socket寄存器。通用寄存器从0x0000开始,Socket寄存器每个Socket占0x0100的地址空间,8个Socket从0x0100到0x0800。收发缓存区从0x1000开始,每个Socket的发送缓存和接收缓存各占16KB,通过寄存器配置。
#define W5500_COMMON_BASE 0x0000 #define W5500_SOCKET_BASE(n) (0x0100 + (n) * 0x0100) #define W5500_TX_BASE(n) (0x1000 + (n) * 0x1000) #define W5500_RX_BASE(n) (0x2000 + (n) * 0x1000)w5500_port.c里实现四个核心函数:w5500_spi_read、w5500_spi_write、w5500_cs_select、w5500_cs_deselect。这四个函数是硬件相关的,移植的时候只需要改这个文件。
static void w5500_cs_select(void) { rt_pin_write(W5500_CS_PIN, PIN_LOW); } static void w5500_cs_deselect(void) { rt_pin_write(W5500_CS_PIN, PIN_HIGH); } static void w5500_spi_write(uint16_t addr, uint8_t ctrl, uint8_t *buf, uint16_t len) { w5500_cs_select(); /* 发送地址高字节 */ rt_spi_send(spi_dev, &addr_high); /* 发送地址低字节 */ rt_spi_send(spi_dev, &addr_low); /* 发送控制字节 */ rt_spi_send(spi_dev, &ctrl); /* 发送数据 */ rt_spi_send(spi_dev, buf, len); w5500_cs_deselect(); }w5500.c里实现Socket操作函数,包括w5500_socket_open、w5500_socket_connect、w5500_socket_send、w5500_socket_recv、w5500_socket_close。这些函数操作W5500的Socket寄存器和收发缓存。
3.3 网络线程的设计与实现
在RT-Thread里,我创建一个网络线程专门处理W5500的Socket事件。线程优先级设为RT_THREAD_PRIORITY_MAX / 2,栈大小1024字节。
static void w5500_thread_entry(void *parameter) { struct sockaddr_in server_addr; uint8_t recv_buf[1024]; int recv_len; /* 初始化W5500 */ w5500_init(); /* 配置网络参数 */ w5500_set_mac(mac); w5500_set_ip(ip); w5500_set_gateway(gateway); w5500_set_subnet(subnet); while (1) { /* 打开Socket 0为TCP模式 */ w5500_socket_open(0, W5500_SOCK_TCP); /* 连接服务器 */ w5500_socket_connect(0, server_ip, server_port); /* 等待连接成功 */ while (w5500_socket_status(0) != W5500_SOCK_ESTABLISHED) { rt_thread_mdelay(10); } /* 收发数据 */ while (1) { recv_len = w5500_socket_recv(0, recv_buf, sizeof(recv_buf)); if (recv_len > 0) { /* 处理接收到的数据 */ process_data(recv_buf, recv_len); } else if (recv_len < 0) { /* 连接断开,跳出重连 */ break; } rt_thread_mdelay(1); } /* 关闭Socket */ w5500_socket_close(0); rt_thread_mdelay(1000); } }这个线程的逻辑是:打开Socket、连接服务器、循环收发数据、连接断开后重连。实际项目中我会加入心跳包机制,定时向服务器发送心跳,如果连续几次没有收到回应就主动断开重连。
3.4 参数计算与配置细节
W5500的收发缓存大小是可以配置的。每个Socket的发送缓存和接收缓存各16KB,8个Socket共享这32KB。通过Sn_TXBUF_SIZE和Sn_RXBUF_SIZE寄存器配置,单位是KB,每个Socket的缓存大小必须是1、2、4、8、16这几个值之一。
我一般只用Socket 0,所以把Socket 0的发送和接收缓存都设为16KB,其他Socket设为0。这样Socket 0有最大的缓存空间,适合大数据量传输。
/* 配置Socket 0缓存为16KB */ w5500_write_reg(W5500_SOCKET_BASE(0) + W5500_Sn_TXBUF_SIZE, 16); w5500_write_reg(W5500_SOCKET_BASE(0) + W5500_Sn_RXBUF_SIZE, 16);SPI时钟的计算:STM32F103的SPI1挂载在APB2上,APB2时钟为72MHz。SPI时钟 = APB2时钟 / 分频系数。我设分频系数为4,得到18MHz。GD32F103的APB2时钟为108MHz,设分频系数为8,得到13.5MHz。两个速率下W5500都能稳定工作,误码率为零。
4. 常见问题排查与实操避坑指南
4.1 W5500初始化失败的排查思路
W5500初始化失败是最常见的问题,表现是读VERSIONR寄存器返回0x00或者0xFF。排查步骤我总结了一个流程。
第一步,检查SPI通信是否正常。用示波器或者逻辑分析仪抓SCLK、MOSI、MISO、CS四根线。正常的SPI波形是:CS拉低后,SCLK出现8个时钟脉冲,MOSI上数据在时钟上升沿变化,MISO上数据在时钟下降沿采样。如果SCLK没有波形,检查SPI外设是否使能、GPIO是否配置为复用功能。
第二步,检查片选信号。W5500的CS必须在整个SPI传输期间保持低电平,传输结束后才能拉高。我遇到过CS在字节之间被拉高的情况,原因是SPI框架的自动片选功能没有关闭。在RT-Thread里,如果使用rt_spi_send,需要确保spi_dev的片选引脚配置为软件控制,或者直接用GPIO手动控制。
第三步,检查复位时序。W5500上电后需要至少500us的低电平复位,我一般延时10ms。如果复位时间不够,W5500内部状态机可能没有正确初始化。
第四步,检查电源和晶振。用万用表量W5500的电源引脚,应该是稳定的3.3V。用示波器量晶振引脚,应该有25MHz的正弦波,幅度在1V左右。如果晶振不起振,检查负载电容和晶振本身。
4.2 网络连接不稳定的原因分析
网络连接不稳定表现为:TCP连接频繁断开、数据发送失败、接收数据丢包。我遇到过几种典型情况。
SPI速率过高:W5500的SPI时钟超过30MHz后,通信误码率会上升。我一开始把STM32F103的SPI1设到36MHz,结果网络通信时断时续。降到18MHz后问题消失。所以SPI速率不是越高越好,要留裕量。
电源纹波过大:W5500在网络通信时电流会有波动,如果电源去耦不够,电压会跌落导致W5500复位。我在每个电源引脚旁边加了100nF电容,整体加了10uF钽电容后问题解决。
网络变压器不匹配:不同厂家的网络变压器参数有差异,如果匝比或者共模抑制比不匹配,会导致信号质量下降。我一般用H1102或者HR911105A,这两个型号经过大量项目验证,兼容性好。
Socket缓存配置不当:如果Socket缓存设得太小,大数据量传输时会丢包。我把Socket 0的缓存设为16KB后,2KB的数据包连续发送没有丢包。
4.3 从GD32F103移植到STM32F103的注意事项
移植的时候,底层SPI初始化代码需要改,其他部分基本不用动。GD32F103的SPI初始化函数和STM32F103的类似,但寄存器名称不同。我一般用宏定义区分:
#ifdef USE_GD32F103 spi_init_gd32(); #else spi_init_stm32(); #endifGPIO配置也有差异。GD32F103的GPIO复用功能配置寄存器和STM32F103不同,GD32需要设置GPIOx_AFSEL寄存器,STM32F103需要设置GPIOx_CRL或GPIOx_CRH寄存器。我在w5500_port.c里把GPIO初始化也封装成函数,移植的时候只改这个函数。
RT-Thread的设备驱动框架在两个平台上都支持,但GD32F103的驱动包可能不如STM32F103完善。如果RT-Thread Studio里没有GD32F103的BSP,可以从STM32F103的BSP移植,主要改时钟配置和启动文件。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读VERSIONR返回0x00 | SPI通信失败 | 逻辑分析仪抓SPI波形 | 检查SPI初始化、片选信号 |
| 读VERSIONR返回0xFF | MISO线未连接或上拉 | 万用表量MISO对地电压 | 检查MISO走线、上拉电阻 |
| 网络连接频繁断开 | SPI速率过高 | 降低SPI时钟测试 | SPI时钟降到18MHz以下 |
| 数据发送失败 | Socket缓存不足 | 读Sn_TXBUF_SIZE寄存器 | 增大Socket缓存 |
| 接收数据丢包 | 接收缓存溢出 | 读Sn_RX_RSR寄存器 | 及时读取数据、增大缓存 |
| 晶振不起振 | 负载电容不匹配 | 示波器量晶振波形 | 更换18pF负载电容 |
| 复位后无响应 | 复位时间不足 | 示波器量RESET引脚 | 延时10ms以上 |
4.5 实操心得与避坑技巧
心得一:先调通SPI再调网络。很多人一上来就写完整的网络代码,结果SPI都没通,网络肯定跑不起来。我的做法是先写一个简单的SPI读写测试,读W5500的VERSIONR寄存器,确认返回0x04后再继续。
心得二:用逻辑分析仪抓SPI波形。逻辑分析仪是调试SPI的利器,几十块钱的USB逻辑分析仪就够用。抓波形的时候注意看CS、SCLK、MOSI、MISO的时序关系,特别是CS的建立时间和保持时间。
心得三:W5500的寄存器地址要仔细核对。W5500的寄存器地址是16位的,发送的时候先发高字节再发低字节。我一开始把高低字节搞反了,读出来的数据全是乱的。后来对着手册一个一个核对才找到问题。
心得四:网络调试助手是必备工具。我用NetAssist或者SSCOM这类网络调试助手,在PC上建一个TCP服务器,W5500作为客户端连接。这样可以直观地看到数据收发情况,排查问题很方便。
心得五:RT-Thread的rt_kprintf要善用。在驱动代码里加日志输出,打印关键寄存器的值和函数执行流程。但注意不要在中断里打印,会影响实时性。我一般在初始化阶段和错误处理分支里加日志。
心得六:Socket关闭后要等待一段时间再重连。W5500的Socket关闭后,内部状态机需要时间回到CLOSED状态。如果立即重连,可能会失败。我一般延时1秒后再重连。
心得七:注意字节序问题。W5500的寄存器是大端模式,而STM32F103和GD32F103是小端模式。在读写16位或32位寄存器的时候要注意字节序转换。我写了一个宏来做转换:
#define HTONS(x) ((uint16_t)((((x) & 0xFF00) >> 8) | (((x) & 0x00FF) << 8)))这个宏在设置IP地址、端口号的时候经常用到。
心得八:W5500的发送缓存写入后要发送SEND命令。很多人写完数据后忘记发SEND命令,导致数据没有真正发送出去。发送流程是:写Sn_TXBUF_SIZE寄存器确认缓存大小,写数据到发送缓存,写Sn_TX_WR寄存器更新写指针,然后写Sn_CR寄存器发送SEND命令。
心得九:接收数据前要读Sn_RX_RSR寄存器确认数据长度。W5500接收到数据后,Sn_RX_RSR寄存器会更新接收数据长度。先读这个寄存器获取长度,再从接收缓存读取数据,最后写Sn_RX_RD寄存器更新读指针,发RECV命令。
心得十:硬件抽象层的接口要稳定。我在设计HAL的时候,把上层应用和底层硬件完全隔离。上层应用只调用w5500_socket_send和w5500_socket_recv,不关心底层是SPI还是其他接口。这样即使以后换用其他网络芯片,上层应用也不用改。
5. 驱动优化与扩展思路
5.1 中断方式替代轮询
目前我的驱动是轮询方式读取Socket状态,CPU占用率虽然不高,但还有优化空间。W5500的INT引脚在Socket事件发生时会拉低,可以接到MCU的外部中断引脚上。在中断服务函数里发送事件给网络线程,网络线程再处理具体事件。这样网络线程可以阻塞等待事件,进一步降低CPU占用。
配置中断的步骤:把W5500的INT引脚接到STM32F103的某个GPIO上,配置为下降沿触发中断。在中断服务函数里发送事件集或者邮箱给网络线程。网络线程用rt_event_recv或者rt_mb_recv阻塞等待。
5.2 多Socket并发处理
W5500支持8个Socket同时工作,可以同时作为TCP服务器、TCP客户端、UDP端点。我的驱动目前只用了Socket 0,扩展多Socket的时候,每个Socket开一个线程,或者用一个线程轮询多个Socket。我倾向于后者,因为线程太多会增加调度开销。
多Socket的驱动代码需要把Socket号作为参数传入,寄存器地址根据Socket号计算。w5500_socket_open、w5500_socket_connect这些函数都要加一个Socket号参数。
5.3 与Modbus协议的结合
工业项目里经常用Modbus TCP协议,W5500作为TCP服务器,PC上的Modbus Poll软件作为客户端。W5500接收到Modbus请求后,MCU解析请求并读取传感器数据,然后组装Modbus响应发回去。
Modbus TCP的报文格式是:事务标识符(2字节)+协议标识符(2字节)+长度(2字节)+单元标识符(1字节)+功能码(1字节)+数据。我在应用层实现Modbus解析和组装,W5500驱动只负责收发原始数据。
5.4 驱动代码的版本管理
驱动代码我放在Git仓库里管理,不同平台的适配代码用分支区分。主分支是通用驱动,gd32f103分支和stm32f103分支分别存放平台相关的代码。合并的时候用git cherry-pick把通用部分的修改同步到各平台分支。
版本号我用语义化版本,主版本号在接口不兼容时递增,次版本号在增加功能时递增,修订号在修复bug时递增。每个版本打tag,方便回溯。
6. 个人实操体会与建议
这个项目从开始到稳定运行,前后花了大约两周时间。其中大部分时间花在调试SPI通信和网络连接稳定性上。如果让我重新做一遍,我会先花半天时间用逻辑分析仪把SPI波形调通,确认VERSIONR寄存器能正确读取,然后再写网络部分的代码。这样能避免很多无效调试。
W5500这颗芯片虽然老,但胜在稳定、资料多、价格便宜。对于GD32F103和STM32F103这类资源有限的MCU来说,硬件TCP/IP协议栈的方案比软件协议栈更合适。RT-Thread的驱动框架让代码组织更规范,移植更方便。
硬件抽象层的设计哲学其实很简单:把变化的部分隔离出来,把不变的部分固化下来。在这个项目里,SPI底层接口是变化的,W5500寄存器操作是不变的,网络功能是相对稳定的。分层设计让每一层只关注自己的职责,代码的可维护性和可移植性都大幅提升。
最后分享一个小技巧:在调试网络通信的时候,我习惯在PC上同时开两个工具,一个是网络调试助手看数据收发,一个是Wireshark抓包看TCP握手和挥手过程。两个工具配合使用,能快速定位是W5500的问题还是网络环境的问题。Wireshark的过滤器设成tcp.port == 你的端口号,只看相关报文,避免干扰。