
这些年调试各类自动化项目我碰到的数据采集程序十有八九是两种画风。一种是一句一句手写通道少还行通道一多代码长得像流水账改一个通道要翻半天另一种是熟练工的写法先把通道信号统一放进一个数组容器再套一个FOR循环批量搬运代码量缩到十分之一不说顺手还能把标定、滤波、报警这些杂活一同处理掉。今天要聊的正是后者在ST语言结构化文本里数组和FOR循环怎么组合出工程价值尤其适合做多通道批量采集的PLC工程师以及刚上手ST、想找一套可直接复用的批量采集写法的朋友。我说的“批量采集”不是要把代码写得花哨而是要解决现场的三个真痛点通道多、数据杂、处理要求快。ST语言下笔干脆数组负责把数据成片装进来FOR循环负责用同一套规则成片处理两者配合几乎就是为这类需求准备的。这篇内容没有平台限制CODESYS、博途SCL、欧姆龙NJ/NX系列这些常见的IEC 61131-3环境都适用核心思路一致只有个别的地址访问写法略有差异。1. ST数组实战先把数组的底层逻辑摸透1.1 把数组理解成一块连续编址的内存是ST的第一课很多人写ST数组时只记语法不理解背后到底发生了什么。其实PLC里的数组本质上就是一块连续编址的内存区域按偏移量访问。比如声明了ARRAY[0..7] OF INT编译器就划出8个连续数据格子每个格子存一个INT访问data[3]时CPU按“基地址 3 × 元素字节数”直接算出物理地址所以数组访问的速度是确定的、可预测的。这个“确定性”在工业环境里非常值钱。设备采集的场景比如8通道温度、16通道压力、32通道振动它们的信号来源是连续的模拟量输入字或者总线从站连续映射的输入数据天然就是按物理通道顺序排列的。用数组来承接这串数据可以让“第i个通道”和“数组下标i”一一对应程序结构马上清晰。这和在Excel里批量提取两列数据做成数组有些相似数据本身有规律你只需要告诉程序“按什么规则装进表格”。ST里这个“表格”就是数组区别在于它更底层、更快也更需要你管好边界。还有一个常见误区是ST的数组下标不一定从0开始你可以写ARRAY[1..8] OF INT下标从1到8。这跟C语言思维不太一样C转过来的工程师特别容易踩坑以为声明了1..8之后还能访问arr[0]结果直接越界。1.2 声明数组时多花一分钟想清楚边界后面少花一小时排查ST数组声明的基本格式是ARRAY[上界..下界] OF 数据类型上下界必须是整数常量不能用变量。举几个典型声明VAR // 最常见的0起始数组适合通道编号从0开始的场景 aiRaw : ARRAY[0..7] OF INT; // 自定义上下界适合通道号从1开始的工艺习惯 tempMatrix : ARRAY[1..4, 1..6] OF REAL; // 二维数组可用于按设备分组的数据缓存 deviceData : ARRAY[0..3, 0..15] OF WORD; END_VAR这里有个关键建议除非有特殊理由数组下界尽量用0。原因很简单当你写FOR循环和后续地址计算时Array[i]里的i从0开始跟C语言、Python的习惯一致和外部通信协议里的字节偏移也更好对齐。用1起始的数组每次循环都要纠结是FOR i : 1 TO 8还是FOR i : 0 TO 7稍不留神就是错位。声明数组时还有个容易被忽略的点数组初始化。很多PLC编译器在变量声明后默认清零但如果你在FB里声明了一个局部数组而FB没有掉电保持的设定那么CPU每次上电或者FB每次复位时数组里的历史值可能不是0而是一块随机残留数据。稳妥做法是显式初始化比如在启动时用FOR循环清一遍或者用数组字面量赋初值。部分较新的ST环境支持arr : [1,2,3,4,5,6,7,8]这种写法但老版本PLC编译器可能不支持建议用循环或MOVE指令来初始化兼容性更好。2. FOR循环批量数据处理的发动机2.1 FOR循环的执行顺序边界只在进入时计算一次ST里的FOR循环语法长这样FOR i : 0 TO 7 DO aiRaw[i] : ReadChannel(i); END_FOR;默认步长是1如果想每次跳2个元素可以写成FOR i : 0 TO 7 BY 2 DO。这个结构和C语言的for几乎对应但有个重要细节ST的FOR循环在进入时会把起始值、终值和步长计算一次之后循环体的执行就是纯粹的“判断当前值是否越过终值→执行循环体→循环变量增加步长”三步循环。如果你在循环体里修改了终值变量比如把循环边界变量改小了对不起ST不会重新计算边界循环还会按原来的终值跑完。这个特性在工程上既是优点也是陷阱优点是不会因为循环体内改动边界而出错陷阱是你想“边跑边缩短循环”来实现某种动态裁剪那是做不到的。迭代变量i通常声明为INT或DINT。我建议批量采集场景下用INT因为通道数一般不超过几百INT足够。大循环或者需要很大计数范围时再用DINT。循环体内不要随意给迭代变量赋值虽然有些编译器允许但这是制造诡异错误的温床你可能会不小心把i重置为0然后程序陷入长时间循环现场看门狗直接报警。2.2 为什么批量采集首选FOR而不是手写并联说到批量采集总有人问通道就8个我手动写8行也能搞定为何非要用FOR我的看法是手动写8行确实能跑但工程场景下最大的成本是后续维护和变通。举个例子你手写8行读取语句此时现场把采集通道从8路改成了16路你需要在代码里再复制粘贴8行改地址、改变量稍不留神就漏改一行或者改错一个偏移量。用FOR循环就不一样FOR chIndex : 0 TO CH_COUNT - 1 DO aiRaw[chIndex] : ReadChannel(chIndex); END_FOR;将来改通道数只需要改CH_COUNT这一个常量。而且当你给每个通道都做同样的标定、量程变换、滤波处理时FOR循环的批量优势就更明显。人工写并联代码时同样一个运算写8遍改系数要改8遍漏改一个通道都不好发现用FOR循环系数只在循环体内出现一次所有通道同步更新。这不是说手写一无是处。如果通道之间有完全不同的处理逻辑每个通道都要单独写判断那硬套FOR循环反而别扭。FOR循环适合的是“同构操作”也就是每个通道的处理方式一致。所以在动手写代码前先问自己一句这批数据是不是同一类信号如果是FOR循环就是最优解。2.3 循环体耗时你看不见的扫描周期黑洞ST毕竟是PLC里的顺序执行语言FOR循环不会因为用了循环就变快它真实的执行时间等于“迭代次数 × 单次循环体耗时”。这个时间在毫秒级但如果在OB1主扫描循环里跑一个大循环循环里再做浮点运算、字符串操作、甚至通信访问主循环周期就可能被拖长导致看门狗超时。我在项目里见过这样一个坑有人在一个中断里做了两层嵌套循环外层1000次、内层50次然后每个内层循环还调用了通信读写函数结果中断执行时间直接冲到了几百毫秒整个程序变得像“卡住”一样。排查了半天才发现是循环次数过多加上IO访问在等待应答时把时间耗掉了。所以批量采集场景里循环次数要克制。50个以内的通道直接循环没问题几百个点的高速采集就要考虑分批处理或者放到专用的循环中断任务里上千个点且带复杂运算的不建议放在PLC上硬算更好的做法是把原始数据打包成数组通过MODBUS、EtherNet/IP、OPC UA等通信方式交给上位机或数据库处理。ST语言里数组和循环帮助你高效组织数据不代表你要用它们把PLC变成一台超算。3. 批量采集实战搭建一套多通道数据采集器3.1 8通道模拟量批量采集可直接套用的完整代码前面说了一堆理论现在来点能直接抄的。下面是一个典型的多通道模拟量批量采集功能块FB负责把当前批次所有通道的原始数值存入一个数组并置位完成标志。以8通道为例FUNCTION_BLOCK FB_AIBatch VAR_INPUT bExecute : BOOL; // 触发一次批量采集 END_VAR VAR_OUTPUT aiRawValues : ARRAY[0..7] OF INT; // 原始的模拟量输入值 bDone : BOOL; // 采集完成标志 END_VAR VAR i : INT; // 循环变量 END_VAR IF bExecute THEN FOR i : 0 TO 7 DO aiRawValues[i] : ReadAnalogChannel(i); // 按通道号采集 END_FOR; bDone : TRUE; END_IF;这里的ReadAnalogChannel是一个示意函数实际写法根据平台来。西门子博途里可以直接读I/O地址比如aiRawValues[i] : %IW256 i * 2前提是模块起始字地址是256每个通道占一个字CODESYS环境里通常I/O映射后直接用映射变量或者用AT %I*地址访问。关键一点是通道地址必须是连续排布的。如果硬件接线时模拟量通道是乱序的那就必须在采集循环外面先建立一个通道号映射表把物理通道映射到逻辑通道再用逻辑顺序填充数组。这个FB设计里有两个细节值得注意。bDone不能用组合逻辑赋值必须等到循环完整执行完才置位这样外部程序看到bDone为TRUE时数组里已经是一整批完整数据。另外FB内部用了实例变量i不要在主程序里同时操作同一个变量否则可能互相干扰。3.2 采集不是目的批量标定、滤波和限幅一起做把原始值装进数组只是第一步。现场模拟量信号多数要经过处理才能用。比如4-20mA电流信号在PLC里读到的原始值是一个整数西门子常见是0~27648部分设备是0~65535你需要把它换算成对应的工程量比如0~100度、0~10MPa。这就是标定。用循环批量标定非常直观FOR i : 0 TO 7 DO engValue[i] : (aiRawValues[i] - rawMin[i]) * (engMax[i] - engMin[i]) / (rawMax[i] - rawMin[i]) engMin[i]; END_FOR;紧接着做滤波。工程上最常见的一阶惯性滤波本质上是一条语句filtered[i] : filtered[i] (engValue[i] - filtered[i]) * filterFactor;filterFactor取值在0到1之间越小滤波越重、响应越慢越大响应越快但噪声抑制差。批量采集场景中你可以在同一个FOR循环里把“标定→滤波→限幅→置报警标志”全部串起来FOR i : 0 TO 7 DO // 标定 engValue[i] : Scale(aiRawValues[i], config[i]); // 滤波 smoothValue[i] : smoothValue[i] (engValue[i] - smoothValue[i]) * CFG_FILTER_FACTOR; // 上限报警 IF smoothValue[i] highLimit[i] THEN bAlarm[i] : TRUE; ELSE bAlarm[i] : FALSE; END_IF; END_FOR;一个循环下来8个通道的原始值就变成了带滤波、带报警状态的工程值。效率高是一方面更重要的是逻辑集中所有通道的处理规则一模一样后期调整滤波系数或限幅值只需要改配置数组里的数据不用改程序结构。这就是数组循环在批量采集场景里最迷人的地方。3.3 双缓冲让采集和处理各干各的活批量采集里还有一个工程细节叫做“采集静止性问题”。什么意思假设你每秒做一次采集每次把数据写入数组A同时在另一个程序段里读取数组A来做计算。如果读取时刻正好撞上写入时刻读到的可能是一半新数据、一半旧数据计算结构彻底错乱。解决思路是用双缓冲工程上叫乒乓缓冲。准备两块一样的数组一个用于当前周期写入采集数据另一个用于上一周期的数据处理。交替使用// 采集周期 IF bUseBufA THEN FOR i : 0 TO 7 DO bufA[i] : iRawValue; END_FOR; ELSE FOR i : 0 TO 7 DO bufB[i] : iRawValue; END_FOR; END_IF bUseBufA : NOT bUseBufA; // 处理周期永远处理另一块 IF bUseBufA THEN ProcessBuffer(bufB); ELSE ProcessBuffer(bufA); END_IF这样采集逻辑永远写入当前空闲缓冲区处理逻辑永远读取上一批次完整数据两边互不干扰。代价是多一倍的数组内存但换来的是数据一致性。对于批量采集频率较高、或者数据处理链路较长的项目这个方案非常值得。很多人一开始嫌麻烦不做双缓冲真到现场数据偶尔跳变、曲线毛刺的时候排查一圈才发现是读写竞态那才叫得不偿失。4. 工程常见问题与排查技巧实录4.1 采着采着就超时循环中断和OB1的取舍批量采集最容易出的事故是在OB1主扫描循环里放了大批量循环导致扫描周期被拉长。PLC扫描周期的看门狗时间是有上限的超出就报故障甚至停机。我接过一次现场电话说设备总是无规律停机频率大概几分钟一次。远程看程序发现有人在OB1里做了两层循环外循环1000次内循环还要调用Modbus通信单次扫描时间被拉到300毫秒以上于是周期性地触发看门狗。正确做法是把真正的批量采集程序放到定时循环中断里比如西门子的OB30/OB35或者CODESYS的任务配置里设定一个周期任务。采集周期在任务配置里设定比如每50毫秒执行一次中断程序里只做“读通道→写数组→置新数据标志”这些轻量工作。主程序负责消费采集结果不负责采集本身。如果循环次数确实很多还可以考虑分批次。比如64个通道不一次全采而是每个扫描周期只采8个通道8个周期轮完一圈。这样单次执行时间被摊薄整体数据实时性略有下降但程序的运行稳定性提升。现场验证过通道数多、PLC性能弱时这个改动往往立竿见影。4.2 越界与错位最常见的两个数据坑数组越界是ST编程里非常经典的错误。数组声明为ARRAY[0..7] OF INT循环却写成FOR i : 0 TO 8最后一次就是越界。在博途和CODESYS里越界可能在编译期不过也可能在运行时产生错误直接让任务停止。更讨厌的是某些老版本编译器不报越界而是悄悄写到数组相邻内存区域覆盖别的变量造成“神秘变量被篡改”的诡异现象。地址错位则更隐蔽。比如模拟量模块的通道数据如果是按双字对齐的而你按单字寻址去读就会采到错的数据。不同厂商的模块映射规则完全不一样有的模块4-20mA在0~27648区间有的在0~65535区间甚至有的还把符号位放进去。因此任何批量采集程序上线前都要用标准信号源给每个通道输入已知值然后连线验证数组里各下标对应的实际通道号。这种验证虽然繁琐但值得做一次因为通道一旦错位后面所有工程量的正确性都是空中楼阁。4.3 嵌套循环的退出EXIT救不了你ST里跳出循环的关键字是EXIT。它可以退出当前所在的这一层循环但如果你写的是三层嵌套循环最内层执行一个EXIT程序只是跳出最内层外层循环照样继续。很多新手以为一个EXIT能一步退出所有循环然后发现程序行为完全不符合预期。多层循环需要从内层全部退出时我的习惯是定义一个布尔变量比如bExitAll内层满足退出条件时置TRUE并EXIT外层循环每次开始检查这个标志为TRUE就EXITbExitAll : FALSE; FOR i : 0 TO 9 DO IF bExitAll THEN EXIT; END_IF; FOR j : 0 TO 9 DO IF 退出条件 THEN bExitAll : TRUE; EXIT; END_IF; END_FOR; END_FOR;这套写法不依赖平台逻辑一眼可见后续维护也容易。不要试图在ST里找类似C语言的goto或break到指定层级的语法做不到的不如老老实实用标志位控制。4.4 问题排查速查表把常见故障一次说清现场现象可能原因处理建议程序周期性报看门狗超时主循环里嵌套大循环或通信读写阻塞把采集移到定时中断或分批处理循环数组变量被莫名篡改数组越界写入相邻内存区域检查所有循环边界加上界访问检查选项采到的数据整体偏大或偏小原始值量程与标定参数不匹配核对硬件模块量程和PLC原始值区间数据偶发跳变、毛刺明显滤波系数过大或没有滤波增加一阶滤波调低filterFactor部分通道的数据顺序错乱通道映射表没有建立或地址对齐错误用信号源逐通道验证映射关系处理程序读到半新半旧数据采集写入与处理读取共用同一数组升级为双缓冲结构排查时有个简单技巧先确认数组里的原始值对不对再谈标定、滤波。如果原始值已经错位或跳动后面的工程值一定不对。很多人在滤波和标定里反复横跳其实问题在最上游的采集阶段。5. 进阶心得把数组和循环用得更有工程感5.1 用常量和枚举管住数组的边界批量采集工程里数组长度最好是常量而不是裸数字。设一个常量VAR CONSTANT CH_COUNT : INT : 8; FILTER_WINDOW : INT : 32; END_VAR然后声明数组时写ARRAY[0..CH_COUNT - 1] OF INT循环时写FOR i : 0 TO CH_COUNT - 1 DO。这样整个程序里只存在一个“8”。下次现场加通道你只需要改这个常量所有数组声明、循环边界、标定配置全部自动跟着走不担心漏改。更进一步如果通道对应不同的物理设备可以声明枚举类型用枚举值做数组下标。比如TYPE E_Channel : ( CH_TEMP_1, CH_TEMP_2, CH_PRESS_1, CH_LEVEL_1 ); END_TYPE然后数组下标用枚举values[CH_TEMP_1]。这样写代码可读性极好别人看程序时不至于对着一个裸数字发愁“0到底是哪个通道”。不过要注意枚举在作为数组下标时不同编译器支持程度不同建议先测试你手里的环境是否支持这类直接索引写法不支持的话退化成“用常量定义通道号”。5.2 连续采集不覆盖环形缓冲在ST中的实现批量采集如果做在高速场景里比如振动信号、波形录波每一次采集周期都可能产生新数据而处理端未必能实时消费掉。这时候就需要环形缓冲。ST里实现环形缓冲很简单核心就是用数组加一个写指针指针越界后自动回绕VAR ringBuffer : ARRAY[0..99] OF INT; writeIdx : INT; sampleCount : INT; END_VAR // 写入 ringBuffer[writeIdx] : currentValue; writeIdx : writeIdx 1; IF writeIdx 100 THEN writeIdx : 0; END_IF; IF sampleCount 100 THEN sampleCount : sampleCount 1; END_IF;读取端按照“最早写入优先”的顺序从writeIdx或writeIdx - 1往回读每次读取后更新读指针。这种结构本质上就是数据结构里的循环队列数组是存储体两个整型变量维护读写位置和当前长度。很多ST环境支持MOD运算所以写指针回绕也可以直接写成writeIdx : (writeIdx 1) MOD 100两种写法都行我习惯用IF判断因为逻辑更直白。环形缓冲在批量采集场景里最大的价值是你可以无压力地把采集端和处理端解耦。采集端负责写处理端负责读谁快谁慢都不会把另一方的数据冲掉只要读写速度差的累计不超过缓冲区大小数据就是安全的。这比手动控制采集节奏要省心得多。5.3 小型PLC上的算法克制排序与查找够用就好数组和FOR循环的另一个常见用途是排序和查找。ST语言里没有现成的Arrays.sort()之类的API所以需要自己写排序算法。小型PLC上不要追求复杂度低的快排、归并这些递归或分治算法在PLC上性能不稳定栈深度也有限。选择排序、插入排序、冒泡排序这类简单算法配上数组规模不超过几十上百个元素完全够用。以插入排序为例FOR i : 1 TO N - 1 DO temp : data[i]; j : i - 1; WHILE (j 0) AND (data[j] temp) DO data[j 1] : data[j]; j : j - 1; END_WHILE; data[j 1] : temp; END_FOR;如果需要在数据里查找某个值线性遍历配合数组的规律性就已经足够。树状数组、线段树这类高级结构在PLC上基本用不上。PLC的数组规模通常有限而树状数组引入的维护逻辑复杂度远大于它带来的查询性能收益。工程上我更建议把精力花在“数据如何采集、如何保证一致性”上而不是在PLC里追求算法复杂度。真要处理海量历史数据把数组通过通信协议抛给上位机用数据库或Python去分析那才是把工具用在了正确的地方。我自己在批量采集上摸爬滚打几年逐渐形成了一套固定写法数组边界一律从0开始长度全部用常量定义循环变量只在循环头使用不在循环体内改采集、标定、滤波、报警四件事在同一个循环里串行处理采集端和处理端之间用双缓冲或环形缓冲隔离别让PLC在OB1里跑大循环。这套写法谈不上惊艳但每一次都在现场给我省了真金白银的调试时间。如果你也在和ST数组、FOR循环打交道建议先照这个框架搭一版再在这个基础上按自己的工艺需求调细节效果不会差。