ESP32 SPI驱动W5500:有线以太网接线、时序与例程

发布时间:2026/9/18 9:51:49
ESP32 SPI驱动W5500:有线以太网接线、时序与例程 前段时间帮朋友收拾一个工业现场的数据采集盒子那环境里 WiFi 信号稀烂得离谱——2.4G 频段被一堆设备挤满设备跑着跑着就掉线一天重启七八次。折腾了两天最后把无线模块拆了换成 W5500 插一根网线上去问题当场消失连跑一周没断过。这次我就把ESP32用SPI驱动W5500做有线以太网这一整套东西掰开揉碎写清楚SPI 的时序到底该怎么理解、W5500 的一帧数据长什么样、ESP32 的 SPI 外设怎么配、例程每一行在干什么照着抄就能跑通。这套东西的价值在于它不是什么高深技术但网上的资料要么只给个接线图让你自己猜要么把库一包了事出了问题完全不知道怎么查。我更想讲的是为什么这么接、为什么这么写这样你板子一旦不通能自己拿示波器或者逻辑分析仪把问题揪出来而不是对着别人的代码发呆。适合谁看手里有 ESP32 开发板、想给项目加个稳定网络通道的人被 WiFi 稳定性折磨过、想切有线的人以及刚接触 SPI 协议、想找个真实芯片练手的人。不需要你懂以太网协议栈只要会点 C 语言、能看懂引脚图就够了。1. 为什么非要上 W5500ESP32 自带的 WiFi 不香吗1.1 无线方案的三个死穴先把话说透ESP32 自带的 WiFi 不是不行日常做个小玩具、传个传感器数据完全够用。但它有三个绕不过去的坑。第一个是信道拥塞。你在办公室、厂房、展会现场这种地方用手机扫一下 WiFi 列表能出来三四十个热点2.4G 就那十几个信道大家挤在一起互相干扰。信号强度显示满格实际丢包率能到 20% 以上TCP 一丢包就重传重传就延迟抖动你做个实时控制根本没法看。第二个是配网和维护成本。产品出厂要用户自己配 WiFi 密码老人不会配、密码改了要重配、现场没有屏幕没法输入。这一套东西开发起来没完没了用户还天天打电话找你。第三个是功耗和实时性。WiFi 的射频前端要维持连接平均电流比有线高出不少而且 WiFi 驱动栈本身很重在 FreeRTOS 里跑起来任务调度会有抖动。有线以太网就没这些问题插上就通插哪台交换机就是哪个网段延迟稳定在亚毫秒级全双工 100Mbps 不用担心邻居抢信道。1.2 W5500 到底替 ESP32 干了什么活这里要澄清一个常见误解很多人以为 W5500 只是个网口转换芯片实际上它是一颗硬件 TCP/IP 协议栈芯片。普通方案是 ESP32 跑软件协议栈LwIP算 IP 头校验和、处理 TCP 滑动窗口、维护重传队列这些全是 CPU 在算。W5500 把这些活全接管了MAC 层、PHY 层、IP、ARP、ICMP、IGMP、UDP、TCP 全都固化在硅片里ESP32 要做的只是通过 SPI 告诉它帮我在 5000 端口监听、把这 200 字节发出去剩下的它自己搞定。W5500 内部给你开了8 个独立的 Socket每个 Socket 可以独立做 TCP 服务端、TCP 客户端或者 UDP。每个 Socket 默认给 2KB 发送缓存和 2KB 接收缓存总共 16KB 收发缓存可以通过寄存器重新划分。数据流是这样的网线上的数据进来W5500 解包后把纯数据塞进对应 Socket 的 RX 缓存ESP32 通过 SPI 读一个寄存器就知道有多少字节等着然后一次性读走。好处很直观ESP32 的 CPU 基本不用管网络主循环该干什么干什么。坏处也有——吞吐受 SPI 速率限制这个后面算给你看。1.3 这套组合适合什么场景我总结下来下面这几类项目闭着眼睛上 W5500场景类型为什么适合典型例子工业数据采集现场电磁环境复杂有线抗干扰电表集中器、PLC 扩展模块固定安装设备位置不动布线方便门禁控制器、机柜监控、广告机多设备组网交换机集中管理IP 固定好排查实验室仪器群控、楼宇自控实时性要求延迟稳定无信道竞争运动控制、音频流传输长期无人值守不会因为路由器重启掉线远程机柜、农业监测站反过来如果你的设备要移动、要电池供电、要临时布点那还是老老实实用 WiFi别为了稳定牺牲灵活性。还有一点值得说W5500 的 SPI 接口本质是个低速内存总线ESP32 和它之间跑的是寄存器读写不是以太网帧。理解这一点很重要后面讲时序的时候你会明白为什么它的协议要设计成地址段 控制字节 数据这种三段式结构——这就是典型的存储器接口风格。2. 硬件准备接线和参考电路里最容易被忽略的细节2.1 ESP32 侧引脚怎么选先避开雷区ESP32 的 GPIO 不是你想象中那么自由有几类引脚碰不得。Flash 占用引脚GPIO6 到 GPIO11这几根内部连到 ESP32 模组上的 SPI Flash你外部接了就直接死机或者启动不了。这是新手最容易犯的错尤其是看到模块上没引出这几脚就在原理图上随便连。Strapping 引脚GPIO0、GPIO2、GPIO4、GPIO5、GPIO12、GPIO15。这几个脚在上电复位的那一瞬间会被采样决定芯片的启动模式、Flash 电压等。特别是 GPIO12它在启动时如果被外部拉高芯片会认为 Flash 是 1.8V 供电直接启动失败。你说巧不巧很多 W5500 模块的 CS 默认就建议接 GPIO5 或 GPIO4——这两个正好是 strapping 脚。我的建议是CS 用 GPIO5 是可以的因为 W5500 的 CS 是输入引脚上电时高阻态或者内部弱上拉只要你在 ESP32 侧不额外加下拉电阻一般不会误触发。但如果你板子上 CS 线上挂了下拉电阻那就麻烦了要么换引脚要么把下拉去掉。只输入引脚GPIO34 到 GPIO39这几根没有输出能力不能做 CS、SCK、MOSI、RST。建议的引脚分配直接用 ESP32 的 VSPI 默认映射省事功能ESP32 引脚说明SCKGPIO18VSPI 默认时钟MOSIGPIO23VSPI 默认主出从入MISOGPIO19VSPI 默认主入从出CSGPIO5片选注意 strapping 问题RSTGPIO4硬复位也可换任意输出脚INTGPIO27中断轮询方式可以不接用默认映射的最大好处是ESP-IDF 和 Arduino 里都能直接引用VSPI常量配置代码短一截出问题的时候也少一个变量要查。如果你非要用别的脚ESP32 的 GPIO 矩阵能让你把 SPI 信号映射到任意引脚但代价是高速率下信号质量可能变差20MHz 以上建议还是走默认脚。2.2 W5500 最小系统与参考电路要点W5500 的外围电路不复杂但有几个地方是少了就废的。第一EXRES1 引脚必须接 12.4kΩ 1% 精度电阻到地。这个引脚是内部基准电流的设置端电阻精度直接影响 PHY 的发送电平。我见过有人拿 12k 或者 13k 凑合结果链路能协商上但丢包严重怎么查都查不出来。一定用 1% 的金属膜电阻别用碳膜。第二25MHz 晶振。W5500 内部 PLL 靠这个 25MHz 参考时钟倍频出 100MHz 的以太网时钟和 SPI 时钟。晶振选无源晶体负载电容按晶体规格书来一般 10pF 到 22pF 之间。这里有个坑如果你买的晶体规格是 CL20pF而你在两个引脚各接了 22pF 电容实际负载电容是 22pF 串联 22pF 再叠加上杂散电容会偏大起振是能起但频率会偏链路容易不稳。正确的算法是CL (C1 × C2) / (C1 C2) Cstray 假设目标 CL 20pFCstray 取 3pF 则 (C1 × C2)/(C1 C2) 17pF 对称设计 C1 C2 34pF所以规格书写的20pF 负载的晶体实际要配 33pF 左右的电容不是 20pF。这个细节 90% 的教程都不讲但它是现场调试能连但容易断的常见原因之一。第三网络变压器和 RJ45。W5500 的 TX/TX-、RX/RX- 是差分信号必须经过网络变压器隔离才能接网线。最省事的做法是用自带变压器的 RJ45 座常见的 HR911105A 这类一次搞定变压器、共模电感、终端电阻和网口。如果用分离方案注意差分对上要串 49.9Ω 电阻中心抽头要接去耦电容到地。第四电源。W5500 是 3.3V 供电但内部有 1.8V 内核通过内部的 LDO 从 3.3V 降下来需要在外围引脚接 1.8V 的滤波电容一般 1uF 加 0.1uF。3.3V 这边数字电源和模拟电源建议用磁珠或者 0Ω 电阻隔开各自挂 10uF 0.1uF。我第一次画板的时候两块电源直接连一起链路灯正常闪但跑 TCP 大流量半小时就挂后来分开加磁珠就好了。2.3 电源与地线的实操细节再补几条我从实际板子上血泪总结出来的每个电源引脚都要有独立的 0.1uF 退耦电容而且要尽量靠近引脚走线别绕。W5500 的瞬时电流变化不小退耦电容放远了等于没放。地平面要完整。差分对下方要有完整的地参考别在下面挖槽走别的线否则阻抗不连续信号反射。差分对等长。TX 和 RX 各自的 P 和 N 要等长误差控制在 5mil 以内走线尽量短且直。晶振周围铺地并加保护环不要走其他信号线靠近尤其是 SPI 的时钟线串扰进去会导致时钟抖动。SPI 走线超过 10cm 就要考虑串阻在 SCK 和 MOSI 上串 22Ω 到 33Ω 的电阻抑制振铃。我用杜邦线接的临时板子上20MHz 时示波器上看时钟上升沿有明显过冲串了 33Ω 之后波形干净多了。3. SPI 协议与 W5500 帧格式把手册讲成人话3.1 先把 SPI 的四种模式捋清楚SPI 本质上就四根线SCK时钟、MOSI主发从收、MISO主收从发、CS片选。主设备拉低 CS 表示我要跟这个从设备说话了然后在 SCK 上打拍子每个时钟周期在 MOSI 上送一位、同时在 MISO 上收一位。全双工收发同时进行。关键在在哪个边沿采样。这就引出 CPOL 和 CPHA 两个参数CPOLClock Polarity空闲时 SCK 是高还是低。0 表示空闲低1 表示空闲高。CPHAClock Phase在第几个边沿采样。0 表示第一个边沿从空闲态跳变的那个边沿采样1 表示第二个边沿采样。组合起来就是四种模式模式CPOLCPHA空闲电平采样边沿常用芯片Mode 000低上升沿W5500、多数 FlashMode 101低下降沿部分 ADCMode 210高下降沿少数传感器Mode 311高上升沿SD 卡、部分 EEPROMW5500 用的是 Mode 0也就是时钟空闲为低、上升沿采样、下降沿输出。这个必须配对配错了读回来的数据全是错位的乱码而且看起来能读到东西特别迷惑人。我个人的理解方式把 SCK 想象成一个人在敲桌子打节拍。CPOL 决定他停手的时候手是抬着还是放下CPHA 决定他是敲下去的那一下还是抬起来的那一下算数。W5500 的习惯是手放在桌上低敲下去的那一瞬间上升沿数据算数。3.2 W5500 的一帧长什么样W5500 的 SPI 帧格式非常有规律一次完整操作固定是三段[ 地址段 2 字节 ] [ 控制字节 1 字节 ] [ 数据 N 字节 ]先看地址段。W5500 内部地址空间不是简单的 16 位线性地址它把整个空间分成了若干块Block用 BSBBlock Select Bits来选块块内再用 11 位偏移寻址。这 2 个字节拼起来就是bit15 - bit11 : BSB5 位选择哪个块 bit10 - bit0 : 块内偏移地址11 位块的编号规则是这样的BSB 值对应区域0公共寄存器区Common Register1 3nSocket n 的寄存器区2 3nSocket n 的发送缓存区3 3nSocket n 的接收缓存区其中 n 是 0 到 7。比如 Socket 0 的寄存器区 BSB1Socket 0 的发送缓存 BSB2接收缓存 BSB3Socket 1 的寄存器区 BSB4以此类推。用代码表达就是#define BSB_COMMON 0x00 #define BSB_Sn_REG(n) (0x01 0x03 * (n)) #define BSB_Sn_TX(n) (0x02 0x03 * (n)) #define BSB_Sn_RX(n) (0x03 0x03 * (n)) // 把 BSB 和偏移地址合成 16 位地址 #define W5500_ADDR(bsb, off) ((uint16_t)(((bsb) 11) | ((off) 0x07FF)))再看控制字节8 位但只用了 3 位有效bit7 RWB1 表示读0 表示写。bit6:5 OM操作模式。00 是 VDM可变长度模式一直传输直到 CS 拉高01 是 FDM固定长度模式长度由控制字节的低 5 位指定。10 和 11 保留。bit4:0FDM 模式下的数据长度1 到 32 字节VDM 模式下填 0。所以常用的控制字节就几个值操作控制字节说明写VDM0x00写完拉高 CS 结束写FDM0x40 | len固定长度批量写读VDM0x80读到最后拉高 CS 结束读FDM0xC0 | len固定长度批量读这里有个致命坑读操作时控制字节是主设备发给 W5500 的而这时候 MISO 线上 W5500 还没开始吐数据。也就是说如果你用一个字节一个字节地读读回来的第一个字节其实是垃圾。很多人写读函数的时候直接把返回的字节流当成数据前面就多了一个无意义的字节导致后续所有寄存器全部错位表现为VERSIONR 读出来不是 0x04。正确的做法有两种要么在接收缓冲区前面留一个字节的坑位要么干脆分两次传输——先把地址和控制字节发出去只发不收再单独读数据。3.3 读写时序逐拍拆解用示波器看一次读 VERSIONR公共寄存器 0x0039的完整波形总共 4 个字节的时钟第 1 个字节时钟地址高字节MOSI 上依次输出0x00因为 BSB0偏移 0x0039合成 16 位地址就是 0x0039高字节 0x00、低字节 0x39。此期间 MISO 上是什么无所谓忽略。第 2 个字节时钟地址低字节MOSI 输出0x39。同样忽略 MISO。第 3 个字节时钟控制字节MOSI 输出0x80读 VDM。这一拍 MISO 上出的还是无效数据不要读。第 4 个字节时钟数据MOSI 输出任意值通常是 0x00 或者 0xFFW5500 在这一拍把 VERSIONR 的值放到 MISO 上。此时读 MISO期望值是0x04——这是身份识别码读到 0x04 就说明 SPI 时序、接线、供电全对了。写操作的时序更简单地址段 2 字节、控制字节 1 字节用 0x00、然后数据字节。中间没有无效字节的问题写什么就是什么。有个细节要提醒CS 必须在整帧期间保持低电平。有些驱动图省事每次发一个字节就拉一次 CSW5500 会把单次传输当成一帧的结束后面的数据就被当成新的帧头来解析结果全乱。这也是为什么我后面在代码里坚持手动控制 CS而不用 ESP-IDF 的自动片选——自动片选是按事务拉低的如果你把一帧拆成两个事务中间 CS 会被抬起来。4. ESP32 的 SPI 外设怎么用三种驱动方式怎么选4.1 ESP32 有三组 SPI能用的只有两组ESP32 芯片内部有 SPI0、SPI1、SPI2、SPI3 这几组控制器别被SPI 数量迷惑SPI0 和 SPI1专门给内部 Flash 和 PSRAM 用的你的代码跑在里面动它等于自毁。ESP32 模组上那些 GPIO6 到 GPIO11 就是它们的信号线。SPI2HSPI和 SPI3VSPI这两组才是留给用户的通过 GPIO 矩阵可以映射到任意可用引脚。在 Arduino 里SPI对象默认用 VSPI在 ESP-IDF 里你用SPI2_HOST或者SPI3_HOST来指定。两块都能用但注意如果你同时用 HSPI 接 W5500、VSPI 接 SD 卡那两组的 CS 和时钟要分配清楚别撞车。还有个常见问题ESP32 的 SPI 时钟线、数据线在高速下能不能跑长线。我的经验是杜邦线跳线的情况下 10MHz 是稳的20MHz 就偶发误码40MHz 基本不可能。PCB 上走线短且阻抗控制好的话40MHz 甚至更高都能跑。4.2 三种驱动方式各有各的活法第一种Arduino 生态的 Ethernet 库。最简单几行代码搞定#include SPI.h #include Ethernet.h byte mac[] {0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED}; void setup() { Ethernet.init(5); // 告诉库 CS 接在 GPIO5 Serial.begin(115200); if (Ethernet.begin(mac) 0) { Serial.println(DHCP 失败); while (1); } Serial.print(本机 IP: ); Serial.println(Ethernet.localIP()); }优点是不用管底层DHCP、DNS、TCP 客户端全有封装。缺点是性能一般、定制性差你想改 Socket 缓存大小或者用多个 Socket 就抓瞎了。适合做验证、做小项目。第二种ESP-IDF 官方的 W5500 组件。从 IDF v5.0 开始官方提供了esp_eth_w5500组件走的是标准esp_eth框架跟板载 PHY 的用法完全一致。配置结构大概是这样#include esp_eth_w5500.h spi_bus_config_t buscfg { .miso_io_num 19, .mosi_io_num 23, .sclk_io_num 18, .quadwp_io_num -1, .quadhd_io_num -1, }; spi_device_interface_config_t devcfg { .mode 0, // W5500 固定 SPI Mode 0 .clock_speed_hz 20 * 1000 * 1000, // 先用 20MHz 调通 .queue_size 8, .spics_io_num 5, }; eth_w5500_config_t w5500_config ETH_W5500_DEFAULT_CONFIG(SPI2_HOST, devcfg); w5500_config.int_gpio_num 27; // 中断引脚不用可设 -1然后用esp_eth_mac_new_w5500()和esp_eth_phy_new_w5500()创建 MAC 和 PHY 对象挂到esp_eth_driver_install()上。这条路的好处是你拿到的就是标准esp_netif能直接用 LwIP 的 socket API、能配静态 IP、能开 DHCP 客户端代码风格统一。缺点是要熟悉 IDF 的驱动模型上手门槛比 Arduino 高一截。第三种完全自己撸寄存器。就是前面讲的那套地址段加控制字节自己写读写函数自己实现 ARP、TCP 状态机。除非你是想彻底搞懂协议栈否则不建议——工作量太大而且容易出错。不过写一个精简的 UDP 收发实现用来理解原理是很有价值的。我的建议先用 Arduino 库验证硬件没问题再切到 ESP-IDF 组件做正式项目。这两步能帮你快速定位问题到底是硬件还是软件。4.3 频率、DMA 和片选三个绕不开的选择题SPI 频率怎么定W5500 手册标称 SPI 最高支持 80MHz但实际能不能跑满取决于你的布线和 ESP32 的驱动能力。我的经验值接线方式建议频率备注杜邦线 10cm 以内8 - 10MHz稳定先用这个调通杜邦线 20cm4 - 5MHz长了就别贪速度PCB 短走线20 - 40MHz串阻加好PCB 优化走线60 - 80MHz需要阻抗控制和屏蔽这里可以算一笔账。SPI 20MHz 意味着每秒 2000 万个时钟每个时钟传 1 位理论带宽 20Mbps也就是 2.5MB/s。而 100Mbps 以太网的理论数据率是 12.5MB/s。所以瓶颈一定在 SPI 侧你的 W5500 号称百兆网口实际通过它能跑到的 TCP 吞吐大概在 1.5 到 2MB/s 上下还要扣掉协议开销和寄存器读写的额外帧。想要更高只能提 SPI 频率或者用并行接口的以太网芯片。要不要 DMAESP-IDF 里配置 SPI 总线时有个.dma_chan参数可以设SPI_DMA_DISABLED或者SPI_DMA_CH_AUTO。原则是这样的单次传输超过 32 字节、或者你要高吞吐就开 DMA让数据搬运不占用 CPU小数据量比如读写单个寄存器反而用轮询模式更快因为 DMA 启动本身有开销。顺便回答一个很多人问的问题SPI 需要两个 DMA 吗不需要。ESP32 的 SPI 是全双工的一个事务里发送和接收是同一组时钟驱动的用一条 DMA 通道就能同时完成收发。只有在用两条独立总线分别接不同从设备的时候才需要各自配一条。硬件片选还是软件片选ESP-IDF 的spi_device_interface_config_t里有个spics_io_num填了就是硬件自动片选——每次spi_device_transmit时驱动自动拉低/拉高 CS。但对 W5500 来说这是有陷阱的如果你用两个事务分别发送地址控制字节和数据硬件片选会在两个事务之间抬起W5500 就把它们当成两帧处理了。解决办法有两个一是把整帧拼成一个 buffer 一次发推荐二是spics_io_num填 -1自己用gpio_set_level()手动控制 CS。我通常选第二种因为读写 Socket 缓存的时候长度是动态的拼 buffer 需要额外 malloc手动控制 CS 更灵活。5. 例程逐行讲透从复位到能 ping 通5.1 第一步里程碑读到 0x04别急着搞网络先做最小验证——能正确读出 VERSIONR。这一步过了说明 SPI 通了过不了后面的折腾全是浪费。#include driver/spi_master.h #include driver/gpio.h #include esp_log.h #define PIN_SCK 18 #define PIN_MOSI 23 #define PIN_MISO 19 #define PIN_CS 5 #define PIN_RST 4 #define BSB_COMMON 0x00 #define W5500_ADDR(bsb, off) ((uint16_t)(((bsb) 11) | ((off) 0x07FF))) static spi_device_handle_t s_spi; static const char *TAG W5500; // 初始化 SPI 总线注意 dma_chan 开了自动分配 static void spi_bus_init(void) { spi_bus_config_t bus { .mosi_io_num PIN_MOSI, .miso_io_num PIN_MISO, .sclk_io_num PIN_SCK, .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 2048, // 单次最大传输字节数按 Socket 缓存来 }; ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, bus, SPI_DMA_CH_AUTO)); spi_device_interface_config_t dev { .clock_speed_hz 8 * 1000 * 1000, // 第一版用 8MHz稳妥 .mode 0, // W5500 必须 Mode 0 .spics_io_num -1, // 关键设为 -1我们自己控 CS .queue_size 8, }; ESP_ERROR_CHECK(spi_bus_add_device(SPI2_HOST, dev, s_spi)); // 手动配置 CS 和 RST 为推挽输出 gpio_config_t io { .pin_bit_mask (1ULL PIN_CS) | (1ULL PIN_RST), .mode GPIO_MODE_OUTPUT, .pull_up_en GPIO_PULLUP_DISABLE, .pull_down_en GPIO_PULLDOWN_DISABLE, .intr_type GPIO_INTR_DISABLE, }; gpio_config(io); gpio_set_level(PIN_CS, 1); // CS 空闲时为高 gpio_set_level(PIN_RST, 1); }逐行说明几个关键点。max_transfer_sz设成 2048 是因为单个 Socket 缓存默认 2KB如果你后面要调大缓存这个值也得跟着改否则写入会被驱动拒绝。.spics_io_num -1是故意的理由前面讲过——要保证一帧之内 CS 不抬起来。.mode 0必须写死别想着试 Mode 3W5500 只认 Mode 0。接下来是硬件复位和读版本号// 手动拉低 CS 时ESP-IDF 不会自动接管需要我们自己保证时序 static void w5500_reset(void) { gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(10)); // 手册要求至少 500us给 10ms 更保险 gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(100)); // 等内部 PLL 稳定这个延时不能省 } static uint8_t w5500_read_byte(uint16_t addr) { uint8_t head[3]; head[0] (uint8_t)(addr 8); // 地址高字节含 BSB head[1] (uint8_t)(addr 0xFF); // 地址低字节块内偏移 head[2] 0x80; // 读 VDM 模式 uint8_t rx[4] {0}; uint8_t tx[4] {head[0], head[1], head[2], 0x00}; spi_transaction_t t { .length 4 * 8, // 注意单位是 bit不是字节 .tx_buffer tx, .rx_buffer rx, }; gpio_set_level(PIN_CS, 0); // 拉低片选开始一帧 spi_device_polling_transmit(s_spi, t); gpio_set_level(PIN_CS, 1); // 拉高帧结束 return rx[3]; // 前 3 个字节是头部回显第 4 个才是数据 }这段代码有三个高频错误点值得单独拎出来第一个.length的单位是 bit。ESP-IDF 的 SPI 驱动里spi_transaction_t.length是比特数不是字节数。我第一次写的时候填了 4结果只发了 4 个 bit读回来自然是垃圾。第二个rx[3]才是数据。就像前面讲的前三个字节期间 MISO 上是无效数据。如果你的代码写成return rx[0]读 VERSIONR 会得到 0x00然后你就开始怀疑人生。第三个CS 手动包住整帧。这里的gpio_set_level一低一高把整整 4 个字节的传输包成一帧符合 W5500 的帧定义。验证代码就这么几行void app_main(void) { spi_bus_init(); w5500_reset(); uint8_t ver w5500_read_byte(W5500_ADDR(BSB_COMMON, 0x0039)); ESP_LOGI(TAG, VERSIONR 0x%02X, ver); if (ver 0x04) { ESP_LOGI(TAG, SPI 通信正常W5500 已就位); } else { ESP_LOGE(TAG, 读到 0x%02X检查接线/供电/CS/Mode, ver); } }读到 0x04 的那一刻其实你就已经掌握了 W5500 的全部通信基础——剩下的所有操作无非是换个地址、换个长度而已。5.2 通用读写函数多字节怎么处理单字节版本够了之后扩展成通用版本。这里我用一次性拼整帧的方式避免多次事务导致 CS 抖动// 通用读addr 里已经包含了 BSBlen 是读取字节数 static void w5500_read(uint16_t addr, uint8_t *buf, size_t len) { // 头部 3 字节 数据 len 字节接收侧前面 3 字节是垃圾 size_t total 3 len; uint8_t *tx heap_caps_malloc(total, MALLOC_CAP_DMA); uint8_t *rx heap_caps_malloc(total, MALLOC_CAP_DMA); assert(tx rx); tx[0] (uint8_t)(addr 8); tx[1] (uint8_t)(addr 0xFF); tx[2] 0x80; // 读 VDM memset(tx[3], 0x00, len); // 后续字节随便填只是为了产生时钟 spi_transaction_t t { .length total * 8, .tx_buffer tx, .rx_buffer rx, }; gpio_set_level(PIN_CS, 0); spi_device_polling_transmit(s_spi, t); gpio_set_level(PIN_CS, 1); memcpy(buf, rx[3], len); // 跳过前 3 个无效字节 free(tx); free(rx); } // 通用写地址 控制字节 数据 static void w5500_write(uint16_t addr, const uint8_t *buf, size_t len) { size_t total 3 len; uint8_t *tx heap_caps_malloc(total, MALLOC_CAP_DMA); assert(tx); tx[0] (uint8_t)(addr 8); tx[1] (uint8_t)(addr 0xFF); tx[2] 0x00; // 写 VDM memcpy(tx[3], buf, len); spi_transaction_t t { .length total * 8, .tx_buffer tx, }; gpio_set_level(PIN_CS, 0); spi_device_polling_transmit(s_spi, t); gpio_set_level(PIN_CS, 1); free(tx); }这里用heap_caps_malloc(..., MALLOC_CAP_DMA)而不是普通的malloc是因为开了 DMA 之后发送缓冲区必须位于 DMA 可以访问的内部内存区。用普通malloc在 ESP32 上通常会落到 PSRAM 或者非 DMA 区域传输直接报错。这个坑很隐蔽报错信息也不会明说只会告诉你传输失败。另外短小的寄存器读写1 到 4 字节其实没必要走堆分配用栈上的固定数组更快。我一般写两个版本w5500_read_reg8/16/32用栈数组走快路径w5500_read_buf用堆分配走慢路径处理大数据块。5.3 初始化时序从复位到能发 ARP寄存器读写通了接下来按顺序配置 W5500。顺序很重要先软复位再配参数static void w5500_hw_init(void) { // 1. 软件复位往 MR 寄存器写 0x80 uint8_t mr 0x80; w5500_write(W5500_ADDR(BSB_COMMON, 0x0000), mr, 1); vTaskDelay(pdMS_TO_TICKS(10)); // 等复位完成 // 2. 设置 MAC 地址6 字节从 SHAR(0x0009) 开始 uint8_t mac[6] {0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED}; w5500_write(W5500_ADDR(BSB_COMMON, 0x0009), mac, 6); // 3. 设置本机 IP4 字节SIPR(0x000F) uint8_t ip[4] {192, 168, 1, 200}; w5500_write(W5500_ADDR(BSB_COMMON, 0x000F), ip, 4); // 4. 设置子网掩码 SUBR(0x0005) uint8_t mask[4] {255, 255, 255, 0}; w5500_write(W5500_ADDR(BSB_COMMON, 0x0005), mask, 4); // 5. 设置网关 GAR(0x0001) uint8_t gw[4] {192, 168, 1, 1}; w5500_write(W5500_ADDR(BSB_COMMON, 0x0001), gw, 4); // 6. 重试时间和次数RTR(0x0019) 200msRCR(0x001B) 8 次 uint8_t rtr[2] {0x07, 0xD0}; // 2000 × 100us 200ms w5500_write(W5500_ADDR(BSB_COMMON, 0x0019), rtr, 2); uint8_t rcr 8; w5500_write(W5500_ADDR(BSB_COMMON, 0x001B), rcr, 1); // 7. 给 8 个 Socket 各分 2KB 收发缓存默认值写一遍确认 for (int i 0; i 8; i) { uint8_t sz 2; w5500_write(W5500_ADDR(BSB_Sn_REG(i), 0x001E), sz, 1); // RXBUF_SIZE w5500_write(W5500_ADDR(BSB_Sn_REG(i), 0x001F), sz, 1); // TXBUF_SIZE } // 8. 检查链路状态PHYCFGR(0x002E) bit0 1 表示网线已连接 uint8_t phy 0; w5500_read(W5500_ADDR(BSB_COMMON, 0x002E), phy, 1); ESP_LOGI(TAG, PHYCFGR 0x%02X, LINK %d, phy, phy 0x01); }几个参数的由来我解释一下。RTR 是重试超时时间单位是 100 微秒2000 就是 200ms。这个值决定了 TCP 发出一个包后等多久没收到 ACK 就重传。局域网里 200ms 足够宽松跨公网可能要调到 1000ms 以上。RCR 是重试次数8 次意味着连续失败 8 次后连接被判定断开。这两个值直接影响断线检测的灵敏度太灵敏会误断太迟钝会卡死。Socket 缓存的划分是有讲究的。总共 16KB 收发缓存要在 8 个 Socket 之间分。如果你只用 1 个 Socket 跑大流量可以把它的缓存调到 8KB 甚至 16KB其他 Socket 设 0如果 8 个 Socket 都要用每个就 2KB。这里的设置必须在 Socket 打开之前完成打开后再改是无效的。PHYCFGR的低位是链路状态和速度信息位含义bit0LINK1 表示链路已建立bit1SPD1 表示 100M0 表示 10Mbit2DPX1 表示全双工bit7RST写 0 复位 PHY如果 bit0 是 0先别怀疑代码去查网线和交换机——网线没插好、交换机端口没开、水晶头没压紧都会是 0。5.4 一个能 ping 通的完整 UDP 回环示例配置完寄存器用最简的 UDP 验证通路。这里开 Socket 0 做 UDP 服务端收到数据原样发回去#define SOCK_UDP 0 #define PORT_UDP 5000 static void udp_echo_loop(void) { // 打开 Socket 0 为 UDP 模式MR 寄存器写 0x02 uint8_t mr 0x02; w5500_write(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0000), mr, 1); // 设置本地端口 5000Sn_PORT(0x0004) uint8_t port[2] {PORT_UDP 8, PORT_UDP 0xFF}; w5500_write(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0004), port, 2); // 发出 OPEN 命令Sn_CR(0x0001) 写 0x01 uint8_t cmd 0x01; w5500_write(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0001), cmd, 1); vTaskDelay(pdMS_TO_TICKS(5)); while (1) { // 读 Sn_RX_RSR(0x0026)看看收到了多少字节 uint8_t rsr[2] {0}; w5500_read(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0026), rsr, 2); uint16_t rx_len (rsr[0] 8) | rsr[1]; if (rx_len 0) { uint8_t buf[256]; if (rx_len sizeof(buf)) rx_len sizeof(buf); // 读接收缓存先读 Sn_RX_RD(0x0028) 拿到读指针 uint8_t rd[2] {0}; w5500_read(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0028), rd, 2); uint16_t offset (rd[0] 8) | rd[1]; // 从 Socket 0 接收缓存区读数据注意基地址是 BSB_Sn_RX w5500_read(W5500_ADDR(BSB_Sn_RX(SOCK_UDP), offset), buf, rx_len); // 更新读指针必须写回否则 W5500 认为你没取走数据 offset rx_len; rd[0] offset 8; rd[1] offset 0xFF; w5500_write(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0028), rd, 2); // 发 RECV 命令Sn_CR 写 0x40通知 W5500 数据已取走 cmd 0x40; w5500_write(W5500_ADDR(BSB_Sn_REG(SOCK_UDP), 0x0001), cmd, 1); ESP_LOGI(TAG, 收到 %d 字节, rx_len); } vTaskDelay(pdMS_TO_TICKS(10)); } }这段代码里有几个不做就不通的地方读指针必须写回。W5500 管理接收缓存用的是环形缓冲区Sn_RX_RD是读指针Sn_RX_WR是写指针两者相等表示缓存空。你读走数据后必须把新的读指针写回然后再发RECV命令W5500 才知道空间被释放了。一度我忘了发 RECV结果是能收到第一个包之后就再也收不到了——缓存满了。命令是写 1 触发。Sn_CR寄存器是命令寄存器写入某个值就执行对应动作执行完自动清零。常用命令值0x01 是 OPEN0x02 是 LISTENTCP 服务端0x04 是 CONNECTTCP 客户端0x08 是 DISCONNECT0x10 是 CLOSE0x20 是 SEND0x40 是 RECV。写完命令后要给一点延时让 W5500 处理或者轮询Sn_CR直到读到 0。UDP 的发送流程跟接收反着来先读Sn_TX_FSR看还剩多少空间再从Sn_TX_WR指向的位置写数据到发送缓存更新写指针最后发SEND命令。如果只想让 W5500 往外发一次目标 IP 和端口需要提前写到Sn_DIPR和Sn_DPORT。TCP 的流程更复杂要经过 OPEN → CONNECT / LISTEN → ESTABLISHED → 收发 → DISCONNECT → CLOSE 这一串状态机每一步都要轮询Sn_SR确认状态迁移成功。这块建议直接用官方组件自己撸容易漏状态。6. 常见问题速查与真实排查现场6.1 问题速查表我把这些年遇到的和网友问过的问题整理成表按现象分类现象最可能的原因排查动作读 VERSIONR 返回 0x00MISO 没接、CS 没接、SPI Mode 错、从设备没供电万用表量 3.3V示波器看 CS 和 SCK 波形读到 0xFFMISO 悬空被上拉、从设备未响应确认 MISO 焊接加 10k 下拉试试读到的值随机跳变频率太高、走线太长、退耦不足降到 1MHz 再试加串阻VERSIONR 0x04 但链路不通PHY 外围电路问题、网线问题、EXRES 电阻错查 EXRES1 是否 12.4kΩ 1%换网线LINK 灯不亮变压器/共模电感虚焊、RJ45 座引脚搞反量差分对到座子的通断能协商上但丢包严重晶振频偏、负载电容算错、电源纹波大换晶振电容示波器看 25MHz通一段时间后死掉散热差、电源跌、Socket 缓存没释放摸芯片温度查 RECV 命令是否发出两个 Socket 只有一个能通BSB 计算出错、缓存划分冲突打印每个 Sn_SR 状态大量数据传输时卡死heap 分配的内存不是 DMA 可用区改用 heap_caps_malloc MALLOC_CAP_DMAESP32 上电反复重启CS 挂在 strapping 脚且被外部拉低换 CS 引脚或去掉下拉6.2 三个真实的排查现场现场一VERSIONR 死活读不到 0x04。一块自己画的板子代码在开发板上跑通了挪到自己板子上怎么都不对读出来一直是 0x00。示波器一挂CS 波形正常拉低拉高都对SCK 有 8MHz 的时钟边沿干净MOSI 上数据也对地址段和控制字节都能看到MISO 上一根直线纹丝不动。MISO 没数据那问题一定在 W5500 侧。量电压3.3V 正常复位引脚正常。再量 MISO 到 ESP32 的连通性通的。最后量到 W5500 的 EXRES1 引脚发现对地是无穷大——那个 12.4kΩ 电阻漏焊了。这颗电阻不给W5500 的内部模拟电路没有基准电流PHY 完全不工作连 SPI 从机控制器都可能不正常。补上电阻一次通。现场二能 ping 通但传大文件传到 500KB 断。链路没问题小数据包也稳。用 iperf 压测跑几十 KB 就断。第一反应是 SPI 频率太高从 20MHz 降到 10MHz断得更慢了但还断。后来用逻辑分析仪抓 SPI发现 W5500 一次要读 2KB 的数据而我的max_transfer_sz配的是 1024ESP-IDF 的驱动直接把超长事务截断了后面的数据全部错位Extract 解析出乱七八糟的 TCP 包。把max_transfer_sz改成 4096 就好了。教训总线初始化里的max_transfer_sz必须大于你单次最大传输长度这个值不对不会报错只会静默截断。现场三上电三分之一的板子起不来。小批量做了 20 块板有 7 块 ESP32 上电就进下载模式卡在那不动。查出来是 CS 接了 GPIO12——就是那个上电时会决定 Flash 电压的 strapping 脚。W5500 的 CS 引脚内部有弱上拉上电瞬间把 GPIO12 拉高了ESP32 误判 Flash 是 1.8V启动失败。把 CS 挪到 GPIO5问题消失。这个坑的隐蔽之处在于同样的代码、同样的 W5500在开发板上一点事没有开发板的 CS 引脚不同量产时才暴露。6.3 我踩过的坑和一点心得几条不成文的经验写在代码注释里不如写在这里关于 SPI 频率永远从低往高试。1MHz 先跑通再 4MHz、8MHz、20MHz 往上加每上一档压测十分钟。直接上 40MHz出问题你都不知道是频率问题还是别的。关于 CS一定要包住整帧。这是我见过最高频的错误。判断方法很简单示波器挂 CS一帧读操作比如读 4 字节应该只有一次下拉如果中间有抬起那就错了。关于延时别硬套手册的最小值。手册写复位需要 500us你写 500us 有可能因为编译器和调度误差不够。我一般写 10 倍以上反正只在初始化时执行一次不影响性能。关于调试手段逻辑分析仪比万用表管用一百倍。一个几十块的 8 通道逻辑分析仪配合软件自带的 SPI 协议解码能直接把 MOSI 上的比特流翻译成字节。我调 SPI 从来都是先抓波形看发送的字节对不对再判断是从设备问题还是主设备问题比盲猜快得多。关于内存DMA 缓冲区一定用heap_caps_malloc。这条前面说过但值得再强调一次因为它不报错、不崩溃只是数据悄悄错位。关于 PHY 状态上电后至少等 300ms 再判断链路。交换机端口自协商需要时间你上电立刻读 PHYCFGR 大概率是未连接状态等一会儿再读就正常了。很多链路检测失败的问题都是这个原因。关于 W5500 的温度正常工作时摸上去是温的烫手就不对。如果芯片明显发烫先查有没有引脚短路、电源电压是不是超了再查差分线有没有接反。我见过把 TX 和 RX 接反的板子芯片能上电但链路死活不通摸上去还挺热。关于多 Socket 的调试一个一个来。别想着一次把 8 个 Socket 全开起来先用 Socket 0 把 TCP 客户端跑通再开第二个。多 Socket 出问题时用Sn_SR寄存器把每个 Socket 的状态打出来能快速定位是哪个卡住了。状态值对照0x00 是 CLOSED0x13 是 INIT0x17 是 ESTABLISHED0x1C 是 CLOSE_WAIT0x22 是 UDP 状态。这套东西我从第一次接触到现在前后踩了差不多两年的坑才摸清楚。现在回头看最难的不是写代码而是搞明白为什么——为什么 CS 要包住整帧、为什么读数据要跳过前三个字节、为什么 EXRES 电阻不能凑合、为什么 strapping 脚会咬人。这些为什么在手册里都有但手册写得太克制不会告诉你踩错了会怎样。如果你的板子现在还通不了别急着重画PCB先用逻辑分析仪抓一帧 SPI 波形把发送的字节序列对照着第 3 节的帧格式一个一个比对十有八九问题就浮出来了。