"配方点了下发,画面显示成功,设备跑出来的还是上一套参数"——这种事故在现场基本都能追到同一个根因:上位机只管"写出去",没人确认 PLC 真的"收到且认下了"。配方下发和采集监控是两回事:采集错一个点顶多画面闪一下,配方写错一组参数就是整批废品甚至设备事故。可靠的配方下发要凑齐四样东西:一张对得上的地址映射表、一笔原子的批量写、一组握手机制字、一套回读校验流程。这篇把这套机制完整画出来。
第一步:映射表是合同,双方各执一份
配方下发的第一原则:上位机软件里不出现任何一个魔法地址。所有"配方参数 ↔ PLC 寄存器"的对应关系全部落在一张映射表里,上位机照表写、PLC 工程师照表编程,调试时对着表逐行核对。我们用 Modbus 保持寄存器(4xxxxx)做配方区,地址连续排布,方便功能码 16 一笔写完。以一条热处理产线为例:
| 参数名 | 寄存器 | 地址 | 类型 | 量程/单位 | 说明 |
|---|---|---|---|---|---|
| ■ 握手区(40090 ~ 40094) | |||||
| 下发请求字 Req | 16位 | 40090 | UInt16 | 0/1 | HMI 写 1 = 请求下发;PLC 处理完自动清 0 |
| 校验回执字 Ack | 16位 | 40091 | UInt16 | 0~4 | 0 空闲 / 1 校验中 / 2 校验OK已生效 / 3 校验失败 / 4 设备忙拒绝 |
| 配方版本号 Ver | 16位 | 40092 | UInt16 | 0~65535 | 每次下发 +1,HMI 与 PLC 比对版本一致才算完成 |
| 校验和 ChkSum | 16位 | 40093 | UInt16 | — | 配方区所有寄存器的累加和(mod 65536),PLC 侧重算比对 |
| 故障码 ErrCode | 16位 | 40094 | UInt16 | 0~ | 校验失败时填第几个参数不合法,画面直接红字提示 |
| ■ 配方数据区(40100 起,地址连续) | |||||
| 一区设定温度 | 2寄存器 | 40100~40101 | Float32 | 0.0~400.0 ℃ | float 占两个寄存器,字序见下文坑点 |
| 二区设定温度 | 2寄存器 | 40102~40103 | Float32 | 0.0~400.0 ℃ | 同上 |
| 保温时间 | 1寄存器 | 40104 | UInt16 | 0~600 min | 整数,缩放系数 1 |
| 升温速率 | 1寄存器 | 40105 | UInt16 | 0.1~10.0 ℃/s | ×10 定点:写 35 表示 3.5,避免 float 对字序 |
| 目标真空度 | 2寄存器 | 40106~40107 | Float32 | 0.0~1.0 MPa | — |
| 冷却风扇延时 | 1寄存器 | 40108 | UInt16 | 0~600 s | — |
▲ 这张表就是合同:上位机按地址写,PLC 按地址读,任何一方改地址都要双方签字改表。数据区地址必须连续,中间不留洞,这是功能码 16 一笔原子写的前提。
第二步:停机互锁,设备跑着的时候禁止下发
下发前必须先问 PLC 一句"现在能不能收"。我们在映射表约定一个只读状态区(采集用),上位机下发前先读:运行中、有未复位报警、自动模式下,下发按钮直接置灰,连请求字都不让写。这个检查放在上位机侧是第一道门,PLC 程序里也要再写一遍(设备运行时即使收到请求字也回 Ack=4 拒绝)——上位机的互锁防误操作,PLC 的互锁防上位机异常,两道门一个都不能省。
第三步:握手状态机,"写出去"不等于"生效了"
核心机制就五个握手字。下发不是"写参数"一个动作,而是一个双方来回确认的状态机:
这里有两个顺序细节,都是现场用事故换来的:一是先写数据、最后置 Req——PLC 看到 Req=1 才开始校验,如果你先置请求再写数据,PLC 读到的是半新半旧的参数,校验和碰巧还能对上旧值;二是上位机必须等 Ack=2 并核对版本号,而不是写完就算成功。Req 是"我请你干活",Ack 才是"我干完了",少了这个回执,画面上的"下发成功"就是自欺欺人。
第四步:回读比对,让 PLC 自己念一遍
Ack=2 之后还有最后一道:上位机把数据区整段回读一次,和本地配方逐寄存器比对。这道关防的是"PLC 校验逻辑本身有 bug"或"写生效区时映射错了地址"——PLC 说它收下了,那让它把收到的念出来对一遍。回读一致,本次下发才算真正闭环。下面是完整的 C# 下发时序骨架:
public RecipeResult Download(Recipe r) { // ① 前置互锁:设备必须停机、无报警、Ack=0(空闲) var st = _plc.ReadHolding(StatusAddr, 4); // 读状态区 if (st.Running || st.Alarm || _plc.Read(40091) != 0) return RecipeResult.BusyOrRunning; // 按钮置灰的服务端兜底 ushort newVer = (ushort)(_plc.Read(40092) + 1); var regs = BuildRegisters(r, newVer); // 映射表:参数→寄存器数组,含缩放、字序 ushort chk = (ushort)(regs.Sum(x => x) % 65536); SetChecksum(regs, chk); // ② 数据先行:功能码16,整段40100~40108一笔写完(原子) _plc.WriteMultiple(40100, regs); _plc.Write(40093, chk); // 校验和 _plc.Write(40092, newVer); // 版本号 // ③ 最后置请求位 _plc.Write(40090, 1); // ④ 轮询回执:500ms一次,最长等10秒 for (int i = 0; i < 20; i++) { Thread.Sleep(500); ushort ack = _plc.Read(40091); if (ack == 2 && _plc.Read(40092) == newVer) goto Verify; if (ack == 3) return RecipeResult.CheckFail(_plc.Read(40094)); if (ack == 4) return RecipeResult.PlcBusy; } return RecipeResult.Timeout; // PLC没回应,记录日志并人工介入 Verify: // ⑤ 回读比对:让PLC把收到的念一遍 var echo = _plc.ReadHolding(40100, regs.Length); if (!echo.SequenceEqual(regs)) return RecipeResult.EchoMismatch; // 罕见但必须报:通信/PLC逻辑异常 _log.Info($"配方 {r.Code} v{newVer} 下发并校验生效"); return RecipeResult.Ok; }
收尾:三个容易被忘掉的细节
- 掉电保持:配方数据区和版本号寄存器在 PLC 侧必须设为掉电保持,设备重启后跑的还是上次确认过的配方,而不是清零后的默认值;
- 下发全程禁止切自动:从置 Req 到 Ack=2 这几秒,PLC 程序里锁定模式切换,防止参数校验半截设备启动;
- 每次下发写审计日志:谁发的、配方名、版本号、结果,全部落库——制药/食品行业这本来就是合规要求,其他行业留着也便于追溯废品批次。
配方下发这套东西,说白了就是把"我写你收"的口头约定,变成映射表对账 + 批量原子写 + 请求回执状态机 + 回读复核的书面流程。如果你现在的系统下发完只弹了个"成功"提示框,建议做个最小验证:下发一套参数后断电重启 PLC,起来看跑的是不是这套值——大概率能测出问题。
