
折腾过 ESP32 外接以太网的人应该都有这种体验手里明明有 W5500 模块照着网上的接线图接好了下载例程串口却什么都不打印或者打印一堆乱码。然后就开始怀疑硬件坏了、线接错了、库没装对最后折腾一晚上发现是 SPI 的时序和片选逻辑出了问题。这篇东西我打算换个讲法不直接丢一个封装好的库让你点编译而是把 ESP32 的 SPI 先掰开揉碎讲清楚再用手写寄存器操作的方式把 W5500 做有线以太网需要用到的每一步——从 SPI 帧结构到 TCP 客户端连接——逐行解释清楚。这样你抄作业也能抄得明明白白后面出问题也知道去哪里查。不管你用的是 Arduino IDE 还是 PlatformIO只要芯片是 ESP32 系列这篇的思路和代码都能直接用。目标是让你看完之后不仅能把 W5500 跑起来还能自己动手改出 UDP、TCP Server 甚至 HTTP Client。1. 为什么 ESP32 的 SPI 总让人犯迷糊先解决认知偏差1.1 你以为的 SPI 和 ESP32 实际的 SPI区别在哪SPI 本身不复杂四根线SCK 提供时钟MOSI 主机输出从机输入MISO 主机输入从机输出CS 片选决定当前和哪个从机说话。它和 I2C、UART 最大的区别是全双工加高速率主从之间靠时钟边沿同步没有地址帧靠 CS 选人。这套逻辑放到 ESP32 上很多从 Arduino UNO 转过来的人会先懵一下。UNO 上的 SPI 引脚是写死的SCK 是 13MISO 是 12MOSI 是 11SS 是 10。但 ESP32 的 SPI 引脚不是固定死的大部分 GPIO 都能通过内部的 GPIO 矩阵映射到 SPI 外设的信号上。这意味着你完全可以把 SCK 放在 18也可以放在 14代码里指定哪个就是哪个。听起来很灵活对吧但灵活的另一面是混乱。网上搜 ESP32 W5500 的教程有人用 18/19/23/5有人用 14/12/13/27还有人用 5/18/19/23谁都没错只是各自选的总线和引脚不同。如果你不知道这层关系照着另一篇教程的接线图配上另一段代码的引脚定义那跑不起来太正常了。还有一个非常容易被忽略的点ESP32 有多个 SPI 控制器。经典款的 ESP32 有 SPI0/SPI1内部 Flash 用、SPI2也就是 HSPI、SPI3也就是 VSPI。在 Arduino 环境里你直接用的SPI对象默认是 VSPI默认引脚映射到 18/19/23/5。但 PlatformIO 或 IDF 环境下写法又不一样不同版本的 Arduino core 对默认引脚的设定也改过。所以最稳妥的办法不是记默认引脚而是自己显式指定。1.2 从 Arduino UNO 转到 ESP32 后最容易踩的引脚坑我用一个表格把经典 ESP32 上常见的 SPI 引脚分配列出来你对照着看就知道为什么教程会打架了。总线名称Arduino 默认 SCK默认 MISO默认 MOSI默认 CS备注VSPISPI31819235ArduinoSPI对象默认走这里HSPISPI214121315第二个 SPI 总线不冲突时可以并用FSPI取决于 core 版本同左同左同左名称在不同环境下含义不一样容易混淆到了 ESP32-S3 和 C3 上命名又变了。S3 只有 SPI2 和 SPI3C3 合成了一个 SPI 外设Arduino core 3.x 对默认引脚的重新映射也导致旧教程大面积失效。所以我的建议是别管网上怎么标代码里自己写清楚SPI.begin(18, 19, 23, 5); // SCK, MISO, MOSI, SS这样你按着代码接线至少不会因为默认引脚版本差异翻车。1.3 模式、速率和片选能跑和跑得稳的差距SPI 有四种模式由 CPOL时钟极性和 CPHA时钟相位组合决定。W5500 支持 Mode 0 和 Mode 3这是官方数据手册写得明明白白的。绝大多数例程用 Mode 0时钟空闲时是低电平第一个边沿采样数据。这也是 W5500 内部移位寄存器的天然工作方式。速率方面W5500 标称支持最高 80MHz 的 SPI 时钟这只是芯片能力的上限不代表你的杜邦线加面包板环境也能稳跑 80MHz。我自己实测下来飞线环境超过 20MHz 就有概率随机读回 0xFF 或者数据错位尤其是 MISO 线稍长的时候。所以例程里统一用 14MHz留足余量。你如果用的是 PCB 板载模块且走线很短可以试着提到 30MHz、40MHz。片选这里也有讲究。ESP32 的 SPI 外设支持硬件自动片选但很多例程会直接用普通 GPIO 软件控制 CS。软件控制的优势是帧边界完全由你把握拉低 CS发完一帧拉高 CS清清楚楚。对 W5500 这种简单从机来说软件片选反而是最容易理解和调试的方案。注意在片选拉低之前调用SPI.beginTransaction把模式、速率、字节序一次性配好整个帧期间不要被其他外设抢占 SPI 总线。2. W5500 为什么适合当你的第一个以太网外设硬件协议栈的思路2.1 全硬件协议栈到底替你干了什么先想一个问题ESP32 本身支持以太网 MAC接个 PHY 芯片也能上网为什么还要用 W5500 这种带协议栈的芯片因为省事。ESP32 内置以太网 MAC 的方案意味着你要在软件里跑 TCP/IP 协议栈lwIP 配起来并不算简单中断、内存管理、收发 buffer 都要自己调。W5500 的思路是把 TCP/IP 协议栈直接做成硬件逻辑它内部集成了 MAC 和 PHYTCP、UDP、ICMP、ARP 这些协议的处理全在芯片内部完成。主控 MCU 要做的只是通过 SPI 把数据塞进它的发送缓冲区或者从接收缓冲区把数据读出来。打个比方软件协议栈像你自己开火做饭洗菜切菜炒菜全程参与自由度大但累W5500 像点了个外卖直接送到嘴边你要做的就是打开餐盒吃。对一些数据量不大、主控资源也不宽裕的场景这简直是救命。我知道会有人问那 ESP32 跑 lwIP 不是顺手的吗确实如果只是做一个带 Wi-Fi 的联网设备内置 Wi-Fi 就够了。但如果你需要有线回程比如做工业网关、串口服务器、采集终端W5500 这种 SPI 以太网方案的优势就出来了协议处理不占主控 CPU不会因为主控制器繁忙导致丢包而且 ER 可靠性比 Wi-Fi 稳定得多布线也简单。2.2 W5500 的 SPI 帧结构地址、控制、数据W5500 的 SPI 通信和普通 SPI 从机最大的区别在于它有一套自己的帧协议。一个最小的 SPI 帧由三部分组成两个字节的地址告诉芯片要访问哪个寄存器或者哪个缓冲区位置。一个字节的控制字告诉芯片这次是读还是写、访问哪个块、按什么数据长度模式操作。零到 N 个字节的数据写操作时主机发送数据读操作时主机提供时钟并从 MISO 上读取数据。控制字要重点说一下。W5500 把内部寄存器和管理对象分成了若干块Block比如 Common 寄存器是一个块Socket 0 到 Socket 7 各是一个块。控制字的高位用来选择块中间有一位决定读还是写低位控制数据长度模式。我们例程统一用可变数据长度模式VDM也就是说一帧里可以连续读写任意长度的数据不用像固定长度模式那样拆成多帧。我先不展开每个寄存器地址后面代码里逐个讲。只给你一个很实用的心理预期读一下 VERSION 寄存器地址是 Common 块的 0x0039如果能读到 0x04说明 SPI 通路、帧格式、引脚接线全都没问题。这是个非常棒的硬件自检手段比一上来就跑 DHCP 靠谱多了。2.3 寄存器体系与双缓冲区先理解再写代码W5500 内部有两类寄存器。Common 寄存器负责全局配置MAC 地址、网关、子网掩码、本机 IP、PHY 状态、版本号等。Socket 寄存器则是每个 socket 一套8 个 socket 对应 8 套独立寄存器包括 socket 模式、命令、状态、远程 IP、远程端口、发送/接收指针等等。缓冲区这边W5500 内置了 32KB 内存其中发送和接收各占一半8 个 socket 均分。默认情况下每个 socket 的发送缓冲和接收缓冲各 2KB。这 2KB 在硬件上被设计成环形缓冲区有一个写指针和读指针数据填满后指针回绕到开头继续填。你在代码里看到的Sn_TX_WR、Sn_TX_RD这些寄存器就是用来维护这套环形缓冲区的。socket 的运作本质是一个状态机。典型的 TCP 客户端流程是先 OPEN 打开 socket状态从 CLOSED 变成 INIT然后写远程 IP 和端口发 CONNECT 命令如果连接成功状态变成 ESTABLISHED发送数据前检查发送缓冲区空闲大小把数据写进 TX 缓冲区更新写指针发 SEND 命令等待发送完成中断。理解了这套机制再看代码就不会觉得 W5500 是个黑盒了。它无非就是一个自带协议栈的 SPI 奴隶你通过一帧一帧的 SPI 事务去读它、写它、命令它。3. 例程逐行拆解从 SPI 初始化到 TCP 客户端连接3.1 接线与工程准备我用的是最常见的那款 W5500 模块SPI 接口引出六个脚SCK、MISO、MOSI、CS、RST、INT。RST 和 INT 在 Arduino 例程里经常被忽略但 RST 这个引脚如果不接芯片上电后可能处于不确定状态很多“莫名其妙跑不通”的问题源头就在这里。W5500 模块引脚ESP32 DevKit V1说明SCKGPIO 18SPI 时钟MISOGPIO 19主入从出MOSIGPIO 23主出从入CSGPIO 5片选软件控制RSTGPIO 4复位低电平有效INT不接中断引脚本例用轮询不用中断3.3V3.3V模块供电GNDGND共地工程直接用 Arduino IDE开发板选 ESP32 Dev Module。要装的库只有一个SPI这是 Arduino core 自带的不需要额外下载。PlatformIO 同理框架选 arduino不需要放第三方库进去。3.2 SPI 底层读写全部数据通路就靠这四个函数先把引脚和基础宏定义写出来#include SPI.h #define W5500_CS 5 #define W5500_RST 4 #define W5500_SCLK 18 #define W5500_MISO 19 #define W5500_MOSI 23 #define W5500_SPI_FREQ 14000000UL #define BLOCK_COMMON 0 #define BLOCK_SOCKET0 1BLOCK_COMMON和BLOCK_SOCKET0对应控制字里的块选择编码。Common 块是 0Socket 0 是 1这个编码后面每个读写函数都会用到。接下来是单个字节的写函数void w5500_write8(uint8_t block, uint16_t addr, uint8_t val) { SPI.beginTransaction(SPISettings(W5500_SPI_FREQ, MSBFIRST, SPI_MODE0)); digitalWrite(W5500_CS, LOW); SPI.transfer(addr 8); SPI.transfer(addr 0xFF); SPI.transfer((block 3) | 0x00); SPI.transfer(val); digitalWrite(W5500_CS, HIGH); SPI.endTransaction(); }逐行看SPI.beginTransaction是 Arduino SPI 库的标准用法里面三个参数分别是时钟频率、字节序MSBFIRST 表示大端优先先传最高位、工作模式。这里选 Mode 0对应 W5500 最常用的时序。有的库代码用 Mode 3 也能跑因为 W5500 同时支持这两个模式但和大多数例程保持一致选择 Mode 0 更省心。digitalWrite(W5500_CS, LOW)拉低片选相当于告诉 W5500接下来这几字节是一整帧你别被其他 SPI 信号干扰。片选必须在发送地址之前拉低而且不能在这个函数返回前拉高。然后三个SPI.transfer按顺序发地址高字节、地址低字节、控制字。控制字里(block 3)是块选择放到第 3~5 位|0x00表示这次是写操作低两位的 OM 是 00也就是可变数据长度模式。最后SPI.transfer(val)把真正的数据发出去然后拉高 CS、调用endTransaction。endTransaction不是可有可无的它会把 SPI 总线配置释放掉避免后面别的外设用不同的速率和模式影响到本次配置。单字节读函数结构几乎一样只是控制字里读标志位变为 1数据方向反过来uint8_t w5500_read8(uint8_t block, uint16_t addr) { uint8_t val 0; SPI.beginTransaction(SPISettings(W5500_SPI_FREQ, MSBFIRST, SPI_MODE0)); digitalWrite(W5500_CS, LOW); SPI.transfer(addr 8); SPI.transfer(addr 0xFF); SPI.transfer((block 3) | 0x04); val SPI.transfer(0x00); digitalWrite(W5500_CS, HIGH); SPI.endTransaction(); return val; }读数据时主机仍然要调用SPI.transfer因为数据是从 MISO 线上同步移出来的主机不发时钟从机就没法把数据挤出来。这里SPI.transfer(0x00)里那个0x00只是用来提供时钟的占位符真正关心的是函数返回值val。多字节读写逻辑也一样只不过数据段要循环发送而已void w5500_write_buf(uint8_t block, uint16_t addr, uint8_t *buf, uint16_t len) { SPI.beginTransaction(SPISettings(W5500_SPI_FREQ, MSBFIRST, SPI_MODE0)); digitalWrite(W5500_CS, LOW); SPI.transfer(addr 8); SPI.transfer(addr 0xFF); SPI.transfer((block 3) | 0x00); for (uint16_t i 0; i len; i) { SPI.transfer(buf[i]); } digitalWrite(W5500_CS, HIGH); SPI.endTransaction(); } void w5500_read_buf(uint8_t block, uint16_t addr, uint8_t *buf, uint16_t len) { SPI.beginTransaction(SPISettings(W5500_SPI_FREQ, MSBFIRST, SPI_MODE0)); digitalWrite(W5500_CS, LOW); SPI.transfer(addr 8); SPI.transfer(addr 0xFF); SPI.transfer((block 3) | 0x04); for (uint16_t i 0; i len; i) { buf[i] SPI.transfer(0x00); } digitalWrite(W5500_CS, HIGH); SPI.endTransaction(); }这组函数是整个 W5500 驱动的基石后面所有寄存器和缓冲区操作都建立在它们之上。写数据时如果长度较长可以考虑用 Arduino SPI 库的 buffer 版SPI.transfer(buf, size)提升效率但循环版本更容易看懂数据量不大时性能也够用。3.3 W5500 初始化软复位、PHY Link、网络参数初始化函数第一件事不是配 IP而是确认芯片活着。void w5500_init() { pinMode(W5500_RST, OUTPUT); digitalWrite(W5500_RST, LOW); delay(100); digitalWrite(W5500_RST, HIGH); delay(200); w5500_write8(BLOCK_COMMON, 0x0000, 0x80); // MR 寄存器写软复位 uint32_t t millis(); while (millis() - t 100) { if ((w5500_read8(BLOCK_COMMON, 0x0000) 0x80) 0) break; delay(1); } uint8_t ver w5500_read8(BLOCK_COMMON, 0x0039); if (ver ! 0x04) { Serial.println(W5500 not found, check SPI wiring!); while (1) delay(100); } t millis(); while (millis() - t 5000) { if (w5500_read8(BLOCK_COMMON, 0x002E) 0x01) break; // PHYCFGR bit0 LINK delay(10); } }我先对 RST 引脚做了一次低电平复位然后再用 MR 寄存器做软复位。MR 的 bit7 是 RST 位写 1 触发软复位芯片完成后会自动清 0所以主循环里不断读 MR直到 bit7 不再为 1就说明复位结束了。接下来读 VERSION 寄存器地址 0x0039。W5500 的版本号固定是 0x04。如果这里读出来的不是 0x04先别查网络、别配 IP你的 SPI 时序或者接线一定有问题早发现早省事。然后轮询 PHYCFGR 的 bit0也就是 LINK 位这一位会反映网线有没有插好、对端交换机是否协商成功。这里设置了 5 秒超时如果你没插网线初始化会卡在这里但至少你通过串口知道问题在哪而不是一脸懵地去查代码。初始化后半段是写入网络参数。用到的 Common 寄存器地址分别是网关 GAR 0x0001、子网掩码 SUBR 0x0005、MAC 地址 SHAR 0x0009、本机 IP SIPR 0x000F。注意多字节写入时数据都是大端序和网络字节序一致直接按顺序填就行。uint8_t mac[6] {0x02, 0x00, 0x00, 0x12, 0x34, 0x56}; uint8_t ip[4] {192, 168, 1, 100}; uint8_t gw[4] {192, 168, 1, 1}; uint8_t mask[4] {255, 255, 255, 0}; w5500_write_buf(BLOCK_COMMON, 0x0001, gw, 4); w5500_write_buf(BLOCK_COMMON, 0x0005, mask, 4); w5500_write_buf(BLOCK_COMMON, 0x0009, mac, 6); w5500_write_buf(BLOCK_COMMON, 0x000F, ip, 4);MAC 地址不要全 0也不要和你局域网里其他设备冲突。我习惯用02:开头本地管理地址段不会被真实网卡的 MAC 撞上而且很多交换机对组播 MAC 有特殊处理避开更好。3.4 TCP 客户端连接从 OPEN 到 ESTABLISHED配置完 IP 之后开一个 TCP 客户端 socket。以太网程序里 socket 的概念和 PC 编程里的 socket 很相似W5500 有 8 个你可以把它想象成芯片内独立的 8 条通信通道。uint8_t server_ip[4] {192, 168, 1, 50}; uint16_t server_port 8080; int w5500_tcp_connect() { w5500_write8(BLOCK_SOCKET0, 0x0000, 0x01); // Sn_MR TCP w5500_write16(BLOCK_SOCKET0, 0x0004, 5000); // 本机端口 w5500_write8(BLOCK_SOCKET0, 0x0001, 0x01); // Sn_CR OPEN uint32_t t millis(); while (millis() - t 200) { if (w5500_read8(BLOCK_SOCKET0, 0x0003) 0x13) break; // SOCK_INIT delay(1); } w5500_write_buf(BLOCK_SOCKET0, 0x000C, server_ip, 4); // Sn_DIPR w5500_write16(BLOCK_SOCKET0, 0x0010, server_port); // Sn_DPORT w5500_write8(BLOCK_SOCKET0, 0x0001, 0x04); // Sn_CR CONNECT t millis(); while (millis() - t 3000) { uint8_t st w5500_read8(BLOCK_SOCKET0, 0x0003); if (st 0x17) return 1; // SOCK_ESTABLISHED if (st 0x00) break; // SOCK_CLOSED连接失败 delay(1); } return 0; }这里我补了一个w5500_write16函数逻辑和 write8 一样只是把两个字节按大端序连续写出去void w5500_write16(uint8_t block, uint16_t addr, uint16_t val) { uint8_t buf[2] { (uint8_t)(val 8), (uint8_t)(val 0xFF) }; w5500_write_buf(block, addr, buf, 2); }再逐行看连接流程。Sn_MR 设置为 0x01 表示 TCP 模式然后设置本机端口 5000。执行 OPEN 命令后socket 会从 CLOSED 状态切到 INIT 状态状态值 0x13。这个等待是必要的芯片需要一点时间完成 socket 内部初始化。然后把服务器的 IP 和端口写进 Sn_DIPR 和 Sn_DPORT再发 CONNECT 命令。此时 W5500 会自己发 SYN 包、处理握手过程主控完全不用管 TCP 三次握手的细节。等待状态变成 ESTABLISHED0x17就说明连接成功了。如果 3 秒超时或者状态回到 CLOSED基本可以确认是网络不通、对端端口没监听或者防火墙拦了 SYN这时候不要怀疑 W5500 芯片坏了先用 PC 上的网络调试助手或者nc命令验证服务器端口是否可达。3.5 发送数据与接收回显环形缓冲区操作TCP 连接建立后真正发送数据才是和缓冲区打交道最紧密的部分。void w5500_tcp_send(const char *str) { uint16_t len strlen(str); uint16_t fsr w5500_read16(BLOCK_SOCKET0, 0x0020); // Sn_TX_FSR if (len fsr) { Serial.println(TX buffer full); return; } uint16_t wr w5500_read16(BLOCK_SOCKET0, 0x0024) 0x07FF; // Sn_TX_WR uint16_t remain 0x0800 - wr; if (len remain) { w5500_write_buf(BLOCK_SOCKET0, 0x4000 wr, (uint8_t *)str, len); } else { w5500_write_buf(BLOCK_SOCKET0, 0x4000 wr, (uint8_t *)str, remain); w5500_write_buf(BLOCK_SOCKET0, 0x4000, (uint8_t *)str remain, len - remain); } w5500_write16(BLOCK_SOCKET0, 0x0024, (wr len) 0x07FF); // 更新 Sn_TX_WR w5500_write8(BLOCK_SOCKET0, 0x0001, 0x20); // Sn_CR SEND uint32_t t millis(); while (millis() - t 500) { if (w5500_read8(BLOCK_SOCKET0, 0x0002) 0x10) break; // Sn_IR bit4 SEND_OK delay(1); } w5500_write8(BLOCK_SOCKET0, 0x0002, 0x10); // 清中断标志 }Sn_TX_FSR 是发送缓冲区剩余空间单位是字节。如果剩余空间小于你要发的长度那只能排队或者丢弃。这个检查不能省否则数据会覆盖未发送的区域。Sn_TX_WR 是当前写的偏移位置数据手册里这个值是在 2KB 范围内回绕的所以代码里 0x07FF。所谓回绕就是写指针到了缓冲区末尾之后下一段数据从头开始写。0x0800 是 2KB对应 Socket 0 的发送缓冲区大小remaining 0x0800 - wr算出当前写到末尾还剩多少空间。接下来如果数据长度不超过剩余空间直接从当前写偏移写入如果超过了就先写尾部一段再从缓冲区头写剩下的一段。这就是环形缓冲区的经典分片写逻辑和你在操作 USB 环形队列、音视频环形 FIFO 时是一样的思路。写完数据后把新的写偏移更新回 Sn_TX_WR然后发 SEND 命令芯片开始把缓冲区里的数据封装成 TCP 段发出去。等待 Sn_IR 的 bit4SEND_OK置位后记得清掉这个中断标志否则下次判断会误触发。有发送就有接收。接收端逻辑类似只不过指针换成 Sn_RX_RD缓冲区地址是 RX 缓冲基址。TCP 连接建立后对端发回的数据会出现在接收缓冲区里Sn_RX_RSR 给出当前可读字节数void w5500_tcp_recv() { uint16_t rsv w5500_read16(BLOCK_SOCKET0, 0x0026); // Sn_RX_RSR if (rsv 0) return; uint16_t rd w5500_read16(BLOCK_SOCKET0, 0x0028) 0x07FF; // Sn_RX_RD uint16_t remain 0x0800 - rd; uint8_t buf[128]; uint16_t len min(rsv, (uint16_t)sizeof(buf)); if (len remain) { w5500_read_buf(BLOCK_SOCKET0, 0x8000 rd, buf, len); } else { w5500_read_buf(BLOCK_SOCKET0, 0x8000 rd, buf, remain); w5500_read_buf(BLOCK_SOCKET0, 0x8000, buf remain, len - remain); } buf[len] 0; Serial.print(recv: ); Serial.println((char *)buf); w5500_write16(BLOCK_SOCKET0, 0x0028, (rd len) 0x07FF); // 更新 Sn_RX_RD w5500_write8(BLOCK_SOCKET0, 0x0001, 0x40); // Sn_CR RECV }读完数据之后把读指针 Sn_RX_RD 往前推进再执行 RECV 命令芯片才允许把后续数据写入这块被释放出来的缓冲空间。这个动作本质上是在告诉芯片我已经消费掉这些字节了你可以覆盖它们了。把这个网络库和主循环接起来一个完整的 TCP 客户端就出来了void setup() { Serial.begin(115200); SPI.begin(W5500_SCLK, W5500_MISO, W5500_MOSI, W5500_CS); w5500_init(); if (w5500_tcp_connect()) { Serial.println(TCP connected); } else { Serial.println(TCP connect failed); } } void loop() { static uint32_t lastSend 0; if (millis() - lastSend 3000) { w5500_tcp_send(hello from ESP32 via W5500\r\n); lastSend millis(); } w5500_tcp_recv(); }4. 抄作业也要躲坑实测中的排查记录4.1 复位引脚比你想的更关键很多模块把 RST 引脚默认拉高了你不接也能工作但一旦遇到上电时序问题芯片会卡在复位状态里。我最开始测的时候直接把 RST 悬空结果十次里有两次 SPI 读版本号读到 0x00以为买到坏片。后来把 RST 接到 GPIO 4 上每次初始化前先拉低 100ms 再拉高再也没出现过偶发初始化失败。如果你不想单独占一个 GPIO也可以在模块的 RST 脚和 3.3V 之间接一个 10kΩ 上拉电阻并在上电后延时几百毫秒再操作 SPI。4.2 3.3V 电源千万别省W5500 内部的 PHY 在活跃通信时电流不小如果直接从 ESP32 开发板的 3.3V 引脚取电再同时带着面包板上的其他模块电压很容易被拉到 3.1V 以下。PHY 一旦供电不足现象非常诡异初始化正常、Link 状态正常但一跑大流量就掉线重连。我建议 W5500 模块单独从开发板的 5V 取电用模块板载的稳压器转到 3.3V。如果模块没有板载稳压器就在 3.3V 和 GND 之间放一个 100μF 电解电容和 0.1μF 陶瓷电容给瞬态电流兜底。4.3 CS 不上拉时序乱是必然SPI 总线在主机不操作时SCK、MOSI 的电平是确定的但 CS 如果没有上拉电阻在 ESP32 GPIO 配置的瞬间可能会有一个不确定的毛刺。这个毛刺如果被 W5500 当成片选拉低又恰好碰到主机复位就会在芯片内部留下一个半残废的 SPI 帧后面所有读写全都错位。解决方案非常便宜CS 引脚外接一个 10kΩ 上拉到 3.3V保证复位期间 CS 保持高电平。很多现成的 W5500 模块其实已经板载了但面包板自制模块往往没有需要自己补。4.4 PHY Link Up 之前别急着连服务器初始化时等待 PHY 的 LINK 位这一步不是可有可无的。如果你网线刚插上就立刻发 CONNECT 命令W5500 的 PHY 还没完成协商ARP 请求发不出去连接会直接失败。这也是我习惯在初始化里加 5 秒超时轮询的原因。真正的项目里网线可能随时拔出再插入所以 Link 状态不能只在初始化时检查一次。比较健壮的做法是主循环里定时读 PHYCFGR 的 bit0一旦变成 0 就主动关闭当前 socket等 Link 恢复后再重新发起连接。这个逻辑加进去以后设备就具备拔线重连的能力了。4.5 别急着跑复杂协议先回读寄存器自检我记得有个读者一开始就套用 MQTT 库连不上就改库浪费了三天。其实只要先回读几个关键寄存器VERSION 是不是 0x04、PHYCFGR 的 LINK 位是不是 1、Sn_SR 是不是 0x17就能把问题范围从“整个协议栈”缩小到“硬件连接”或“网络环境”两层。串口打印这几个值比任何调试器都直观。再不行就上逻辑分析仪抓 CS、SCK、MOSI、MISO 四根线看片选是否干净、时钟是否完整、MISO 上有没有回应。实际上 W5500 的 SPI 协议非常简单逻辑分析仪一帧一帧对着数据手册看很快就能找到问题。我自己现在做以太网相关的模块除非有特别高的速率要求否则首选还是 W5500。它的接口模型清晰数据通路不绕弯出现问题也能靠寄存器回读快速定位不会出现那种“代码逻辑完全正确但就是跑不通”的玄学。如果你照这篇抄完还卡在某一环建议把串口打印的 VERSION 和 LINK 状态发出来对一下第 4 章里的排查项基本就能锁死问题了。