前阵子接了一个改造项目,现场的采集设备是STM32F103做的仪表板,原本只跟组态软件跑Modbus TCP通信,数据一直很稳。结果客户中途提了个需求:现场要加一块触摸屏做本地监控,工程师还想用电脑临时连上去查看内部寄存器。我一开始没当回事,觉得从站协议已经通了,多个客户端连进来无非是多开几个连接的事。结果实测直接翻车——触摸屏一接进来,组态软件这边开始疯狂掉线,ping设备也是断断续续,典型的连接资源被抢占、socket没有合理回收的表现。
这个项目让我把“STM32 + W5500 + Modbus TCP多主站”这套方案从硬件到应用层完整重做了一遍。这篇就把整个过程拆开讲清楚:为什么选W5500、硬件上哪些坑不能踩、Modbus TCP报文怎么处理、多主站并发到底在并什么、以及我排查“W5500运行几天后连不上”这个经典故障的完整思路。代码基于STM32标准库和WIZnet官方驱动写,注释里有完整逻辑,照着移植到其他M3/M4内核芯片也没问题。
1. 为什么是“STM32 + W5500 + Modbus TCP”:这套组合的选型逻辑
1.1 多主站场景的真正难点在哪
很多刚接触Modbus TCP的人会有一个误解:从站只要把协议栈跑通,谁连上来都能正常读写。单主站场景下确实如此,但一旦涉及多主站,难点就不在协议本身了,而在于连接管理。
Modbus TCP基于TCP/IP,TCP允许一个服务器同时接受多个客户端连接。W5500提供了8个独立的socket硬件通道,听上去8个连接绰绰有余。但W5500只是把TCP/IP协议栈固化了,连接是谁建立的、什么时候断开、断开了之后socket资源怎么回收、多个客户端同时读写寄存器时数据怎么保证一致,这些事情全得在MCU固件里处理。任何一个环节没做好,就会出现我开头说的现象:连接一多就掉线,过几天彻底连不上。
多主站系统本质上要求从站具备三个能力:能同时维护多条TCP连接、能区分每个请求来自哪个主站、能保证多个主站并发读写时寄存器数据的完整性。这三点对应到工程实现,就是socket资源管理、请求上下文识别、寄存器访问互斥。这篇文章的核心就是围绕这三个能力展开。
1.2 全硬件协议栈的优势和对比
W5500是WIZnet的以太网控制器,内部用硬件逻辑实现了TCP/IP协议栈。MCU只需要通过SPI接口读写寄存器,TCP的三次握手、分包重传、ARP应答、ICMP应答这些事全部由芯片硬件完成。
对比另外两类常见方案:一类是ENC28J60这类只有MAC+PHY的芯片,TCP/IP协议栈必须在MCU上跑软件栈,比如uIP、lwIP。另一类是STM32内部以太网MAC加外部PHY,比如LAN8720,同样需要跑lwIP这类协议栈。
从实际工程角度看,三个方案的对比如下:
| 方案 | 协议栈实现 | MCU负载 | 开发难度 | 稳定性风险点 |
|---|---|---|---|---|
| W5500 | 全硬件 | 极低,只做应用层 | 低,寄存器API清晰 | 链路异常恢复需软件配合 |
| ENC28J60 + 软件栈 | MCU软件 | 较高,CPU频繁处理中断 | 高,内存占用大 | 协议栈Bug、内存碎片 |
| STM32 MAC + PHY + lwIP | MCU软件 | 中等,依赖RTOS | 高,配置复杂 | 驱动Bug、内存管理 |
我选W5500的核心原因不是性能,而是确定性和开发效率。STM32F103这种M3内核芯片主频只有72MHz,RAM也只有20KB,跑lwIP虽然可行,但留给应用层的余量很小。再者Modbus TCP的应用层本来就简单,不值得为了一个从站功能引入一个完整的软件协议栈。W5500把协议处理放在硬件里,MCU只处理应用层请求,逻辑清晰,排查问题也容易。
1.3 Modbus TCP比RTU更适合多主站
Modbus RTU走串口,物理上是半双工,485总线上只能一主多从,天然是“一个主站轮流点名”的模式。要实现多主站,必须在应用层做令牌传递这类复杂机制,工程上很不划算。
Modbus TCP走以太网,物理上是全双工,TCP本身支持多条连接同时建立。每个TCP连接对应一个主站,连接之间天然隔离。报文里还带单元标识符(Unit ID)和事务标识符(Transaction ID),从站可以区分请求来自哪个主站、主站也可以区分响应对应哪个请求。
更重要的一点:Modbus TCP的报文格式是MBAP头加PDU,MBAP头里有两个字节的Length字段,PDU里又有功能码和寄存器地址,解析逻辑非常清晰。即使完全不依赖第三方协议栈,自己手写一个Modbus TCP从站处理逻辑也就几百行代码的事。这让整套系统的透明度和可控性都很好。
2. 硬件设计:W5500典型电路与打板注意事项
2.1 最小系统电路要点
W5500本身需要的外围器件不多,但对细节比较挑剔。先列一份我实际验证过的元件清单:
| 模块 | 参数 | 说明 |
|---|---|---|
| 主控 | STM32F103C8T6 | SPI2接口,18MHz时钟 |
| 以太网控制器 | W5500 | SPI从机,最高支持80MHz,实际跑18MHz |
| 晶振 | 25MHz无源晶振,20pF负载电容 | W5500内部PLL依赖此晶振 |
| 网络变压器 | HR911105A(集成RJ45) | 带变压器的一体式RJ45座,布线简单 |
| 去耦电容 | 0.1uF + 10uF组合 | 每个电源引脚都要放0.1uF |
| 复位电路 | 10K上拉 + 0.1uF对地 | 低电平复位,需确保上电时序 |
W5500的SPI片选引脚必须接一个上拉电阻,否则上电瞬间片选脚电平不确定,芯片可能进入异常状态。我在第一版板上漏了这个电阻,有将近三分之一的板子首次上电无法ping通,重新复位后才能工作。
25MHz晶振的负载电容不要太随意,尽量按数据手册推荐值。曾遇到过晶振起振不稳导致W5500偶尔无法建立TCP连接的案例,换了精度更高的晶振后问题消失。这类问题很难排查,因为现象是间歇性的,示波器不是人人都有,所以从一开始就按参考电路设计最省心。
2.2 电源和复位那点事
W5500对电源纹波比较敏感,尤其是PHY部分模拟电路和数字电路共用电源时。我习惯在3.3V入口放一个磁珠,再在芯片电源引脚附近放10uF钽电容并联0.1uF陶瓷电容。磁珠能滤掉高频干扰,钽电容保证瞬态响应。
有一个坑必须提醒:W5500正常工作时的电流比很多人预想的大,全速收发时峰值可达150mA以上。如果3.3V用的是AMS1117这类LDO,输入电压余量不足会导致掉电复位。我实测过AMS1117-3.3输入5V时,叠加W5500瞬态电流后输出跌到3.0V,系统就会随机死机。
复位电路方面,W5500的复位引脚建议由MCU的GPIO控制,而不是只靠RC上电复位。原因是:MCU启动后可以等待W5500完全上电后再拉高复位引脚,避免两者上电时序竞争。如果只做RC复位,遇到电源抖动时两个芯片可能不同步,导致W5500上电后PHY状态异常。
2.3 参考电路与布线经验
W5500官方有一个典型的参考设计图,网上也能搜到“w5500参考电路”的完整原理图。我建议第一版打板严格参照官方设计,不要自作聪明省略网络变压器的共模电感或终端电阻。
差分走线(TXN/TXP/RXN/RXP)要等长且成对贴近走线,尽量从W5500引脚直连到RJ45座,不要打过孔。天线走线理论不复杂,但打过孔就会引入阻抗不连续,恶劣环境下丢包率会明显上升。我吃过这个亏:为了布线方便让差分线跨层,结果在EMC测试中辐射超标,重新改板后才解决。
网络变压器的中心抽头电容,官方资料给的是RC到地的接法,实际上很多一体式RJ45座内部已经处理好了。采购时最好直接选WIZnet官方评估板同款座子,省掉匹配问题。
3. 驱动移植与协议栈搭建:从SPI读到Modbus TCP回包
3.1 SPI底层读写封装
W5500的SPI通信格式是:片选拉低后,先发3个字节描述地址和操作类型,再读或写数据。地址是16位的,高字节在前;第三个字节的最低bit为0表示读,为1表示写。
// 读单个字节 uint8_t wiz_read_byte(uint32_t reg) { uint8_t frame[3] = { (reg & 0xFF00) >> 8, reg & 0x00FF, 0x00 // 最低bit为0,读操作 }; uint8_t val = 0; ETH_CS_LOW(); HAL_SPI_Transmit(&hspi2, frame, 3, 10); HAL_SPI_Receive(&hspi2, &val, 1, 10); ETH_CS_HIGH(); return val; }SPI时钟极性极相位,W5500要求CPOL=0、CPHA=1,也就是SPI模式3。我第一次移植时用了模式0,数据看起来读回来了,但偶发错位,排查了半天才发现是SPI模式配置错了。
为了提高效率,批量读写的场景要用连续读模式。W5500支持地址自增,也就是第三个字节的最bit为0时,后续时钟继续输出数据,地址自动加1。Modbus TCP一次最多读125个寄存器,对应250字节数据,用连续读能减少大量CS切换开销。
3.2 Socket监听与连接状态机
W5500的8个socket中,我固定把socket0用作监听socket,socket1到socket7用作数据socket。监听socket只负责接受新连接,一旦有主站连进来,就把这个连接分配到一个空闲的数据socket上。
// 初始化监听socket setSn_MR(0, Sn_MR_TCP); // 设置为TCP模式 setSn_PORT(0, 502); // Modbus TCP标准端口 setSn_CR(0, Sn_CR_OPEN); // 打开socket while (getSn_SR(0) != SOCK_INIT); // 等待进入INIT状态 setSn_CR(0, Sn_CR_LISTEN); // 进入监听状态 while (getSn_SR(0) != SOCK_LISTEN);每个数据socket在使用前要初始化成TCP模式并Open,但不去Listen,而是等待监听socket把已建立的连接传过来。W5500的socket状态寄存器会给出SOCK_ESTABLISHED、SOCK_CLOSE_WAIT这些状态,主循环里轮询这些状态即可。
3.3 Modbus TCP报文解析与组包
Modbus TCP的请求帧格式很规整:MBAP头7字节加上PDU。MBAP头结构如下:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 事务标识符 | 2 | 主站生成,从站原样返回 |
| 协议标识符 | 2 | Modbus TCP固定为0x0000 |
| 长度 | 2 | 后续所有字节的数量(Unit ID + PDU) |
| 单元标识符 | 1 | 用于区分串行链路从站地址 |
PDU首字节是功能码。常用功能码里,0x03读保持寄存器、0x04读输入寄存器、0x06写单个寄存器、0x16写多个寄存器,这四个在数据采集项目里基本够用。
解析时只做两件事:校验数据长度和按功能码分发。长度校验不能只看TCP载荷长度,因为W5500把整个TCP报文作为一个数据块交给应用层,所以第一步判断这个块是否包含完整MBAP头。如果长度小于7字节,说明是TCP保活包或异常包,直接丢弃。
组响应报文时,MBAP头里的长度字段最容易被忽略,写错会导致主站一直报超时。长度等于Unit ID加PDU的总字节数,不包括MBAP头自己前面那6个字节。
// 假设rx_buf是收到的完整TCP数据 uint16_t trans_id = (rx_buf[0] << 8) | rx_buf[1]; uint16_t proto_id = (rx_buf[2] << 8) | rx_buf[3]; uint16_t length = (rx_buf[4] << 8) | rx_buf[5]; uint8_t unit_id = rx_buf[6]; uint8_t func = rx_buf[7];4. 多主站并发的核心机制:连接管理、资源互斥与异常回收
4.1 多Socket分配与Accept策略
W5500的socket不足8个,但8个socket给同一时间点连接的主站数量设了上限。监听socket占1个,可用数据socket是7个,意味着最多同时服务7个主站。对于绝大多数场景够用,但需要把上限想清楚。
Handle新连接是在主循环里轮询socket状态实现的。具体做法是:先查监听socket的中断标志,如果有SOCK_INT_CONNECT事件,就把这个连接对应的socket号找出来,然后找一个空闲的数据socket,执行Accept操作。分配策略用最简单的方式:优先分配编号最小的空闲socket。
实际使用中,我会做一层连接注册表,记录每个数据socket上绑定的对端IP和端口。这样不光是给调试用,更重要的是能主动踢掉某个占着连接但已经不活跃的主站,防止恶意或异常的客户端耗尽连接资源。
4.2 寄存器访问互斥
多主站同时读写寄存器,本质上是多个TCP连接在并发请求同一片内存数据。如果在处理请求A的过程中,请求B把寄存器值改了,A回给主站的数据就可能不一致。
最稳妥的解决办法是把整个Modbus请求处理过程设计成非抢占式的。主循环轮询到某个socket有数据时,先把整个请求收完,解析完,执行寄存器读写,组好响应,发送完成,再处理下一个socket。整个流程中没有RTOS、没有中断打断,天然不会产生竞态。
但这有个前提:寄存器区域本身不能有别的任务在写。我项目中有一路数据来自ADC采样,采样值由定时器中断更新。这时就存在中断和主循环同时访问寄存器的隐患。我的处理方式是在定时器中断里只更新一个临时缓冲变量,Modbus读请求再从临时缓冲拷贝到响应报文,避免直接在主循环里和中断同时操作同一块内存。
如果后续要升级到RTOS多线程,寄存器互斥就必须引入互斥锁了,但在裸机轮询架构下,串行化处理是最简单可靠的方案。
4.3 超时保活与异常连接回收
W5500把TCP状态机做在硬件里,但异常断开的检测必须靠应用层主动轮询。
最常见的异常场景是主站突然断电或网线断开。主站侧没有发FIN,W5500不会自动关闭连接,socket会一直停留在SOCK_ESTABLISHED状态。如果主站换了个IP重新连接,旧的连接还占着socket,新连接就进不来。
我的处理逻辑是这样的:每个数据socket维护一个最后一次通信的时间戳,主循环每次处理完请求后更新。另外每隔100ms检查一次socket状态,一旦发现SOCK_CLOSE_WAIT(对端已发FIN)就立即执行CLOSE命令。SOCK_ESTABLISHED但超过30秒没有收到任何请求的socket,直接强制CLOSE,并登记到日志里。这样的策略下来,连接资源永远不会被死连接占满。
5. 实测场景:与组态软件/S7-1200/触摸屏多主站同跑的联调记录
5.1 测试环境搭建
为了验证多主站能力,我搭了一个模拟真实现场的测试环境:
- 从站设备:自研STM32F103 + W5500板卡,用Modbus Poll模拟第一台主站
- 第二台主站:组态王跑在PC上,创建Modbus TCP设备
- 第三台主站:威纶通触摸屏,直接建Modbus TCP驱动
- 第四台主站:西门子S7-1200通过TCP通信指令,以PUT/GET方式读取从站数据
四台主站的轮询周期各不相同,Modbus Poll是500ms,组态王是1000ms,触摸屏是300ms,S7-1200是程序里自己写的300ms循环。从站里的寄存器一共规划了128个保持寄存器,其中包含模拟量输出、设备状态、累计值等。
5.2 4主站并发轮询的压力表现
联调刚开始就出现了一个现象:逻辑上四台主站都在正常读写,但Modbus Poll偶尔出现超时。抓包后发现,偶尔会有TCP重传,原因是从站在同一时刻收到多个主站的请求,而我当时的代码是逐个处理,如果某个socket的数据刚好落在W5500接收缓冲区的末尾,处理时间长了一点,超过了另一个主站的超时时间。
这个问题的解法不是把处理速度提上去,而是加大W5500的RX缓冲区分配。W5500每个socket的收发缓冲区大小可以独立配置,我将每个数据socket的RX/RX Buffer都配置成8KB,保证每个主站的连续请求都有足够缓冲空间,即使某一瞬间处理不过来也不会丢包。
调整后的响应时间测试数据如下:
| 主站 | 轮询周期 | 平均响应时间 | 最大响应时间 | 结果 |
|---|---|---|---|---|
| Modbus Poll | 500ms | 2.4ms | 28ms | 稳定 |
| 组态王 | 1000ms | 3.1ms | 35ms | 稳定 |
| 威纶通触摸屏 | 300ms | 2.8ms | 31ms | 稳定 |
| S7-1200 | 300ms | 3.5ms | 42ms | 稳定 |
四台主站同时跑了一个小时,零丢包,零超时。
5.3 连续运行稳定性的量化结果
功能跑通后最担心的就是长稳。我对这套系统做了连续72小时的压力测试,四台主站保持同样的配置,每隔10分钟记录一次从站的socket连接状态、内存使用情况和日志信息。
结果有一个现象值得关注:在持续运行到28小时左右,有一个数据socket进入了SOCK_CLOSE_WAIT状态,但我的异常回收逻辑没有立即清理它。原因是这个socket的通信超时计时被我设置得太长(60秒),S7-1200那边因为程序逻辑异常停止了轮询,但TCP连接没有断开,导致这个socket一直挂着。把超时缩短到30秒后,这种情况能在两个轮询周期内恢复。
72小时测试结束,从站侧统计到的总请求次数超过24万次,期间只有4次TCP重传事件,均发生在网络对端侧,所有主站的读写数据均正确。
6. 经典故障排查:W5500运行几天后连不上、Ping断断续续的完整链路
6.1 故障现象与初步判断
“w5500正常工作几天后连不上,ping时候断断续续”这个关键词被搜烂了,我最初也以为是个例,直到在一个现场项目里连续遇到三块板子出现同样问题,才认真对待。现象几乎一致:设备刚上电时一切正常,连续运行两三天后,上位机开始出现ping超时,重试几次又能通,通几秒又断。断电重启后恢复,过一两天又复发。
这种故障最坑的地方在于不是必现的,如果不是连续运行,很难在日常调试中暴露。排查这类问题不能靠猜,必须按照从物理层到应用层的顺序逐步缩小范围。
6.2 从物理层到协议栈的逐步排查
第一步,先排除物理层。用好的网线直接连接笔记本和故障设备,排除交换机端口问题。观察RJ45座上的Link指示灯,故障期间Link指示灯状态是正常的,说明物理链路没有断开。
第二步,抓包看二层行为。在PC端用Wireshark持续抓包,发现设备对ICMP Echo(Request)的应答时有时无。关键的是,仔细观察ARP包发现一个规律:PC发出ARP Request后,设备有时候会回复,有时候不回复。而Modbus TCP的通信依赖TCP连接,如果ARP老化后设备不回复ARP,TCP连接就会中断,表现为“断断续续”。
第三步,检查W5500的PHY状态寄存器。通过SPI读取PHYSR寄存器,发现Link状态始终是up,说明问题不在PHY模拟链路,而在于W5500的ARP缓存表没有正确处理ARP请求。
6.3 根因分析与加固方案
问题定位到W5500的ARP处理机制上。W5500的ARP表是硬件维护的,容量有限,当网络环境中有大量广播包或ARP请求时,硬件ARP表可能被频繁刷新。更关键的是,部分固件版本在长时间运行后存在ARP表项无法正确更新或老化的问题,导致新发起的ARP请求得不到正确应答。
针对这个问题我做了三层加固:
- 在应用层增加周期性的ARP请求发送。每隔5分钟,从站主动向网关和当前连接的主站发送一个ARP请求,刷新交换机端口和主站的ARP缓存,避免主站侧先把从站IP对应的MAC缓存老化掉。
- 优化socket异常回收逻辑。Sync一下连接状态,对长时间空闲的socket主动断开,避免硬件连接资源被耗尽。
- 增加W5500定期自检。每24小时读取一次VERSIONR寄存器(应为0x04)和PHYSR寄存器,如果发现PHY状态异常或寄存器的值不对,就执行一次硬复位并重新初始化所有socket。
做了这三层加固后,同一批板子在产线上连续运行了三个月,再没有出现过长时间运行后连不上的问题。顺带说明,这个故障和芯片批次有关,早期遇到过一批芯片固件版本有差异,后来统一换新批次后故障率进一步下降。
7. 工程代码结构与后续扩展空间
完整的工程结构按层划分,方便移植和排查问题:
| 层级 | 文件 | 职责 |
|---|---|---|
| 硬件抽象层 | spi_w5500.c / w5500_hw.c | SPI读写、GPIO、复位控制 |
| 协议核心层 | wizchip_conf.c / socket.c | WIZnet官方驱动,封装socket API |
| 应用协议层 | modbus_tcp.c | MBAP解析、PDU处理、功能码分发 |
| 业务逻辑层 | app_main.c | 寄存器表定义、数据采集、连接管理 |
| 主循环层 | main.c | 初始化、轮询调度、异常处理 |
后续扩展可以从这几个方向走:
- 增加Web配置页面,用socket上的HTTP解析实现对IP地址、波特率、寄存器映射表的现场配置,省去每次改参数都要重新烧固件的麻烦。
- 增加数据断点续传功能,如果现场数据需要上传到云平台,W5500的另一个socket可以作为MQTT客户端连接云服务器,实现本地设备和云端的双通道数据同步。
- 如果现场设备超过一台,可以把Modbus TCP从站和Modbus RTU主站结合,让STM32既做TCP从站,又通过串口轮询下挂的一串485仪表,把数据聚合后再统一通过TCP暴露给多主站。
最后说一点个人体会:W5500这套方案的稳定性上限,很大程度上取决于应用层对连接资源的敬畏程度。再好的硬件协议栈,也扛不住业务层的粗放设计。每一次读写请求都做超时控制,每一个异常断开都及时回收,每一条日志都留足够信息,这些看起来不起眼的习惯,才是保证设备在现场长期稳定运行的根本。我踩过的那些坑,希望你能在一个逻辑清晰的设计中直接绕开。