10 · 运行数据引擎
程序集:Hmi.Backend · 源码:src/Hmi.Backend/
什么时候你会需要它
- 你要把工程真正跑起来:连 PLC、出实时值
- 你在排查"采集没跑 / 设备掉线 / 写点没生效"
- 你要自己写一个无界面的运行服务(不带 WPF 壳)
如果你只是做组态(改工程文件),不需要 Hmi.Backend —— 那是第 15 章的事。
一句话概括
Hmi.Backend 是把"工程文件 + 协议驱动 + 实时库"三样东西拼起来、让数据真的流动起来的那一层。
它是无头的 —— 不带界面、不带窗口,只负责出数据和收命令。所以它既能跑在 WPF 壳里,
也能跑在一个纯控制台进程里(那就是 Hmi.RuntimeCore.Host,见第 17 章)。
它负责什么
┌────────────────── Hmi.Backend.RuntimeHost ──────────────────┐
│ │
工程 ─>│ ① 建实时库(RtdbBridge: TagDef → Tag) │
│ ② 建协议注册表(内置 + 插件) │
│ ③ 为每条通道规划块读(BlockPlanner) │
│ ④ 起采集循环(每通道一个任务) │
│ ⑤ 收写命令,排队下发给驱动 │
│ ⑥ 上报通道超时(CycleOverrun 事件) │
│ │
└────────────────────────────────────────────────────────────┘
怎么用:五步
using Hmi.Backend;
using Hmi.Project.Persistence;
// 1. 先加载工程
LoadedProject project = ProjectLoader.Load(@"C:\我的工程");
// 2. 建协议注册表(内置 8 个 + 指定目录里的插件)
ProtocolRegistry registry = DriverFactory.BuildRegistry(@"C:\HMI\plugins");
// ↑ 不传参数就是只用内置协议;加载失败的插件错误在 DriverFactory.PluginErrors 里
// 3. 建运行宿主(这一步会把工程装配成可运行状态,但还没开始采集)
var host = new RuntimeHost(project, registry);
// 4. 订阅你关心的东西(可选)
host.CycleOverrun += info =>
Console.WriteLine($"通道 {info.ChannelName} 本轮超时了");
// 5. 开始采集
host.Start();
// …跑着…
// 6. 停机
await host.StopAsync();
host.Dispose();
⚠ 第 5 步之前不会有任何数据。
Start()之后采集循环才开始跑,PLC 才开始被读。
公开成员
public sealed class RuntimeHost : IDisposable
{
public RuntimeHost(LoadedProject project, ProtocolRegistry? protocols = null);
// 实时库 —— 想读值就找它
public RtdbCore Rtdb { get; }
// 点名 → 数字 Id,省得每次查
public IReadOnlyDictionary<string, int> TagIdByName { get; }
// 生命周期
public void Start();
public Task StopAsync();
public void Dispose();
// 动作(界面/脚本通过这些方法间接影响运行)
public void WriteTag(string name, string value);
public void OpenScreen(string name);
public void RunScript(string name);
// 诊断
public bool IsDeviceOnline(string deviceName);
public bool ReconnectChannel(string channelName);
// 事件
public event Action<CycleOverrunInfo>? CycleOverrun;
public Action<string>? OnOpenScreen;
public Action<string>? OnRunScript;
}
逐个说清楚
WriteTag(name, value) —— 写点
host.WriteTag("StartBtn", "1");
注意这不是立刻发到 PLC 的。 它会进这条通道的写队列,等当前这轮采集读完了再发出去。
所以:
- 界面上点按钮到设备动作之间,有最多一个扫描周期的延迟(默认 200 毫秒)
- 连续写同一个点,后面的会覆盖前面的(队列里只保留最新值)
⚠ 写采集点是可以的(这是运行态的正常操作,界面按钮就这么干); 但脚本里写采集点会被拦下(见第 12 章)。两者规则不同,别混。
IsDeviceOnline / ReconnectChannel —— 诊断与恢复
if (!host.IsDeviceOnline("1号PLC"))
{
Console.WriteLine("设备掉线了");
host.ReconnectChannel("车间Modbus");
}
⚠
ReconnectChannel不是"立刻重连成功" —— 它只是断开当前连接, 真正的重连靠第 07 章讲的指数退避在下一轮自然发生。
CycleOverrun 事件 —— 采集跟不上了
host.CycleOverrun += info =>
{
// info.ChannelName / info.ElapsedMs / info.ScanMs / info.Streak / info.BlockCount
log.Warn($"通道 {info.ChannelName} 计划 {info.ScanMs}ms,实际花了 {info.ElapsedMs}ms" +
$"(连续第 {info.Streak} 轮,本周期 {info.BlockCount} 块)");
};
CycleOverrunInfo 的五个字段:
| 字段 | 意思 |
|---|---|
ChannelName |
超时的是哪条通道 |
ElapsedMs |
本轮"采集 + 清写队列"实际耗时 |
ScanMs |
这条通道设定的采集周期(注意叫 ScanMs,不是 IntervalMs) |
Streak |
连续超时第几轮(1 = 刚开始;恢复后清零)—— 用它做节流,别每周期刷屏 |
BlockCount |
本周期要读的块总数(判断是不是块划得太碎) |
这个事件说明采集周期设得太紧,或者点太多、块划得太碎。 长期超时要处理,否则数据会越来越滞后。
OnOpenScreen / OnRunScript —— 把动作交回给宿主
RuntimeHost 是无头的,它不知道怎么切画面、怎么跑脚本(那是前端和脚本运行时的事)。
所以它把这两个动作以回调形式交出去,由宿主决定怎么做:
host.OnOpenScreen = name => 前端.切到画面(name);
host.OnRunScript = name => 脚本宿主.跑(name);
和脚本/历史/报警怎么接
RuntimeHost 只负责"采集 + 实时库"。要历史、报警、脚本,得再叠一层:
using Hmi.ScriptRuntime;
// 一次性把报警 + 历史 + 保留清理 + 联动镜像都装上
RuntimeDataServices services = RuntimeDataServices.Compose(
rtdb: host.Rtdb,
project: project,
historianDbPath: @"C:\我的工程\history.db");
// 内部已经帮你 Subscribe 了,而且走的是 Lossless 车道
详见第 14 章。
完整示例:一个最小的无界面采集服务
using Hmi.Backend;
using Hmi.Project.Persistence;
using Hmi.ScriptRuntime;
class Program
{
static async Task Main(string[] args)
{
string dir = args[0];
var project = ProjectLoader.Load(dir);
var registry = DriverFactory.BuildRegistry(Path.Combine(dir, "plugins"));
// 插件加载失败要看一眼,不然会以为"协议不支持"
foreach (var err in DriverFactory.PluginErrors)
Console.Error.WriteLine($"[插件] {err}");
using var host = new RuntimeHost(project, registry);
host.CycleOverrun += i => Console.Error.WriteLine($"[超时] {i.ChannelName}");
using var services = RuntimeDataServices.Compose(
host.Rtdb, project, Path.Combine(dir, "history.db"));
host.Start();
Console.Error.WriteLine("采集已启动,Ctrl+C 退出");
var done = new TaskCompletionSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; done.TrySetResult(); };
await done.Task;
await host.StopAsync();
}
}
💡 注意日志都往
Console.Error写。如果这个进程以后要改成用 stdout 做进程间通信 (像Hmi.RuntimeCore.Host那样),stdout 会被协议占用,日志必须走 stderr。见第 17 章。
踩过的坑
- 忘了
Start()—— 装配完了不等于跑起来了。没数据先检查这一步。 Dispose()没调 —— 采集任务还在跑,进程退不干净。用using或await StopAsync()后再释放。- 把
ReconnectChannel当成"立刻重连" —— 它只是断开,重连要等退避周期。 CycleOverrun一直报 —— 采集周期太紧或点太多。调大ScanIntervalMs,或者把点按地址顺序排好让块划得更整。- 插件加载失败没看 —— 表现为"协议不可用",实际原因在
DriverFactory.PluginErrors里。 - 以为
WriteTag是同步下发 —— 它是入队,有一个扫描周期的延迟。
小结
Hmi.Backend= 无头的数据引擎:建实时库 → 起采集循环 → 收写命令- 顺序:
Load→BuildRegistry→new RuntimeHost→Start WriteTag是入队,不是立即下发,有最多一个扫描周期的延迟CycleOverrun告诉你采集跟不上,别忽略- 历史/报警/脚本要另外叠
RuntimeDataServices.Compose