
搞过 ESP32 的人应该都有这种感觉点灯、串口、Wi-Fi 都是一把过但一到 SPI 就懵了。什么 MOSI、MISO、SCLK、CS还有 CPOL、CPHA、Mode 0、Mode 3看手册像看天书抄例程也不知道每行在干嘛。偏偏很多项目又绕不开 SPI——传感器要 SPI屏幕要 SPI做有线以太网还是绕不过 SPI。这篇文章就拿 ESP32 W5500 这个非常经典的组合把 SPI 协议本身、ESP32 的 SPI 外设和库函数、W5500 的驱动移植、以及一份可以直接抄作业的 TCP Client 例程逐行讲透。如果你之前用过 ENC28J60 或者一直在用 Wi-Fi 做联网想换有线方案或者就是想把 SPI 彻底搞明白这篇应该能帮你省下好几个晚上的查资料时间。1. 为什么是 W5500有线以太网方案的选型逻辑先说结论在 ESP32 上做有线以太网W5500 是目前综合成本、难度、稳定性最平衡的选择。但这不是拍脑袋说的我把主流的几条路线摆出来对比你看完就知道为什么这么选。1.1 主流以太网芯片横向对比市面上 ESP32 能用的以太网方案无非这么几类内置 MAC 外接 PHY比如 LAN8720、全硬件协议栈芯片W5500、以及更老的 ENC28J60。方案接口协议栈承载需外部时钟接线难度稳定性LAN8720RMIIESP32 软件跑 lwIP需要 50MHz 时钟源高对时序敏感高但布线要求高ENC28J60SPI芯片内部仅 MACPHYTCP/IP靠软件或外部25MHz 晶振低一般驱动占用资源W5500SPI全硬件 TCP/IP 协议栈25MHz 晶振低高不占 MCU 算力LAN8720 这条路线其实性能更好因为 ESP32 内置 MAC配合 lwIP 效率很高。但 RMII 接口对时序很敏感如果你的板子是手工焊接或者用杜邦线飞线50MHz 参考时钟稍微有点毛刺就会莫名丢包而且引脚分配、启动配置都有讲究。热词里专门的“esp32 连接 lan8720 以太网模块常遇到的 3 个问题”就是最好的证明——这芯片不是不能用是新手用起来太容易踩坑。ENC28J60 虽然也是 SPI 接口但内部没有硬件协议栈TCP/IP 处理很吃资源而且 SPI 速率上不去吞吐量有限。1.2 W5500 的硬件优势把 TCP/IP 烧进芯片里W5500 的核心特点是全硬件 TCP/IP 协议栈。TCP、UDP、ICMP、IPv4、ARP、PPPoE 这些协议全部由芯片内部的逻辑电路完成MCU 只需要通过 SPI 告诉它“建立连接”“发送这段数据”“读取收到的数据”就行了。这意味着三件事不占用 ESP32 的算力。ESP32 主频虽然不低但跑 Wi-Fi 协议栈已经很忙了如果 TCP/IP 也丢给 CPU 软件处理高负载下可能互相拖累。W5500 把联网这件事直接外包了。代码逻辑简单。你不必理解 TCP 三次握手、重传、滑动窗口这些细节把它们当黑盒调用就行。实时性有保障。硬件协议栈的处理延迟远低于软件协议栈对于需要稳定、可靠上报的场景更合适。1.3 什么场景下我更推荐 W5500根据我实际做项目的经验下面这几类场景无脑选 W5500 就对了需要 7x24 小时稳定运行的工业数据采集设备WiFi 信号不稳定但现场有网口的机房/工厂环境产品功能简单不想在 ESP32 上为 lwIP 内存调优花时间对实时性和连接稳定性要求高比如 Modbus TCP 网关热词里专门有 Freemodbus TCP W5500 源码说明这个组合在工控领域非常成熟反过来如果你要跑高吞吐量传输比如视频流那 LAN8720 配合 ESP32 内部 MAC 更合适。但我做过的绝大多数物联网项目W5500 的性能都绰绰有余。2. SPI 协议拆解MOSI、MISO、SCLK、CS 到底在干嘛很多教程上来就扔给你一堆时序图然后告诉你“照抄就行”。这不解决问题。你搞清楚 SPI 的本质再看代码每一行就会非常顺。2.1 本质是两排移位寄存器对推数据SPISerial Peripheral Interface最直观的理解主设备和从设备内部各有一个 8 位移位寄存器SCLK 每跳变一次双方就通过 MOSI 和 MISO 交换一位数据。MOSIMaster Out Slave In主机输出、从机输入MISOMaster In Slave Out从机输出、主机输入SCLK时钟线由主机产生CS/SS片选线拉低表示选中某个从机这个机制可以类比成两个人隔着窗口推箱子SCLK 是节拍器每个节拍两边同时把一个箱子推给对方。所以 SPI 是同步、全双工的——同一时刻主机发一位也收一位。你每次SPI.transfer(0xAA)表面上是写了一个字节实际上同时收到了从机推回来的一个字节。这也是为什么在 W5500 驱动里读寄存器是“发送一个假字节同时接收返回数据”的套路。2.2 四种模式CPOL 和 CPHA 的排列组合SCLK 空闲时是高电平还是低电平CPOL数据在时钟上升沿还是下降沿采样CPHA两个参数排列组合出四种模式模式CPOLCPHA特点Mode 000空闲低电平上升沿采样Mode 101空闲低电平下降沿采样Mode 210空闲高电平上升沿采样Mode 311空闲高电平下降沿采样我当初学的时候也老记混后来用一句话记住CPOL 管“空闲时什么样”CPHA 管“第几个边沿采数据”。绝大多数 SPI 从设备用 Mode 0Telemetry 类传感器比如 MAX6675 也常用 Mode 1/2。W5500 官方手册明确支持 Mode 0 和 Mode 3咱们用 Mode 0 就行。如果模式配错从机可能能收到字节但内容错位或者干脆没反应。这个坑在下一章排错部分会专门讲。2.3 硬件片选与软件片选ESP32 上最容易忽视的坑热词里有“spi硬件片选与软件片选”说明大家在这块确实容易迷糊。所谓硬件片选是说 SPI 外设在一次 transaction 开始时自动拉低 CS结束后自动拉高软件片选就是你自己用digitalWrite(csPin, LOW/HIGH)手动控制。ESP32 的 SPI 库和 STM32 不一样它允许你指定任意 GPIO 作为 CS但这个 CS 在库内部有两种管理方式。在 Arduino 版本的 ESP32 SPI 库中SPI.begin(sck, miso, mosi, ss)这个ss参数的作用只是告诉库“这个引脚要被驱动为输出并初始化成高电平”。你如果后续用digitalWrite手动控制 CS那传不传这个参数其实都行但如果你不传又期望库自动控制那 CS 就永远不会拉低通信自然失败。用beginTransaction配合SPI.transfer时ESP32 库默认不会自动拉 CS——SPI.transfer不管片选只负责推字节。自动拉 CS 其实是SPI.transfer(ss, data)这种带引脚参数的重载才会做的。这就导致很多人把代码从别的板子迁过来逻辑明明一样就是不工作。我建议在 W5500 这类明确的芯片驱动里直接手动控制 CS逻辑最清晰开始传输前拉低 CS传输完拉高 CS。这也是官方 ioLibrary_Driver 的默认做法。3. ESP32 的 SPI 外设与库函数逐行解读这块是很多教程省略的重点ESP32 的 SPI 和 Arduino Uno 那种纯软件模拟不一样它有好几组独立硬件 SPI 外设引脚还能映射到任意 GPIO灵活性很高但坑也多。3.1 四组 SPI 控制器VSPI 和 HSPI 是什么ESP32 芯片内部有 4 个 SPI 控制器SPI0/SPI1绑定了 Flash用户程序不要碰。SPI2在 Arduino 环境里通常叫 HSPI。SPI3通常叫 VSPI。Arduino 环境默认的SPI对象走的是 VSPI 的典型引脚SCLK18、MISO19、MOSI23、SS5。你用默认引脚时SPI.begin()不带参数即可如果换成自定义引脚就要按下一节的写法。很多人混用 HSPI 和 VSPI 导致数据错乱其实它们都是硬件 SPI性能一致区别只是默认映射的引脚不同。在同一个项目里如果你 W5500 用 VSPI屏幕用 HSPI互不干扰这本身是优点。3.2 初始化函数SPI.begin() 到底做了什么SPI.begin(sck, miso, mosi, ss);这行代码的作用是把这四个引脚配置为 SPI 功能。miso是输入模式sck、mosi、ss是输出模式同时把ss输出高电平。这里有个容易翻车的点如果接线时把miso和mosi接反了程序不会报错但通信结果全是垃圾数据。接反是 SPI 调试里最常见的低级错误没有之一。很多人拿着示波器疯狂查时序最后发现是两根线交叉了。3.3 beginTransaction 和 SPISettings给每个从设备独立配置SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));SPISettings三个参数速率、位序、模式。每次 beginTransaction 都会让 SPI 外设按这套参数重新配置所以多个从设备可以轮流使用同一组 SPI 总线而互不影响——这个从设备要 Mode 0 就 Mode 0那个要 Mode 3 就 Mode 3只要每次传输前正确指定就行。实际频率多少合适W5500 最高支持 33.3MHz但在 ESP32 上用杜邦线连接时跑到 10MHz 以上就开始有丢数据风险。我实测下来 4MHz ~ 10MHz 是最稳的区间。速率不是越高越好稳定性优先。3.4 transfer 与 endTransaction一次完整传输的节奏一次典型读取操作是这样的SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0)); digitalWrite(csPin, LOW); // 选中从机 uint8_t cmd 0x00; uint8_t data SPI.transfer(cmd); // 发一个字节同时收一个字节 digitalWrite(csPin, HIGH); // 释放从机 SPI.endTransaction();beginTransaction到endTransaction之间的区域是独占 SPI 总线的临界区。在 Arduino 单线程环境下没什么问题但在 ESP-IDF 多任务环境两个任务同时调用 SPI 就会冲突。所以如果做多任务这段区间要加锁或者注册 W5500 的临界区保护回调。还有一点ESP32 Arduino 库中SPI.transfer()要求数据 MSB 在前W5500 官方协议也是 MSB 先行所以MSBFIRST保持默认即可。如果你从其他平台移植代码特别要检查有没有设置成 LSB那会全盘错位。4. 手写 W5500 驱动从寄存器到 Socket API 的分层思路W5500 的驱动没必要从头写官方有一个维护得还不错的库叫ioLibrary_Driver几乎所有 W5500 项目都在用它。但你直接拿过来用遇到问题就抓瞎了因为你不理解它的层次结构。我先把这层窗户纸捅破。4.1 驱动层的四层结构ioLibrary_Driver的代码组织逻辑非常清晰从上到下分四层应用层你写的业务代码比如 HTTP 上报、Modbus 协议。Socket API 层socket.c提供socket()、connect()、send()、recv()、close()这些类 BSD 风格的接口。芯片配置层wizchip_conf.c处理 IP、MAC、网关、子网掩码、超时设置等。芯片底层w5500.c寄存器和收发缓冲区的读写实现。底层再往下就是你的平台移植层包括 SPI 读写、CS 控制和复位时序。这也是你唯一需要自己写的一部分。4.2 移植时最核心的是注册回调函数刚开始移植ioLibrary_Driver时最让人困惑的是这行代码reg_wizchip_spi_cbfunc(wizchip_read, wizchip_write);它的意思是把底层的 SPI 读写函数注册给 W5500 库以后芯片层要读写寄存器时就调用你提供的这两个函数。本质上就是接口注入——库本身不知道你的芯片是什么平台它只认你注册的接口。你还需要注册 CS 控制函数reg_wizchip_cs_cbfunc(wizchip_select, wizchip_deselect);这里的wizchip_select和wizchip_deselect里面就是digitalWrite(csPin, LOW)和digitalWrite(csPin, HIGH)就两行。4.3 W5500 的寄存器寻址机制W5500 的寄存器分四个区块通用寄存器、Socket 寄存器、TX 缓冲、RX 缓冲。通过 SPI 访问时每一帧数据由三部分构成2 字节地址、1 字节控制、若干字节数据。地址阶段告诉芯片要访问哪个寄存器偏移。控制阶段的块选择位告诉芯片这次访问的是通用寄存器还是 Socket 寄存器还是缓冲区读写位告诉芯片是读还是写。数据阶段就是实际传输的数据。这个机制你不用自己拼比特w5500.c内部已经把地址和控制字节的组合封装好了。但你调试时看到wizchip_write(0x0039, buf, 1)这类调用要能看懂——0x0039就是版本寄存器 VERSIONR 的地址。我初始化完后会顺手读一下 VERSIONR正常应该返回0x04。这是判断 SPI 通路是否打通的最快方法比调试网络连接提前了一个层次。4.4 初始化的标准流程初始化 W5500 的代码骨架是固定的// 1. 复位芯片拉低 RSTN延时 500us 以上再拉高 // 2. 注册 CS 回调 // 3. 注册 SPI 回调 // 4. 调用 wizchip_init() // 5. 配置 MAC、IP、网关、子网掩码其中wizchip_init()有个重要参数是 TX/RX buffer 大小配置用两个数组分别指定 8 个 socket 的收发缓冲。比如{2,2,2,2,2,2,2,2}就是每个 socket 缓冲 2KB。如果你的应用只用 socket 0可以给其他 socket 分配为 0节省缓冲。这里要看清楚分配错了可能导致部分 socket 收发异常。4.5 自写底层还是用官方库我的建议很明确先用官方库跑通了再考虑自己精简。官方 ioLibrary_Driver 的问题是多了一些历史包袱但稳定性经过了大量项目验证。你自己写寄存器层省不了多少空间反而容易在边界条件上踩坑。真有优化需求把 socket.c 里不用到的函数剪掉就够了。5. 完整例程逐行注释基于 ioLibrary_Driver 的 TCP Client这一章直接进入“抄作业”环节。目标ESP32 通过 W5500 连接路由器主动向一个 TCP 服务器发起连接并发送一段数据。为了解释清楚我把代码拆成几段逐行加注释。5.1 接线表先按这个表接线板子用最常见的 ESP32 DevKit模块用市面常见的 W5500 网络扩展板。W5500 引脚ESP32 引脚VCC3.3VGNDGNDSCLKGPIO 18MISOGPIO 19MOSIGPIO 23SCSGPIO 5RSTNGPIO 4可配置如果你的模块不支持 3.3V 逻辑电平之外的电平千万别接到 5V 上。W5500 是纯 3.3V 器件接错可能直接烧。5.2 底层接口实现#define W5500_SCS 5 #define W5500_RSTN 4 void w5500_hard_reset() { pinMode(W5500_RSTN, OUTPUT); digitalWrite(W5500_RSTN, HIGH); delay(100); digitalWrite(W5500_RSTN, LOW); // 拉低复位引脚 delay(100); digitalWrite(W5500_RSTN, HIGH); // 拉高结束复位 delay(300); // 等芯片内部稳定手册要求至少 500us } void wizchip_select() { digitalWrite(W5500_SCS, LOW); // 片选拉低选中 W5500 } void wizchip_deselect() { digitalWrite(W5500_SCS, HIGH); // 释放片选 } uint8_t wizchip_read(uint32_t addr) { uint8_t rx; SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0)); digitalWrite(W5500_SCS, LOW); SPI.transfer(addr 8); // 发送地址高字节 SPI.transfer(addr 0xFF); // 发送地址低字节 SPI.transfer(((addr 0x00FF0000) 16) | 0x04); // 控制字节 rx SPI.transfer(0x00); // 发送假字节读取数据 digitalWrite(W5500_SCS, HIGH); SPI.endTransaction(); return rx; } uint8_t wizchip_write(uint32_t addr, uint8_t wb) { SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0)); digitalWrite(W5500_SCS, LOW); SPI.transfer(addr 8); SPI.transfer(addr 0xFF); SPI.transfer((addr 0x00FF0000) 16); // 控制字节写操作 SPI.transfer(wb); digitalWrite(W5500_SCS, HIGH); SPI.endTransaction(); return 1; }注意addr是一个 32 位变量高 16 位是偏移和块信息ioLibrary内部把这个地址拆分发给寄存器。这段代码是标准的ioLibrary_Driver对接代码addr 8、addr 0xFF、(addr 0x00FF0000) 16这三字节帧就是 W5500 的 SPI 寻址帧。5.3 初始化部分#include wizchip_conf.h #include socket.h #include w5500.h uint8_t myMAC[6] {0x00, 0x08, 0xDC, 0x12, 0x34, 0x56}; uint8_t myIP[4] {192, 168, 1, 100}; uint8_t myGW[4] {192, 168, 1, 1}; uint8_t mySN[4] {255, 255, 255, 0}; void w5500_init() { SPI.begin(); // 初始化 SPI 外设 w5500_hard_reset(); // 先给芯片一个干净的上电复位 reg_wizchip_cs_cbfunc(wizchip_select, wizchip_deselect); reg_wizchip_spi_cbfunc(wizchip_read, wizchip_write); uint8_t txbuf[8] {2,2,2,2,2,2,2,2}; uint8_t rxbuf[8] {2,2,2,2,2,2,2,2}; if (wizchip_init(txbuf, rxbuf) 0) { Serial.println(W5500 init OK); } else { Serial.println(W5500 init FAIL); } wizchip_setmacaddr(myMAC); wizchip_setipaddr(myIP[0]); wizchip_setgwipaddr(myGW[0]); wizchip_setsubnetmask(mySN[0]); // 读取版本寄存器验证通信 uint8_t version; wizchip_read_version(version); Serial.printf(W5500 VERSIONR: 0x%02X\n, version); }wizchip_init返回值不是错误码是底层缓冲区大小是否满足要求。如果返回失败多半是分配的缓冲数组有问题。5.4 TCP Client 连接与数据发送下面这段是建立 TCP 连接并发送 HTTP GET 请求的过程。用loop里的状态机控制避免阻塞。#include socket.h #define SOCK_TCPS 0 // 使用 socket 0 void tcp_client_task() { static uint8_t status 0; switch (status) { case 0: { // 创建 socket参数socket号、协议、端口(0表示由connect指定)、标志 if (socket(SOCK_TCPS, Sn_MR_TCP, 0, 0) SOCK_TCPS) { status 1; } break; } case 1: { uint8_t serverIP[4] {192, 168, 1, 50}; uint16_t serverPort 8080; // connect 是异步的吗? 不是它发出 TCP SYN 包后返回 if (connect(SOCK_TCPS, serverIP, serverPort) SOCK_OK) { status 2; } else { Serial.println(connect failed); close(SOCK_TCPS); // 注意失败必须 close 才能重新 socket status 0; } break; } case 2: { // 连接建立后发送数据 const char *req GET /api/data HTTP/1.1\r\nHost: 192.168.1.50\r\n\r\n; int32_t len send(SOCK_TCPS, (uint8_t*)req, strlen(req)); if (len 0) { Serial.printf(sent %d bytes\n, len); status 3; } break; } case 3: { // 接收服务器响应 uint8_t buf[512]; int32_t rlen recv(SOCK_TCPS, buf, sizeof(buf)); if (rlen 0) { Serial.printf(recv %d bytes\n, rlen); // 处理数据... } else if (rlen SOCK_BUSY) { // 还没数据下一轮再试 } else { close(SOCK_TCPS); status 0; // 连接断开重新开始 } break; } } }这个状态机有几个关键点要说清楚。第一socket()创建成功后如果后续connect()失败必须调用close()再重新socket()否则这个 socket 号会一直处于被占用的半开状态。这就是很多例程运行一段时间后卡死的原因之一。第二recv()在没有数据时返回的是SOCK_BUSY而不是 0所以判断语句必须把SOCK_BUSY单独处理。有人直接写成if (rlen 0)处理数据、else就断开结果服务器响应稍微慢点连接就被误关了。第三W5500 的send()一次能发多少取决于 socket 的 TX buffer 大小。我之前分配的是 2KB你如果要发大包要么把 buffer 调大要么在应用层拆包发送。5.5 编译烧录的注意事项把ioLibrary_Driver的Ethernet目录和Internet目录拷到你的项目里时有几个容易出问题的地方wizchip_conf.h里的WIZCHIP宏要定义为W5500默认可能不是。库文件编码如果是旧的 ANSIESP32 编译可能会报警告不影响使用但看着烦。ESP32 的 Arduino 环境默认 flash 大小要设成 4MB 以上如果项目里同时塞 WiFi 和 W5500 的代码默认分区可能不够建议选Minimal SPIFFS或Huge APP分区。6. 实战排错连不上、Ping 丢包、SPI 不工作的完整排查链路写代码半小时调试一整天这基本是嵌入式开发的常态。我把 W5500 ESP32 最常见的几类问题整理成一套排查流程你按顺序查比瞎改代码有效得多。6.1 问题一SPI 完全没反应表现初始化时打印W5500 init FAIL或者读 VERSIONR 不是 0x04。按这个顺序排查查接线。重点查 MOSI/MISO 是否交叉接反、SCLK 是否接到正确引脚、CS 是否真的接到了 GPIO 5。查电平。用万用表量VCC引脚必须稳在 3.3VW5500 工作时电流跳动比较大如果电压跌落超过 0.2V大概率是供电不足。查 SPI 模式。确认SPISettings里是SPI_MODE0且 W5500 的 SCS 空闲时必须为高。某些模块的 CS 引脚有内部下拉如果你用软件片选但初始化顺序不对通信就失败。用逻辑分析仪看波形。这是最直接的手段。把 CLK、MOSI、CS 三个通道接上触发一次初始化看 CS 是否有完整的低电平脉冲看 CLK 是否在跳看 MOSI 上是否有 3 字节帧头。哪条线没波形问题就出在哪条线。我遇到过一个特别隐蔽的情况GPIO 4 和 GPIO 5 在模块上是相邻引脚飞线时焊锡连锡了CS 一直被拉高导致所有 SPI 操作全部无效。这类问题用万用表测引脚连通性和对地电阻就能揪出来。6.2 问题二初始化成功但 Ping 不通表现VERSIONR 读到 0x04wizchip_setipaddr都执行了但电脑 Ping 不通。如果 SPI 通路正常问题基本在网络参数层逐项核对MAC 地址是否与其他设备冲突。特别是重复烧录时MAC 一模一样又在同一局域网ARP 缓存会乱掉。建议每条开发板用不同的 MAC。IP 地址/网关/子网掩码是否有填错。这是我见过最多的低级错误尤其是把小数的字节序搞反。网线是否真的通了。W5500 的 LINK 灯亮不代表网线质量好交叉线在现在的自动协商网口上一般没事但劣质网线或者线序错乱的线会导致协商失败。ping 的是模块本身还是外部。先用ping 192.168.1.100模块IP确认本地链路通不通再去 ping 网关。如果 ping 网关不通检查网关地址填的跟路由器实际地址是否一致。还有一个很多人忽略的W5500 的 DHCP 如果没做好手动配静态 IP 时子网掩码填错成 255.255.255.255这会让 ARP 广播发不出去表现就是“能收到数据但发不出包”。我排查过一次还挺折腾。6.3 问题三W5500 正常工作几天后断连、Ping 时断时续这是热词里特意提到的“W5500 正常工作几天时间后连不上ping时候断断续续”属于典型的长稳运行问题比一次性不通难查得多。结合我经验主要原因排前面的是这几个代码里 socket 泄漏。如果 TCP 服务端逻辑运行一段时间后某个分支的close()没执行到socket 资源就永久占用了。W5500 只有 8 个 socket泄漏几个就全堵死了。排查方法是周期打印socketStatus看是否一直递增。接收缓冲没及时消费。如果recv()处理速度跟不上数据到达速度W5500 的 RX buffer 被填满芯片会把新到的 TCP 包丢掉对端就会不断重传表现就是 Ping 延迟变大、时通时不通最后彻底断连。TCP keepalive 没配好。中间路由器或交换机把空闲连接清理了但你的程序不知道还在往旧连接上发数据。表现就是几天后突然“失联”。解决办法是应用层做心跳几秒钟一次或者在芯片层合理配置超时重传。硬件层面还有一种可能W5500 模块上的晶振或电源滤波电容老化/虚焊导致 SPI 时钟抖动偶发通信错误。因为不是每次都错所以很难察觉。遇到这类问题关掉 DHCP 租约问题——如果你用了 DHCPW5500 需要自己维护租约续期租约过期后芯片还拿着旧 IP 不放网络就断了。很多人自己的 DHCP 逻辑没写好就会跑几天断一次。6.4 顺带说说 LAN8720 的三个常见问题虽然这篇主角是 W5500但热词里 LAN8720 出现频率很高许多人在这两个方案之间纠结。LAN8720 用 RMII 口ESP32 需要提供 50MHz 的 REF_CLK它最大的坑有三个RMII 的 CRS_DV 引脚接错或者 REF_CLK 没有按要求从指定引脚输入导致 PHY 根本无法工作。PHY 地址冲突LAN8720 的 PHYAD0 引脚如果悬空或接不对实际地址可能不是默认的 0。50MHz 时钟走杜邦线太不稳定从 ESP32 输出到 PHY 的时钟随便用飞线连可能信号质量不够导致丢包、协商失败。这也是我建议新手用 W5500 的原因——SPI 只有 4 根线接线简单25MHz 靠模块自己晶振不依赖 ESP32 时钟。7. 从能跑通到工程可用DHCP、断线重连与整体状态机直接抄还需要把 W5500 用在小项目里有一些设计套路可以顺手带走。7.1 静态 IP 还是 DHCP测试阶段用静态 IP 最快但产品上线千万别写死 IP。W5500 官方库在Internet目录下提供了 DHCP 客户端用法是注册回调然后周期性调用DHCP_run()首次调用后DHCP_run()返回DHCP_RUNNING说明还在申请。等到返回DHCP_IP_LEASED说明拿到了 IP库会自动把 IP 写进 W5500。之后还要周期性调用DHCP_run()来维护租约续期间隔时间可以按文档建议 40 秒左右。忘记在 main loop 里持续调用DHCP_run()是很多 DHCP 版本“跑几天断网”的元凶。租约过期没人续芯片手里的 IP 就是无效的。7.2 断线重连不要盲目 close 再 socket断线重连的逻辑最忌讳的是反复创建 socket 而不关闭旧的或者关闭后立刻重新创建而没等系统处理完。我的经验是给每个 socket 配一个超时变量连接建立后每次收到数据就刷新超时时间如果超时时间内没收到任何数据就主动close()等一段时间再用同一个 socket 号重新走socket - connect流程。整个流程用一个简单状态机控制事件驱动不要用delay()阻塞。7.3 多任务环境下要保护 SPI 总线如果用了 FreeRTOS 跑多个任务而其中一个任务用到了 SPI比如另一个任务在读传感器两个任务同时调SPI.transfer就会互相踩踏。Arduino 环境可以简单用portMUX_TYPE加临界区或者把所有 SPI 操作收敛到同一个任务里其他任务通过队列发请求。代码上给wizchip_select到wizchip_deselect这段加互斥锁即可。这也是ioLibrary_Driver里留了reg_wizchip_cris_cbfunc回调的原因把临界区保护函数注册进去库内部访问芯片时就会自动加锁。我在实际项目里遇到过这样的问题采集任务和通信任务分属两个任务一开始 W5500 偶尔收发异常后来把 SPI 操作全部加锁后问题就消失了。这种偶发故障最难排查所以一开始就把锁加上比事后靠运气强得多。7.4 给产品做上报业务时协议比功能重要最后讲一个经验。W5500 本身只负责传输业务层怎么设计是你自己的事。我做数据上报项目时会固定用这样一套结构应用层发送数据包带递增序号和时间戳。服务端收到后回复 ACK序号对上才算发送成功。发送失败或超时数据进入重发队列最多重试 3 次。重发超过 3 次主动断开 TCP重新连接而不是在坏连接上死磕。这个设计能避开 W5500 缓冲被填满、TCP 长时间假死、对端连接被回收等一系列问题。我自己折腾 W5500 从点不亮到量产前后踩过不少坑。最值钱的教训其实就三条第一SPI 模式下先用读版本寄存器确认物理层通没通再去看网络层第二Socket 用完了必须 close这个无底洞会慢慢吃掉所有连接资源第三稳定运行问题优先怀疑缓冲和 TCP 生命周期而不是上来就换芯片。按这个思路走W5500 ESP32 有线以太网这块基本不会再卡人了。