
搞嵌入式这几年有一个很深的体会Modbus TCP这套东西你只要做过一次后面就是抄作业。但第一次从RTU挪到TCP时确实踩了不少坑尤其当MCU资源紧张到连lwIP都跑不利索的时候W5500这种硬核协议栈芯片几乎是唯一解。这篇文章就记一次在STM32F103上移植FreeModbus TCP的完整过程核心思路一句话概括——用W5500把TCP/IP全部干完MCU只处理Modbus应用层20KB RAM的单片机也能轻松跑。整个过程我会把选型原因、源码接法、裁剪参数、W5500回调实现、实测现象和全军覆没的坑全部交代清楚。1. 为什么是W5500FreeModbus这套组合到底解决了什么问题1.1 W5500在STM32项目里到底扮演什么角色先看硬件方案。STM32做以太网有两种路线一种是片内带MAC的型号比如F207、F407、H743外接PHY芯片再配合lwIP软件协议栈另一种就是用W5500这种集成MACPHYTCP/IP协议栈的全硬件芯片MCU这边只需要一根SPI总线剩下的事W5500全包了。差异非常明显。拿STM32F103C8T6来说它没有以太网MAC你想跑lwIP就得换芯片。就算换成F407lwIP在工业设备上稳定跑起来RAM占用通常在30KB往上Flash也得吃掉不少代码量上去了排查问题的面也广了。而W5500方案里TCP/IP协议栈是芯片内部硬件状态机实现的TCP三次握手、数据重传、滑动窗口这些全部不占用MCU资源。MCU只做SPI读写和Modbus帧解析这活C8T6的72MHz主频20KB RAM绰绰有余。从成本上讲W5500模块散买也就十几块钱比换一颗带MAC的MCU便宜开发周期更是短一个量级。你做项目多了就明白有时候不是功能做不出来是时间不允许你慢慢调协议栈。W5500就是典型的用硬件换时间方案。1.2 从RTU到TCPFreeModbus的两种移植思路FreeModbus是一个开源的Modbus协议栈支持RTU、ASCII、TCP三种模式。很多玩单片机的人第一次接触它是在串口屏或RS485设备上跑Modbus RTU只需要两个文件一个portserial.c管收发一个porttimer.c管3.5字符超时定时再加上eMBInit(MB_RTU,...)、eMBEnable()、eMBPoll()这老几样就能跑起来。但到了TCP模式你会发现套路完全变了。RTU依赖串口和定时器去判断一帧数据的结束TCP则自带帧边界——TCP是流协议一帧Modbus TCP报文以MBAP报文头里的长度字段来界定根本不需要定时器。所以FreeModbus的TCP版移植工作集中在你用什么方式把TCP数据交给协议栈这个问题上。官方demo里提供了基于lwIP的移植层porttcp.c如果你项目里没跑lwIP就得把这个移植层替换成W5500的socket API实现。这正是很多人的第一个坎搜到资料都是lwIP版的自己用的是W5500不知道回调函数怎么填。这篇文章主要就把这一块讲透。移植5分钟的前提也就是你有一个正确的Framebuffer我说的不是从零开发而是把源码路径理顺、回调填对剩下的交给协议栈。2. 移植前必须备齐的软硬件环境2.1 硬件清单与连接表我这次用的核心硬件如下都是常见物料器件型号/规格说明MCUSTM32F103C8T620KB RAM蓝板即可以太网芯片W5500模块淘宝焊好的排针模块带RJ45USB转TTLCH340调试日志输出调试工具Modbus Poll上位机模拟Modbus主站W5500与STM32的连接走SPI1引脚分配如下W5500引脚STM32引脚说明CSPA4SPI片选软件控制SCKPA5SPI时钟MISOPA6SPI主入从出MOSIPA7SPI主出从入RSTPA3硬件复位低电平有效INTPA2中断引脚可选需要提一句的是W5500模块的供电。很多模块上自带3.3V稳压但如果你用的是那种不带稳压的裸片转接板务必确保给W5500的3.3V是干净的电源网口变压器的隔离地也要处理好。之前见过一个设备频繁网口掉线最后查出来是模块供电纹波太大加点去耦电容就好了。2.2 开发环境里最容易卡住的两个点开发环境我用的是Keil MDK 5配合STM32CubeMX生成HAL库初始化代码。CubeMX生成工程时SPI1的速率要注意W5500最高SPI时钟频率可以到几十MHz但STM32F103的SPI1挂在APB2总线上实际跑12分频或18分频是稳的再高就要看布线。我在demo里直接用了2分频打头的保守设置实测吞吐完全够用。第二个容易卡住的是CubeMX里的SPI模式选择W5500要求SPI Mode 0或Mode 3官方手册建议Mode 0CPOL0, CPHA0但很多模块的参考设计其实两种都兼容。建议生成工程后先用一个简单的SPI回环读写W5500的版本寄存器VERSIONR地址0x0039能读到0x04就说明时序对了。这一步放在移植FreeModbus之前做能省下后面至少2小时的排查时间。2.3 FreeModbus源码怎么拿、目录结构怎么摆FreeModbus现在托管在GitHub上搜索freemodbus就能找到官方仓库我用的版本是1.6。仓库里核心协议栈部分在modbus目录下包括mb.c协议栈主状态机mbproto.c功能码解析mbfunccoils.c / mbfuncdisc.c / mbfuncholding.c / mbfuncinput.c四种寄存器读写实现mbcrc.cCRC16RTU模式下用到TCP模式可以不用portevent.c事件处理移植层porttcp.cTCP协议栈对接层重点改这个port.c协议栈初始化相关移植搭建工程时把上述源文件加进Keilmodbus/include路径加进去即可。为了少走弯路我通常把demo目录下TCP版官方有demo/TCP里的porttcp.c直接拷进工程再改而不是从零写。3. 核心移植步骤从拷贝源码到502端口能通3.1 把FreeModbus源码接入工程先打开Keil工程在项目树里新建一个Modbus组把第2.3节列出的C文件全部添加进去。添加完先编译一次此时大概率报错集中在portevent.c和porttcp.c里提示缺头文件或者函数未定义这是因为移植层的函数实现还停留在官方demo。别慌这正是我们要改的地方。随后在工程里添加一个Modbus_Port.c文件用来放W5500相关初始化和TCP回调对接的代码。我习惯把所有移植代码集中在这个文件里方便后续换平台时整体搬走。3.2 mbconfig.h裁剪TCP模式必须开的宏mbconfig.h是FreeModbus的配置总开关涉及本次TCP模式移植的关键项如下/* 协议栈工作模式TCP、RTU、ASCII 三选一本次只开TCP */ #define MB_TCP_ENABLED 1 #define MB_RTU_ENABLED 0 #define MB_ASCII_ENABLED 0 /* TCP监听端口Modbus标准为502 */ #define MB_TCP_PORT_NUM 502 /* 最大同时连接数W5500最多8个socket一般开2足够 */ #define MB_TCP_NUM_SOCKETS 1 /* 从机地址TCP模式对应MBAP头里的Unit ID */ #define MB_SLAVE_ADDRESS 1有个细节容易忽略官方默认的mbconfig.h里MB_RTU_ENABLED是1如果你在板子上同时启用了串口打印RTU模块会带来额外的串口和定时器移植需求。既然走纯TCP干脆把所有非TCP模式关掉编译器也能裁掉不少无用代码。3.3 实现W5500版TCP回调FreeModbus怎么和socket对接FreeModbus的TCP移植层抽象出了四个回调事件连接建立、连接断开、收到数据、需要发送。官方demo里你看到的是lwIP版本的实现我们要做的是把里面的数据收发函数替换成W5500的socket API。先初始化W5500并把它配置成TCP Server#include wizchip_conf.h #include socket.h uint8_t g_RemoteIP[4]; /* 记录客户端IP用于调试 */ uint16_t g_RemotePort; void W5500_Config(void) { uint8_t mac[6] {0x00, 0x08, 0xDC, 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}; /* SPI1初始化、复位W5500拉低RST再拉高 */ W5500_Init(); reg_wizchip_cs_cbfunc(W5500_CS_Enable, W5500_CS_Disable); reg_wizchip_spi_cbfunc(W5500_SPI_ReadByte, W5500_SPI_WriteByte); reg_wizchip_cris_cbfunc(W5500_CRIS_Enter, W5500_CRIS_Exit); /* 写入MAC、IP、网关、掩码 */ setSHAR(mac); setSIPR(ip); setGAR(gw); setSUBR(mask); /* 启动TCP Server监听502端口 */ socket(0, Sn_MR_TCP, 502, 0x00); listen(0); }然后在主循环里轮询socket状态把FreeModbus需要的事件回调出去static void W5500_Poll(void) { uint16_t len; uint8_t buf[512]; switch(getSn_SR(0)) { case SOCK_ESTABLISHED: if (g_IsConnected 0) { g_IsConnected 1; getSn_DIPR(0, g_RemoteIP); g_RemotePort getSn_DPORT(0); /* 通知FreeModbus有客户端连接 */ eMBTCPCallBack(MB_TCP_EVENT_CONNECT, 0); } /* 有数据到来 */ len getSn_RX_RSR(0); if (len 0) { len recv(0, buf, 512); if (len 0) { /* 拷贝到协议栈缓冲区并通知接收 */ memcpy(ucTCPBuf, buf, len); eMBTCPCallBack(MB_TCP_EVENT_RECEIVE, len); } } break; case SOCK_CLOSE_WAIT: case SOCK_CLOSED: case SOCK_FIN_WAIT: if (g_IsConnected) { g_IsConnected 0; eMBTCPCallBack(MB_TCP_EVENT_DISCONNECT, 0); /* 释放socket并重新监听关键 */ close(0); socket(0, Sn_MR_TCP, 502, 0x00); listen(0); } break; default: break; } }eMBTCPCallBack内部的具体处理可以参考官方porttcp.c里的事件处理框架核心是把接收到的数据交给eMBPoll去解析把待发送的数据通过send接口发出去。这里贴一个我改写的发送接口static void vMBTCPSend(uint8_t *pucData, uint16_t usLen) { send(0, pucData, usLen); }这个回调体系是整篇移植的精华它把W5500 socket收数据和FreeModbus协议栈处理请求两条线对接起来。你不需要关心TCP分包和粘包W5500的socket API拿到的是已经去掉MAC帧头和IP头之后的TCP载荷Modbus TCP解析刚好只需要这一层。3.4 绑定寄存器数组并启动协议栈接下来就是常规操作定义保持寄存器和输入寄存器数组并实现四个回调函数。以保持寄存器为例uint16_t usRegHoldingBuf[100]; /* 保持寄存器地址从0x0000开始 */ eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus MB_ENOERR; int16_t iRegIndex (int16_t)usAddress; if ((iRegIndex 0) (iRegIndex usNRegs 100)) { if (eMode MB_REG_WRITE) { while (usNRegs 0) { usRegHoldingBuf[iRegIndex] (uint16_t)(pucRegBuffer[0] 8) | pucRegBuffer[1]; pucRegBuffer 2; iRegIndex; usNRegs--; } } else { while (usNRegs 0) { pucRegBuffer[0] (uint8_t)(usRegHoldingBuf[iRegIndex] 8); pucRegBuffer[1] (uint8_t)(usRegHoldingBuf[iRegIndex] 0xFF); pucRegBuffer 2; iRegIndex; usNRegs--; } } } else { eStatus MB_ENOREG; } return eStatus; }主函数里初始化int main(void) { SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); W5500_Config(); eMBInit(MB_TCP, 1, 0, 0); /* 第二个参数1就是从站地址 */ eMBEnable(); while (1) { eMBPoll(); W5500_Poll(); } }到这里502端口应该已经能被扫描到了。4. 实测Modbus Poll连接502后的现象与验证4.1 第一次连上的检查顺序打开Modbus Poll按F8设置连接参数协议选Modbus TCPIP填W5500配的192.168.1.100端口502从站地址填1。点击连接按钮后如果左下角没有跳红叉说明TCP链路已经通了。第一次跑通时的经验是先别急着读寄存器先用Modbus Poll自带的读保持寄存器功能读取地址0长度10。如果返回了数据那么整个链路基本没戏了。我把测试过程中的几个关键现象列成了一张表方便你对照现象原因处理连接不到502端口W5500未进入监听态 / 防火墙拦截确认socket()和listen()已执行Windows关闭防火墙或添加例外能连上读寄存器超时eMBPoll没有执行 / 回调没触发确认主循环中有eMBPoll()收发函数有日志输出能刷新一次后面卡死socket状态机异常在主循环里定时检查Sn_SR异常时close重新listen读取时地址越界寄存器回调里usAddress判断错误打印usAddress核对Modbus Poll的地址映射4.2 寄存器读写实测我用Modbus Poll对地址0的保持寄存器写入了一个数再读取回来验证。写入值是0xABCD读回来的结果就是0xABCD说明eMBRegHoldingCB的大小端转换和FreeModbus内部处理的链路是正确的。这套TCP方案跑了一整天期间用Modbus Poll的循环请求从5ms到1s间隔都测过响应稳定。补充一个关于TCP三次握手的观察W5500作为硬件协议栈TCP握手过程MCU完全无感知。你在调试时打开抓包工具能看到W5500自动回SYN-ACK不需要MCU参与。这在lwIP时代是需要你在accept回调里处理的W5500把这层细节抹平了。5. 移植全程最容易踩的坑5.1 连不上502从网线到socket的完整排查链路如果你按第4章的步骤走还是连不上我提供一个排查链路按顺序走完基本能解决问题第一步看网口指示灯。W5500模块的RJ45上有Link/ACT指示灯不亮说明网线和交换机问题这步最基础但最容易忽略。第二步ping一下W5500的IP。能ping通说明IP、掩码、网关配置没问题TCP层是通的。ping不通则重点检查SPI通信尤其是SPI模式和W5500的版本寄存器是否读到了0x04。第三步用netstat或者TCP调试工具主动连接W5500的502端口看W5500侧有没有进入SOCK_ESTABLISHED状态。如果一直停在SOCK_INIT或SOCK_LISTEN说明listen()之后的accept流程没走对检查中断或轮询逻辑。第四步确认Modbus Poll中从站地址与eMBInit初始化时的地址一致。TCP模式下这个地址对应MBAP里的Unit ID很多第一次接触的人把这里理解成网络端口或者随便填一个导致协议栈收到请求后查找不到对应从站直接丢弃。这个环节我帮别人排查时就遇到过三次全是在这一步卡住的。5.2 读回来的寄存器数据不对大小端和地址映射你按照3.4节代码实现了回调读回来的数据却出现高低字节互换或者地址整体偏移了一个首先要怀疑的是Modbus Poll的地址显示方式。Modbus Poll里的地址显示可以设置从0开始还是从1开始而底层寄存器回调里的usAddress是按0开始的偏移真正发送到总线上的协议地址是usAddress1。这一层是Modbus协议地址从1开始的传统导致的不是bug。大小端问题我之前也踩过在回调里把usRegHoldingBuf的值直接memcpy到pucRegBuffer然后读回来发现数值看起来反了。Modbus协议规定16位寄存器在网络传输时是大端字节序高字节在前所以回调里必须手动拆包封包。3.4节代码里已经处理好了你自己写回调时不要贪图方便用memcpy高字节在前的移位操作是必须的。5.3 通信跑一段时间后设备不响应W5500 socket状态卡死这是用W5500做Modbus TCP最容易遇到线上问题的情况设备刚上电时通信正常跑几小时或客户端异常断开后设备突然不响应了。原因一般是socket卡在CLOSE_WAIT或FIN_WAIT状态没有回收。W5500的socket状态机虽然硬件实现了TCP协议但应用层必须按照状态变化调用close()和重新listen()。我前面的W5500_Poll()里已经写了这部分处理逻辑。还有一个细节是如果客户端设备比如组态软件反复无常地断开重连最好在检测到SOCK_CLOSE_WAIT时主动调用close(0)以发出FIN而不是等待超时。可以加一个定时器每500ms轮询一次socket状态这样能大幅提高恢复速度。5.4 缓冲区大小和并发连接的坑FreeModbus内部有一个处理数据缓冲默认大小在mbconfig.h里可以调整。如果你需要处理复杂的功能码比如读多个寄存器或者一次性读很大的数据块建议把缓冲区加大同时W5500的收发socket buffer通过setsockopt配置也要对应加大。W5500每个socket默认收发buffer是2KB对Modbus来说足够但如果你把MB_TCP_NUM_SOCKETS配置成2以上注意总buffer上限8KB分配不开会导致socket初始化失败。关于并发连接很多刚上手的人会把MB_TCP_NUM_SOCKETS设成很大的值但实际上工业现场Modbus TCP连接数根本不需要很多Modbus Poll一般是单连接几十个上位机同时连也只会在极少数场景出现。多开一个socket意味着多占W5500的RAM buffer和MCU的轮询时间默认保持1到2就够。最后再分享一个小技巧我在移植完成后习惯把W5500_Poll和eMBPoll做成两个独立函数并在主循环里给eMBPoll更高的调用频率。因为Modbus协议栈的响应速度取决于你调用eMBPoll的周期而socket轮询哪怕慢几个毫秒也没有关系。这套小而稳的方案项目落地之后基本上就是忘了它的存在。这篇文章从W5500选型讲到FreeModbus TCP的回调对接再到实测和线上问题的修复路径核心就是让你少走弯路。如果你手头正好是STM32加W5500直接按这个结构跑一遍从零到502端口通真的只需要一顿饭的功夫。