在结构化文本(ST)编程语言中,递归调用指函数或功能块(FB)在自身执行过程中直接或间接调用自身。尽管递归在高级语言(如Python、C)中是常见且强大的抽象手段,但在IEC 61131-3标准下的PLC编程环境(尤其是ST)中,递归本质上是被禁止的——不是语法上绝对不可写,而是运行时极大概率导致不可恢复的系统故障。本指南不讨论“如何绕过限制实现递归”,而是直击本质:为什么ST中递归危险?哪些条件下看似‘成功’的递归实为侥幸?堆栈溢出如何发生?如何用安全、等效、符合工业规范的方式替代它?
一、ST语言与PLC运行机制的本质约束
PLC程序运行于资源受限的嵌入式实时系统中。其核心特征包括:
- 固定大小的调用堆栈(Call Stack):由硬件或固件预分配,典型值为2–8 KB,不可动态扩展。
- 无虚拟内存与垃圾回收:所有局部变量、临时表达式结果、返回地址均压入堆栈;函数返回时仅弹出,不自动清理。
- 确定性执行周期(Cycle Time)要求:每个扫描周期必须在毫秒级内完成,否则触发看门狗超时(WDT timeout),导致CPU停机。
ST语言本身未定义递归语法支持。多数PLC厂商(如Siemens TIA Portal、Rockwell Studio 5000、Codesys)在编译阶段即静默禁用递归检测,或仅在调试模式下发出警告,但不阻止代码生成。这意味着:
- 若你编写
FUNCTION_BLOCK FB_Counter并在内部调用Self()或FB_Counter(...), 编译器可能接受(尤其当调用路径隐含于条件分支中),但运行时行为完全失控。 - 所谓“递归成功”,往往只是因为递归深度极浅(≤2层)、堆栈初始占用极小、且未触发边界检查——这属于未暴露的缺陷,而非合法能力。
二、堆栈溢出的精确发生过程(无公式,纯动作推演)
假设某ST函数 F_Calc(x: INT): INT 被错误设计为递归:
FUNCTION F_Calc : INT
VAR_INPUT
x : INT;
END_VAR
IF x > 0 THEN
F_Calc := x + F_Calc(x - 1); // 危险:此处调用自身
ELSE
F_Calc := 0;
END_IF
执行 F_Calc(5) 时,堆栈变化如下(以典型32位PLC为例,每帧开销≈16字节):
- 主程序调用
F_Calc(5)→ 堆栈压入:返回地址 +x=5+ 局部变量空间 → 占用16 B F_Calc(5)判x>0,调用F_Calc(4)→ 新压入16 B → 累计32 BF_Calc(4)调用F_Calc(3)→ 累计48 BF_Calc(3)调用F_Calc(2)→ 累计64 BF_Calc(2)调用F_Calc(1)→ 累计80 BF_Calc(1)调用F_Calc(0)→ 累计96 BF_Calc(0)返回0 → 弹出最后一帧 → 累计80 BF_Calc(1)计算1 + 0 = 1,返回 → 弹出 → 累计64 B- ……依此类推,直至全部返回。
表面看仅7层,耗96 B,远低于8 KB。但现实远比此严酷:
- 实际帧开销非固定16 B:若函数含数组形参(如
arr: ARRAY[1..10] OF REAL),每次调用复制整个数组 → 单帧暴涨400+ B; - 编译器优化失效:ST编译器极少做尾递归优化(Tail Call Optimization),所有中间状态全保留;
- 中断与多任务叠加:PLC同时运行多个任务(如高速计数、PID控制),各自堆栈独立但共享物理内存池。一个任务溢出可污染相邻任务数据;
- 无堆栈保护机制:不像PC操作系统有Guard Page,PLC堆栈溢出直接覆盖紧邻内存区(如全局变量DB块、甚至固件代码段),导致:
- 变量值随机跳变(如温度设定值突变为-32768);
- 程序计数器(PC)指向非法地址 → CPU立即停机,ERROR LED常亮;
- 最坏情况:固件校验失败,需强制固件重刷。
✅ 关键结论:堆栈溢出不是“概率事件”,而是“确定性崩溃”,其触发阈值取决于具体硬件、编译选项、参数规模,无法在开发阶段穷尽测试。
三、所谓“安全递归”的三大幻觉与破除方法
部分工程师声称在某些场景下“已稳定运行递归多年”。经核查,均为以下三类误判:
| 幻觉类型 | 典型表现 | 真相剖析 | 安全替代方案 |
|---|---|---|---|
| 静态深度幻觉 | “只允许最大调用3层,已加if限制” | 深度限制无法防止堆栈碎片化累积;多任务并发时,3层×N任务 = 实际深度翻倍 | 改用循环+状态机:<br>WHILE x > 0 DO sum := sum + x; x := x - 1; END_WHILE |
| 无局部变量幻觉 | “函数只有输入输出,没声明VAR,肯定不占栈” | 即使无显式VAR,编译器仍需保存返回地址、形参副本、临时计算结果(如 x-1 的中间值) |
提取为全局工作区:<br>声明 GVL_Work: STRUCT counter: INT; sum: INT; END_STRUCT,用 FOR 或 REPEAT 驱动 |
| 调试模式幻觉 | “在线监控时递归正常,下载后也OK” | 调试模式禁用部分优化,且监视窗口本身占用额外内存;正式运行时启用所有优化,堆栈布局改变 | 强制静态分析:<br>在TIA Portal中启用 Tools > Options > General > Show stack usage per block,查看编译报告中的 Stack Size 字段 |
四、工业级替代方案:4种零风险等效实现
方案1:迭代循环(最推荐)
适用场景:数学求和、阶乘、数组遍历、树形结构扁平化
核心原则:用 FOR/WHILE/REPEAT 替代函数调用,用变量存储中间状态。
// ❌ 危险递归(阶乘)
FUNCTION F_Factorial_Rec : DINT
VAR_INPUT n : DINT; END_VAR
IF n <= 1 THEN F_Factorial_Rec := 1;
ELSE F_Factorial_Rec := n * F_Factorial_Rec(n-1);
END_IF
// ✅ 安全迭代
FUNCTION F_Factorial_Iter : DINT
VAR_INPUT n : DINT; END_VAR
VAR
i : DINT;
result : DINT := 1;
END_VAR
FOR i := 2 TO n DO
result := result * i;
END_FOR
F_Factorial_Iter := result;
方案2:状态机驱动(处理复杂逻辑分支)
适用场景:多步骤计算、条件依赖链、需暂停/恢复的流程
核心原则:将递归的“调用栈”显式转化为状态变量+循环。
// 示例:计算斐波那契数列第n项(避免指数级递归)
TYPE ST_FibCalc :
STRUCT
state : (sInit, sCalc, sDone);
n : UINT;
a : UINT := 0; // F(i-2)
b : UINT := 1; // F(i-1)
i : UINT := 1; // 当前步数
result : UINT;
END_STRUCT
END_TYPE
// 在主程序中调用:
IF StartCalc THEN
fbFib.state := sInit;
fbFib.n := TargetN;
END_IF
CASE fbFib.state OF
sInit:
IF fbFib.n <= 1 THEN
fbFib.result := fbFib.n;
fbFib.state := sDone;
ELSE
fbFib.state := sCalc;
END_IF
sCalc:
fbFib.i := fbFib.i + 1;
fbFib.a := fbFib.b;
fbFib.b := fbFib.a + fbFib.b;
IF fbFib.i >= fbFib.n THEN
fbFib.result := fbFib.b;
fbFib.state := sDone;
END_IF
sDone:
// 输出fbFib.result,复位状态
END_CASE
方案3:预分配数组模拟栈(仅限已知最大深度)
适用场景:解析嵌套括号、XML标签、有限深度树遍历
核心原则:用固定大小数组+索引指针模拟栈的PUSH/POP。
// 模拟栈深上限10层
VAR_GLOBAL
g_stk_depth : INT := 0;
g_stk_data : ARRAY[0..9] OF INT;
END_VAR
// PUSH操作(在循环中调用)
IF need_push THEN
IF g_stk_depth < 10 THEN
g_stk_data[g_stk_depth] := new_value;
g_stk_depth := g_stk_depth + 1;
ELSE
// 处理溢出:报错或截断
ErrorFlag := TRUE;
END_IF
END_IF
// POP操作
IF need_pop AND g_stk_depth > 0 THEN
g_stk_depth := g_stk_depth - 1;
current_value := g_stk_data[g_stk_depth];
END_IF
方案4:异步分片处理(应对超长计算)
适用场景:需处理数千点数据,单周期无法完成
核心原则:将大任务切分为小块,每周期处理一块,用静态变量保存进度。
FUNCTION_BLOCK FB_ProcessArray
VAR
arr : ARRAY[0..9999] OF REAL;
startIndex : INT := 0;
chunkSize : INT := 100; // 每周期处理100点
processedCount : INT := 0;
END_VAR
METHOD ProcessChunk : BOOL
VAR
i : INT;
END_VAR
IF startIndex + chunkSize <= SIZEOF(arr)/SIZEOF(REAL) THEN
FOR i := startIndex TO startIndex + chunkSize - 1 DO
arr[i] := arr[i] * 1.05; // 示例处理
END_FOR
startIndex := startIndex + chunkSize;
processedCount := processedCount + chunkSize;
ProcessChunk := FALSE; // 未完成,需继续
ELSE
ProcessChunk := TRUE; // 完成
END_IF
五、编译器与调试工具的验证方法(实操清单)
-
编译阶段必查项:
- 在TIA Portal中,右键项目 →
Properties > Compiler > Enable stack usage analysis→ 重新编译,检查Diagnostics窗口是否有Stack size exceeded提示; - 在Codesys中,启用
Project > Options > Code Generation > Show stack usage in build report。
- 在TIA Portal中,右键项目 →
-
运行时监控:
- 使用PLC内置诊断寄存器:Siemens S7-1500的
SR0寄存器中STK_ERR位; - 在ST中插入堆栈水位检查(需厂商支持):
IF __GET_STACK_USAGE() > 7500 THEN // 单位:字节 TriggerAlarm('STACK_CRITICAL'); END_IF
- 使用PLC内置诊断寄存器:Siemens S7-1500的
-
静态代码扫描:
- 使用PLC厂商提供的静态分析工具(如Rockwell’s Logix Designer Analyzer);
- 或第三方工具(如LDRA Testbed)配置规则:
禁止在FUNCTION/FUNCTION_BLOCK内出现函数名自身调用。
六、最后的硬性红线(必须刻入开发规范)
- 禁止在任何生产代码中出现任何形式的自调用:包括直接调用(
F_X())、间接调用(通过指针pFunc := ADR(F_X); pFunc^();)、或跨FB调用形成环路(FB_A调FB_B,FB_B又调FB_A); - 禁止在中断组织块(OB30–OB38)中使用任何非原子操作:因中断抢占会进一步挤压堆栈余量;
- 所有新功能块必须通过堆栈使用率审查:若单个FB堆栈占用 > 512字节,必须重构;
- 代码评审 checklist 第一条:
□ 已确认无递归调用 □ 堆栈报告已归档至版本库。

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