(* ============================================================ *)
(* modbus_自由口从站 —— S7-1200 自由口 Modbus RTU 从站          *)
(* S7-1200 TIA Portal SCL 源文件（导入用，GB2312/ANSI 编码）    *)
(* 导入：项目树->外部源文件->添加新外部源文件->从源生成块       *)
(* 本文件只含 FB "modbus_自由口从站"，不含 FC。                 *)
(* 共享的 FC "CRC16校验" 在 CRC16校验.scl 里，须先导那一份。    *)
(* 不要先手动新建同名对象。完整说明见 UTF-8 主版本。            *)
(* 支持功能码：01/02/03/04/05/06/15/16 + 规范异常响应。         *)
(* 依赖系统指令 Send_P2P / Receive_P2P（PTP 通信）。            *)
(* 版本：v1.0  2026-09-29                                       *)
(* ============================================================ *)

FUNCTION_BLOCK "modbus_自由口从站"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 1.0
   VAR_INPUT 
      硬件标识符 : UInt;                        // CM1241 的硬件标识符（设备组态里查）
      从站号 : UInt := 1;                       // 本从站地址，合法 1..247
      起始地址 : UInt := 0;                     // 地址偏移：主站地址 = 内部下标 + 本值
      超时时间 : Time := T#2s;                  // 发送兜底超时（见程序体第 2 段）
      使能 : Bool := TRUE;                      // FALSE = 本从站完全不应答（HMI 可当"停用"用）
   END_VAR

   VAR_OUTPUT 
      (* —— 与原版同名同义，OB / HMI 里已经在用的不用改 ——            *)
      发送完成 : Bool;                          // Send_P2P 的 DONE（单周期脉冲）
      发送错误 : Bool;                          // Send_P2P 的 ERROR（单周期脉冲）
      发送信息 : Word;                          // Send_P2P 的 STATUS
      接收完成 : Bool;                          // Receive_P2P 的 NDR（单周期脉冲）
      接收错误 : Bool;                          // Receive_P2P 的 ERROR
      接收信息 : Word;                          // Receive_P2P 的 STATUS
      接收长度 : UInt;                          // 最近一帧的字节数（NDR 有效）
      校验信息 : Word;                          // 诊断码，锁存：正常=0，出错置码（见文档表）
      读写完成 : Bool;                          // 一次请求被正常执行完（单周期脉冲）
      读写错误 : Bool;                          // 一次请求被回了异常帧（单周期脉冲）
      (* —— v1.0 新增 ——                                              *)
      发送超时 : Bool;                          // 锁存：上次发送超时；下次发送成功才清
      参数错误 : Bool;                          // 稳态：从站号不在 1..247
      最后功能码 : Byte;                        // 最近一帧请求的功能码
      最后异常码 : Byte;                        // 最近一次回给主站的异常码（0=没回过）
      最后起始地址 : UInt;                      // 最近一帧请求的起始地址（原始值）
      最后数量 : UInt;                          // 最近一帧请求的数量字段（原始值）
      好帧计数 : UDInt;                         // 累计：站号匹配 + CRC 正确 + 功能码像请求
      坏帧计数 : UDInt;                         // 累计：被丢弃的帧（长度/站号/CRC/回环）
      异常计数 : UDInt;                         // 累计：回给主站的异常帧次数
      广播计数 : UDInt;                         // 累计：收到的广播帧（站地址 0）次数
      版本指纹 : Byte;                          // 在线监视到 16#0A 说明跑的是本版代码
   END_VAR

   VAR_IN_OUT 
      读数据 : Array[0..1999] of Word;          // FC03 数据源
      写数据 : Array[0..1999] of Word;          // FC06 / FC16 落点
      输入寄存器 : Array[0..1999] of Word;      // FC04 数据源
      线圈 : Array[0..1999] of Bool;            // FC01 读、FC05 / FC15 写
      离散输入 : Array[0..1999] of Bool;        // FC02 数据源
   END_VAR

   VAR 
      Send_P2P_Instance {InstructionName := 'Send_P2P'; LibVersion := '4.0'; S7_SetPoint := 'False'} : Send_P2P;
      Receive_P2P_Instance {InstructionName := 'Receive_P2P'; LibVersion := '4.1'} : Receive_P2P;
      超时定时器 {InstructionName := 'TON_TIME'; LibVersion := '1.0'; S7_SetPoint := 'False'} : TON_TIME;
      边沿完成 {InstructionName := 'R_TRIG'; LibVersion := '1.0'} : R_TRIG;
      边沿错误 {InstructionName := 'R_TRIG'; LibVersion := '1.0'} : R_TRIG;
      边沿接收错 {InstructionName := 'R_TRIG'; LibVersion := '1.0'} : R_TRIG;
      发送待发 : Bool;                          // 有应答帧在等下一扫描发出
      发送进行 : Bool;                          // 发送作业正在跑
      发送启动 : Bool;                          // 给 Send_P2P 的 REQ 单周期脉冲
      发送长度 : UInt;                          // 本次要发的字节数
      接收报文 : Array[0..255] of Byte;         // 接收缓冲（静态：要跨扫描留存）
      发送报文 : Array[0..255] of Byte;         // 发送缓冲（静态：同上）
      指纹 : Byte := 16#0A;                     // 版本指纹
   END_VAR

   VAR_TEMP 
      接收NDR : Bool;
      帧OK : Bool;
      值OK : Bool;
      站地址 : Byte;
      是广播 : Bool;
      功能码 : Byte;
      起始 : UInt;
      数量 : UInt;
      字节数 : UInt;
      上限 : UInt;
      实际起始 : UInt;
      地址可算 : Bool;
      异常码 : Byte;
      有响应 : Bool;
      位 : Bool;
      循环 : UInt;
      CRC低 : Byte;
      CRC高 : Byte;
   END_VAR


BEGIN

(* ============================================================ *)
(* 0. 版本指纹                                                  *)
(* 静态量 #指纹 实例 DB 里能看到；输出 #版本指纹 每周期镜像过去 *)
(* 在线监视表里一眼就能确认烧进去的是哪一版。                   *)
(* ============================================================ *)
#指纹     := 16#0A;
#版本指纹 := #指纹;

(* ============================================================ *)
(* 1. 参数体检（每扫描周期重算，必须是"稳态"的）                *)
(* 0 是广播地址，不能拿来当本机地址；248..255 是保留/非法。     *)
(* 这里不放在某条分支里置一次——只置一次的话，现场打开监视表的   *)
(* 那一刻它早就被下一周期清掉了，等于白做。                     *)
(* ============================================================ *)
#参数错误 := FALSE;
IF (#从站号 < 1) OR (#从站号 > 247) THEN
    #参数错误 := TRUE;
    #校验信息 := 16#6106;
END_IF;

(* ============================================================ *)
(* 2. 发送作业结账                                              *)
(* ------------------------------------------------------------ *)
(* 为什么 DONE / ERROR 必须取上升沿，而不是看电平：             *)
(* Send_P2P 的 DONE 会一直保持到下一次作业启动才复位。          *)
(* 拿电平当"已完成"，新作业启动第一扫描就会看到上一次遗留的     *)
(* DONE 电平，误判成"已完成"当场取消——应答帧就发不出去，        *)
(* 而且时有时无，取决于上一个作业什么时候完成。                 *)
(* 取沿就完全没这个问题：没发生 0->1 跳变就不结账。             *)
(* ============================================================ *)
#边沿完成(CLK := #发送完成);
#边沿错误(CLK := #发送错误);

#超时定时器(IN := #发送进行,
            PT := #超时时间);

IF #发送进行 AND (#边沿完成.Q OR #边沿错误.Q) THEN
    #发送进行 := FALSE;
    IF #边沿错误.Q THEN
        #校验信息 := 16#6120;                 (* 发送失败，STATUS 看 #发送信息 *)
    ELSE
        #发送超时 := FALSE;                   (* 发成功一次就清掉超时锁存 *)
    END_IF;
END_IF;

IF #发送进行 AND #超时定时器.Q THEN
    (* 兜底超时：Send_P2P 既不报 DONE 也不报 ERROR（模块卡死、端口被别的
       程序占用、RS485 被拉死）。不解锁的话本从站会永久卡在"发送中"，
       之后任何请求都收不进来——现场表现就是"通信突然全没了"。
       默认 2 s 是按 9600 bps 下最长 255 字节帧（约 267 ms）留的余量；
       波特率更低（1200/2400）时长帧本身就要 1~2 s，得把"超时时间"调大。 *)
    #发送进行 := FALSE;
    #发送超时 := TRUE;                        (* 锁存 *)
    #校验信息 := 16#6101;
END_IF;

(* ============================================================ *)
(* 3. 启动新发送                                                *)
(* ------------------------------------------------------------ *)
(* 故意"慢一拍"：本扫描收到的请求，下一扫描才发应答。           *)
(* 原因是 RS485 半双工——刚收完帧，CM1241 的收发器还在收态，     *)
(* 同一扫描就怼着发，方向切换来不及，头几个字节容易被削掉。     *)
(* 隔一个扫描周期（OB1 里通常几毫秒）正好是最省事又够用的间隔。 *)
(* ============================================================ *)
#发送启动 := FALSE;
IF #发送待发 AND NOT #发送进行 THEN
    #发送待发 := FALSE;
    #发送进行 := TRUE;
    #发送启动 := TRUE;
END_IF;

(* ============================================================ *)
(* 4. 接收                                                      *)
(* ------------------------------------------------------------ *)
(* Receive_P2P 是"常驻作业"，必须每扫描周期调用；帧的结束由     *)
(* CM1241 的组态决定（见头部约定 1），本块不自己做帧间隔计时。  *)
(* ============================================================ *)
#Receive_P2P_Instance("PORT" := #硬件标识符,
                      BUFFER := #接收报文,
                      NDR    => #接收NDR,
                      ERROR  => #接收错误,
                      STATUS => #接收信息,
                      LENGTH => #接收长度);
#接收完成 := #接收NDR;

#边沿接收错(CLK := #接收错误);
IF #边沿接收错.Q THEN
    #校验信息 := 16#6121;                     (* 接收出错，STATUS 看 #接收信息 *)
END_IF;

(* ============================================================ *)
(* 5. 解析并执行请求                                            *)
(* ============================================================ *)
#读写完成 := FALSE;                            (* 脉冲型输出，先清零 *)
#读写错误 := FALSE;

(* 发送期间不解析：485 半双工时本机正在发，收到的只可能是干扰或自环回。
   把帧丢掉比半路改发送缓冲安全得多。 *)
IF #接收NDR AND #使能 AND (NOT #参数错误) AND (NOT #发送进行) THEN

    (* ----------------------- 5.1 帧长下限 ----------------------- *)
    (* 合法 Modbus RTU 帧最短 5 字节（异常响应帧）；连 4 字节都不到的，
       不值得回帧，直接丢。 *)
    #帧OK := TRUE;
    IF #接收长度 < 4 THEN
        #帧OK := FALSE;
        #坏帧计数 := #坏帧计数 + 1;
        #校验信息 := 16#6103;
    END_IF;

    (* ------------------------ 5.2 站地址 ------------------------ *)
    (* 不是发给本从站的：静默丢弃。同一条 485 上挂多台从站时这是常态，
       只能丢，绝不能回错误帧——一回就把别家的应答撞坏了。 *)
    IF #帧OK THEN
        #站地址 := #接收报文[0];
        #是广播 := (#站地址 = 0);
        IF (#站地址 <> #从站号) AND (NOT #是广播) THEN
            #帧OK := FALSE;
            #坏帧计数 := #坏帧计数 + 1;
            #校验信息 := 16#6102;
        END_IF;
    END_IF;

    (* ------------------------- 5.3 CRC -------------------------- *)
(* Modbus 的 CRC 低字节先发，即末两字节是"低字节, 高字节"。     *)
    IF #帧OK THEN
        "CRC16校验"(校验字节数 := #接收长度 - 2,
                  校验数据 := #接收报文,
                  CRC低 => #CRC低,
                  CRC高 => #CRC高);
        IF (#CRC低 <> #接收报文[#接收长度 - 2]) OR (#CRC高 <> #接收报文[#接收长度 - 1]) THEN
            (* CRC 错的帧一律静默丢弃——这是规范的要求：线路上本来就有干扰，
               再回一帧只会让总线更乱，而且回帧的 CRC 对方也未必收得对。 *)
            #帧OK := FALSE;
            #坏帧计数 := #坏帧计数 + 1;
            #校验信息 := 16#6100;
        END_IF;
    END_IF;

    (* --------------------- 5.4 排除"应答帧" --------------------- *)
    (* 功能码最高位为 1（>= 0x80）的是**应答帧**，正常主从通信里本机不该收到。
       一旦收到，基本只有两种可能：485 的 A/B 接反形成自环回，或者总线上
       还挂着另一个主站。必须静默丢弃——要是拿它当请求去回异常帧，
       两台设备就会互相怼，总线直接堵死。 *)
    IF #帧OK AND (#接收报文[1] >= 16#80) THEN
        #帧OK := FALSE;
        #坏帧计数 := #坏帧计数 + 1;
        #校验信息 := 16#6105;
    END_IF;

    IF #帧OK THEN
        (* ---------------- 5.5 取字段并做地址偏移换算 ---------------- *)
        #好帧计数 := #好帧计数 + 1;
        IF #是广播 THEN
            #广播计数 := #广播计数 + 1;
        END_IF;

        #功能码 := #接收报文[1];
        #起始 := BYTE_TO_UINT(#接收报文[2]) * 256 + BYTE_TO_UINT(#接收报文[3]);
        #数量 := BYTE_TO_UINT(#接收报文[4]) * 256 + BYTE_TO_UINT(#接收报文[5]);
        #最后功能码 := #功能码;
        #最后起始地址 := #起始;
        #最后数量 := #数量;

        (* 广播（站地址 0）在 Modbus 里是"只执行、不应答"：总线上没有请求方
           在等回复，回了就是多余的报文。 *)
        #有响应 := NOT #是广播;
        #异常码 := 16#00;

        (* 地址偏移：主站地址 - 偏移 = 内部下标。
           减出负数说明主站在访问本从站区以外的地址，判非法。 *)
        IF #起始 >= #起始地址 THEN
            #实际起始 := #起始 - #起始地址;
            #地址可算 := TRUE;
        ELSE
            #实际起始 := 0;
            #地址可算 := FALSE;
        END_IF;

        IF (#功能码 = 1) OR (#功能码 = 2) THEN
            (* ============================================================ *)
            (* FC01 读线圈 / FC02 读离散输入                                *)
            (* 请求 8 字节，应答 3 + ceil(数量/8) + 2 字节                  *)
            (* ============================================================ *)
            IF #接收长度 <> 8 THEN
                #异常码 := 16#03;                       (* 请求长度不对 *)
            ELSIF (#数量 < 1) OR (#数量 > 2000) THEN
                #异常码 := 16#03;                       (* 数量超规范范围 *)
            ELSIF (NOT #地址可算) OR ((#实际起始 + #数量) > 2000) THEN
                #异常码 := 16#02;                       (* 地址 + 数量越界 *)
            ELSE
                #字节数 := (#数量 + 7) / 8;
                #发送报文[0] := UINT_TO_BYTE(#从站号);
                #发送报文[1] := #功能码;
                #发送报文[2] := UINT_TO_BYTE(#字节数);
                (* 先把数据区清零：后面是"置位"式打包，不清零的话上一帧的
                   残留位会被 OR 进来，主站读到的线圈会凭空多出几个 TRUE。 *)
                FOR #循环 := 0 TO #字节数 - 1 DO
                    #发送报文[3 + #循环] := 0;
                END_FOR;
                FOR #循环 := 0 TO #数量 - 1 DO
                    IF #功能码 = 1 THEN
                        #位 := #线圈[#实际起始 + #循环];
                    ELSE
                        #位 := #离散输入[#实际起始 + #循环];
                    END_IF;
                    IF #位 THEN
                        (* Modbus 规定同一字节内"地址小的在低位"，
                           所以第 n 个线圈放在该字节的第 (n MOD 8) 位。 *)
                        #发送报文[3 + #循环 / 8] :=
                            #发送报文[3 + #循环 / 8]
                            OR SHL(IN := B#16#01, N := #循环 MOD 8);
                    END_IF;
                END_FOR;
                "CRC16校验"(校验字节数 := 3 + #字节数,
                          校验数据 := #发送报文,
                          CRC低 => #CRC低,
                          CRC高 => #CRC高);
                #发送报文[3 + #字节数] := #CRC低;
                #发送报文[4 + #字节数] := #CRC高;
                #发送长度 := 5 + #字节数;
            END_IF;

        ELSIF (#功能码 = 3) OR (#功能码 = 4) THEN
            (* ============================================================ *)
            (* FC03 读保持寄存器(读数据) / FC04 读输入寄存器                *)
            (* 请求 8 字节，应答 3 + 数量*2 + 2 字节                        *)
            (* ============================================================ *)
            IF #接收长度 <> 8 THEN
                #异常码 := 16#03;
            ELSIF (#数量 < 1) OR (#数量 > 125) THEN
(* 125 是 Modbus 规范上限：一帧 256 字节装不下更多              *)
                #异常码 := 16#03;
            ELSIF (NOT #地址可算) OR ((#实际起始 + #数量) > 2000) THEN
                #异常码 := 16#02;
            ELSE
                #字节数 := #数量 * 2;
                #发送报文[0] := UINT_TO_BYTE(#从站号);
                #发送报文[1] := #功能码;
                #发送报文[2] := UINT_TO_BYTE(#字节数);
                FOR #循环 := 0 TO #数量 - 1 DO
                    (* 寄存器在 Modbus 上是"高字节先发"。注意 .%B1 是高字节、
                       .%B0 是低字节——TIA 的片段访问跟 S7 绝对地址 MB0/MB1 的
                       大端顺序是**相反**的，别按老习惯写，写反了数值整体错位。 *)
                    IF #功能码 = 3 THEN
                        #发送报文[3 + #循环 * 2] := #读数据[#实际起始 + #循环].%B1;
                        #发送报文[4 + #循环 * 2] := #读数据[#实际起始 + #循环].%B0;
                    ELSE
                        #发送报文[3 + #循环 * 2] := #输入寄存器[#实际起始 + #循环].%B1;
                        #发送报文[4 + #循环 * 2] := #输入寄存器[#实际起始 + #循环].%B0;
                    END_IF;
                END_FOR;
                "CRC16校验"(校验字节数 := 3 + #字节数,
                          校验数据 := #发送报文,
                          CRC低 => #CRC低,
                          CRC高 => #CRC高);
                #发送报文[3 + #字节数] := #CRC低;
                #发送报文[4 + #字节数] := #CRC高;
                #发送长度 := 5 + #字节数;
            END_IF;

        ELSIF (#功能码 = 5) OR (#功能码 = 6) THEN
            (* ============================================================ *)
            (* FC05 写单个线圈 / FC06 写单个寄存器                          *)
            (* 请求 8 字节，应答 = 原样回显这 8 个字节                      *)
            (* ============================================================ *)
            #值OK := TRUE;
            IF #功能码 = 5 THEN
                (* 规范规定写线圈的值只能是 0x0000(断开) 或 0xFF00(闭合)，
                   其他值一律判非法数据值。很多主站会误发 0x0001，这里必须挡住，
                   否则现场会出现"写下去没反应但也不报错"。 *)
                #值OK := ((#接收报文[4] = 16#00) OR (#接收报文[4] = 16#FF))
                         AND (#接收报文[5] = 16#00);
            END_IF;

            IF #接收长度 <> 8 THEN
                #异常码 := 16#03;
            ELSIF (NOT #地址可算) OR (#实际起始 > 1999) THEN
                #异常码 := 16#02;
            ELSIF NOT #值OK THEN
                #异常码 := 16#03;
            ELSE
                (* 注意：FC05/FC06 的请求里根本没有"数量"字段——
                   第 5、6 个字节是**要写的值**，不是计数。
                   原版在这里用 #解析读写长度 参与下标计算，是个隐患。 *)
                IF #功能码 = 5 THEN
                    IF #接收报文[4] = 16#FF THEN
                        #线圈[#实际起始] := TRUE;
                    ELSE
                        #线圈[#实际起始] := FALSE;
                    END_IF;
                ELSE
                    #写数据[#实际起始].%B1 := #接收报文[4];   (* 高字节 *)
                    #写数据[#实际起始].%B0 := #接收报文[5];   (* 低字节 *)
                END_IF;
                #发送报文[0] := UINT_TO_BYTE(#从站号);
                #发送报文[1] := #功能码;
                #发送报文[2] := #接收报文[2];
                #发送报文[3] := #接收报文[3];
                #发送报文[4] := #接收报文[4];
                #发送报文[5] := #接收报文[5];
                "CRC16校验"(校验字节数 := 6,
                          校验数据 := #发送报文,
                          CRC低 => #CRC低,
                          CRC高 => #CRC高);
                #发送报文[6] := #CRC低;
                #发送报文[7] := #CRC高;
                #发送长度 := 8;
            END_IF;

        ELSIF (#功能码 = 15) OR (#功能码 = 16) THEN
            (* ============================================================ *)
            (* FC15 写多线圈 / FC16 写多寄存器                              *)
            (* 请求 9 + N 字节数据，应答 8 字节（回显起始+数量）            *)
            (* ============================================================ *)
            IF #功能码 = 15 THEN
                #上限 := 1968;                          (* 规范上限：1968 个线圈 *)
            ELSE
                #上限 := 123;                           (* 规范上限：123 个寄存器 *)
            END_IF;

            IF #接收长度 < 9 THEN
                #异常码 := 16#03;
            ELSIF (#数量 < 1) OR (#数量 > #上限) THEN
                #异常码 := 16#03;
            ELSIF BYTE_TO_UINT(#接收报文[6]) <> (#接收长度 - 9) THEN
(* 第 7 字节是"后续数据字节数"，必须等于帧里真正剩下的字节数    *)
                #异常码 := 16#03;
            ELSIF (NOT #地址可算) OR ((#实际起始 + #数量) > 2000) THEN
                #异常码 := 16#02;
            ELSE
                (* 数据字节数还要跟数量自洽：线圈是 ceil(数量/8)，
                   寄存器是 数量*2。不自洽就是畸形帧，不能按"数量"去取数组，
                   否则会读到报文以外的地方。 *)
                IF #功能码 = 15 THEN
                    #字节数 := (#数量 + 7) / 8;
                ELSE
                    #字节数 := #数量 * 2;
                END_IF;

                IF BYTE_TO_UINT(#接收报文[6]) <> #字节数 THEN
                    #异常码 := 16#03;
                ELSE
                    IF #功能码 = 15 THEN
                        FOR #循环 := 0 TO #数量 - 1 DO
                            IF (#接收报文[7 + #循环 / 8]
                                AND SHL(IN := B#16#01, N := #循环 MOD 8)) <> B#16#00 THEN
                                #线圈[#实际起始 + #循环] := TRUE;
                            ELSE
                                #线圈[#实际起始 + #循环] := FALSE;
                            END_IF;
                        END_FOR;
                    ELSE
                        FOR #循环 := 0 TO #数量 - 1 DO
                            #写数据[#实际起始 + #循环].%B1 := #接收报文[7 + #循环 * 2];
                            #写数据[#实际起始 + #循环].%B0 := #接收报文[8 + #循环 * 2];
                        END_FOR;
                    END_IF;

                    #发送报文[0] := UINT_TO_BYTE(#从站号);
                    #发送报文[1] := #功能码;
                    #发送报文[2] := #接收报文[2];       (* 回显原始起始地址高字节 *)
                    #发送报文[3] := #接收报文[3];       (* 回显原始起始地址低字节 *)
                    #发送报文[4] := #接收报文[4];       (* 回显原始数量高字节 *)
                    #发送报文[5] := #接收报文[5];       (* 回显原始数量低字节 *)
                    "CRC16校验"(校验字节数 := 6,
                              校验数据 := #发送报文,
                              CRC低 => #CRC低,
                              CRC高 => #CRC高);
                    #发送报文[6] := #CRC低;
                    #发送报文[7] := #CRC高;
                    #发送长度 := 8;
                END_IF;
            END_IF;

        ELSE
            (* 不在支持清单里的功能码 -> 异常 01。
               注意这里用 ELSE 而不是逐个列举，凡是 0x80 以下没实现的都走这里。 *)
            #异常码 := 16#01;
        END_IF;

        (* ---------- 5.6 收尾：组异常帧 / 记统计 / 提交发送 ---------- *)
        IF #异常码 <> 16#00 THEN
            #最后异常码 := #异常码;
            #读写错误 := TRUE;
            IF #异常码 = 16#01 THEN
                #校验信息 := 16#6110;               (* 功能码不支持 *)
            ELSIF #异常码 = 16#02 THEN
                #校验信息 := 16#6111;               (* 地址 / 数量越界 *)
            ELSE
                #校验信息 := 16#6112;               (* 数据值或帧结构非法 *)
            END_IF;

            IF #有响应 THEN
(* 异常响应帧：站号 + (功能码|0x80) + 异常码 + CRC = 5 字节     *)
                #发送报文[0] := UINT_TO_BYTE(#从站号);
                #发送报文[1] := #功能码 OR 16#80;
                #发送报文[2] := #异常码;
                "CRC16校验"(校验字节数 := 3,
                          校验数据 := #发送报文,
                          CRC低 => #CRC低,
                          CRC高 => #CRC高);
                #发送报文[3] := #CRC低;
                #发送报文[4] := #CRC高;
                #发送长度 := 5;
                #发送待发 := TRUE;
                #异常计数 := #异常计数 + 1;
            ELSE
                #发送待发 := FALSE;                  (* 广播出错也不回帧 *)
            END_IF;
        ELSE
            (* 执行成功。校验信息清 0 —— 让它反映"现在健不健康"；
               历史上出没出过错看 坏帧计数 / 异常计数 两个单调累加量。 *)
            #最后异常码 := 0;
            #校验信息 := 16#0000;
            #读写完成 := TRUE;
            #发送待发 := #有响应;                    (* 广播：执行了但不回帧 *)
        END_IF;
    END_IF;
END_IF;

(* ============================================================ *)
(* 6. 调用发送指令                                              *)
(* ------------------------------------------------------------ *)
(* 每扫描周期无条件调用：REQ 为 FALSE 时不动作，但 DONE/ERROR   *)
(* 与 STATUS 会持续刷新，这正是第 2 段能取到上升沿的前提。      *)
(* REQ 只在一个扫描周期为 TRUE（第 3 段产生的 #发送启动）。     *)
(* ============================================================ *)
#Send_P2P_Instance(REQ := #发送启动,
                   "PORT" := #硬件标识符,
                   BUFFER := #发送报文,
                   LENGTH := #发送长度,
                   DONE  => #发送完成,
                   ERROR => #发送错误,
                   STATUS => #发送信息);

END_FUNCTION_BLOCK

(* ============================================================ *)
(* 调用示例（OB1 或循环中断 OB，每扫描周期执行）                *)
(* ------------------------------------------------------------ *)
(* // 五块数据区必须放在全局 DB 里（VAR_IN_OUT 要从外面传进来） *)
(* // 一个从站实例配一套，两个实例不要共用，否则互相覆盖        *)
(*                                                              *)
(* "modbus_自由口从站_DB"("硬件标识符" := 269,  // 组态里查     *)
(* "从站号"     := 1,                                           *)
(* "起始地址"   := 0,                                           *)
(* "超时时间"   := T#2s,                                        *)
(* "使能"       := TRUE,                                        *)
(* "读数据"     := "Modbus数据".读数据,                         *)
(* "写数据"     := "Modbus数据".写数据,                         *)
(* "输入寄存器" := "Modbus数据".输入寄存器,                     *)
(* "线圈"       := "Modbus数据".线圈,                           *)
(* "离散输入"   := "Modbus数据".离散输入);                      *)
(*                                                              *)
(* // 全局 DB "Modbus数据" 里的声明（大小必须一模一样）：       *)
(* //   读数据     : Array[0..1999] of Word;                    *)
(* //   写数据     : Array[0..1999] of Word;                    *)
(* //   输入寄存器 : Array[0..1999] of Word;                    *)
(* //   线圈       : Array[0..1999] of Bool;                    *)
(* //   离散输入   : Array[0..1999] of Bool;                    *)
(*                                                              *)
(* ------------------------------------------------------------ *)
(* CM1241 组态要点（少一样都通不了）：                          *)
(* 1. 设备组态里把端口改成"自由口"，波特率按主站设，            *)
(* 数据位 8 / 偶校验 / 停止位 1（Modbus RTU 标准）。            *)
(* 2. "接收结束"选"字符间延时"，值按 ≈3.5 个字符时间折算：      *)
(* 9600 bps -> 4 ms；19200 -> 2 ms；115200 -> 1 ms（最小值）。  *)
(* 选"收到 N 个字符"或"收到指定字符"都会把 Modbus 帧切碎。      *)
(* 3. 硬件标识符在"设备组态->属性->系统常数"里查，是十进制数。  *)
(* 4. RS485 两线制时 A/B 别接反：接反的表现正是本块诊断码 6105  *)
(* （收到 0x80 以上的应答帧 = 自环回）。                        *)
(* ------------------------------------------------------------ *)
(* 常见问题对照（历史看两个计数）：                             *)
(* 校验信息 = 6100：CRC 错 -> 查波特率/校验位/停止位            *)
(* 校验信息 = 6101：发送超时 -> 看 发送信息（Send_P2P）         *)
(* 校验信息 = 6102：站号不匹配 -> 从站号填错，或主站地址填错    *)
(* 校验信息 = 6103：帧长不对 -> 接收结束判据要设成"字符延时"    *)
(* 校验信息 = 6105：收到应答帧 -> 485 的 A/B 接反（自环回）     *)
(* 校验信息 = 6106：从站号非法 -> 从站号必须填 1..247           *)
(* 校验信息 = 6110：功能码不支持 -> 主站用了 07/08/0B 等诊断码  *)
(* 校验信息 = 6111：地址越界 -> 主站地址超 0..1999，或偏移设错  *)
(* 校验信息 = 6112：数据值非法 -> 如 FC05 的值只认 0000/FF00    *)
(* 校验信息 = 6120：发送失败 -> 看 发送信息（Send_P2P）         *)
(* 校验信息 = 6121：接收错误 -> 看 接收信息（Receive_P2P）      *)
(* 好帧/坏帧计数都不涨 -> 根本没收到帧，查线/波特率/从站号      *)
(* 好帧计数涨但主站超时 -> 应答发出去了但主站不认，多半是       *)
(* 地址基准（40001 那套）没设对。                               *)
(* ------------------------------------------------------------ *)
(* 另：应答正在发送的这几个扫描周期里不接收新请求，这期间主站发 *)
(* 来的帧会被丢弃。半双工 485 不会发生（收发互斥）；四线制全双  *)
(* 工时主站必须等本帧应答收完再发下一帧。                       *)
(* ------------------------------------------------------------ *)
(* S7-200 SMART 用户：Micro/WIN SMART 不支持自定义 Modbus RTU   *)
(* 从站。用自由口指令（XMT/RCV）实现时，逻辑照搬本块第 5 段的   *)
(* 分支即可，但要注意 SMART 的 RCV 是字节中断、没有 256 字节    *)
(* 硬件组帧，必须自己在中断里做 3.5 字符间隔计时来判断帧结束。  *)
(* ============================================================ *)
