
做现场自动化这些年只要设备一多“采集”这俩字就特别容易让人头疼。早期我用梯形图写过一套36个温度通道的采集程序硬生生复制粘贴了36段几乎一模一样的网络地址编到眼花改一个通道的量程要翻半天程序。后来把ST语言结构化文本里的数组和FOR循环用起来一个问题彻底想通了批量采集的本质就是用同一套逻辑反复处理结构相同的数据而数组提供容器FOR循环提供遍历机制两者组合就能把几十段重复代码压成三五行。这篇文章就围绕“ST数组 FOR循环”这个组合讲讲我实际做批量采集时的完整思路。不管你是刚接触ST语言的PLC工程师还是正在把老项目从梯形图往文本语言迁移下面这些内容都值得花十分钟看完。里面的代码示例主要以IEC 61131-3标准为基础CODESYS、TwinCAT、汇川、信捷等主流平台基本通用。1. 批量采集为什么让人头大重复代码与维护噩梦1.1 梯形图时代的“复制粘贴式”采集如果你用梯形图写过8台甚至更多台设备的采集程序一定经历过这种场景一台温控仪四个参数写成4个采集网络8台设备就是32个网络。中间如果夹着Modbus通信指令每个功能块实例还得单独命名地址单独填写错误标志单独复位。程序一长监控的时候翻页翻到手指发酸。问题不只是“长”而是结构化的重复带来结构化的维护成本。某一天现场换了一台不同型号的仪表量程从0到100摄氏度改成-20到80摄氏度你要么逐个打开那8段网络去改要么祈祷自己没有漏掉哪一台。批量采集这个需求本身在逻辑上极其简单——读同一类数据、做同样的处理但梯形图的网状结构天然不适合表达这种“对集合内所有元素执行同一操作”的语义。1.2 ST语言的循环思想为什么契合采集场景ST语言是IEC 61131-3标准中面向过程的高级文本语言语法上很像Pascal和C的混合体。它带来的核心转变是从“画”程序变成“写”程序。数组能把同类型变量组织成带索引的整体FOR循环则让“对每一个元素执行动作”变成一行代码这种表达方式和批量采集的需求几乎是完美对齐的。举个最直观的对比。梯形图做8台设备温度采集需要8个功能块实例而ST数组方案只需要VAR arrTemperature : ARRAY[1..8] OF REAL; iIndex : INT; END_VAR FOR iIndex : 1 TO 8 DO arrTemperature[iIndex] : ReadTemperature(iIndex); END_FOR;代码量从几十个网络变成几行而且“8”这个数字只出现一次。以后要改成16台改一个数组上界和循环终值即可不用再增加半个程序页面。1.3 哪些平台能用这套方案现在的市场情况是主流PLC和运动控制器基本都支持ST语言差异只是平台之间的具体语法细节和调试手段平台ST语言名称数组上界写法备注CODESYS V3ST / SFCARRAY[0..7] 或 ARRAY[1..8]支持REFERENCE调试友好倍福 TwinCAT 3STARRAY[0..7] 或 ARRAY[1..8]底层是C#运行时性能强西门子 S7-1200/1500SCLARRAY[0..7] 或 ARRAY[1..8]在TIA Portal中编辑三菱 GX Works3ST不支持ARRAY[1..3]这种上界必须是常量数组下标必须从0开始施耐德 SoMachine/Unity ProSTARRAY[1..8]传统命名习惯常从1开始汇川 / 信捷等国产中型PLCST视具体平台而定兼容CODESYS语法的通常从0或1均可这里面有一个特别容易踩的差异数组下标到底从0开始还是从1开始。用惯了C语言的工程师到了CODESYS平台下意识就写ARRAY[0..7]结果现场设备编号是1到8每个索引都得做偏移处理。我的建议是设备号、通道号在工程上通常习惯从1开始那么数组就声明成ARRAY[1..8]循环就从1开始代码和现场标签完全对应排查问题的时候大脑不用做减法。2. 写FOR循环之前先把这几个ST语言基础夯实2.1 数组声明和它在PLC内存里的真面目数组在ST里本质上是连续内存区上一组同类型数据的集合。比如ARRAY[1..8] OF REAL就是向控制器请求了8个REAL尺寸4字节的连续空间相邻索引之间在内存地址上是线性相邻的。理解这一点很重要批量采集爱用数组不只是因为语法方便更是因为数组的操作可以被控制器底层优化成连续内存访问比散落的独立变量更高效。声明数组的标准写法VAR arrRawValue : ARRAY[1..8] OF INT; (* 8个原始值 *) arrEngValue : ARRAY[1..8] OF REAL; (* 8个工程值 *) arrStatus : ARRAY[1..8] OF BOOL; (* 8个状态位 *) END_VAR多维数组也是支持的后面讲矩阵式采集时会用到。需要提醒的一点很多平台在声明数组时必须用常量指定上下界不能用变量。如果你确实需要“运行时决定采集多少个点”只能声明一个足够大的固定数组再用一个变量作为有效长度循环时只跑到有效长度为止而不是反复修改数组定义。2.2 FOR循环的完整语法和几个冷门细节ST语言的FOR循环标准语法如下FOR iIndex : 1 TO 8 BY 1 DO // 循环体 END_FOR;其中BY 1是步长为1时可以省略。步长也可以写成负数实现从后往前遍历FOR iIndex : 8 TO 1 BY -1 DO // 从第8个往前处理 END_FOR;这里藏着几个很多老手都容易忽略的细节。循环变量iIndex在循环结束后其值并不是8而是终值加上步长也就是9。所以不要在循环结束之后拿循环变量去当有效值用。另外循环变量的数据类型推荐用INT或DINT绝不能用REAL浮点变量的累加误差会让循环次数变得不可预测。最后标准规定不要在循环体内修改循环变量的值如果你发现自己正在写iIndex : 5;这种代码几乎一定是用错了结构应改用WHILE或者重新设计逻辑。2.3 循环体内适合放什么不适合放什么这个点我觉得比语法本身更重要。FOR循环适合放无阻塞的纯逻辑操作数值运算、赋值、比较、数组元素访问、调用没有等待行为的功能块。不适合放需要等待外部条件满足的操作比如“发送Modbus请求后等从站回复”“等待串口返回OK字符串”。为什么PLC是循环扫描模型CPU按扫描周期周期性地执行程序。如果你在FOR循环里真等外部设备的响应几十毫秒甚至几秒的等待时间会让整个扫描周期被拖垮轻则通信超时重则触发看门狗复位。正确的做法是把“等待”变成状态机——每个扫描周期内只检查一次状态条件满足则继续不满足就跳出当前周期的处理等下一个扫描周期再回来。这个矛盾后面在批量采集实战里会专门展开因为它是新手最容易踩的坑。3. 批量采集实战FOR循环配合轮询状态机3.1 场景设计8台温控仪表的数据读取假设现场有一条生产线8台温控表通过Modbus RTU挂在一条总线上每台表需要采集当前温度保持寄存器地址40001。传统做法是给每台表建一个独立通信功能块实例挨个填写从站地址再写8段轮询逻辑。我们用ST数组FOR循环来做整个采集核心代码控制在40行以内而且后续增加从站数量时改动极小。变量定义如下VAR arrSlaveAddr : ARRAY[1..8] OF BYTE; (* 从站地址表 *) arrTemp : ARRAY[1..8] OF REAL; (* 温度值数组 *) nCurStation : INT : 1; (* 当前轮询的从站编号 *) xRequestSend : BOOL; (* 发送请求标志 *) xReplyReady : BOOL; (* 回复就绪标志 *) nTimeoutCount : INT; (* 超时计数器 *) arrReadMutex : ARRAY[1..8] OF BOOL; (* 各站是否完成本周期读取 *) END_VAR注意arrSlaveAddr这个数组设备的Modbus从站地址规划好后全部填在这里后续程序逻辑不再出现任何硬编码的地址数字。这其实就是“数据驱动采集”的雏形——程序的结构和数据分离现场改动从站地址时只需要改这个数组的初始值不需要动逻辑代码。3.2 第一版直觉写法一个FOR循环读所有设备很多第一次接触ST的工程师会这么写FOR nCurStation : 1 TO 8 DO Modbus_ReadRequest(nCurStation, arrSlaveAddr[nCurStation], arrTemp[nCurStation]); // 就地问答模式立刻等待响应 END_FOR;漂亮是漂亮但基本跑不通。原因就是上面说的通信响应不是同步的你发出请求后从站需要几个毫秒甚至更长的时间返回数据主站也要等待总线上的数据帧解析完成。如果这个功能块调用本身是阻塞的扫描周期会被严重拖长如果功能块是非阻塞的立即返回后台完成通信那这个FOR循环一次扫描内连发8个请求从站根本来不及响应总线冲突、数据错乱是必然结果。第一次实测时我甚至看到现象是8台仪表中只有随机的一两台有正确数据其他全是上一次的值或异常值。这就是“逻辑正确但时序错误”的典型案例。3.3 状态机解法每个扫描周期只处理一台设备正确思路是把“对所有设备执行操作”进行时间切片一个扫描周期内只通过FOR循环定位到“当前该处理的设备”其他设备等在队列里。标准做法如下(* 每次扫描进入此段时先用FOR循环找到当前该发送请求的站号 *) xFound : FALSE; FOR nCurStation : 1 TO 8 DO IF arrReadMutex[nCurStation] FALSE THEN xFound : TRUE; EXIT; (* 找到第一个未完成的站跳出循环 *) END_IF; END_FOR; IF xFound THEN xRequestSend : TRUE; arrReadMutex[nCurStation] : TRUE; (* 先把该站标记为处理中防止重复触发 *) Modbus_ReadRequest(arrSlaveAddr[nCurStation], arrTemp[nCurStation]); END_IF;接下里的几个扫描周期程序检查通信功能块的完成标志和超时时间IF xRequestSend AND Modbus_ReadDone THEN (* 数据已经写入 arrTemp[nCurStation]处理下一个站 *) nTimeoutCount : 0; xRequestSend : FALSE; ELSIF xRequestSend THEN (* 还没完成累加超时时间 *) nTimeoutCount : nTimeoutCount 1; IF nTimeoutCount 1000 THEN arrReadMutex[nCurStation] : TRUE; (* 超时也置为已完成避免卡死 *) arrTemp[nCurStation] : 0.0; (* 失败值按0处理 *) xRequestSend : FALSE; nTimeoutCount : 0; END_IF; END_IF;8个站都完成后即arrReadMutex全部为TRUE进入复位阶段把所有标志复位开始下一轮批量采集周期。这种做法对总线负载很友好每个扫描周期最多发一帧请求也符合Modbus主站“一主多从一问一答”的时序要求。我建议所有做串口或总线批量采集的工程师都记住这个思路批量永远不等于“同一时刻处理全部”而是“一段周期内按队列逐个处理”。3.4 批量采集只是起点数据同步归档到数据块采集完成之后8个温度值都在arrTemp数组里了。下一件事通常是把这些值送到HMI显示、数据库存档或者交给另一个控制程序使用。如果这些消费方分布在程序的不同POU里直接把arrTemp声明成全局变量是最省事也是最合理的做法。但要注意跨POU访问数组时平台通常不支持把数组整体作为形参传递要么传递数组的起始地址TwinCAT里用REFERENCE要么就把数组声明为全局变量让其他POU直接按索引访问。我还习惯在采集完成后用FOR循环把数组内容整体搬运到“带时间戳的归档镜像数组”里这样即使下一轮采集覆盖了arrTemp上一轮的数据仍然保留着用于趋势分析FOR iIndex : 1 TO 8 DO arrTempHistory[iIndex] : arrTemp[iIndex]; END_FOR;哪怕是这么简单的搬运也别小看FOR循环的意义。它保证了你只需要维护一份“源数组”和一份“目标数组”中间无论加多少个通道循环体一行都不用改。4. 循环里面不只有“读”滤波、换算、归档一次搞定4.1 滑动平均滤波用循环把毛刺压下去工业现场的模拟量信号尤其是温度、压力、流量这些多多少少都有干扰。如果有干扰的原始值直接进到逻辑控制里轻则波动显示重则引起设备误动作。批量采集通常和滤波是一对搭档。滑动平均滤波器实现起来很简单维护一个“前几次的采样值”数组每次用当前值和最近N次值做平均。(* 假设 arrTemp 是本周期采集的原始温度 *) arrRawHistory[1..8] 的每个元素实际上是二维结构这里为了示例简化 *) FOR iIndex : 1 TO 8 DO arrSum : 0.0; // 把当前原始值存入历史数组末尾同时把最老的值挤出去 FOR jIndex : 9 TO 2 BY -1 DO arrHistory[iIndex, jIndex] : arrHistory[iIndex, jIndex - 1]; END_FOR; arrHistory[iIndex, 1] : arrRawValue[iIndex]; // 求和平均 FOR jIndex : 1 TO 5 DO (* 窗口5次 *) arrSum : arrSum arrHistory[iIndex, jIndex]; END_FOR; arrFiltered[iIndex] : arrSum / 5.0; END_FOR;这个例子展示了FOR循环嵌套的一个典型场景外层遍历8个通道内层实现每个通道的历史数据搬运和平均值计算。如果你用梯形图表达这段逻辑即便是5个窗口、8个通道网络数量也在40个以上而ST只用了十几行。4.2 工程量换算在循环里把原始值变单位采集到的原始值往往是PLC字长对应的二进制数比如4到20毫安电流信号对应0到27648的原始字工程上要换成0到100摄氏度的实际值。一个通道的换算公式不复杂麻烦的是几十个通道都要做。最常见的线性换算FOR iIndex : 1 TO 8 DO arrEngValue[iIndex] : (arrRawValue[iIndex] - nRawLow) / (nRawHigh - nRawLow) * (fEngHigh - fEngLow) fEngLow; END_FOR;这里nRawLow和nRawHigh是仪表量程对应的原始值范围fEngLow和fEngHigh是工程值范围。把这些参数也声明成数组就能实现每个通道独立量程FOR iIndex : 1 TO 8 DO arrEngValue[iIndex] : (arrRawValue[iIndex] - arrRawLow[iIndex]) / (arrRawHigh[iIndex] - arrRawLow[iIndex]) * (arrEngHigh[iIndex] - arrEngLow[iIndex]) arrEngLow[iIndex]; END_FOR;以后现场换了一台不同量程的表你只改arrRawLow[iIndex]和arrRawHigh[iIndex]两个数组元素即可每个通道独立互不影响。我刚入行时用梯形图改量程改32路信号要用几个小时核对地址用数组之后这个工作量变成了“读一行INIT表格”。这种对比做过一次就不会再想回去了。4.3 数据归档把一维数组写进历史记录块当采集系统和上位机或者MES系统对接时常常需要把一整批数据按帧整理成数据块上传。有的平台支持专用的块搬运指令比如三菱的BMOV西门子的MOVE_BLK但用FOR循环写数组搬运依然是兼容性最好的做法尤其当数据不是连续内存或需要加入校验规则时。(* 把8个温度值和8个状态字打包成数据块 *) FOR iIndex : 1 TO 8 DO arrDataPackage[iIndex] : arrEngValue[iIndex]; arrDataPackage[iIndex 8] : INT_TO_REAL(arrStatus[iIndex]); END_FOR;数据的组织方式由你的自定义协议决定FOR循环在这里扮演的角色是“顺序承包人”——它保证了你对数据块每个位置的填充逻辑是一致的。如果后续协议改了把第8个通道的状态字挪到第18个位置修改一行数组下标运算即可。5. 批量采集高频踩坑记录数组越界、扫描超时、动态长度5.1 数组越界循环上界和声明长度对不上这是ST项目里最常见的崩溃来源。典型场景程序里数组声明是ARRAY[1..6]循环写着FOR iIndex : 1 TO 8运行到第7次时访问了不存在的元素。后果取决于平台——有些PLC在下一次循环扫描时才报错停机有些干脆直接进入STOP模式。排查这类问题技巧是先怀疑循环上界再怀疑数组声明。我自己的习惯是凡是数组循环必须把数组上界定义成一个符号常量或配置变量并用它同时控制声明和循环VAR CONSTANT cMaxStation : INT : 8; END_VAR VAR arrTemp : ARRAY[1..cMaxStation] OF REAL; END_VAR FOR iIndex : 1 TO cMaxStation DO arrTemp[iIndex] : 0.0; END_FOR;这样声明和循环天然一致改设备数量时只动这一处彻底消灭“数组改大了循环忘改了”这类低级事故。5.2 循环体里等“OK”从通信死循环到看门狗复位的教训第一次写串口仪表批量采集时我犯过一个特别典型的错误。当时接了一批带串口输出的仪表数据帧以OK结尾表示校验通过我满心欢喜地在ST里写了这么一段FOR iIndex : 1 TO 8 DO xCommandSend : TRUE; WHILE xOKFlag FALSE DO // 等待仪表返回OK END_WHILE; arrValue[iIndex] : nReadValue; END_FOR;结果程序一运行PLC直接停机报警原因是扫描周期超时看门狗复位。这个问题的本质是PLC程序不是无限等待外部设备的程序它是按固定周期反复扫描的实时系统。在循环体内等OK等于把整个PLC钉死在那个循环里所有其他控制逻辑全部停摆。正确的做法我在3.3节已经演示了把“发送请求 → 检查OK → 继续下一站”拆成三个扫描周期内的步骤用状态标志驱动。每轮扫描只做一件事没OK就计数超时超时则放弃该站继续下一站。不管你是连Modbus、串口、还是以太网凡是涉及“询问—应答”的批量采集一律不要用阻塞等待。5.3 循环变量被“聪明”地修改之后还有一类隐藏很深的问题在循环体内修改循环变量或者把循环变量的值赋给别的变量后继续使用。比如想“跳到第4个元素跳过处理”直接写FOR iIndex : 1 TO 8 DO IF iIndex 3 THEN iIndex : 4; (* 危险做法 *) END_IF; END_FOR;这在不同编译器上的行为完全不同有的直接死循环有的结果违背预期。正确做法是用条件判断跳过处理逻辑而不是改循环变量FOR iIndex : 1 TO 8 DO IF iIndex 3 THEN CONTINUE; (* 跳到下一次循环不执行下面的代码 *) END_IF; arrValue[iIndex] : 0.0; END_FOR;ST标准里CONTINUE继续下一次循环和EXIT跳出整个循环是安全的结构化跳转能用这两个关键字解决的需求就不要去动循环变量本身。5.4 运行时动态改变采集数量上限保护不可省前面提过数组上界必须是常量但实际生产又要支持“今天用6台明天用9台”的动态需求。常见的折中方案数组声明为最大可能数量程序里用一个nActiveCount变量表示当前实际使用数量FOR循环用这个变量作上界FOR iIndex : 1 TO nActiveCount DO arrTemp[iIndex] : ReadTemperature(iIndex); END_FOR;此时必须加一条保险nActiveCount 的赋值要限幅不能超过数组声明的最大上界。否则操作员在HMI上误填一个20程序立刻数组越界。我通常会在赋值处写nActiveCount : MIN(nMaxDevice, nHmiInputValue);这样即使HMI输入异常程序也只是按最大设计能力运行不会崩。6. 从一维到二维多设备多参数的矩阵式批量采集6.1 二维数组怎么声明和遍历当8台设备每台要采集温度、压力、流量、液位四个参数时一维数组的“8个温度”“8个压力”维护起来就开始繁琐了。更合理的载体是二维数组VAR arrProcessData : ARRAY[1..8, 1..4] OF REAL; (* 8个站点4个参数 *) END_VAR第一维设备编号第二维参数编号。采集和处理的循环改成嵌套结构FOR iDevice : 1 TO 8 DO FOR iParam : 1 TO 4 DO arrProcessData[iDevice, iParam] : ReadProcessValue(iDevice, iParam); END_FOR; END_FOR;嵌套循环执行顺序是外层固定一个设备号内层把4个参数全部读完然后外层切换到下一个设备。在ST里这种“行优先”的访问顺序和数组内存排布方式一致效率上也更友好。6.2 二维数据矩阵的批量换算与质量判断二维数组真正的优势在批量后处理时体现得淋漓尽致。给8台设备的4个参数分别做量程换算和越限判断只需嵌套循环加一个参数类型判断FOR iDevice : 1 TO 8 DO FOR iParam : 1 TO 4 DO // 量程换算 arrEngData[iDevice, iParam] : (arrRawData[iDevice, iParam] - arrRawZero[iDevice, iParam]) / (arrRawSpan[iDevice, iParam]) * (arrEngSpan[iDevice, iParam]) arrEngZero[iDevice, iParam]; // 越限判断 IF arrEngData[iDevice, iParam] arrHighLimit[iDevice, iParam] THEN arrAlarmFlag[iDevice, iParam] : TRUE; ELSE arrAlarmFlag[iDevice, iParam] : FALSE; END_IF; END_FOR; END_FOR;这里每一行读起来都很“重”但放在嵌套循环里32个通道8乘4的运算一次完成。如果需要往报警逻辑里增加“参数优先级”再增加一个二维数组存优先级权重循环逻辑结构不会变。6.3 指针数组到底要不要用什么时候用做ST批量采集的工程师多多少少会遇到有人提“指针数组”尤其是有C语言背景的开发者在TwinCAT或者CODESYS里干活时。我的建议是多数场景不要用数组加索引已经足够。ST里数组的访问本身就是“基址加偏移”的寻址方式你写出arrValue[iIndex]时编译器已经在做间接寻址了显式指针通常只带来额外的调试复杂度和越界风险。真正需要指针或REFERENCE的场景一般是算法要求动态切换目标数组例如同一套处理逻辑这一轮处理A线数据下一轮处理B线数据。这种需求在TwinCAT里可以用REFERENCE TO ARRAY实现但可维护性并不好。我采用过的更保守方案是把A线和B线都声明成二维数组的两个“页”处理时用一层循环加一个“页号”变量控制实际上就是三维数组。这样不用指针逻辑也完全清晰还方便上位机把两线数据一并存档。7. FOR循环采集的边界思考什么时候循环是错的写了这么多FOR循环的应用我想补充一个“什么时候别用”的思考这部分是很多教程不会告诉你的。FOR循环强调的是对已知数量元素的遍历它解决的是“已知多少个挨个处理”的问题。如果你的采集数量在生产过程中动态变化很大比如从0到几百台设备随机接入那更适合的数据结构是队列或链表ST里对应的是FIFO指针块而不是固定长度数组。另外大量嵌套循环会显著拉长扫描周期。假设你有20台设备、每台10个参数、每个参数的滤波窗口是50次采样三层循环的运算量是20乘10乘50等于10000次浮点运算。对大多数PLC来说这个量级还能接受但如果你在循环里调用通信功能块或者文件读写时间就会指数级恶化。我通常的做法是把“采集I/O操作”和“信号处理纯运算”拆成两个独立任务块采集用状态机驱动信号处理才用循环密集型计算两者按不同的周期执行互不阻塞。还有一个在工程上容易被忽视的细节不要在FOR循环里直接写HMI显示缓存的数组。显示刷新频率往往远低于采集频率循环内每次更新显示数组既浪费资源又可能导致画面闪烁。更好的做法是让采集循环只写采集缓冲区再由一个慢周期任务把最新值搬运到显示区。同样是FOR循环不同用途分配到不同的任务周期里系统的实时性才会更健康。回头看我这些年在批量采集上的经历真正让我工作效率质的飞跃的不是某个花哨指令而是“把现场设备的编号、地址、量程、限值全部数组化再用FOR循环驱动同一套处理逻辑”这个朴素的结构。它让程序设计从“描绘每个元件的连接”变成了“定义集合和规则”程序篇幅缩短修改成本下降排查问题时的思路也肉眼可见地清晰。下一篇我会具体拆解如何用ST数组实现Modbus总线上各从站的无冲突轮询时序以及轮询周期和设备数量之间的匹配计算感兴趣的话可以先在项目里把数组声明和FOR循环跑熟这一步是所有进阶玩法的基础。