ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

1个FB适配8种机型:ST语言配置表驱动复用实战

1个FB适配8种机型:ST语言配置表驱动复用实战 1. 从“1个FB vs 8种机型”说起这个标题到底在讲什么第一次看到“1个FB vs 8种机型ST复用这样写”这个标题很多人会愣一下。FB是什么ST又是什么为什么一个FB要对付8种机型其实把关键词拆开就清楚了FB是功能块Function BlockST是结构化文本Structured Text这是PLC和工业自动化领域里最常用的两种编程语言形态。标题的意思说白了就是——用同一个功能块去适配8种不同型号的设备靠ST语言的复用写法来搞定。这个场景在工控行业里太常见了。一条产线上可能同时跑着8种不同型号的伺服驱动器、变频器或者仪表它们的通信协议、数据长度、寄存器地址、量程范围都不一样。如果给每种机型单独写一套逻辑那就是8份代码维护起来简直是灾难——改一个报警阈值要改8个地方加一个功能要复制粘贴8遍。所以“复用”就成了刚需能不能写一个通用的功能块通过配置表来区分机型让同一套ST代码跑遍所有设备答案是能而且这是每一个从“能跑就行”进阶到“工程化交付”的工控人都必须掌握的技能。这篇文章就是把这个过程完整拆开从需求分析、方案选型、配置表设计到ST代码的具体写法、参数计算、调试踩坑全部讲透。不管你是刚接触ST语言的新手还是写过一些功能块但总觉得复用起来别扭的老手都能从这里拿到可以直接抄作业的东西。我做过好几个类似的项目最多的一次是一个功能块适配了12种仪表涵盖Modbus RTU、Modbus TCP和自定义串口协议三种通信方式。踩过的坑包括配置表字段设计不合理导致后期加机型要改代码、数据类型不匹配导致读数跳变、以及最经典的——某个机型的寄存器偏移量算错了排查了一整天才发现是配置表里一个字节序标志位写反了。这些经验我都会在下面详细展开。2. 整体设计思路为什么选“配置表ST复用”这条路2.1 先搞清楚要解决的核心问题在动手写代码之前得先把问题定义清楚。假设我们有8种机型它们之间的差异通常体现在这几个维度通信参数不同波特率、数据位、停止位、校验方式、站号寄存器地址不同同一个物理量比如“当前温度”在A机型是40001在B机型是40010数据类型不同有的是16位整数有的是32位浮点数有的还是BCD码量程和缩放不同原始值0-10000对应实际0-100.0℃还是0-65535对应0-500℃功能支持不同高端机型有报警输出低端机型没有有的支持写参数有的只读字节序不同32位数据是大端还是小端字交换还是字节交换如果针对每种机型写一个独立的功能块代码量是8倍而且逻辑完全重复。一旦发现某个通用逻辑有bug要改8次漏改一次就是现场事故。所以复用的核心目标就是把“变化的”抽出来做成配置把“不变的”沉淀成通用逻辑。2.2 为什么是FB而不是FC在IEC 61131-3标准里功能块FB和功能FC的区别在于FB有内部状态FC没有。对于设备通信这种场景FB的优势非常明显FB可以保存上一次的通信状态、错误计数、重试次数FB的实例可以独立维护自己的数据区8个机型就是8个实例互不干扰FB支持在内部定义静态变量不需要额外开辟全局DB用FC也能实现但你需要自己管理状态变量代码会变得很啰嗦。所以只要涉及“有状态”的逻辑优先选FB。这也是为什么标题里说的是“1个FB”而不是“1个FC”。2.3 为什么用ST而不是梯形图梯形图LD适合写逻辑联锁但处理配置表、数组、循环、字符串比较这些操作就非常吃力。ST语言本质上是结构化编程支持IF/CASE/FOR/WHILE支持数组和结构体写配置表驱动逻辑天然合适。举个例子要根据机型编号从配置表里取出寄存器地址梯形图里你得用一堆MOVE和比较指令堆出来ST里就是一行diRegAddr : astConfig[iModelIndex].diTempRegAddr;清晰、紧凑、易维护。所以这个方案的技术底座就是FB ST 配置表数组。2.4 配置表驱动的核心架构整个方案的架构可以分成三层配置层一个结构体数组每个元素对应一种机型存放该机型的所有差异参数逻辑层FB内部的通用逻辑包括通信收发、数据解析、量程转换、报警判断接口层FB的输入输出变量输入是机型编号和原始数据输出是工程值和状态这种分层的好处是新增一种机型只需要在配置表里加一行逻辑层完全不动。这就是“对扩展开放对修改关闭”的思路在工控编程里的落地。3. 配置表设计8种机型的差异怎么抽象成字段3.1 结构体定义与字段规划配置表是整个方案的核心设计得好不好直接决定了后期维护的成本。我一般会定义一个结构体类型把所有可能变化的参数都放进去。以下是一个经过多个项目验证的字段设计TYPE ST_DeviceConfig : STRUCT sModelName : STRING[32]; // 机型名称用于调试显示 iStationAddr : INT; // 站号 diRegTemp : DINT; // 温度寄存器地址 diRegPressure : DINT; // 压力寄存器地址 diRegStatus : DINT; // 状态字寄存器地址 iDataType : INT; // 数据类型0INT16,1UINT16,2REAL32,3BCD iByteOrder : INT; // 字节序0大端,1小端,2字交换 rScaleRawMin : REAL; // 原始值下限 rScaleRawMax : REAL; // 原始值上限 rScaleEngMin : REAL; // 工程值下限 rScaleEngMax : REAL; // 工程值上限 bHasAlarm : BOOL; // 是否支持报警 bWritable : BOOL; // 是否支持写参数 iTimeoutMs : INT; // 通信超时时间 END_STRUCT END_TYPE这个结构体一共14个字段基本覆盖了90%以上的机型差异。字段的设计原则是只放会变的不放不变的。比如通信协议如果是统一的Modbus RTU那协议类型就不用放进去如果8种机型里有3种是Modbus、5种是自定义协议那就得加一个协议类型字段。3.2 8种机型的配置数据实例假设我们有8种机型配置表数组可以这样写VAR_GLOBAL CONSTANT c_iModelCount : INT : 8; c_astConfig : ARRAY[1..8] OF ST_DeviceConfig : [ (sModelName:M100, iStationAddr:1, diRegTemp:40001, diRegPressure:40002, diRegStatus:40003, iDataType:0, iByteOrder:0, rScaleRawMin:0.0, rScaleRawMax:10000.0, rScaleEngMin:0.0, rScaleEngMax:100.0, bHasAlarm:TRUE, bWritable:TRUE, iTimeoutMs:1000), (sModelName:M200, iStationAddr:2, diRegTemp:40010, diRegPressure:40011, diRegStatus:40012, iDataType:2, iByteOrder:2, rScaleRawMin:0.0, rScaleRawMax:65535.0, rScaleEngMin:-50.0, rScaleEngMax:150.0, bHasAlarm:TRUE, bWritable:FALSE, iTimeoutMs:1500), // ... 其余6种机型 ]; END_VAR这里有几个细节值得注意。第一数组下标从1开始因为工控现场习惯用1-based编号操作员说“1号机型”就是数组第1个元素不用做减一转换。第二rScaleRawMin和rScaleRawMax用REAL而不是INT因为有些机型的原始值是浮点数。第三iTimeoutMs单独配置因为不同机型的响应速度差异很大老设备可能500ms才回一次新设备50ms就回了。3.3 配置表字段的扩展性考量设计配置表最容易犯的错误是字段不够用后期加机型时发现某个参数没地方放只能改结构体定义然后所有已有的配置数据都要跟着改。为了避免这个问题我在设计时会预留几个通用字段iReserved1 : INT; // 预留字段1 iReserved2 : INT; // 预留字段2 rReserved1 : REAL; // 预留字段2 sReserved1 : STRING[16]; // 预留字符串这些预留字段在初期不赋值等真的遇到特殊机型时再启用。比如后来遇到一个机型需要配置“寄存器偏移量”直接用iReserved1就行不用改结构体。这个技巧帮我省了好几次大改。注意预留字段不要太多3-4个就够了。太多会让配置表看起来很臃肿新人接手时一脸懵。而且预留字段一定要在注释里写清楚用途否则过两个月自己都忘了。4. ST复用代码的核心写法从配置表到通用逻辑4.1 FB的接口定义先看FB的输入输出定义这是复用的入口FUNCTION_BLOCK FB_DeviceHandler VAR_INPUT iModelIndex : INT; // 机型编号 1-8 bEnable : BOOL; // 使能 bReadRequest : BOOL; // 读请求 bWriteRequest : BOOL; // 写请求 rWriteValue : REAL; // 写入值 iWriteTarget : INT; // 写入目标0温度,1压力 END_VAR VAR_OUTPUT rTempValue : REAL; // 温度工程值 rPressureValue : REAL; // 压力工程值 iStatusWord : INT; // 状态字 bAlarm : BOOL; // 报警 bCommOK : BOOL; // 通信正常 sErrorMsg : STRING[64]; // 错误信息 END_VAR VAR astConfig : ST_DeviceConfig; // 当前机型配置副本 iStep : INT : 0; // 状态机步序 iRetryCount : INT : 0; // 重试计数 tonTimeout : TON; // 超时定时器 // ... 其他内部变量 END_VAR接口设计的关键是输入尽量少输出尽量全。输入只需要机型编号和操作请求输出把能给的诊断信息都给出来。这样上层调用者不需要关心内部细节换个机型只需要改iModelIndex。4.2 配置加载与参数校验FB的第一步是根据机型编号加载配置并做合法性校验// 机型编号范围检查 IF iModelIndex 1 OR iModelIndex c_iModelCount THEN sErrorMsg : 机型编号越界; bCommOK : FALSE; RETURN; END_IF; // 加载配置 astConfig : c_astConfig[iModelIndex]; // 配置合法性校验 IF astConfig.rScaleRawMax astConfig.rScaleRawMin THEN sErrorMsg : 量程配置错误原始值上下限相等; bCommOK : FALSE; RETURN; END_IF; IF astConfig.iTimeoutMs 100 THEN sErrorMsg : 超时时间过短最小100ms; bCommOK : FALSE; RETURN; END_IF;这段校验代码看起来简单但非常必要。我遇到过现场调试时操作员误把机型编号设成0结果FB去访问数组越界整个PLC直接停机。加了范围检查之后最坏情况只是这个FB报错不影响其他逻辑。4.3 数据类型解析的通用处理不同机型的数据类型不同这是复用中最麻烦的部分。我的做法是写一个通用的解析函数根据iDataType和iByteOrder把原始字节转换成REALMETHOD PRIVATE ParseRawData : REAL VAR_INPUT byRawData : ARRAY[0..3] OF BYTE; // 原始字节 iDataType : INT; // 数据类型 iByteOrder : INT; // 字节序 END_VAR VAR iTemp16 : INT; diTemp32 : DINT; rTemp : REAL; byTemp : ARRAY[0..3] OF BYTE; END_VAR // 字节序处理 CASE iByteOrder OF 0: // 大端直接使用 byTemp : byRawData; 1: // 小端反转字节 byTemp[0] : byRawData[3]; byTemp[1] : byRawData[2]; byTemp[2] : byRawData[1]; byTemp[3] : byRawData[0]; 2: // 字交换交换高低字 byTemp[0] : byRawData[2]; byTemp[1] : byRawData[3]; byTemp[2] : byRawData[0]; byTemp[3] : byRawData[1]; END_CASE; // 数据类型转换 CASE iDataType OF 0: // INT16 iTemp16 : BYTE_TO_INT(byTemp[0]) * 256 BYTE_TO_INT(byTemp[1]); rTemp : INT_TO_REAL(iTemp16); 1: // UINT16 rTemp : INT_TO_REAL(BYTE_TO_INT(byTemp[0]) * 256 BYTE_TO_INT(byTemp[1])); 2: // REAL32 rTemp : DWORD_TO_REAL( BYTE_TO_DWORD(byTemp[0]) * 16777216 BYTE_TO_DWORD(byTemp[1]) * 65536 BYTE_TO_DWORD(byTemp[2]) * 256 BYTE_TO_DWORD(byTemp[3]) ); 3: // BCD码 iTemp16 : BYTE_TO_INT(byTemp[0]) * 256 BYTE_TO_INT(byTemp[1]); rTemp : INT_TO_REAL( (iTemp16 AND 16#0F00) / 16#0100 * 1000 (iTemp16 AND 16#00F0) / 16#0010 * 100 (iTemp16 AND 16#000F) * 1 ); END_CASE; ParseRawData : rTemp;这段代码里的BCD转换值得说一下。BCD码每个半字节表示一个十进制位比如16#1234表示1234。转换时要分别取出千位、百位、十位、个位。我见过有人直接用INT_TO_REAL转换结果16#1234变成了4660差了十万八千里。4.4 量程转换的通用公式原始值转工程值用线性映射公式METHOD PRIVATE ScaleValue : REAL VAR_INPUT rRaw : REAL; rRawMin : REAL; rRawMax : REAL; rEngMin : REAL; rEngMax : REAL; END_VAR VAR rRatio : REAL; END_VAR rRatio : (rRaw - rRawMin) / (rRawMax - rRawMin); ScaleValue : rEngMin rRatio * (rEngMax - rEngMin);这个公式看起来简单但有两个坑。第一如果rRaw超出[rRawMin, rRawMax]范围算出来的工程值会超量程需要在调用前做限幅。第二如果rRawMax rRawMin会除零所以前面配置校验里必须检查。我一般会在ScaleValue里加一个保护IF rRawMax rRawMin THEN ScaleValue : rEngMin; RETURN; END_IF;4.5 状态机驱动的通信流程通信逻辑用状态机写最清晰也最容易调试。我一般用5个状态CASE iStep OF 0: // 空闲等待请求 IF bReadRequest OR bWriteRequest THEN iStep : 10; iRetryCount : 0; END_IF; 10: // 发送请求 BuildRequest(); // 根据配置表构建报文 SendRequest(); tonTimeout(IN:TRUE, PT:INT_TO_TIME(astConfig.iTimeoutMs)); iStep : 20; 20: // 等待响应 IF ReceiveResponse() THEN tonTimeout(IN:FALSE); iStep : 30; ELSIF tonTimeout.Q THEN tonTimeout(IN:FALSE); iRetryCount : iRetryCount 1; IF iRetryCount 3 THEN sErrorMsg : 通信超时重试3次失败; bCommOK : FALSE; iStep : 0; ELSE iStep : 10; // 重试 END_IF; END_IF; 30: // 解析数据 ParseResponse(); iStep : 40; 40: // 完成复位 bCommOK : TRUE; iStep : 0; END_CASE;状态机的好处是每一步做什么非常明确调试时看iStep的值就知道卡在哪一步。我一般会把iStep映射到一个输出变量方便在HMI上显示。5. 实操过程从零搭建一个可复用的FB5.1 开发环境与前置准备我用的环境是CODESYS V3.5但同样的思路适用于西门子TIA Portal的SCL、三菱的ST、欧姆龙的ST。差异主要在语法细节上核心逻辑完全一样。前置准备包括确认8种机型的通信协议文档整理出寄存器地址表确认每种机型的数据类型和字节序最好用调试工具实测验证确认量程范围特别是工程值的单位和精度在PLC里规划好全局变量区配置表数组建议放在GVL里5.2 配置表的录入与验证配置表录入是最容易出错的地方。我的做法是先在Excel里整理好所有机型的参数然后写一个脚本转换成ST代码。Excel的列对应结构体字段行对应机型。转换脚本用Python写核心就是字符串拼接models [ {name:M100,addr:1,reg_temp:40001,reg_press:40002,dtype:0,border:0,raw_min:0,raw_max:10000,eng_min:0,eng_max:100,alarm:True,write:True,timeout:1000}, # ... 其余机型 ] for m in models: print(f(sModelName:{m[name]}, iStationAddr:{m[addr]}, fdiRegTemp:{m[reg_temp]}, diRegPressure:{m[reg_press]}, fiDataType:{m[dtype]}, iByteOrder:{m[border]}, frScaleRawMin:{m[raw_min]}.0, rScaleRawMax:{m[raw_max]}.0, frScaleEngMin:{m[eng_min]}.0, rScaleEngMax:{m[eng_max]}.0, fbHasAlarm:{str(m[alarm]).upper()}, bWritable:{str(m[write]).upper()}, fiTimeoutMs:{m[timeout]}),)这样生成的代码不会有拼写错误而且改参数只需要改Excel重新生成。我强烈建议用这种方式手工敲8个机型几十个字段不出错才怪。5.3 通信报文的动态构建不同机型的寄存器地址不同报文构建必须动态化。以Modbus RTU读保持寄存器为例METHOD PRIVATE BuildRequest VAR i : INT; wCRC : WORD; END_VAR // 从站地址 abyTxBuffer[0] : INT_TO_BYTE(astConfig.iStationAddr); // 功能码 03 abyTxBuffer[1] : 16#03; // 起始地址根据读目标选择 IF iReadTarget 0 THEN abyTxBuffer[2] : DINT_TO_BYTE(astConfig.diRegTemp / 256); abyTxBuffer[3] : DINT_TO_BYTE(astConfig.diRegTemp MOD 256); ELSE abyTxBuffer[2] : DINT_TO_BYTE(astConfig.diRegPressure / 256); abyTxBuffer[3] : DINT_TO_BYTE(astConfig.diRegPressure MOD 256); END_IF; // 寄存器数量 abyTxBuffer[4] : 0; abyTxBuffer[5] : 2; // CRC计算 wCRC : CalculateCRC(abyTxBuffer, 6); abyTxBuffer[6] : WORD_TO_BYTE(wCRC MOD 256); abyTxBuffer[7] : WORD_TO_BYTE(wCRC / 256);CRC计算是Modbus的必备函数网上有很多现成的实现直接拿来用就行。注意CRC的低字节在前、高字节在后这个顺序搞反了通信永远不通。5.4 调试与验证方法调试分三步走第一步单机型验证。先把iModelIndex固定为1用Modbus调试工具模拟从站确认收发报文正确、数据解析正确。这一步不要急着上真实设备。第二步多机型切换验证。写一个测试程序每隔5秒切换一次机型编号观察输出值是否跟着配置表变化。这一步能发现配置表字段对应错误的问题。第三步真实设备联调。接上真实设备逐个机型测试。重点验证边界情况超时重试、数据溢出、报警触发。我一般会在FB里加一个调试输出把当前使用的配置表内容以字符串形式输出方便对比sDebugInfo : CONCAT(机型:, astConfig.sModelName); sDebugInfo : CONCAT(sDebugInfo, 温度寄存器:); sDebugInfo : CONCAT(sDebugInfo, DINT_TO_STRING(astConfig.diRegTemp));6. 常见问题与排查技巧实录6.1 数据跳变或数值离谱这是最常见的问题90%的情况是字节序或数据类型配置错了。排查方法用调试工具读原始字节手工按不同字节序解析看哪个结果合理。比如原始字节是00 00 41 20按大端REAL解析是10.0按字交换解析就是另一个值。实测对比一下就知道配置表里该填哪个。6.2 通信时好时坏如果通信偶尔成功偶尔失败先检查超时时间是否够。老设备响应慢1000ms可能不够改成2000ms试试。如果还是不行检查重试次数是否太少改成5次。另外注意有些设备的站号响应有延迟连续请求间隔太短会被忽略需要在两次请求之间加50ms延时。6.3 新增机型后原有逻辑出错这通常是配置表数组越界或者字段错位导致的。检查新增机型的配置数据是否完整特别是字符串字段有没有超长。STRING[32]最多存31个字符超了会截断甚至覆盖后面的字段。我遇到过sModelName写了40个字符结果把后面的iStationAddr覆盖了通信直接跑到别的站号去了。6.4 常见问题速查表现象可能原因排查方法解决方案数值跳变字节序错误手工解析原始字节对比修改iByteOrder配置数值离谱数据类型错误检查iDataType是否匹配修改iDataType配置通信超时超时时间过短增大iTimeoutMs测试调整到合理值通信时好时坏请求间隔太短加延时测试请求间加50ms延时新增机型出错配置表越界检查数组下标范围修正iModelIndex范围报警不触发bHasAlarm配置错误检查该机型配置修改bHasAlarm写入无效bWritable为FALSE检查写权限配置修改bWritable字符串乱码编码或长度问题检查STRING长度控制在31字符内6.5 独家避坑技巧技巧一配置表版本管理。配置表一定要纳入版本管理每次修改记录改了什么、为什么改。我见过因为配置表被误改导致整条线停产的案例有版本记录就能快速回滚。技巧二上电自检。FB第一次执行时做一次全配置表自检检查所有机型的配置是否合法有问题直接报错。这样能在调试阶段就发现问题而不是等到切换机型时才暴露。技巧三原始值缓存。把每次通信的原始字节缓存下来出问题时可以回放分析。我一般缓存最近10次的数据用环形缓冲区实现。技巧四机型编号用枚举。虽然配置表数组下标是INT但在上层调用时用枚举更清晰TYPE E_DeviceModel : ( eM100 : 1, eM200 : 2, // ... eM800 : 8 ); END_TYPE这样调用时写FB_DeviceHandler(iModelIndex : eM100)比写1直观多了也不会搞错。7. 复用方案的扩展与进阶玩法7.1 从8种机型扩展到N种这套方案最大的优势是扩展成本极低。新增机型只需要在配置表数组里加一行把c_iModelCount改成9其他代码一行不动。我后来把这个方案扩展到了12种机型新增4种只花了半小时其中20分钟还是在整理新机型的寄存器文档。如果机型数量超过20种建议把配置表从常量数组改成从文件加载。CODESYS支持从CSV文件读取配置这样不用重新编译程序就能更新配置表。具体做法是用SysFile库读取CSV解析后填充数组。7.2 多协议混合场景如果8种机型里既有Modbus RTU又有Modbus TCP还有自定义协议可以在配置表里加一个iProtocolType字段然后在通信层用CASE分支CASE astConfig.iProtocolType OF 0: // Modbus RTU ModbusRTU_Handler(); 1: // Modbus TCP ModbusTCP_Handler(); 2: // 自定义协议 Custom_Handler(); END_CASE;每个协议处理函数内部再根据配置表的寄存器地址构建报文。这样协议差异被隔离在通信层数据解析层完全复用。7.3 与HMI的配合配置表的内容最好能在HMI上显示和修改。我一般会做一个配置页面用表格控件显示所有机型的参数操作员可以直接修改。修改后的配置存在掉电保持区上电时加载。这样现场调整参数不需要工程师到场改程序。需要注意的是HMI修改配置要有权限控制普通操作员只能看不能改工程师才能修改。而且修改后要做合法性校验不合法的配置直接拒绝并提示原因。7.4 性能优化建议如果8种机型需要同时通信FB会被调用8次每次都要做配置加载和校验。优化方法是在FB外部做一次配置加载把配置结构体作为输入传给FBFB内部不再重复加载。这样能减少CPU开销特别是在机型数量多、扫描周期短的情况下。另外配置表数组如果很大访问时会有内存开销。可以把常用的字段单独提取成小数组比如所有机型的温度寄存器地址放一个数组这样访问更高效。不过对于8种机型这种规模优化收益不大代码清晰更重要。8. 一些实操后的个人体会这套“1个FB 配置表 ST复用”的方案我从第一次尝试到现在已经用了五六年覆盖了十几个项目、几十种机型。最大的感受是前期多花一小时设计配置表后期能省几十小时的维护时间。我见过太多项目因为一开始没做复用后期加机型时改代码改到崩溃最后不得不重构。另一个体会是配置表的字段设计要“够用就好适度预留”。字段太少后期要改结构体字段太多配置表臃肿难维护。我的经验是核心字段10-15个预留字段3-4个基本能覆盖所有场景。最后说一个容易被忽视的点文档。配置表每个字段的含义、取值范围、单位一定要写清楚。我一般会在结构体定义里用注释详细说明另外单独写一份配置表填写指南。这样即使换了人维护也能快速上手。毕竟代码是写给未来的自己看的配置表也是。
返回列表