
1. 为什么要在博途里用自由口手搓一个ModbusRTU主站在博途V14 SP1这个版本上做过西门子S7-1200/1500通信的朋友应该都有体会官方其实提供了MB_MASTER和MB_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~0xF70是广播功能码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的多项式反向后的0x800516#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这里buffer用VAR_IN_OUT是为了避免数组拷贝带来的额外开销直接操作调用者的缓冲区。3. 自由口发送与接收的时序控制才是真正的难点3.1 发送SEND_PTP指令的正确用法自由口发送用的是SEND_PTP指令它的REQ引脚需要一个上升沿触发DATA指向发送缓冲区LENGTH是字节数。关键点在于REQ不能一直给TRUE否则会连续发送。正确做法是用一个状态机在准备发送状态下给一个周期的上升沿然后等DONE或ERROR。我见过有人用时钟脉冲直接怼REQ结果从站收到一堆重复帧直接罢工。所以发送必须由状态机驱动不能靠定时器硬触发。发送完成后SEND_PTP的DONE会置位一个周期。这时候不能马上进入接收因为RS485是半双工发送和接收共用一对差分线必须等发送移位寄存器彻底空掉、总线释放之后才能切到接收。CM1241模块内部会自动处理收发切换但软件上要留一点余量通常等DONE之后延时1~2ms再开接收比较稳。3.2 接收RCV_PTP和帧结束判断接收用RCV_PTP它的EN_R引脚给TRUE就持续使能接收。数据收满或者收到指定长度后NDR置位。但ModbusRTU的响应帧长度是不固定的读不同数量的寄存器返回的字节数不同所以不能靠固定长度来判断帧结束。我的做法是先按最大可能长度接收然后根据功能码和字节数域反推实际长度再用CRC校验确认帧完整性。具体来说RCV_PTP的LENGTH设成能容纳最大响应帧的值比如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.04ms3.5个字符 ≈ 3.65ms。19200波特率下就是1.82ms。实际实现时我会取一个稍大的值比如5ms留点余量。3.3 状态机的设计整个主站通信用一个状态机来驱动状态大致分这几个状态含义转移条件IDLE空闲有请求时进入BUILDBUILD组装请求帧组装完成进入SENDSEND发送中DONE或ERROR后进入WAITWAIT等待总线释放延时结束进入RECVRECV接收中超时或收满后进入CHECKCHECK校验响应校验通过进入DONE失败进入RETRYRETRY重传重传次数未超限回SEND超限报错DONE完成回IDLE这个状态机是整个源码的骨架所有时序控制都挂在上面。用SCL写状态机我习惯用CASE语句配合一个Int型的状态变量清晰好维护。提示状态机里所有延时都不要用WAIT指令SCL里没有而是用定时器TON配合状态转移条件。博途的TON在SCL里调用方式是#myTimer(IN : ..., PT : ...)输出Q和ET。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 程序块划分整个主站功能我拆成这几个块CRC16FCCRC计算BuildReadHolding/BuildWriteSingle/ ...FC各功能码帧组装ModbusMasterFB主状态机核心逻辑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秒以内。