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

三个特点:

  1. 行号(Id)是数字,从 0 开始连续编号。所有内部代码都用数字 Id 寻址,不用字符串 —— 字符串比对慢,数字直接当数组下标。
  2. 值是"原始值 + 质量 + 时间戳"三件套。只看数值会误判,PLC 断线时数值可能还是上次的旧值,但质量戳会变成 Bad。
  3. 进程一关就没了。要长期保存得靠历史库(第 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 产品显示名的唯一来源

踩过的坑

  1. 顺序错:没 new RtdbCore 之前查 TryGetId,查不到。必须先建库。
  2. 点 Id 不连续:RtdbBridge.BuildTags 会抛 FormatException。点 Id 必须 0 起连续、不重复。
  3. 忘了看质量戳:只看数值会读到"上次的旧值"。判断值能不能用,永远先看 Quality。
  4. 回调里抛异常会被吞掉:分发线程不会因为你的回调报错就崩,但你会静默丢失事件。回调里自己 try/catch 并记日志。
  5. 该用 Lossless 用了 Droppable:报警/历史会丢数据,而且丢了不会有任何提示。
  6. 忘了释放订阅令牌:订阅表一直挂着你的对象,内存泄漏。

小结

  • 实时库 = 内存里的点值表,Id 是数组下标,读用 Id、写用 Id
  • 初始化顺序:Load → BuildTags → new RtdbCore → 查 Id → 订阅
  • 值是三件套(值 + 质量 + 时间戳),判断可用性看质量戳
  • 订阅分两条车道:界面用 Droppable,报警历史用 Lossless
  • 想接新消费者,实现 ITagSubscriber 即可

下一步:第 05 章 点、通道、设备 —— 搞清楚这些点是从哪来的。