07 · 驱动是怎么工作的

程序集:Hmi.Drivers + Drivers.Abstractions · 源码:src/Hmi.Drivers/、src/Drivers.Abstractions/

什么时候你会需要它

  • 你接不上 PLC,想搞明白采集是怎么跑的、失败会怎样
  • 你要调采集性能(周期、超时、重试、块大小)
  • 你要写自己的协议插件 —— 这一章是前置知识
  • 你想知道"为什么读 PLC 要合并成块读"

如果你只是想用现成的协议,看第 08 章就够了;这一章讲的是框架怎么运作。

一句话概括

一条通道 = 一个后台循环,不停地"读一批点 → 拆包 → 写实时库"。

        ┌──────────── 一个通道的循环(独立线程/任务) ────────────┐
        │                                                      │
   ┌────v─────┐   ┌──────────┐   ┌───────────┐   ┌──────────┐  │
   │ 取本轮的  │──>│ 规划成块  │──>│ 驱动读块   │──>│ 拆包写    │  │
   │ 点清单    │   │BlockPlanner│  │ReadBlockAsync│ │实时库     │  │
   └──────────┘   └──────────┘   └─────┬─────┘   └──────────┘  │
                                       │ 失败                  │
                                       v                       │
                                  ┌─────────┐                  │
                                  │ 退避重连 │                  │
                                  └─────────┘                  │
        └──────────────────── 等下一个扫描周期 ──────────────────┘

核心概念一:块读(为什么不能一个点读一次)

假设你要读 50 个点。最笨的做法是发 50 次请求:

读 40001 → 等网络往返 → 读 40002 → 等网络往返 → …

一次 Modbus TCP 请求的往返大约 1~5 毫秒,50 次就是 50~250 毫秒。而 200 毫秒的扫描周期根本不够用。

正确做法是合并成块:

读 40001~40050(一次请求,50 个寄存器)→ 拿到 100 字节 → 本地拆成 50 个值

一次往返搞定。 这就是 BlockPlanner 干的事。

块是怎么划出来的

BlockPlanner 按三个约束切块:

约束 含义
单次上限 协议规定的最大读写长度。Modbus 是 125 个寄存器 / 2000 个位
地址连续 只有地址连续的才能并进同一个块
间隙容忍 两个点之间最多隔几个寄存器还能算"同一块",默认 8

最后那条 GapTolerance=8 是个权衡:中间空几个寄存器,多读几个没用的是划算的(反正一次请求的钱已经花了), 但空太多就浪费带宽。8 是个经验值。

using Hmi.Drivers.Planning;

// 把一批点规划成若干块
BlockPlan plan = BlockPlanner.Plan(tags, limits);
// plan 里每块含:起始地址、长度、以及块内每个点落在哪个偏移

核心概念二:驱动的契约

所有协议驱动都实现同一个接口:

namespace Hmi.Drivers.Abstractions;

public interface IProtocolDriver : IDisposable
{
    string Name { get; }                 // 驱动名
    ProtocolLimits Limits { get; }       // 单次读写上限
    bool IsConnected { get; }            // 现在连着吗
    int MaxConcurrentRequests => 1;      // 支持几个并发请求(默认 1,即串行)

    Task ConnectAsync(DeviceConfig dev, CancellationToken ct);
    void Disconnect();

    Task<ReadResult> ReadBlockAsync(ReadBlock block, CancellationToken ct);
    Task WriteAsync(WriteRequest req, CancellationToken ct);
    Task WriteManyAsync(IReadOnlyList<WriteRequest> reqs, CancellationToken ct);
}

写驱动就是实现这 6 个方法。 框架负责调度、重连、拆包、写库 —— 你只管"怎么把字节读回来"。

配套的还有一个地址解析器:

public interface IAddressParser
{
    ParsedAddress Parse(Tag tag);   // 把 "40001" / "DB1.DBD0" 解析成结构化的地址
}

💡 为什么要单独一个地址解析器:地址字符串只在建连接和规划时用一次,解析成 ParsedAddress (含区号、偏移、位宽)之后,后续全用结构化数据,不再碰字符串。

核心概念三:一通道一循环,读写串行

每个通道跑在自己的任务里,通道之间互不阻塞。 一条通道挂了不影响另一条。

同一条链路上,读和写是串行的。 原因很简单:Modbus 这类协议是"一问一答"的, 你不能在等读响应的时候插一个写请求进去,响应会串台。

所以写值不是立刻发出去的,而是进写队列,等这一轮读完了再发:

采集循环:  [读块1] [读块2] [读块3] [处理写队列] → 等下一周期

⚠ 这解释了一个常见疑问:"我在界面上点了按钮,怎么设备过了一两百毫秒才动?" 因为它要排队等当前这轮采集做完。扫描周期设得越大,这个延迟越明显。

重连与退避

连不上怎么办?不能死循环猛试 —— 会把设备打爆,也会把日志刷爆。

BackoffPolicy 的默认策略:

第 1 次失败 → 等 1 秒
第 2 次失败 → 等 2 秒
第 3 次失败 → 等 4 秒
…           → 翻倍,封顶 30 秒
连上了      → 立刻 Reset,回到 1 秒

⚠ ReconnectChannel(name) 这个方法名字有误导性:它不是"立刻重连", 只是断开当前连接,真正的重连靠下一轮退避自然发生。别指望调完马上就好。

超时、重试与质量戳

一次读的完整流程:

发请求 → 等响应
         ├─ 超时(TimeoutMs,默认 1000ms)
         └─ 收到但校验失败
              ↓
         重试(RetryCount,默认 3 次)
              ↓
         还是失败 → 返回 ReadResult.Failure(注意:不抛异常)
              ↓
         把这些点的质量戳置为 CommFailure

读失败不抛异常,而是返回一个"失败结果"。 这是刻意的设计:采集是长期运行的后台循环, 一次失败不应该让整个通道崩掉。

写队列与停机

控制写按优先级进入通道写队列,默认最多挂起 4096 条(ChannelDef.WriteQueueCapacity)。 队列满时普通写会被拒绝并留痕;更高优先级写可以淘汰低优先级旧写,淘汰同样会留痕。 停机时先关闭新写入口,在独立的有限窗口内排空已经入队的写,窗口耗尽会记录 WriteDrainTimeout; 排空过程发生异常会记录 WriteDrainFailure,最后才断开驱动。

⚠ 所以"值一直不动"的时候,先看质量戳。采集失败不会报错,只会默默把质量标成坏值。

拆包写库

读到字节之后,要把它们拆成一个个点的值:

using Hmi.Drivers.Scheduling;

Unpacker.Unpack(block, rawBytes, /* 逐点回调 */ (tagId, value, quality) =>
{
    rtdb.UpdateFromDriver(tagId, value, quality, ts);
});

拆包时会用到:

  • 点的类型(Float 占 4 字节、Int16 占 2 字节…)
  • 字节序(ByteOrder,跨寄存器的 32/64 位值怎么拼)
  • BCD 编码(有些仪表用 BCD 存数值)

拆包和写实时库都在同一个线程上串行做 —— 这样实时库只有一个写者,省掉了大量锁竞争。

模拟驱动:没有 PLC 也能调试

没有真实设备时用 Simulation 协议:

ProtocolId = "Simulation"

它不真连任何设备,而是按地址生成假数据。做界面、调脚本、演示都能用它,不用等设备到场。

内置协议一览

ProtocolId 驱动类 传输
Simulation SimulationDriver 不真连(占位)
ModbusTcp ModbusTcpDriver TCP + 串口(RTU)
S7-200SMART S7SmartDriver TCP
S7-300/400 S7SmartDriver TCP
S7-1200/1500 S7SmartDriver TCP
Mitsubishi-MC MitsubishiDriver TCP
Omron-FINS OmronFinsDriver TCP
Panasonic-Mewtocol PanasonicDriver TCP

地址写法、连接参数、各自的坑见第 08 章。

常用类型

类型 路径 干什么
BlockPlanner Hmi.Drivers/Planning/BlockPlanner.cs 把点规划成块
ChannelScheduler Hmi.Drivers/Scheduling/ChannelScheduler.cs 每通道的采集循环
ChannelRuntime Hmi.Drivers/Scheduling/ChannelRuntime.cs 通道运行态(配置+驱动+写队列)
Unpacker Hmi.Drivers/Scheduling/Unpacker.cs 拆包写实时库
BackoffPolicy Hmi.Drivers/Reconnect/BackoffPolicy.cs 指数退避重连
ProtocolRegistry Hmi.Drivers/Plugins/ProtocolRegistry.cs 协议注册表(按 ID 造驱动)
ProtocolPluginLoader Hmi.Drivers/Plugins/ProtocolPluginLoader.cs 扫描目录加载插件
DriverFactory Hmi.Backend/DriverFactory.cs 一把建好"内置 + 插件"的注册表
SimulationDriver Hmi.Drivers/Simulation/ 模拟驱动

踩过的坑

  1. 池化缓冲忘了归还 —— ReadResult.Raw 用的是 ReadOnlyMemory<byte>,可能来自对象池。 调度器拆包后会调 Release,自己写驱动时别漏。
  2. OperationCanceledException 必须上抛 —— 停机时取消操作,吞掉这个异常会导致进程退不干净。
  3. 改 MaxConcurrentRequests 要谨慎 —— 只有支持管线化的协议才能设大于 1,而且驱动自己必须保证 ReadBlockAsync 可重入。
  4. S7 的 slot 不能乱设 —— 写死 1 会让 300/400 连不上;1200/1500 还要求 PLC 侧开启 PUT/GET。
  5. 超时设太小 —— 现场网络抖动时会导致大量 CommFailure。默认 1000ms 是有道理的,别随手改成 100。

小结

  • 一通道一循环,通道之间互不影响;同一条链路上读写串行
  • 块读是性能关键:把地址相近的点合并成一次请求
  • 驱动只要实现 IProtocolDriver 的 6 个方法,调度/重连/拆包/写库都是框架的事
  • 读失败不抛异常,只改质量戳 —— 排查问题先看质量
  • 连不上走指数退避,封顶 30 秒

下一步:第 08 章 内置协议怎么用 —— 找到你的 PLC 型号照着配。