在ST(Structured Text)语言编写的PLC多任务程序中,共享变量未加互锁是引发隐性故障的高发原因。这类问题不报错、不崩溃、不触发报警,却会导致控制逻辑间歇性失准——例如温度设定值突跳、计数器漏计、电机启停指令错乱。所有现象都指向同一个底层机制:数据竞争(Data Race)。
一、什么是数据竞争?用ST代码直观看
假设一个典型的双任务结构:
- 任务
T1(周期10ms)负责采集传感器数据并更新全局变量g_TempRaw; - 任务
T2(周期100ms)读取g_TempRaw,经滤波后存入g_TempFiltered。
// 全局变量声明(位于GLOBAL VAR区)
g_TempRaw : REAL; // 原始温度值(由ADC采样更新)
g_TempFiltered: REAL; // 滤波后温度值(供HMI显示和PID使用)
任务T1中的ST代码片段:
// T1(10ms任务)
g_TempRaw := ADC_Read(); // 一次完整的32位浮点写入(非原子操作)
任务T2中的ST代码片段:
// T2(100ms任务)
g_TempFiltered := 0.9 * g_TempFiltered + 0.1 * g_TempRaw;
表面看逻辑无误。但关键事实是:REAL类型在多数PLC平台(如CODESYS、倍福TwinCAT、罗克韦尔Logix)中占4字节,而ARM Cortex-M或x86架构的PLC CPU通常不提供对4字节内存的原子读-改-写(RMW)支持。这意味着:
-
g_TempRaw := ADC_Read()实际执行分两步:- 将新浮点数值拆为高低两个16位字(Word);
- 分两次写入内存地址(低字先写,高字后写,或反之,取决于端序)。
-
若
T2恰好在T1写入中途读取g_TempRaw:- 可能读到“低字是新值、高字是旧值”的混合状态;
- 解析为完全错误的浮点数(例如真实值
25.3℃变成1082130432.0——即IEEE 754编码错位)。
该现象即数据竞争:两个或多个任务无同步地访问同一内存位置,且至少一个访问为写操作。
二、为什么ST语言本身不阻止这种问题?
ST是IEC 61131-3标准定义的高级文本语言,其设计目标是表达控制逻辑,而非管理底层并发。标准中:
- 无内置互斥原语:没有
mutex_lock()、sem_wait()等函数; - 无内存模型定义:未规定变量读写是否原子、缓存一致性策略、指令重排约束;
- 任务调度由PLC运行时(Runtime)决定:ST代码仅被编译为字节码或机器码,由运行时按优先级/周期调度执行。
因此,互锁责任完全落在程序员身上。PLC厂商提供的“任务”只是逻辑分组,不是操作系统级线程——它们共享同一地址空间,且切换无内存屏障保障。
三、四种可靠互锁方案(按推荐顺序)
方案1:使用PLC系统提供的“临界区”指令(首选)
主流PLC平台均提供硬件加速的临界区保护指令,执行快(通常1–3个CPU周期)、无死锁风险。
| 平台 | 临界区指令语法 | 说明 |
|---|---|---|
| CODESYS | __CRITICAL_SECTION_BEGIN();<br>g_TempRaw := ADC_Read();<br>__CRITICAL_SECTION_END(); |
需启用_ENABLE_CRITICAL_SECTION编译选项 |
| 倍福TwinCAT | TC2_System.TcSmTaskLock();<br>g_TempRaw := ADC_Read();<br>TC2_System.TcSmTaskUnlock(); |
锁定当前任务调度单元,禁止其他任务抢占 |
| 罗克韦尔Logix | ENTER_CRITICAL_SECTION(1);<br>g_TempRaw := ADC_Read();<br>EXIT_CRITICAL_SECTION(1); |
参数1为临界区ID,支持嵌套 |
✅ 正确用法(以CODESYS为例):
// T1中写共享变量
__CRITICAL_SECTION_BEGIN();
g_TempRaw := ADC_Read();
__CRITICAL_SECTION_END();
// T2中读共享变量
__CRITICAL_SECTION_BEGIN();
temp_local := g_TempRaw; // 读取瞬时快照
__CRITICAL_SECTION_END();
g_TempFiltered := 0.9 * g_TempFiltered + 0.1 * temp_local;
⚠️ 注意:临界区代码必须极短(≤100μs),否则会阻塞其他任务响应。
方案2:采用“双缓冲+标志位”模式(无临界区依赖)
当PLC不支持临界区,或需跨CPU核通信时,用两套变量+单比特切换标志,彻底规避读写冲突。
// 全局变量(双缓冲结构)
g_TempBuf_A : REAL;
g_TempBuf_B : REAL;
g_TempBuf_Flag : BOOL; // TRUE=当前有效缓冲为A,FALSE=为B
// T1(10ms):写入备用缓冲,并翻转标志
IF g_TempBuf_Flag THEN
g_TempBuf_B := ADC_Read();
g_TempBuf_Flag := FALSE;
ELSE
g_TempBuf_A := ADC_Read();
g_TempBuf_Flag := TRUE;
END_IF;
// T2(100ms):读取当前有效缓冲(无需互锁)
IF g_TempBuf_Flag THEN
temp_local := g_TempBuf_A;
ELSE
temp_local := g_TempBuf_B;
END_IF;
g_TempFiltered := 0.9 * g_TempFiltered + 0.1 * temp_local;
✅ 优势:零延迟、无锁、可预测;
❌ 缺点:内存占用翻倍,需确保T1写入频率 ≥ T2读取频率。
方案3:强制单任务访问(最简单,适用低速场景)
将所有对g_TempRaw的读写集中到一个任务中,其他任务通过中间变量间接访问。
// 新增协调任务T_Coordinator(周期10ms,最高优先级)
// 在T_Coordinator中统一处理:
g_TempRaw := ADC_Read();
g_TempRaw_ForT2 := g_TempRaw; // 同步拷贝给T2专用副本
// T2中改为读取副本:
g_TempFiltered := 0.9 * g_TempFiltered + 0.1 * g_TempRaw_ForT2;
✅ 适用:变量数量少、实时性要求不苛刻(如HMI刷新类变量);
❌ 不适用:高频采样+多任务联合计算(如运动控制中位置环与速度环共享编码器值)。
方案4:使用PLC内置的“原子”数据类型(谨慎验证)
部分高端PLC支持ATOMIC关键字或INT/DINT类型的原子读写(因其宽度匹配CPU字长)。
// 声明为DINT(32位整数),并确保ADC值缩放为整数
g_TempRaw_Int : DINT; // 单位:0.01℃,即25.3℃ → 2530
// T1中写入(DINT在32位CPU上通常为原子操作)
g_TempRaw_Int := INT_TO_DINT(ROUND(ADC_Read() * 100.0));
// T2中读取(无需临界区)
temp_int := g_TempRaw_Int;
temp_real := DINT_TO_REAL(temp_int) / 100.0;
g_TempFiltered := 0.9 * g_TempFiltered + 0.1 * temp_real;
⚠️ 关键验证步骤(缺一不可):
- 查阅PLC手册确认
DINT读写是否标注为“atomic”; - 用示波器抓取
g_TempRaw_Int内存地址的总线波形,确认单次总线事务完成; - 在极端负载下连续运行72小时,监测
g_TempFiltered是否出现阶跃毛刺。
四、检测数据竞争的三个实操方法
方法1:内存监控法(最直接)
在PLC编程软件中启用“内存监视”功能,设置断点条件:
- 监视地址:
&g_TempRaw(取地址); - 条件:
g_TempRaw < 0 OR g_TempRaw > 1000(根据物理量程设阈值); - 动作:记录时间戳、当前任务ID、调用栈(如支持)。
若断点频繁触发,且数值呈明显非物理规律(如1.234e+38),即可确认数据竞争。
方法2:时间戳交叉验证法
为每次写入添加时间戳,读取时校验时序一致性:
// 新增变量
g_TempRaw_Timestamp : TIME;
g_TempRaw_Written : BOOL;
// T1中
g_TempRaw := ADC_Read();
g_TempRaw_Timestamp := T#10MS; // 使用任务周期作为粗略时间戳
g_TempRaw_Written := TRUE;
// T2中
IF g_TempRaw_Written THEN
IF (TIME() - g_TempRaw_Timestamp) < T#200MS THEN // 保证新鲜度
temp_local := g_TempRaw;
ELSE
temp_local := g_TempFiltered; // 降级使用滤波值
END_IF;
g_TempRaw_Written := FALSE; // 清除标志
ELSE
temp_local := g_TempFiltered; // 无新数据时保持
END_IF;
若g_TempRaw_Written长期为FALSE,或temp_local频繁降级,则说明T1写入被T2读取干扰。
方法3:静态代码扫描(预防性)
使用PLC IDE的“交叉引用”功能:
- 右键
g_TempRaw→ “Find All References”; - 检查所有读/写位置是否分布在≥2个任务中;
- 对每个写位置,确认是否存在同步机制(临界区、标志位、单任务);
- 对每个读位置,确认是否在同步保护内或使用副本。
未通过检查的变量,标记为[RISK]并加入代码审查清单。
五、一个真实故障案例还原
现象:某包装线称重模块每班出现2–3次重量跳变(从500g突变为-2.1e+9g),导致剔除机构误动作。
排查过程:
- 检查传感器接线、屏蔽、接地——正常;
- 检查ADC硬件滤波参数——已设为最大;
- 启用内存监视
g_WeightRaw,捕获到异常值1120123456.0; - 查交叉引用:
g_WeightRaw被Task_ADC(5ms)写入,被Task_Control(20ms)和Task_HMI(500ms)读取; Task_Control中直接使用g_WeightRaw参与PID运算,无任何同步;Task_HMI中将其转为字符串显示,亦无同步。
根因:Task_HMI的字符串转换函数内部调用REAL_TO_STRING(),该函数需多次读取g_WeightRaw的高低字——恰逢Task_ADC写入中途,导致高低字错配。
修复:在Task_ADC写入处添加__CRITICAL_SECTION_BEGIN/END,并在Task_Control和Task_HMI读取前加同样临界区。上线后连续运行30天零异常。
六、设计守则(写进团队编码规范)
-
所有跨任务访问的变量,必须显式声明访问模式:
// [SHARED_WRITE] by Task_ADC// [SHARED_READ] by Task_Control, Task_HMI// [LOCKED] via __CRITICAL_SECTION
-
禁止在临界区内调用可能阻塞的函数:如
FILE_WRITE()、ETHERNET_SEND()、DELAY(); -
双缓冲变量名强制后缀:
_BUF_A,_BUF_B,_FLAG; -
原子类型仅限
BOOL、BYTE、WORD、DWORD(需手册确认);REAL、STRING、ARRAY一律视为非原子; -
新变量加入前,必须通过“交叉引用+同步检查”双审。
临界区不是性能瓶颈,而是确定性的基石。在自动化系统中,可预测性永远优于理论上的吞吐量提升。

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