Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

05 | 从 C/Python 转 SCL: 27 个常见陷阱与最佳实践 (S7-1200 + TIA Portal)

面向有 10 年 C/Python 经验、刚接触 PLC 的工程师。所有行为描述以 S7-1200 + TIA Portal V16~V20 为准; 不确定处标注 (待核实)。SCL 代码中 # 前缀是块内局部/接口变量, "..." 是全局 DB/块名。

本文技术断言经三轮独立复核(运行语义 / 版本适用性 / C 类比误导性)修正, 存疑处已按官方文档改写。权威出处: TIA Portal Information System (docs.tia.siemens.cloud) 与 S7-1200 System Manual (Entry 109977302)。

目录:

  • 陷阱 1~27: 每条 = 现象 / 根因 / 正确写法 / C/Python 对照
  • 最佳实践: 命名规范、UDT 优先、状态机惯用法(完整骨架)、FB 单一职责、库化复用、TIA 项目版本管理、在线下载对 DB 的影响

陷阱 1: := 与 = 写反

现象: 编译报错, 或逻辑永远不成立。最常见三种写错: IF #a := 1 THEN#b = 5;、以及从 C 带来的 #a == 1

根因: SCL 里赋值是 :=, 比较是 =。没有 C 的 ==, 也没有 !=(不等是 <>)。赋值是语句, 比较是表达式, 二者语法位置不同。

正确写法:

// 声明区 (FB 接口)
// Input:  cmd : Bool
//         setpoint : Int
// Output: done : Bool
// Static: step : Int

// 代码区
IF #cmd = TRUE THEN        // 比较: =
    #step := #setpoint;    // 赋值: :=
END_IF;

#done := (#step = 100);    // 比较产生 Bool 表达式, 可直接赋给 Bool

C/Python 对照: 相当于 C 把 === 的职责分给两个 token。C 里 if (a = b) 是合法但致命的 bug; SCL 里 IF #a := #b THEN 直接编译失败——错误在编译期被拦住, 这是好事。反过来, 读别人代码时 IF a = b 是“等于“, 别按 C 直觉当成赋值。


陷阱 2: VAR_TEMP 不初始化, 残留脏数据

现象: FC/FB 里临时变量第一次用就读到随机值, 且 bug 间歇性出现——改了别处代码, 这个块的脏数据就变了。

根因: 临时局部数据(VAR_TEMP)在 L 栈上, 官方明确: 只在一个扫描周期内有效, “必须在读取它的那个周期内先写入”; 标准访问的 FC 中未初始化的 Temp “值可能是随机的”。它读到的是上一次恰好压在同一栈位置的其他块留下的内容。

正确写法:

// FC "FC_Avg" 接口
// Input:  value : Real
// Output: avg   : Real
// Temp:   sum   : Real
//         i     : Int

// 代码区: 先写后读, 每周期开头清零
#sum := 0.0;
FOR #i := 1 TO 10 DO
    #sum := #sum + #value;
END_FOR;
#avg := #sum / 10.0;

需要跨周期记忆的值放 Static(FB)或全局 DB, 绝不放 Temp。

C/Python 对照: 就是“读未初始化的栈变量“——和 C 的 float sum; 未初始化一样, 值是栈上残留。Python 没有这个问题(名字必须先绑定), 所以 Python 背景的人最容易踩。类比成立之处: 都读的是内存残留; 不成立之处: PLC 的 L 栈内容在两个扫描周期之间“看起来还在“, 让人误以为 Temp 能存状态——不能。


陷阱 3: 数组下标非 0 基, 以及越界的真实后果

现象: ARRAY[1..8] 被当成 ARRAY[0..7] 用, #a[0]#a[8] 越界; 更隐蔽的是 FOR 循环边界抄错。程序看起来还在正常跑, 但某个阀门永远不动。

根因: SCL 数组上下界任你声明([1..8][0..7]、甚至 [-10..10] 都合法), 与 C 的“数组名即首地址、恒 0 基“不同。越界的后果在 1200 上反直觉: 官方文档明确, S7-1200 运行期访问声明限值之外的数组元素, CPU 记入诊断缓冲并保持 RUN——不 STOP, ENO 也不会被置 FALSE(ENO 置 FALSE 的是 S7-300/400 且勾选 Check ARRAY limits 的场景; S7-1500 则直接 STOP)。

正确写法:

// FC "FC_Scan" 接口
// InOut: data : Array[0..15] of Int    // 习惯上声明成 0 基, 与 C 直觉一致
// Input: enable : Bool
// Temp:  i : DInt
// Output: firstNeg : Int

#firstNeg := 0;
FOR #i := 0 TO 15 DO          // 边界直接用常量, 或见下
    IF #data[#i] < 0 THEN
        #firstNeg := #data[#i];
        EXIT;
    END_IF;
END_FOR;

接收“任意长度数组“的通用写法用 ARRAY[*] 形参 + LOWER_BOUND/UPPER_BOUND(S7-1200 需优化块 + 固件 >= V4.2; 按系统手册表: FC 中只能声明在 Input 与 InOut 区, FB 中只能放 InOut, Output/Return 不可声明):

// FC "FC_SumAny" — InOut: arr : Array[*] of DInt;  Temp: i : DInt;  Return: sum : DInt
#sum := 0;
FOR #i := LOWER_BOUND(ARR := #arr, DIM := 1) TO UPPER_BOUND(ARR := #arr, DIM := 1) DO
    #sum := #sum + #arr[#i];
END_FOR;

C/Python 对照: C 越界是未定义行为(可能段错误); Python 越界抛 IndexError。S7-1200 越界既不崩也不抛——只有诊断缓冲里一条记录, 产线继续跑, 这是比崩溃更危险的行为。养成习惯: 数组边界永远来自声明本身(常量或 LOWER/UPPER_BOUND), 不手写魔数。注意 FOR 的循环变量是 DInt/Int 等整型, 不要用 Bool 之类。


陷阱 4: 优化 DB vs 标准 DB: 绝对地址/AT/PEEK 全部失效

现象: 第三方设备用 GET 按地址读不到你 DB 里的数据; PEEK 报错或读到错位数据; AT 覆盖声明编译不过; HMI 写 InOut 偶发丢值。

根因: TIA 新建 DB 默认勾选 “Optimized block access”。优化 DB 的元素只有符号名、没有固定绝对地址(%DBn.DBX... 不再有意义), CPU 自动紧凑排放。于是: PEEK/POKE 访问 DB 区要求标准 DB; AT 覆盖在优化块里只允许覆盖保持性(retentive)标签(标准块可覆盖任意标签); 跨 CPU 的 PUT/GET 类绝对地址访问在伙伴侧要求标准 DB; WSTRING 只能声明在优化块; ARRAY[*] 在 1200 上也要求优化块。标准 DB 则是固定偏移、兼容 S7-300/400 生态。

特性优化 DB (默认)标准 DB
符号访问支持支持
绝对地址 %DBn.DBXm.y无意义有效
PEEK/POKE 访问 DB 区不可用可用
AT 覆盖仅保持性标签任意标签
伙伴 CPU 用 GET 绝对读不可用可用
WSTRING 声明不可
存储紧凑/防错位有对齐空隙, 插变量会移动后面所有偏移

正确写法: 默认用优化 DB(安全、防地址错位); 只有确需按字节偏移映射(通信表、AT 覆盖、PEEK/POKE)的 DB 才取消勾选优化。AT 做“位视图“的例子(标准访问的 FB 内, 这是 AT 的传统合法位置):

// UDT "UDT_WordBits"(PLC 数据类型):
//   STRUCT  b0, b1, ... b15 : Bool;  END_STRUCT
//   -- 16 个 Bool 成员在 Struct 内按位打包, 共 2 字节

// FB "FB_Raw"(标准访问) 接口 Static:
//   raw  : Word;
//   bits : "UDT_WordBits" AT raw;   // AT 覆盖: 与 raw 共享同一 2 字节
//                                    (TIA 中 AT 写在接口声明的"覆盖"列)

// 代码区
#raw := 16#00FF;
IF #bits.b0 THEN ... END_IF;

AT 的硬规则(易错点): 覆盖体大小不得超过被覆盖变量; Array of Bool 的存储布局与块访问模式有关——标准块中每元素 1 字节(16 个 Bool 占 16 字节, 无法覆盖 2 字节的 Word), 优化块中才按位打包, 想稳妥地给 Word 做位视图应使用 16 个 Bool 成员的 Struct/UDT(Struct 内 Bool 按位打包, 共 2 字节); AT 声明传统上只能写在 FB/FC/OB 的接口区(全局 DB 内的 AT 需 TIA V16+ 且 1200 固件 >= V4.4 的标准 DB, 兼容性差, 不推荐); 优化块内的 AT 有限制(仅保持性标签, 以官方页面 “Accessing a tag with an AT overlay” 为准)——所以实践建议: AT 只在标准块里用

C/Python 对照: 优化 DB 像 Python 对象——只能按属性名访问, 拿不到稳定偏移; 标准 DB 像 C 结构体——有 offsetof, 能 memcpy, 但插入字段会把后面全移位(所以标准 DB 的通信表一旦定型不要中间插变量)。类比基本成立, 但注意优化 DB 不是 Python 的动态对象, 类型仍编译期固定。


陷阱 5: REAL 相等比较的浮点误差

现象: IF #level = 10.0 THEN 永远不触发; 累加 0.1 十次得到 1.0000001(16#3F800001)而不是精确的 1.0; #ratio = #a / #b 判断不稳定。

根因: REAL 是 IEEE 754 单精度, 二进制无法精确表示 0.1 这类十进制小数, 与 C/Python 的 float 完全同源。另外 SCL 的 / 在两个整型之间做整型除法(截断), 1/3 是 0——想算实数必须让操作数至少一方是 Real。

正确写法:

// 声明区 (FB Static)
// setpoint : Real := 10.0
// eps      : Real := 1.0E-6
// tenth    : Real
// acc      : Real

IF ABS(#level - #setpoint) < #eps THEN   // 容差比较
    #ok := TRUE;
END_IF;

#acc := 0.0;
FOR #i := 1 TO 10 DO
    #acc := #acc + 0.1;      // 累加误差, 与 C float 相同
END_FOR;

#ratio := #a * 1.0 / #b;     // 或 REAL#1.0/3.0; 两个 Int 相除会截断

C/Python 对照: 与 fabs(x-y) < epsmath.isclose 一一对应, 类比完全成立。差异点: SCL 里 REAL 是 32 位, 精度只有约 7 位十进制有效数字——比 Python 默认的 64 位 double 差得多(Python 里 sum([0.1]*10) 得 0.9999999999999999, SCL 的 REAL 得 1.0000001, 都是错, 错法不同), 误差更早暴露; 需要高精度用 LREAL(1200 支持 LREAL)。


陷阱 6: 边沿检测: R_TRIG/F_TRIG 是 FB, 必须有实例

现象: 想在按钮按下瞬间动作一次, 结果写成 IF #btn THEN ..., 按住一秒动作了几百次(每个 scan cycle 一次); 或者把 R_TRIG 的调用放在 IF 里, 边沿时灵时不灵。

根因: PLC 的输入是“电平“, 每个 cycle 都会被重新求值, 没有“事件回调“。边沿检测本质是“本周期值 vs 上周期值“的比较, 需要一块跨周期记忆的存储——这正是 FB + 实例 DB。R_TRIG/F_TRIG 就是这样的 FB, 调用时必须指定实例(单实例 DB 或多重实例)。

正确写法(多重实例, 推荐):

// FB 接口
// Input:  btn : Bool
// Output: pulse : Bool
// Static: trigBtn : R_TRIG     // 多重实例, 放 Static; 无条件每周期调用

#trigBtn(CLK := #btn);          // 无条件调用, 让它每周期都刷新记忆
#pulse := #trigBtn.Q;
IF #pulse THEN
    #count := #count + 1;       // 只在 0->1 的那个周期执行一次
END_IF;

手写等价物(理解原理用):

// Static: btnLast : Bool
#pulse := #btn AND NOT #btnLast;   // 上升沿
#btnLast := #btn;                  // 每周期末尾保存, 供下一周期比较

C/Python 对照: 就是 C 的 edge = cur && !last; last = cur; 或 Python 边沿检测库里的 was_pressed()。R_TRIG 的实例 DB 相当于那个 static bool last。类比的边界: 实例 DB 是持久状态, 掉电后按保持性设置决定是否恢复; C 的 static 变量重启即丢——保持性(retentive)是 PLC 独有概念。另见陷阱 7: 把 R_TRIG 放进条件分支调用会破坏 last 的连续性。


陷阱 7: 条件调用 FB: 不调用的周期输出保持旧值

现象: IF #mode = 2 THEN #motorFB(...); END_IF;——切到别的模式后, #motorFB 的输出(速度、运行反馈、故障字)冻结在最后一次调用时的值, HMI 上显示一个不存在的旧转速。

根因: FB 的输出与内部状态全部存在实例 DB 里。FB 不被调用的周期, 实例 DB 原样保留, 输出自然不动。没有任何机制帮你“清零“。

正确写法: 二选一。方案 A(推荐): FB 每周期无条件调用, “使能“作为 FB 的一个输入, 由 FB 内部决定输出:

// FB "FB_Heater" 接口
// Input: enable, fault, cmd
// Output: power : Real
// Static: state : Int

// 调用侧 (每周期一次):
// #heater(enable := #modeOk, cmd := #cmd, fault := #fault);

// FB 内部: 不使能时显式给输出赋安全值
IF NOT #enable THEN
    #power := 0.0;
    #state := 0;
    RETURN;               // 直接返回, 后面逻辑不执行
END_IF;

方案 B: 调用侧在跳过分支手工清理输出/内部状态。

C/Python 对照: 相当于一个长生命周期的对象, 不调用 update() 它的字段就停在旧值——C 里全局 struct、Python 里的单例都一样。类比成立之处: 状态持久; 不成立之处: PLC 里“跳过调用“是常见控制流(按模式切换), 而软件里通常不会整轮跳过 update, 危险性更高。附带提醒: FB 内部的 IEC 定时器基于时间累计, FB 长时间不调用后重新调用, 其 TON 可能直接判为已到期(待核实, 取决于固件对 IEC_Timer 结构的时间戳处理), 跨周期的长延迟逻辑要显式设计。


陷阱 8: WHILE 阻塞扫描: 没有 sleep, 定时必须 TON 跨周期

现象: 想等 500 ms 再动作, 写了 WHILE 循环空转; 或者在 OB1 里处理一大批数据耗时 300 ms——CPU 一会儿就 STOP, 诊断缓冲显示 cycle time 超时。

根因: PLC 程序不是常驻进程, 而是 scan cycle 模型: 读输入 -> 逐条执行 OB1 -> 写输出 -> 内务 -> 下一轮。OB1 必须在最大扫描循环时间(S7-1200 范围 1~6000 ms, 默认 150 ms, 恒启用)内完成。超时触发“时间错误“事件: 若编程了时间错误 OB(默认优先级 22, 可改 22~26)则执行它并保持 RUN; 未编程时按系统手册(不同章节措辞略有差异, 建议以你固件版本手册为准): 同一扫描周期内第一次超时通常被容忍、保持 RUN, 第二次超时(周期耗时达 2 倍最大循环时间)记入诊断缓冲并 STOP。没有 sleep/delay 函数, 空转 WHILE 就是烧掉整个周期预算; 且阻塞期间所有输出刷新、低优先级任务全部停摆。

正确写法: 延时用 TON, 让“等待“分布在多个扫描周期里:

// FB 接口
// Input:  cmd : Bool
// Static: tOn : TON_TIME          // 多重实例
//         state : Int
// Output: done : Bool

// 代码区
#tOn(IN := (#state = 1), PT := T#500MS);   // 无条件调用, IN 由状态驱动
IF #cmd AND (#state = 0) THEN
    #state := 1;
ELSIF #state = 1 AND #tOn.Q THEN           // 500 ms 后跨周期推进
    #state := 2;
    #done := TRUE;
END_IF;

大批量数据处理同样拆分: 每周期处理 N 条, 用 Static 游标记住进度。确有一段逻辑必须长时间运行时, 可用 RE_TRIGR 重启周期监视(当前周期 < 10 倍最大值时 ENO=TRUE), 但这是最后手段。

C/Python 对照: 相当于裸机嵌入式的主循环 + 看门狗: while(1) { read_inputs(); step(); write_outputs(); feed_watchdog(); }——你在 step()sleep(1) 整个系统就死。time.sleep() 在 PLC 世界没有对应物, “经过时间“只能靠定时器状态机这种协作式调度表达。类比完全成立, 只是 PLC 的看门狗超时默认直接 STOP 整台 CPU, 比多数嵌入式更狠。


陷阱 9: 除零/数学错误的真实行为: 不崩溃, 静默返回 0

现象: 除数为 0, 程序“没事“继续跑, 结果悄悄变成 0, 控制量跳变; 浮点非法运算得到 NaN 往下游传播。

根因: S7-1200 官方行为表: 整数除零(IN2=0)时“结果未定义并返回 0“, ENO=0, CPU 保持 RUN, 不触发任何错误 OB; REAL/LREAL 的 NaN/INF 非法运算同样 ENO=0 返回 NaN。而且 S7-1200 上不存在编程错误 OB(OB121)/I/O 访问错误 OB(OB122)——这类事件在 1200 上要么走诊断缓冲保持 RUN, 要么在启用了“局部错误处理“的块里用 GET_ERROR/GET_ERROR_ID 读取。CONV 转换溢出: ENO=0(OUT 的具体取值不同版本文档表述有差异——不要依赖 OUT, 以 ENO 为准); 字符串转数值(STRG_VAL/S_CONV)遇非法字符或超范围: ENO=0, OUT=0。全部保持 RUN。

正确写法: 除法前显式判零, 数学指令的错误要么预检要么事后校验:

// 声明区 (FB Static)
// denom : Real,  numer : Real,  ratio : Real,  ratioOk : Bool

IF ABS(#denom) < 1.0E-9 THEN      // 预检, 别指望运行时拦截
    #ratio := 0.0;
    #ratioOk := FALSE;
ELSE
    #ratio := #numer / #denom;
    #ratioOk := TRUE;
END_IF;

在块内放置 GET_ERROR/GET_ERROR_ID 指令, 即启用该块的局部错误处理(注意: 这不是块属性开关, 而是“块里有没有这对指令“), 这是 1200 上替代 OB121 的机制。

C/Python 对照: C 整数除零是未定义行为(通常 SIGFPE 崩溃), Python 抛 ZeroDivisionError——你习惯了“除零必炸“。S7-1200 恰恰相反: 静默给 0 且继续 RUN。类比不成立之处必须记住: 这里的错误哲学是“带 ENO 的容错“, 与其等异常, 不如把守卫写在前面。


陷阱 10: 通信字节序(大端)与 SWAP

现象: 从 Modbus TCP / 第三方设备收到的 INT 值“怪怪的“: 0x1234 变成 0x3412, 4660 变成 13330; REAL 完全是乱码。

根因: S7 CPU 的数据存储与协议字节序是大端(big-endian, 高字节在低地址), 与 x86/ARM 上 C 的小端相反。两个寄存器拼 DINT/REAL 时还有“高低字顺序“问题(不同厂商对 32 位值的两个字先后排列不统一)。Python 的 struct.pack/unpack 默认按本机小端, 同样要显式 > 才是大端。

正确写法:

// 声明区 (FB Static)
// hiB, loB : Byte;  wRaw, wFix : Word;  vInt : Int;
// wHi, wLo : Word;  dw : DWord;  vReal : Real;

// 两字节 -> INT (大端帧): 高字节左移 8 位
#wRaw := SHL(IN := BYTE_TO_WORD(#hiB), N := 8) OR BYTE_TO_WORD(#loB);
#vInt := WORD_TO_INT(#wRaw);

// 字节序颠倒时的修复: SWAP 交换 WORD 内两字节
#wFix := SWAP(IN := #wRaw);          // 16#3412 -> 16#1234

// 两字 -> DINT/REAL (确认高字在前):
#dw := SHL(IN := WORD_TO_DWORD(#wHi), N := 16) OR WORD_TO_DWORD(#wLo);
#vReal := DWORD_TO_REAL(#dw);        // DWORD->REAL 按位搬运, 不做数值转换

调试手段: 用 PEEK_WORD/PEEK_DWORD 对标准 DB 或 I 区按字节偏移检查原始帧(区域码 16#81=I, 16#82=Q, 16#83=M, 16#84=DB; DB 区必须标准 DB)。

C/Python 对照: 等价于 C 的 ntohs/ntohl 与手动移位拼包, 等价于 Python struct.unpack('>h', data)。类比成立。额外注意: DWORD_TO_REAL 这类等宽转换是“位模式搬运“(类似 C 的 memcpy / 指针重解释), 而 DINT_TO_REAL 是数值转换(类似 (float)i)——用错语义就出鬼数据。


陷阱 11: STRING 定长、截断与形参传递

现象: 赋值后字符串尾部莫名丢失; 一个“字符串变量“占了 256 字节; 传参后长度变化; 拼接结果变短。

根因: String 是带 2 字节头的定长缓冲: 第 1 字节最大长度, 第 2 字节当前长度, 后跟字符, 总占用 = 最大长度 + 2。声明 myStr : String 不写 [n] 时默认 String[254], 即占 256 字节。赋给更短的 String 时从右侧截断到目标最大长度。形参也有自己的声明长度, 实参过长同样截断。String 不能赋给 I/Q 存储区。比较用 =/<>(按字符比较)。子串/查找等操作靠 LEN、LEFT、RIGHT、MID、CONCAT、DELETE、INSERT、REPLACE、FIND 等 FC。

正确写法:

// 声明区
// name   : String[16]      // 占 18 字节, 按需声明, 别默认 254
// line   : String[32]
// n      : Int

#name := 'PUMP-01';                 // 当前长度 7, 实际存 7 个字符
#line := CONCAT(IN1 := #name, IN2 := ' OK');   // 超过 32 截断
#n := LEN(IN := #name);             // 7

IF #name = 'PUMP-01' THEN ... END_IF;

// 传参: 形参声明 String[16]; 传入 String[254] 的实参会被截到 16

C/Python 对照: String 既不是 C 的 char*(无指针、无 ‘\0’ 结尾, 长度显式存在头部), 也不是 Python str(非不可变对象, 长度上限编译期固定)。最接近的类比是 C 的 struct { char max, len; char buf[N]; } 定长字段。注意 WString(UTF-16)在 1200 上只能声明在优化块中; 字符串指令(LEN/CONCAT/FIND 等)的参数表本身同时接受 String 与 WString 操作数, 但 WString 进不了标准 DB 与通信映射表, 实际项目仍以 String 为主。


陷阱 12: 多 OB 中断并发访问共享数据的一致性

现象: OB1 里算出的流量偶尔跳变一个离谱值; 累计值偶尔少加一次; 状态机偶发“跳状态“。

根因: S7-1200 的程序循环 OB(OB1, 优先级 1, 最低)“可被所有其它事件类型中断”——循环中断 OB30~38(默认优先级 8~17)、硬件中断(优先级 18)、延时中断 OB20~23 等随时打断 OB1(中断延迟约 175 us)。共享的全局 DB 数据没有任何锁; OB1 里“读-改-写“序列(如 #total := #total + #x;)或读取多字段结构的过程中断, 就会出现撕裂。抢占规则完全由优先级决定: 任意高优先级 OB 都能打断正在执行的任何低优先级 OB——硬件中断(默认优先级 18)可打断循环中断(OB30~38, 默认 8~17), 循环中断可打断 OB1; 同优先级的多个事件只排队(先来先服务), 不互相打断。所以风险不只是“打断 OB1“: 任意两个不同优先级的 OB 之间, 只要共享数据就可能撕裂。

正确写法:

// 硬件中断 OB (>=123): 只做最小工作, 原子地更新快照
"DB_Shared".snapshot := "DB_Shared".rawValue;   // 单个 Real 赋值, 一条指令完成
"DB_Shared".seq := "DB_Shared".seq + 1;         // 序号, 供 OB1 校验

// OB1: 用"序号未变则不取"的模式读一致快照
#seq1 := "DB_Shared".seq;
#val  := "DB_Shared".snapshot;
#seq2 := "DB_Shared".seq;
IF #seq1 = #seq2 THEN        // 序号相同 => 读取期间未被中断改写
    #use := #val;
END_IF;

原则: (1) 共享量控制在单个对齐的字/双字内(单条指令搬运, 天然原子); (2) 多字段一致性用双缓冲 + 序号; (3) 能在中断 OB 里完成的算术就别放 OB1; (4) 绝不在两个不同优先级 OB 里做同一变量的读-改-写。

C/Python 对照: 中断 OB 就是 signal handler / 硬件 ISR——共享变量没有锁, data race 与 torn read 的模型完全一致, volatile/memory barrier 那套直觉可以迁移。不成立之处: 这里的“线程“是按优先级严格抢占且不可阻塞, 也没有 mutex 原语可用, 只能靠“缩短临界区 + 数据布局“解决, 类似无锁编程的约束。


陷阱 13: TIME 回绕(约 24.8 天)

现象: 设备连续运行 24.8 天后, 基于“自上电自由计时“做差值比较的逻辑集体异常: 延时瞬间触发或永不触发。

根因: TIME 是 32 位有符号毫秒, 范围 T#-24d_20h_31m_23s_648ms ~ T#+24d_20h_31m_23s_647ms(即 -2147483648 ~ +2147483647 ms), 任何自由运行计时到约 24.8 天即回绕; 直接比较 now >= deadline 在回绕点附近必然出错。平台大坑: 教程里常见的 TIME_TCK() 是 S7-1500 专属指令, 在 S7-1200 上编译直接报错(“Block TIME_TCK is not supported by the CPU”)。1200 上测耗时用 RUNTIME()(返回 LReal 秒, 基于内部高频计数器, 溢出时会返回 <= 0, 该次读数应忽略)、RTM 运行时间计量, 或 RD_SYS_T 读日历时间做差。

正确写法: 差值比较(对计数回绕安全的惯用法):

// 声明区 (FB Static)
// lastT : LReal,  interval : LReal := 1.0   // 秒

#now := RUNTIME();                     // S7-1200 可用; LReal 秒
IF #now > 0.0 THEN                     // 丢弃溢出时的无效读数(<=0)
    IF (#now - #lastT) >= #interval THEN   // 差值法, 对回绕安全
        #lastT := #now;
        #fire := TRUE;
    ELSE
        #fire := FALSE;
    END_IF;
END_IF;

一般的延时逻辑优先用 TON(IEC 定时器自己处理时间累计), 只有自研调度/秒表才碰自由计时; 对 TIME 类型做算术时, “有符号差值 >= 间隔“的补码回绕惯用法依然成立且推荐。1200 上无 LTIME(1500 独占), DTL 可用于日历时刻(1970~2262)。

C/Python 对照: 与 Arduino 的 millis() 回绕、Linux jiffies 完全同构, (uint32_t)(now - last) >= dt 这个惯用法原样迁移(这里用 DInt 有符号数, 补码回绕行为等效)。类比成立; 额外坑是 TIME 字面量写法 T#24d_20h 与类型检查, 混用 TIME/DInt 需显式 TIME_TO_DINT。


陷阱 14: 符号大小写不敏感撞名

现象: 块里已声明 motor, 再加 Motor 直接报“标识符已使用“; 两个 FB 一个叫 fbPump 一个叫 FbPump, 在跨块引用/库导入时报重名冲突。

根因: TIA 对 PLC 标签/块名的唯一性检查不区分大小写——Motor MOTOR 视为同名。另外保留关键字(指令名、IEC 函数名、数据类型名如 Int、Time)不得用作标识符; 含空格/特殊字符的名字要加双引号。

正确写法: 同一作用域内不要用大小写区分两个名字; 命名规范里显式规定风格(见最佳实践), 并避免:

// 编译失败示例:
// Input:  cmd : Bool
// Static: Cmd : Int        // 与 cmd 同名 (大小写不敏感)

// 正确: 语义不同就换词
// Input:  cmd : Bool
// Static: cmdCount : Int

C/Python 对照: C 与 Python 都大小写敏感, Total/total 是两个变量——这个直觉在 SCL 失效。可以理解为“所有标识符都被 lower-case 归一化后再查重“, 类似某些配置系统对环境变量名的处理。团队协作时特别危险: 你觉得是两个人定的不同名字, 编译器认为是重名。


陷阱 15: 巨型 OB1 与全局变量滥用

现象: OB1 写到上千行, 几十个 FB 直接连着几十个全局 DB, 逻辑互相穿插; 改一处, 三台设备行为都变; 没法在 PLCSIM 里单测任何一段。

根因: C/Python 里“一个 main + 一堆全局变量“的反模式在 PLC 界同样致命, 但更隐蔽——因为全局 DB 在 TIA 里用起来毫无摩擦(双引号即达), 且早期 LAD 习惯就是把所有东西放 M 区/全局 DB。后果: 命名冲突面大(全局且大小写不敏感)、隐式耦合(FB 内部直接读全局 DB, 签名上看不出依赖)、下载影响面失控、无法复用到下一台机器。

正确写法: OB1 只当调度器; 每个工艺对象一个 FB + 实例 DB; 全局只留物理 I/O 映射和真正的共享资源:

// OB1 (SCL) — 只做调度
// 全局 DB "DB_IO": 仅物理量 (sensorRaw, cmdPulse ...) 与 HMI 共享量
// FB "FB_Conveyor": 封装整条输送机逻辑; FB "FB_Pump": 封装泵逻辑

"FB_Conveyor_DB"(sensor   := "DB_IO".convSensor,
                 motorCmd => "DB_IO".convMotor);

"FB_Pump_DB"(enable   := "FB_Conveyor_DB".pumpReq,
             feedback := "DB_IO".pumpFb,
             cmd      => "DB_IO".pumpCmd);

判断标准: FB 的所有输入输出都出现在接口里、不读写全局 DB 的内部状态, 才能整体拷贝到下一个项目。

C/Python 对照: OB1 = main(), FB + 实例 DB = 类 + 实例, 全局 DB = 全局单例, UDT = struct 定义。整个重构直觉(mains should be thin, 依赖走参数)可以直接迁移。不成立之处: FB 的“实例“是数据块, 与代码分离——更像“一个函数 + 一块专属内存“, 没有方法表与继承。


陷阱 16: 临时区大数组吃 L 区

现象: FC 里声明 Temp: buf : Array[1..4096] of DInt 或十几个 String(默认各 256 字节), 编译过但运行行为异常, 或深调用链上某块直接出错——L 栈耗尽。

根因: VAR_TEMP 分配在局部数据栈(L 区)上, 每层调用都占一份, 大小按 CPU 型号有限(典型仅若干 KB, 具体查 CPU 规格表)(待核实: 各型 1200 的 L 区精确上限)。S7 用户程序也不支持递归调用, 深层嵌套 + 大 Temp 是最常见的爆栈方式。另外 Temp 不初始化(陷阱 2), 大缓冲读脏数据的概率更高。

正确写法: 大缓冲放 DB(全局 DB 或 FB 的 Static/InOut), 通过引用传递:

// FC "FC_Filter" 接口 — 大数组走 InOut (按引用传递, 不占 L 栈)
// InOut:  buf : Array[0..4095] of DInt
// Input:  len : DInt
// Return: avg : DInt

#sum := 0;
FOR #i := 0 TO #len - 1 DO
    #sum := #sum + #buf[#i];
END_FOR;
IF #len > 0 THEN #avg := #sum / #len; ELSE #avg := 0; END_IF;

C/Python 对照: 与“不要在栈上放 16 KB 数组, 改用堆/memcpy 传指针“一致; InOut 传参相当于传指针(C 的 int buf[] 参数 / Python 传列表引用)。类比成立之处: 栈容量有限、按值拷贝昂贵; 不成立之处: PLC 没有 malloc/堆, “堆“的角色由 DB 承担, 且 DB 同时是持久存储。


陷阱 17: 调试方式差异: 没有断点, 没有 print

现象: 想 printf 一个中间值发现没有控制台; 想打断点单步, TIA 界面上的断点按钮是灰的。

根因: 官方明确: 断点测试仅支持 STL 与 SCL 代码, 但支持的 CPU 只有 S7-300/400 和固件 >= V2.5 的 S7-1500——S7-1200 不支持断点(包括 PLCSIM 仿真 1200 的场景)。也没有任何 print/控制台: CPU 上没有 stdio。

正确替代手段(按用途选):

需求1200 上的做法
看变量实时值Program status(在线监视 SCL 代码行旁的值)
盯一组关键量Watch table(监视表), 可在线修改/强制(force)
看时序波形Trace(示波器式采集; 1200 固件 V4.0 起支持, 需真实 CPU——PLCSIM 不支持录制)
“打印“日志写入专用诊断 DB(环形缓冲 + 时间戳), HMI 或监控端读取
事后分析CPU 诊断缓冲(在线与诊断 -> 诊断缓冲)
仿真调试PLCSIM + 上述手段(注意 PID_Compact V2.x 不支持对经典 1200 仿真)

调试用诊断 DB 的最小实现:

// 全局 DB "DB_Diag"
// idx : DInt
// log : Array[0..99] of String[40]

// 需要记录处:
"DB_Diag".idx := ("DB_Diag".idx + 1) MOD 100;
"DB_Diag".log["DB_Diag".idx] := CONCAT(IN1 := 'E', IN2 := INT_TO_STRING(#errorCode));

C/Python 对照: 心智模型从 gdb/IDE 断点 + print 切换到“永远在线的仪表“: Watch table 像调试器的 watch 面板(但常驻、免断点), Trace 像 logic analyzer/示波器, 诊断 DB 像结构化日志。关键差异: PLC 的“调试“不打断被控过程——设备在转, 你不能暂停真实世界, 这也是断点在低危 CPU 上被砍掉的根本原因。


陷阱 18: FC 不是纯函数——它有副作用, 形参也会“脏“

现象: 把 FC 当无副作用的纯函数用, 后来发现它悄悄改了全局 DB; 标准访问的 FC 里读到的输入形参值有时不对。

根因: FC 只是没有自己的持久存储, 并不妨碍它读写全局 DB 与 M 区——“FC=纯函数“是错觉。且标准访问的 FC 中, 未连接/未写入的形参与 Temp 同源, 值不确定。

正确写法: FC 内部不直接读写全局 DB(依赖全走形参); 形参先赋值再读; 需要跨周期记忆就换 FB。

C/Python 对照: C 函数也能改全局变量, 这半条类比成立; 但 C 没有“未初始化形参“, 形参那半条是 PLC 特有。


陷阱 19: 优化块的 Temp 会被预置默认值——坏习惯被掩盖

现象: 在(默认的)优化块里 Temp 不初始化也“没事“; 把代码搬到标准块(为做 AT/PEEK/通信表)后, 同一段代码静默读出脏数据。

根因: S7-1200 固件 >= V4(及 S7-1500 全系)的优化块中, Temp 每次调用前被预置默认值(数值 0); 标准访问块没有这层保护。

正确写法: 始终按陷阱 2 的“Temp 必脏“纪律写代码, 不因当前块恰是优化块而偷懒——代码是会被搬家的。

C/Python 对照: 相当于“Debug 构建清零、Release 构建不清零“的经典差异: 行为依环境而变, 唯一安全假设是“未初始化即未知“。


陷阱 20: 布尔表达式不短路求值

现象: IF #i <= #n AND #a[#i] > 0 THEN 在 #i 越界时照样触发访问错误——守卫没起作用。

根因: SCL 的 AND/OR 对两个操作数都求值, 没有短路语义(不同于 C 的 && 与 Python 的 and)。

正确写法: 守卫拆成嵌套 IF:

IF #i <= #n THEN
    IF #a[#i] > 0 THEN
        // 安全: 只有第一层成立才求值第二层
    END_IF;
END_IF;

C/Python 对照: 这是 Pascal 系语言的经典差异; 所有依赖短路的条件表达式(ptr && ptr->vali < n and a[i])都必须重写成嵌套 IF, 无一例外。


陷阱 21: 同一输出多处赋值——最后写者胜, 且周期末才刷新

现象: 两个 FB 都驱动同一个 Q, 输出“偶发不对“, 无任何编译告警; 写了 Q 后立即读回, 读到的是旧值。

根因: 一个扫描周期内对同一地址多次赋值, 只有最后一次生效; 物理输出在周期末统一刷新; 读 %Q 读到的是输出映像而非端子电平。

正确写法: 一个输出只有一个“属主“代码路径; 模式切换用显式优先级 IF 或 SEL/MUX 收敛到一次赋值。确需本周期立即生效时写 %Q:P(直写外设, 慎用)。

C/Python 对照: 像多写者竞争, 但没有竞态——执行顺序确定(自上而下), 只是反直觉: “赋值即生效“的事件驱动直觉不迁移。


陷阱 22: 输入映像每周期只采样一次——窄脉冲整体消失

现象: 按钮/传感器的短脉冲偶尔“没收到“, 以为是程序 bug。

根因: %I 在每个扫描周期开始时快照一次; 比扫描周期短的电平变化(或恰好落在两次采样之间的脉冲)完全不可见。

正确写法: 短脉冲/高频信号用硬件中断 OB(编号 >= 123)、HSC 高速计数器, 或 %I:P 直读外设; 普通按钮无需处理(按压时长远大于周期)。

C/Python 对照: 采样率不足的 ADC——Nyquist 直觉直接迁移: 扫描周期就是你的输入采样周期(典型 1~10 ms)。


陷阱 23: 两次 FB 调用共用一个实例 DB——状态串扰

现象: 两台“同型号设备“互相影响: 一台复位, 另一台的定时器/状态也乱了; 偶发性与陷阱 7 相似, 极难排查。

根因: FB 的全部状态在实例 DB 里; 同一实例接两台设备 = 两个“对象“共享同一块内存。

正确写法: 每个“对象“一个实例——多重实例(在父 FB 的 Static 里声明)或独立实例 DB; 排查偶发状态异常时, 第一步检查实例 DB 是否被两处复用。

C/Python 对照: 误共享 static/单例的经典事故, 完全同构。


陷阱 24: 在线改值会被用户程序在下一个周期改回去

现象: Watch table 给变量写了新值, “没反应“或一闪而过; 以为通信或程序坏了。

根因: 只要程序每周期给该变量赋值, 手写的值立即被覆盖——与 gdb set var + continue 的预期完全不同。

正确写法: 调试用 force(仅 I/Q 可强制); 或程序里预留 manual/auto 使能分支; 或临时禁用写它的逻辑(记得恢复)。

C/Python 对照: 调试器改值后被主循环覆盖; PLC 里这是常态而非异常, 设计阶段就要给“人工干预“留门。


陷阱 25: 非保持数据掉电即回初始值

现象: HMI 上改好的设定值, 一次停电后“自己变回去“; 现场投诉高发。

根因: DB/M 区数据默认不保持: 暖启动后回到组态初始值; 只有显式设置保持性(retentive, 容量有上限)或写入存储卡/配方机制的数据才掉电保留。

正确写法: 运行期可写参数在 DB 声明中设保持性(注意保持性存储容量上限); 大批量参数用配方/存储卡; 关键累计量显式设计掉电策略。

C/Python 对照: “所有变量都是易失的, 除非你显式放进 EEPROM”——PLC 把这个选择交给程序员, 默认易失。


陷阱 26: “OB1 = main()“类比的边界

现象: 以为整个程序只有一个入口; 多 OB 的执行时机与预期不符。

根因(类比必须标注的边界): 启动 OB(100 或 >= 123)在进入循环前执行一次(≈ init/全局构造); 多个程序循环 OB(同为优先级 1)每周期按编号依次执行, 不是并行; 任何 OB 执行中都可能在语句级被更高优先级 OB 抢占(见陷阱 12); OB 不可重入。

正确写法: 初始化放启动 OB; 程序循环只留 OB1, 不同频率的任务用循环中断 OB(OB30~38)而不是多个循环 OB。

C/Python 对照: 更接近“固定优先级抢占式 RTOS 的任务集“而不是单线程 main: 每个 OB 是一个任务, 优先级表就是调度器; main 只对应了其中一个任务。


陷阱 27: 整型运算静默回绕——Python 背景毫无防备

现象: Int 计数器到 32767 后变成 -32768; 大数乘法/累加结果莫名变负; 无任何告警。

根因: SCL 整型就是 C 的定宽补码整数: 溢出回绕, 编译器与运行时都不报。Python 的 int 无限精度, 从 Python 过来毫无防备(C 背景至少有肌肉记忆)。

正确写法: 计数/累计/大数组下标一律 DInt/UDInt; 关键累加处显式判上限:

IF #count < #COUNT_MAX THEN
    #count := #count + 1;
END_IF;

C/Python 对照: 与 C 完全一致(细节上 C 的有符号溢出是 UB, SCL 是定义良好的回绕, 工程上同样不能依赖); 与 Python 完全不同。


最佳实践

1. 命名规范

原则: 全项目统一; 不靠大小写区分语义(陷阱 14); 避开保留字。推荐一套可直接用的:

对象风格示例
FB/FC/OBPascalCase + 前缀FB_Conveyor, FC_CalcAvg
全局 DBDB_ 前缀DB_IO, DB_Recipe
UDTPascalCaseMotorConfig
实例 DB块名 + _InstFB_Conveyor_Inst
局部变量/Input/OutputcamelCasecmdStart, speedActual
常量(CONST)UPPER_SNAKEST_RUNNING, CMD_TIMEOUT_MS
I/O tag物理功能名ixStart(i=输入 x=Bool)之类按需定

变量名里就编码类型/方向是团队自选项, 但常量用 UPPER_SNAKE 与状态名前缀 ST_ 强烈推荐——它们是 SCL 里枚举的替身(见下)。

2. UDT 优先

凡“同一结构出现在多处“(每台电机一组参数、每段配方一组字段), 先定义 UDT(用户数据类型), 再在 FB 接口、全局 DB、实例 DB 中复用:

TYPE "MotorConfig" :
STRUCT
    maxSpeed  : Real;
    accelTime : Time;
    invert    : Bool;
END_STRUCT;
END_TYPE

// FB 接口: Input: cfg : "MotorConfig"
// 全局 DB:  motors : Array[1..8] of "MotorConfig"

等于 C 的共享 struct 头文件: 改一处定义, 全部使用点编译期强制同步; 且 SCL 的 Array of UDT 天然替代“多台同型设备“的复制粘贴。类比成立, 差异是 UDT 改定义后所有引用块需重新编译下载。

3. 状态机惯用法: CONSTANT + INT 的枚举替身(完整骨架)

SCL 没有枚举类型。惯用法: 块内 CONST 定义状态码, Static 里存 state : Int, CASE 驱动。完整可抄的骨架(含延时、互锁、故障、HMI 状态码):

// ============ FB "FB_Pump" 接口(声明区) ============
// CONST
//   ST_IDLE     : Int := 0;
//   ST_STARTING : Int := 1;
//   ST_RUNNING  : Int := 2;
//   ST_STOPPING : Int := 3;
//   ST_FAULT    : Int := 99;
// END_CONST
// Input:
//   cmdStart  : Bool;      // 电平
//   cmdStop   : Bool;
//   cmdReset  : Bool;
//   faultSig  : Bool;      // 外部故障(电平)
//   startDelay : Time := T#3S;
//   stopDelay  : Time := T#5S;
// Output:
//   runCmd    : Bool;      // 接触器命令
//   stateId   : Int;       // 给 HMI 显示
//   ready     : Bool;
// Static:
//   state     : Int;
//   faultLatch : Bool;
//   tmrStart  : TON_TIME;  // 多重实例
//   tmrStop   : TON_TIME;

// ============ 代码区 ============
// 1) 故障处理最优先: 任意状态 -> ST_FAULT
IF #faultSig THEN
    #faultLatch := TRUE;
END_IF;
IF #faultLatch AND #cmdReset AND NOT #faultSig THEN
    #faultLatch := FALSE;
END_IF;

// 2) 定时器集中无条件调用(避免陷阱 6/7), IN 由状态译码
#tmrStart(IN := (#state = ST_STARTING), PT := #startDelay);
#tmrStop (IN := (#state = ST_STOPPING),  PT := #stopDelay);

// 3) 状态转移与输出
CASE #state OF
    ST_IDLE:
        #runCmd := FALSE;
        IF #faultLatch THEN
            #state := ST_FAULT;
        ELSIF #cmdStart AND NOT #cmdStop THEN
            #state := ST_STARTING;
        END_IF;

    ST_STARTING:
        #runCmd := TRUE;
        IF #faultLatch THEN
            #state := ST_FAULT;
        ELSIF #tmrStart.Q THEN
            #state := ST_RUNNING;
        END_IF;

    ST_RUNNING:
        #runCmd := TRUE;
        IF #faultLatch THEN
            #state := ST_FAULT;
        ELSIF #cmdStop OR NOT #cmdStart THEN
            #state := ST_STOPPING;
        END_IF;

    ST_STOPPING:
        #runCmd := FALSE;
        IF #tmrStop.Q THEN
            #state := ST_IDLE;
        END_IF;

    ST_FAULT:
        #runCmd := FALSE;
        IF NOT #faultLatch THEN
            #state := ST_IDLE;
        END_IF;

    ELSE  // 非法状态(如下载后 DB 脏值)兜底回 IDLE
        #state := ST_IDLE;
END_CASE;

// 4) 集中导出对外状态
#stateId := #state;
#ready   := (#state = ST_IDLE) AND NOT #faultLatch;

要点: 定时器实例的 IN 用状态译码驱动、调用永不进分支; 状态码 0 给初始态(Static 默认 0, 下载初始化即回 IDLE); ELSE 分支兜底; HMI 只读 stateId, 不读内部 flag。C/Python 对照: 这就是函数指针表/字典分发的 switch 状态机, CONST 相当于 enum { ST_IDLE = 0, ... }; 差异是没有真正的枚举类型——状态码本质是裸 Int, 类型系统不防你把 stateId 赋成 47, 所以不要复用状态变量存任意数值。

4. FB 单一职责

一个 FB 只管一个工艺对象(一台泵、一段输送机、一个配方槽)。判据: 名字能用一句话说清; 接口不超过约 15 个信号; 不读写自己的全局 DB; 内部不调用“隔壁设备“的 FB(设备间协同放到上层 FB 或 OB1 编排)。层层嵌套 FB 是允许且推荐的(顶层 FB 用多重实例持有子 FB), 与 C 的模块分层同构。

5. 库化复用

把验证过的 FB/FC/UDT/全局 DB 组拖入 TIA 的 project library / global library, 从库里拖出使用而非复制粘贴; 库支持版本化(库版本 = 你的 release)。修改流程: 改库内对象 -> 升库版本 -> 各项目更新引用。配合 UDT, “第三台产线“的搭建方式是“从库拖件 + 改参数”, 不是“复制整个项目改名字“。类比: global library = 内部包仓库(nuget/pypi), 库版本 = 语义化发布的依赖。注意跨项目复制 FB 时其依赖(UDT、被调 FB)必须一起带走, TIA 的“组复制“(copy group)就是为此设计。

6. TIA 项目的版本管理思路

TIA 项目文件(.ap17/.ap18/…)是二进制容器, git 无法做有意义的 diff/merge。现实可行的组合:

  1. 块级源码入库: 用 TIA 的外部源(external source)功能把 SCL 块导出为 .scl 文本文件(或用 TIA Openness API 批量导出块/PLC 变量为 XML)纳入 git, 代码评审、diff、 blame 都落在这一层; 二进制 .ap 文件也提交(可配 LFS), 但只当“完整快照“。
  2. 变更对比: TIA 自带项目/块比较工具(离线 vs 在线、项目 vs 项目), 用于人工比对两版本差异, 相当于图形化 diff。
  3. 里程碑用库版本管理: 发布给其他项目的块冻结为库版本, 项目里记录“用的哪个库版本“。
  4. 提交纪律: 二进制无法 diff, 所以 commit message 必须写清改了哪些块、为什么——相当于强制性的 CHANGELOG。

Openness API 是官方脚本化接口(可编程导出/导入、自动编译下载), 把“导出源码 + 编译验证“做成 CI 步骤是成熟做法(具体 API 版本随 TIA 版本变化, 使用前查对应版本文档)。

7. 在线下载对 DB 值的初始化影响

下载不是无害操作。关键事实与策略:

  • 下载改动过的 DB 时, 默认会用组态的初始值重新初始化当前值——运行时累计的计数值、当前状态码会丢。关键事实(系统手册 15.16): 下载不会清除保持性存储器中的现值(想清除只能恢复出厂设置); S7-1200(固件 V4.x)支持“不重新初始化下载“(Download without reinitialization), 但仅限优化块内新增标签且不超过内存预留(新建块默认预留 100 字节, 可在块属性中调整; 新增保持性标签需另配保持性内存预留); 修改既有标签的名称/数据类型/数组长度/保持性, 仍会强制重新初始化(当前值复位为初始值)。
  • 策略 1: FB 的 Static 里, 所有“可从外部重建“的初值写进声明默认值(state := 0 就是设计的一部分, 见状态机骨架)。
  • 策略 2: 关键初始化逻辑放启动 OB(S7-1200 上启动 OB 编号 100 或 >=123, CPU 每次暖启动都会执行它), 不依赖下载时的默认值行为——这等于 C 程序的 init() 显式初始化, 比“相信加载器清零“可靠。
  • 策略 3: 改 DB 结构前先想清楚哪些是“运行时数据“(计数值、批次号), 把它们隔离到独立 DB, 与“参数/组态“分开, 下载影响面就可控。
  • 策略 4: 生产设备上的下载遵守流程: PLCSIM 验证 -> 停机窗口 -> 下载 -> 核对初始化效果。RUN 模式下载仅对兼容改动可用, 不兼容改动会触发 CPU 停止/重启。

附: 陷阱速查表

#陷阱一句话防御
1:= 与 =赋值 :=, 比较 =, 不等 <>
2Temp 脏数据先写后读; 跨周期记忆放 Static
3数组越界0 基声明 + LOWER/UPPER_BOUND; 1200 越界不 STOP
4优化 DB默认优化; 需绝对地址/AT/PEEK 才用标准 DB
5REAL 相等容差比较; Int/Int 相除是整除
6边沿R_TRIG 实例化, 无条件调用
7条件调用 FB每周期调用, enable 做输入; 不使能时显式清输出
8WHILE 阻塞延时一律 TON; 大任务分片跨周期
9除零预检分母; 错误是 ENO=0 + 返回 0, 不是异常
10字节序大端; SWAP/移位拼装; DWORD_TO_REAL 是位搬运
11STRING定长 n+2 字节; 默认 254; 赋值截断
12中断并发共享量单字原子 + 序号校验; 临界区放高优先级 OB
13TIME 回绕差值法; 优先 TON
14大小写全局不区分大小写, 勿靠大小写区分名字
15巨型 OB1OB1 只调度; FB 封装; 全局只留 I/O
16大 Temp大数组走 DB + InOut 引用
17调试1200 无断点: Watch/Trace/程序状态/诊断 DB
18FC 非纯函数FC 可写全局 DB/M 区; 形参先赋值再读; 要记忆用 FB
19优化块 Temp 预置默认值始终按“Temp 必脏“写代码, 搬到标准块才不炸
20布尔不短路AND/OR 全操作数求值, 守卫拆嵌套 IF
21多处驱动同一输出last-write-wins; 立即生效写 %Q:P(慎用)
22窄脉冲丢失输入映像每周期采样一次; 用硬件中断/HSC/%I:P
23共用实例 DB一个对象一个实例, 严禁两处共用
24在线改值被覆盖周期程序会改回去; 用 force 或 manual 分支
25掉电丢设定值可写参数设保持性(retentive)或存配方/存储卡
26OB1=main 类比边界启动 OB 先行; 多循环 OB 按序; 语句级可抢占; 不可重入
27整型静默回绕Int 32767 翻负无告警; 计数用 DInt 并设上限
-平台专属易踩指令TIME_TCK/REF/POINTER/ANY/LTIME/断点均为 1500 独占, 1200 不可用