ESP32通过SPI驱动W5500:以太网全链路与实战排查 手里有 ESP32 的兄弟十个有九个是先跑 WiFi 的。可真到了要 7×24 小时不间断上报数据的场合WiFi 那点脾气就全露出来了路由器重启一次、信道被邻居家 AP 挤一下、现场金属柜门一关连接就断然后在深更半夜你被电话叫起来重启设备。这时候很多人会想到加一块 W5500让 ESP32 走有线以太网。想法很好但一上手就卡在 SPI 上——四根线怎么接、模式怎么设、为什么读出来的版本号是 0xFF、为什么网口灯亮着但 DHCP 拿不到地址。这篇就把 ESP32 驱动 W5500 的完整链路掰开揉碎讲一遍从 SPI 时序到寄存器帧格式再到能直接抄的例程和一份排查表。1. 为什么给 ESP32 配 W5500而不是继续用 WiFi1.1 无线在工业现场的三个真实痛点先说结论不是 WiFi 不好是它的工作方式决定了它不适合做命脉通道。ESP32 的 WiFi 是射频通信受环境影响的变量太多——2.4G 频段拥挤、多径反射、金属屏蔽、AP 负载。做过现场设备的人应该都经历过设备在办公室测试一周没事装到配电房旁边的铁皮柜里一天断八次。第二个痛点是资源争抢。ESP32 只有一个射频前端WiFi 和蓝牙共存时要分时复用协议栈内部要做仲裁。如果你的项目里同时开了 BLE 配网和 WiFi 上传再加上 TLS 握手任务堆栈和堆内存会非常紧张表现就是偶发的连接超时、内存不足重启。这类问题极难复现也极难定位。第三个痛点是重新入网这件事本身。设备换了个现场、换了路由器或者 AP 的 SSID 改了你就得重新配网。ESP32 上做配网要么开热点让用户连、要么走蓝牙、要么走 SmartConfig每一种都增加了现场维护的复杂度。有线就简单多了插上网线DHCP 拿地址或者干脆静态 IP插上就跑。1.2 W5500 与 LAN8720 两条路线的取舍ESP32 做有线以太网主流就两条路。一条是LAN8720 这类 RMII PHY。它走的是 ESP32 内置的 EMAC 外设用 RMII 接口理论上性能更好直接走 MAC 层吞吐能跑满 100Mbps 的大部分带宽。但它的问题也很实在占引脚多TXD0/TXD1、RXD0/RXD1、TX_EN、CRS_DV、REF_CLK 等等七八根起步还需要一个 50MHz 参考时钟——要么 PHY 自己出、要么 ESP32 出、要么外挂晶振时钟方向和配置错一个就跑不起来。论坛上大量LAN8720 用外部 50M 的设计讨论绝大多数都是在踩时钟这个坑。另一条就是W5500。它是 WIZnet 的硬件协议栈芯片内置 TCP/IP 硬件逻辑、MAC 和 PHY主机只需要通过 SPI 把它当成一堆寄存器去读写就行。占用引脚只有 4 根 SPI 线加一根复位线架构上极其干净。代价是吞吐受 SPI 带宽限制而且你失去了 ESP-IDF 里那套成熟的esp_eth网络栈直通能力——不过这一点在 W5500 上也有官方驱动可以对接后面会讲。1.3 SPI 方案对引脚和内存的占用盘点引脚账很好算。W5500 方案SCLK、MISO、MOSI、CS 四根加上复位 RST、可选的中断 INT一共 5 到 6 根。LAN8720 方案RMII 数据线加控制线通常要 9 根以上还要加上 MDC/MDIO碰上模组还占 PSRAM 引脚的项目基本就分配不开了。内存账更关键。W5500 内部自带16KB 发送缓冲 16KB 接收缓冲默认分给 8 个 socket 各 2KB。这意味着 TCP 的重传、窗口管理、序号维护这些活全在 W5500 芯片里干了ESP32 端的 RAM 消耗极小。而 LAN8720 走的是 ESP32 自己的 LwIP 协议栈每个连接都要占 PCB 控制块、收发缓冲区8 个并发连接下来配合 WiFi 和 BLE 一起开内存压力是肉眼可见的。所以我的选型经验是这样引脚紧张、内存紧张、连接数不多几个到十几个、吞吐要求几十 Mbps 以内W5500 是更省心的方案要大带宽比如视频流、要低延迟、引脚富裕才考虑 RMII 那套。2. SPI 协议先啃透四根线是怎么把 32 位地址搬进芯片的2.1 从 CS/SCLK/MOSI/MISO 的角色分工说起很多人用 SPI 用了好几年但还是说不清这四个信号谁是主谁是从。用一句话概括时钟永远由主机给从机跟着时钟走。SCLK串行时钟。每个时钟周期传输一位上升沿还是下降沿采样由 SPI 模式决定。ESP32 作为主机这个脚的时钟是它产生的。MOSIMaster Out Slave In主机发、从机收。在 ESP32 驱动 W5500 的场景里命令、地址、要写入的数据都从这里出去。MISOMaster In Slave Out从机发、主机收。读 W5500 的寄存器、读接收缓冲区的网络数据都从这里回来。CS片选。低电平有效拉低表示我现在要跟你说话。总线上如果还挂着别的 SPI 设备比如 SPI Flash、SPI 屏就靠这根线来区分跟谁说话。理解的关键点在于SPI 是全双工的MOSI 和 MISO 在同一个时钟周期里同时各传一位。所以你在 Arduino 里看到的SPI.transfer(0xFF)这种写法意思不是发一个 0xFF而是在接下来 8 个时钟周期里MOSI 输出 0xFF同时把 MISO 上收到的 8 位读回来。读寄存器时之所以要发一个哑字节dummy byte就是因为时钟得转起来从机才能往外吐数据。2.2 模式 0 的时序细节与时钟极性的坑SPI 有四种模式由 CPOLClock Polarity空闲电平和 CPHAClock Phase采样边沿组合而成。W5500 只支持 SPI 模式 0也就是 CPOL0、CPHA0时钟空闲时为低电平数据在上升沿被采样。为什么这个要说清楚因为如果你用错了模式现象会非常迷惑——有时候读出来是全 0有时候是全 0xFF有时候数值看着像但又不对而且换个批次芯片现象还不一样。我第一次用软件模拟 SPI 的时候就在这上面耗了一下午后来拿示波器打了一下发现时钟空闲是高采样沿也在下降沿整个反了。还有一个容易忽略的细节是位序。W5500 是MSB First先传最高位。绝大多数 SPI 设备都是 MSB First但配置里忘了设读出来的字节就是完全反的比如期望 0x04 读出 0x20这种情况一看就是位序错了。传输时序上正确的顺序是CS 拉低 → 发地址高字节 → 发地址低字节 → 发控制字节 → 收发数据 → CS 拉高。整套动作必须在一次 CS 有效期内完成中途 CS 抖动会导致 W5500 状态机复位读出来就是垃圾数据。2.3 W5500 三字节帧头地址 控制字节逐位拆解这是整篇文章最关键的地方也是大部分例程能跑但没人讲清楚的部分。W5500 的 SPI 帧结构是定长的 3 字节头部 数据第一个字节是地址高 8 位第二个字节是地址低 8 位第三个字节是控制字节。控制字节的 8 位是这样分配的位域名称含义bit 7 ~ bit 3BSB[4:0]块选择5 位共 32 个块bit 2RWB0 读1 写bit 1 ~ bit 0OM工作模式00 表示可变长度模式BSB 是最需要理解的。W5500 把内部空间划成了 32 个块常见的有0x00通用寄存器块MR、GAR、SHAR、SIPR、VERSIONR 这些0x01 3n第 n 个 socket 的寄存器块0x02 3n第 n 个 socket 的发送缓冲区0x03 3n第 n 个 socket 的接收缓冲区所以 socket 0 的三个块分别是 0x01、0x02、0x03socket 1 是 0x04、0x05、0x06以此类推。为什么会这样设计因为 W5500 内部是 8 个 socket × 3 类空间的规则矩阵用 5 位块号就能线性寻址硬件实现简单软件算起来也方便。OM 位是很多人没注意的地方。默认的00是可变长度模式意思是你发多少字节我就当多少个数据适合收发网络报文这种长度不定的场景。另外三种是定长模式1/2/4 字节在读单个寄存器时用定长模式理论效率更高但你得自己区分场景反而容易出错。我自己的做法是统一用可变长度模式代码简单、不容易翻车。2.4 硬件片选和软件片选差在哪这是搜得最多的 SPI 问题之一。所谓硬件片选是 SPI 外设自己在传输开始时把 CS 拉低、结束时拉高软件片选则是你自己用 GPIO 手动控制。在 ESP32 上Arduino 的 SPI 库用的是硬件外设但片选实际上还是走 GPIO 手动控制的SPI.begin(sck, miso, mosi, ss)里那个 ss 只是记了个默认值真正的 CS 时序由库或你的代码来管。ESP-IDF 的spi_master里则是明确区分spi_device_interface_config_t的spi_device_interface_config_t::spics_io_num设了就是硬件片选设成 -1 就是不用硬件片选你自己控制。那什么时候需要软件片选两种情况。一是多个从机共享总线需要你自己决定谁被选中二是CS 与其它信号有严格时序配合比如你需要在 CS 拉低之后再延迟一点才开始传输。对 W5500 来说核心要求只有一条一次完整的寄存器访问必须在一个 CS 低电平窗口内完成。用 Arduino 库时库内部处理了这件事自己写驱动的时候注意别在传输中间去动 CS 就行。3. 接线与参考电路上电前必须核对的清单3.1 引脚分配表与 ESP32 脚位禁区先给一张我常用的接线表基于 ESP32 的 VSPI默认 SPI 外设ESP32 引脚信号说明GPIO 18SCLKSPI 时钟输出GPIO 19MISO从 W5500 读数据GPIO 23MOSI向 W5500 写数据GPIO 5CS片选低有效GPIO 4RST硬件复位低有效GPIO 27INT可选W5500 中断输出3V3VCC供电GNDGND共地这里有几个不能碰的引脚新手特别容易踩GPIO 6 ~ 11接内部 SPI Flash占用了就下载不了程序甚至开不了机。GPIO 34 ~ 39只有输入功能没有输出驱动能力绝对不能拿来做 SCLK、MOSI、CS。GPIO 16 / 17很多模组默认给了内部 PSRAM用了会冲突。GPIO 12启动时的电平会影响 Flash 供电电压MTDI strapping如果外部上拉导致启动时是高电平可能直接启动失败。所以 HSPI 那组默认引脚里的 MISO12 要特别当心最好换一组引脚映射。GPIO 0 / 2 / 15也都是 strapping 脚用之前先确认上电瞬间的电平是否符合要求。我个人的习惯是做 W5500 项目时把 SPI 映射到 18/19/23 这组默认脚CS 用 5RST 用 4把 0/2/12/15 全部空出来不用。这样最不容易出玄学问题。3.2 供电、复位、晶振与 12.4k 偏置电阻供电这块W5500 是 3.3V 单电源但电流不小——空闲状态大概几十毫安收发数据时峰值能到 130mA 以上。这意味着如果你用的是体积很小的 ESP32 模组板载 LDO 的余量可能不够尤其是同时还要给 WiFi 供电的时候。我的建议是如果 ESP32 板上是 AMS1117 这类能出 800mA 的 LDO直接取 3.3V 通常没问题如果板子是精简版、LDO 只有 300mA 级别一定给 W5500 单独供一路 LDO。同时在 W5500 的 VDD 附近就近放 100nF 和 10uF 的退耦电容这一点在 PCB 设计上别省。复位电路W5500 的 RSTn 是低电平有效数据手册要求低电平保持时间不低于 500 微秒。实际写代码时统一给 10ms 更保险。复位拉高之后不要立刻访问寄存器内部 PLL 需要锁定时间建议至少等 1ms 以上我一般给 50 到 100ms。晶振W5500 需要外部 25MHz 晶振如果买的是集成晶振的模块就不用管如果是自己画板晶振的负载电容要按晶振规格书配通常 12 到 22pF走线尽量短离芯片近。还有一颗绝对不能少的电阻RSET_BG 引脚到地的 12.4kΩ精度要求 1%。这是 PHY 的偏置电流设定电阻决定发送驱动电流的大小。我见过一个板子为了省事用了个 10kΩ 的结果就是网口能 link 上但丢包率极高换个网线就好一点、换个交换机就完全不通查了三天才发现是这颗电阻。这种问题最难查因为现象是半死不活而不是完全不工作。3.3 网口变压器与差分走线W5500 内部集成了 PHY但没有集成变压器。也就是说从芯片出来的 TXP/TXN、RXP/RXN 是差分信号必须经过 1:1 的隔离变压器再到 RJ45。最省事的做法是用带变压器的 RJ45 座比如常见的 HR911105A 这类一颗器件解决问题。自己搭分立变压器的话要注意变压器中心抽头的接法各家参考设计略有差异务必以 W5500 官方硬件设计指南和模块厂家的原理图为准。差分布线有几个原则TX 对和 RX 对各自等长对内走线长度差控制在很小范围内差分阻抗按 100Ω 控制尽量远离晶振、SPI 时钟这些干扰源两条线之间不要走其它信号。这些东西在低速下可能体现不出来但走线乱搞的板子在长网线、恶劣电磁环境下的误码率会明显上升。再多说一句买模块是最划算的选择。W5500 的模拟部分对布线和器件精度有要求自己画板省下的那点钱很可能换来几周的调试时间。市面上成熟的 W5500 模块已经把晶振、变压器、偏置电阻都做全了接线只剩下电源和 SPI性价比极高。4. 例程逐行拆解从寄存器读写到 TCP 回环4.1 复位与 SPI 初始化先看初始化的部分。以下基于 Arduino 环境#include SPI.h #define PIN_SCLK 18 #define PIN_MISO 19 #define PIN_MOSI 23 #define PIN_CS 5 #define PIN_RST 4 static void w5500HardwareReset() { pinMode(PIN_RST, OUTPUT); digitalWrite(PIN_RST, LOW); // 拉低复位低有效 delay(10); // 手册要求 500us这里给足余量 digitalWrite(PIN_RST, HIGH); // 释放复位 delay(100); // 等内部 PLL 锁定别急着访问寄存器 } static void w5500SpiInit() { SPI.begin(PIN_SCLK, PIN_MISO, PIN_MOSI, PIN_CS); pinMode(PIN_CS, OUTPUT); digitalWrite(PIN_CS, HIGH); // 空闲时必须拉高否则总线误触发 // 模式 0MSB First时钟 20MHz SPI.beginTransaction(SPISettings(20000000, MSBFIRST, SPI_MODE0)); }逐行说几个点。pinMode(PIN_CS, OUTPUT)和digitalWrite(PIN_CS, HIGH)这两行顺序很重要——如果先 begin 后设方向中间会有一小段时间 CS 处于高阻态可能被误判成低电平导致 W5500 收到一段垃圾时序。这就是很多人上电第一次通信必失败、重试就好的原因。SPI.beginTransaction里的 20MHz 是我实测比较稳的值。W5500 手册标称支持到 80MHz但能不能跑到这个频率取决于你的走线质量。杜邦线飞线的情况下20MHz 是比较务实的起点调通了再往上试。4.2 通用读写函数怎么封装有了前面讲的帧格式读写函数就非常直白// 写bsb 是块号len 是字节数 static void w5500Write(uint16_t addr, uint8_t bsb, const uint8_t *buf, uint16_t len) { digitalWrite(PIN_CS, LOW); SPI.transfer((uint8_t)(addr 8)); // 地址高字节 SPI.transfer((uint8_t)(addr 0xFF)); // 地址低字节 SPI.transfer((uint8_t)((bsb 3) | 0x04)); // BSB RWB1(写) OM00 for (uint16_t i 0; i len; i) { SPI.transfer(buf[i]); } digitalWrite(PIN_CS, HIGH); } // 读 static void w5500Read(uint16_t addr, uint8_t bsb, uint8_t *buf, uint16_t len) { digitalWrite(PIN_CS, LOW); SPI.transfer((uint8_t)(addr 8)); SPI.transfer((uint8_t)(addr 0xFF)); SPI.transfer((uint8_t)((bsb 3) | 0x00)); // RWB0(读) for (uint16_t i 0; i len; i) { buf[i] SPI.transfer(0x00); // 哑字节换数据 } digitalWrite(PIN_CS, HIGH); }(bsb 3)把 5 位块号挪到控制字节的高 5 位| 0x04把 RWB 位置 1 表示写。读的时候 RWB 是 0所以只留(bsb 3)。这里有个细节值得注意SPI.transfer(0x00)里那个 0x00 本身没有任何意义MOSI 上发什么从机都不关心它只是在产生时钟。如果你不小心写成SPI.transfer(buf[i])程序也能跑只是读回来的数据被覆盖了——这种 bug 特别隐蔽。再封装一层单字节读写会更好用static void w5500WriteByte(uint16_t addr, uint8_t bsb, uint8_t val) { w5500Write(addr, bsb, val, 1); } static uint8_t w5500ReadByte(uint16_t addr, uint8_t bsb) { uint8_t v 0; w5500Read(addr, bsb, v, 1); return v; }4.3 读版本号验证链路在写任何网络逻辑之前一定要先读一次版本寄存器这是判断 SPI 链路是否通的最快手段void setup() { Serial.begin(115200); w5500HardwareReset(); w5500SpiInit(); uint8_t ver w5500ReadByte(0x0039, 0x00); // VERSIONR Serial.printf(W5500 VERSIONR 0x%02X\r\n, ver); if (ver ! 0x04) { Serial.println(SPI 链路异常先别往下走); while (1) delay(1000); } }0x0039是通用寄存器块里的版本寄存器地址W5500 固定返回0x04。这个值不对后面所有代码都是白搭。读出来是什么基本对应什么问题读到的值可能原因0x00MISO 没接好或从机没被选中CS 没拉低或 SPI 模式错0xFFMISO 被上拉、从机没响应、模块没供电、复位没释放0x04 但偶发乱码时钟太高、走线太长、CS 时序有毛刺0x20 / 0x08 之类位序搞反了MSB/LSB 设错这张表我建议截图存手机里现场调试时比翻手册快得多。4.4 Socket 开 TCP 服务端8 条寄存器指令走完流程链路通了之后就可以走 W5500 的 socket 流程了。这里以 socket 0 开一个 TCP 服务端为例块号是0x01各寄存器地址按 socket 内偏移计算。#define S0_BASE 0x0000 // socket0 寄存器块内基址 #define S0_MR 0x0000 // 模式寄存器 #define S0_CR 0x0001 // 命令寄存器 #define S0_SR 0x0003 // 状态寄存器 #define S0_PORT 0x0004 // 本地端口(2字节) #define S0_RXBUF_SIZE 0x001E #define S0_TXBUF_SIZE 0x001F #define S0_TX_FSR 0x0020 // 空闲发送缓冲大小 #define S0_TX_WR 0x0024 // 发送写指针 #define S0_RX_RSR 0x0026 // 已接收字节数 #define S0_RX_RD 0x0028 // 接收读指针 #define BSB_S0_REG 0x01 // socket0 寄存器块 #define BSB_S0_TX 0x02 // socket0 发送缓冲 #define BSB_S0_RX 0x03 // socket0 接收缓冲 #define CMD_OPEN 0x01 #define CMD_LISTEN 0x02 #define CMD_CLOSE 0x10 #define CMD_SEND 0x20 #define CMD_RECV 0x40 #define SR_CLOSED 0x00 #define SR_INIT 0x13 #define SR_LISTEN 0x14 #define SR_ESTABLISHED 0x17 #define SR_CLOSE_WAIT 0x1C打开服务端总共就两步void socket0Listen(uint16_t port) { w5500WriteByte(S0_MR, BSB_S0_REG, 0x01); // 0x01 TCP 模式 uint8_t p[2] { (uint8_t)(port 8), (uint8_t)(port 0xFF) }; w5500Write(S0_PORT, BSB_S0_REG, p, 2); // 写入监听端口 w5500WriteByte(S0_CR, BSB_S0_REG, CMD_OPEN); // 命令OPEN waitSr(SR_INIT); // 等状态变 SOCK_INIT w5500WriteByte(S0_CR, BSB_S0_REG, CMD_LISTEN); // 命令LISTEN waitSr(SR_LISTEN); // 等状态变 SOCK_LISTEN }Sn_MR写0x01表示 TCP写0x02是 UDP这个值的低 4 位就是协议类型。端口是高字节在前这是网络字节序写反了就会监听到一个莫名其妙的端口上。Sn_CR是发令寄存器写进去一个命令值W5500 硬件就开始执行。执行完成后命令寄存器会被硬件自动清零所以你不需要手动清。接收数据的流程是这样的bool socket0Recv(uint8_t *buf, uint16_t *len) { uint8_t sr w5500ReadByte(S0_SR, BSB_S0_REG); if (sr ! SR_ESTABLISHED sr ! SR_CLOSE_WAIT) return false; uint8_t rsr[2]; w5500Read(S0_RX_RSR, BSB_S0_REG, rsr, 2); uint16_t rxLen ((uint16_t)rsr[0] 8) | rsr[1]; if (rxLen 0) return false; uint8_t rd[2]; w5500Read(S0_RX_RD, BSB_S0_REG, rd, 2); uint16_t rdPtr ((uint16_t)rd[0] 8) | rd[1]; uint16_t readLen rxLen *len ? *len : rxLen; // 注意接收缓冲的读地址需要按 16 位偏移去访问 w5500Read(rdPtr, BSB_S0_RX, buf, readLen); *len readLen; // 更新读指针并下发 RECV 命令通知芯片这段数据已取走 rdPtr readLen; uint8_t newRd[2] { (uint8_t)(rdPtr 8), (uint8_t)(rdPtr 0xFF) }; w5500Write(S0_RX_RD, BSB_S0_REG, newRd, 2); w5500WriteByte(S0_CR, BSB_S0_REG, CMD_RECV); return true; }这段逻辑初学者最容易漏的是最后必须下发 RECV 命令。W5500 的接收缓冲是个环形区你不告诉它我已经取走了读指针不推进下一次读还是同一份数据表现就是收到一条消息后疯狂重复处理同一条。我第一次遇到这个现象时以为是上层重发查了好久。发送侧相对简单bool socket0Send(const uint8_t *buf, uint16_t len) { uint8_t fsr[2]; w5500Read(S0_TX_FSR, BSB_S0_REG, fsr, 2); uint16_t freeSize ((uint16_t)fsr[0] 8) | fsr[1]; if (freeSize len) return false; // 缓冲不够等下一轮 uint8_t wr[2]; w5500Read(S0_TX_WR, BSB_S0_REG, wr, 2); uint16_t wrPtr ((uint16_t)wr[0] 8) | wr[1]; w5500Write(wrPtr, BSB_S0_TX, buf, len); // 数据写进发送缓冲 wrPtr len; uint8_t newWr[2] { (uint8_t)(wrPtr 8), (uint8_t)(wrPtr 0xFF) }; w5500Write(S0_TX_WR, BSB_S0_REG, newWr, 2); // 更新写指针 w5500WriteByte(S0_CR, BSB_S0_REG, CMD_SEND); // 命令SEND return true; }关键顺序是先写数据、再更新写指针、最后发 SEND 命令。顺序错了就会出现发送成功但对方收到的是上一条数据这种诡异现象。4.5 用现成库走快车道Arduino 与 ESP-IDF 两种写法如果你不想碰寄存器两条快车道Arduino 环境用 WIZnet 维护的 Ethernet 库支持 W5100/W5200/W5500 的那个版本核心就两行#include SPI.h #include Ethernet.h void setup() { SPI.begin(18, 19, 23, 5); Ethernet.init(5); // 指定 W5500 的片选引脚 byte mac[] {0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED}; if (Ethernet.begin(mac) 0) { // DHCP 拿地址 Ethernet.begin(mac, IPAddress(192,168,1,100)); // 失败就静态 IP } Serial.println(Ethernet.localIP()); }要提醒的是ESP32 Arduino core 自带的ETH库主要针对 RMII 类 PHY不是给 W5500 用的别拿错了库。另外Ethernet.begin()里默认会跑一遍链路检测网线没插会卡住一段时间加个超时逻辑更稳妥。ESP-IDF 环境官方有 W5500 的 MAC 和 PHY 驱动思路是先建 SPI 总线、再建 MAC 和 PHY、然后装驱动、最后挂到 netif 上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_bus_initialize(SPI2_HOST, buscfg, SPI_DMA_CH_AUTO); spi_device_interface_config_t devcfg { .clock_speed_hz 20 * 1000 * 1000, .mode 0, .spics_io_num 5, .queue_size 20, }; // 后续依次调用 esp_eth_mac_new_w5500 / esp_eth_phy_new_w5500 // 再 esp_eth_driver_install 并绑定 esp_netifIDF 这条路的优势是能直接复用 LwIP、事件循环、mDNS、HTTP 服务这些上层组件做产品的话省很多事。顺带回应一个常见疑问SPI 需要两个 DMA 吗这个问法多半来自 STM32 那边全双工收发各占一个 DMA 流。ESP32 这边 SPI 主机有 DMA但 W5500 这种寄存器级别的短传输根本用不上只有在搬运大块收发缓冲时 DMA 才有意义。别被这个概念绕进去。5. 常见问题排查速查表与踩坑记录5.1 现象-定位-处理 对照表现象优先怀疑处理方式版本号读 0x00SPI 未通信查 MISO 接线、确认 CS 拉低、确认模式 0版本号读 0xFF从机无响应查供电、查复位是否释放、查 CS 是否被其它设备占用版本号正确但 DHCP 超时网络层问题查网线、查变压器、查 12.4k 偏置电阻、查 MAC 地址是否重复网口灯不亮PHY 未工作查 25MHz 晶振、查供电电流是否足够、查复位时序数据能收但重复处理忘了 RECV 命令收完数据后更新 RX_RD 并下发 RECV发送成功但内容错位写指针顺序错必须写数据 → 更新 TX_WR → 发 SEND传几百字节后卡死发送缓冲满每次发之前读 TX_FSR不够就等下次偶发乱码、不定时重启SPI 时钟太高或电源不稳降到 10MHz 试检查退耦电容和电源余量5.2 几个不太容易想到的坑第一个坑是网线和交换机。有次调试设备接办公室交换机一切正常接到现场某台老交换机就疯狂丢包。最后发现是那台交换机只有 10M 半双工能力自动协商没谈拢。W5500 支持自协商但老设备有时候协商不干净。可以通过通用寄存器的 PHY 配置寄存器强制双工和速率虽然不常见但遇到时能救命。第二个坑是多个 CS 共用时的总线争抢。如果你的板子上同时挂了 W5500 和 SPI Flash 或 SPI 屏一定要确保每个设备的 CS 在非访问期间是拉高的。曾经有个项目的 SPI 屏 CS 悬空导致它偶尔偷听发给 W5500 的命令现象是随机死机查了一周。第三个坑是复位与上电顺序。如果 W5500 和 ESP32 共用同一个 3.3V上电瞬间 W5500 可能比 ESP32 先就绪也可能后就绪。稳妥的做法是 ESP32 启动后主动拉一次 RST 引脚别指望上电复位电路。第四个坑是MAC 地址复用。同一个局域网里两台设备的 MAC 地址一样症状是两台设备轮流失联看起来像网络不稳其实是 ARP 表在打架。批量生产的项目一定要给每台设备分配唯一 MAC可以从芯片 ID 派生。第五个坑是打印调试拖慢了流程。用串口打印每个寄存器读写耗时很长尤其是 115200 波特率下。如果你在 socket 轮询循环里频繁打印会错过接收窗口表现就是高流量下丢包。调试时用 LED 或计数器指示状态比打印靠谱。6. 进阶优化多 socket、吞吐与长连接保活6.1 8 个 socket 的分配策略W5500 有 8 个独立 socket每个都能单独配成 TCP 或 UDP。分配上有几个经验原则。别把 8 个 socket 全开成 TCP 服务端。每开一个 socket就占掉一份缓冲和连接状态。合理的做法是按功能分组比如socket 0 做配置通道HTTP 或简单 TCPsocket 1 做数据上报MQTT 或自定义协议socket 2 做调试通道剩下的留给并发客户端。缓冲大小可以动态调。Sn_TXBUF_SIZE和Sn_RXBUF_SIZE默认都是 2KB8 个 socket 一共用满 16KB 16KB。如果是 UDP 广播这种小包场景把单个 socket 的缓冲调到 1KB 甚至 0.5KB就能腾出空间给关键 socket 用 8KB。但要注意所有 socket 的缓冲之和不能超过芯片总量超了后面的配置会静默失败这个坑很隐蔽配置寄存器写下去读回来会发现根本没生效。关闭不用的 socket 要发 CLOSE 命令。直接不理会它是不行的状态机会一直停在某个中间态占着资源。6.2 吞吐实测与 SPI 时钟的关系理论上 W5500 的 SPI 能跑到 80MHz但实际吞吐瓶颈往往不在 SPI 时钟而在你的轮询循环效率。粗略算一下SPI 20MHz 意味着每秒 2000 万个时钟周期一个字节 8 位理论极限大约 2.5MB/s。但每次读接收缓冲都要经历读 RSR → 读 RX_RD → 读数据 → 写 RX_RD → 发 RECV这一整套头部的 SPI 事务开销占了不少。实际测下来20MHz 配置、单纯做 TCP 回环稳定吞吐大概在几百 KB/s 到 1MB/s 之间。想提高吞吐有几个方向提高 SPI 时钟但要看走线质量杜邦线飞线基本到 20-30MHz 就是极限PCB 短走线可以试 40MHz 以上。增大单次读取长度一次把 RSR 里所有数据都读走而不是一次读几十字节。减少轮询里的无用操作别每次都去读一遍全部寄存器可以先读Sn_IR中断寄存器判断有没有事件。用 INT 引脚让 W5500 在收到数据时主动拉低中断ESP32 用 GPIO 中断唤醒处理比死循环轮询省 CPU 也更快响应。6.3 断线重连与心跳设计有线网络比无线稳定得多但交换机重启、网线被拔、对端服务器挂了这些情况一样会断所以重连逻辑必须有。我的做法是在 socket 轮询里加一个状态检查uint8_t sr w5500ReadByte(S0_SR, BSB_S0_REG); if (sr SR_CLOSED) { // 连接已关闭重新走 OPEN LISTEN 流程 socket0Listen(5000); }对 TCP 客户端主动连接服务端的场景还要注意CONNECT 命令发出后会有一个短暂的中间态不能立刻判断失败要给它几百毫秒转成 ESTABLISHED。超时了再发 CLOSE 然后重试。心跳方面如果对端是 MQTT 服务器用协议自带的心跳就够了如果是自己写的 TCP 长连接建议每 30 到 60 秒发一个字节的心跳包。有心跳的好处是能及时发现TCP 连接还在但实际链路已断的僵死状态——这种情况在网线被拔出、交换机端口被关闭时很常见光靠 TCP 状态机是发现不了的。还有一点重连要加退避。别一断开就立刻重连那样如果对端服务没起来会形成密集重试。我的习惯是 1 秒、2 秒、4 秒、8 秒封顶 30 秒连上之后重置。最后再分享一个我自己用下来很受用的做法在设备上留一个通过 W5500 访问的极简 HTTP 接口返回当前 IP、运行时长、连接状态、最近一次收发时间戳。现场排查时手机连上同一个网络打开浏览器就能看到设备状态比带电脑接串口方便太多。这个接口只用几十行代码但省下的现场时间非常可观。