☰
STM32+W5500移植FreeModbus实现Modbus TCP从机实战
2026/10/3 3:32:59 网站建设 项目流程

去年做一台现场采集设备,上位机那边非Modbus TCP不可,说SCADA系统只认这个协议。手里硬件是现成的STM32F407加W5500模组,串口版的FreeModbus RTU之前倒是折腾过,TCP版一直没动。后来趁着一天晚上集中把W5500的驱动和FreeModbus的TCP移植层接上,第二天测试直接就通了,速度比我预想快得多。这篇文章就把整个移植过程完整拆一遍,从方案选型、框架设计、数据帧理解到代码实现和问题排查,全部围绕STM32、W5500、FreeModbus、TCP这四个关键词展开,适合那些做设备联网、想把Modbus TCP从机接口快速落到项目里的嵌入式工程师。

1. 方案选型:为什么是STM32+W5500+FreeModbus这个组合

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硬件MAC+PHY+LwIP较高,需管理协议栈和内存中高,网络知识要求高复杂协议、多连接、高吞吐
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等)+ 业务逻辑 协议层:FreeModbus(mb.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是单元ID,03是功能码(读保持寄存器),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都支持,我用的是模式0,CPOL和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也需要一个基础tick
w5500_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_WAIT,TCP四次挥手走到一半,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请求并显示返回值。

我建议测试流程按下面顺序走:

  1. 在PC上用Modbus Poll新建一个TCP连接,填设备IP和502端口;
  2. 选择功能码03(读保持寄存器),地址填0,数量填10,点击连接;
  3. 如果能读到数据,再测试06功能码写单个寄存器;
  4. 再测试16功能码写多个寄存器;
  5. 最后用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参数。

还有一个隐蔽问题:如果上一轮连接异常关闭后没有重新listen,Socket会停留在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跑Modbus,Socket1监听504跑自定义协议,互不干扰。这在一些带额外诊断协议的设备里很实用。

第三,如果非要支持同一端口多客户端,W5500的方案确实有点绕。受硬件限制,一个Socket只能建立一个TCP连接,而Modbus标准端口502也不允许用多个Socket同时监听。工业现场如果确实有多个上位机同时连接需求,建议换用带MAC+PHY的MCU跑LwIP,或者前面加一个协议转换网关。但在大多数“一台设备配一套软件”的场景里,单客户端完全够用。

第四,要知道W5500的收发缓冲区是可以按需配置的。每个Socket有独立的RX/TX buffer大小寄存器,默认是2KB+2KB。Modbus报文一般不到256字节,可以缩小RX buffer,把空间让给其他Socket做别的事情。合理的Buffer配置能提升多Socket场景下的并发能力。

最后再分享一个我个人的习惯:移植这种网络协议,第一次一定要先跑通最小系统,再加扩展。先把一个Socket一个寄存器数组跑起来,让Modbus Poll能读到数据,后面再慢慢加多端口、断线重连、日志上报这些细节。我见过太多人一开始就想把所有功能一把梭,结果定位问题的时候,分不清到底是哪个环节出了错。先把链路打通,后面每一步都有迹可循,这才是高速交付的正道。

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

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

立即咨询