
去年做一台现场采集设备上位机那边非Modbus TCP不可说SCADA系统只认这个协议。手里硬件是现成的STM32F407加W5500模组串口版的FreeModbus RTU之前倒是折腾过TCP版一直没动。后来趁着一天晚上集中把W5500的驱动和FreeModbus的TCP移植层接上第二天测试直接就通了速度比我预想快得多。这篇文章就把整个移植过程完整拆一遍从方案选型、框架设计、数据帧理解到代码实现和问题排查全部围绕STM32、W5500、FreeModbus、TCP这四个关键词展开适合那些做设备联网、想把Modbus TCP从机接口快速落到项目里的嵌入式工程师。1. 方案选型为什么是STM32W5500FreeModbus这个组合1.1 硬件侧对比W5500凭什么比LwIP方案更省心很多人一提起以太网第一反应是LwIP哪个便宜用哪个。但如果只是做一个Modbus TCP从机我强烈建议算一笔账LwIP虽然免费但它本质是把TCP/IP协议栈搬到MCU上跑需要消耗不少RAM和Flash还要处理内存池、超时重传、ARP老化这些机制。对不熟悉协议栈的朋友来说调试起来相当痛苦随便一个内存越界都可能让协议栈“躺平”。对比之下W5500是纯硬件协议栈方案芯片内部直接实现了TCP/IP协议MCU只通过SPI接口读写寄存器就可以完成TCP连接、收发数据。这意味着不占用MCU的运行时间和内存去维护TCP状态机内部有8个独立Socket硬件自动处理ACK、重传、分片这些繁琐事32KB收发缓存不用自己管理内存池对外就一个SPI从机接口接线非常干净。再对比一下常见的方案方案MCU负担开发难度适用场景STM32硬件MACPHYLwIP较高需管理协议栈和内存中高网络知识要求高复杂协议、多连接、高吞吐ENC28J60高依赖软件协议栈中但性能一般低成本、低速率场景DM9000A高并口引脚占用多高性能要求高的平台W5500极低硬件卸载协议低寄存器操作即可工业设备、Modbus网关、数据采集W5500方案跑Modbus这种低频小包应用实在有些“杀鸡用牛刀”但恰恰是这种性能冗余让系统非常稳定。我实测过在10MHz SPI时钟下Modbus请求响应延迟在1ms以内完全满足绝大多数工业上位机的100ms轮询周期。1.2 FreeModbus的价值别重复造轮子了Modbus协议看起来简单自己从头写一套也就是几百行的事情但写出来的代码在异常处理、功能码覆盖、广播帧处理这些边角上很难做到周详。FreeModbus是业界很成熟的开源Modbus协议栈支持RTU、ASCII和TCP三种模式。它已经帮你处理好了功能码分发、合法地址检查、异常码生成、CRC校验串口模式等一整套逻辑。重点说一下TCP模式和RTU模式的区别这对理解移植很重要RTU模式在串口上要严格处理3.5字符时间的超时帧与帧之间靠时间间隔区分TCP模式没有这个时间问题因为以太网上每个TCP段天然就是完整数据边界只要从Socket读到一个完整TCP数据段就能交给协议栈解析TCP的报文里用MBAP头替代了RTU的CRC包含事务标识符、协议标识符、长度和单元标识符这部分FreeModbus自己也处理。所以整个移植的核心工作量不是去写Modbus解析而是把“W5500收到TCP数据”这个事件正确地通知给FreeModbus的协议栈。这也是这篇文章最想讲清楚的部分。1.3 总体架构三层分离各干各的移植前我习惯先在脑子里面把代码分层想清楚不然代码写到最后就是一团麻。整体结构可以理解为三层应用层寄存器映射回调eMBRegHoldingCB等 业务逻辑 协议层FreeModbusmb.c、mbfunc.c、mbtcp.c 移植层portevent.c W5500 Socket适配代码 硬件层STM32 SPI外设 W5500芯片应用层只需要关心“寄存器数组里存的是什么业务数据”协议层负责把Modbus数据帧翻译成对寄存器回调的调用移植层要解决的是“W5500收到/发送数据后怎么告诉FreeModbus”硬件层就是纯粹的SPI读写。这种分层的好处是以后从一台MCU换到另一台MCU只需要重写移植层和SPI驱动业务层和协议层几乎不用动。我在后续项目里把这套东西变成了一个模板新项目一天就能接好。2. 移植前必须吃透的几个核心细节2.1 FreeModbus的TCP模式到底需要你提供什么拿到FreeModbus源码后先不要急着往工程里拖先看关键函数调用关系。协议栈的eMBInit(MB_TCP, 0x01, 502, 0, MB_PAR_NONE)初始化的是从机参数第三个参数是端口号。初始化完成后协议栈内部会调用到底层一个和TCP连接相关的初始化函数大致做的事情是创建一个监听Socket。协议栈在运行过程中通过一个事件队列和移植层通信。常见事件包括帧接收、帧发送完成等。你需要在移植层实现这些关键点建立一个TCP监听Socket绑定502端口轮询或中断感知客户端连接建立、数据到达、连接断开收到完整数据帧后用vMBPortEventPost把接收事件投递到协议栈队列当协议栈处理完请求生成响应帧后它会触发发送事件由移植层从缓冲区取出响应数据并通过W5500的send函数发出去。这里有个容易误会的点FreeModbus协议栈本身不关心你用的是什么TCP/IP实现它只定义了“建立TCP服务”“接收数据”“发送数据”“事件通知”这几个抽象接口。W5500天然适合这个模式因为Socket状态、接收数据长度都直接映射到寄存器不需要自己维护复杂状态机。2.2 W5500的Socket状态机与Modbus连接生命周期W5500每个Socket都有清晰的寄存器状态对一个TCP Server来说典型的生命周期是SOCK_INIT - SOCK_LISTEN - SOCK_ESTABLISHED - SOCK_CLOSE_WAIT / SOCK_CLOSED对应到Modbus TCP场景系统启动后把Socket配置成TCP Server模式监听本地502端口上位机Modbus Poll等主动连接W5500硬件完成三次握手状态变为SOCK_ESTABLISHED客户端发来Modbus请求接收数据寄存器里能看到接收数据长度响应发送完后如果客户端关闭连接状态可能进入SOCK_CLOSE_WAIT或直接SOCK_CLOSED必须在检测到断开后清理当前Socket并重新进入监听状态否则后面的客户端连不进来。这是W5500移植最核心的循环逻辑。我建议在主循环里用一个函数专门处理这个状态机每次循环都去读Socket状态寄存器根据不同状态做不同动作。2.3 Modbus TCP报文和寄存器模型理解数据帧是调试的关键。Modbus TCP的一块请求帧长这样以读保持寄存器为例事务标识符(2字节) 协议标识符(2字节0x0000) 长度(2字节) 单元标识符(1字节) 功能码(1字节) 起始地址(2字节) 寄存器数量(2字节)比如请求读地址0开始的10个保持寄存器抓包大概是00 01 00 00 00 06 01 03 00 00 00 0A其中的00 06是后面还有6个字节的长度01是单元ID03是功能码读保持寄存器00 00是起始地址00 0A是数量。FreeModbus在底层会把这7字节的MBAP头解析掉然后根据功能码调用对应的回调函数。这也是为什么TCP模式下不需要在应用层关心“CRC校验”和“长度计算”协议栈全部处理完了。寄存器模型上最常见的映射关系保持寄存器Holding Register对应eMBRegHoldingCB支持03读、06写单个、16写多个线圈Coil对应eMBRegCoilsCB支持01读、05写单个、15写多个输入寄存器Input Register对应eMBRegInputCB只读离散输入Discrete Input对应eMBRegDiscreteCB只读。在MCU里通常就是一段静态数组回调函数里按地址索引数组写入或读出数据。2.4 裸机环境下事件轮询和tick的处理串口RTU模式对时间精度非常敏感需要在帧间隔上做精确计时但TCP模式宽松很多因为数据边界由TCP协议本身保证。不过协议栈内部还有一些超时逻辑比如等待发送完成所以最好有一个1ms或更粗的tick来驱动。我用的是最简单的方式SysTick定时1ms中断在中断里置一个标志位主循环检测到标志后更新计数器然后调用eMBPoll()。eMBPoll()是协议栈的主处理函数它负责从事件队列取事件并处理。主循环的大致框架while(1) { vMBTCPPoll(); // 轮询W5500 Socket状态和处理数据 eMBPoll(); // 让FreeModbus处理事件队列 // 其他用户任务 }有RTOS的话可以把eMBPoll()放到一个专用任务里但裸机能跑通说明这个移植思路完全不依赖操作系统门槛其实很低。3. 手把手实操把FreeModbus TCP跑起来3.1 工程准备与文件清单先用STM32CubeMX把基础工程建好开启SPI、一个GPIO做W5500片选、一个GPIO做复位可选再配一个1ms的SysTick定时器。我这里选的是STM32F407但换成F103、F429都一样W5500驱动只依赖SPI和几个GPIO。W5500与STM32的接线STM32引脚W5500引脚说明PA5 SPI_SCKSCLKSPI时钟PA7 SPI_MOSIMOSI主机输出从机输入PA6 SPI_MISOMISO主机输入从机输出PA4 GPIOSCS片选低有效可选EXTIINTn中断通知可选可选GPIORSTn硬件复位可选SPI速率建议先用2MHz调试确认通信没问题后再提到10MHz以上。W5500的SPI模式是模式0和模式3都支持我用的是模式0CPOL和CPHA都为0。片选引脚不能直接用硬件NSS最好用软件GPIO控制因为W5500的高效连续读需要自己控制帧结束信号。FreeModbus源码目录里demo/TCP文件夹下的移植示例是很好的模板。我的实际工程文件组织大致如下文件作用需要改吗mb.c / mb.h协议栈主入口不改mbfunc.c / mbfunccoil.c等功能码处理不改mbtcp.c / mbtcp.hTCP模式协议逻辑不改portevent.c事件队列实现按平台微调portserial.c串口移植RTU用用不上可不编译porttimer.c定时器移植TCP也需要一个基础tickw5500_platform.cW5500驱动自己写modbus_tcp_port.cW5500与FreeModbus适配层自己写3.2 W5500初始化和网络参数配置W5500上电后要做一个完整的复位和基础配置包括关闭所有Socket、设置本机MAC、IP、网关、子网掩码然后检测PHY是否Link上。我这里直接给出一个精简的初始化流程// 软件复位W5500 w5500_write_reg(REG_MR, 0x80); delay_ms(10); // 配置网络参数 w5500_write_reg(REG_GAR, gateway[0]); // 依次写入网关地址 // GAR: 网关地址寄存器 // SUBR: 子网掩码 // SHAR: MAC地址 // SIPR: 本机IP地址 setSIPR(ip); setSHAR(mac); setSUBR(subnet); setGAR(gateway); // 等待PHY链路建立 while(!w5500_get_phy_link_status()) { delay_ms(100); } // 打开Socket0为TCP Server监听502 socket(0, Sn_MR_TCP, 502, Sn_MR_ND);如果你用的官方驱动库WIZCHIP_READ/WIZCHIP_WRITE这些宏会帮你封装寄存器读写直接调setSIPR、setSHAR这些函数即可。我习惯把这类代码统一放到一个w5500_config.c里方便以后换平台复制粘贴。注意一个坑PHY链路检测不能省。如果网线没插好后面所有Socket操作都不会正常调试时会误以为代码有问题实际上是物理链路就没起来。3.3 移植层代码把W5500接进FreeModbus这一节是整个移植的核心。我做一个很轻量的适配思路是用一个全局变量保存当前监听Socket和连接状态然后轮询处理。核心代码不长但每一步都有含义// modbus_tcp_port.c #include mb.h #include port.h #include w5500.h static int8_t g_modbus_sock 0; static int8_t g_sock_status SOCK_CLOSED; // FreeModbus初始化时会调用到这里实际是创建TCP服务 void vMBTCPPortInit(uint16_t usPort) { g_modbus_sock socket(0, Sn_MR_TCP, usPort, Sn_MR_ND); } void vMBTCPPortPoll(void) { if (g_modbus_sock 0) { return; } g_sock_status getSn_SR(g_modbus_sock); switch (g_sock_status) { case SOCK_INIT: listen(g_modbus_sock); break; case SOCK_ESTABLISHED: // 有客户端连入检查是否有数据到达 uint16_t len getSn_RX_RSR(g_modbus_sock); if (len 0) { // 把TCP段内容读到协议栈缓冲区 uint16_t rlen recv(g_modbus_sock, (uint8_t *)ucMBFrame, len); usMBFrameLen rlen; // 投递帧接收事件给协议栈 vMBPortEventPost(EV_FRAME_RECEIVED); } break; case SOCK_CLOSE_WAIT: case SOCK_CLOSED: // 客户端断开清理并重新监听 close(g_modbus_sock); g_modbus_sock socket(0, Sn_MR_TCP, 502, Sn_MR_ND); break; default: break; } } // 协议栈发送响应时会调用到这里 void vMBPortEventLoop(void) { eMBEventType eEvent; if (xMBPortEventGet(eEvent) TRUE) { if (eEvent EV_FRAME_SENT) { // 把协议栈生成的响应帧通过W5500发出 if (g_sock_status SOCK_ESTABLISHED) { send(g_modbus_sock, (uint8_t *)ucMBFrame, usMBFrameLen); } } } }这段代码是核心骨架有几个地方值得多说两句。第一ucMBFrame和usMBFrameLen是FreeModbus协议栈内部使用的全局收发缓冲区我们在移植层直接借用它来读Socket接收数据、发送响应数据这是一种常见但有点“hack”的做法好处是省一次内存拷贝坏处是你得确保协议栈在eMBPoll()处理完之前不会动这块缓冲。实际使用没问题因为事件队列和轮询是串行执行的。第二SOCK_CLOSE_WAIT的处理非常关键。很多W5500移植一旦运行一段时间后新客户端连不上就是因为只处理了SOCK_CLOSED没处理SOCK_CLOSE_WAITTCP四次挥手走到一半Socket悬挂在半关闭状态。必须在检测到SOCK_CLOSE_WAIT时立刻关闭并重新监听。第三这段代码默认只支持一个客户端连接。Modbus TCP本身允许长连接工业上位机一般也是单主站轮询所以单连接在很多场景是够用的。如果确实需要多客户端同时连接W5500的硬件限制会成为一个问题后文会专门讨论。3.4 寄存器回调实例用数组模拟保持寄存器下面的回调代码展示怎么把Modbus请求和业务数据绑在一起。保持寄存器是工业设备最常用的数据对象我用一个100个字的数组模拟// 保持寄存器在内存中的映射 static uint16_t usRegHoldingBuf[100]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, MB_REGISTER_TYPE eMode) { USHORT iRegIndex; USHORT usRegValue; // Modbus地址是从1开始的数组索引从0开始 iRegIndex usAddress - 1; switch (eMode) { case MB_REG_READ: while (usNRegs 0) { usRegValue usRegHoldingBuf[iRegIndex]; // Modbus寄存器是大端高字节在前 *pucRegBuffer (UCHAR)(usRegValue 8); *pucRegBuffer (UCHAR)(usRegValue 0xFF); iRegIndex; usNRegs--; } break; case MB_REG_WRITE: while (usNRegs 0) { usRegValue (USHORT)(*pucRegBuffer 8); usRegValue | (USHORT)(*pucRegBuffer); usRegHoldingBuf[iRegIndex] usRegValue; iRegIndex; usNRegs--; } break; } return MB_ENOERR; }很多新手第一次看MB_REG_READ和MB_REG_WRITE的字节拼装容易晕这里记住一句话Modbus协议规定寄存器数据是大端而STM32本地内存是小端。所以读的时候要把数组里的uint16_t拆成高字节先发写的时候要把先收到的高字节拼到高位。如果寄存器回调返回MB_ENOREG协议栈会自动给上位机回复异常帧功能码加上0x80错误码为02非法数据地址。这是调试时判断“寄存器地址有没有超出范围”的快速手段。3.5 测试验证用Modbus Poll和Wireshark双重确认代码烧进去以后先用Modbus Poll这类工具测它能直接发Modbus TCP请求并显示返回值。我建议测试流程按下面顺序走在PC上用Modbus Poll新建一个TCP连接填设备IP和502端口选择功能码03读保持寄存器地址填0数量填10点击连接如果能读到数据再测试06功能码写单个寄存器再测试16功能码写多个寄存器最后用Wireshark抓包确认Modbus TCP帧结构是否正确。如果Modbus Poll读不到数据不要急着怀疑代码先抓包看看请求有没有到W5500。Wireshark过滤规则填tcp.port 502能看到TCP三次握手是否成功、Modbus请求帧是否发出、有没有响应帧。这一步能帮你快速区分问题出在上位机配置、网络链路还是协议栈。4. 实测中掉过的坑与排查手册4.1 现象一ping不通设备这个现象最早出现说明问题出在物理层或SPI通信。我遇到的几种原因SPI片选引脚没初始化好W5500一直处于未选中状态寄存器读写全部失败W5500的PHY没Link上网线接口灯不亮SPI时钟相位/极性配置不对读出来的版本号不对。快速定位方法上电后读W5500版本寄存器VERSIONR正常值应该是0x04。如果读到0xFF说明SPI时序或片选逻辑有问题先别管网络参数。如果版本对但ping不通看一下PHY链路状态寄存器以及网线是不是交叉线/劣质线。4.2 现象二能ping通但502端口连不上这个问题的典型原因是Socket没有真正进入监听状态。最容易犯的错误是初始化时调了socket()但忘了调listen()或者把端口号写错成小端模式。排查思路在程序里读一下Sn_SR寄存器确认当前状态是SOCK_LISTEN用PC的netstat -ano看有没有建立到设备IP:502的连接如果确认Socket没进入监听重点检查初始化顺序和Socket参数。还有一个隐蔽问题如果上一轮连接异常关闭后没有重新listenSocket会停留在SOCK_CLOSED或SOCK_CLOSE_WAIT状态新连接自然进不来。这也是我为什么在主循环里一直看着Socket状态一旦异常就清理重建。4.3 现象三能连接但寄存器读写无响应能连上说明TCP链路通问题多出在D数据传输链路。此时不要凭感觉改代码直接用Wireshark看如果请求帧到了设备但设备没回应说明协议栈可能没处理这个请求重点查寄存器回调是不是返回了MB_ENOREG如果设备回了响应但上位机不认多半是字节序或者帧长填错了如果响应帧明显是协议栈生成的但W5500没发出来重点查EV_FRAME_SENT事件触发逻辑和send()函数。我这里说的“响应帧不认”有一个典型场景FreeModbus生成响应时事务标识符会原样拷贝请求里的值。如果你在发送时自己改了缓冲区的某个字节上位机会因为事务ID不匹配而丢帧。所以移植层最好不要乱动ucMBFrame里的数据发原样缓冲就好。4.4 现象四运行一段时间后新客户端连不上这基本就是Socket悬挂问题。W5500不是操作系统TCP的半关闭状态需要应用主动处理。客户端进程崩溃后W5500会检测到对端关闭并进入SOCK_CLOSE_WAIT如果你只盯着SOCK_CLOSED它可能永远停在那个状态。我建议把所有Socket状态变化都在日志里打出来加一个调试串口。测试时模拟拔网线、关闭上位机、重启上位机这些操作确认每次都能重新回到SOCK_LISTEN状态。4.5 综合排查速查表现象可能原因快速排查方法版本寄存器读到0xFFSPI通信异常、片选没控制好检查SPI接线、GPIO配置、时钟极性PHY链路指示灯不亮网线问题、PHY没初始化读PHY寄存器换网线ping不通设备IP配置错误、ARP没通检查SIPR、SHAR、GAR、SUBR寄存器ping通但502连不上Socket没监听、上次连接悬挂读Sn_SR确认是SOCK_LISTEN能连上但无响应寄存器回调返回异常、协议栈事件没投递抓包看请求读寄存器回调返回值有响应但内容错误字节序不对、地址偏移错抓包比对MBAP和寄存器数据区长时间运行后连不上Socket悬挂在CLOSE_WAIT每次poll检查状态并清理重建这张表我在几个群里分享过基本能覆盖掉90%的常见问题。真遇到特别诡异的就上抓包工具数据不会骗人。5. 再往前走一步从Demo到产品级改造5.1 “5分钟移植”的现实和我的模板化做法标题说5分钟说句实话第一次做肯定不止光理解协议栈和Socket状态就要花不少时间。但当你把下面这些东西固化成模板后下一个项目真的可以很快一份能直接用的modbus_tcp_port.c只暴露几个配置宏W5500驱动文件SPI接口做成弱函数换平台只改底层SPI读写寄存器映射表通过数组大小和起始地址统一管理一个测试上位机配置示例省得每次重新填。我的做法是把配置集中在头文件里// modbus_tcp_config.h #define MODBUS_TCP_PORT 502 #define MODBUS_TCP_SOCKET 0 #define MODBUS_HOLDING_REG_NUM 100 #define MODBUS_INPUT_REG_NUM 50新项目启动时改IP、改寄存器数量、改业务回调函数编译下载就能跑。这也是为什么我常说“第一次移植是学习第二次移植才是效率”。5.2 扩展方向从单机从站到网关/LAN设备这套架构跑通后后续很多扩展都是顺水推舟的事。第一如果你后面要做Modbus TCP到Modbus RTU的网关原理也很简单一个端口保持TCP从站另一个串口跑RTU从站应用层在两者之间搬寄存器数据。FreeModbus本身就支持多实例只是要稍微改造一下实例管理。第二如果要用W5500支持多端口服务可以用多个Socket监听不同端口。例如Socket0监听502跑ModbusSocket1监听504跑自定义协议互不干扰。这在一些带额外诊断协议的设备里很实用。第三如果非要支持同一端口多客户端W5500的方案确实有点绕。受硬件限制一个Socket只能建立一个TCP连接而Modbus标准端口502也不允许用多个Socket同时监听。工业现场如果确实有多个上位机同时连接需求建议换用带MACPHY的MCU跑LwIP或者前面加一个协议转换网关。但在大多数“一台设备配一套软件”的场景里单客户端完全够用。第四要知道W5500的收发缓冲区是可以按需配置的。每个Socket有独立的RX/TX buffer大小寄存器默认是2KB2KB。Modbus报文一般不到256字节可以缩小RX buffer把空间让给其他Socket做别的事情。合理的Buffer配置能提升多Socket场景下的并发能力。最后再分享一个我个人的习惯移植这种网络协议第一次一定要先跑通最小系统再加扩展。先把一个Socket一个寄存器数组跑起来让Modbus Poll能读到数据后面再慢慢加多端口、断线重连、日志上报这些细节。我见过太多人一开始就想把所有功能一把梭结果定位问题的时候分不清到底是哪个环节出了错。先把链路打通后面每一步都有迹可循这才是高速交付的正道。