如果把工业现场比作一张复杂的交通网那Modbus设备就是路上跑的一辆辆小车。去年在帮朋友改造一条小产线时我遇到了一个非常典型的需求厂里的温控表、电表、变频器全是串口Modbus RTU从站可现场既要让本地触摸屏读数又要让上位机数据库采集甚至MES系统也想要一份实时数据。按常规思路串口Modbus从站通常只能被一个主站点名多个主站直接挂到一起轻则通信冲突重则底设备直接不响应。于是我干脆用STM32加W5500搭了一个Modbus TCP多主站数据采集系统把底层串口设备“变成”网络侧的一个个Modbus TCP服务器让任意数量的上位机同时来读各读各的互不干扰。这篇文章就把这个项目的完整思路、硬件设计、协议拆解、代码框架和踩坑记录都写出来适合正在做工业数据采集、设备联网或者毕业设计的朋友参考。1. 项目概览与硬件选型思考1.1 这套系统到底解决什么问题先说清楚需求底层设备是一批只有Modbus RTU串行接口的仪表比如温控器、电流表、湿度传感器通信接口是RS485波特率9600或者19200。上层却有两三个“主站”同时要数据一个是本地HMI触摸屏一个是车间监控主机一个是工厂MES系统。如果把这些主站全部直接接到RS485总线上就会遇到一个要命的问题Modbus协议规定总线上同一时刻只能有一个主站发起请求多个主站同时轮询从站收到乱七八糟的请求响应冲突率会非常高最后谁的数据都不准。这个项目要做的就是一个“影子从站”或者说“协议网关”。STM32作为RS485总线上的唯一主站主动去轮询所有底层仪表把读到的数据缓存到自己的内存里。同时STM32通过W5500接入以太网在网口上开一个Modbus TCP服务器监听标准的502端口。上位机发来的Modbus TCP请求不再直接透传到RS485总线而是由STM32直接查缓存用缓存数据构造响应返回给上位机。这样做有三个明显好处底层RS485总线永远只有一个主站不会冲突从站轮询周期稳定。上层有多少个Modbus TCP主站都无所谓大家读到的都是同一份缓存快照数据一致性好。上位机读取响应速度极快因为不涉及串口等待时间通常是几毫秒内返回。这套架构在工业上很常见其实就是“多主站数据采集网关”。本项目的价值在于用最便宜的STM32F103C8T6加一块W5500模块就能实现成本几十块钱还不需要跑复杂操作系统。1.2 为什么选W5500而不是其他以太网方案做以太网接入方案其实很多我把当时考虑过的几种都列一下也说说为什么最后选了W5500。方案一ENC28J60 软件协议栈。ENC28J60芯片便宜SPI接口但芯片本身只做MAC和PHYTCP/IP协议栈得完全靠MCU软件实现。如果只是实现HTTP服务器或者简单UDP还好要实现多连接并发的Modbus TCP服务器工作量大得吓人而且软件协议栈的稳定性是个大坑跑几天就丢包的例子太多了。这个方案不适合小白也不适合追求稳定性的工业项目。方案二STM32 LAN8720 LwIP。LAN8720是工业上很常用的PHY芯片配合STM32的MAC控制器和LwIP协议栈性能和灵活性都很强。但这个方案对开发者的要求高不少你得熟悉以太网驱动、LwIP的内存管理、Netconn或者Socket API出了问题光定位协议栈问题就能耗掉半天。如果项目时间紧不建议上手就搞这套。方案三CH395。国产芯片也内置TCP/IP协议栈SPI接口资料也不缺。但当时手边没有现成模块而且W5500的官方例程和参考设计更多英文数据手册写得也清楚社区问答资料丰富出了问题搜得到答案所以没选它。方案四W5500。最终选择。这颗芯片的亮点是内嵌了完整的硬件TCP/IP协议栈MAC和PHY也都集成在内外部只需要加一个带网络变压器的RJ45座和几颗电容电阻。MCU通过SPI接口读写寄存器就能完成建连、收发、断开这些操作TCP连接状态机由硬件维护MCU几乎不消耗多余资源。8个独立的Socket对Modbus TCP这种小数据量的应用来说绰绰有余。它的缺点是协议栈版本固化在芯片里没办法深度定制但对标准Modbus协议来说完全够用。从实际工程的稳定性来看W5500在工业网关、数据采集器里有大量成熟应用表现稳定资料多新手也容易上手。1.3 “多主站”到底是什么含义这里要区分两种“多主站”场景。第一种是多个串口主站同时带多个串口从站比如一台触摸屏和一台PC同时通过RS485总线采集数据这种场景非常容易出现总线冲突一般要加协议转换或者逻辑锁。第二种就是本项目实现的场景上层多个TCP客户端也就是网络主站同时访问一个Modbus TCP服务器而这个服务器的背后是若干串口从站。第二种场景的关键在于上层主站看到的“服务器”其实是一个虚拟设备它并不直接对应某个底层从站而是代表了“整个数据集合”。我在设计时的核心思路是把底层设备数据统一映射到一个寄存器缓存区网络主站想读什么寄存器我就从缓存区里取什么数据。这样从上位机的视角来看好像有一台大型Modbus设备摆在那里里面的数据应有尽有。这种架构还解决了另一个隐蔽问题不同主站的数据刷新频率可能是不同的。本地触摸屏要求1秒刷新一次MES系统可能10秒才读一次如果每次都去RS485总线上走一圈底总线会忙不过来。集中缓存之后无论谁读、多久读一次底层串口轮询周期都是固定的不会因为主站数量的增加而加重底层设备的负担。2. 硬件搭建与W5500电路要点2.1 W5500核心电路与STM32引脚连接我用的MCU是STM32F103C8T6W5500模块是市面上常见的那种集成带网络变压器的RJ45例如HR911105A一体化模块。这种模块的好处是拿来就能用不用自己画网络变压器电路抗干扰能力也还可以。如果你是自己画板子W5500的数据手册里给了完整的参考电路照着画就行。我的引脚分配如下STM32引脚功能连接到W5500PA4SPI1_NSSCS片选PA5SPI1_SCKSCLKPA6SPI1_MISOMISOPA7SPI1_MOSIMOSIPB0GPIO输出RST复位低有效PB1GPIO输入外部中断INT中断低有效供电方面W5500内核和IO都是3.3V我直接给模块供3.3V。要注意的是W5500内部的PHY对电源纹波比较敏感最好在模块电源引脚附近加一颗10uF和一颗100nF的滤波电容。如果你用的是模块一般模块上已经放好了但如果是自己画板这个不能省。晶振方面W5500需要一颗25MHz晶振模块上一般自带。当时我排查过一个问题就是W5500怎么都不工作最后发现是模块上晶振虚焊重新补焊就正常了。如果你自己画板晶振负载电容要按数据手册选通常是18pF~22pF。SPI通信这部分STM32的SPI1配置为主模式时钟极性CPOL0相位CPHA0这是W5500要求的SPI Mode 0。数据帧格式是8位MSB先行。2.2 W5500的SPI帧格式与寄存器读写W5500的SPI通信帧结构比较特殊分为三个段16位地址段、8位控制段、N字节数据段。地址段用来指定要访问的寄存器地址控制段里包含块选择位BSB[4:0]、读写选择位RWB和数据长度模式OM[1:0]。控制段的低两位OM[1:0]决定后续数据段是1字节、2字节还是4字节还是可变长度。实际写驱动时通常加一个可变长度模式也就是OM[1:0] 0b11然后等待CS拉高结束数据传输。读写函数的核心就一句话CS拉低发送地址高字节发送地址低字节发送控制字节然后读写数据最后CS拉高。我这里给出一个最简单的寄存器读写实现void W5500_Write_Reg(uint16_t reg, uint8_t *dat, uint16_t len) { CS_LOW(); SPI1_WriteByte(reg 8); SPI1_WriteByte(reg 0xFF); SPI1_WriteByte((reg 0x0400) ? 0x04 : 0x00); // BSB 区选择实际操作要按寄存器所在块设置 // 控制字节RWB0 写模式OM11 可变长 SPI1_WriteByte(0x00 | 0x03); for (uint16_t i 0; i len; i) SPI1_WriteByte(dat[i]); CS_HIGH(); } void W5500_Read_Reg(uint16_t reg, uint8_t *dat, uint16_t len) { CS_LOW(); SPI1_WriteByte(reg 8); SPI1_WriteByte(reg 0xFF); SPI1_WriteByte((reg 0x0400) ? 0x04 : 0x00); // RWB1 读模式OM11 SPI1_WriteByte(0x01 | 0x03); for (uint16_t i 0; i len; i) dat[i] SPI1_ReadByte(); CS_HIGH(); }这里可能有人会问控制字节里的BSB怎么确认W5500数据手册里有寄存器分块表比如通用寄存器在0x0000~0x0030Socket 0的发送缓冲在0x0400之后Socket 1在0x0500之后以此类推。我的做法是直接根据寄存器地址的最高两位计算块选择地址的第10位和第9位对应BSB[4:3]的选择。代码里写的(reg 0x0400) ? 0x04 : 0x00只是一个简化示例实际使用建议按照数据手册把寄存器地址宏定义好例如VERSIONR 0x0039、Sn_MR 0x0000 sock * 0x100 0x30这种。2.3 复位与时序注意事项W5500的复位时序经常被忽略但它非常关键。上电后外部引脚RST要保持至少500us的低电平然后拉高再等约10ms~50ms芯片内部PHY才能完成初始化。我习惯写一个W5500_Init()函数先拉低RST延时10ms拉高RST再延时200ms然后再开始读写寄存器。如果复位延时不够读寄存器可能全是0xFF怎么调都不对。另外W5500的INT引脚是中断输出当Socket收到数据或者连接状态变化时它会拉低。如果使用中断方式接收数据可以把PB1配成外部中断下降沿触发。但实际做Modbus TCP时我倾向于用轮询加中断结合的方式因为Modbus TCP的数据交互频率不高中断处理不当反而容易丢失事件。具体后面代码部分再说。3. Modbus TCP协议拆解与报文分析3.1 从Modbus RTU到Modbus TCP到底改了什么做这个项目之前很多人都已经熟悉Modbus RTU帧头是从站地址、功能码、数据、CRC16校验一帧报文有明确的字节顺序和校验机制。Modbus TCP不是简单地把RTU套上TCP而是重新定义了一个报文结构剥掉了地址和CRC改成由TCP/IP自身保证传输可靠性和错误校验同时加了一个MBAP报文头。RTU一个典型读保持寄存器请求帧是01 03 00 00 00 0A C5 CD其中01是从站地址03是功能码00 00是起始寄存器地址00 0A是寄存器数量C5 CD是CRC。Modbus TCP的同一请求变成00 01 00 00 00 06 01 03 00 00 00 0A这里多了MBAP头一共7字节00 01事务处理标识符由主站生成从站在响应时必须原样返回。00 00协议标识符固定0x0000表示Modbus协议。00 06剩余字节数也就是从功能码开始到帧结束的字节数量这里是功能码1字节起始地址2字节数量2字节6。01单元标识符相当于RTU帧里的从站地址。在网关类设备中它用来表示最终要访问的串口从站地址。这样一来Modbus TCP少掉了CRC校验多了一组用于多会话管理的事务标识符和单元标识符。这给多主站并发访问打下了基础。3.2 请求与响应报文结构详解以读保持寄存器功能码0x03为例请求报文格式如下字段长度示例值说明事务标识符2字节0x0001用于匹配请求与响应协议标识符2字节0x00000为Modbus长度2字节0x0006后面数据的字节数单元标识符1字节0x01底层从站地址功能码1字节0x03读保持寄存器起始地址2字节0x0000寄存器起始地址寄存器数量2字节0x000A读10个寄存器正常情况下从站响应帧是00 01 00 00 00 15 01 03 14 00 01 00 02 ...解析一下事务标识符0x0001协议标识符0x0000长度0x001521字节单元标识符1字节功能码1字节字节数1字节数据20字节单元标识符0x01功能码0x03字节数0x1420字节后面就是10个寄存器的数据每个寄存器占2字节。如果请求的寄存器地址越界或者数量超过上限从站不能直接忽略应该返回异常响应。异常响应的功能码是请求功能码的最高位置1也就是0x83然后附带一个异常码。异常码1表示非法功能2表示非法数据地址3表示非法数据值4表示从站设备故障。这在多主站系统中非常重要因为上位机只有收到明确响应才能判断是重试还是报错。3.3 多主站并发必须理解的事务标识符机制这里要重点说一个容易被坑的地方多主站同时访问时每个主站都会用自己的一套事务标识符。主站A发了一个事务ID为0x0001的请求主站B也可能发一个事务ID为0x0001的请求但它们是两个独立的TCP连接互不干扰。我作为网关在设计响应时最重要的原则就是收到请求时原样保留请求里的事务标识符响应时直接回写绝对不要自己重新生成一个新的事务标识符。有些新手写代码时习惯把事务ID当流水号管理结果上位机发出的请求ID是0x0010收到的响应ID是0x0020直接判断不匹配这次通信就“失败”了。此外Modbus TCP标准允许一个服务器同时服务多个客户端连接。标准没有限制最大连接数但实际受硬件资源限制。W5500的每个Socket在启用MULTI模式后最多支持4个连接。对绝大多数小规模多主站场景4个连接已经够了。如果确实需要更多可以把多个Socket都配置为TCP服务器模式监听同一个端口轮询处理。不过这样代码复杂度会上升当时我的现场需求是2个主站所以就用单个Socket的MULTI模式解决。4. 代码框架与核心逻辑实现4.1 工程结构与模块划分代码我没有用任何RTOS就是裸机前后台架构定时器做时间基准主循环处理W5500收发和Modbus业务。整个工程分为几个模块├── User/ │ ├── main.c // 主循环、初始化、任务调度 │ ├── w5500_drv.c // W5500 SPI驱动寄存器读写、Socket收发 │ ├── modbus_tcp.c // Modbus TCP报文解析和响应组装 │ ├── modbus_master.c // RS485串口主站轮询状态机 │ ├── data_cache.c // 寄存器缓存区管理 │ └── app_config.h // 引脚定义、IP配置、轮询表配置模块划分的原则很简单W5500只负责网络收发不过问Modbus业务Modbus TCP只管解析网络侧报文Modbus Master只管通过串口轮询底层设备数据缓存区是两者之间共享的“数据交换中心”。这样划分之后你想把底层设备从温控器换成PLC只需要改轮询表和寄存器映射不用动网络层代码。4.2 SPI底层与W5500初始化SPI1使用标准库配置GPIO改成复用推挽输出。这里只保留关键代码void SPI1_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1 | RCC_APB2Periph_AFIO, ENABLE); gpio.GPIO_Pin GPIO_Pin_4; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); spi.SPI_Direction SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode SPI_Mode_Master; spi.SPI_DataSize SPI_DataSize_8b; spi.SPI_CPOL SPI_CPOL_Low; spi.SPI_CPHA SPI_CPHA_1Edge; spi.SPI_NSS SPI_NSS_Soft; spi.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_4; // 初始18MHz/44.5MHz spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE); }这里说一个经验SPI时钟频率不要一上来就拉到极限。W5500数据手册标称SPI时钟可以到几十MHz但实际布线、模块质量和干扰都会影响稳定性。我调试时先用到4.5MHz跑通之后再提升。如果出现随机数据错误第一件事就是把SPI分频调大往往能解决一半的问题。W5500的初始化流程是复位设置网络参数MAC、IP、子网掩码、网关然后配置各Socket的收发缓冲区大小。我的配置是Socket 0的RX和TX缓冲都设为4KB用于TCP服务器Socket 1的RX和TX缓冲设为2KB用于可能存在的透传功能其他Socket关闭。void W5500_Init(void) { uint8_t mac[6] {0x00, 0x08, 0xDC, 0x12, 0x34, 0x56}; uint8_t ip[4] {192, 168, 1, 200}; uint8_t mask[4] {255, 255, 255, 0}; uint8_t gw[4] {192, 168, 1, 1}; W5500_Reset(); setSHAR(mac); setSIPR(ip); setSUBR(mask); setGAR(gw); setSn_MR(0, Sn_MR_TCP); setSn_PORT(0, 502); // 分配Socket 0收发缓冲 setSn_RXBUF_SIZE(0, 4); setSn_TXBUF_SIZE(0, 4); // 启用多连接模式 uint8_t mr getSn_MR(0); setSn_MR(0, mr | Sn_MR_MULTI); socket(0, Sn_MR_TCP, 502, Sn_MR_MULTI); listen(0); }注意setSn_RXBUF_SIZE的参数单位是KB所以要分配4KB就写4。缓冲区大小设置必须在socket打开之前完成否则无效。4.3 建立TCP服务器并处理多主站连接在裸机环境下用一个状态机处理Socket 0的状态变化。W5500的Socket状态寄存器Sn_SR会经历SOCK_INIT、SOCK_LISTEN、SOCK_ESTABLISHED、SOCK_CLOSE_WAIT等状态。简化做法是阻塞式监听状态变化但实际上我更推荐这种非阻塞状态机void tcp_server_task(void) { uint8_t state getSn_SR(0); uint8_t connNum 0; int32_t len; switch (state) { case SOCK_CLOSED: socket(0, Sn_MR_TCP, 502, Sn_MR_MULTI); listen(0); break; case SOCK_LISTEN: // 等待客户端连接 break; case SOCK_ESTABLISHED: // 检查是否收到数据 len getSn_RX_RSR(0); if (len 0) { uint8_t rxBuf[512]; uint8_t txBuf[512]; int32_t recvLen recv(0, rxBuf, len); if (recvLen 0) { uint16_t respLen process_modbus_tcp_request(rxBuf, recvLen, txBuf); if (respLen 0) { send(0, txBuf, respLen); } } } break; case SOCK_CLOSE_WAIT: disconnect(0); close(0); break; default: break; } }这里的getSn_RX_RSR(0)获取接收缓冲区中已收到的字节数recv函数从W5500的接收缓冲区读出数据。处理完业务后用send返回响应如果连接被对端关闭状态会变成SOCK_CLOSE_WAIT此时要调用disconnect和close关闭socket然后重新listen。如果不处理这个状态时间长了W5500的socket资源会被耗尽新主站就连不上了这个就是很多同学遇到的“跑几天后连不上”的最常见原因。4.4 Modbus TCP请求解析与响应组装process_modbus_tcp_request是核心函数输入是收到的TCP数据输出是响应缓冲区。这个函数必须快速、稳定因为在多主站模式下每个连接随时都可能有请求进来。uint16_t process_modbus_tcp_request(uint8_t *rx, uint16_t len, uint8_t *tx) { if (len 8) return 0; // MBAP 7字节 功能码1字节 uint16_t txid (rx[0] 8) | rx[1]; uint16_t protoId (rx[2] 8) | rx[3]; uint16_t mbLen (rx[4] 8) | rx[5]; uint8_t unitId rx[6]; uint8_t func rx[7]; if (protoId ! 0) return 0; uint8_t *pdu rx 7; uint16_t pduLen len - 7; // 组装MBAP头事务ID原样返回 tx[0] rx[0]; tx[1] rx[1]; tx[2] 0x00; tx[3] 0x00; uint8_t *start tx 6; // 暂时跳过长度字段 start[0] unitId; start[1] func; uint8_t respLen 2; // unit func if (func 0x03) // 读保持寄存器 { if (pduLen 5) return 0; uint16_t startAddr (pdu[1] 8) | pdu[2]; uint16_t regCnt (pdu[3] 8) | pdu[4]; if (regCnt 125 || startAddr regCnt REG_CACHE_SIZE) { return build_error(tx, txid, unitId, func, 0x02); } start[2] regCnt * 2; for (uint16_t i 0; i regCnt; i) { uint16_t val get_cached_reg(startAddr i); start[3 i * 2] val 8; start[4 i * 2] val 0xFF; } respLen 3 regCnt * 2; } else if (func 0x06) // 写单个寄存器 { if (pduLen 5) return 0; uint16_t regAddr (pdu[1] 8) | pdu[2]; uint16_t regVal (pdu[3] 8) | pdu[4]; set_cached_reg(regAddr, regVal); // 同时可以选择透传到串口 start[2] pdu[1]; start[3] pdu[2]; start[4] pdu[3]; start[5] pdu[4]; respLen 6; } else { return build_error(tx, txid, unitId, func, 0x01); // 非法功能 } // 补长度字段 tx[4] (respLen 1) 8; tx[5] (respLen 1) 0xFF; return 6 respLen; }这里要注意几点响应长度字段是“从单元标识符开始到报文结束”的字节数所以是respLen 1加一个单元标识符本身。读寄存器的数量上限是125个这是Modbus协议规定的防止单帧数据过大。寄存器缓存区的定义大小要和底层从站实际寄存器数量匹配不要越界。写寄存器的请求除了更新缓存还应该把写请求通过RS485转发给底层从站。这里我简化成直接更新缓存实际项目里要加一个“待写队列”由modbus_master任务发送到串口收到从站响应后再确认写成功。4.5 多从站轮询调度的状态机实现网络侧的任务准备好了但缓存区的数据从哪来这就需要modbus_master任务定时去轮询底层RS485从站。轮询不能简单写一个while循环依次读一遍就完事因为网络侧任何一个请求都可以随时访问缓存区底层串口的读写又不能被占用太长时间。所以我还是用状态机每次主循环跑一小步保证串口读写不阻塞。我定义了一个轮询表每个成员代表一个底层从站的采集配置typedef struct { uint8_t slave_id; uint16_t start_reg; uint16_t reg_cnt; uint16_t poll_interval_ms; // 轮询间隔 uint8_t retry_cnt; } PollItem; const PollItem poll_table[] { {1, 0, 10, 200, 3}, // 1号温控器读10个寄存器每200ms轮询一次 {2, 0, 6, 300, 3}, // 2号电表读6个寄存器每300ms轮询一次 {3, 0, 4, 500, 3}, // 3号湿度计读4个寄存器每500ms轮询一次 };轮询状态机大致是typedef enum { POLL_IDLE, POLL_SEND_REQ, POLL_WAIT_RESP, POLL_WAIT_INTERVAL } PollState; void modbus_master_task(void) { switch (pollState) { case POLL_IDLE: if (now - lastPollTime poll_table[currentItem].poll_interval_ms) { currentIndex; if (currentIndex ITEM_COUNT) currentIndex 0; build_rtu_request(poll_table[currentIndex]); send_rs485_request(); pollState POLL_SEND_REQ; } break; case POLL_SEND_REQ: pollState POLL_WAIT_RESP; waitTimeout now RTU_TIMEOUT_MS; break; case POLL_WAIT_RESP: if (rs485_rx_done_flag) { parse_rtu_response(); update_reg_cache(currentIndex); rs485_rx_done_flag 0; pollState POLL_WAIT_INTERVAL; } else if (now waitTimeout) { if (retryCount poll_table[currentIndex].retry_cnt) { retryCount; build_rtu_request(poll_table[currentIndex]); // 重发 send_rs485_request(); waitTimeout now RTU_TIMEOUT_MS; } else { retryCount 0; pollState POLL_WAIT_INTERVAL; // 放弃等下一轮 } } break; case POLL_WAIT_INTERVAL: if (now - lastPollTime 20) // 轮询之间留20ms最小间隔保护串口 { pollState POLL_IDLE; } break; } }这里的细节是超时和重试机制。底层设备偶尔会因为电气干扰或接触不良没有响应如果网关一直卡在等待响应所有主站数据都会变成“老数据”。我设定的超时时间一般是50~100ms重试3次超过次数就跳过这个设备继续轮询下一台。宁可一次不刷新也不能阻塞整个采集任务。4.6 寄存器缓存区与共享数据保护在裸机环境下网络任务和串口轮询任务交替运行对缓存区的读写存在竞争。最简单可靠的办法是读缓存和写缓存都在主循环里执行只要不在中断里访问缓存就不用加复杂的锁。我在设计时把UART接收中断只负责把数据放入串口接收缓冲区解析和缓存更新都在主循环完成这样寄存器缓存区天然就是“单线程访问”不会出现数据错乱。缓存区的实现很简单static uint16_t reg_cache[REG_CACHE_SIZE]; void update_cached_reg(uint16_t addr, uint16_t val) { if (addr REG_CACHE_SIZE) reg_cache[addr] val; } uint16_t get_cached_reg(uint16_t addr) { if (addr REG_CACHE_SIZE) return reg_cache[addr]; return 0xFFFF; }要注意的是Modbus TCP请求里主站可能直接读一个连续范围但底层从站的数据可能分散在不同寄存器区。此时可以在更新缓存时把底层设备的寄存器原样映射到缓存地址上位机按统一的地址表访问即可。地址映射表的定义放在data_cache.c里项目后期维护时只需要修改映射关系不用动网络层代码。5. 调试实录与故障排查5.1 主站连不上W5500该怎么办项目调试过程中最常遇到的第一类问题就是网络主站根本连不上W5500。排查步骤我总结成了一套固定流程。第一步先ping。如果ping不通问题基本在硬件链路或者W5500初始化上。检查W5500的供电是否正常3.3V有没有检查25MHz晶振是否起振用示波器量晶振引脚应该有正弦波如果没有大概率晶振虚焊或者匹配电容有问题检查W5500的RST引脚电平是否正确正常工作时RST应该是高电平。如果这些都正常用ST-Link连接STM32单步执行W5500_Init()读一下VERSIONR寄存器正常值应该是0x04。读到0xFF或者0x00说明SPI通信都不通优先检查SPI接线和片选逻辑。如果ping通了但Modbus TCP连接不上问题多半出在Socket配置上。最常见的就是没有调用listen()或者端口号设置不对默认是502某些上位机软件可以改端口但你必须保持一致。还有一种情况是Socket 0已经进入了SOCK_CLOSE_WAIT状态而没有重新关闭新连接进不来。此时可以读Sn_SR寄存器如果一直是0x1CSOCK_CLOSE_WAIT就说明服务端没有正确关闭连接。解决办法就是在状态机里把CLOSE_WAIT分支加上收到关闭请求后先disconnect再close再重新listen。5.2 通信稳定性和“跑几天连不上”问题怎么排查在我做过的几个W5500项目里最让人头疼的就是“正常工作几天后突然连不上ping得通但是断断续续”。这个现象不是个例很多人都遇到过。我自己排查下来原因无非是下面几种。软件层面最常见的是socket资源泄漏。当上位机异常断电或者软件崩溃时TCP连接并不会立即关闭W5500会一直维持这个连接的状态直到超时。如果代码没有处理好断线检测长时间运行后所有连接槽位都被无效连接占满新连接自然进不来。解决办法是在主循环里周期性检查Sn_SR状态对长时间没有数据交互的连接主动断开。也可以在Sn_IR中检查Socket的断开事件收到断开事件就立即close然后重新listen。硬件层面优先检查W5500的电源纹波。W5500内部的PHY对电源敏感如果3.3V纹波过大长时间工作后会出现丢包、ping延迟大、断断续续的现象。我当时用示波器测得某模块的电源纹波有300mV后来在模块电源入口补了一颗100uF钽电容问题就消失了。另外网络变压器的差分线对也要注意如果是自己画的板子差分线要尽量等长并远离其他高速信号线贴片RJ45座要焊牢避免虚焊时好时坏。晶振的频率稳定性也很重要。W5500要求25MHz晶振如果晶振偏差过大或者起振不稳以太网链路会时好时坏ping通但丢包严重。可以尝试更换质量更好的晶振或者选择带内部振荡器的以太网芯片方案但W5500不支持。5.3 Modbus TCP数据不对、读写失败怎么排查通信正常了数据不一定对。这类问题排错我推荐直接用Wireshark或者tcpdump抓包看报文交互是否符合预期。抓包的时候重点看三点事务ID是否回显一致协议ID是否为0长度字段是否正确。如果发现响应帧的长度字段比实际数据长说明代码里长度计算有误回去检查respLen 1这个逻辑。如果上位机报“非法地址”说明请求的寄存器地址超出了缓存区范围回去核对地址映射表。读写失败还有一种情况是单元标识符。如果上位机配置的是Unit ID0而底层从站地址是1网关在组装RTU请求时用了0作为从站地址底层设备理都不理。我的做法是在收到TCP请求时用MBAP里的单元标识符作为RTU请求的从站地址直接去轮询对应设备。如果缓存里已经有数据就用缓存数据直接响应如果缓存中没有可以先返回正常响应但数据是旧值同时立即触发一次针对该单元标识符的强制轮询。这样既保证了响应速度又能让数据最终刷新。5.4 常见问题速查表整理一个排查速查表方便后续对照现象可能原因排查方法Ping不通W5500初始化失败读VERSIONR寄存器检查晶振与SPIPing通TCP连不上Socket没有listen检查Sn_SR状态确认端口TCP连接不稳定电源纹波大、晶振不稳示波器测电源纹波换晶振跑几天后连不上Socket资源泄漏未关闭检查CLOSE_WAIT分支主动断开无效连接数据一直是旧值串口轮询卡死或设备无响应打开串口调试检查RTU状态机超时逻辑上位机报错“非法地址”寄存器地址映射错误核对缓存区大小和地址表Modbus写操作无效写请求未下发到串口检查写透传逻辑确认串口收发正常一个实测过程中的小技巧最后分享一个我在调试里觉得特别有用的方法无论上层有多少个主站先别急着写复杂的业务逻辑第一步先让W5500跑一个最简单的TCP回环测试。也就是上位机发什么数据W5500就原样返回什么数据。这一步通过之后再往上加Modbus帧解析。把链路层和业务层分开测试能省掉大量联合调试的时间。另外Modbus TCP调试时我会在PC上装一个Modbus Poll软件配合Modbus Slave虚拟从站来做对照测试。先用Modbus Slave开一个模拟服务器验证上位机的配置是否正确再切换回自己的网关这样一旦出问题就能快速区分是上位机问题还是网关问题。这套方法陪我排掉了不少莫名其妙的问题希望也能帮你少走弯路。