
前阵子做了一条老化测试线的改造。现场有20台加热箱每台对应的温度变送器输出4-20mA信号进PLC模拟量模块上位机要求每100ms刷新一次全部温度并画趋势曲线。原程序是梯形图写的每个通道单独读原始值、单独做工程量转换几十个网络把重复逻辑铺了一地。我接手后把整段逻辑换成了ST结构化文本来实现——核心就是一个数组加一个FOR循环做批量采集原先几十个网络压缩到不到十行代码量直接少三分之二。后面再叠加均值滤波、越限报警、历史趋势缓冲全是数组配FOR循环的组合戏。这篇文章就把这套思路完整拆开讲。先说说为什么ST和数组、FOR循环天生适合干“批量采集”这活然后从数组声明讲起中间放一个完整的模拟量通道采集案例再聊几个我实测中踩过最深的坑最后补上二维数组、结构体数组这些进阶玩法以及调试监控时的经验。不管你是刚学ST的新手还是从梯形图往ST转的工程师应该都能找到可以直接抄作业的东西。1. 为什么是ST数组FOR循环从一次产线改造说起1.1 从一段让人崩溃的重复代码说起接着开头那段故事。我打开旧程序看的时候发现整个逻辑大概是这么个味道1号通道读模拟量字、乘系数、存到DB2号通道读模拟量字、乘系数、存到DB3号通道读模拟量字、乘系数、存到DB……一直重复到20号通道从%IW100到%IW138一个通道两三个网络20个通道就得几十个网络。如果通道数量固定也就算了问题是产线后面还要加加热箱每加一台就得复制粘贴一批网络改完地址还得反复核对有没有抄错。这种代码看着就想摔键盘。这类问题的本质是什么数据本身具备“同构性”。每个通道的采集逻辑完全一致区别只是地址和索引不同。梯形图的思维方式是“一个通道写一段”而数组加FOR循环的思维方式是“所有通道共用一个逻辑用下标区分谁是谁”。后者天然适合处理同构批量数据。1.2 ST语言是什么它和梯形图、C语言的关系ST全称Structured Text结构化文本是IEC 61131-3标准里的六种PLC编程语言之一。语法长得像Pascal也像简化版的C变量先声明然后用赋值、IF、CASE、FOR、WHILE这些结构化语句写逻辑。很多工程师对ST有个误解觉得梯形图才是主流ST只是偶尔写算法用的。实际上在批量数据处理、通信报文解析、数组运算、PID整定调试这些场景里ST的表达效率远高于梯形图。打个比方梯形图是手动螺丝刀ST是电动螺丝刀区别只在省不省力不在能不能拧螺丝。如果你有C、Java、Python的基础ST几乎不存在学习门槛。但是要注意ST是强类型语言INT赋值给REAL必须显式转换这一点比Python严格得多和C的隐式转换规则也不一样写的时候经常会卡在类型转换上。1.3 批量采集到底在采什么三种最典型的数据源从实际项目经验看需要“数组FOR循环批量采集”的场景不外乎三类第一类是模拟量通道。温度、压力、流量变送器进AI模块每个通道一个原始值批量读出来再统一做工程量转换。第二类是通信报文。Modbus、CANopen、串口、以太网收到的寄存器数据或报文帧一次来几百个字节要用数组接着FOR循环再逐字段解析。第三类是设备状态集合。几十台从站或者几十个IO状态字每台设备的状态位、故障码要集中存放循环汇总判断。这三类的共同特征是数据量随系统规模线性增长但处理逻辑完全一致。这种结构最适合用数组做容器、FOR循环做搬运工。你与其让代码量跟着设备数量同步膨胀不如让代码复杂度保持恒定——不管接10台还是100台设备逻辑代码就那一份。2. ST数组声明与初始化的冷知识别在第一条踩坑2.1 数组声明语法与上下界的灵活性ST数组的声明比C语言直观得多基本格式是这样VAR arrInt : ARRAY[0..9] OF INT; // 10个整数下标从0开始 arrReal : ARRAY[1..24] OF REAL; // 24个实数下标从1开始 matrix : ARRAY[1..3, 1..4] OF UINT; // 二维数组3行4列 END_VAR很多从C转过来的人会觉得不适应数组下界为什么不需要是0这是IEC 61131-3的规矩数组的上下界可以自定义。好处是下标能和实际物理编号对齐。比如现场温度通道编号是1到20那我直接定义ARRAY[1..20] OF REAL采集代码里用通道号当下标不用来回做“物理编号减一”的换算。我自己的使用习惯是这样的对外通信缓冲区的数组统一用ARRAY[0..N-1]因为通信协议本身就从0开始排列对应物理设备通道的数组优先用ARRAY[1..N]让通道号等于数组下标直观不易错。这个习惯看着不起眼等你写了几百个通道的采集代码就能体会到差异。2.2 初始化与批量清零的姿势数组初始化得分两种情况。第一种是声明时就给初值标准IEC 61131-3支持这种写法但不同品牌的支持程度不一样VAR counts : ARRAY[0..9] OF INT : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; END_VAR第二种是运行前用FOR循环统一处理这也是我最常用的方式FOR i : 0 TO 9 DO counts[i] : 0; END_FOR注意这里的关键点循环的上下界和数组的上下界必须严格对应。如果数组是ARRAY[1..20] OF REAL循环就要写成FOR i : 1 TO 20不能像C语言的习惯写FOR i : 0 TO 19。这类错误很隐蔽——下标从0开始取数组长度又刚好20不会触发越界保护但第一个通道的数据灌进了下标0这个“既不合法又没越界”的位置整个数据全错位了。排查起来非常难受。2.3 元素类型选择INT还是DINT还是REAL数组元素类型直接决定数据精度和内存占用。我平时参照这个规则选型数据类型使用场景备注BOOL状态位集合、报警字位提取配合位运算INT原始字数据、计数器、Modbus保持寄存器模拟量原始值常用DINT长时间累计、批次计数防止32位以内溢出REAL工程量数值温度、压力、流量转换后存储用WORD通信原始报文、状态字做按位处理更方便自定义结构体多字段数据聚合详见第5章ST是强类型语言INT装不下REAL不能在同一个数组里混装原始值和工程值。我的惯用做法是维护两份数组原始值数组存INT工程值数组存REAL用同一组下标对应。虽然多占一点内存但排查问题的时候很清晰——一眼就能看出是通道原始数据坏了还是工程量转换逻辑错了。3. FOR循环批量采集的核心实现模拟量通道组实操3.1 场景与变量设计用一个最常见的实战场景来演示完整实现。假设现场有24路温度采集模拟量模块返回的原始值在PLC内部表现为INT地址从IW100开始连续排布原始值0~10000对应物理温度0~200℃。变量设计如下VAR aiRaw : ARRAY[1..24] OF INT; // 原始值数组 tempVal : ARRAY[1..24] OF REAL; // 工程温度数组 sumTemp : ARRAY[1..24] OF REAL; // 求和缓冲均值滤波用 avgTemp : ARRAY[1..24] OF REAL; // 最终平均值 ch : INT; // 通道循环变量 s : INT; // 采样次数循环变量 END_VAR核心设计原则就是让物理通道号和数组下标完全一致。1号温度变送器接模块1通道数据存进aiRaw[1]后续做报警、趋势、报表直接引用同一个下标省去一堆映射转换。3.2 核心循环代码采集、转换、入数组一次完整循环采集的ST代码如下// 批量读取原始值 FOR ch : 1 TO 24 DO aiRaw[ch] : %IW99 ch; // 示意访问方式不同品牌有差异 END_FOR // 批量工程量转换 FOR ch : 1 TO 24 DO tempVal[ch] : INT_TO_REAL(aiRaw[ch]) / 10000.0 * 200.0; // 线性标定 END_FOR不要直接照抄%IW99 ch不同PLC品牌的模拟量读取方式差别很大西门子用地址直接访问倍福用EtherCAT端子模块的通道地址汇川、信捷等国产平台可能要通过库函数读取。这里套路的重点是两个批量读FOR循环把物理通道原始值装进数组。批量转另一个FOR循环对每个元素做线性变换。为什么把“读”和“转”拆成两个循环而不是在一个循环里又读又转因为有些PLC的AI通道读取不是同步内存访问需要等待数据更新你一边读一边转可能碰上一次循环读到的是上一轮旧数据。拆开后逻辑更稳还方便在中间插一个原始值合理性检查。3.3 带上滑动滤波连续N次采集求均值温度这类缓变信号现场多少都带干扰最简单的滤波手段就是连续采集N次求平均。双层FOR循环就能搞定// 先清零求和缓冲 FOR ch : 1 TO 24 DO sumTemp[ch] : 0.0; END_FOR // 连续采集10轮累加 FOR s : 1 TO 10 DO FOR ch : 1 TO 24 DO sumTemp[ch] : sumTemp[ch] INT_TO_REAL(%IW99 ch) / 10000.0 * 200.0; END_FOR END_FOR // 求平均 FOR ch : 1 TO 24 DO avgTemp[ch] : sumTemp[ch] / 10.0; END_FOR性能细节值得一提内层循环每次做一次INT_TO_REAL转换和浮点运算24通道×10轮共240次在PLC上执行时间是毫秒量级。如果你采样点更多比如24通道×100轮建议先把原始值批量读到数组再统一转换处理减少重复访问外部IO口的次数。IO口访问比内存数组访问慢得多这是做性能优化时的第一优先项。3.4 配合Modbus通信的批量读取思路模拟量采集只是批量数据的一种来源实际项目里更常见的是Modbus从站采集——从变频器、电表、温控表上读一串保持寄存器。数组在这里的角色是通信缓冲区VAR mbBuffer : ARRAY[0..99] OF WORD; // Modbus保持寄存器缓冲 mbValue : ARRAY[1..50] OF REAL; // 解析后的数据 regIndex : INT; // 寄存器索引 chanIndex : INT; // 通道索引 END_VAR调用Modbus主站功能块读100个寄存器后用FOR循环按协议映射解析// 假设每个设备占2个寄存器32位浮点从1号设备开始 FOR chanIndex : 1 TO 50 DO regIndex : (chanIndex - 1) * 2; mbValue[chanIndex] : WORD_TO_REAL_COMBINE(mbBuffer[regIndex], mbBuffer[regIndex 1]); END_FOR这里有一个新手最容易犯的错在FOR循环里一个寄存器一个寄存器地调Modbus通信功能块。通信的耗时主要在握手和等待一条报文能带多个寄存器就不要拆成多次请求。正确做法是一次性把100个寄存器读进缓冲区FOR循环只负责解析内存数据。我以前见过有人循环里嵌套通信调用结果采集周期拉了十几秒通信负荷还翻倍纯属自己坑自己。4. 循环边界与数组越界实测中最容易翻车的地方4.1 数组越界真实PLC里到底会发生什么数组越界是ST入行最常见的错误可怕的不是报错而是它经常不报错。有的PLC运行时环境会把越界当成致命错误直接停机有的则静默地读到了相邻变量的内存数据——这种“不报错的越界”最阴险。我遇到过一次记忆很深的案例给一个报警字数组赋值时下标偏了一位结果把另一段程序里的关键变量给覆盖了导致某个阀门毫无规律地动作。查问题查了一整天最终发现根因在一段看起来毫无关系的数组赋值代码里。防御手段两条。第一条是物理通道号对齐法下标从一开始和现场端子编号一致。第二条是常量定义法把数组大小和循环上下界都定义成常量VAR CONSTANT CH_COUNT : INT : 24; END_VAR VAR aiRaw : ARRAY[1..CH_COUNT] OF INT; END_VAR FOR ch : 1 TO CH_COUNT DO aiRaw[ch] : readChannel(ch); END_FOR以后扩展通道数量只需要改一个常量的值。这个习惯能避免一大半的越界问题我强烈建议从现在开始就养成。4.2 循环变量类型与上下界的匹配ST的FOR循环计数器必须是整型这个约束比较严格FOR f : 0.0 TO 10.0 DO // 编译直接报错不能是REAL END_FOR另外注意FOR的结束值是包含的到上界即停。FOR i : 0 TO 9会执行10次FOR i : 0 TO 10就是11次。这种差一错误和C语言完全一致。数组有24个元素正确写法是FOR i : 0 TO 23或者FOR i : 1 TO 24看你的数组下界怎么定的。如果用第4.1节的常量法ARRAY[0..CH_COUNT-1]配FOR i : 0 TO CH_COUNT-1或者ARRAY[1..CH_COUNT]配FOR i : 1 TO CH_COUNT整套代码从头到尾保持一致。4.3 FOR循环拖慢扫描周期实测数据与优化对策批量循环最大的隐忧是循环体太耗时导致扫描周期超时。一个现代PLC做一次简单整数运算大概几百纳秒到几微秒一万次循环体也就几个毫秒到几十毫秒。听起来不大但这要放进整个扫描周期里算账——如果循环体里还有通信等待、浮点除法、数组访问耗时就完全不是一个量级了。我实测过用某一款国产中型PLC做单循环处理4000个浮点数的标定纯运算大约8ms加上外围IO访问超过15ms。如果程序扫描周期设定10ms直接超时CPU负载报警甚至可能停机。对应有三条对策大数组批处理不要放在每个扫描周期都执行的主任务里放进定时中断任务如100ms周期任务或需要时才触发的程序段。循环体能精简就精简能提到外层做的事就提前做。数据量确实大就分块处理。比如10000个点拆成每扫描周期处理1000个分10个周期完成用静态变量记录当前处理到的下标。VAR processPointer : INT : 0; // 当前处理位置 END_VAR FOR i : processPointer TO processPointer 999 DO // 处理 data[i] END_FOR processPointer : processPointer 1000; IF processPointer 10000 THEN processPointer : 0; END_IF这是很实用的分块批处理套路大数据量全部能处理完单个扫描周期又不会过长。4.4 循环中写数组的时机一个隐蔽的数据污染问题还有一个人人都踩过的坑在同一个FOR循环里一边遍历数组一边修改正在被遍历的元素。做相邻差值计算时如果处理结果直接写回原数组后面的迭代会读到已经改过的值结果完全乱掉// 错误示范边读边写后续元素用的是被污染的值 FOR i : 1 TO 23 DO raw[i] : raw[i1] - raw[i]; END_FOR // 正确示范结果写入新数组 FOR i : 1 TO 23 DO newArr[i] : raw[i1] - raw[i]; END_FOR在批量采集场景里我一般按“采集数组 → 处理数组 → 发布数组”三层管理。采集数组只由采集循环写处理数组只由算法循环写发布数组才是人机界面和上位机读的。每层各司其职数据不会互相污染。5. 进阶玩法二维数组、结构体数组与指针数组5.1 二维数组通道与批次的数据矩阵如果采集数据除了“通道维度”还要有“时间维度”就该上二维数组了。比如保存24个通道最近10轮的采样值VAR history : ARRAY[1..24, 1..10] OF REAL; // [通道, 采样批次] sampleIndex : INT; // 当前批次 ch : INT; END_VAR写入时批次下标滚动前进超出则回到开头形成环形缓冲的思路sampleIndex : sampleIndex 1; IF sampleIndex 10 THEN sampleIndex : 1; END_IF FOR ch : 1 TO 24 DO history[ch, sampleIndex] : tempVal[ch]; END_FOR这样一来历史数据在数组里形成了一个滚筒最新一帧总在sampleIndex位置旧数据被新数据逐轮覆盖。做趋势曲线的时候FOR循环顺着时间轴把history[ch, t]输出出去完全不需要额外开大文件存储。热词里提到的“数组分割并显示包含某一字符”也是同一个思路数据在结构上按批存放需要时按条件取段展示坑主要在边界判断上。二维数组访问顺序也有讲究。大多数PLC内存按行优先存放也就是说最右侧下标变化最快。写循环时让最右下标放在内层循环访问效率通常好一些FOR ch : 1 TO 24 DO FOR t : 1 TO 10 DO history[ch, t] : 0.0; END_FOR END_FOR对PLC来说这个差别是微秒级大矩阵上才明显但写代码时养成内层循环变最右下标的好习惯不亏。5.2 结构体数组一个元素携带完整设备状态只存一个数值的数组用起来简单但通道一多、每个通道还带一堆附加属性量程、上下限、报警标志、滤波结果数据管理就开始变得松散。结构体数组是这时候的正解。先定义一个结构体类型TYPE ST_TempChannel : STRUCT rawValue : INT; // 原始值 engValue : REAL; // 工程值 highLimit : REAL; // 高报警限 lowLimit : REAL; // 低报警限 alarmFlag : BOOL; // 报警状态 enable : BOOL; // 是否参与计算 END_STRUCT END_TYPE再声明结构体数组VAR channels : ARRAY[1..24] OF ST_TempChannel; END_VAR使用起来非常自然FOR ch : 1 TO 24 DO channels[ch].rawValue : %IW99 ch; channels[ch].engValue : INT_TO_REAL(channels[ch].rawValue) / 10000.0 * 200.0; IF channels[ch].enable AND channels[ch].engValue channels[ch].highLimit THEN channels[ch].alarmFlag : TRUE; END_IF END_FOR一个数组元素就是一个通道的完整档案。后面要改报警限只需要改结构体里的REAL字段不需要另开几个平行数组来回对应。相比“5个平行数组各管各的”结构体数组的代码阅读成本和维护成本差距是非常大的。结构体数组还有一个好处可以直接作为功能块的INOUT参数传递。你可以写一个FB_TemperatureFilter参数是ST_TempChannel数组内部用FOR循环批量滤波外部调用时传整个数组进去。批量采集加批量处理的模式就能沉淀成可复用的库函数而不是每次都是散装的临时代码。5.3 指针与地址运算把数组传给通信接口ST数组的另一个常见用途是取地址传给通信接口。IEC 61131-3里有ADR()运算符可以取变量地址典型用法如下VAR txBuffer : ARRAY[0..49] OF WORD; mbSlave : MB_SLAVE; END_VAR // 通信功能块通过指针直接操作数组 mbSlave.PTR_TX : ADR(txBuffer[0]); mbSlave.LEN_TX : 100;注意不是所有PLC的地址运算都能随便用。部分品牌对ADR()有严格的使用位置限制可能要求直接在功能块调用接口里写不允许事先存到中间变量。用之前一定要查手册或者先写个小程序做最小验证。指针数组在标准ST里不是主流因为标准ST没有C语言那种“指向某种类型的指针数组”原生语法。但通过ADR()配合库函数的REF参数可以实现类似的间接访问。我的建议是没有强需求不要硬上指针。数组加下标已经很直观了风险远低于指针操作。指针一个用不好现场排查难度比数组越界高一个数量级。6. 批量采集落地后的调试心得数组监控、超时保护与规模扩展6.1 在线监控数组的正确姿势数组调试和单变量调试很不一样。以常见的CODESYS、汇川AutoShop这类编辑器来说在线监控数组时默认只显示第一个元素要看完整数据得展开变量树里数组的加号一个一个找。数据量一大眼睛根本看不过来我的做法是在监视表里加入数组名并展开或者直接对关键元素建立监视表达式比如 channels[1].engValue、channels[24].engValue紧盯端点和中间点。用“交叉引用列表”排查哪些程序段在读写这个数组快速定位数据变化源头。在线修改变量时注意强制只能强制定点变量数组不能整体强制。数组数据错乱的时候我通常先在监视表里看两组数据原始值数组和工程值数组对比同一通道两个数组的值就知道问题在采集环节还是转换环节。这个分层的设计帮我在现场省掉大量排查时间。6.2 循环等待外部条件必须带超时保护别让循环变成死循环前面聊过嵌入式串口调试里“ESP8266恢复出厂设置指令检测不到OK导致进入死循环”的经典问题其实PLC的通信等待也是同一个坑。循环体在等待外部条件变的满足如果外部条件永远不来程序就永远卡在那里。我做一个PLC和仪表Modbus通信时遇到过互锁某个从站在线不稳定主站功能块通信失败后没返回程序在等待完成的循环里一直转。后来我给自己定了个铁规所有带“等待”性质的循环一律加超时计数器。VAR waitDone : BOOL : FALSE; timeoutCnt : INT : 0; END_VAR waitDone : FALSE; timeoutCnt : 0; WHILE NOT waitDone AND timeoutCnt 5000 DO // 开启一次通信或在循环内检查运行标志 timeoutCnt : timeoutCnt 1; END_WHILE IF NOT waitDone THEN // 超时处理记录故障、复位通信模块 END_IFFOR循环是定次数循环本身不会无限跑WHILE和REPEAT就必须格外小心条件不满足会直接卡死扫描周期严重时触发CPU看门狗。即便你用FOR次数固定循环体里如果调用了通信功能块这种耗时操作实际耗时也可能远超预期。等待外部条件这个动作一定要有可靠的退出底线。6.3 从20路到200路的扩展套路把采集逻辑模块化最后聊点规模化的心得。小项目里“声明数组写FOR循环”够用系统规模一旦上去比如从20路扩到200路散装代码就会慢慢失控。这时候最好把“读通道→转换→校验→入库”封装成功能块数组作为功能块的处理对象。具体套路是定义数据类型ST_Channel包含原始值、工程值、量程、报警限、状态字段。定义一个FB_DataAcquisition输入是通道数组的INOUT参数内部FOR循环统一采集和滤波。主程序只保留数组声明、功能块实例化和一个调用语句。新加通道时硬件配置加通道再改通道数量常量和数组上界即可。这种代码无论在西门子、倍驰还是国产PLC平台上结构都是通用的。我维护过一些大型程序打开几百个网络却很难看懂数据流向大部分原因就是缺少“批量数据用数组统一管理”的意识。ST的数组和FOR循环不只是语法技巧更是一种数据管理思维。以前加10个通道等于多写几十个梯形图网络现在加10个通道就是改一个常量、加一段硬件配置。这种“改一处全盘生效”的痛快就是数组加FOR循环批量采集最大的价值。最后分享一个个人小习惯。每次写完批量采集代码我都会在注释里明确写清楚三件事数组下界为什么选这个值、循环上下界和物理通道怎么对应、数据类型转换公式的来由。批量代码本身很短真正容易忘的是当初的设定原因。等过三个月你回来维护这段代码会感谢当时肯写注释的自己。ST数组和FOR循环不是多高深的知识但在PLC工程里就是最实用的“批量数据搬运工”。希望这篇短文能帮你把这条路走顺少踩几个我当年踩过的坑。