一条产线改造,把三菱 Q 系列换成了西门子 S7-1500,上位机里几千行 MC 协议读写代码全部作废——这种亏很多集成商都吃过。OPC UA 就是为解决"设备说话各说各的"而生的统一标准:它不只是个协议,更是一套带语义的信息模型 + 平台无关的安全通信。这篇讲清楚 C# 里怎么用 OPC Foundation 官方库把不同品牌的 PLC 接成一套代码:证书信任、节点浏览、订阅监视项,以及落地时绕不开的坑。
一、先算账:Modbus 用得好好的,为什么还要 OPC UA
| 对比项 | Modbus TCP | OPC 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 服务器为例:
- 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
▲ 关系是 1 个 Session : N 个 Subscription : N 个 MonitoredItem。通知由服务端按发布间隔攒包下发;断线期间服务端按队列长度缓存变化值,重连后补送——这是"订阅"优于"轮询"的关键机制,但队列有长度,断太久旧值仍会丢,关键业务点要配足够队列或在上位机补采。
四、证书信任:新手 90% 的时间耗在这一步
OPC UA 默认安全模式下,客户端和服务器双向证书认证:上位机要有自己的应用实例证书,PLC 也要有,双方把对方证书放进各自的信任列表才允许连接。第一次连接报 BadCertificateUntrusted 是常态,不是故障。
客户端信任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); } }
六、浏览节点:让上位机自己"发现"点位
调试或做通用网关时,用 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(); // 重连失败走整链重建 } }
八、上线检查清单
- PLC 侧最小暴露:用博图建独立 Server Interface,只挂生产需要的 DB 字段;DB 属性勾"可从 OPC UA 访问"不等于全暴露,接口白名单才是边界;
- 安全策略定 Basic256Sha256 + SignAndEncrypt,设置独立 UA 用户名口令,不用匿名;证书指纹白名单入库,关闭 AutoAcceptUntrustedCertificates;
- 参数别拍脑袋:PublishingInterval 不小于 PLC 循环周期(常见 500ms~1s),SamplingInterval 与死区按信号特性定,QueueSize 按可接受的最长断线时间 ÷ 变化频率估算;
- 时间戳统一用 SourceTimestamp,全厂 NTP 对时;坏质量值只标记不落业务库;
- 演练四件事:拔网线 10 秒/10 分钟各一次(验证补送与全量对齐)、重启 PLC(验证整链重建)、证书过期前 30 天告警、换上位机走证书迁移 SOP。
做完这些,OPC UA 给你的回报是:下次产线再换品牌 PLC,只要它是合规的 UA Server 且信息模型按同一套接口组织,上位机改的只有一份点表配置,通信层代码一行不动。把协议差异挡在配置层之外,正是统一接入架构的价值所在。
