# Modbus 写缓存队列 —— 代码评审与改进说明

> 目标块：`FC_ModbusWrite_PutTask` / `FC_ModbusWrite_CacheRun`（原稿 v0.1，桌面上的两个 `.scl`）
> 改进版：v2.1（入队触发语义重做），已落地在 `D:\workbuddyspace\02_PLC程序\`
> 评审日期：2026-09-29　｜　v2.1 修订：2026-10-23

---

## 0. 一句话结论

原稿的**队列设计是对的**（环形队列 + 状态机 + 重试，思路没问题），
但**通信层的健壮性缺得比较多**：少一个上升沿、少一个超时、重试策略一刀切、出错之后什么都查不到。
这三条里前两条会直接造成现场"偶发、说不清"的故障，所以建议换 v2.0。

一共 15 项，其中 **3 项 P0**（会真出事）、**6 项 P1**、**6 项 P2**。

---

## 1. 问题清单

### P0-1 入队没有上升沿检测（注释和代码不一致）

原稿 `FC_ModbusWrite_PutTask`：

```scl
// 上升沿添加任务                       <-- 注释这么写
IF #AddTrigger AND NOT "DB_ModbusWriteCache".QueueFull THEN    <-- 代码是电平触发
```

`AddTrigger` 只要保持几个扫描周期，同一条数据就会被塞进队列好几次、被写好几遍。

| | 触发信号保持 20 个扫描周期 |
|---|---|
| 原稿 | 同样的写**发了 20 帧** |
| v2.0 | 只发 1 帧 |

（仿真实测，见 `test_ModbusWriteCache_simulation.py` 场景 [4]）

现场表现：HMI 上手点一下、或者某个标志位忘了复位，从站就被连写好几次。
如果写的是"启动/复位/清零"这类命令寄存器，就是反复动作。

**v2.0**：块内做上升沿检测（默认 `TRIG_MODE=0`）。确实需要一处提交一条的，见 P1-9 的说明。

**v2.1**：块内不再取沿，`AddTrigger` 必须是调用方给的单周期脉冲（见 P1-9 与 §9 修订记录）。

---

### P0-2 没有兜底超时 —— 卡住之后永久不动，而且一声不响

原稿状态机只有"置 REQ → 等 DONE/ERROR"两步，**没有任何超时**。
只要 MB_CLIENT 一直不返回 DONE/ERROR，就永远停在等结果那一步。

仿真里把"对端 TCP 半开（MB_CLIENT 受理了但永远不结账）"造出来：

| | 跑 5000 个扫描周期后 |
|---|---|
| 原稿 | 状态机**永久停在 2**，`Busy` 永远亮着，失败计数 **0**（一次都没记上） |
| | 此后新提交的 5 条写请求**一帧都发不出去** |
| v2.0 | 兜底超时触发 → 按失败处理 → 断线重连 → 丢弃并锁存 `16#FFFF` → 状态机回到 0 |

最要命的是"**一声不响**"：原稿没有任何失败计数，
HMI 上看起来一切正常，实际整个写通道已经死了。这类问题现场最难查。

**v2.0**：等受理（`T_ACCEPT`）和等结果（`T_REQ`）各有一个超时，
且超时和"指令报错"走同一套失败统计。

---

### P0-3 "请求未被受理"也会永久卡死，而原稿那个补丁救不了它

这是 P0-2 的一个具体且**很容易碰到**的分支：
同一端口上还有别的 Modbus 请求在跑时，MB_CLIENT 返回 `16#8200`（端口正在处理另一个请求），
**既不拉 BUSY 也不报错**，这次 REQ 就白置了。

原稿为了防这个，写了一段：

```scl
//===程序卡住重新下发读命令一次===
IF "MB_CLIENT_DB".STATUS = 16#7004 AND ... MB_State = 2 AND NOT Execute THEN
    Execute := true;
END_IF;
```

**这段的判据选错了。** Siemens S7-1200 系统手册里 `16#7004` 的定义是：

> 7004 Connection established and monitored. **No job processing active.**
> （连接已建立且处于受监视状态，未激活任何作业处理）

也就是说 `16#7004` 是**正常空闲态**，恰恰只在"没有作业在跑"的时候出现，不是"卡住"。
真卡死时的 STATUS 是 `16#7005`（正在发送）或 `16#8200`，它一个都不匹配。

仿真把这一点量化了：

| 场景 | 原稿那个重发分支实际触发次数 |
|---|---|
| 对端 TCP 半开 | **0 次** |
| 请求未被受理（8200） | **0 次** |

所以它救不了任何真实的卡死，反而在读错的状态上补了一刀。

**v2.0** 的正确做法：
- 置 REQ 后**等 BUSY 到来才算受理**，`T_ACCEPT` 内等不到就判"未被受理"（记自定义码 `16#FFFE`）；
- REQ 一旦等到 BUSY 立刻落下 —— **REQ 保持高电平会让 MB_CLIENT 在本次作业完成后立刻再发一遍同样的写**，
  手册原文：*"当前请求一完成，若以 REQ=1 执行，就可以处理新请求"*。

顺带说明：新版对"REQ 电平会不会自动重发"这个手册措辞的两种解释都做了验证，
两种解释下都**恰好只发一次**（仿真场景 [D]）。

---

### P1-4 UDT 和 DB 在两个源文件里各写了一份

原稿两个 `.scl` 里各自都有一份 `TYPE "UDT_ModbusWriteTask"` 和
`DATA_BLOCK "DB_ModbusWriteCache"`（逐字相同）。

后果：**两个文件必须同时导入、且必须保持同步**。
改了一边的字段忘了改另一边，TIA 不会报错，只会用后导入的那份覆盖前一份 ——
表现是"字段莫名少了一个"或"组态值被重置"。这正是 `04_文档` 里记过的那类共享块导入冲突。

**v2.0**：拆成三个文件，每个块只在一处声明：

| 文件 | 内容 |
|---|---|
| `ModbusWriteCache_Types.scl` | `UDT_ModbusWriteTask` + `DB_ModbusWriteCache`（含全部组态/状态/统计） |
| `FC_ModbusWrite_PutTask.scl` | 只含入队 FC |
| `FC_ModbusWrite_CacheRun.scl` | 只含出队 FC |

**没有把 `MB_CLIENT_DB` / `MB_CLIENT_PARA` 打进包里** ——
你现场这两个 DB 里存着真实的 IP 和连接号，重导一次就会把它们冲掉，
所以 v2.0 只在代码里引用它们。从零建工程时再按 §4 的说明补。

---

### P1-5 重试策略一刀切

原稿不管什么错误都硬重试 3 次。但错误分两类，性质完全不同：

| 错误码 | 含义 | 重试有用吗 |
|---|---|---|
| `16#838x` | 从站回的 Modbus 异常码（地址越界、功能码不支持…） | **没用**，地址就是错的 |
| `16#818x` | 我方组态错误（指针指向优化 DB、MB_MODE 非法…） | **没用**，重试还是错 |
| `16#80Cx` | TCP 层错误 / 从站无响应 | 有用，且**必须先断开重连** |
| `16#FFFE/FFFF` | 我方兜底超时 | 有用，且要重连 |

原稿把第一类也重试 3 次，纯粹是白等 2 个来回：

| 场景 | 原稿 | v2.0 |
|---|---|---|
| 从站回 `16#8383` | 发 3 帧 | **发 1 帧**，直接丢弃 + 报警 |
| 指针指向优化 DB（`16#818C`） | 发 3 帧 | **发 1 帧**，直接丢弃 + 报警 |

**v2.0**：按上表分类。想给"网关偶发帧错"的现场留口子，
把 `DB_ModbusWriteCache.RETRY_ON_PROTO_ERR` 置 TRUE 即可。

> 另外 `RETRY_MAX` 沿用原稿语义：它是**总尝试次数（含第一次）**。
> 填 3 = 首发 1 次 + 重试 2 次。DB 注释里已经写清楚了，别按"重试 3 次"理解。

---

### P1-6 出错之后现场什么也查不到

MB_CLIENT 的 `DONE` / `ERROR` / `STATUS` **只存在一个扫描周期**
（系统手册原文，Siemens 中国站的 Modbus TCP 例程也专门讲过）。
原稿没有做任何锁存，所以：

- 你去监视表看的时候，STATUS 早回到 `16#7000`/`16#7004` 了；
- 失败了 N 次，不知道失败的是哪个从站、哪个地址、想写什么值；
- 连"失败过几次"都没有计数。

表现就是那句老话：**"明明报过错，但没人知道错在哪"**。

**v2.0** 补上（都在 `DB_ModbusWriteCache` 里，HMI 直接读）：

| 量 | 说明 |
|---|---|
| `MB_StatusLatched` | 锁存的错误码，成功时清零 → 反映"当前是否健康" |
| `MB_StatusLive` | MB_CLIENT 原始实时 STATUS，调试用 |
| `LastErrStatus` | 最近一次失败的状态码 |
| `LastErrTask` | 最近一次失败的**任务快照**：从站号 / 地址 / 值 / 剩余重试 |
| `OkCount` / `RetryCount` / `ErrCount` | 成功 / 重试 / 最终失败 计数 |
| `DropCount` / `RejectCount` / `TimeoutCount` / `AddrWarnCount` | 队列满 / 参数非法 / 超时 / 地址可疑 计数 |
| `SeqNo`（在每条任务里） | 入队序号单调递增 —— 看它动不动就知道队列还活着 |
| `VER` | 版本指纹，在线看到 `16#21` 说明下进去的是 v2.0.1 |

---

### P1-7 队列判满用的是"上一拍算出来的标记"

原稿的 `QueueFull` 在 `CacheRun` 开头算，`PutTask` 读它。
于是在**"这一拍刚好有一条出队、格子空出来"**的那一刻，`PutTask` 看到的还是上一拍的 `TRUE`，
**这一条写请求就被静默丢掉了，而且没有任何计数**。

用确定性探针测（灌满队列 → 跑到第一次出队那一拍 → 同拍提交一条）：

| | 这一条写进去了吗 |
|---|---|
| 原稿 | **没有**（被静默丢掉） |
| v2.0 | 进去了 |

现场的观感是"**偶尔有一条写没生效**"，这种偶发问题基本没法查。

**v2.0**：`PutTask` 判满直接看当前条目数（`QueueCount`），
`CacheRun` 每周期从两个指针重算水位，**两个块的执行先后顺序不再影响结果**。

---

### P1-8 `MB_DATA_ADDR` 的地址区号是个大坑（原稿没有任何提示）

MB_CLIENT 是**按地址区号自动选功能码**的，你不选 FC：

| `MB_DATA_ADDR` 落在 | 被当成 | 发出的功能码 |
|---|---|---|
| 00001–09999 | 线圈 | FC05 / FC15（**写位**） |
| 10001–19999 | 输入位 | 只读 |
| 30001–39999 | 输入寄存器 | 只读 |
| **40001–49999** | **保持寄存器** | FC06 / FC16（**写字**） |

原稿的 `RegAddr` 是直接透传给 `MB_DATA_ADDR` 的，注释只写"Modbus寄存器起始地址"。
如果谁填了个 `100`（想着"第 100 个寄存器"），MB_CLIENT 会**当成线圈发 FC05**，
写的是完全不同的对象，而且**不报任何错**。

**v2.0** 做了两件事：
1. 填了 `RegAddr < 40001` 时，`AddrWarn` 输出置起并计数 —— **只警示不拦截**。
   不拦是因为你的旧工程可能就是这么用的，硬拦等于直接改现场行为。
2. 提供 `ADDR_OFFSET`：习惯用 0 基寄存器号的把 DB 里这个值填 `40001`，
   填多少就统一加多少，普通写法规矩不用改。

> 建议：如果你现在传的本来就是 `40001` 起的形式，什么都不用动；
> 如果传的是 0/1/100 这种小数，**赶紧去核对一下是不是一直在写线圈**。

---

### P1-9 多处提交任务时会静默丢任务

`PutTask` 的上升沿检测用一份"共享的上一拍值"，所以：

- **`TRIG_MODE=0`（默认）**：每周期只能调一次。调两次时，第二次形不成边沿 → 任务丢。
  v2.0 加了 `MultiCall` 输出专门抓这个（>1 就亮）。
- **`TRIG_MODE=1`**：块内不做边沿检测，**每个调用点各配一个 `R_TRIG`**，
  把各自的单周期脉冲接到 `AddTrigger`，这样同一周期里多少处提交都行。

原稿两种都做不到（本来就没有边沿检测）。

**v2.1 推翻了这个处理方式 —— 因为那两条根本不是"双模式"，是把缺陷包装成了使用限制。**

现场最需要的就是"一键下发一组参数"（同一周期调 3~5 次），恰好被 `TRIG_MODE=0` 那条限制
直接挡住；而 `TRIG_MODE=1` 只是把责任推给调用方，块本身一点没修好。

根因是 **FC 没有静态区**：`LastTrigger` 只能存在 DB 里，全工程就一份，
N 个调用点抢同一份历史，必然互相顶掉。在这个前提下"块内取沿"这条路**根本不可实现**，
v2.0 写的"每周期只调一次"其实是在承认这一点，只是没承认得那么直白。

v2.1 的做法：**块内彻底不做边沿检测**，`AddTrigger` 语义明确为"单周期脉冲"——
来一拍受理一条，同一周期调几次都各自独立。`TRIG_MODE` 字段作废（保留仅为不动 DB 结构）。
配套加 `TrigHoldWarn`：触发位连续两个扫描周期为 TRUE 就报一次，提示回去查脉冲源。

---

### P2 其它（不致命，但顺手都改了）

| # | 问题 | v2.0 |
|---|---|---|
| P2-1 | 出队代码抄了三遍（正常出队 / 无效槽位跳过 / 重试耗尽） | 收敛成一处收尾，改一处不会漏另两处 |
| P2-2 | `100` / `99` / `3` 硬编码，改数组长度要动 4 处 | 统一用 `QUEUE_MAX_LEN` / `QUEUE_CAPACITY` / `RETRY_MAX` |
| P2-3 | 环形队列指针无越界保护 —— 一旦跑飞就是**数组越界 = PLC 停机** | 每周期钳位自愈 |
| P2-4 | 同一寄存器在同一周期被写多次 → 排队好几个来回 | **同址去重**：只更新值，不新增条目 |
| P2-5 | 连接参数每周期无条件写入 `MB_CLIENT_PARA` | 只在**没有作业在跑**时写（手册要求作业期间输入不得变更） |
| P2-6 | 状态机多两个"只做收尾"的状态 | 合并掉，**每任务 4 拍**，与原稿吞吐持平 |

---

## 2. v2.0 状态机

```
        队列非空且总闸开
   0 空闲 ──────────────► 10 等受理 ──BUSY──► 20 等结果 ──DONE──► 出队(成功) ──┐
       ▲                    │                  │                              │
       │                    │T_ACCEPT 超时     │T_REQ 超时 / ERROR            │
       │                    ▼                  ▼                              │
       │                  ┌──────────────────────┐                            │
       │                  │ 30 判定               │                           │
       │                  │  · 818x/838x → 丢弃   │                           │
       │                  │  · 80Cx/超时 → 60 断线│                           │
       │                  │  · 其余 → 原地重试    │                           │
       │                  └────────┬─────────────┘                            │
       │                           ▼                                          │
       └───────────────────── 60 断开 ──► 65 冷却 ──► 回到 10（重试）          │
       └──────────────────────────── 丢弃(失败) ◄─────────────────────────────┘
```

| 状态 | 含义 | 兜底 |
|---|---|---|
| 0 | 空闲，队列空或总闸关 | — |
| 10 | 已置 REQ，等 MB_CLIENT 受理 | `T_ACCEPT` 超时 → `16#FFFE` → 30 |
| 20 | 已受理，REQ 落低，等 DONE/ERROR | `T_REQ` 超时 → `16#FFFF` → 30 |
| 30 | 判定：丢弃 / 原地重试 / 先重连 | 保证 REQ 至少一拍低电平 |
| 60 / 65 | 断开保持 `T_DISC` → 释放 → 冷却 `T_RECONN` | 之后回 10 重试 |

**成功和丢弃都不单占状态**，就地出队。这样每个任务固定 4 拍，和原稿一样快。

---

## 3. 参数速查

全部在 `DB_ModbusWriteCache` 里，HMI 或监视表直接改（DB 是非保持的，掉电回默认值）。

| 参数 | 默认 | 什么时候要改 |
|---|---|---|
| `QUEUE_MAX_LEN` / `QUEUE_CAPACITY` | 100 / 99 | 要改队列深度时，同时改 `TaskQueue` 数组长度 |
| `RETRY_MAX` | 3 | **总尝试次数**（含第一次）。局域网 3 够，别贪大 |
| `ENABLE_WRITE` | TRUE | 临时停写（检修/联调）置 FALSE，任务仍会入队不丢 |
| `RETRY_ON_PROTO_ERR` | FALSE | 网关偶发帧错、想连异常码也重试时置 TRUE |
| `ADDR_OFFSET` | 0 | **地址习惯是 0 基寄存器号就填 40001**（见 P1-8） |
| `TRIG_MODE` | 0 | **v2.1 起已废弃，FC 不再读它**，填 0 填 1 都一样。保留字段只为不动 DB 结构 |
| `T_ACCEPT` | T#1s | 跨网段 / 无线可放宽到 T#2s |
| `T_REQ` | T#6s | **必须比 MB_CLIENT 背景 DB 里的响应超时长**（那里默认 5s），否则本块先报超时，拿不到真正的错误码 |
| `T_DISC` / `T_RECONN` | 300ms / 500ms | 一般不用动 |

---

## 4. 导入步骤

> ⚠️ **导入 `ModbusWriteCache_Types.scl` 会覆盖现有的 `DB_ModbusWriteCache`**：
> 队列清空、统计清零、组态值回到默认。队列本来就是非保持的（见下文），
> 但**请在停机试车的窗口里做**，别在线路上正在跑东西的时候导。

| 步 | 做什么 |
|---|---|
| 0 | 先把现在工程里的两个 FC 和 `DB_ModbusWriteCache` 备份（或用 TIA 的版本对比） |
| 1 | 导 `ModbusWriteCache_Types_GB2312.scl`（想看中文注释稳一点）或 `..._EN.scl`（纯 ASCII，最稳） → 生成 UDT + DB |
| 2 | 导 `FC_ModbusWrite_PutTask_*.scl` |
| 3 | 导 `FC_ModbusWrite_CacheRun_*.scl` |
| 4 | **不要重导 `MB_CLIENT_DB` / `MB_CLIENT_PARA`**（本包不含，避免冲掉现场 IP 与连接号） |
| 5 | 检查 OB1：`FC_ModbusWrite_CacheRun` 每周期调一次、**全工程只调一次**；`FC_ModbusWrite_PutTask` 每周期可调多次，但每处的 `AddTrigger` 都必须是**单周期脉冲**（同一个脉冲可以喂给多个调用点） |
| 6 | 在线看 `DB_ModbusWriteCache.VER`，是 `16#21` 就说明新代码生效了 |

从零建工程时，另外需要：
`MB_CLIENT_DB`（在 OB 里拖一次 MB_CLIENT 指令让 TIA 自动生成）+
`MB_CLIENT_PARA`（一个 `T_CON_IP_v4` 结构，
`InterfaceId=64` / `ID=21` / `ActiveEstablished=1` / `RemotePort=502` / `LocalPort=0`）。

**两个 FC 的输入接口完全没变**，原有的调用语句不用改；
新增的都是输出引脚，可以全部不接（状态在 DB 里都有）。

---

## 5. 仿真验证

脚本：`05_工具脚本\test_ModbusWriteCache_simulation.py`

```bash
cd "D:/workbuddyspace/05_工具脚本"
"C:/Users/HawkZhang/.workbuddy/binaries/python/versions/3.13.12/python.exe" test_ModbusWriteCache_simulation.py
```

用 Python 1:1 复刻了两套状态机的离散递推，并对 MB_CLIENT 建模了**四种**搭档行为
（正常 / 报错 / 受理后永不返回 / 根本不受理）—— 只跑成功路径是测不出问题的。

**结果：57 项断言全部通过**，其中包含 6 组"新版 vs 原稿"对照实验。

关键对照数据：

| 场景 | 原稿 | v2.0 | v2.1 |
|---|---|---|---|
| 同周期调 5 次（同一个单周期脉冲） | 5 帧 | **只进 1 条**，另 4 条静默丢 | **5 帧全进** |
| 触发保持 20 拍（没取沿，属于接错） | 发 20 帧同样的写 | 发 1 帧（块内偷偷补了取沿） | 发 4 帧 + `TrigHoldWarn` 报警 19 拍 |
| 对端半开 5000 拍后 | 状态机永久停在 2，失败计数 0，后续 5 条全滞留 | 超时 → 重连 → 丢弃并锁存 `16#FFFF`，状态机回 0 | 同 v2.0 |
| 请求未被受理 | 永久停在 2，`7004` 重发分支触发 0 次 | 报 `16#FFFE`，回 0 | 同 v2.0 |
| 从站回 `16#8383` | 发 3 帧白试 | 发 1 帧 | 同 v2.0 |
| 满队列里"刚好空出一格"那一拍提交 | 静默丢弃 | 正常入队 | 同 v2.0 |
| 同一寄存器同周期写 3 次 | 3 帧 | **1 帧**（值 = 最后一次） | 同 v2.0 |

> v2.1 只动了"入队触发怎么取沿"这一件事，通信层、重试、断线、去重全部沿用 v2.0，未改动。

> **诚实声明**：以上是**仿真 + 手册核对 + 实机验证**的结果。
> 已在 **S7-1510SP-1 PN** 上导入 TIA 工程实跑，验证了「正常写入通路」与
> 「同一扫描周期多次提交」两项（v2.1 的核心改动）。其余边界场景（超时兜底、
> 断线重连、失败丢弃）仍是仿真结论，未在真机上逐条复现。
> 换到自己的工程时，仍建议在停机窗口做，先置 `ENABLE_WRITE=FALSE` 观察状态机跑通，再开闸。

---

## 6. 常见问题

| 现象 | 原因 | 怎么办 |
|---|---|---|
| 一条都写不出去，`MB_StatusLatched` = `16#818C` | `MB_DATA_PTR` 指向了**优化访问**的 DB | 把 `DB_ModbusWriteCache` 的"优化的块访问"关掉。**别把它改成优化** |
| 写出去的是线圈不是寄存器 | 地址没带区号（填了 100 而不是 40100） | 见 P1-8，填 40001 起的写法，或设 `ADDR_OFFSET=40001` |
| 队列只入不发，`WriteLocked`=TRUE | `ENABLE_WRITE` 是 FALSE | 打开写总闸 |
| `PutTask` 的 `MultiCall` 亮了 | 同一周期不是第一次调用。**v2.1 起这只是诊断位，不是故障** | 不用管；v2.0 时期接了报警的，这次要拆掉 |
| `PutTask` 的 `TrigHoldWarn` 亮了 | 触发位连续两个扫描周期为 TRUE，多半是把按钮电平直接接进来了 | 用 `R_TRIG` 把电平取成单周期脉冲再喂给每个调用点 |
| `16#8200` 端口正在处理另一个请求 | 同一连接号被两处用了 / 上一作业没收尾 | 检查全工程是不是有第二个 MB_CLIENT 用了同一个 `ID` |
| `16#8387` | 运行中改了 `#ID` | 一个 MB_CLIENT 背景 DB 只能配一个连接号；改完要复位重连 |
| `16#80C8` 一直是它 | 从站不通 | 先 ping 从站、查 502 端口/防火墙/网段 |
| `StatusLive` 一直 `16#7004` | **这是正常的**（连接已建立、无作业在跑） | 不是故障，别当错误码查 |
| `StatusLive` 停在 `16#7005` 不动 | 作业发出去了，从站不回 | 等 `T_REQ` 超时；同时 ping 从站 |
| 编译报 **"操作数 `DB_ModbusWriteCache.MB_CLIENT_W_DATA` 未定义"** | v2.0 的 Types 文件漏声明了这个 DB 字段（FC 里两处引用还在）。见 §9 修订记录 | 用 **v2.0.1** 的 `ModbusWriteCache_Types.scl` 重导一次即可。**两个 FC 不用重导** |
| 时间戳/计数掉电就归零 | DB 是 `NON_RETAIN` | **有意的**：写任务绝不能在重启后被重放 |

---

## 7. 有意做的取舍（不是漏，是选的）

1. **`ENABLE_WRITE` 默认 TRUE，不是"默认关"。**
   按通用规范新导入的块不该自己往从站写东西，但这个模块的职责就是写，
   你的系统本来就靠它工作。默认 FALSE 会让"下载一次之后现场莫名不写了"，
   而且这个 DB 非保持、每次掉电都会回到默认值 —— 那才是隐患。
   要联调锁就用监视表临时置 FALSE。

2. **地址 `< 40001` 只警示不拦截。**
   硬拦等于直接改现有系统行为，风险大于收益。`AddrWarn` 亮起来就该去核对。

3. **没有做"多寄存器合并写"。**
   理论上同一周期里地址相邻的几条可以合成一帧 FC16，省来回。
   但**随机写**本来就不相邻，合并收益很小；而且合并会改变从站侧看到的时间语义，
   有真实设备会因此出问题。收益低、风险高，不做。

4. **没有做"写后回读校验"。**
   从站接受了写却不真正生效的情况确实有。但回读会让每个任务的时间开销翻倍，
   而且回读本身也可能失败。真需要就单独做一轮读校验，别塞进写通道。

5. **没有做优先级插队。**
   报警复位这类写要插到队首，确实是个合理需求。等真有这个需求再加一个
   "插队提交"入口，不影响现有接口。

6. **边沿检测推给调用方，块内不做（v2.1 的决定）。**
   FC 没有静态区，块内做就只能共用一份历史，多调用点必丢 —— 这不是"实现得不够好"，
   是这条路本身走不通。要么改成 FB（每个调用点一个实例），要么把取沿责任交给调用方。
   选了后者：不动现有调用方式、不动导入对象、现场只需在每个调用点前面加一个 `R_TRIG`。
   代价是**漏配就退化成"电平入队、反复写同一个值"**，`TrigHoldWarn` 专门盯这个。

---

## 8. 文件清单

| 路径 | 说明 |
|---|---|
| `02_PLC程序\ModbusWriteCache_Types.scl` | 主版（UTF-8，中文完整说明，**只阅读留档**） |
| `02_PLC程序\ModbusWriteCache_Types_EN.scl` | 纯 ASCII，**导入首选** |
| `02_PLC程序\ModbusWriteCache_Types_GB2312.scl` | GBK 无 BOM，中文导入用（**派生文件，别手工改**） |
| `02_PLC程序\FC_ModbusWrite_PutTask*.scl` | 同上三版 |
| `02_PLC程序\FC_ModbusWrite_CacheRun*.scl` | 同上三版 |
| `05_工具脚本\test_ModbusWriteCache_simulation.py` | 仿真验证（含新旧对照），可复跑 |
| `05_工具脚本\check_db_fields.py` | **跨文件符号自检**：Types 里声明了什么、各块引用了什么，两头对一遍 |
| 本文件 | 评审 + 导入说明 |

`check_db_fields.py` 的用法（改完 Types 或 FC 后跑一次，比等 TIA 报错快）：

```bash
cd "D:/workbuddyspace"
"C:/Users/HawkZhang/.workbuddy/binaries/python/versions/3.13.12/python.exe" \
  "05_工具脚本/check_db_fields.py" \
  "02_PLC程序/ModbusWriteCache_Types.scl" \
  "02_PLC程序/FC_ModbusWrite_PutTask.scl" \
  "02_PLC程序/FC_ModbusWrite_CacheRun.scl"
```

---

## 9. 修订记录

### v2.1 —— 2026-10-23（入队触发语义重做，只动了 `FC_ModbusWrite_PutTask`）

**现象**（用户实机使用中提出的）：

> "同一周期内我可能要多次调用这个 FC，你把 `AddTrigger` 做了上升沿检测，这样行不通吧？"

**确认：行不通，是真缺陷。** 问题代码就两行：

```scl
#bEdge := #AddTrigger AND NOT "DB_ModbusWriteCache".LastTrigger;
"DB_ModbusWriteCache".LastTrigger := #AddTrigger;   // 每次调用都覆盖同一个 DB 位
```

同一周期调 3 次、三处喂同一个脉冲时：

| 调用 | AddTrigger | 比较用的 LastTrigger | bEdge | 结果 |
|---|---|---|---|---|
| 第 1 次 | TRUE | FALSE（上周期留下） | TRUE | 入队 |
| 第 2 次 | TRUE | **TRUE**（被第 1 次写回） | FALSE | **静默丢弃** |
| 第 3 次 | TRUE | TRUE | FALSE | **静默丢弃** |

3 条写请求只进去 1 条，另外 2 条按"没有上升沿"扔掉，**不报任何错**。
反向更阴：某个调用点的触发位一直保持 TRUE，会把后面调用点真正的上升沿一起吃掉。

**根因**：FC 没有静态区。`LastTrigger` 只能存 DB，全工程一份，N 个调用点抢同一份历史。
v2.0 写的"每周期只能调一次 + MultiCall 报警"是把缺陷包装成了使用限制，
而**"一键下发一组参数"恰好就是同一周期调好几次的场景**，直接被这条限制挡住。

**修法**：

1. 块内彻底删掉边沿检测，`#bEdge := #AddTrigger;` —— 来一拍受理一条。
2. `AddTrigger` 语义明确为**单周期脉冲**，由调用方用 `R_TRIG` 取沿后喂给每个调用点。
3. 新增输出 `TrigHoldWarn`：只在每周期第一次调用时跟"上周期最后一次调用的值"比一次，
   连续两拍 TRUE 就报一拍。**只提示、不拦截**（各调用点若改用不同触发位，这一位可能误报）。
4. `MultiCall` 降级为纯诊断（本周期第 2 次调用起亮），不再是"误用报警"。
   **v2.0 时期把它接过报警的，这次要拆掉。**
5. `TRIG_MODE` 字段作废但保留 —— 删字段就得重导 DB，而重导会把在线整定过的
   `T_ACCEPT` / `T_REQ` / `RETRY_MAX` 一起重置。

**本次导入只需 `FC_ModbusWrite_PutTask` 一个文件**（三版任选，EN 版最稳）。
`ModbusWriteCache_Types` 只改了注释，结构一个字节没动，**不用重导**。

**回归**：仿真脚本同步改了三处（去掉 `TRIG_MODE` 分支、加 `TrigHoldWarn`、
用例 4 从"电平只写一次"改成"电平会重复写 + 报警"），新增
"同周期调 5 次全部入队"与"取沿后按住只写一次"两组断言，57 项全过。

---

### v2.0.1 —— 2026-09-29（编译修订，只动了 Types 文件）

**现象**（现场编译报的）：

```
错误 87  FC_ModbusWrite_CacheRun  操作数"DB_ModbusWriteCache".MB_CLIENT_W_DATA 未定义
错误 251 同一条
编译完成（错误：2；警告：20）
```

**根因**：v2.0 把两个源文件里重复声明的 UDT + DB 合并到 `ModbusWriteCache_Types.scl` 时，
**漏掉了原稿 DB 里的 `MB_CLIENT_W_DATA : Word;`**（写数据缓冲区，MB_CLIENT 的
`MB_DATA_PTR` 指着它）；而 `FC_ModbusWrite_CacheRun` 里那两处引用还在（赋值 + 传参），
于是两边对不上。是我合并时的疏漏，不是调用方式的问题。

**为什么 TIA 的行号看着对不上文件**：TIA 编译信息的行号是**相对 `BEGIN` 的正文行号**，
不含文件头部注释和接口（VAR_INPUT/OUTPUT/TEMP）那一段。这个文件的正文从第 102 行开始，
所以 TIA 的 `87` / `251` 对应源文件第 `188` / `352` 行（差 101）。
知道这个偏移，看编译报错就能直接跳到源文件对应位置（`源文件行 = TIA 行 + 偏移`）。

**修法**：在 DB 的 `状态机区` 与 `统计与诊断区` 之间补一个"通信缓冲区"小节，
放回 `MB_CLIENT_W_DATA : Word;`（**名字沿用 v0.1，不改名** —— 引用它的块太多），
并给了一个初值。顺带：

- DB 的 `VERSION` 从 `0.2` → `0.3`（接口变了）；
- 版本指纹 `VER` 从 `16#20` → **`16#21`**，这样在线监视一眼就能判断
  工程里下进去的到底是修好的这份还是漏字段的那份；
- **两个 FC 一个字没改**（文件字节未变），所以**只需要重导 Types 这一个文件**。

**后续防呆**：新增 `05_工具脚本\check_db_fields.py`。它把 Types 里的 STRUCT 成员
抽出来，再把各块里所有 `"DB_X".Field` / `#var.Field` 引用扫一遍，报出未声明的成员 ——
正好就是这次这类错。写完/改完 Types 或 FC 先跑它，不用等 TIA 编译。
本文档 §8 有调用示例。

> **一个提醒**：重导 `ModbusWriteCache_Types.scl` 会把工程里现有的 `DB_ModbusWriteCache`
> 覆盖回默认值（队列清空、统计清零）。这个 DB 本来就是 `NON_RETAIN` 的，
> 但**请在停机窗口做**。另外如果 HMI 上对 `DB_ModbusWriteCache` 的引用是
> **按绝对地址**配的，这次字段增删会让偏移全变 —— 请改成按符号名引用。


版本指纹 `VER = 16#21`（v2.0.1），在线监视一眼可确认。
后续改逻辑时记得同步 +1，并重跑全部仿真场景做回归。
