一条产线改造,把三菱 Q 系列换成了西门子 S7-1500,上位机里几千行 MC 协议读写代码全部作废——这种亏很多集成商都吃过。OPC UA 就是为解决"设备说话各说各的"而生的统一标准:它不只是个协议,更是一套带语义的信息模型 + 平台无关的安全通信。这篇讲清楚 C# 里怎么用 OPC Foundation 官方库把不同品牌的 PLC 接成一套代码:证书信任、节点浏览、订阅监视项,以及落地时绕不开的坑。

一、先算账:Modbus 用得好好的,为什么还要 OPC UA

对比项Modbus TCPOPC UA
数据模型只有寄存器地址(40001、DB1.DBW0),地址本身没有含义,含义全靠上位机里的点表维护地址空间是带名字、类型、描述、工程单位的对象树,设备自描述,点表可自动发现
数据获取方式上位机周期轮询,变化快慢都得扫支持订阅,数据变化/定时由服务端主动推送通知
安全性协议本身无认证无加密,安全全靠网络隔离内置 X509 证书、签名加密、用户认证、操作审计,可跨厂区公网传输
数据类型16 位寄存器拼一切,字符串/结构体全靠手工约定字节序内置类型系统:数组、结构体、枚举、方法、报警事件都是一等公民
互通程度寄存器排布每家都不一样,换品牌点表重对配套 Companion Spec(如 PLCopen、PackML),同行业设备模型趋同
成本与门槛极简,任何串口服务器都支持,老设备零授权费要 PLC 固件支持 UA Server(S7-1500、汇川 AM/AC 系列、倍花等支持;S7-200SMART 等老型号不支持)
典型场景少量点位、单一品牌、内网直连的设备级采集多品牌混线、跨车间/跨厂数据集成、对安全和语义有要求的项目

▲ 二者是分层关系:老设备、简单仪表继续 Modbus;多品牌产线和厂级数据平台用 OPC UA。实在不支持 UA 的老 PLC,还可以在边缘网关跑一个 UA Server(KEPServerEX、OPC Router 或自研网关)把 Modbus 包装成 UA 节点。

二、理解地址空间:OPC UA 里没有"地址",只有节点

第一次连 UA 服务器最不适应的是没有 DBD/DBW 这种地址。整个服务器暴露的是一棵节点(Node)树,每个节点有一个全局唯一的 NodeId(命名空间号 + 标识符),节点之间用引用串起来,变量节点带数据类型、访问级别、工程单位等属性。以西门子 S7-1500 开放的 OPC UA 服务器为例:

Server ├─ Objects (ns=0, i=85) │ ├─ Server // 服务端状态、命名空间表 │ └─ PLC-1500 (ns=3, s="PLC_1") │ ├─ DataBlocksGlobal │ │ ├─ "DB_Production" // 你在博图里勾选了"可从OPC UA访问"的DB │ │ │ ├─ "iOutputCount" (Int32, 单位=件) │ │ │ ├─ "rTemperature" (Float, EURange 0..300) │ │ │ ├─ "stRecipe" (结构化节点,成员各带名字和类型) │ │ │ └─ "arTemps" (数组,可整体读也可订阅元素) │ │ └─ "DB_Alarm" │ └─ ServerInterfaces // 博图里建的自定义服务器接口(推荐:只暴露该暴露的) └─ Types // 类型定义:数据类型/引用类型/对象类型
  • NodeId 两种写法ns=3;s="DB_Production"."rTemperature"(字符串标识,西门子风格,可读性好)和 ns=3;i=12345(数字标识,更稳定);ns=0 是 UA 标准命名空间,不要往里面写业务点;
  • 不要在代码里硬编码一堆 NodeId 字符串。两种工程做法:小项目把 NodeId 配在点表 JSON/数据库里;规范项目在博图里建 Server Interface 或用 PLCopen Companion 模型,上位机按浏览路径发现节点,PLC 内部换 DB 号不影响上位机;
  • 结构体和数组直接可用:这是 UA 相对 Modbus 最大的省事点——一个配方结构体作为单个节点读出就是强类型对象,不用再约定字节序手工拼。

三、三层通信模型:Session / Subscription / MonitoredItem

Session 会话建立在安全通道之上的逻辑连接,持有用户身份与服务器分配的会话凭证;TCP 断了可在超时内用同一凭证重建(Reconnect),订阅状态在服务端保留。
Subscription 订阅会话下的数据上报容器,有独立的发布间隔 PublishingInterval 和生命周期。订阅本身不指定看什么数据,只负责"多久送一包通知"。
MonitoredItem 监视项挂在订阅里的具体节点:监视模式(禁用/只上报值/上报值+事件)、采样间隔 SamplingInterval、触发方式(状态/变化/绝对死区)、队列长度与溢出策略。

▲ 关系是 1 个 Session : N 个 Subscription : N 个 MonitoredItem。通知由服务端按发布间隔攒包下发;断线期间服务端按队列长度缓存变化值,重连后补送——这是"订阅"优于"轮询"的关键机制,但队列有长度,断太久旧值仍会丢,关键业务点要配足够队列或在上位机补采。

四、证书信任:新手 90% 的时间耗在这一步

OPC UA 默认安全模式下,客户端和服务器双向证书认证:上位机要有自己的应用实例证书,PLC 也要有,双方把对方证书放进各自的信任列表才允许连接。第一次连接报 BadCertificateUntrusted 是常态,不是故障。

生成自签证书客户端首次运行自动生成,含应用URI
首次连接被拒服务端把客户端证书丢进 Rejected
双向信任PLC信任客户端证书
客户端信任PLC证书
正式连接签名+加密建安全通道

证书存放位置:

  • 上位机侧:OPCFoundation 库默认用一个基于目录的证书仓库(CertificateStore,推荐 X509Store 或 DirectoryStore),own/certs 放自己的证书,trusted/certs 放信任的对端证书,rejected/certs 里能看到连过来但没被信任的证书;
  • PLC 侧:S7-1500 通过 TIA 博图或 Web 服务器的"OPC UA 证书管理器",把上位机证书导入"受信任的客户端";同时把 PLC 自己的证书导出(.der)拷到上位机 trusted 目录,或首次连接时在回调里人工确认后自动信任——自动信任只建议在调试期开启
  • 证书的应用 URI 必须与配置一致(ApplicationUri 不匹配会报 BadCertificateUriInvalid);证书过期、换电脑没迁移证书都会断连,装机流程里要带证书导入这一步。

五、C# 实战:连接、证书信任、订阅、收通知

用 OPC Foundation 官方维护的库(NuGet:OPCFoundation.NetStandard.Opc.Ua,.NET Framework 4.6.2+ 与 .NET 6/8 都能用)。下面是一个可直接改造的最小可用客户端骨架。

1)用 ApplicationConfiguration 配置证书仓库,首次运行自动自签:

var config = new ApplicationConfiguration {
    ApplicationName = "WorkshopScada",
    ApplicationUri = Utils.Format("urn:{0}:WorkshopScada", System.Net.Dns.GetHostName()),
    ApplicationType = ApplicationType.Client,
    SecurityConfiguration = new SecurityConfiguration {
        AutoAcceptUntrustedCertificates = false,   // 生产环境关掉,调试可临时true
        RejectSHA1SignedCertificates = true,
        ApplicationCertificate = new CertificateIdentifier {
            StoreType = CertificateStoreType.X509Store,
            StorePath = "CurrentUser\\My"
        },
        TrustedIssuerCertificates  = new CertificateTrustList { StoreType = CertificateStoreType.Directory,
                                                                  StorePath = @"D:\opcua\pki\trusted" },
        TrustedPeerCertificates    = new CertificateTrustList { StoreType = CertificateStoreType.Directory,
                                                                  StorePath = @"D:\opcua\pki\trusted" },
        RejectedCertificateStore   = new CertificateTrustList { StoreType = CertificateStoreType.Directory,
                                                                  StorePath = @"D:\opcua\pki\rejected" }
    },
    TransportConfigurations = new TransportConfigurationCollection(),
    TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
    ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }
};
await config.Validate(new CertificateValidationEventHandler(OnCert), true); // 没证书时自动生成

static ServiceResult OnCert(CertificateValidator sender, CertificateValidationEventArgs e)
{
    // 生产:只信任白名单指纹;调试:弹框让运维确认后写入trusted
    if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
    {
        if (FingerprintWhitelist.Contains(e.Certificate.Thumbprint)) { e.Accept = true; }
    }
    return ServiceResult.Good;
}

2)选择安全策略、建立会话(不要一上来就 None):

var endpoint = CoreClientUtils.SelectEndpoint(config, "opc.tcp://192.168.1.10:4840", true);
// 第二个参数true=必须有安全策略。常见级别:
//   Basic256Sha256 + SignAndEncrypt  → 生产首选
//   Sign                             → 只签名不加密,内网性能优先
//   None                             → 仅限物理隔离网段调试
var endpointCfg = EndpointConfiguration.Create(config);
using var channel = SessionChannel.Create(config, new ConfiguredEndpoint(null, endpoint,
                                       new EndpointConfiguration(endpointCfg)), ...);
var session = Session.Create(
    config,
    new ConfiguredEndpoint(null, endpoint),
    updateBeforeConnect: false,
    sessionName: "Scada@" + Environment.MachineName,
    sessionTimeout: 60000,
    identity: new UserIdentity("opcuser", "口令存加密配置"),
    preferredLocales: null).GetAwaiter().GetResult();
session.KeepAlive += OnKeepAlive;   // 心跳回调里检测Reconnecting/续约失败

3)创建订阅、挂监视项、收变化通知(这是替代轮询的核心):

var sub = new Subscription(session.DefaultView) {
    PublishingInterval = 500,          // 500ms打包送一次通知,按业务节奏定,别小于PLC扫描周期
    PublishingEnabled  = true,
    LifetimeCount       = 1000,        // 生命周期=发布间隔×LifetimeCount,超时订阅被销毁
    MaxKeepAliveCount   = 10,
    MinLifetimeInterval = 10000,
    DisplayName         = "LineA-Production"
};

var points = new Dictionary<string, NodeId>
{
    ["output"] = NodeId.Parse("ns=3;s=\"DB_Production\".\"iOutputCount\""),
    ["temp"]   = NodeId.Parse("ns=3;s=\"DB_Production\".\"rTemperature\""),
};
foreach (var kv in points)
{
    var item = new MonitoredItem(sub.DefaultItem) {
        StartNodeId       = kv.Value,
        AttributeId       = Attributes.Value,
        SamplingInterval  = 250,        // 服务端最小采样250ms;高频点可设-1由订阅周期决定
        QueueSize         = 20,         // 断线期间缓存20个变化,溢出按DiscardOldest丢最旧
        DiscardOldest     = true,
        DisplayName       = kv.Key
    };
    item.Filter = new DataChangeFilter {
        Trigger = DataChangeTrigger.StatusValue,
        DeadbandType = (uint)DeadbandType.Absolute,
        DeadbandValue = 0.1             // 温度变化小于0.1℃不上报,挡抖动省流量
    };
    item.Notification += OnNotification;
    sub.AddItem(item);
}
session.AddSubscription(sub);
sub.Create();

void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
{
    foreach (var n in item.DequeueMessages().Cast<MonitoredItemNotification>())
    {
        if (StatusCode.IsNotGood(n.Value.StatusCode)) continue; // 坏值照收但标记,别写库
        var v = Convert.ToDecimal(n.Value.Value);
        var ts = n.Value.SourceTimestamp;      // 用PLC侧源时间戳,不要用本机收到时间
        PointSink.Write(item.DisplayName, v, ts);
    }
}
绝对死区(Deadband)是免费的防抖。温度、压力这类连续量在小数位来回跳,不设死区通知量会翻几十倍。变化频繁的计数/状态点用 StatusValue 触发(值或状态变化才上报),周期巡检点用 Reporting + PublishingInterval 节奏即可。

六、浏览节点:让上位机自己"发现"点位

调试或做通用网关时,用 Browse 服务遍历地址空间,拿到节点名、NodeId、类型后再批量订阅,省去手工对点:

var nodes = session.FetchReferences(ObjectIds.ObjectsFolder); // 也可传任意起点做递归
var browser = new Browser(session) {
    BrowseDirection = BrowseDirection.Forward,
    ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
    IncludeSubtypes = true,
    NodeClassMask   = (uint)NodeClass.Variable
};
foreach (var node in browser.Browse(startNodeId))
{
    var rd = node as ReferenceDescription;
    var id = new NodeId(rd.NodeId.Identifier, rd.NodeId.NamespaceIndex);
    var dv = session.ReadValue(id);
    Log.Information($"{rd.BrowseName}  {id}  {dv.WrappedValue.TypeInfo}  ={dv.WrappedValue.Value}");
}

实际项目里更常见的用法是一次性导出点表:写个小工具浏览 PLC 暴露的 Server Interface 节点,导出 CSV/JSON(点名、NodeId、类型、单位),评审签字后作为上位机点表基线,运行时严格按基线订阅——既能自动发现,又不允许运行中随意加点,变更走点表版本管理。

七、断线重连:UA 库给了机制,但策略要自己定

情况库行为应用层要做的事
瞬断后会话未超时Session.Reconnect 用同一通道重建,订阅与队列保留KeepAlive 回调中调用 Reconnect,成功后无需重订
会话超时/服务端重启旧会话失效,报 BadSessionIdInvalid整链重建:Create Session → 重建 Subscription → 批量重加 MonitoredItem
PLC 停机较久MonitoredItem 队列溢出,旧变化丢失重连后对关键累计量(产量、能耗)执行一次 Read 全量对齐,再恢复增量订阅
时钟不同步时间戳偏差影响排序与对账PLC 与上位机统一 NTP 对时,服务端时间偏差过大时告警
private void OnKeepAlive(ISession s, KeepAliveEventArgs e)
{
    if (e.Status != null && ServiceResult.IsNotGood(e.Status))
    {
        Log.Warning($"OPC UA链路异常:{e.Status},尝试重连");
        if (!s.Reconnect()) RebuildSessionAndSubscriptions();  // 重连失败走整链重建
    }
}
整链重建一定要幂等。重连逻辑可能被 KeepAlive 与网络异常回调同时触发,用一把锁 + 状态标记保证同一时刻只有一个重建流程;重建成功后重新 Read 一次关键累计量,避免"重连成功但产量对不上"。

八、上线检查清单

  1. PLC 侧最小暴露:用博图建独立 Server Interface,只挂生产需要的 DB 字段;DB 属性勾"可从 OPC UA 访问"不等于全暴露,接口白名单才是边界;
  2. 安全策略定 Basic256Sha256 + SignAndEncrypt,设置独立 UA 用户名口令,不用匿名;证书指纹白名单入库,关闭 AutoAcceptUntrustedCertificates;
  3. 参数别拍脑袋:PublishingInterval 不小于 PLC 循环周期(常见 500ms~1s),SamplingInterval 与死区按信号特性定,QueueSize 按可接受的最长断线时间 ÷ 变化频率估算;
  4. 时间戳统一用 SourceTimestamp,全厂 NTP 对时;坏质量值只标记不落业务库;
  5. 演练四件事:拔网线 10 秒/10 分钟各一次(验证补送与全量对齐)、重启 PLC(验证整链重建)、证书过期前 30 天告警、换上位机走证书迁移 SOP。

做完这些,OPC UA 给你的回报是:下次产线再换品牌 PLC,只要它是合规的 UA Server 且信息模型按同一套接口组织,上位机改的只有一份点表配置,通信层代码一行不动。把协议差异挡在配置层之外,正是统一接入架构的价值所在。