1. 一个让老手都翻车的下标问题
三菱GX Works3里写ST程序,数组和FOR循环是再基础不过的组合。但就是这个基础组合,坑过的人不在少数。我见过一个现场调试的哥们,数组开了16个元素,FOR循环上限也老老实实写了16,结果第17号数据死活读不出来,查了半天硬件、查了半天通讯,最后发现问题出在自己写的循环上。
这个标题说的就是这件事:三菱ST数组越界,FOR上限写16,为什么丢了17号。听起来像绕口令,实际上是一个关于数组下标起点、循环边界条件、以及ST语言数组声明方式的综合问题。它牵扯到的知识点不复杂,但每一个都容易在实操中被忽略。
这篇文章适合所有用GX Works3写ST程序的同行看,不管你是刚接触ST的新手,还是写了好几年梯形图刚转ST的老手,这个问题都值得花时间彻底搞明白。我会从数组的声明方式讲起,把下标起点、元素个数、循环边界这三者的关系掰开揉碎,再给出可以直接抄的代码模板和排查清单。看完之后,你至少不会再在FOR循环的边界上栽跟头。
2. 数组声明方式决定了你的下标从几开始
2.1 三菱ST数组的两种声明写法
在三菱的ST语言里,数组声明有两种常见写法,而这两种写法直接决定了你的下标是从0开始还是从1开始。很多人出问题,就是没搞清楚自己用的到底是哪一种。
第一种写法是指定下标范围:
VAR DataBuf : ARRAY[0..15] OF INT; END_VAR这种写法明确告诉编译器,这个数组的下标范围是0到15,一共16个元素。下标从0开始,最后一个元素是DataBuf[15]。
第二种写法是只给元素个数:
VAR DataBuf : ARRAY[1..16] OF INT; END_VAR这种写法下标范围是1到16,也是16个元素,但下标从1开始,最后一个元素是DataBuf[16]。
还有一种是很多从其他PLC平台转过来的人习惯用的写法:
VAR DataBuf : ARRAY[16] OF INT; END_VAR在三菱的ST里,这种写法默认下标从0开始,也就是说它的有效下标是0到15,一共16个元素。这一点和某些其他品牌的PLC不一样,那些平台可能默认从1开始。如果你带着别的平台的思维来写三菱ST,这里就是第一个坑。
2.2 下标起点不同带来的连锁反应
下标起点不同,影响的不只是你访问元素时写的数字,更关键的是循环的边界条件。
假设你声明了ARRAY[0..15] OF INT,一共16个元素。如果你想遍历所有元素,FOR循环应该这样写:
FOR i := 0 TO 15 DO Process(DataBuf[i]); END_FOR;循环变量i从0开始,到15结束,正好覆盖16个元素。
但如果你声明的是ARRAY[1..16] OF INT,遍历所有元素的循环就应该是:
FOR i := 1 TO 16 DO Process(DataBuf[i]); END_FOR;循环变量i从1开始,到16结束,也是16个元素。
问题来了:标题里说的“FOR上限写16,丢了17号”,说明写代码的人心里想的是“我要处理17个数据”,但数组只开了16个元素的空间。这多出来的第17号数据,要么是数组声明的时候少开了一个,要么是循环边界写错了导致访问了不存在的元素。
2.3 一个容易混淆的场景:数组元素个数与最大下标
这里有一个非常容易混淆的点,我单独拎出来说。
当你看到ARRAY[0..15]的时候,元素个数是16,最大下标是15。当你看到ARRAY[1..16]的时候,元素个数也是16,最大下标是16。
很多人脑子里记住的是“我开了16个元素的数组”,然后在写FOR循环的时候,下意识地写FOR i := 0 TO 16,觉得“16个元素嘛,循环到16就对了”。但实际上,如果下标从0开始,循环到16就访问了第17个元素,而那个元素根本不存在,直接触发数组越界。
反过来,如果下标从1开始,FOR i := 1 TO 16是正确的,但如果你写成FOR i := 0 TO 15,虽然循环次数也是16次,但第一次访问的是DataBuf[0],这个下标在ARRAY[1..16]里是不存在的,同样越界。
所以核心原则是:FOR循环的起始值和结束值,必须和数组声明的下标范围完全匹配。起始值等于数组的最小下标,结束值等于数组的最大下标。不要用元素个数去推导循环边界,要用下标范围去写。
3. FOR循环边界写错的三种典型情况
3.1 情况一:下标从0开始,循环却从1开始
这是最常见的一种错误。数组声明为ARRAY[0..15],但写循环的时候写成了:
FOR i := 1 TO 16 DO Process(DataBuf[i]); END_FOR;这个循环有两个问题。第一,DataBuf[0]被跳过了,第一个元素没处理到。第二,DataBuf[16]不存在,最后一次循环直接越界。结果就是“丢了0号,多了16号”,程序要么报错,要么读到垃圾数据。
这种错误通常发生在从其他平台转过来的人身上,因为有些平台的数组默认从1开始,写习惯了FOR i := 1 TO N,到了三菱这边没改过来。
3.2 情况二:下标从1开始,循环却从0开始
反过来,数组声明为ARRAY[1..16],循环写成:
FOR i := 0 TO 15 DO Process(DataBuf[i]); END_FOR;第一次循环访问DataBuf[0],这个下标不存在,直接越界。后面虽然访问了1到15,但DataBuf[16]没被处理到。结果是“多了0号,丢了16号”。
这种错误往往是因为写代码的人记得“数组有16个元素”,然后习惯性地从0数到15,忘了这个数组的下标是从1开始的。
3.3 情况三:元素个数与下标范围混用
这是标题里描述的那种情况。写代码的人心里想的是“我要处理17个数据”,于是数组声明为ARRAY[0..16],一共17个元素。但写FOR循环的时候,脑子里想的是“16个元素”,于是写了FOR i := 0 TO 15。结果第17号元素DataBuf[16]没被处理到,看起来就是“丢了17号”。
或者反过来,数组声明为ARRAY[0..15],一共16个元素,但写循环的时候想的是“我要处理到16号”,于是写了FOR i := 0 TO 16。结果最后一次循环访问DataBuf[16],越界。
这两种情况的根源都是一样的:元素个数和最大下标是两个不同的概念,混在一起用就出错。
3.4 用表格把关系理清楚
我把常见的数组声明和对应的正确循环写法整理成一张表,方便你对照检查:
| 数组声明 | 元素个数 | 最小下标 | 最大下标 | 正确的FOR循环 |
|---|---|---|---|---|
| ARRAY[0..15] OF INT | 16 | 0 | 15 | FOR i := 0 TO 15 |
| ARRAY[1..16] OF INT | 16 | 1 | 16 | FOR i := 1 TO 16 |
| ARRAY[0..16] OF INT | 17 | 0 | 16 | FOR i := 0 TO 16 |
| ARRAY[1..17] OF INT | 17 | 1 | 17 | FOR i := 1 TO 17 |
| ARRAY[16] OF INT | 16 | 0 | 15 | FOR i := 0 TO 15 |
这张表建议你截图存手机里,写ST的时候拿出来对一眼,能省下大量调试时间。
4. 实操:从零搭建一个不会越界的数组处理程序
4.1 需求分析与数组容量规划
假设我们要做一个配方数据缓冲区,需要存储17个工位的温度设定值。每个工位一个INT数据,总共17个数据。
第一步是确定数组声明。17个数据,如果下标从0开始,声明为ARRAY[0..16];如果下标从1开始,声明为ARRAY[1..17]。两种都可以,关键是后续的循环要匹配。
我个人的习惯是统一用0作为起始下标,因为这样和大多数编程语言的惯例一致,查资料、看例程的时候不容易混淆。所以这里声明为:
VAR RecipeTemp : ARRAY[0..16] OF INT; (* 17个工位温度设定值 *) i : INT; Sum : INT; Avg : INT; END_VAR4.2 正确的FOR循环写法与参数计算
遍历这17个元素,求平均值:
Sum := 0; FOR i := 0 TO 16 DO Sum := Sum + RecipeTemp[i]; END_FOR; Avg := Sum / 17;循环变量i从0到16,一共循环17次,正好覆盖17个元素。这里的关键是:结束值16来自数组声明的最大下标,而不是元素个数17。如果你写成FOR i := 0 TO 17,就会越界;如果写成FOR i := 0 TO 15,就会漏掉最后一个元素。
再强调一遍:FOR的结束值 = 数组声明的最大下标。这个等式永远成立,不管数组有多少个元素。
4.3 用常量定义数组大小,避免硬编码
在实际项目里,数组大小可能会调整。如果每次调整都要去改FOR循环的边界,很容易漏改。更好的做法是用常量来定义:
VAR_GLOBAL CONSTANT RECIPE_COUNT : INT := 17; RECIPE_MAX_IDX : INT := 16; END_VAR VAR RecipeTemp : ARRAY[0..RECIPE_MAX_IDX] OF INT; i : INT; END_VAR FOR i := 0 TO RECIPE_MAX_IDX DO Process(RecipeTemp[i]); END_FOR;这样,如果以后工位数量变成20个,只需要改RECIPE_COUNT和RECIPE_MAX_IDX两个常量,循环边界自动跟着变,不会出现漏改的情况。
注意:三菱ST里数组声明使用常量作为边界时,常量必须是编译期可确定的。用
VAR_GLOBAL CONSTANT定义的常量可以满足这个要求。如果你用的是VAR定义的变量,编译器会报错。
4.4 实操现场记录:一次真实的排查过程
我之前在一个包装机项目上遇到过类似的问题。设备有24个气缸,每个气缸有两个位置传感器,一共48个BOOL量。我声明了一个ARRAY[0..47] OF BOOL来存传感器状态,然后写了一个FOR循环来扫描:
FOR i := 0 TO 48 DO IF CylSensor[i] THEN CylCount := CylCount + 1; END_IF; END_FOR;编译没报错,下载运行也没立刻出问题。但运行了大概十几分钟后,PLC报了“数组下标越界”的错误,设备停机。
排查的时候我一开始怀疑是传感器抖动导致的,查了半天硬件没发现问题。后来把程序调出来逐行看,才发现FOR循环写的是0 TO 48,而数组最大下标是47。每次扫描都会访问CylSensor[48],这个下标不存在,偶尔读到的是相邻变量的数据,偶尔直接触发越界保护。
改成FOR i := 0 TO 47之后,问题消失。这个坑的教训是:编译通过不代表运行没问题,数组越界有时候不会在编译阶段被发现,而是在运行时才暴露。
5. 数组越界的排查思路与常见问题速查
5.1 编译期能发现和不能发现的越界
三菱GX Works3对数组越界的检查分两个层面。
编译期能发现的:如果你直接用常量下标访问数组,比如DataBuf[20],而数组声明是ARRAY[0..15],编译器会直接报错,告诉你下标超出范围。这种错误最容易发现,也最容易改。
编译期不能发现的:如果你用变量作为下标,比如DataBuf[i],编译器无法在编译阶段知道i的值是多少,所以不会报错。只有在运行时i的实际值超出了数组范围,才会触发越界。这种错误最危险,因为它可能潜伏很久才暴露,而且暴露的时候往往是在客户现场。
还有一种情况是间接越界:你用了一个中间变量来计算下标,比如DataBuf[Base + Offset],编译器同样无法在编译期判断。这种越界最隐蔽,排查起来也最费时间。
5.2 运行时越界的表现与诊断方法
运行时数组越界在三菱PLC上的表现有几种:
第一种是PLC报错停机,错误代码里会提示数组下标越界。这是最好的情况,因为问题立刻暴露,你知道去查数组相关的代码就行。
第二种是读到错误数据但不报错。PLC访问了数组范围之外的内存区域,读到的可能是其他变量的值,也可能是随机数据。程序继续运行,但逻辑已经错了。这种最麻烦,因为现象可能千奇百怪,你很难第一时间想到是数组越界。
第三种是写入越界。如果越界的是写操作,可能会覆盖其他变量的值,导致完全不相关的功能出问题。比如你本来想写DataBuf[16],结果写到了相邻的Timer变量上,定时器就乱了。
诊断方法我一般用这几招:
- 在FOR循环里加一个范围检查,如果i超出预期范围就置一个标志位,方便事后查看。
- 用GX Works3的监视功能,在线监视循环变量i的值,看它实际循环到了多少。
- 在循环前后各加一个计数器,对比循环次数是否和预期一致。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方法 |
|---|---|---|---|
| PLC报数组越界错误 | FOR结束值大于最大下标 | 检查FOR的TO后面的值 | 改为最大下标 |
| 第一个元素没被处理 | FOR起始值大于最小下标 | 检查FOR的:=后面的值 | 改为最小下标 |
| 最后一个元素没被处理 | FOR结束值小于最大下标 | 检查FOR的TO后面的值 | 改为最大下标 |
| 编译报错“下标超出范围” | 常量下标超出声明范围 | 检查数组声明和访问代码 | 调整声明或访问下标 |
| 运行一段时间后出错 | 变量下标在特定条件下越界 | 监视下标变量的取值范围 | 加范围限制或修正逻辑 |
| 数据莫名其妙被改写 | 写操作越界覆盖了其他变量 | 检查所有数组写操作 | 修正下标范围 |
5.4 几个容易忽略的边界场景
除了基本的FOR循环边界,还有几个场景容易出问题。
场景一:倒序循环。如果你写FOR i := 16 TO 0 DO,三菱ST默认是递减循环,从16到0,这是合法的。但如果你写FOR i := 0 TO 16 BY -1 DO,就会出问题,因为起始值小于结束值,步长为负,循环条件永远不满足,循环体一次都不执行。
场景二:嵌套循环共用循环变量。外层循环用i,内层循环也用i,内层循环结束后i的值被改掉了,外层循环的计数就乱了。这种错误不会报越界,但逻辑完全错误。解决办法是内外层用不同的循环变量。
场景三:循环体内修改循环变量。在FOR循环体里给i赋值,比如i := i + 1,会打乱循环的正常计数。三菱ST里FOR循环的计数是编译器管理的,你在循环体里改i的值,下一次循环的时候i会被重新赋值为下一个值,你的修改被覆盖。但这种行为在不同编译器上可能不一样,最好的做法是永远不要在FOR循环体里修改循环变量。
6. 写ST数组循环的几条铁律
6.1 声明与循环必须成对检查
每次写完一个数组和对应的FOR循环,花十秒钟做一次对照检查:数组的最小下标是不是等于FOR的起始值?数组的最大下标是不是等于FOR的结束值?这两个问题都回答“是”,才能往下走。
我自己的习惯是在数组声明后面加一行注释,写明最小下标和最大下标:
RecipeTemp : ARRAY[0..16] OF INT; (* idx: 0-16, cnt: 17 *)这样写FOR循环的时候,直接看注释就知道该写0 TO 16,不用再去数元素个数。
6.2 用FOREACH思维替代手工下标
如果你的PLC型号和GX Works3版本支持,可以考虑用更高级的遍历方式。不过三菱ST对FOREACH的支持有限,大多数情况下还是得用FOR循环加下标。那就老老实实把下标范围写对。
6.3 边界测试不能省
程序写完之后,至少做一次边界测试:把数组的第一个元素和最后一个元素分别赋一个特殊值,然后运行循环,看这两个值有没有被正确处理。如果第一个元素没被处理到,说明起始值写大了;如果最后一个元素没被处理到,说明结束值写小了。
这个测试花不了两分钟,但能帮你提前发现大部分边界问题。
6.4 版本差异要注意
不同版本的GX Works3对数组越界的检查严格程度不一样。有些版本编译期检查很松,运行期也不一定报错;有些版本则很严格。如果你从旧版本升级到新版本,原来能跑的代码可能突然报越界错误。这不是新版本有问题,而是旧版本帮你掩盖了问题。遇到这种情况,老老实实把下标范围改对就行。
7. 从数组越界延伸出去的几个思考
数组越界这个问题,表面上看是FOR循环边界写错了,往深了想,其实是对“范围”这个概念的理解不够精确。在编程里,范围无处不在:数组有下标范围,循环有迭代范围,数据类型有取值范围,通讯有地址范围。每一个范围都有起点和终点,混淆起点终点、混淆范围大小和边界值,就会出问题。
我自己的经验是,写任何涉及范围的代码,都先把范围的起点和终点明确写出来,再写循环或访问逻辑。不要凭记忆,不要凭感觉,不要“我觉得应该是”。把范围写下来,对着写,出错概率能降低一大半。
另外,数组越界只是ST编程里众多边界问题的一个。类似的还有字符串处理时的长度边界、结构体数组的成员访问边界、二维数组的行列边界等等。掌握了“范围起点终点必须明确”这个原则,这些问题都能用同样的思路去排查和解决。
最后说一个我踩过的坑:有一次我用ARRAY[1..10]声明数组,然后在另一个地方用SIZE_OF函数去取数组大小,得到的结果是10。我下意识地认为最大下标就是10,循环写了FOR i := 1 TO 10,这是对的。但后来我把数组改成ARRAY[0..10],元素个数变成了11,SIZE_OF返回11,我却还是写FOR i := 1 TO 10,结果DataBuf[0]没被处理到。这个坑的教训是:数组大小和最大下标是两个不同的数,改数组声明的时候,两个都要检查。