在电气自动化系统中,尤其是基于PLC(可编程逻辑控制器)的工业控制程序里,ST(Structured Text,结构化文本)语言是IEC 61131-3标准定义的五大编程语言之一。它语法接近Pascal,支持数组、结构体、函数块等高级数据结构,被广泛用于实现复杂逻辑、运动控制、PID调节和数据采集任务。但正因其表达力强,也更容易隐藏危险——其中最隐蔽、最易被忽视、又最可能导致灾难性后果的问题之一,就是数组下标越界(Array Index Out of Bounds)。
这个问题不抛出“错误弹窗”,不中断调试器,甚至在仿真环境中也常悄然通过。它可能让设备突然停机、阀门误动作、变频器输出异常频率、安全继电器意外释放,或在关键工艺段引发批次报废。而所有这些故障的起点,往往只是程序员在写 MyTemp[0] 还是 MyTemp[1] 时,对ST语言数组索引默认规则的一次误判。
一、ST语言数组索引的本质:从声明到内存布局
ST语言中的数组声明格式为:
ARRAY [下界..上界] OF 数据类型
例如:
TempValues : ARRAY [0..9] OF REAL;
MotorSpeeds : ARRAY [1..4] OF INT;
Flags : ARRAY [-2..2] OF BOOL;
关键点在于:ST语言不规定数组必须从0开始,也不强制从1开始;它的起始下标由程序员显式声明决定。这与C/C++/Python等“约定俗成0起始”的语言有根本区别,也与SCL(西门子版ST)在部分老版本中默认1起始的历史惯性形成认知冲突。
因此,判断 Array[0] 是否合法,唯一依据是该数组声明中是否包含了0这个下标值。
-
若声明为
ARRAY [0..9] OF ...:- 合法下标范围是
0, 1, 2, ..., 9(共10个元素)。 Array[0]是第一个元素,完全合法;Array[10]才是越界。
- 合法下标范围是
-
若声明为
ARRAY [1..8] OF ...:- 合法下标是
1, 2, ..., 8(共8个元素)。 Array[0]和Array[8]均合法(因为8在范围内),但Array[0]是越界;Array[9]也是越界。
- 合法下标是
-
若声明为
ARRAY [-3..3] OF ...:- 合法下标是
-3, -2, -1, 0, 1, 2, 3(共7个元素)。 Array[0]不仅合法,还是中间元素。
- 合法下标是
可见,Array[0] 本身不是问题,问题在于它是否落在声明区间内。把“ST数组从1开始”当作铁律,或把“ST数组从0开始”当作常识,都是危险的误解。
二、越界访问为何导致内存错误?——PLC内存模型真相
PLC的RAM(工作内存)是连续分配的线性空间。当编译器处理 ARRAY [L..H] OF TYP 声明时,会执行两步操作:
- 计算元素总数:
N = H − L + 1 - 分配连续字节块:大小为
N × sizeof(TYP)字节,起始地址记为BaseAddr
每个元素 Array[i] 的内存地址按以下公式计算:
$$ \text{Address}(i) = \text{BaseAddr} + (i - L) \times \text{sizeof(TYP)} $$
注意:这里 i - L 是偏移量(offset),永远 ≥ 0 且 < N —— 这是编译器生成地址计算代码的数学基础。
当 i 超出 [L, H] 范围时:
- 若
i < L:i − L为负数 → 地址BaseAddr + 负值→ 访问BaseAddr之前的内存; - 若
i > H:i − L ≥ N→ 地址≥ BaseAddr + N×sizeof(TYP)→ 访问BaseAddr之后的内存。
而这前后区域,极大概率已被其他变量、临时寄存器、函数块实例数据或系统保留区占用。
典型后果举例:
| 越界方向 | 访问位置 | 可能覆盖内容 | 实际现象 |
|---|---|---|---|
| 向前越界 | Array[-1] |
前一个变量的末尾字节 | 上一布尔变量被意外置位/复位 |
| 向后越界 | Array[10](声明为[0..9]) |
下一INT变量的低字节 | 整数被篡改(如设定值突变为128) |
| 向后越界 | Array[100] |
系统堆栈或监控缓冲区 | PLC运行缓慢、通讯中断、看门狗超时 |
更危险的是:某些PLC固件(尤其国产或定制内核)不实施运行时边界检查。ST代码被编译为高效机器码直接执行地址运算,越界访问不会触发异常,而是静默读写——你看到的“程序正常运行”,实则是内存正在被悄悄腐蚀。
三、真实工程场景中的6类高频越界陷阱
以下均为现场调试中反复出现、经抓取内存快照证实的案例。
陷阱1:循环计数器与数组长度错配(最常见)
// 错误示例:假设 SensorReadings 声明为 ARRAY[1..16] OF REAL
FOR i := 0 TO 15 DO // ❌ i 从0开始,但数组无索引0
Avg := Avg + SensorReadings[i]; // 第一次即越界!
END_FOR
✅ 正确写法(两种):
// 方案A:匹配声明下界
FOR i := 1 TO 16 DO
Avg := Avg + SensorReadings[i];
END_FOR
// 方案B:用LEN()函数动态获取长度(推荐)
len := LEN(SensorReadings); // 返回16(元素总数)
FOR i := 1 TO len DO
Avg := Avg + SensorReadings[i];
END_FOR
💡 提示:
LEN(数组名)总是返回元素总数(正整数),与下标起始无关;LOWER(数组名)和UPPER(数组名)分别返回声明的下界和上界值。
陷阱2:初始化循环漏掉首/尾元素
// 声明:AlarmHistory : ARRAY[0..99] OF AlarmStruct;
// 错误:只清空了1..99,遗漏了索引0
FOR i := 1 TO 99 DO
AlarmHistory[i].Active := FALSE;
END_FOR
// 结果:AlarmHistory[0] 保持旧报警状态,可能误触发连锁停机
陷阱3:指针/地址运算误用
// 声明:Buffer : ARRAY[0..255] OF BYTE;
ptr := ADR(Buffer); // ptr 指向 Buffer[0]
// 错误:认为 ptr+1 就是 Buffer[1],直接解引用
value := BYTE(ADR(Buffer) + 1^); // ❌ 未校验是否越界!若Buffer仅1字节则立即越界
✅ 安全做法:始终用数组索引访问,避免裸指针算术。
陷阱4:字符串操作越界(STRING/NSTRING)
s : STRING[20] := 'Hello';
// 错误:STRING内部按字节数组存储,索引从1开始(IEC标准)
// 但程序员误以为类似C的\0结尾,尝试 s[0] 或 s[21]
s[0] := 'X'; // ❌ 越界(STRING[20]合法索引为1..20)
s[21] := 'Y'; // ❌ 越界(超出长度上限)
陷阱5:多维数组降维访问失误
// 声明:Matrix : ARRAY[0..3, 0..2] OF REAL; // 4行×3列
// 错误:当成C风格的[行][列],却写反维度
val := Matrix[2, 5]; // ❌ 第二维最大为2,5越界
// 更隐蔽错误:用单索引访问(某些PLC支持线性寻址)
val := Matrix[10]; // ❌ 若按行优先,合法索引为0..11;但10是否在范围内需严格计算
陷阱6:函数块数组接口不一致
// FB声明输入:
METHOD Calculate : REAL
VAR_INPUT
DataPoints : ARRAY[*] OF REAL; // * 表示“任意大小数组”
END_VAR
// 调用方:
LOCAL_TEMP : ARRAY[1..10] OF REAL;
result := MyFB.Calculate(LOCAL_TEMP); // ✅ 传入10个元素
// 但FB内部若写:
FOR i := 0 TO 9 DO // ❌ 假设从0开始,实际数组下界是1
sum := sum + DataPoints[i]; // 第一次即越界!
END_FOR
✅ 解决方案:FB内部必须用 LOWER(DataPoints) 和 UPPER(DataPoints) 获取真实边界。
四、防御性编程:4层加固策略
避免越界不能依赖“小心”,而要靠机制。以下是经产线验证的四层防护法:
第一层:声明即契约——用常量固化边界
// ❌ 魔法数字散落各处
TempArray : ARRAY[1..12] OF REAL;
// ✅ 边界定义为全局常量,一处修改,全局生效
CONST
TEMP_COUNT : INT := 12;
END_CONST
TempArray : ARRAY[1..TEMP_COUNT] OF REAL;
// 循环自然变成:
FOR i := 1 TO TEMP_COUNT DO
...
END_FOR
第二层:运行时断言——主动捕获越界
// 在关键访问前插入检查(调试期启用,量产可条件编译)
#IFDEF DEBUG_MODE
IF (i < LOWER(TempArray)) OR (i > UPPER(TempArray)) THEN
// 触发诊断事件、写入错误日志、置位故障标志
FaultCode := 1001;
FaultDescription := 'TempArray index out of bounds';
RETURN; // 或调用ERROR_HANDLER()
END_IF
#ENDIF
第三层:工具链拦截——利用IDE静态分析
主流PLC开发环境(TIA Portal、Codesys、Unity Pro)均支持:
- 语法高亮标记非法索引(如访问未声明下标);
- 代码检查规则(Coding Rule):启用 “Array index must be within declared bounds”;
- 自定义脚本扫描:遍历所有
.st文件,提取ArrayName[后数字,比对声明范围。
示例(Python正则扫描思路):
匹配声明:ARRAY\s*\[(\d+)\.\.(\d+)\]→ 得到 L=1, H=12
匹配访问:ArrayName\[(\d+)\]→ 检查数字是否 ∈ [1,12]
第四层:硬件级隔离——启用MMU/MPU(高端PLC)
在支持内存管理单元(MMU)的PLC(如部分Beckhoff CX系列、研华UNO-2484G)中:
- 将数组所在DB块映射为独立内存页;
- 设置页表项为“用户可读写,但禁止跨页访问”;
- 越界访问触发硬件异常,由固件转入安全模式。
此层需固件支持,非通用方案,但代表未来演进方向。
五、调试实战:3步定位越界源头
当现场出现疑似越界症状(如某变量值随机跳变、通讯周期异常增长),按此流程排查:
步骤1:冻结变量并观察变化链
- 在TIA Portal中,将疑似越界数组(如
MotorCmd[0..3])加入“监视表”; - 启用“更新时触发”+“数值变化时高亮”;
- 手动触发一次相关逻辑(如按下启动按钮),观察哪个索引值最先异常变化;
- 若
MotorCmd[0]突变为非预期值,而声明是ARRAY[1..3],则确认0是非法索引。
步骤2:反向追踪赋值源
- 在变量
MotorCmd[0]上右键 → “交叉引用”(Cross Reference); - 查看所有写入该索引的位置;
- 重点检查:是否有
FOR循环使用i:=0、是否有硬编码MotorCmd[0] := ...、是否有指针解引用p^且p指向错误偏移。
步骤3:内存快照比对(终极手段)
- 使用PLC厂商提供的内存导出工具(如Codesys Memory Dump、罗克韦尔 Logix Designer Memory View);
- 在故障发生前后各抓取一次RAM快照;
- 对比数组所在地址区间(如
0x20000到0x20040); - 若发现相邻变量(如
SafetyFlag的地址紧邻数组末尾)的值同步改变,则100%确认向后越界。
六、最佳实践清单(可直接嵌入团队编码规范)
| 类别 | 规则 | 说明 |
|---|---|---|
| 声明 | 所有数组必须显式声明完整范围,禁用 * 通配符(除非在FB接口且必做边界检查) |
避免隐式依赖默认行为 |
| 循环 | FOR 循环起始/结束值必须用 LOWER() / UPPER() 函数获取 |
FOR i := LOWER(MyArr) TO UPPER(MyArr) DO |
| 访问 | 禁止硬编码数组索引;所有索引必须为变量或常量,且在访问前验证 | IF (idx >= LOWER(Arr)) AND (idx <= UPPER(Arr)) THEN ... |
| 字符串 | STRING 类型索引从1开始;BYTE 数组索引按声明范围;严禁混用 |
s[1] 是首字符,s[0] 永远非法 |
| 调试 | 发布前启用全部静态检查规则,并导出检查报告归档 | 报告中“Array index warning”必须为0条 |
七、一个完整安全示例:温度均值计算函数块
FUNCTION_BLOCK SafeAvgCalc
VAR_INPUT
Samples : ARRAY[*] OF REAL; // 接口接受任意大小数组
ValidCount : INT; // 实际有效样本数(≤ LEN(Samples))
END_VAR
VAR_OUTPUT
Average : REAL;
IsValid : BOOL;
END_VAR
VAR
i : INT;
sum : REAL;
count : INT;
low, high : INT;
END_VAR
// 1. 获取真实边界
low := LOWER(Samples);
high := UPPER(Samples);
// 2. 边界检查与预处理
IF (ValidCount <= 0) OR (ValidCount > (high - low + 1)) THEN
Average := 0.0;
IsValid := FALSE;
EXIT;
END_IF
// 3. 安全求和:确保i在[low, low+ValidCount-1]范围内
sum := 0.0;
count := 0;
FOR i := low TO (low + ValidCount - 1) DO
// 即使ValidCount超限,此循环也不会越界
sum := sum + Samples[i];
count := count + 1;
END_FOR
// 4. 输出结果
IF count > 0 THEN
Average := sum / REAL(count);
IsValid := TRUE;
ELSE
Average := 0.0;
IsValid := FALSE;
END_IF
暂无评论,快来抢沙发吧!