博途自由口手搓ModbusRTU主站:SCL源码与CRC16实现
2026/9/20 6:02:20 网站建设 项目流程

1. 为什么要在博途里用自由口手搓一个ModbusRTU主站

在博途V14 SP1这个版本上做过西门子S7-1200/1500通信的朋友应该都有体会,官方其实提供了MB_MASTERMB_SLAVE这两个库指令,直接拖出来配置一下就能跑ModbusRTU。但实际项目里,尤其是老设备改造、第三方仪表对接、或者PLC本体串口资源被占用的场景下,官方库指令经常会出现一些让人头疼的限制:比如它要求你必须在硬件组态里把串口模块配置成特定的通信模式,一旦你同时还要跑别的自定义协议,两边就会打架;再比如某些国产仪表对ModbusRTU的帧间隔、字节超时要求比较刁钻,官方库的黑盒处理方式让你根本没法插手干预。

所以很多老工程师会选择一条更"原始"但更可控的路子——用自由口通信(Freeport)自己实现ModbusRTU的Master指令。所谓自由口,就是把串口模块的通信协议完全交给用户程序来管,发送什么字节、什么时候发、怎么判断帧结束,全部由你的SCL代码说了算。这样做的好处是灵活度拉满,坏处是所有细节都得自己扛,包括CRC校验、3.5字符间隔、超时重传、异常码解析这些。

这篇内容就是围绕"用自由口通信制作的ModbusRTU协议Master指令的SCL源码"这个主题展开的。我会把整个实现思路、关键代码结构、踩过的坑、以及为什么这么设计的原因都摊开讲清楚。适合两类人看:一类是已经会用官方库但想搞明白底层到底怎么回事的,另一类是串口被占用、官方库跑不通、被迫自己动手的。代码基于博途V14 SP1的SCL语言,S7-1200和S7-1500都适用,串口模块以CM1241 RS485为例,其他模块逻辑一致。

先说清楚一个前提:自由口通信下,你发出去的每一个字节都是你自己拼的,PLC不会帮你加任何东西。ModbusRTU的帧结构是"地址+功能码+数据+CRC16",其中CRC是低字节在前、高字节在后,这个顺序很多人第一次写都会搞反。另外帧与帧之间要求至少3.5个字符时间的静默间隔,这个在自由口里通常靠发送完成后的延时或者接收超时来判断,后面会详细说。

2. ModbusRTU主站帧结构的拆解与CRC16的SCL落地

2.1 一帧数据到底长什么样

ModbusRTU的一帧,从字节层面看是这样的:

字段长度说明
从站地址1字节0x01~0xF7,0是广播
功能码1字节如0x03读保持寄存器、0x06写单寄存器
数据域N字节随功能码变化,含起始地址、数量或写入值
CRC校验2字节低字节在前,高字节在后

以读保持寄存器(功能码0x03)为例,请求帧是:地址 + 0x03 + 起始地址高 + 起始地址低 + 寄存器数量高 + 寄存器数量低 + CRC低 + CRC高,一共8个字节。响应帧是:地址 + 0x03 + 字节数 + 数据... + CRC低 + CRC高

这里有个特别容易翻车的点:Modbus协议里所有16位数据都是大端序(高字节在前),但CRC是例外,它是小端序(低字节在前)。我见过不止一个项目因为CRC字节顺序搞反,导致从站完全不响应,抓包一看CRC明明算对了,就是顺序错了。

2.2 CRC16查表法还是逐位计算法

CRC16的计算有两种常见做法:逐位计算和查表。逐位计算代码短、占用内存少,但每次要循环8次;查表法速度快,但需要一张256项的表格,占512字节的DB空间。

在S7-1200这种资源有限的PLC上,如果主站轮询的从站数量不多、通信频率不高,逐位计算完全够用。我实测下来,一个8字节的帧算一次CRC,在1214C上大概几十微秒,对扫描周期影响可以忽略。但如果你的项目要轮询几十个从站、每个周期几百毫秒,那查表法会更稳。

下面是我常用的逐位计算CRC16的SCL代码,直接可以抄:

FUNCTION "CRC16" : Void { S7_Optimized_Access := 'TRUE' } VERSION : 0.1 VAR_INPUT data : Array[*] of Byte; // 待校验数据 length : Int; // 数据长度 END_VAR VAR_OUTPUT crc : Word; // 计算结果 END_VAR VAR_TEMP i : Int; j : Int; tempCrc : Word; END_VAR BEGIN tempCrc := 16#FFFF; FOR i := 0 TO length - 1 DO tempCrc := tempCrc XOR data[i]; FOR j := 0 TO 7 DO IF (tempCrc AND 16#0001) <> 0 THEN tempCrc := (tempCrc SHR 1) XOR 16#A001; ELSE tempCrc := tempCrc SHR 1; END_IF; END_FOR; END_FOR; crc := tempCrc; END_FUNCTION

这段代码里16#A001是Modbus CRC16的多项式(反向后的0x8005),16#FFFF是初始值。注意SCL里SHR是无符号右移,正好符合CRC计算的需求。算完之后,发送时要把crc的低字节先发、高字节后发,也就是:

sendBuffer[6] := WORD_TO_BYTE(crc AND 16#00FF); // CRC低字节 sendBuffer[7] := WORD_TO_BYTE(crc SHR 8); // CRC高字节

注意:如果你用的是Array[*]这种可变长度数组,在博途V14 SP1里调用时要确保实参类型匹配,有些早期版本对Array[*]支持不完善,建议直接用固定长度数组更稳妥。

2.3 功能码的封装思路

主站要支持的功能码通常就那么几个:0x01读线圈、0x03读保持寄存器、0x06写单寄存器、0x10写多寄存器。我的做法是给每个功能码写一个独立的请求帧组装函数,输入参数是"从站地址、起始地址、数量或数据",输出是一个完整的发送缓冲区。

这样设计的原因是:不同功能码的数据域长度和含义完全不同,硬塞进一个函数里会写出一堆if-else,可读性极差。分开写虽然代码量大一点,但每个函数职责单一,调试的时候一眼就能看出问题在哪。

以0x03为例,组装函数大概长这样:

FUNCTION "BuildReadHolding" : Void VAR_INPUT slaveAddr : Byte; startAddr : Word; regCount : Word; END_VAR VAR_IN_OUT buffer : Array[0..7] of Byte; END_VAR VAR_TEMP crcVal : Word; END_VAR BEGIN buffer[0] := slaveAddr; buffer[1] := 16#03; buffer[2] := WORD_TO_BYTE(startAddr SHR 8); buffer[3] := WORD_TO_BYTE(startAddr AND 16#00FF); buffer[4] := WORD_TO_BYTE(regCount SHR 8); buffer[5] := WORD_TO_BYTE(regCount AND 16#00FF); "CRC16"(data := buffer, length := 6, crc => crcVal); buffer[6] := WORD_TO_BYTE(crcVal AND 16#00FF); buffer[7] := WORD_TO_BYTE(crcVal SHR 8); END_FUNCTION

这里bufferVAR_IN_OUT是为了避免数组拷贝带来的额外开销,直接操作调用者的缓冲区。

3. 自由口发送与接收的时序控制才是真正的难点

3.1 发送:SEND_PTP指令的正确用法

自由口发送用的是SEND_PTP指令,它的REQ引脚需要一个上升沿触发,DATA指向发送缓冲区,LENGTH是字节数。关键点在于:REQ不能一直给TRUE,否则会连续发送。正确做法是用一个状态机,在"准备发送"状态下给一个周期的上升沿,然后等DONEERROR

我见过有人用时钟脉冲直接怼REQ,结果从站收到一堆重复帧直接罢工。所以发送必须由状态机驱动,不能靠定时器硬触发。

发送完成后,SEND_PTPDONE会置位一个周期。这时候不能马上进入接收,因为RS485是半双工,发送和接收共用一对差分线,必须等发送移位寄存器彻底空掉、总线释放之后才能切到接收。CM1241模块内部会自动处理收发切换,但软件上要留一点余量,通常等DONE之后延时1~2ms再开接收比较稳。

3.2 接收:RCV_PTP和帧结束判断

接收用RCV_PTP,它的EN_R引脚给TRUE就持续使能接收。数据收满或者收到指定长度后,NDR置位。但ModbusRTU的响应帧长度是不固定的(读不同数量的寄存器,返回的字节数不同),所以不能靠固定长度来判断帧结束。

我的做法是:先按最大可能长度接收,然后根据功能码和字节数域反推实际长度,再用CRC校验确认帧完整性。具体来说,RCV_PTPLENGTH设成能容纳最大响应帧的值(比如256),收到数据后先看buffer[1]功能码,如果是0x03,那么实际长度 = 3 + buffer[2] + 2(地址+功能码+字节数+数据+CRC)。如果CRC校验通过,就认为这一帧有效。

但这里有个坑:RCV_PTP在收到LENGTH指定的字节数之前不会置位NDR,如果你设了256,而从站只返回8个字节,那NDR永远不来。所以更靠谱的做法是用接收超时来判断帧结束:使能接收后启动一个定时器,如果在3.5个字符时间内没有新字节进来,就认为帧结束了。

3.5个字符时间怎么算?以9600波特率、8数据位、1停止位、无校验为例,一个字符是10位(1起始+8数据+1停止),一个字符时间 = 10/9600 ≈ 1.04ms,3.5个字符 ≈ 3.65ms。19200波特率下就是1.82ms。实际实现时我会取一个稍大的值,比如5ms,留点余量。

3.3 状态机的设计

整个主站通信用一个状态机来驱动,状态大致分这几个:

状态含义转移条件
IDLE空闲有请求时进入BUILD
BUILD组装请求帧组装完成进入SEND
SEND发送中DONE或ERROR后进入WAIT
WAIT等待总线释放延时结束进入RECV
RECV接收中超时或收满后进入CHECK
CHECK校验响应校验通过进入DONE,失败进入RETRY
RETRY重传重传次数未超限回SEND,超限报错
DONE完成回IDLE

这个状态机是整个源码的骨架,所有时序控制都挂在上面。用SCL写状态机,我习惯用CASE语句配合一个Int型的状态变量,清晰好维护。

提示:状态机里所有延时都不要用WAIT指令(SCL里没有),而是用定时器TON配合状态转移条件。博途的TON在SCL里调用方式是#myTimer(IN := ..., PT := ...),输出QET

4. 异常处理与重传机制:让通信真正可靠

4.1 Modbus异常响应码的解析

从站如果收到非法请求,会返回一个异常响应:功能码的最高位置1(比如0x03变成0x83),后面跟一个异常码。常见异常码有:

  • 0x01:非法功能码
  • 0x02:非法数据地址
  • 0x03:非法数据值
  • 0x04:从站设备故障
  • 0x05:确认(从站正在处理,需要继续轮询)
  • 0x06:从站忙

主站收到异常响应后,不能简单当成通信失败重传,因为像0x02这种是请求本身有问题,重传多少次都没用。我的处理逻辑是:先判断功能码最高位,如果是异常响应,解析异常码并记录,不重传,直接报错给上层。只有CRC错误、超时无响应、帧格式错误这些才触发重传。

4.2 重传次数与退避策略

重传次数一般设2~3次就够了。设太多会导致一个故障从站拖慢整个轮询周期。我通常设3次,每次重传之间加一个短延时(比如20ms),给从站一点恢复时间。

这里有个经验:如果连续多个从站都通信失败,大概率是总线接线问题或者终端电阻没接,而不是从站本身的问题。这时候重传再多次也没用,应该报一个总线级故障,让上层知道要检查硬件。

4.3 超时时间的设定

响应超时时间要覆盖"从站处理时间+传输时间"。9600波特率下,一个8字节响应帧传输时间约8.3ms,加上从站处理时间(一般几毫秒到几十毫秒),超时设200~500ms比较合理。设太短会误判,设太长会拖慢轮询。

如果轮询多个从站,每个从站的超时时间可以单独配置,因为有些慢速仪表响应确实慢。我的做法是在从站配置DB里给每个从站一个超时字段,状态机里动态加载。

5. 源码整体结构与关键DB设计

5.1 程序块划分

整个主站功能我拆成这几个块:

  • CRC16:FC,CRC计算
  • BuildReadHolding/BuildWriteSingle/ ...:FC,各功能码帧组装
  • ModbusMaster:FB,主状态机,核心逻辑
  • ModbusMaster_DB:背景DB,存状态、缓冲区、统计信息
  • SlaveConfig_DB:从站配置,存地址、功能码、寄存器地址、数量、超时等

用FB而不是FC来做主状态机,是因为需要保存状态和缓冲区数据,FB的背景DB天然适合。从站配置单独放一个DB,方便在线修改和批量配置。

5.2 缓冲区设计

发送缓冲区和接收缓冲区各用一个Array[0..255] of Byte。发送缓冲区实际只用前8个字节(读请求)或前N个字节(写请求),接收缓冲区按最大256字节准备。

这里有个细节:接收缓冲区在每次接收前要清零,否则上一帧的残留数据会干扰CRC校验。我一般用FILL指令或者MOVE块清零,博途里FILL在SCL里的用法是FILL(IN := 0, COUNT := 256, OUT => recvBuffer)

5.3 统计信息

背景DB里我习惯加几个统计字段:总请求数、成功数、CRC错误数、超时数、异常响应数。这些数据在调试阶段特别有用,一眼就能看出通信质量。上线后也可以用来做预防性维护,比如CRC错误率突然升高,说明总线干扰变大了。

6. 实测中踩过的坑和调试技巧

6.1 CRC算对了但从站不响应

这个坑我踩过两次。第一次是CRC字节顺序搞反了,第二次是起始地址的字节序搞反了。Modbus的地址和数据都是大端,唯独CRC是小端。调试的时候建议用串口调试助手先手动发一帧,确认从站能响应,再对比PLC发出的帧,逐字节比对。

6.2 接收偶尔丢帧

丢帧通常有两个原因:一是接收使能太晚,从站响应已经发完了才开接收;二是接收超时设太短,帧还没收完就判结束了。前者要在发送DONE后尽快开接收,后者要把超时时间调大一点。我一般把接收超时设成3.5字符时间的1.5倍。

6.3 多从站轮询时周期太长

如果从站多、每个都等超时,轮询周期会很长。优化思路是:正常响应的从站快速通过,只有失败的从站才走完整超时。另外可以把超时时间按从站分级,快的从站给短超时,慢的给长超时。

6.4 博途V14 SP1的SCL编译器有些小脾气

V14 SP1的SCL编译器对Array[*]的支持不完整,有些写法在V15以后能过,在V14 SP1会报错。建议用固定长度数组,或者把可变数组改成VARIANT配合MOVE_BLK。另外V14 SP1里WORD_TO_BYTE这种转换函数是有的,但BYTE_TO_WORD要注意符号扩展问题,最好用WORD类型中转。

6.5 终端电阻和屏蔽接地

这是硬件层面的坑,但影响巨大。RS485总线两端必须接120欧终端电阻,屏蔽层单端接地。我遇到过一个现场,通信时好时坏,查了半天代码没问题,最后发现是终端电阻没接,加上之后立刻稳定。所以调试通信问题,先查硬件再查软件。

7. 关于这套源码的扩展思路

这套自由口ModbusRTU主站跑通之后,扩展方向其实挺多的。比如可以加一个从站扫描功能,自动遍历地址1~247,把在线的从站列出来,省去手动配置的麻烦。也可以把统计信息通过Web服务器或者HMI展示出来,做成通信质量监控面板。

另外,如果项目里既有ModbusRTU又有ModbusTCP,可以把两者的数据层统一抽象,上层应用只关心"读哪个地址、读多少",底层走串口还是网口由配置决定。这样代码复用率会高很多。

我个人在实际项目里的体会是,自由口手搓Modbus虽然前期投入比官方库大,但一旦跑通,后面遇到任何奇葩从站都不慌,因为每一字节都在你掌控之中。尤其是那些对时序要求苛刻的老仪表,官方库搞不定的,自由口方案基本都能救回来。这套源码我在好几个现场用过,9600和19200波特率下都稳,从站数量最多带过20多个,轮询周期控制在1秒以内。

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

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

立即咨询