04 · 实时库:内存里的点值表
程序集:Hmi.Core · 源码:src/Hmi.Core/
什么时候你会需要它
- 你要读某个点的当前值(界面刷新、脚本判断、报警判定)
- 你要把采集到的值写进内存(写驱动的时候)
- 你要接一个新的数据消费者(比如自己做一个数据转发服务)
- 你遇到"值不对 / 值没更新 / 界面不刷新"的问题,想搞明白底层怎么运作
如果你只是想在脚本里读值,用 #设备名.点名.实时值 就够了,不用直接碰 Hmi.Core —— 那是第 13 章的事。这一章讲的是底层机制。
实时库是什么
把它想成一张放在内存里的大表,每一行是一个点:
| 行号(Id) | 点名 | 值 | 质量 | 时间戳 |
|---|---|---|---|---|
| 0 | Boiler_Temp1 |
87.5 | Good | 14:02:31.220 |
| 1 | Pump1_Run |
true | Good | 14:02:31.221 |
| 2 | Tank_Level |
— | Bad | 14:02:31.500 |
三个特点:
- 行号(Id)是数字,从 0 开始连续编号。所有内部代码都用数字 Id 寻址,不用字符串 —— 字符串比对慢,数字直接当数组下标。
- 值是"原始值 + 质量 + 时间戳"三件套。只看数值会误判,PLC 断线时数值可能还是上次的旧值,但质量戳会变成
Bad。 - 进程一关就没了。要长期保存得靠历史库(第 14 章)。
⚠ 实时库 ≠ 历史库。实时库只有"现在",历史库存"过去"。两者是独立的两个程序集。
怎么用:五步,顺序不能乱
using Hmi.Core.Rtdb;
using Hmi.Core.Models;
using Hmi.Project.Persistence;
// 1. 先从磁盘加载工程(得到点定义)
var project = ProjectLoader.Load(@"C:\我的工程");
// 2. 把"工程里的点定义"转成"运行时点对象"
Tag[] tags = RtdbBridge.BuildTags(project.Tags);
// 3. 建实时库 —— 注意:构造时会把每个 Tag 的 Id 就地改写成数组下标
var rtdb = new RtdbCore(tags);
// 4. 查一次 Id 并缓存起来(热路径别反复查字符串)
rtdb.TryGetId("Boiler_Temp1", out int tempId);
// 5. 订阅这个点,有变化就回调
using var sub = rtdb.Subscribe(new[] { tempId }, mySubscriber, DeliveryMode.Lossless);
第 3 步是必须的:new RtdbCore(tags) 会就地改写每个 Tag 的 Id,把它设成数组下标。
在这之前 Id 可能是别的值,在这之后才稳定。所以顺序必须是"先建库,再查 Id"。
用
using包住Subscribe返回的对象 —— 它Dispose时自动退订。忘了释放会导致内存泄漏(订阅表一直挂着你的对象)。
读值
// 读标量(数字 / 布尔):返回一个快照
TagChange ch = rtdb.Read(tempId);
double v = ch.Value.AsDouble(); // 数值
Quality q = ch.Quality; // 质量戳 —— 一定要看!
long ts = ch.TimestampUtc; // 时间戳(UTC ticks)
// 读非标量(字符串 / 数组 / 结构体):走另一条路
NonScalarSnapshot snap = rtdb.ReadNonScalar(tempId);
读是线程安全的,内部用 seqlock 乐观读,多个线程同时读不会互相阻塞,也不需要你加锁。
💡 seqlock 是什么:读之前记一个版本号,读完再看版本号变没变;变了说明读的中间被人改了,重读一遍。 好处是读操作完全不加锁 —— 这就是实时库能扛高频读的原因。
写值
写值分三种情况,别用错方法:
| 你要做的事 | 用哪个方法 |
|---|---|
| 采集到了新值(标量) | UpdateFromDriver(id, value, quality, tsUtc) |
| 采集到了新值(字符串/数组) | UpdateNonScalar(id, value, quality, tsUtc) |
| 值没变,只是通信状态变了 | SetQuality(id, quality, tsUtc) |
rtdb.UpdateFromDriver(tempId, TagValue.FromDouble(87.5), Quality.Good, DateTime.UtcNow.Ticks);
三个方法都返回 bool —— 返回 false 表示这个值被死区挡掉了(变化幅度太小,不算变化),不是出错。
⚠ 非标量值写进去之后就当它是只读的。别把数组对象存进去后又原地改它 —— 读方可能正读到一半, 会读到撕裂的数据。要更新就构造一个新对象再
UpdateNonScalar。
订阅:别轮询,让库来通知你
订阅是实时库最核心的机制。你告诉它"我关心哪几个点",有变化它调你的回调。
using Hmi.Core.Subscription;
public sealed class 我的订阅者 : ITagSubscriber
{
public void OnTagChanged(ReadOnlySpan<TagChange> batch)
{
foreach (var ch in batch)
{
// ch.TagId / ch.Value / ch.Quality / ch.TimestampUtc
Console.WriteLine($"点 {ch.TagId} 变成 {ch.Value.AsDouble()}");
}
}
}
// 订阅一批点
using var sub = rtdb.Subscribe(new[] { id1, id2, id3 }, new 我的订阅者(), DeliveryMode.Droppable);
// 或者订阅全部(慎用,点多了会很吵)
using var all = rtdb.SubscribeAll(new 我的订阅者(), DeliveryMode.Droppable);
两条车道:为什么必须有
分发队列有两条,建订阅时必须选一条:
| 车道 | 行为 | 谁该用 |
|---|---|---|
Droppable(默认) |
有界队列(默认容量 65536),满了丢最旧的 | 界面刷新。反正下一帧就是新值,丢一帧无所谓 |
Lossless |
一个都不许丢,队列无上限 | 报警、历史库。丢一条报警就是生产事故 |
⚠
Lossless的队列没有上限。如果你的消费者处理得太慢,内存会一直涨。 所以只给"必须一条不丢"的消费者用,而且消费者本身要够快。💡
Droppable的容量可以在建分发器时调:ChangeDispatcher(tagCount, displayCapacity)。
谁在用这个接口
实时库里的 ITagSubscriber 实现有好几个,理解它们就理解了整条数据链路:
TagSubscriptionBridge—— 把变化推给前端界面HistorianRecorder—— 决定哪些值要落历史库AlarmEngine—— 做报警判定LinkedVariableMirror—— 内部变量之间的联动镜像
你想接一个新的数据消费者(比如转发到 MQTT),实现这个接口再 Subscribe 就行。
常用方法一览
RtdbCore(src/Hmi.Core/Rtdb/RtdbCore.cs):
| 方法 | 干什么 |
|---|---|
RtdbCore(IReadOnlyList<Tag> tags) |
建库。就地改写 Tag.Id 为下标;点重名抛 ArgumentException |
int Count |
有多少个点 |
bool TryGetId(string name, out int id) |
点名 → 数字 Id。查一次就缓存,别在循环里反复查 |
Tag GetTag(int id) |
拿点的定义(名字、类型、量程、单位…) |
TagChange Read(int id) |
读当前值快照(标量) |
NonScalarSnapshot ReadNonScalar(int id) |
读当前值快照(字符串/数组/结构) |
bool UpdateFromDriver(int id, TagValue v, Quality q, long tsUtc) |
写标量值 |
bool UpdateNonScalar(int id, object? v, Quality q, long tsUtc) |
写非标量值 |
bool SetQuality(int id, Quality q, long tsUtc) |
只改质量戳(值不动) |
IDisposable Subscribe(IEnumerable<int> ids, ITagSubscriber sub, DeliveryMode mode) |
订阅一批点 |
IDisposable SubscribeAll(ITagSubscriber sub, DeliveryMode mode) |
订阅全部点 |
void Dispose() |
关闭内部分发线程 |
ITagSubscriber(src/Hmi.Core/Subscription/ITagSubscriber.cs):
void OnTagChanged(ReadOnlySpan<TagChange> batch);
⚠
batch只在本次回调内有效。你要是想留着数据稍后处理,必须自己拷贝一份 —— 直接存引用会读到被复用的内存。
同程序集里还有几个小工具
| 类型 | 用途 |
|---|---|
ScalingHelper |
原始值 ↔ 工程值线性换算 |
DeadbandHelper |
死区判定 |
TagClassification |
判断一个点是模拟量还是开关量 |
UiStateStore |
运行期界面属性覆盖(比如操作工临时改了个颜色) |
Branding |
产品显示名的唯一来源 |
踩过的坑
- 顺序错:没
new RtdbCore之前查TryGetId,查不到。必须先建库。 - 点 Id 不连续:
RtdbBridge.BuildTags会抛FormatException。点 Id 必须 0 起连续、不重复。 - 忘了看质量戳:只看数值会读到"上次的旧值"。判断值能不能用,永远先看
Quality。 - 回调里抛异常会被吞掉:分发线程不会因为你的回调报错就崩,但你会静默丢失事件。回调里自己 try/catch 并记日志。
- 该用
Lossless用了Droppable:报警/历史会丢数据,而且丢了不会有任何提示。 - 忘了释放订阅令牌:订阅表一直挂着你的对象,内存泄漏。
小结
- 实时库 = 内存里的点值表,Id 是数组下标,读用 Id、写用 Id
- 初始化顺序:
Load→BuildTags→new RtdbCore→ 查 Id → 订阅 - 值是三件套(值 + 质量 + 时间戳),判断可用性看质量戳
- 订阅分两条车道:界面用
Droppable,报警历史用Lossless - 想接新消费者,实现
ITagSubscriber即可
下一步:第 05 章 点、通道、设备 —— 搞清楚这些点是从哪来的。