在 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_MutexLock 和 FB_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;
表面看逻辑完整。但真实工况中,以下任一情况发生即触发死锁:
CALCULATE_SP(...)内部调用了一个外部函数块,该函数块因通信超时抛出ERROR,而 ST 不支持try/catch,程序直接跳出当前 POU,跳过FB_MutexUnlock;bStartControl在加锁成功后、解锁前瞬间变为FALSE,后续IF判断失效,流程自然结束,FB_MutexUnlock永远不被执行;- 程序员为调试临时插入
RETURN;语句在解锁之前,上线时忘记删除; Task_LowFreq在调用FB_MutexLock返回FALSE后,未做退避重试,而是进入忙等待循环WHILE NOT bLocked DO bLocked := FB_MutexLock(...); END_WHILE,但Task_HighFreq已持锁死亡,该循环永不退出。
此时,g_Mutex_Valve 永远为 TRUE,Task_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_IF、CASE、FOR、WHILE; - 无函数调用:禁止调用任何自定义 FB 或系统 FB(
MOVE、TON等基础指令除外); - 无等待操作:禁止
WAIT、DELAY、SLEEP或任何依赖时间的指令。
✅ 正确示例:
// ✅ 允许:纯数据搬运与简单运算
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_A 中 ADR(g_Mutex) 与 Task_B 中 ADR(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_OK与UNLOCK数量差值 → 差值 > 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 项,全部满足方可上线:
- [ ] 所有 Mutex 变量命名含
_Mutex_前缀,且类型为BOOL(禁止INT或BYTE伪装); - [ ] 每个 Mutex 仅被一个且仅一个
CASE状态机管理,无裸调用FB_MutexLock/Unlock; - [ ] 所有临界区代码长度 ≤ 5 行,且不含
IF、CASE、FOR、WHILE、FB_调用; - [ ] 全局
g_aMutexLastUse[]数组大小 ≥ 项目中 Mutex 总数,并初始化为T#0s; - [ ]
Task_Watchdog周期设为T#5s,超时阈值设为T#30s,且LOG_EVENT已启用; - [ ] 编译器已加载静态检查插件,拦截“跨任务 Mutex”、“嵌套锁”、“裸赋值”;
- [ ] HMI 上线“Mutex 实时监控表”,字段完整、刷新及时;
- [ ] 日志功能开启,
[MUTEX]与[WATCHDOG]事件等级设为INFO或更高; - [ ] 压力测试脚本在仿真环境中完成 1000 次循环,0 失败;
- [ ] 所有
FB_MutexLock调用前,添加注释// LOCK GUARD START; - [ ] 所有
FB_MutexUnlock调用后,添加注释// LOCK GUARD END; - [ ] 项目文档《Mutex 使用规范》已签署,全体程序员知晓“三不”红线。
六、常见误区澄清(直接纠正,不绕弯)
-
误区:“ST 不支持多线程,所以不用管 Mutex”
→ 错。PLC 的多任务(Task)就是硬实时多线程,Task_HighFreq与Task_LowFreq由 OS 调度器并发执行,内存共享,完全符合死锁四必要条件(互斥、占有并等待、非抢占、循环等待)。 -
误区:“用全局变量
g_bMutexBusy自己实现就够了”
→ 错。g_bMutexBusy := TRUE;不是原子操作。在多任务下,两个任务可能同时读到FALSE,同时写入TRUE,结果双双认为自己获得了锁。 -
误区:“加锁越细粒度越好,每个变量都配 Mutex”
→ 错。Mutex 本身有调度开销。应按数据一致性边界分组:例如g_nValveSP与g_bValveAutoMode属于同一控制语义,共用一个 Mutex;但g_nValveSP与g_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。

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