Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

02 PLC 核心概念: 执行模型与程序架构 — 写给 C/Python 开发者

读者画像: 10 年 C/Python 经验, 零 PLC 基础, 学习目标为西门子 S7-1200 + TIA Portal (V16~V20) + SCL。 本章是全套材料里“思维切换“的关键章: 从事件驱动/进程退出模型, 切换到扫描周期/永不退出模型。后面所有 SCL 语法技巧, 都建立在本章概念之上。

本章结构: 1. 本质差异 → 2. 扫描周期与看门狗 → 3. 存储体系 → 4. 数据类型 → 5. 块模型 → 6. 块接口语义 → 7. 调用与执行语义 → 8. 寻址 → 9. PROFINET 与硬件组态 → 10. 诊断体系 → 11. 概念对照总表 → 12. 类比何时失效。

1. PLC 与通用计算机的本质差异

PLC (Programmable Logic Controller) 不是“放在控制柜里的电脑“, 而是一台确定性地、无限重复地执行同一个用户程序的实时控制器。差异不在性能, 而在执行契约:

维度通用计算机 (你熟悉的)S7-1200 PLC
程序生命周期main() 跑完退出; 守护进程靠事件循环用户程序永不退出, 每个扫描周期整体重入
执行触发事件/中断/消息驱动 (回调)时间驱动: 每周期从头到尾全量执行一遍
并发模型多线程/多进程 + 锁单主线 (OB1) + 少量优先级抢占的中断 OB
内存heap + stack, malloc/GC完全静态分配, 无 heap, 无递归
I/O内核驱动抽象, read()/write()过程映像: 每周期一次批量快照
错误处理异常/errno/core dump一般不崩溃: ENO 置位 + 继续 RUN (见 7.3)
实时性尽力而为 (best effort)确定性: 看门狗监督周期上限, 超时两次直接 STOP
状态保持进程死了状态就没了掉电保持 (retentive) 数据跨断电存活

为什么这样设计: 被控对象是电机、阀门、加热器——物理过程连续运行, 控制程序必须“永远在跑“; 同时控制逻辑要求可预测 (一个周期内必须算完), 所以宁可 STOP 也不允许无界延迟。你写过的最接近 PLC 的东西, 可能是 MCU 上的 while(1) 主循环 + 裸机中断, 而不是 Linux 服务。

2. 扫描周期 (scan cycle): PLC 的心跳

2.1 三阶段模型

CPU 的操作系统 (OS) 永远在做同一件事, 用 C 伪代码表达:

// PLC 操作系统的本质 (伪代码)
for (;;) {
    read_physical_inputs_to_input_image();  // 1. 物理输入 -> 输入映像 (process image input, I 区)
    execute_ob1_and_event_obs();            // 2. 执行用户程序 (OB1, 可被高优先级中断 OB 打断)
    write_output_image_to_terminals();      // 3. 输出映像 (Q 区) 一次性写到物理端子
    housekeeping();                         // 4. 诊断、通信、更新周期计时
    watchdog_check();                       // 5. 超过最大循环时间则按规则处置
}

关键语义:

  1. 读输入: 周期开始时, OS 把所有物理输入采样到输入映像表 (I 区)。用户程序读 %I0.0 读的是这份快照, 不是实时电平。
  2. 执行: OB1 从第一行到最后一行完整执行。期间读到的输入永远是本周期开头那份快照; 对 %Q 的赋值只改输出映像, 不立刻驱动端子。
  3. 写输出: 周期结束时 OS 把输出映像一次性刷到端子。即使程序中间对同一个输出赋值 10 次, 端子上只会看到最后一次的值——这是与“每条语句立即生效“的 MCU 直操 GPIO 的关键差异。
  4. 中断 OB (硬件中断、循环中断等) 抢占 OB1 执行, 打断延迟约 175 µs (官方数据)。

2.2 典型周期时长与看门狗 (watchdog / 循环监控时间)

  • 周期时长完全由用户程序规模决定, 小到中型项目通常在 1~10 ms 量级 (经验值, 非官方承诺); 实际值在 CPU 在线属性的 “Cycle time” 页可看到 current/min/max。
  • 最大循环时间 (maximum scan cycle time): 配置范围 1~6000 ms, 默认 150 ms, 恒启用, 配置入口为设备组态 → CPU 属性 → “Cycle”。这就是 PLC 的 watchdog。类比 MCU 的看门狗定时器, 区别: 你不需要手动喂狗——正常情况下一个周期结束 OS 自动重启计时。
  • 只有程序卡死 (死循环、过大的循环体) 才会触发。处置规则: 组态了时间错误 OB 时执行它并保持 RUN; 未组态时, 同一扫描内第一次超时被忽略, CPU 保持 RUN; 第二次超时 → 写诊断缓冲区并转 STOP (不同固件版本手册章节措辞略有差异, 以你的固件手册为准)。
  • RE_TRIGR 指令: 在用户程序里重启监视计时, 相当于“合法喂狗“, 可把本周期上限最多延长到 10 倍 (当前周期 < 10 × 最大值时 ENO=TRUE)。
  • 最小循环时间 (minimum cycle time): 默认禁用; 启用后短周期会被人为拉长, 用于让采样周期恒定 (例如 PID 控制需要稳定 Δt)。

2.3 思维转变: 不是事件驱动, 而是每周期整体重入

main loop + 回调 的对比:

事件驱动 (浏览器/Node/桌面)PLC 扫描模型
代码何时跑事件到达时回调被触发每周期无条件全量执行
“只在变化时处理”框架帮你做 diff你自己写边沿检测 (R_TRIG)
handler 内可以 await/阻塞可以绝对不行, 阻塞 = 拖垮周期 = STOP
注册/注销回调动态无此概念, 调用结构编译期固定

由此推出三条铁律 (C/Python 直觉全部失效的地方):

  1. 程序没有“跑完“这个概念。 每个周期都是一次完整重入。任何需要跨周期的信息 (计数器、状态机当前态、定时器累计值) 必须放在持久位置: M 区、DB、FB 的静态变量——绝不能放 VAR_TEMP (见 6.5)。
  2. “等待“不能靠 sleep。 time.sleep() 会吃掉整个看门狗预算。等待 = 用 TON 定时器描述“条件随时间的变化”, 本周期判一次, 不满足就结束本轮, 下周期再来——本质是状态机, 不是阻塞。
  3. 短事件可能被错过。 按钮按下 100 ms、周期 5 ms, 没问题; 若信号脉宽 < 一个周期, 采样可能漏掉——要么用硬件锁存/硬件中断 (OB40 类), 要么缩短周期。事件驱动框架里“回调永不丢失“的假设在这里不成立。

3. 存储体系: 装载 / 工作 / 保持 + 三个数据区

3.1 三级存储器

存储器易失性存什么类比
装载存储器 (load memory)非易失 (内部 Flash + 可选 SIMATIC Memory Card)完整用户程序、组态、初始值磁盘上的 firmware 镜像
工作存储器 (work memory)易失 (RAM)执行相关的代码与数据 (OS 从装载区复制过来)加载进 RAM 的 .text/.data
保持性存储 (retentive memory)非易失被标记 RETAIN 的工作内存值, 断电/暖启动后恢复MCU 的备份寄存器 / EEPROM
  • 下载 (download) 时块先落入装载存储器, OS 再把执行所需部分复制进工作存储器。S7-1200 内置装载存储器, 通常无需插卡即可运行; SIMATIC Memory Card 用于固件升级、程序交付与扩充 (不同型号/固件行为有差异, 以官方 CPU 手册为准)(待核实)。
  • 保持性存储容量很小, S7-1200 上常引用的规格是 10 KB 量级 (待核实, 以所用 CPU 规格表为准)。RETAIN 是稀缺资源, 别把大数组全设成保持。
  • 类比失效点: 没有“程序文件被 loader 按需换页“这回事——工作存储器装不下就下载失败, 相当于链接期报错而非运行期 OOM; 也没有 heap, 一切内存在编译期定死。

RETAIN 用法 (DB 变量属性):

DATA_BLOCK "Gbl_Persist"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
   VAR
      iTotalParts : Int := 0;         // 累计产量
      iShift : Int := 1;              // 班次
      rTempAtPowerFail : Real;        // 掉电瞬间温度
   END_VAR
BEGIN
   iTotalParts := 0;
   iShift := 1;
END_DATA_BLOCK

在 TIA 中把这几个变量的 “Retain” 列勾选 (或在声明中加 RETAIN 关键字, 取决于编辑器), 断电再上电后它们保留最后一次值; 未设 RETAIN 的变量在暖启动 (warm restart) 时被复位为声明时的初始值。启动流程: 上电 → 恢复保持值 → 执行启动 OB (OB100) → 进入 RUN 循环 OB1。

3.2 过程映像 I/Q 与位存储区 M

区域名称写入者类比
I (input)过程映像输入OS 每周期开头采样物理输入影子寄存器 / 每帧同步的输入快照
Q (output)过程映像输出用户程序写, OS 周期末刷到端子双缓冲的输出帧
M (bit memory)位存储区/标志区用户程序自由读写全局变量区
  • M 区就是“带绝对地址的全局变量“: %M100.5 一个 bool, %MB100/%MW100/%MD100 字节/字/双字。M 区可以在 CPU 属性里按范围设置保持性。容量数 KB 量级 (待核实, 随型号)。
  • :P 后缀绕过映像直访外设: "RawAI":P 立即读端子。仅在必须跳过快照语义时用。
  • 内存映射 IO (MMIO) 类比: %I0.0 像 MCU 的寄存器地址, 成立之处在于“固定地址、可直接读写“。失效之处: MMIO 寄存器反映硬件实时状态, 而 I 区是每周期一次的快照——你在 OB1 中段读 %I0.0, 拿到的仍是周期开始那一刻的电平, 中途的真实跳变你看不见。它更像“每帧同步一次的输入结构体“, 不像 volatile 寄存器。

4. 数据类型总览

4.1 基本类型表

类型大小取值范围 / 说明C 近似
BOOL1 位TRUE/FALSEbool
BYTE8 位16#00~16#FF, 按位串解释, 无符号语义uint8_t
WORD16 位16#0000~16#FFFF 位串uint16_t
DWORD32 位位串uint32_t
SINT / USINT8 位-128~127 / 0~255int8_t/uint8_t
INT16 位-32768~32767int16_t
UINT16 位0~65535uint16_t
DINT32 位-2147483648~2147483647int32_t
UDINT32 位0~4294967295uint32_t
REAL32 位IEEE 754 单精度, 约 ±1.18e-38~±3.40e38float
LREAL64 位IEEE 754 双精度double
TIME32 位有符号毫秒: T#-24d_20h_31m_23s_648 ~ T#+24d_20h_31m_23s_647 (约 ±24.8 天溢出)int32_t(毫秒语义)
DATE16 位1990-01-01 ~ 2168-12-31
TIME_OF_DAY (TOD)32 位当日毫秒数 00:00:00.000~23:59:59.999
DTL12 字节1970-01-01 ~ 2262-04-11, 含年月日时分秒毫秒/星期struct tm
CHAR / WCHAR8/16 位ASCII / UCS-2char/char16_t
STRING[n]n+2 字节2 字节头 + 最多 n 字符; 默认 n=254, 上限 254定长 char[] + len 头
WSTRING[n]2n+2 字节最多 65534 字符; 仅优化块可声明 (指令参数表同时接受 STRING/WSTRING, 但 WSTRING 进不了标准 DB 与通信映射, 实际项目以 STRING 为主)
ARRAY[a..b] OF T同质聚合, 支持多维C 数组
STRUCT异质聚合struct

注意: LTIME/LTOD/LDT 等 64 位时间类型是 S7-1500 独占, S7-1200 没有 (官方文档标题即标注 (S7-1500))。

4.2 STRING 的内存布局 (面试级细节)

字节:  [0]最大长度 [1]当前长度 [2..n+1]字符数据
声明 STRING[10] 占 12 字节:  10 | cur | 'a','b',...
  • 头两个字节分别是最大长度、当前长度, 所以 STRING[10] 实占 12 字节, 而非 10。
  • STRING 不能赋给 I/Q 存储区 (M/DB 可以); WSTRING 只能声明在优化块中, 进不了标准 DB 与通信映射表, 实际项目以 STRING 为主 (字符串指令参数表本身同时接受两者)。
  • 与 C 字符串最大的区别: 显式长度而非 \0 结尾; 与 Python str 的区别: 定长、单字节字符、可变而非不可变对象。

4.3 UDT (PLC data type ≈ typedef struct)

TYPE "UDT_MotorCfg"
VERSION : 0.1
   STRUCT
      rAccelRamp : Real := 1.5;      // 加速斜率, 声明即默认值
      tTripDelay : Time := T#3S;     // 跳闸延时
      iMaxTries  : Int := 3;
   END_STRUCT;
END_TYPE

UDT 是纯类型模板, 不占运行内存; 在 DB/FB 接口里 stCfg : "UDT_MotorCfg"; 使用。改 UDT 定义后, 所有使用处右键 “Update data type” 同步布局——相当于改了 struct 后重编所有引用单元。

5. 块模型 (block model): OB / FC / FB / DB / UDT

这是本章核心。PLC “程序“不是一坨源文件, 而是一组块 (block) 的集合, OS 按规则调度它们。

本质类比有状态?
OB (Organization Block)OS 的入口点/调度单元main + 各类 ISR无实例
FC (Function)无状态函数纯函数 (无 static 的 C 函数)否 (Temp 不算)
FB (Function Block)带状态的函数类定义是 (存在背景 DB)
背景 DB (instance DB)FB 的一次实例化对象实例
全局 DB (global DB)全局数据结构全局 struct 变量
UDT (PLC data type)类型定义typedef struct— (纯模板)

5.1 OB (组织块): OS 与用户代码的唯一接缝

OB = “当某事件发生时, OS 调用你写在这个 OB 里的代码”。S7-1200 优先级 1~26, 数字越大优先级越高; 同优先级不互相打断, 按队列/顺序执行。官方默认优先级与 1200 支持度:

事件经典编号 (旧文献)S7-1200 上的编号与支持默认优先级
程序循环OB1OB1, 也可再建多个循环 OB (编号 >=123, 依次执行)1 (最低)
启动OB100OB100 或 >=123, 可多个1
日时间中断 (time-of-day)OB101200 文档中未见 OB10 编号, TIA 对话框新建默认给 >=123; 最多 2 个事件2
延时中断OB20~23OB20-23 或 >=123; 与循环中断合计最多 4 个3~6
循环中断 (cyclic interrupt)OB30~38OB30-38 或 >=123; 时基 1~60000 ms; 与延时合计最多 4 个8~17
硬件中断OB40编号 >=123 (最多 50 个事件挂接)18
诊断错误中断OB82支持; 未组态 OB82 时 CPU 忽略诊断错误保持 RUN5
模块拔/插支持 (编号 >=123)6
机架/站故障OB86支持 (PROFINET 站掉线等)6
时间错误OB80支持22 (可改 22~26)
MC-Servo / MC-Interpolator运动控制 Technology Objects 内部使用25 / 24
编程错误 OB1211200 上不存在, 用 GET_ERROR/GET_ERROR_ID 局部处理
I/O 访问错误 OB1221200 上不存在, 记诊断缓冲保持 RUN

逐个说明:

  • OB1 (主循环): 心脏。每周期从头到尾完整执行一遍你的主逻辑, 可被所有其他事件类型打断 (优先级 1 最低)。
  • OB100 (启动): RUN 之前执行一次, 用于初始化: 恢复通信、设置初始模式、清批次数组。相当于“只跑一次的 setup()“。
  • 时间中断: 在设定的日历时刻/周期 (每分钟、每天 06:00 等) 触发, 用于报表、定时采样。老书上的 OB10 在 1200 上不要依赖手输, 用 TIA 对话框生成 (编号 >=123)。
  • 循环中断 (OB30x 类): 固定周期执行, 与 OB1 当前负载无关, 用于 PID 等需要恒定 Δt 的算法。注意其周期必须远小于其中代码耗时, 否则挤爆 CPU。
  • 硬件中断 (OB40 类): 输入模块检测到边沿等硬件事件立刻触发, 延迟约 175 µs——这才是 PLC 里最接近“回调“的机制。但语义仍是“跑一个短 OB“, 不是注册 handler。
  • 错误 OB: 时间错误 (周期超限等)、诊断错误 (从站诊断, OB82)、拔插、机架故障。1200 的重大差异: OB121/OB122 不存在——编程错误在启用局部错误处理的块里用 GET_ERROR 捕获 (7.3 节), I/O 访问错误只记诊断缓冲并保持 RUN。
  • OB 之间的抢占完全由优先级决定: 任意高优先级 OB 都能打断正在执行的任何低优先级 OB (硬件中断 18 可打断循环中断 8~17, 循环中断可打断 OB1); 同优先级的多个事件只排队依次执行, 不互相打断。不存在“默认不可中断“的组态——设计共享数据时按“任何 OB 都可能被更高优先级打断“来考虑。

OB1 骨架 (SCL 版):

// 组织块: Main [SCL] (OB1)
VAR_TEMP
   // OB 只有临时变量, 没有静态区
END_VAR

BEGIN
// 信息流: 输入(快照) -> 逻辑 -> 输出映像; 以下每周期整体执行
"FB_Station_DB"(bEStop := "EStop_Relay");          // 调用 FB, 单实例 DB
"Gbl_Data".iCycleCount := "Gbl_Data".iCycleCount + 1;  // 全局 DB 累计
END_ORGANIZATION_BLOCK

5.2 FC: 无状态函数

  • 没有“记忆“: 除 VAR_TEMP (不保证初始化的栈) 外无本地存储; 需要跨周期状态就得靠调用方经 VAR_IN_OUT 传入, 或用全局 DB。
  • 无递归: 块调用链编译期固定且禁止循环调用, 这是为了让存储需求可静态计算。
  • 适合: 纯计算 (缩放、限幅、CRC)、把大段逻辑拆小。类似 Python 里不碰全局变量的纯函数。
FUNCTION "FC_Sum1toN" : Int
VERSION : 0.1
   VAR_INPUT
      iN : Int;
   END_VAR
   VAR_TEMP
      i : Int;
      iAcc : Int;      // 陷阱见 6.5: 用前必须先赋值
   END_VAR

BEGIN
   #iAcc := 0;
   FOR #i := 1 TO #iN DO
      #iAcc := #iAcc + #i;
   END_FOR;
   #FC_Sum1toN := #iAcc;   // 函数名即返回值
END_FUNCTION

5.3 FB + 背景 DB: 类与对象

FB = 带接口声明 + 静态存储的“类“; 每次实例化生成一个背景 DB (instance DB), 存放该实例的 Input/Output/Static 副本。状态天然跨周期持久——这是 PLC 对 C 程序员最大的礼物: 你不用手工把状态提到全局区, FB 实例帮你封装。

FUNCTION_BLOCK "FB_Motor"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
   VAR_INPUT
      bStart : Bool;         // 瞬时启动信号
      bStop  : Bool;         // 瞬时停止信号
   END_VAR
   VAR_OUTPUT
      bRun   : Bool;         // -> 接触器
      bReady : Bool;         // 稳定运行标志
   END_VAR
   VAR
      tonTrip : TON_TIME;    // IEC 定时器多重实例 (16 字节 IEC_Timer 结构)
      iStartCount : Int := 0; // 静态: 跨周期保持, 启动计数
   END_VAR

BEGIN
   // 启保停: 每周期重入, 用 IF/ELSIF 表达"锁存"
   IF #bStop THEN
      #bRun := FALSE;
   ELSIF #bStart THEN
      #bRun := TRUE;
      #iStartCount := #iStartCount + 1;   // 演示: 无边沿检测每周期都会加 (见 R_TRIG 章节)
   END_IF;

   #tonTrip(IN := #bRun, PT := T#3S);     // 定时器状态存在 #tonTrip 里, 跨周期累计
   #bReady := #tonTrip.Q;
END_FUNCTION_BLOCK

单实例调用 (OB1 里, 自动生成实例 DB “FB_Motor_DB”): "FB_Motor_DB"(bStart := "BtnStart", bStop := "BtnStop", bRun => "K1");

5.4 全局 DB: 全局 struct

上面 3.1/2 已示例。要点: 全局 DB 是命名数据容器, HMI、通信、程序共同读写; 优化访问是默认 (见 8.2); 变量可设 RETAIN。

5.5 多重背景 (multi-instance): 成员对象组合

FB 内部声明另一个 FB 类型的静态变量, 该成员实例的数据存在外层 FB 的背景 DB 里, 不产生独立的 DB——等价于 C++ 里 class Station { Motor motorA; Motor motorB; }; 的成员对象, 而不是指针。

FUNCTION_BLOCK "FB_Station"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
   VAR_INPUT
      bEStop : Bool;
   END_VAR
   VAR_OUTPUT
      bAnyRunning : Bool;
   END_VAR
   VAR
      fbMotorA : "FB_Motor";    // 多重背景: 组合
      fbMotorB : "FB_Motor";
   END_VAR

BEGIN
   #fbMotorA(bStart := "BtnA_Start", bStop := #bEStop, bRun => "K_A");
   #fbMotorB(bStart := "BtnB_Start", bStop := #bEStop, bRun => "K_B");
   #bAnyRunning := "K_A" OR "K_B";
END_FUNCTION_BLOCK

好处: 20 台电机 = 一个数组化的组合 FB + 一个背景 DB, 而不是 20 个散落的全局 DB。

6. 块接口语义: 五种声明区逐个讲透

声明区语义C 类比传入副本?
VAR_INPUT值传入, 块内可读; 一般不改 (改了也不写回调用者)按值参数
VAR_OUTPUT值传出, 周期结束时写回调用者实参返回值/输出参数是 (写出)
VAR_IN_OUT引用传入, 读写直接作用于调用者的变量float* / C++ float&名义上副本进出, 语义即引用
VAR (仅 FB)静态变量, 存于背景 DB, 跨周期持久类的 private 成员
VAR_TEMP临时局部变量, 栈上, 不保证清零未初始化的栈变量

6.1 VAR_INPUT / VAR_OUTPUT: 值语义

调用 #fbMotorA(bStart := x, bRun => y): bStart 的当前值拷入实例接口; 块执行完, Output 的值拷给 y。Input 写了新值不会影响调用者的 x——除非你显式接回。类比 C 的按值传参 + 输出参数; 类比失效处: FB 的 Input/Output 本身就是背景 DB 里的存储, 上周期的值还在, “未接线的 Output 保留旧值“可能造成误解。

6.2 VAR_IN_OUT: 引用语义

FUNCTION "FC_Scale"
VERSION : 0.1
   VAR_INPUT
      rInMin : Real := 0.0;
      rInMax : Real := 27648.0;   // 模拟量原始满量程
      rOutMin : Real := 0.0;
      rOutMax : Real := 100.0;
   END_VAR
   VAR_IN_OUT
      rValue : Real;              // 读-改-写回调用者的变量 (类比 Real*)
   END_VAR

BEGIN
   IF #rValue < #rInMin THEN
      #rValue := #rInMin;          // 限幅直接写回调用者
   ELSIF #rValue > #rInMax THEN
      #rValue := #rInMax;
   END_IF;
   #rValue := (#rValue - #rInMin) * (#rOutMax - #rOutMin)
              / (#rInMax - #rInMin) + #rOutMin;
END_FUNCTION

调用: #ret := ... 不存在, 直接 FC_Scale(rValue := "Raw"); 之后 "Raw" 已被就地修改。规则: 大数据 (ARRAY/STRUCT/STRING) 尽量走 InOut 避免拷贝开销; InOut 实参必须是可写左值。注意 1200 上没有 POINTER/ANY/REF 引用类型 (那是 S7-1500 的), “传任意类型“用 VARIANT (待深入, 见 8.4)。

6.3 VAR (FB 静态): 成员变量

见 5.3。要点: 初值只在下载/存储复位/暖启动时生效一次, 不是“每次调用“——FB 没有 constructor

6.4 VAR 与全局 DB 的选择

模块自身状态 → FB 静态; 多模块/外部 (HMI、通信) 共享的数据 → 全局 DB。

6.5 VAR_TEMP: 未初始化栈变量, 官方盖章的坑

官方原文: 临时局部数据仅保留一个扫描周期, “必须在想读取它的那个周期内先写入”; 在标准访问的 FC 中使用前必须初始化, 否则值可能是随机的; Temp 不存入实例 DB。

用 C 的话说: VAR_TEMP 等价于 int x; 后直接用——UB。用 Python 的话说: 没有“未定义就报 NameError“这层保护。所以 SCL 惯例是 Temp 先赋值再读; 编译器不会替你清零, 也别依赖上周期残留值做“记忆“ (那该用静态)。

7. 调用与执行语义

7.1 OB1 每周期从头到尾完整执行

调用图编译期固定: OB1 → FB → FB/FC……像一个静态调用树每周期遍历一次。条件调用 (IF cond THEN "FB_x"(...)) 是合法且常用的——但见下条后果。

7.2 FB 未被调用时, 背景 DB 保持上次值

C 对象离开作用域即析构, Python 靠 GC; 背景 DB 永远存在, FB 一旦不被调用, 只是“这个周期没执行它的代码“, 其静态变量与上次的 Input/Output 原样冻结。后果:

  • 特性: 条件调用大算法可以省周期时间, 实例状态无损。
  • 坑 1: FB 内部的 IEC 定时器/计数器不会走, 但也不会复位——“暂停“而非“清零”; R_TRIG 之类的内部边沿记忆同样冻结。
  • 坑 2: 恢复调用的第一个周期, 上次冻结的中间状态继续演进, 与“重新开始“语义不同。需要重入复位就显式给 FB 加 bReset 输入。
  • 坑 3: Output 未接线时, 读到的是冻结的旧值。

7.3 ENO 与 S7-1200 的运行期错误行为

ENO (enable out) 是“上一指令出错“位: 出错时 ENO=FALSE, 后续 ENO 级联的指令被跳过; SCL 里可用 instruction(..., ENO => #ok); 捕获。没有异常机制, 没有 core dump, CPU 一般保持 RUN。S7-1200 具体行为 (官方 ENO 表):

运行期错误S7-1200 行为
整数除零 (IN2=0)结果为 0 (undefined, zero returned), ENO=0, 保持 RUN
REAL/LREAL 非法运算 (NaN/INF)ENO=0, 返回 NaN, 保持 RUN
CONV 转换溢出/NaNENO=0 (OUT 取值不同版本文档表述有差异——以 ENO 为准, 不要依赖 OUT), 保持 RUN
字符串转数值 (S_CONV/STRG_VAL) 遇非法字符/超范围ENO=0, OUT=0 (从首字符转换到首个非法字符为止), 保持 RUN
数组下标越界记诊断缓冲区并保持 RUN (S7-1500 才是转 STOP/OB121); ENO 不会因越界置 FALSE
编程错误 (块内启用局部错误处理)写入 ErrorStruct, 用 GET_ERROR/GET_ERROR_ID 读取

对比 C: 除零在整数下是 UB/崩溃, 在浮点下是 INF; 对比 Python: 抛 ZeroDivisionError。PLC 的哲学是“受控地给出错值并让机器继续转“——所以每条会出错的指令都该检查 ENO, 否则 0 会像真数据一样流进控制逻辑。数组越界不 STOP 这点对 C 程序员尤其反直觉: 坏下标静默读出垃圾值, 务必在代码里自己守住边界。

8. 寻址: 绝对 / 符号 / 优化块 / AT / PEEK-POKE

8.1 绝对寻址与符号寻址

绝对地址含义
%I0.0 / %Q0.0 / %M100.5输入/输出/M 区的位 (字节 0/100, 位 0/5)
%IB0 %QB4 %MB100字节
%IW0 %MW100字 (16 位)
%ID0 %MD100双字 (32 位)
"Gbl_Data".iCycleCount符号寻址: DB 名.变量名

规则: 每个物理点先在硬件组态里分到 I/Q 地址, 再在 PLC tags 表里给 %I0.0 起符号名 ("EStop_Relay"), 代码里一律用符号。注意标识符不区分大小写: MotorMOTOR 是同一个 tag (官方规则); 保留关键字 (Int、指令名等) 不得作 tag 名。

8.2 优化块 vs 标准块 (内存布局与通信映射)

新建 DB/FB 默认 “Optimized block access”。差异全景:

维度优化块 (默认)标准块 (standard)
变量地址只有符号名, 无 % 地址, CPU 自动紧凑排列无空隙符号 + 固定绝对地址 (如 %DB5.DBX30.0)
布局编译器自由重排 (按访问优化)按声明顺序对齐, 可能有填充字节
兼容性S7-1200/1500兼容 S7-300/400 及经典工具/协议
PEEK/POKE 访问 DB不允许允许
AT 覆盖仅保持性 (retentive) 标签任意标签
WSTRING允许声明不允许
按“固定字节偏移“的外部映射不适用 (偏移不承诺稳定)适用

对通信映射的意义: 凡是“第三方按偏移读写你的 DB“的场景——跨 CPU 通信表、Modbus TCP 保持寄存器映射、非西门子 HMI/网关——都需要可承诺的字节布局, 因此要么用标准 DB, 要么用专门的标准“接口 DB“做边界转换。优化块内部随便改不影响外部布局, 这是“信息隐藏“; 标准块是“序列化格式“。两者并存的最佳实践: 内部全优化, 边界放标准 DB。

8.3 AT 视图: 同一块内存的另一种类型 (≈ union 的受控版)

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

FUNCTION_BLOCK "FB_UnpackMode"
{ S7_Optimized_Access := 'FALSE' }   // 标准块才能对任意标签做 AT 覆盖
VERSION : 0.1
   VAR_INPUT
      byMode : Byte;                       // 来自通信报文的原始字节
      xBits  : "UDT_ByteBits" AT byMode;   // 同一内存的位视图 (TIA 中 AT 写在"覆盖"列)
   END_VAR

BEGIN
   IF #xBits.b0 THEN        // 读 byMode 的 bit0
      ;
   END_IF;
END_FUNCTION_BLOCK

规则: 覆盖体大小 ≤ 被覆盖体; 不能覆盖 VARIANT; 优化块中只能覆盖保持性标签 (以官方页面为准, 实践建议 AT 只在标准块用)。注意 Array of Bool 的布局与块访问模式有关 (标准块每元素 1 字节, 优化块按位打包)——位视图请用 Bool 成员的 Struct (按位打包) 而不是 Bool 数组。与 C union 的差别: AT 是“给已有标签加视图“, 不额外占内存, 也不允许两个写者打架之外的新风险面。

8.4 间接访问: 数组变址与 PEEK/POKE

S7-1200 没有 C 式指针 (POINTER/ANY/REF_TO 是 S7-1500 的能力)。间接访问三板斧:

  1. 数组变址: #v := "Gbl_Data".aiBuf[#i]; 下标可用 Int/DInt 变量, 支持多维 [i, j]
  2. PEEK/POKE 家族 (仅 SCL), 按区域码+字节偏移读写:
// 区域码: 16#81=I, 16#82=Q, 16#83=M, 16#84=DB (DB 必须是标准块)
#raw := PEEK_WORD(area := 16#83, dbNumber := 0, byteOffset := 100);   // 读 MW100
POKE(area     := 16#84, dbNumber := 5, byteOffset := 20, value := 16#00FF);
POKE_BOOL(area := 16#83, dbNumber := 0, byteOffset := 10, bitOffset := 3, value := TRUE);
  1. VARIANT 形参 + VariantGet/VariantPut/CountOfElements: 传“任意类型“参数并运行期取实数边界 (LOWER_BOUND/UPPER_BOUND), 是 1200 上做泛型库函数的正道; ARRAY[*] 形参需优化块 + 固件 >= V4.2。

另有 slice 访问: "dwFlags".X3 / .B1 / .W1 / .D0 直接取整型变量的位/字节/字片段 (SCL 中需关闭该块的 IEC 检查)。

9. PROFINET 与分布式 IO: 硬件组态是什么

  • PROFINET IO: 建立在标准以太网上的工业实时协议。三个角色: IO Controller (PLC CPU, 主)、IO Device (分布式 IO 站, 如 ET 200SP, 从)、IO Supervisor (工程/诊断工具)。
  • 周期数据交换: Controller 与每个 Device 按组态的更新周期 (毫秒级) 交换输入/输出帧; 远程站的通道在硬件组态里被分配 I/Q 地址后, 在用户程序里与本地 IO 完全同构 (%I2.0 可能来自 30 米外的 ET 200SP 模块)。这正是过程映像抽象的威力: 分布式对程序员透明。
  • 非周期通道 (record data) 用于参数化与诊断, 与周期数据分离。S7-1200 支持 RT 通信等级; 等时同步 IRT 不在 1200 能力内 (待核实)。S7-1200 可同时充当 IO Controller 与 IO Device (智能从站, 组合限制以手册为准)(待核实)。
  • 硬件组态 (device configuration): 在 TIA Portal 里声明“机架上有什么“: CPU 型号、信号模块 (SM) 槽位、PROFINET 站 (设备名 + IP)、各通道地址与参数 (如模拟量 4~20 mA / 0~10 V)。它不是代码, 是一份随项目一起下载到 CPU 的静态描述。最贴切的类比: 嵌入式 Linux 的 devicetree——用声明式数据描述硬件拓扑, 运行时 OS 据此映射寄存器/报文到地址空间。第三方设备用 GSD 文件导入 (≈ 驱动描述文件, 厂商提供设备的能力模板)。
  • 站掉线属于“机架/站故障“事件 (优先级 6), 可组态 OB 处理或靠诊断缓冲发现。

10. 诊断体系: 诊断缓冲区 / LED / 在线模式

  • 诊断缓冲区 (diagnostic buffer): CPU 内的带时间戳环形事件日志: 模式切换 (STOP/RUN 及原因)、断电、模块故障、站掉线、周期超时、数组越界等全部留痕。排障第一站永远是 “Go online → Diagnostics buffer”。类比: 内核日志 ring buffer (dmesg), 但每个条目带硬件时间戳且断电不丢。
  • CPU LED:
LED颜色含义
RUN绿RUN 模式, 循环执行
STOPSTOP 模式
ERROR出错: 闪烁 = 事件级错误 (如站故障); 常亮 = CPU 硬件缺陷
MAINT需要维护 (如组态与实机不一致)
  • 在线模式 (online mode): 下载/监控三件套——监视表/监控表 (watch table) 实时看/改变量; 强制表 (force table) 把变量强制为固定值 (绕过程序, 类比调试器的变量 pin, 但作用于真实端子, 现场危险操作); 程序状态 (status) 在线看 SCL 行级当前值。注意: S7-1200 不支持断点调试 (断点仅 S7-300/400 与固件 >= V2.5 的 S7-1500, 官方明示), 仿真器 PLCSIM 仿 1200 同样无断点。你的调试工作流要从“打断点单步“改成“watch table + status + PLCSIM 仿真 + 诊断缓冲“。

11. PLC ↔ C/Python 概念对照总表

C / PythonS7-1200 (TIA Portal)备注
main() + while(1)OB1 程序循环每周期全量重入, 不退出
中断服务程序 ISR事件 OB (硬件中断/循环中断/时间中断)优先级抢占, 快进快出
setup() / 构造OB100 启动RUN 前跑一次
无状态函数FC无 static, 无递归
类定义FB接口 + 静态数据
对象实例背景 DB (instance DB)生命周期 = 永久, 见 7.2
成员对象组合多重背景 multi-instance嵌在外层背景 DB 里
全局 struct全局 DB可 RETAIN, HMI 可直接绑
typedef structUDT (PLC data type)纯模板
按值参数 / 输出参数VAR_INPUT / VAR_OUTPUT值拷贝进出
T* / T& 传参VAR_IN_OUT引用语义
未初始化局部变量VAR_TEMP不清零, 先写后读
类私有成员FB 的 VAR (静态)跨周期持久
内存映射 IO 寄存器过程映像 I/Q差异: 快照而非实时, 见 3.2
volatile 全局变量M 区 / I / Q每周期被 OS/现场“改写“
看门狗喂狗循环监视 150 ms 默认, 自动重启RE_TRIGR 手动喂, 超时两次 STOP
heap / malloc / GC不存在一切静态分配, 编译期定死
异常 / errnoENO + 诊断缓冲不崩溃, 见 7.3
断点调试1200 无断点; watch/force/statusPLCSIM 也无
devicetree硬件组态 (device config)声明式硬件描述

12. 类比何时失效 (重要: 每条都是真事故来源)

  1. “程序会跑完/会退出“失效。 PLC 程序是无限重入的循环体。把“一次性初始化“写成裸代码而不放进 OB100, 它会每周期执行一遍; 把跨周期状态放 VAR_TEMP, 下周期读到的是随机残留。
  2. 对象生命周期失效。 C++ 栈对象离开作用域析构, Python 靠 GC; 背景 DB 永不析构。“未调用 = 冻结而非销毁”——定时器不走也不清零, 恢复调用时从冻结点继续 (见 7.2)。FB 也没有 constructor, 初值只在下载/重启时生效。
  3. “回调/事件不丢“失效。 没有框架替你做 diff 与排队; 每周期全量执行意味着边沿要自己检 (R_TRIG), 比扫描周期更短的脉冲会丢, 除非上硬件中断。
  4. 异常处理模型失效。 除零不抛异常: 结果 0 + ENO=0 + 继续 RUN; 数组越界在 1200 上不 STOP, 静默读垃圾。不检查 ENO, 错误值会混进 PID 与 HMI。没有 try/catch, 局部错误用 GET_ERROR 结构化捕获。
  5. 指针与动态内存失效。*、无指针算术、无 malloc、无递归; VAR_IN_OUT 只有“引用“语义; 间接访问走数组变址/PEEK/POKE/VARIANT。
  6. “阻塞等待“失效。 sleep/await/长循环都会吃掉看门狗预算, 两次超时即 STOP; 等待必须改写成定时器驱动的状态机。
  7. 多线程直觉部分失效。 没有线程, 但中断 OB 与 OB1 是真实的并发单元: 两者共享的 DB 数据有一致性问题 (读了一半被改), 需要像对待 ISR 共享数据一样设计访问次序。没有 mutex, 靠优先级与结构安排。
  8. “断点单步“失效。 S7-1200 连仿真器都没有断点; 调试 = watch table + status + PLCSIM + 诊断缓冲。习惯 printf 的人转 trace/trace table (若 CPU 支持) 或把状态导出到 HMI。
  9. 内存布局直觉失效。 优化块 (默认) 没有稳定地址, 编译器会重排; 按字节偏移对外映射必须显式用标准块。别拿 C 的 struct 布局/对齐直觉去猜 DB 偏移。
  10. 大小写与命名失效。 tag 名不区分大小写 (Motor == MOTOR), 与 Python/C 的标识符敏感性相反; 保留字 (如 Int) 不能作变量名。
  11. 精度直觉部分失效。 TIME 是 32 位毫秒整数, 约 ±24.8 天回绕——长期累计计时要用 DTL 或自行进位; REAL 是单精度, 别拿 double 的精度预期写控制算法。
  12. 安全责任不类比。 桌面软件崩溃重启即可; PLC 输出直接驱动物理设备, “保持 RUN + 输出错误值“可能比崩溃更危险。急停、联锁必须在硬件层与程序层双重存在——这不是语法问题, 是行业底线。

下一篇 (03) 将进入 SCL 语法细节: 表达式、控制流、定时器/计数器与状态机写法。