ST语言递归调用深度过大导致堆栈溢出的迭代改写

发布于 2026-03-17 10:32:55 · 浏览 5 次 · 评论 0 条

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...”,应立即怀疑递归深度失控,并启动迭代改写。


一、识别递归风险:三步定位法

  1. 扫描所有 FUNCTION / FUNCTION_BLOCK 定义
    在工程中全局搜索 FUNCTIONFUNCTION_BLOCK 关键字,逐一检查其内部是否包含对自身名称的直接调用(如 MyFilter(...)MyFilter 函数体内出现),或通过中间函数间接调用(如 A() 调用 B()B() 再调用 A())。

  2. 绘制调用链并估算最坏深度
    对每个疑似递归单元,手工推演输入边界条件下的调用次数。例如:

    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层安全阈值)。

  3. 启用编译器栈使用报告(若支持)
    部分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 循环替代递归调用,每次迭代更新 ab
  • 移除所有函数自调用语句,确保无栈增长。

策略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,利用实例变量 memois_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 输入控制启动时机,防止误触发。

三、硬性防护措施(必须添加)

即使完成迭代改写,仍需部署三重防线,确保万无一失:

  1. 栈深度硬限检查
    在所有循环入口添加计数器,超限时强制退出:

    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
  2. 全局栈使用监控(针对Codesys/TwinCAT)
    调用系统函数获取实时栈用量:

    // Codesys示例:读取当前任务栈使用量(单位:字节)
    stack_used := __GET_TASK_STACK_USAGE(__CURRENT_TASK);
    IF stack_used > 6000 THEN // 预警阈值
        set_alarm(ALARM_HIGH_STACK_USAGE);
    END_IF
  3. 编译期禁用递归(推荐)
    在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代码不再依赖看不见的栈,而由你亲手掌控的变量和循环驱动时,自动化系统才真正拥有了工业级的健壮根基。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文