简介:《C# OPC客户端测试》是一份面向工业自动化、数据采集方向C#开发者的OPC通信示例工程,基于VS2010与OpcNetApiChs组件,演示客户端连接OPC服务器、浏览组与项、订阅读取、写入数据、处理错误与服务器事件以及断开释放资源等核心流程,适合需要理解OPC客户端实现或搭建测试环境的初、中级开发者参考。压缩包共71个文件,约2.84MB,包含14个cs源码、2个sln解决方案与2个csproj项目文件,以及dll、exe、config、resx、resources等运行库、配置和界面资源,另有少量txt、xml、ico等辅助文件,便于直接打开工程调试并观察完整项目结构。已有254人学习。工程将连接、数据读写与事件响应拆分为可运行的测试窗体,读者可据此梳理OPC客户端的调用顺序、订阅回调与异常处理思路,也能参考其配置和资源组织方式,快速对照实现自己的采集端程序。
1. 现场那个“全是 Bad”的标签:C# OPC 客户端测试到底解决什么
去年在一个车间,一台工控机上的 C# OPC 客户端界面三十多个标签全部显示 Bad,服务器侧数据是活的,换另一台机器上的工具去连又有值。折腾到后半夜才发现是 DCOM 身份验证级别和 32/64 位注册表错位,这类问题在任何手册里都只有一句话,在现场能耗掉一整天。这份 C# OPC 客户端测试资源要做的事很朴素:把连接服务器、建组、加项、同步读、订阅回调、写入、断线重连这一串动作拆成能反复执行的步骤,每一步都能拿到返回码,让你能在几分钟内判断问题落在客户端、配置还是对端。它适合做上位机、产线数据采集、与老 OPC DA 服务器对接的开发者,也适合需要向别人交付“通信链路正常”结论的现场工程师。它不能替你配置服务器,但能让你不再靠猜。
2. DA 还是 UA:C# OPC 客户端的选型与工程骨架
2.1 COM 编组与 UA 二进制:两条链路差在哪
OPC DA 的底层是 COM,接口是一组 IUnknown 派生出来的:IOPCServer 负责建组,IOPCItemMgt 负责加项,IOPCSyncIO 和 IOPCAsyncIO2 负责读写,IOPCDataCallback 负责把变化推回来。同一台机器上走的是本地过程调用,开销小;一旦跨机器,就必须经过 DCOM 编组,链路上会多出端点映射器和一个动态端口范围,还要在两端配访问权限和身份验证级别。C# 端拿到的是一个 RCW(运行时可调用包装),它的生命周期由 GC 和引用计数共同决定,这就是后面很多“句柄不释放”“对象已释放”问题的根源。
OPC UA 是另一条路。它是应用层协议,跑在 TCP 上,单端口(常见 4840),自己带安全通道、会话、订阅、方法调用。二进制编码把结构体压成 Chunk 传输,安全策略在通道层协商,跟操作系统权限没有关系。C# 这边是纯托管实现,全异步 API,没有 COM 引用计数那套东西。选型上的判断其实不复杂:对端只暴露 DA,那就只能在 COM 这条路上做文章,或者中间加一个协议转换网关;对端支持 UA,新项目没有理由再碰 DCOM。
真正容易忽略的是混合场景。很多老产线是 DA 服务器加若干年没动过的上位机,新系统要求接进来。常见做法是在采集机上跑一个转换网关,把 DA 标签映射成 UA 节点,C# 端统一用 UA 栈对接。代价是多了一个单点进程和一层标签映射维护,好处是客户端代码只有一套,测试用例也只有一套。我在项目里一般会先问清楚:这条链路上有几个人能改服务器配置?如果答案是“没人能动”,网关方案基本就是唯一解。
2.2 四套可选栈的取舍:从 tlbimp 到官方 .NET 栈
C# 做 OPC 客户端能选的技术栈就那么几种,差在控制力和维护成本上。下面这张表是我在几个项目里踩过之后的实际感受:
| 方案 | 依赖 | 上手成本 | 典型问题 | 适用场景 |
|---|---|---|---|---|
| 类型库导入生成互操作程序集(tlbimp) | 目标机装 OPC Core Components,配 DCOM | 高 | COM 生命周期、编组、位数不匹配 | 只有 DA 服务器,且需要精细控制接口 |
| 第三方或自研的 DA 托管封装 | 同上 | 中 | 异常被吞、封装版本老化、回调线程不确定 | 快速对接已有 DA,能接受黑盒 |
| 官方 .NET Standard UA 栈(Opc.Ua.Client 命名空间) | 纯托管,无外部依赖 | 中 | 证书、会话保活、订阅队列配置 | 新项目、跨平台、需要安全策略 |
| DA 转 UA 网关 + UA 客户端 | 网关进程 | 低 | 网关单点、标签映射维护 | 老设备无法改配置 |
第一种方案里,互操作程序集不是从 NuGet 上随便装一个就完事,得用目标机上注册的类型库生成,而且 32 位和 64 位是两份:
# 64 位进程用,从 System32 下的类型库生成 tlbimp "%SystemRoot%\System32\opcda.dll" /out:Interop.OpcDa64.dll /namespace:Opc.Da tlbimp "%SystemRoot%\System32\opccomn_ps.dll" /out:Interop.OpcCom64.dll /namespace:Opc.Com # 32 位进程用,必须从 SysWOW64 取,生成出来的接口 GUID 一样但编组代理不同 tlbimp "%SystemRoot%\SysWOW64\opcda.dll" /out:Interop.OpcDa32.dll /namespace:Opc.Da这里的逻辑是:opcda.dll是 OPC DA 的代理/桩,类型库描述了 IOPCServer 等接口的 vtable 布局,tlbimp把它翻译成 C# 的接口声明和 RCW 调用。/namespace只是把生成的类型塞进指定命名空间,方便区分。参数上最需要注意的是位数——一个 AnyCPU 的进程在 64 位系统上默认以 64 位运行,去连一个只有 32 位代理的服务器,报的错往往是“类未注册”或者“拒绝访问”,看上去像权限问题,实际是位数问题。
提示:如果项目必须同时支持两种位数,别指望一个互操作程序集通吃,直接把客户端工程拆成 x86 和 x64 两个构建配置,各自引用对应的一份。
2.3 工程骨架:把连接参数从代码里赶出去
不管是 DA 还是 UA,第一件该做的事是把端点、账号、超时、订阅周期这些从代码里挪到配置里,否则测试用例没法参数化,换一台机器就得改代码重编译。下面是一个最小骨架,UA 和 DA 共用一个选项类:
public sealed class OpcConnectionOptions { // "UA" 或 "DA",决定走哪条连接分支 public string Mode { get; set; } = "UA"; // UA 用 opc.tcp://host:4840;DA 用 ProgID,如 VendorSim.OPCServer.1 public string Target { get; set; } = "opc.tcp://127.0.0.1:4840"; // DA 跨机器时填远程主机名,本机留空 public string RemoteHost { get; set; } = ""; public bool UseSecurity { get; set; } = false; public string UserName { get; set; } = ""; public string Password { get; set; } = ""; public int OperationTimeoutMs { get; set; } = 15000; public int SessionTimeoutMs { get; set; } = 60000; public int PublishingIntervalMs { get; set; } = 1000; public int SamplingIntervalMs { get; set; } = 500; }public static class OpcClientFactory { public static IOpcTagClient Create(OpcConnectionOptions opt) { return opt.Mode switch { "UA" => new UaTagClient(opt), "DA" => new DaTagClient(opt), _ => throw new ArgumentException($"未知模式:{opt.Mode}") }; } }OpcConnectionOptions里每个字段都对应一个实际会出问题的点:Target在 UA 下是端点 URL,在 DA 下是 ProgID,两者的字符串规则完全不同,混用是最常见的低级错误;OperationTimeoutMs控制单次调用的等待上限,UA 默认值偏大,测试里建议压到 5 秒,不然一个坏节点能把整轮用例拖住;RemoteHost只在 DA 跨机器时生效,它会被传给Type.GetTypeFromProgID的第二个参数,留空表示本机。
IOpcTagClient这个接口是整套测试的支点,后面所有用例都面向它写,而不是直接面向具体的 Session 或 COM 对象:
public interface IOpcTagClient : IAsyncDisposable { Task ConnectAsync(CancellationToken ct = default); Task<IReadOnlyList<TagSample>> ReadAsync(IReadOnlyList<string> tagIds, CancellationToken ct = default); Task<WriteResult> WriteAsync(string tagId, object value, CancellationToken ct = default); IObservable<TagSample> Subscribe(IReadOnlyList<string> tagIds, TimeSpan interval); ConnectionState State { get; } event EventHandler<ConnectionState> StateChanged; }TagSample里除了值和状态码,还要带上服务器时间戳、源时间戳、客户端收到的时间,这三个时间在后面判断“数据是新的还是缓存的”时是唯一的抓手。接口设计上刻意把读和订阅分开,因为这两条路径在 DA 和 UA 下的行为差异极大,混在一个方法里会让测试结论失去意义。
3. 把值读上来:会话建立、批量读与订阅回调的三种写法
3.1 建立会话:UA 的端点选择与 DA 的组对象
UA 建会话分两步:先发现端点,再创建会话。端点选择决定了安全策略、传输配置和服务器证书,选错了后面全是握手失败。官方 .NET 栈的写法通常是:
var appConfig = new ApplicationConfiguration { ApplicationName = "OpcProbe", ApplicationUri = $"urn:{Utils.GetHostName()}:OpcProbe", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { // 测试环境先自动接受,生产环境必须换成显式信任列表 AutoAcceptUntrustedCertificates = true, ApplicationCertificate = new CertificateIdentifier() }, TransportQuotas = new TransportQuotas { OperationTimeout = opt.OperationTimeoutMs }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opt.SessionTimeoutMs } }; await appConfig.Validate(ApplicationType.Client); // 发现端点:useSecurity=false 时选 None 策略,注意服务器可能禁用它 var endpointDescription = CoreClientUtils.SelectEndpoint(appConfig, opt.Target, useSecurity: opt.UseSecurity); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); var identity = string.IsNullOrEmpty(opt.UserName) ? new UserIdentity() // 匿名 : new UserIdentity(opt.UserName, opt.Password); var session = await Session.Create( appConfig, endpoint, updateBeforeConnect: false, // true 会强制刷新端点缓存 sessionName: "OpcProbe", sessionTimeout: (uint)opt.SessionTimeoutMs, identity: identity, preferredLocales: null);SelectEndpoint内部会做一次 GetEndpoints 调用,返回服务器支持的所有端点,然后按安全策略挑一个。updateBeforeConnect设成 false 是省一次往返,但如果服务器改过端口或证书,就会拿着旧缓存去连,表现是连接超时。sessionTimeout一定要显式给,默认值往往比现场网络的心跳周期短,链路一抖会话就被服务器回收了。
DA 这边没有会话的概念,取而代之的是“服务器对象 + 组对象”。组是刷新周期的载体,同一组里的项共享一个更新速率:
// 通过 ProgID 拿到服务器对象,remoteHost 非空即走 DCOM Type serverType = Type.GetTypeFromProgID(opt.Target, opt.RemoteHost, throwOnError: true); object comObj = Activator.CreateInstance(serverType); var server = (IOPCServer)comObj; // AddGroup 参数:组名、是否激活、请求刷新周期(ms)、客户端组句柄、 // 时区偏移、死区百分比、LCID、返回的服务器组句柄、协商后的周期、组对象 IID、组对象指针 int serverGroupHandle, revisedRate; IntPtr pGroupUnk; Guid iidGroup = typeof(IOPCItemMgt).GUID; server.AddGroup("probe", true, 500, 1, IntPtr.Zero, IntPtr.Zero, 0, out serverGroupHandle, out revisedRate, ref iidGroup, out pGroupUnk); var group = (IOPCItemMgt)Marshal.GetObjectForIUnknown(pGroupUnk);revisedRate是服务器协商后的实际刷新周期,它跟请求值经常不一致——很多服务器有最小周期限制,请求 100ms 可能返回 500ms。测试里如果按请求值去算时延,结论会全错,必须以revisedRate为准。iidGroup传的是IOPCItemMgt的 GUID,意思是“把这个组对象的这个接口给我”,拿到指针后必须用Marshal.GetObjectForIUnknown转成托管接口,这一步会加一次引用计数,后面要配对释放。
3.2 同步读、异步读与订阅:三种方式的边界
DA 的同步读走 IOPCSyncIO,数据源参数有两个取值:OPC_DS_CACHE读服务器缓存,OPC_DS_DEVICE直接读设备。前者快但可能是旧值,后者慢但真实。做链路验证时我一般两种都读一遍,如果缓存有值、设备读超时,说明服务器到设备的这一层断了,问题不在客户端。
// 先定义项:项 ID、访问路径、是否激活、客户端句柄、请求的数据类型 var defs = new OPCITEMDEF[tagIds.Count]; for (int i = 0; i < tagIds.Count; i++) { defs[i].szItemID = tagIds[i]; defs[i].szAccessPath = null; // 多数服务器不用访问路径 defs[i].bActive = true; defs[i].hClient = i + 1; // 回调里靠它对应回标签 defs[i].vtRequestedDataType = 0; // 0 表示不指定,让服务器返回原生类型 } group.AddItems(defs.Length, defs, out IntPtr pResults, out IntPtr pErrors); // pResults 是 OPCITEMRESULT 数组,pErrors 是每项的 HRESULT,必须逐项检查vtRequestedDataType填 0 是最稳的做法,让服务器返回它自己的原生类型。指定成VT_R4之类看起来能省一次转换,实际经常拿到Bad_TypeMismatch,因为服务器内部存的是VT_R8或者VT_BSTR。hClient是客户端自己维护的句柄,订阅回调里只能靠它把值映射回标签名,别用数组下标,项的增删会让下标错位。
UA 的读是批量的,一次请求可以带多个节点,返回的数组顺序与请求一致:
var toRead = new ReadValueIdCollection( tagIds.Select(id => new ReadValueId { NodeId = NodeId.Parse(id), AttributeId = Attributes.Value // 读值属性,读其他属性改这里 })); session.Read(null, 0, TimestampsToReturn.Both, toRead, out DataValueCollection values, out DiagnosticInfoCollection diagnostics); for (int i = 0; i < values.Count; i++) { // StatusCode 是 Good 才代表值可信,Bad 时 Value 可能是 null Console.WriteLine($"{tagIds[i]} => {values[i].Value} [{values[i].StatusCode}] " + $"src={values[i].SourceTimestamp:O} srv={values[i].ServerTimestamp:O}"); }TimestampsToReturn.Both要求服务器同时返回源时间戳和服务器时间戳,这两个值在排查“数据是不是卡住了”时非常关键:源时间戳不变说明设备侧没更新,服务器时间戳在变而源时间戳不变说明服务器在拿旧值应付你。maxAge参数填 0 表示强制从设备读,填uint.MaxValue表示接受任意缓存。
订阅是长跑场景的正解。UA 的订阅有三层参数:发布周期决定服务器多久推一次,采样周期决定服务器多久采一次,队列长度决定两次发布之间能攒多少个值:
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = opt.PublishingIntervalMs, // 推送节奏 KeepAliveCount = 10, // 连续多少次无数据也发心跳 LifetimeCount = 30, // 多少次发布无订阅者则销毁 MaxNotificationsPerPublish = 1000, PublishingEnabled = true }; session.AddSubscription(subscription); await subscription.CreateAsync(); foreach (var id in tagIds) { var item = new MonitoredItem(subscription.DefaultItem) { DisplayName = id, StartNodeId = NodeId.Parse(id), AttributeId = Attributes.Value, SamplingInterval = opt.SamplingIntervalMs, QueueSize = 5, DiscardOldest = true }; item.Notification += (mi, e) => { foreach (var v in mi.DequeueValues()) Console.WriteLine($"{mi.DisplayName} {v.Value} {v.StatusCode} {v.SourceTimestamp:O}"); }; subscription.AddItem(item); } await subscription.ApplyChangesAsync();采样周期应当小于等于发布周期,否则每个发布周期采样不到一次,队列长度形同虚设。队列长度按“发布周期 / 采样周期 + 2”估,DiscardOldest = true保证积压时丢旧值而不是丢新值。LifetimeCount必须大于KeepAliveCount,写成相等的话订阅在某些服务器上会反复重建,日志里能看到周期性的 CreateSubscription 调用。
3.3 写入与类型转换:Variant 那一关
写入的坑比读多一个量级。DA 的写入要求 Variant 的类型与服务器上该项的规范类型兼容,VT_I4写给一个VT_R4的项,有的服务器会自动转,有的直接返回0xC0040003。稳妥做法是先读一次拿到实际类型,再按类型装箱:
var syncIO = (IOPCSyncIO)group; // 组对象同时支持同步读写 int[] handles = { serverHandles[0] }; // AddItems 返回的服务器句柄,不是 hClient var hr = syncIO.Write(handles.Length, handles, new object[] { 12.5f }); // 单项写入失败不会抛异常,必须检查返回的 HRESULT 数组注意写入用的是服务器句柄,读订阅回调里用的是客户端句柄,两套句柄混用是新手最常见的错误,现象是写完没报错但值没变。UA 那边写的是WriteValue,StatusCode 在DataValue里,同样不会抛异常:
var writeValues = new WriteValueCollection { new WriteValue { NodeId = NodeId.Parse(tagId), AttributeId = Attributes.Value, Value = new DataValue(new Variant(12.5f)) } }; session.Write(null, writeValues, out StatusCodeCollection results, out _); if (StatusCode.IsBad(results[0])) throw new InvalidOperationException($"写入被拒:{results[0]}");Variant的构造决定了服务器看到的类型,传12.5是VT_R8,传12.5f才是VT_R4。这一条在对接那些严格校验类型的服务器时,能省掉半天排查。
4. 测试工程化:把手工点按钮变成可回归的 4 类用例
4.1 用例分层:单元、集成、稳定性、性能
手工点按钮验证一次链路,换台机器就得重来一遍。可回归的做法是按依赖程度把用例分成四层:纯逻辑层(状态码映射、类型转换、重连退避计算)不依赖任何服务器,用单元测试跑;协议层(连接、读写、订阅)依赖真实服务器,标记成集成测试,按环境变量决定跑不跑;稳定性层跑小时级,验证重连和订阅恢复;性能层只测时延和吞吐。
public class TagSampleMapperTests { [Theory] [InlineData("0x00000000", "Good")] [InlineData("0x80340000", "Bad")] // Bad_NodeIdUnknown [InlineData("0x80900000", "Bad")] // Bad_DataEncodingInvalid public void StatusCode_Should_Map_To_Level(string raw, string expected) { var code = StatusCode.Parse(raw); Assert.Equal(expected, StatusCode.IsGood(code) ? "Good" : "Bad"); } } [Trait("Category", "Integration")] public class UaReadIntegrationTests : IAsyncLifetime { private IOpcTagClient _client = null!; public async Task InitializeAsync() { var opt = TestConfig.Load(); // 从环境变量读端点,拿不到就 Skip _client = OpcClientFactory.Create(opt); await _client.ConnectAsync(); } [Fact] public async Task Read_Returns_Good_StatusCode() { var samples = await _client.ReadAsync(new[] { TestConfig.KnownTag }); Assert.Single(samples); Assert.True(StatusCode.IsGood(samples[0].StatusCode), samples[0].StatusCode.ToString()); } public async Task DisposeAsync() => await _client.DisposeAsync(); }IAsyncLifetime保证每个测试类只连一次,比每个用例连一次快一个数量级。Trait标记配合dotnet test --filter Category=Integration就能在 CI 上把依赖硬件的用例排除掉,本地再单独跑。集成测试的断言不要只断言“不为空”,要断言状态码是 Good——很多时候连接成功、读调用也成功,返回的却是一串 Bad,只断言非空等于没测。
4.2 断线重连:怎么把网线拔了写成用例
重连是这类客户端里最容易写错的部分。UA 栈自带SessionReconnectHandler,它会监听会话的 KeepAlive 失败事件并自动重建会话和订阅,但它不会把订阅里已有的 MonitoredItem 重新挂回去——这一层要自己补:
var reconnect = new SessionReconnectHandler(); session.KeepAlive += (s, e) => { if (StatusCode.IsBad(e.Status)) Console.WriteLine($"保活失败:{e.Status},开始重连"); }; // 触发重连,参数:会话、是否重新创建、初始等待毫秒 reconnect.BeginReconnect(session, 5000, (s, e) => { // 重连完成回调里 session 已被替换,旧的 subscription 对象全部失效 if (e == null || StatusCode.IsGood(e.Status)) { SubscribeAll(session); // 必须重新建订阅并重新 AddItem Console.WriteLine("重连成功,订阅已重建"); } });BeginReconnect的第二个参数是重连失败后的重试间隔,内部用的是退避策略。真正要写进用例的断言是:断开后状态变成 Disconnected、重连后状态回到 Connected、订阅在 3 个发布周期内重新收到新时间戳的值。模拟断开最省事的办法是把服务器进程停掉再起,或者在虚拟机上把网卡禁用再启用——后者更接近现场,因为 TCP 连接会进入半开状态,客户端要等到 KeepAlive 超时才能发现。
4.3 时延与抖动:别用一次测量的结果下结论
性能测试最容易自欺欺人。一次读 20ms 不代表稳定,要跑至少 1000 次取分位数:
var latencies = new List<double>(2000); for (int i = 0; i < 2000; i++) { var sw = Stopwatch.StartNew(); var r = await _client.ReadAsync(new[] { tagId }); sw.Stop(); // 只统计 Good,Bad 的耗时没有参考价值 if (StatusCode.IsGood(r[0].StatusCode)) latencies.Add(sw.Elapsed.TotalMilliseconds); } latencies.Sort(); double P(double p) => latencies[(int)(latencies.Count * p)]; Console.WriteLine($"P50={P(0.5):F1}ms P95={P(0.95):F1}ms P99={P(0.99):F1}ms Max={latencies[^1]:F1}ms");P50 和 P99 差三倍以上通常意味着网络或者服务器调度有问题,而不是客户端慢。订阅场景下要统计的是到达间隔的抖动,也就是相邻两条通知的时间差与发布周期的偏差,这个指标比单次时延更能反映链路质量。把结果写成 CSV,按时间列画出来,一眼就能看出是持续抖动还是周期性卡顿。
5. 避坑排查:5 条从现场 OPC 链路里抠出来的故障
5.1 DA 在本机能读,换台机器就“拒绝访问”
现象:同一份客户端程序,装在服务器本机跑正常,装到办公机上连就报0x80070005,日志里只有一句“拒绝访问”。
原因:跨机器走 DCOM,默认的身份验证级别是“连接时认证”,且不允许匿名访问,当前登录账号在目标机上没有远程启动和访问权限。很多人第一反应是关防火墙,实际上防火墙只影响端口,权限问题照样报同一个错。
解决:在目标机上打开组件服务,找到对应的 OPC 服务器应用,把“身份验证级别”改成“无”或“连接”,“模拟级别”改成“标识”或“模拟”,然后在“安全”页里给当前账号加上“本地启动”“远程启动”“本地激活”“远程激活”四项权限。改完必须重启服务器进程,配置不会热加载。防火墙侧放行 135 和服务器实际使用的动态端口范围,或者干脆把动态范围固定下来。
5.2 64 位进程连不上 32 位服务器
现象:控制台程序跑得好好的,换成 x64 构建就报“类未注册(0x80040154)”,但 ProgID 明明在注册表里能找到。
原因:OPC DA 的代理/桩按位数分别注册,HKLM\SOFTWARE\Classes和HKLM\SOFTWARE\WOW6432Node\Classes是两套。32 位服务器只在 WOW6432Node 下注册,64 位进程查不到。
解决:先确认服务器进程的位数,再把客户端编译成对应位数。如果必须用 64 位客户端对接 32 位服务器,中间加一层代理,或者换成 DA 转 UA 的网关方案。测试用例里我一般会加一条断言,把当前进程位数和服务器 ProgID 的注册表位置都打出来,出问题时不用再问“你是 32 位还是 64 位”。
5.3 订阅还在,值不更新了
现象:客户端连着,订阅对象没报错,回调也偶尔触发,但值一直停在某个时刻,源时间戳不动。
原因:三种可能。会话被服务器静默回收,客户端以为自己还连着;订阅的LifetimeCount到了上限被服务器销毁;队列溢出后DiscardOldest把新值也丢了,因为队列长度小于一个发布周期内的采样数。
解决:先看session.KeepAlive事件有没有报 Bad,有就是会话问题;没有的话打印订阅的CurrentLifetimeCount和CurrentKeepAliveCount,接近上限就是配置问题;两者都正常,那就是队列长度不够,按“发布周期 / 采样周期 × 2”往上调。这三条我写成了一个自检方法,每次订阅创建后跑一次,日志里落一行参数快照。
5.4 一批项里总有几条 Bad,其他都正常
现象:50 个标签,45 个 Good,5 个报Bad_NodeIdUnknown或Bad_AttributeIdInvalid,但标签名是从服务器导出的,看着一模一样。
原因:节点 ID 的命名空间索引不是固定的,服务器重启或者配置导入后索引可能变化;标签名带前后空格或不可见字符;大小写在部分服务器上是敏感的;从表格复制时把全角字符带进来了。
解决:不要硬编码ns=2,启动时先浏览一次 NamespaceArray 节点,按命名空间 URI 反查索引。标签名统一做Trim()并检查有没有非 ASCII 字符。测试用例里对每个 Bad 项单独输出原始字符串的十六进制,一眼就能看出有没有隐藏字符。
5.5 跑一整夜内存和句柄都在涨
现象:稳定性测试跑 8 小时,进程私有内存从 80MB 涨到 400MB,句柄数从 900 涨到 6000,最终在某次回调里抛“对象已被释放”。
原因:订阅对象和 MonitoredItem 只加不减,断线重连时旧订阅没释放就建新的;DA 那边Marshal.GetObjectForIUnknown拿到的接口没有配对Marshal.Release;Notification事件订阅没有解绑,闭包持有外部对象。
解决:所有订阅走一个管理器,重建前先RemoveSubscription并等一次发布周期;DA 的 COM 对象用try/finally保证释放,IOPCServer、组对象、IOPCSyncIO各自记一次引用计数;事件回调在Dispose里显式-=。修完之后同样的 8 小时测试,内存曲线是平的,句柄数在 1000 附近小幅波动。
6. 进阶自证:用一列时间戳判断这条链路稳不稳
6.1 记录格式:每个采样落一行
链路“通”和链路“稳”是两件事。要证明稳,需要在客户端留一份可回看的记录,每个采样一行,字段固定:
| 字段 | 含义 | 用途 |
|---|---|---|
| tagId | 标签标识 | 定位 |
| value | 值 | 业务 |
| statusCode | 状态码 | 判断可信度 |
| sourceTs | 源时间戳 | 设备侧是否更新 |
| serverTs | 服务器时间戳 | 服务器是否在处理 |
| clientTs | 客户端收到时间 | 端到端时延 |
| seq | 自增序号 | 检查有没有丢包 |
private long _seq; private void Dump(TagSample s) { // 时间统一转 UTC 落盘,展示层再转本地,避免夏令时和时区混用 var line = string.Join(',', s.TagId, s.Value?.ToString() ?? "", s.StatusCode.ToString(), s.SourceTimestamp.ToUniversalTime().ToString("O"), s.ServerTimestamp.ToUniversalTime().ToString("O"), DateTime.UtcNow.ToString("O"), Interlocked.Increment(ref _seq)); _writer.WriteLine(line); }用 UTC 落盘是血泪经验。有一次排查时延,客户端和服务器的时区差了一小时,两边日志一对,怎么算都对不上,白花了半天。从那以后我所有落盘时间一律 UTC,只在界面上转本地。
6.2 三个判据:状态码分布、时间戳差值、序号连续性
拿到这份记录之后,三个判据就能覆盖绝大多数故障。状态码分布看 Good 占比,低于 99% 说明有项在周期性失败。源时间戳与服务器时间戳的差值,正常情况下应该在采样周期的量级,如果服务器时间戳在走而源时间戳不动,问题在服务器到设备那段。客户端时间与服务器时间之差就是端到端时延,P99 超过发布周期两倍就要查网络。
序号的连续性最容易被忽略。订阅模式下,如果seq出现跳号,说明有推送没被处理,要么是回调线程池被占满,要么是队列溢出。这个信号比任何日志都直接。
6.3 把自检跑成一条命令
最后我把这套东西收成一个自检入口:连上之后先读一次 NamespaceArray,再对每个标签读缓存和设备两种源,然后建订阅跑 5 分钟,期间统计状态码、时延分位数和序号连续性,最后输出一份结论。整个流程一条命令跑完,输出里只要出现 Bad 占比超阈值、时延 P99 超发布周期两倍、序号跳号中的任意一条,就判为不通过。这套自检我在每个新现场都强制走一遍,比事后翻日志省太多时间。希望帮到你。
本文还有配套的精品资源,点击获取