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/ |
模拟驱动 |
踩过的坑
- 池化缓冲忘了归还 ——
ReadResult.Raw用的是ReadOnlyMemory<byte>,可能来自对象池。 调度器拆包后会调Release,自己写驱动时别漏。 OperationCanceledException必须上抛 —— 停机时取消操作,吞掉这个异常会导致进程退不干净。- 改
MaxConcurrentRequests要谨慎 —— 只有支持管线化的协议才能设大于 1,而且驱动自己必须保证ReadBlockAsync可重入。 - S7 的 slot 不能乱设 —— 写死 1 会让 300/400 连不上;1200/1500 还要求 PLC 侧开启 PUT/GET。
- 超时设太小 —— 现场网络抖动时会导致大量
CommFailure。默认 1000ms 是有道理的,别随手改成 100。
小结
- 一通道一循环,通道之间互不影响;同一条链路上读写串行
- 块读是性能关键:把地址相近的点合并成一次请求
- 驱动只要实现
IProtocolDriver的 6 个方法,调度/重连/拆包/写库都是框架的事 - 读失败不抛异常,只改质量戳 —— 排查问题先看质量
- 连不上走指数退避,封顶 30 秒
下一步:第 08 章 内置协议怎么用 —— 找到你的 PLC 型号照着配。