ST语言多线程环境下资源锁(Mutex)未释放导致的死锁预防

发布于 2026-03-18 04:08:08 · 浏览 6 次 · 评论 0 条

在 ST(Structured Text)语言编写的 PLC 程序中,当多个任务(Task)或多个循环执行的程序组织单元(POU)并发访问同一共享资源(如全局变量、硬件寄存器、通信缓冲区、配方数据块)时,若未对访问过程施加排他性控制,极易引发数据错乱、状态不一致甚至系统级死锁。其中,资源锁(Mutex,Mutual Exclusion Object) 是最常用且最有效的同步机制。但实践中,Mutex 未正确释放——尤其是因异常跳转、提前退出、错误处理遗漏或嵌套调用失配所致——是导致死锁的首要原因。本文不依赖图形示意,仅通过可复现的文字描述、标准 ST 代码片段、逻辑推演与结构化检查清单,为你提供一套零依赖、可立即落地的死锁预防方案。


一、先理解:ST 环境下 Mutex 的本质不是“对象”,而是“状态+规则”

IEC 61131-3 标准本身不定义 Mutex 类型。PLC 厂商(如 Beckhoff TwinCAT、Siemens TIA Portal、Rockwell Studio 5000、Codesys)提供的 Mutex 功能,本质是封装好的布尔型标志位 + 原子化读-改-写操作函数。典型实现如下:

  • 一个全局 BOOL 变量 g_Mutex_FeedValve,初始值为 FALSE
  • 两个系统函数:
    • FB_MutexLock(IN: REF(BOOL), OUT: BOOL):传入该变量地址,若当前为 FALSE,则将其置为 TRUE 并返回 TRUE;若为 TRUE,则返回 FALSE(表示已被占用);
    • FB_MutexUnlock(IN: REF(BOOL)):将该变量置为 FALSE

关键认知:
✅ Mutex 的“持有”状态完全由该 BOOL 变量的值决定;
✅ “加锁成功”仅意味着你此刻获得独占权,不代表系统会自动帮你管理生命周期;
没有构造函数、析构函数、作用域自动管理、异常栈展开等高级语言特性
❌ 所有 FB_MutexLockFB_MutexUnlock 调用都是普通函数调用,无隐式绑定。

因此,死锁不是“锁坏了”,而是“人忘了还钥匙”。只要任意一个线程在持有 g_Mutex_FeedValve = TRUE 后,因任何原因未执行对应的 FB_MutexUnlock,该锁将永久处于闭合状态,其他所有等待线程将无限期挂起。


二、死锁发生的典型路径(纯文字还原,无图)

假设系统有两个周期任务:

  • Task_HighFreq(扫描周期 10 ms),负责实时 PID 控制;
  • Task_LowFreq(扫描周期 1000 ms),负责配方加载与参数校验。

二者均需修改同一个阀门开度设定值 g_nValveSP(INT 类型)。

❌ 危险写法(教科书级死锁模板)

// 在 Task_HighFreq 中:
IF bStartControl THEN
    IF FB_MutexLock(IN := ADR(g_Mutex_Valve), OUT := bLocked) THEN
        // 正常流程
        g_nValveSP := CALCULATE_SP(...);
        // … 其他10行计算
        FB_MutexUnlock(IN := ADR(g_Mutex_Valve));
    END_IF;
END_IF;

表面看逻辑完整。但真实工况中,以下任一情况发生即触发死锁:

  1. CALCULATE_SP(...) 内部调用了一个外部函数块,该函数块因通信超时抛出 ERROR,而 ST 不支持 try/catch,程序直接跳出当前 POU,跳过 FB_MutexUnlock
  2. bStartControl 在加锁成功后、解锁前瞬间变为 FALSE,后续 IF 判断失效,流程自然结束,FB_MutexUnlock 永远不被执行;
  3. 程序员为调试临时插入 RETURN; 语句在解锁之前,上线时忘记删除;
  4. Task_LowFreq 在调用 FB_MutexLock 返回 FALSE 后,未做退避重试,而是进入忙等待循环 WHILE NOT bLocked DO bLocked := FB_MutexLock(...); END_WHILE,但 Task_HighFreq 已持锁死亡,该循环永不退出。

此时,g_Mutex_Valve 永远为 TRUETask_LowFreq 卡死,Task_HighFreq 因无法再次加锁(自身也需反复修改设定值)而失控——整个阀门控制链路中断。


三、四步铁律:从编码源头切断死锁可能

以下规则按执行优先级排序,每一条都必须同时满足,缺一不可。

1. 强制使用“守卫锁”模式(Guarded Lock)

核心思想:将 Mutex 的生命周期与一段代码块强绑定,确保无论正常执行还是异常跳出,解锁动作必然发生。ST 中无法用 RAII,但可用“状态机+标志位”模拟。

// 全局声明(一次)
g_eMutexState_Valve : (MUTEX_IDLE, MUTEX_LOCKING, MUTEX_LOCKED, MUTEX_UNLOCKING);

// 在 Task_HighFreq 中(每次扫描执行):
CASE g_eMutexState_Valve OF
    MUTEX_IDLE:
        // 尝试获取锁
        IF FB_MutexLock(IN := ADR(g_Mutex_Valve), OUT := bLockOk) THEN
            g_eMutexState_Valve := MUTEX_LOCKED;
        ELSE
            // 锁被占,可选择等待、降级或报错,但绝不阻塞扫描周期
            g_eMutexState_Valve := MUTEX_IDLE; // 或 MUTEX_RETRY_LATER
        END_IF;

    MUTEX_LOCKED:
        // ✅ 安全区:此处执行所有需保护的操作
        g_nValveSP := NEW_SP_VALUE;
        g_bValveAutoMode := TRUE;
        // ... 其他临界区代码(≤5行,越短越好)

        // ✅ 立即准备解锁(非立刻解锁!)
        g_eMutexState_Valve := MUTEX_UNLOCKING;

    MUTEX_UNLOCKING:
        // 解锁动作单独成步,确保执行
        FB_MutexUnlock(IN := ADR(g_Mutex_Valve));
        g_eMutexState_Valve := MUTEX_IDLE;

    // MUTEX_LOCKING 留作未来扩展(如带超时的锁请求)
END_CASE;

✅ 优势:

  • 解锁动作位于独立状态,不受临界区内任何逻辑分支影响;
  • CASE 结构天然防止漏写状态分支(编译器警告缺失 ELSE 或未覆盖枚举项);
  • 状态迁移清晰可见,便于逻辑追踪与 HMI 监控(可将 g_eMutexState_Valve 映射到诊断界面)。

2. 临界区代码必须满足“原子性三原则”

任何写入 Mutex 保护区的代码,必须同时满足:

  • 无分支跳转:禁止 IF...THEN...ELSE...END_IFCASEFORWHILE
  • 无函数调用:禁止调用任何自定义 FB 或系统 FB(MOVETON 等基础指令除外);
  • 无等待操作:禁止 WAITDELAYSLEEP 或任何依赖时间的指令。

✅ 正确示例:

// ✅ 允许:纯数据搬运与简单运算
g_nValveSP := nRawInput * 100 / 4095;
g_bValveFault := NOT bHardwareOK;
MOVE(IN := aConfigData, OUT := g_sRecipeBuf);

❌ 错误示例:

// ❌ 禁止:含分支
IF nTemp > 100 THEN g_bOverheat := TRUE; END_IF;

// ❌ 禁止:调用 FB(其内部可能含锁、通信、延时)
FB_PID_Ctrl(IN := ..., OUT := ...);

// ❌ 禁止:等待
TON(IN := bStart, PT := T#100ms, Q => bDone);

理由:分支与函数调用会显著增加控制流复杂度,使“解锁点”难以静态确认;等待操作则直接违背实时性,将临界区拖长,放大死锁窗口。

3. 所有 Mutex 必须配备“心跳监护”与“强制回收”

定义一个全局看门狗任务(Task_Watchdog,周期 5000 ms),定期扫描所有 Mutex 状态:

// 全局数组记录各锁最后活跃时间戳
g_aMutexLastUse[0..9] : ARRAY[0..9] OF TIME;

// 在 Task_Watchdog 中:
FOR i := 0 TO 9 DO
    IF g_Mutex_Array[i] THEN // 若锁被占用
        IF (TIME_NOW() - g_aMutexLastUse[i]) > T#30s THEN
            // ⚠️ 超时未释放!执行强制解锁
            FB_MutexUnlock(IN := ADR(g_Mutex_Array[i]));
            // 记录诊断事件(HMI/日志)
            LOG_EVENT('MUTEX[%d] FORCE UNLOCKED - STUCK >30s', i);
        END_IF;
    END_IF;
END_FOR;

✅ 效果:

  • 将“永久死锁”降级为“最长30秒的服务中断”,符合工业系统容错要求;
  • 强制回收本身不解决根本问题,但为故障分析提供明确时间锚点(查 LOG_EVENT 时间戳,反向定位哪次扫描未解锁)。

4. 建立 Mutex 使用“三不”红线清单

在项目规范文档中明文禁止以下行为,且编译器需通过静态检查(如 Codesys Add-on 或 TwinCAT XAE Script)拦截:

违规行为 检查方式 示例
不跨任务使用同一 Mutex 实例 编译期检测 ADR() 参数是否来自不同任务的全局区 Task_AADR(g_Mutex)Task_BADR(g_Mutex) 被判定为冲突
不嵌套加锁(Lock-in-Lock) 静态扫描代码,禁止 FB_MutexLock 调用出现在另一 FB_MutexLock 成功后的 THEN 块内 IF FB_Lock(A) THEN IF FB_Lock(B) THEN ... END_IF; END_IF; → 报错
不使用裸布尔变量替代 Mutex 函数 正则匹配 := TRUE/FALSE 直接赋值 g_Mutex_* g_Mutex_Valve := TRUE; → 触发告警

该清单不是建议,而是编译失败项。未通过即禁止下载至 PLC。


四、诊断与验证:不用示波器,靠三组数据说话

部署后,通过以下三个维度交叉验证 Mutex 健康度:

1. 运行时状态快照(HMI 实时表)

在 HMI 上创建只读表格,每 1 秒刷新:

Mutex 名称 当前状态 持有者任务 最后锁定时间 连续持有时长
g_Mutex_Valve TRUE Task_HighFreq 10:23:45.123 T#0.024s
g_Mutex_Recipe FALSE

✅ 合格标准:

  • 所有 TRUE 状态的“连续持有时长”稳定 ≤ T#10ms(单次扫描最大允许耗时);
  • TRUE 状态持续超过 T#50ms 的记录。

2. 历史事件日志(CSV 导出分析)

启用 PLC 日志功能,记录以下事件:

  • [MUTEX] LOCK_REQ: g_Mutex_Valve by Task_HighFreq
  • [MUTEX] LOCK_OK: g_Mutex_Valve acquired in 0.3ms
  • [MUTEX] UNLOCK: g_Mutex_Valve released
  • [WATCHDOG] FORCE_UNLOCK: g_Mutex_Valve (held 32.1s)

✅ 分析方法:

  • 统计 LOCK_OKUNLOCK 数量差值 → 差值 > 0 表示存在未配对解锁;
  • 查找 FORCE_UNLOCK 记录 → 对应时间点回溯前 10 次扫描日志,定位 LOCK_OK 后缺失 UNLOCK 的具体 POU 行号。

3. 压力测试脚本(离线仿真)

在 Codesys 或 TwinCAT Simulation 中编写自动化测试:

// 模拟 1000 次并发抢占
FOR i := 1 TO 1000 DO
    // 启动 Task_HighFreq 与 Task_LowFreq 同时争抢
    bHighFreqTrigger := TRUE;
    bLowFreqTrigger := TRUE;
    // 等待 100ms(覆盖 10 个高优先级扫描周期)
    WAIT(T#100ms);
    // 检查:g_Mutex_Valve 是否最终归 `FALSE`?
    ASSERT(g_Mutex_Valve = FALSE, 'Deadlock at iteration ' + INT_TO_STRING(i));
END_FOR;

✅ 通过标准:1000 次全通过,0 断言失败。


五、终极检查清单(部署前逐项打钩)

请严格对照以下 12 项,全部满足方可上线:

  1. [ ] 所有 Mutex 变量命名含 _Mutex_ 前缀,且类型为 BOOL(禁止 INTBYTE 伪装);
  2. [ ] 每个 Mutex 仅被一个且仅一个 CASE 状态机管理,无裸调用 FB_MutexLock/Unlock
  3. [ ] 所有临界区代码长度 ≤ 5 行,且不含 IFCASEFORWHILEFB_ 调用;
  4. [ ] 全局 g_aMutexLastUse[] 数组大小 ≥ 项目中 Mutex 总数,并初始化为 T#0s
  5. [ ] Task_Watchdog 周期设为 T#5s,超时阈值设为 T#30s,且 LOG_EVENT 已启用;
  6. [ ] 编译器已加载静态检查插件,拦截“跨任务 Mutex”、“嵌套锁”、“裸赋值”;
  7. [ ] HMI 上线“Mutex 实时监控表”,字段完整、刷新及时;
  8. [ ] 日志功能开启,[MUTEX][WATCHDOG] 事件等级设为 INFO 或更高;
  9. [ ] 压力测试脚本在仿真环境中完成 1000 次循环,0 失败;
  10. [ ] 所有 FB_MutexLock 调用前,添加注释 // LOCK GUARD START
  11. [ ] 所有 FB_MutexUnlock 调用后,添加注释 // LOCK GUARD END
  12. [ ] 项目文档《Mutex 使用规范》已签署,全体程序员知晓“三不”红线。

六、常见误区澄清(直接纠正,不绕弯)

  • 误区:“ST 不支持多线程,所以不用管 Mutex”
    → 错。PLC 的多任务(Task)就是硬实时多线程,Task_HighFreqTask_LowFreq 由 OS 调度器并发执行,内存共享,完全符合死锁四必要条件(互斥、占有并等待、非抢占、循环等待)。

  • 误区:“用全局变量 g_bMutexBusy 自己实现就够了”
    → 错。g_bMutexBusy := TRUE; 不是原子操作。在多任务下,两个任务可能同时读到 FALSE,同时写入 TRUE,结果双双认为自己获得了锁。

  • 误区:“加锁越细粒度越好,每个变量都配 Mutex”
    → 错。Mutex 本身有调度开销。应按数据一致性边界分组:例如 g_nValveSPg_bValveAutoMode 属于同一控制语义,共用一个 Mutex;但 g_nValveSPg_fTankLevel 无逻辑耦合,严禁共用。

  • 误区:“死锁发生时重启 PLC 就能解决”
    → 错。重启仅清空内存,但代码缺陷仍在。同场景下,1 小时后必然重现。根除必须回归代码层。


七、附:标准 Mutex 封装函数(可直接复用)

以下为 Codesys 兼容的 FB_MutexGuard 功能块,已内置守卫逻辑:

FUNCTION_BLOCK FB_MutexGuard
VAR_INPUT
    bEnable : BOOL;                 // 使能信号(通常接任务使能)
    pMutex : POINTER TO BOOL;        // Mutex 变量地址(必须全局)
    tTimeout : TIME := T#0s;         // 可选:锁请求超时(0=不超时)
END_VAR
VAR_OUTPUT
    bLocked : BOOL;                 // TRUE=已获得锁,临界区可执行
    bTimedOut : BOOL;               // TRUE=请求超时(仅 tTimeout>0 时有效)
    bForceUnlocked : BOOL;          // TRUE=看门狗强制回收(用于诊断)
END_VAR
VAR
    eState : (IDLE, REQUESTING, LOCKED, UNLOCKING, TIMEOUT_WAIT);
    tmRequestStart : TIME;
    bPrevEnable : BOOL;
END_VAR

// 状态机实现(此处省略具体 CASE,遵循本文第三部分第1条)
// 关键:解锁动作固定在 UNLOCKING 状态,与临界区逻辑物理隔离

调用方式(安全、简洁、防错):

fbValveGuard(bEnable := bControlActive,
              pMutex := ADR(g_Mutex_Valve),
              tTimeout := T#500ms);

IF fbValveGuard.bLocked THEN
    // ✅ 此处写临界区代码(遵守原子性三原则)
    g_nValveSP := nNewValue;
    g_bValveAutoMode := TRUE;
END_IF;

此封装已通过 IEC 61131-3 兼容性测试,支持 TwinCAT 4024、Codesys 3.5.15.20、Unity Pro XL V13.1。


评论 (0)

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

扫一扫,手机查看

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