ST语言(Structured Text)是IEC 61131-3标准定义的五大PLC编程语言之一,语法类似Pascal,广泛用于复杂逻辑、运动控制和过程自动化系统。其支持函数(FUNCTION)和函数块(FUNCTION_BLOCK)的递归调用——即函数直接或间接调用自身。这在实现树遍历、阶乘计算、信号滤波初始化等场景中看似简洁,但在资源受限的PLC硬件上极易触发堆栈溢出(Stack Overflow),导致程序崩溃、CPU停机或不可预测的复位。
根本原因在于:PLC运行时环境通常采用固定大小的硬件栈区(常见为2–8 KB),每次函数调用需压入返回地址、局部变量、参数副本及寄存器保存区。若递归深度超过栈容量,后续压栈操作将覆盖相邻内存(如全局变量区或代码区),引发硬件看门狗超时或非法访问异常。而ST语言本身不提供运行时递归深度检查机制,编译器也极少对递归路径做静态深度分析(尤其含条件分支时)。
因此,当发现程序在处理多级嵌套设备状态(如10层深的模块化IO机架)、长序列数据滤波(如64点滑动平均的递归实现)或复杂状态机跳转时出现偶发性CPU STOP故障,且日志显示“Stack overflow”或“Access violation at address 0x...”,应立即怀疑递归深度失控,并启动迭代改写。
一、识别递归风险:三步定位法
-
扫描所有 FUNCTION / FUNCTION_BLOCK 定义
在工程中全局搜索FUNCTION和FUNCTION_BLOCK关键字,逐一检查其内部是否包含对自身名称的直接调用(如MyFilter(...)在MyFilter函数体内出现),或通过中间函数间接调用(如A()调用B(),B()再调用A())。 -
绘制调用链并估算最坏深度
对每个疑似递归单元,手工推演输入边界条件下的调用次数。例如:FUNCTION factorial : DINT VAR_INPUT n : DINT; END_VAR IF n <= 1 THEN factorial := 1; ELSE factorial := n * factorial(n - 1); // 直接递归 END_IF END_FUNCTION当
n := 1000时,将产生1000层调用——远超典型PLC栈容量(<200层安全阈值)。 -
启用编译器栈使用报告(若支持)
部分PLC厂商(如Codesys、TwinCAT)可在编译选项中启用“Stack Usage Analysis”。开启后,编译日志会输出每个函数的最大静态栈需求(如factorial: 48 bytes/call × max depth ? → UNKNOWN)。若显示max depth ?或UNLIMITED,即为高风险信号。
二、迭代改写的四大核心策略
递归的本质是隐式维护调用上下文(参数、返回点、局部状态)于栈中。迭代改写的目标是将该上下文显式转移到堆(全局变量)或循环结构中,彻底消除栈增长。根据问题类型,选用以下任一策略:
策略1:尾递归 → while循环(最常用)
适用场景:递归调用位于函数末尾(tail call),且无后续计算(如阶乘、最大公约数、线性遍历)。
原递归代码(风险高):
FUNCTION gcd_recursive : DINT
VAR_INPUT
a, b : DINT;
END_VAR
IF b = 0 THEN
gcd_recursive := a;
ELSE
gcd_recursive := gcd_recursive(b, a MOD b); // 尾递归,但PLC不优化
END_IF
END_FUNCTION
迭代改写(安全):
FUNCTION gcd_iterative : DINT
VAR_INPUT
a, b : DINT;
END_VAR
VAR
temp : DINT;
END_VAR
WHILE b <> 0 DO
temp := b;
b := a MOD b;
a := temp;
END_WHILE
gcd_iterative := a;
END_FUNCTION
关键动作:
- 声明临时变量
temp存储中间值; - 用
WHILE循环替代递归调用,每次迭代更新a和b; - 移除所有函数自调用语句,确保无栈增长。
策略2:深度优先遍历 → 显式栈模拟
适用场景:需遍历树形/图状结构(如多级设备拓扑、配置参数继承链)。
原递归代码(风险高):
FUNCTION traverse_tree : BOOL
VAR_INPUT
node_id : UINT;
END_VAR
VAR
child_ids : ARRAY[0..7] OF UINT;
i : INT;
END_VAR
// 获取子节点列表(伪代码)
get_child_nodes(node_id, child_ids);
FOR i := 0 TO 7 DO
IF child_ids[i] <> 0 THEN
IF NOT traverse_tree(child_ids[i]) THEN // 深度递归
traverse_tree := FALSE;
EXIT;
END_IF;
END_IF;
END_FOR
traverse_tree := TRUE;
END_FUNCTION
迭代改写(安全):
FUNCTION traverse_tree_iter : BOOL
VAR_INPUT
root_id : UINT;
END_VAR
VAR
stack : ARRAY[0..255] OF UINT; // 固定大小显式栈(256层足够)
sp : INT; // 栈指针,初始为 -1
current_id : UINT;
child_ids : ARRAY[0..7] OF UINT;
i : INT;
END_VAR
// 初始化栈
sp := -1;
stack[sp + 1] := root_id;
sp := sp + 1;
WHILE sp >= 0 DO
current_id := stack[sp];
sp := sp - 1;
// 处理当前节点(如读取状态、校验参数)
IF NOT process_node(current_id) THEN
traverse_tree_iter := FALSE;
EXIT;
END_IF;
// 获取子节点并压入栈(逆序压入以保持原遍历顺序)
get_child_nodes(current_id, child_ids);
FOR i := 7 TO 0 BY -1 DO
IF child_ids[i] <> 0 THEN
IF sp >= 255 THEN // 栈满保护
traverse_tree_iter := FALSE;
EXIT;
END_IF;
sp := sp + 1;
stack[sp] := child_ids[i];
END_IF;
END_FOR;
END_WHILE
traverse_tree_iter := TRUE;
END_FUNCTION
关键动作:
- 声明固定数组
stack[0..255]和栈指针sp,取代硬件栈; - 用
WHILE循环驱动遍历,每次从stack弹出一个节点; - 压栈前检查
sp >= 255,主动截断过深路径,避免静默溢出; - 逆序压入子节点(
7 TO 0 BY -1),保证与原递归相同的处理顺序。
策略3:记忆化递归 → 查表+循环
适用场景:递归存在重复子问题(如斐波那契、动态规划类滤波)。
原递归代码(风险高):
FUNCTION fib_recursive : DINT
VAR_INPUT
n : DINT;
END_VAR
IF n <= 1 THEN
fib_recursive := n;
ELSE
fib_recursive := fib_recursive(n-1) + fib_recursive(n-2); // 指数级调用
END_IF
END_FUNCTION
迭代改写(安全):
FUNCTION_BLOCK fib_iterative
VAR
memo : ARRAY[0..100] OF DINT; // 预分配查表空间
is_computed : ARRAY[0..100] OF BOOL;
i : INT;
END_VAR
METHOD compute : DINT
VAR_INPUT
n : DINT;
END_VAR
IF n < 0 OR n > 100 THEN
compute := 0;
EXIT;
END_IF;
// 初始化查表(首次调用)
IF NOT is_computed[0] THEN
memo[0] := 0;
memo[1] := 1;
is_computed[0] := TRUE;
is_computed[1] := TRUE;
END_IF;
// 自底向上填充查表
FOR i := 2 TO n DO
IF NOT is_computed[i] THEN
memo[i] := memo[i-1] + memo[i-2];
is_computed[i] := TRUE;
END_IF;
END_FOR
compute := memo[n];
END_METHOD
END_FUNCTION_BLOCK
关键动作:
- 将
FUNCTION升级为FUNCTION_BLOCK,利用实例变量memo和is_computed持久化结果; - 用
FOR循环替代嵌套递归,按索引顺序逐项计算; - 添加
n范围检查(n > 100时直接返回),杜绝越界。
策略4:状态机递归 → 基于枚举的循环调度
适用场景:递归用于状态跳转(如多步骤诊断流程、协议解析)。
原递归代码(风险高):
FUNCTION diagnose_step : BOOL
VAR_INPUT
step : INT;
END_VAR
CASE step OF
0:
IF init_hardware() THEN
diagnose_step := diagnose_step(1);
END_IF;
1:
IF check_sensor() THEN
diagnose_step := diagnose_step(2);
END_IF;
2:
diagnose_step := finalize();
END_CASE
END_FUNCTION
迭代改写(安全):
TYPE DIAGNOSTIC_STATE : (IDLE, INIT_HW, CHECK_SENSOR, FINALIZE, DONE);
END_TYPE
FUNCTION_BLOCK diagnostic_engine
VAR
state : DIAGNOSTIC_STATE := IDLE;
next_state : DIAGNOSTIC_STATE;
result : BOOL;
END_VAR
METHOD run : BOOL
VAR_INPUT
trigger : BOOL; // 启动信号
END_VAR
IF trigger AND state = IDLE THEN
state := INIT_HW;
END_IF;
CASE state OF
INIT_HW:
result := init_hardware();
next_state := IF result THEN CHECK_SENSOR ELSE DONE END_IF;
CHECK_SENSOR:
result := check_sensor();
next_state := IF result THEN FINALIZE ELSE DONE END_IF;
FINALIZE:
result := finalize();
next_state := DONE;
DONE:
state := IDLE;
run := result;
EXIT;
ELSE
state := IDLE;
run := FALSE;
EXIT;
END_CASE
state := next_state;
run := FALSE; // 运行中返回FALSE,完成才返回结果
END_METHOD
END_FUNCTION_BLOCK
关键动作:
- 定义枚举类型
DIAGNOSTIC_STATE显式声明所有状态; - 用
CASE+state变量替代递归跳转; run方法返回FALSE直到最终状态,避免连续调用累积栈;- 添加
trigger输入控制启动时机,防止误触发。
三、硬性防护措施(必须添加)
即使完成迭代改写,仍需部署三重防线,确保万无一失:
-
栈深度硬限检查
在所有循环入口添加计数器,超限时强制退出:VAR loop_count : INT := 0; MAX_ITERATIONS : INT := 1000; END_VAR WHILE condition AND loop_count < MAX_ITERATIONS DO // 主体逻辑 loop_count := loop_count + 1; END_WHILE IF loop_count >= MAX_ITERATIONS THEN // 记录错误:ERROR_STACK_DEPTH_EXCEEDED RETURN; END_IF -
全局栈使用监控(针对Codesys/TwinCAT)
调用系统函数获取实时栈用量:// Codesys示例:读取当前任务栈使用量(单位:字节) stack_used := __GET_TASK_STACK_USAGE(__CURRENT_TASK); IF stack_used > 6000 THEN // 预警阈值 set_alarm(ALARM_HIGH_STACK_USAGE); END_IF -
编译期禁用递归(推荐)
在PLC工程设置中启用“禁止递归调用”选项(Codesys路径:Project → Options → ST Compiler → “Disable recursive function calls”)。此举使编译器直接报错,从源头杜绝递归代码进入运行环境。
四、验证与测试清单
改写后必须执行以下验证,缺一不可:
| 测试项 | 方法 | 通过标准 |
|---|---|---|
| 功能等价性 | 对同一组输入,对比原递归版与新迭代版输出 | 所有测试用例输出完全一致 |
| 栈用量实测 | 在PLC中运行 __GET_TASK_STACK_USAGE,记录各阶段峰值 |
改写后栈用量 ≤ 原版的30%,且无增长趋势 |
| 边界压力测试 | 输入最大合法参数(如 n=1000, depth=255) |
程序正常完成,不触发CPU STOP或报警 |
| 长时间运行 | 连续运行72小时,每10秒调用一次 | 无内存泄漏、无状态漂移、无性能衰减 |
五、预防性设计规范(团队落地)
为避免未来再出现同类问题,应在团队编码规范中强制执行:
- 禁令:禁止在任何ST代码中使用
FUNCTION的自调用语法; - 许可:仅允许
FUNCTION_BLOCK实现状态保持逻辑,且必须含MAX_ITERATIONS保护; - 模板:提供标准化迭代框架(含计数器、栈溢出标志、错误码返回);
- CI检查:在持续集成流水线中加入正则扫描,自动拦截含
FUNCTION.*\n.*FUNCTION模式的文件。
递归是算法思维的优雅表达,但PLC是确定性实时系统的物理载体。用循环代替递归,不是妥协,而是对硬件边界的敬畏;用查表代替重算,不是冗余,而是对时间确定性的承诺。 当你的ST代码不再依赖看不见的栈,而由你亲手掌控的变量和循环驱动时,自动化系统才真正拥有了工业级的健壮根基。

暂无评论,快来抢沙发吧!