☰
STM32F103移植FreeModbus TCP:W5500硬件协议栈实战
2026/10/3 3:33:24 网站建设 项目流程

搞嵌入式这几年,有一个很深的体会:Modbus TCP这套东西,你只要做过一次,后面就是抄作业。但第一次从RTU挪到TCP时,确实踩了不少坑,尤其当MCU资源紧张到连lwIP都跑不利索的时候,W5500这种硬核协议栈芯片几乎是唯一解。这篇文章就记一次在STM32F103上移植FreeModbus TCP的完整过程,核心思路一句话概括——用W5500把TCP/IP全部干完,MCU只处理Modbus应用层,20KB RAM的单片机也能轻松跑。整个过程我会把选型原因、源码接法、裁剪参数、W5500回调实现、实测现象和全军覆没的坑全部交代清楚。

1. 为什么是W5500+FreeModbus:这套组合到底解决了什么问题

1.1 W5500在STM32项目里到底扮演什么角色

先看硬件方案。STM32做以太网有两种路线:一种是片内带MAC的型号(比如F207、F407、H743),外接PHY芯片,再配合lwIP软件协议栈;另一种就是用W5500这种集成MAC+PHY+TCP/IP协议栈的全硬件芯片,MCU这边只需要一根SPI总线,剩下的事W5500全包了。

差异非常明显。拿STM32F103C8T6来说,它没有以太网MAC,你想跑lwIP就得换芯片。就算换成F407,lwIP在工业设备上稳定跑起来,RAM占用通常在30KB往上,Flash也得吃掉不少,代码量上去了,排查问题的面也广了。而W5500方案里,TCP/IP协议栈是芯片内部硬件状态机实现的,TCP三次握手、数据重传、滑动窗口这些全部不占用MCU资源。MCU只做SPI读写和Modbus帧解析,这活C8T6的72MHz主频+20KB RAM绰绰有余。

从成本上讲,W5500模块散买也就十几块钱,比换一颗带MAC的MCU便宜,开发周期更是短一个量级。你做项目多了就明白,有时候不是功能做不出来,是时间不允许你慢慢调协议栈。W5500就是典型的"用硬件换时间"方案。

1.2 从RTU到TCP:FreeModbus的两种移植思路

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模块淘宝焊好的排针模块,带RJ45
USB转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 0(CPOL=0, CPHA=0),但很多模块的参考设计其实两种都兼容。建议生成工程后先用一个简单的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.c:CRC16(RTU模式下用到,TCP模式可以不用)
  • portevent.c:事件处理移植层
  • porttcp.c:TCP协议栈对接层(重点改这个)
  • port.c:协议栈初始化相关移植

搭建工程时,把上述源文件加进Keil,modbus/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 TCP,IP填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开始的偏移,真正发送到总线上的协议地址是usAddress+1。这一层是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端口通,真的只需要一顿饭的功夫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询