更新记录(仅首版,2026年11月01日 发布)
- 首次发布
引用本文(DOI: 10.5281/zenodo.23968383)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
Go Komura(2026)。《OPC UA 入门 ── 用 Windows 应用收集 PLC 与设备的数据》。小村软件有限公司。https://doi.org/10.5281/zenodo.23968383 https://comcomponent.com/zh-CN/blog/opc-ua-windows-data-collection/
- DOI(最新版本)
- 10.5281/zenodo.23968383
- DOI(此版本)
- 10.5281/zenodo.23968384
用 Windows 应用收集 PLC 或设备的数据时,如果设备带有 OPC UA 的服务器,有时就不必为每家厂商单独编写通信程序。不过只是连接并读取值还不够。如果不了解与值一起返回的状态和时间戳、通知变化的订阅、用证书允许连接的方式,以及断开后如何恢复,收集到的数据以后就无法信任。
本文说明 OPC UA 的机制中数据收集所需的部分,并附上用 C# 客户端实际运行的结果。客户端使用 OPC Foundation 的 .NET 库,对端使用模拟的设备服务器。没有在特定的 PLC 或设备上试过。各设备的设置请以该设备的资料确认。
从 GigE Vision 或 USB3 Vision 相机收集图像的内容整理在“Dropped Frames with GigE Vision and USB3 Vision Cameras”中,串口通信设备的内容整理在“串口通信应用的陷阱”中。
OPC UA 与 OPC Classic 的不同
OPC Classic 的规范使用 Windows 的 COM/DCOM,在软件组件之间交换数据。处理过程数据的 OPC DA、处理报警的 OPC A&E、处理历史数据的 OPC HDA,各自定为独立的规范1。
OPC Foundation 在 2008 年发布了 OPC UA(Unified Architecture)。OPC UA 是不依赖特定平台的面向服务的架构,把 Classic 各规范的功能整合在一起处理。OPC Foundation 列举的重新设计理由包括:Microsoft 开始比 COM/DCOM 更重视跨平台的机制,需要在 Microsoft 以外的系统和嵌入式设备上运行,需要跨越防火墙安全地连接等1。
OPC UA 的规范在 IEC 也作为 IEC 62541 系列成为标准2。通信端口方面,IANA 把 4840/TCP 注册为 opcua-tcp3。
flowchart TB
accTitle: OPC Classic 与 OPC UA 的不同
accDescr: 表示 OPC Classic 以 Windows 的 COM 和 DCOM 为基础,分成 DA、A&E、HDA 等独立规范,而 OPC UA 把这些功能整合进一个不依赖平台的规范的图。
C["OPC Classic"] --> D["在 COM/DCOM(Windows)上运行"]
D --> DA["分成 DA、A&E、HDA 的规范"]
DA -->|"整合功能"| U["OPC UA(2008 年)"]
U --> P["不依赖特定平台"]
P --> S["标准化为 IEC 62541"]
图 1: OPC Classic 是 COM/DCOM 上的各个独立规范,OPC UA 把它们整合成一个。
现有设备只有 OPC DA 服务器时,需要使用 COM/DCOM 那一侧的机制。本文只讨论 OPC UA。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 13 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
试用的配置
运行的配置如下。
| 角色 | 使用的东西 |
|---|---|
| 客户端 | C#、.NET 10 SDK 10.0.114、NuGet 包 OPCFoundation.NetStandard.Opc.Ua.Client 2.0.1 |
| 服务器 | 用 Python 3.13 和 asyncua 2.1.0 编写的模拟设备(同一台 PC 的 4840 端口) |
| 操作系统 | Linux(没有试过在 Windows 上运行) |
客户端的库是 OPC Foundation 发布的 .NET 实现。2.0 系列的用法参照同一仓库中 Getting started 的写法4。
flowchart TB
accTitle: 试用的配置
accDescr: 表示 C# 客户端使用 OPC Foundation 的 .NET 库,以加密的连接连到在 4840 端口监听的 asyncua 模拟设备服务器的配置的图。
A["C# 客户端(LineCollector)"] -->|"opc.tcp、SignAndEncrypt"| B["模拟的设备服务器(asyncua)"]
A -.-> L["OPC Foundation 的 .NET 库 2.0.1"]
B -.-> V["Line1 的 4 个变量"]
图 2: 在同一台 PC 内,从 C# 客户端连到模拟的设备服务器。
模拟的设备服务器
我用 asyncua 编写了一个小服务器来代替设备5。在名为 Line1 的对象下,放了温度(Temperature)、生产数(PartCount)、运行中(Running)、设定值(Setpoint)4 个变量。温度每 0.5 秒变化一次,生产数每 2 秒变化一次。安全性准备了不加密(None)与签名并加密(Basic256Sha256 的 SignAndEncrypt)两种。
# 用于确认运行的模拟设备。不能替代生产环境的设备或 PLC。
import asyncio, math, random, socket
from pathlib import Path
from cryptography.x509.oid import ExtendedKeyUsageOID
from asyncua import Server, ua
from asyncua.crypto.cert_gen import setup_self_signed_certificate
HERE = Path(__file__).parent
APP_URI = "urn:komurasoft:demo-plc"
async def main():
key, cert = HERE / "server_key.pem", HERE / "server_cert.der"
await setup_self_signed_certificate(
key, cert, APP_URI, socket.gethostname(),
[ExtendedKeyUsageOID.SERVER_AUTH], {"commonName": "DemoPlcServer"})
server = Server()
await server.init()
server.set_endpoint("opc.tcp://0.0.0.0:4840/demo/")
server.set_server_name("DemoPlcServer")
await server.set_application_uri(APP_URI)
server.set_security_policy([
ua.SecurityPolicyType.NoSecurity,
ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
])
await server.load_certificate(str(cert))
await server.load_private_key(str(key))
ns = await server.register_namespace("urn:komurasoft:demo-plc:line1")
line1 = await server.nodes.objects.add_object(ua.NodeId("Line1", ns), "Line1")
temperature = await line1.add_variable(ua.NodeId("Line1.Temperature", ns), "Temperature", 25.0)
part_count = await line1.add_variable(
ua.NodeId("Line1.PartCount", ns), "PartCount", ua.Variant(0, ua.VariantType.UInt32))
await line1.add_variable(ua.NodeId("Line1.Running", ns), "Running", True)
setpoint = await line1.add_variable(ua.NodeId("Line1.Setpoint", ns), "Setpoint", 30.0)
await setpoint.set_writable()
async with server:
print("listening", flush=True)
t, count = 0, 0
while True:
await asyncio.sleep(0.5)
t += 1
value = 25.0 + 2.0 * math.sin(t / 4) + random.uniform(-0.05, 0.05)
await temperature.write_value(round(value, 2))
if t % 4 == 0:
count += 1
await part_count.write_value(ua.Variant(count, ua.VariantType.UInt32))
asyncio.run(main())
这个服务器不检查连入的客户端的证书就接受。这是测试用的设置,与生产环境设备的行为不同。
C# 客户端
项目文件中加入了以下两个包。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
</ItemGroup>
</Project>
客户端依次进行连接、浏览、读取、写入、订阅。各部分的含义从下一节起依次说明。
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using MonitoredItemOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
string url = args.Length > 0 ? args[0] : "opc.tcp://localhost:4840/demo/";
int notifications = args.Length > 1 ? int.Parse(args[1]) : 6;
const string LineNamespace = "urn:komurasoft:demo-plc:line1";
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services
.AddOpcUa()
.AddClient(options =>
{
options.ApplicationName = "LineCollector";
options.ApplicationUri = "urn:localhost:LineCollector";
options.ProductUri = "urn:komurasoft:LineCollector";
options.PkiRoot = Path.Combine(AppContext.BaseDirectory, "pki");
})
.AddDiscoveryAndConnect(options =>
{
options.DiscoveryUrl = url;
options.SecurityMode = MessageSecurityMode.SignAndEncrypt;
options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
});
using IHost host = builder.Build();
await host.StartAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
CancellationToken ct = timeout.Token;
try
{
var connect = host.Services
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await connect(ct);
await using (session)
{
Console.WriteLine($"接続: {session.ConfiguredEndpoint?.EndpointUrl}");
// 命名空间索引由服务器决定。从 URI 查出后再创建 NodeId
ushort ns = (ushort)session.NamespaceUris.GetIndex(LineNamespace);
var line1 = new NodeId("Line1", ns);
var temperature = new NodeId("Line1.Temperature", ns);
var partCount = new NodeId("Line1.PartCount", ns);
var setpoint = new NodeId("Line1.Setpoint", ns);
// 浏览:列出 Line1 下的变量
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Variable,
};
Console.WriteLine("Line1 の変数:");
foreach (ReferenceDescription child in await browser.BrowseAsync(line1, ct))
{
Console.WriteLine($" {child.DisplayName} {child.NodeId}");
}
// 读取:不只看值,也看状态和两个时间戳
DataValue t = await session.ReadValueAsync(temperature, ct);
Console.WriteLine($"Temperature = {t.WrappedValue} 状態 {t.StatusCode}");
Console.WriteLine($" Source {Hms(t.SourceTimestamp)} / Server {Hms(t.ServerTimestamp)}");
// 读取不存在的节点时,返回的不是值而是 Bad 状态
try
{
await session.ReadValueAsync(new NodeId("Line1.Pressure", ns), ct);
}
catch (ServiceResultException ex)
{
Console.WriteLine($"Line1.Pressure の読み取り: {ex.StatusCode}");
}
// 写入:把 Setpoint 设为 32.5
var write = new WriteValue
{
NodeId = setpoint,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(32.5)),
};
WriteResponse wr = await session.WriteAsync(null, new[] { write }, ct);
Console.WriteLine($"Setpoint の書き込み: {wr.Results[0]}");
// 订阅:请服务器每 500ms 采样一次,并通知变化
Console.WriteLine($"購読({notifications}件まで):");
await foreach (DataValueChange change in session.DefaultStreaming
.SubscribeDataChangesAsync(new[] { temperature, partCount }, options: new MonitoredItemOptions
{
SamplingInterval = TimeSpan.FromMilliseconds(500),
QueueSize = 5,
}, ct: ct)
.TakeAsync(notifications)
.WithCancellation(ct))
{
DataValue v = change.Value;
string name = change.MonitoredItem?.Name.Split("s=")[^1] ?? "?";
Console.WriteLine($" {Hms(v.SourceTimestamp)} {name,-17} {v.WrappedValue,6} {v.StatusCode}");
}
}
}
catch (ServiceResultException ex)
{
Console.Error.WriteLine($"OPC UA エラー {ex.StatusCode}: {ex.Message}");
Environment.ExitCode = 1;
}
finally
{
await host.StopAsync();
}
static string Hms(DateTimeUtc time) => time.ToDateTime().ToString("HH:mm:ss.fff");
第一次运行时没能连接。原因在“用证书允许连接”一节说明。信任服务器的证书之后的输出如下。省略了库的警告日志。
接続: opc.tcp://localhost:4840/demo/
Line1 の変数:
Temperature ns=2;s=Line1.Temperature
PartCount ns=2;s=Line1.PartCount
Running ns=2;s=Line1.Running
Setpoint ns=2;s=Line1.Setpoint
Temperature = 23.09 状態 Good [0x00000000]
Source 13:40:51.302 / Server 13:40:51.302
Line1.Pressure の読み取り: BadNodeIdUnknown [0x80340000]
Setpoint の書き込み: Good [0x00000000]
購読(6件まで):
13:40:51.302 Line1.Temperature 23.09 Good [0x00000000]
13:40:50.801 Line1.PartCount 67 Good [0x00000000]
13:40:51.802 Line1.Temperature 23.05 Good [0x00000000]
13:40:52.304 Line1.Temperature 23.07 Good [0x00000000]
13:40:52.806 Line1.Temperature 23.2 Good [0x00000000]
13:40:52.806 Line1.PartCount 68 Good [0x00000000]
时间为 UTC。
设备的数据表现为地址空间中的节点
OPC UA 的服务器把拥有的数据作为节点的集合公开。这个集合称为地址空间。节点有表示文件夹或设备的对象、拥有值的变量等种类,节点之间通过引用相连。客户端用 Browse 服务沿着引用查找,调查有哪些节点6。
上面的输出中,从 Line1 沿着层级引用找到了 4 个变量。
flowchart TB
accTitle: 模拟设备的地址空间
accDescr: 表示 Objects 文件夹下有 Line1 对象,其下有 Temperature、PartCount、Running、Setpoint 4 个变量的图。
O["Objects"] --> L["Line1(对象)"]
L --> T["Temperature(变量)"]
L --> P["PartCount(变量)"]
L --> R["Running(变量)"]
L --> S["Setpoint(变量、可写入)"]
图 3: 模拟设备的地址空间。客户端用 Browse 沿着引用找到节点。
NodeId 由命名空间与标识符组成
节点用 NodeId 标识。输出中的 ns=2;s=Line1.Temperature 表示命名空间的索引是 2,标识符是字符串 Line1.Temperature。
命名空间的本质是 URI。规范规定在服务中用数值表示命名空间的 URI,该数值对应服务器的 NamespaceArray 中的位置。索引 0 是 OPC UA 的命名空间 http://opcfoundation.org/UA/7。
因此不要把索引的数值写在代码里,而是从 URI 查出索引。上面的代码用 session.NamespaceUris.GetIndex(LineNamespace) 从 URI 求出 2,再由此创建 NodeId。
flowchart TB
accTitle: 从命名空间 URI 创建 NodeId 的流程
accDescr: 表示客户端在服务器的 NamespaceArray 中查找命名空间的 URI 得到索引,再把该索引与标识符组合成 NodeId 的图。
U["命名空间的 URI"] --> A["服务器的 NamespaceArray"]
A --> I["索引(此例中为 2)"]
I --> N["NodeId ns=2;s=Line1.Temperature"]
K["标识符 Line1.Temperature"] --> N
图 4: 命名空间的索引从 URI 查出,再与标识符组合成 NodeId。
读取和写入时,值会与状态和时间戳一起返回
变量的值通过 Read 和 Write 服务读写8。读取时返回的不只是值。以 DataValue 这个结构返回值、状态码、源时间戳和服务器时间戳9。
| 项目 | 含义 |
|---|---|
| 值 | 变量的值。状态码表示错误时忽略值,服务器会设为 null |
| 状态码(StatusCode) | 服务器是否能获取、提供该值 |
| SourceTimestamp | 数据的来源为值打上的时间 |
| ServerTimestamp | 服务器收到值,或确认值正确的时间 |
状态码的高 2 位表示 Good(成功)、Uncertain(部分成功,视用途可能无法使用)、Bad(失败)10。输出中的 Good [0x00000000] 是 Good。
读取不存在的 Line1.Pressure 时,返回的不是值,而是 BadNodeIdUnknown [0x80340000]。这个库的 ReadValueAsync 在状态为 Bad 时会抛出异常4。在记录值的机制中,把状态码与值并列保存,以后就能区分在 Bad 期间记录的值。
flowchart TB
accTitle: DataValue 的内容
accDescr: 表示读取或通知返回的 DataValue 拥有值、状态码、源时间戳、服务器时间戳 4 项,状态码为 Bad 时忽略值的图。
D["DataValue"] --> V["值"]
D --> S["状态码"]
D --> TS["SourceTimestamp(来源的时间)"]
D --> TV["ServerTimestamp(服务器的时间)"]
S -->|"若为 Bad"| X["忽略值"]
图 5: 读取的结果不只有值,还有状态码和两个时间戳。
写入时向 Setpoint 写入 32.5,结果的状态码返回 Good。变量是否可写入由服务器决定。这个模拟服务器只让 Setpoint 可写入。是否允许从外部改写设备的设定值,涉及设备的安全设计,不在本文的范围内。
让服务器通知变化:订阅与 MonitoredItem
跟踪值的变化时,也可以由客户端按固定间隔反复 Read。OPC UA 另外还有把值的监视交给服务器、让它通知变化的机制。那就是订阅(Subscription)与 MonitoredItem。
MonitoredItem 是监视的一个对象,带有采样间隔。采样间隔是服务器从来源采样值的最快间隔,规范中称为“尽力而为(best effort)”的周期11。样本满足过滤条件时就会生成通知。如果不指定过滤器,则在值或状态变化时通知12。
通知会先放进 MonitoredItem 的队列。要事先决定队列溢出时丢弃旧的还是新的,队列大小为 2 以上时,状态码中会置上表示已丢弃的 Overflow 位13。
订阅把多个 MonitoredItem 归为一组,每个发布间隔把尚未发送的通知发给客户端。没有通知可发的周期持续时,会发送表示订阅仍然存活的保活(keep-alive)14。
flowchart TB
accTitle: 订阅与 MonitoredItem 的流程
accDescr: 表示 MonitoredItem 按采样间隔采样来源的值,把通过过滤的通知放进队列,订阅按发布间隔汇总后发给客户端,没有通知时发送保活的图。
S["来源(设备的值)"] --> M["MonitoredItem"]
M -->|"采样间隔"| F["过滤器(默认为值或状态的变化)"]
F --> Q["队列(溢出时 Overflow)"]
Q --> SUB["订阅"]
SUB -->|"发布间隔"| C["客户端"]
SUB -.->|"没有通知时"| K["保活"]
图 6: MonitoredItem 采样后存入队列,订阅汇总后发给客户端。
上面的代码以采样间隔 500 毫秒、队列大小 5 订阅了 Temperature 和 PartCount15。输出中,温度大约每 0.5 秒、生产数大约每 2 秒到达一次。最初的 2 条是开始订阅时各变量所持有的值。生产数的 13:40:50.801 是开始订阅之前值变化的时间。通知的时间显示的是源时间戳,所以显示的是值变化的时间,而不是收到通知的时间。
反复读取与订阅的区分使用
反复读取的方法可以由客户端决定间隔,机制也简单。相对地,即使值没有变化,每次都会往返请求和响应,比间隔更短的变化也会漏掉。订阅由服务器采样、只发送变化,因此即使是变化少的值,通信也不容易增加。不过即使是订阅,比采样间隔更短的变化也可能不会被通知。规范允许比指定更快地采样,但并不要求11。
flowchart TB
accTitle: 反复读取与订阅的不同
accDescr: 表示反复读取的方法由客户端决定间隔并每次请求,订阅由服务器按采样间隔采样并只发送变化,两者都可能取不到比间隔更短的变化的图。
A["跟踪值的变化"] --> R["反复读取"]
A --> S["订阅"]
R --> R1["客户端决定间隔、每次请求"]
S --> S1["服务器采样、只发送变化"]
R1 --> X["可能取不到比间隔更短的变化"]
S1 --> X
图 7: 无论哪种方法,都可能取不到比间隔更短的变化。
用证书允许连接
OPC UA 的连接用 MessageSecurityMode 选择消息保护的程度。取值有不保护的 None、只签名的 Sign、签名并加密的 SignAndEncrypt16。签名和加密使用的算法组合由安全策略决定,本文使用的是 Basic256Sha256。
签名和加密使用每个应用各自的证书。客户端和服务器互相确认对方的证书,拒绝与不信任的对象连接。用 OPC Foundation 的 .NET 库编写的服务器,默认不提供不加密的 None 策略4。
第一次连接停在 BadCertificateUntrusted
第一次运行时,客户端不信任服务器的证书,所以连接被拒绝。以下是输出的一部分。
fail: Opc.Ua.CertificateValidationCore[91]
Certificate [Subject=CN=DemoPlcServer, CN=urn:komurasoft:demo-plc, Thumbprint=4E236DDA629FE2AB32D185BF681609FD6C525363, NotBefore=2026-11-01, NotAfter=2027-11-01, KeyAlgorithm=1.2.840.113549.1.1.1] validation failed with suppressible errors but was rejected. Reason=Id: BadCertificateUntrusted
...
OPC UA エラー BadCertificateUntrusted [0x801A0000]: [801A0000] (BadCertificateUntrusted) 'Could not verify security on OpenSecureChannel response.'
这个库会把拒绝的证书保存到 rejected 存储。管理员把它从那里移到 trusted 存储后,就会信任该对象17。这次把位于 pki/rejected/certs 的服务器证书移到 pki/trusted/certs,再运行一次就能连接了。移动之前,要与服务器的管理员确认文件名中也包含的指纹4。
flowchart TB
accTitle: 到信任证书为止的流程
accDescr: 表示第一次连接时客户端拒绝服务器的证书并保存到 rejected 存储,确认指纹后移到 trusted 存储,下次连接时就会信任并能连接的图。
A["第一次连接"] --> B["BadCertificateUntrusted"]
B --> C["把证书保存到 rejected 存储"]
C --> D["与服务器的管理员确认指纹"]
D --> E["移到 trusted 存储"]
E --> F["下次连接时信任并能连接"]
图 8: 确认被拒绝的证书后移到 trusted,就会信任该对象。
还有一个自动接受不受信任证书的设置 AutoAcceptUntrustedCertificates。默认值是 false,库的资料写着它在开发环境中很有用,但在生产环境中不要使用17。
日志中的 NotAfter=2027-11-01 是模拟服务器证书的有效期限。这是用 asyncua 的函数生成的自签名证书,期限是生成之日起 1 年后。设备的证书也有期限,所以要在运维中规定好在期限到期前更换、并让对方信任的步骤。Windows 证书存储的处理整理在“Windows 证书存储实务指南”中。
用户的认证
用证书确认的是应用之间。除此之外,打开会话时还要传递表示用户的令牌。规范中有匿名、用户名和密码、X.509 证书、外部签发的令牌 4 种18。这次的客户端以匿名方式连接。服务器接受哪些令牌,包含在服务器的端点信息中19。
现场数据收集中容易出问题的地方
下面根据这次的实验与规范,列出收集设备数据的机制中容易发生的问题。
服务器停止期间的值不会送达
在订阅过程中停止服务器,约 15 秒后重新启动。这个库的 ManagedSession 在连接断开时会自动重新连接20。停止期间,客户端一直在反复重新连接。
warn: Opc.Ua.ChannelManager[6]
Reconnect failed. Endpoint=opc.tcp://localhost:4840/demo/, Attempt=3, Outcome=transient-failure, Status=BadNotConnected, ErrorMessage=Could not connect to the remote endpoint.
服务器恢复后,通知也恢复了。以下是订阅输出的一部分。
13:41:13.364 Line1.Temperature 25.61 Good [0x00000000]
13:41:13.865 Line1.Temperature 25.12 Good [0x00000000]
13:41:34.285 Line1.Temperature 26.55 Good [0x00000000]
13:41:33.784 Line1.PartCount 2 Good [0x00000000]
13:41:34.786 Line1.Temperature 26.16 Good [0x00000000]
13 点 41 分 13 秒到 34 秒之间的值一条也没有送达。生产数从 78 回到了 2。这是因为重新启动模拟服务器后,数量从 0 重新开始计数。
这个例子中服务器本身停止了,所以那段期间的值没有留在任何地方。重新连接的机制会恢复连接,但不会事后补上停止期间的值。
只有通信中断、服务器仍在运行的情况就不同了。规范中有 Republish 服务,可以让服务器从重发用的队列再次发送没送达的通知消息21。MonitoredItem 的队列中也会累积通知,但超过队列大小的部分会被丢弃13。这次的实验没有试过这种情况。无论哪种情况,都要把断开与重新连接的时间记录下来,以便知道哪个时间段没有收到值。串口通信设备的重新连接与日志设计,在“串口通信应用的陷阱”中也有说明。
flowchart TB
accTitle: 停止服务器时的记录
accDescr: 表示订阅中服务器停止时,客户端会反复重新连接,服务器恢复后通知也会恢复,但停止期间的值不会送达,所以要把断开与重新连接的时间记录下来的图。
A["订阅中"] --> B["服务器停止"]
B --> C["客户端反复重新连接"]
C --> D["服务器恢复、通知也恢复"]
B -.-> E["停止期间的值不会送达"]
E -.-> F["记录断开与重新连接的时间"]
图 9: 重新连接会恢复连接,但停止期间的值不会送达。
记录哪一个时间戳
规范中,SourceTimestamp 是数据的来源为值打上的时间,应当表示值变化的时间。ServerTimestamp 是服务器收到值,或确认值正确的时间9。这次的模拟服务器自己生成值,所以两个时间相同。
想知道设备上值变化的时间时,使用 SourceTimestamp。不过该时间是用来源的时钟打上的,所以设备或服务器的时钟有偏差时,记录也会有偏差。如果要把收集到的数据与其他记录对照,把接收端 PC 的时间也并列保存,以后就能确认偏差。Windows 端的时间同步整理在“Windows的时刻同步(w32time)与业务系统”中。
不要把状态不是 Good 的值直接当作数值处理
状态码为 Bad 时,值应当被忽略9。Uncertain 时,视用途可能是无法使用的值10。放进图表或统计之前先按状态码分类,并事先决定如何处理不是 Good 的值。队列溢出时的 Overflow 位也会置在状态码中13。设备的状态如何在画面上显示,在“外部设备状态检查与显示的最佳实践”中说明。
在防火墙中开放端口
IANA 注册的 OPC UA 端口是 4840/TCP3。不过并不是所有服务器都使用这个号码。OPC Foundation 的 .NET 示例使用 62541 等端口4。用设备的端点 URL 确认端口号,允许通往该端口的连接。服务器在 Windows 上运行时在该 Windows 防火墙中,中间有网络设备时在该设备上允许。入站规则的创建方法整理在“Windows 防火墙与业务应用”中。
flowchart TB
accTitle: 数据收集中容易出问题的地方
accDescr: 把设备数据收集中容易出问题的地方,分成记录的内容(服务器停止期间的值不会送达、记录哪一个时间戳、不是 Good 的值的处理)与连接的准备(防火墙的端口、证书的期限)来表示的图。
A["数据收集中容易出问题的地方"] --> R["记录的内容"]
A --> N["连接的准备"]
R --> B["停止期间的值不会送达"]
B ~~~ C["用哪一个时间戳"]
C ~~~ D["不是 Good 的值的处理"]
N --> E["防火墙的端口"]
E ~~~ F["证书的期限"]
图 10: 连接成功之后,也要确认记录的缺失、时间、状态、端口与证书。
与 Modbus/TCP 或厂商专有通信的区分使用
与 PLC 或设备的通信,除了 OPC UA 之外,还有 Modbus/TCP 或 PLC 厂商提供的通信。
Modbus 的规范用 4 个表处理数据。只读的 1 位 Discrete Input、可读写的 1 位 Coil、只读的 16 位 Input Register、可读写的 16 位 Holding Register22。IANA 把 502/TCP 注册为 Modbus 的应用协议(mbap)3。
这些表中放的是位和 16 位的字,所以哪个地址以什么格式放了什么值,要用设备的资料确认后写在客户端一侧。OPC UA 则如前面所见,服务器把带名称的变量公开在地址空间中,客户端可以用 Browse 调查。值带有状态码和时间戳,连接则有用证书进行的允许。
用哪一种,多半取决于设备提供了什么。设备带有 OPC UA 的服务器就用它,只有 Modbus/TCP 就制作地址对照表来读取。只有厂商的通信或 SDK 时,就按照其资料。与 USB 设备通信方式的选择整理在“在 Windows 应用中处理 USB 设备的方法”中。
flowchart TB
accTitle: 与设备通信的选择方式
accDescr: 表示设备带有 OPC UA 的服务器时用 OPC UA 浏览变量并收集,只有 Modbus/TCP 时用资料确认地址与格式并读取寄存器,只有厂商的通信时按照其资料的图。
A["设备提供的通信"] --> B{"有什么?"}
B -->|"OPC UA 的服务器"| C["浏览变量并收集"]
B -->|"只有 Modbus/TCP"| D["用资料确认地址与格式"]
B -->|"只有厂商的通信"| E["按照厂商的资料与 SDK"]
D --> F["读取寄存器并转换成值"]
图 11: 用什么收集,多半取决于设备提供了什么。
相关文章
- Dropped Frames with GigE Vision and USB3 Vision Cameras
- 串口通信应用的陷阱 - 涵盖重连与日志设计
- 外部设备状态检查与显示的最佳实践
- 在 Windows 应用中处理 USB 设备的方法
- Windows 防火墙与业务应用
- Windows 证书存储实务指南
- Windows的时刻同步(w32time)与业务系统
参考链接
-
OPC Foundation, Classic. OPC Classic 基于 COM/DCOM、DA、A&E、HDA 各规范,以及 2008 年 OPC UA 的发布与重新设计的理由。 ↩ ↩2
-
IEC, IEC TR 62541-1:2020 OPC Unified Architecture - Part 1: Overview and concepts. 讨论 OPC UA 概念与概要的 IEC 62541 系列第 1 部分(已被后续版本取代)。 ↩
-
IANA, Service Name and Transport Protocol Port Number Registry. 4840/TCP 的
opcua-tcp与 502/TCP 的mbap的注册。 ↩ ↩2 ↩3 -
OPC Foundation, UA-.NETStandard 2.0.1: Getting started. 2.0 系列客户端的写法、
ReadValueAsync遇到 Bad 时抛出异常、拒绝与信任的存储、服务器默认不提供None,以及示例的端口号。 ↩ ↩2 ↩3 ↩4 ↩5 -
FreeOpcUa, asyncua(PyPI). 用于模拟设备服务器的 Python OPC UA 库。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.9.2 Browse. 沿着节点引用查找的服务。 ↩
-
OPC Foundation, OPC 10000-3 v1.05, 8.2.2 NamespaceIndex. 在服务中用数值表示命名空间的 URI,该数值对应 NamespaceArray 中的位置。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.11 Attribute Service Set. Read 与 Write 服务。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.11 DataValue. 值、状态码、SourceTimestamp、ServerTimestamp 的定义。 ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 7.38.1 StatusCode General. 高 2 位表示的 Good、Uncertain、Bad。 ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.2 Sampling interval. 采样间隔是尽力而为的周期。 ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 7.22.2 DataChangeFilter. 没有过滤器时按值或状态的变化通知。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.5 Queue parameters. 队列大小、丢弃方式、Overflow 位。 ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 5.14.1.1 Subscription model. 发布间隔与保活。 ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Subscriptions and Monitored Items Service Set.
SubscribeDataChangesAsync与MonitoredItemOptions的采样间隔、队列大小。 ↩ -
OPC Foundation, OPC 10000-4 v1.05, 7.20 MessageSecurityMode. None、Sign、SignAndEncrypt 的含义。 ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Certificates. 从 rejected 存储移到受信任存储的运维方式,以及不要在生产环境中使用
AutoAcceptUntrustedCertificates。 ↩ ↩2 -
OPC Foundation, OPC 10000-4 v1.05, 7.40 UserIdentityToken parameters. 匿名、用户名、X.509、已签发令牌 4 种。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.14 EndpointDescription. 端点接受的用户令牌列表(userIdentityTokens)。 ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Sessions, Reconnection, and Subscription Engines.
ManagedSession内置重新连接的机制。 ↩ -
OPC Foundation, OPC 10000-4 v1.05, 5.14.6.1 Republish. 让服务器从订阅的重发用队列再次发送通知消息的服务。 ↩
-
Modbus-IDA, MODBUS Application Protocol Specification V1.1b(放在芝加哥大学服务器上的副本). 4.3 节的数据模型(Discrete Input、Coil、Input Register、Holding Register)。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
在 C# 与 PowerShell 中使用 WMI/CIM——硬件信息获取、进程监控与远程查询实务指南
获取电脑序列号、监控磁盘剩余空间、检测进程启动,这些需求的经典答案就是 WMI/CIM。本文讲解 Get-CimInstance 等 CIM cmdlet 的用法与从旧版 Get-WmiObject 的迁移、C# 中 System.Management 与 CIM API ...
Windows I/O 底层原理(第 4 篇)——缓存管理器:你的 WriteFile 何时才真正写入磁盘
本文是图解讲解 Windows 缓存管理器的系列第 4 篇。梳理以文件映射方式实现的缓存、预读与延迟写入、FlushFileBuffers 与 FILE_FLAG_NO_BUFFERING 的取舍,以及断电导致数据丢失的条件。
Windows I/O 底层原理(第 3 篇)——I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室
本文是图解讲解 I/O 完成端口(IOCP)的系列第 3 篇。梳理把完成队列与线程数控制融为一体的设计、并发值与 LIFO 释放,以及 .NET 线程池和 async/await 续体的执行线程。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
要开发从设备收集数据的 Windows 应用,并包括订阅的设置、重新连接、时间戳与状态的记录时,可以作为 Windows 应用开发来处理。
技术咨询 & 设计评审
想决定是用 OPC UA 收集,还是使用 Modbus/TCP 或厂商的通信,以及证书如何管理时,可以作为技术咨询与设计评审来处理。
常见问题
汇总了咨询这一主题时常见的问题。
- OPC UA 与 OPC Classic(OPC DA)有什么不同?
- OPC Classic 是通过 Windows 的 COM/DCOM 交换数据的规范。OPC UA 是 2008 年发布、不依赖特定操作系统的规范,把 Classic 的功能整合在一起处理。OPC UA 的客户端在 Windows 以外也能运行,也不需要 DCOM 的设置。
- 用 OPC UA 收集值时,按固定间隔读取就可以了吗?
- 值的数量少、间隔长的话,用读取也能收集。如果要跟踪值的变化,可以用订阅把采样交给服务器,让它通知变化。无论哪种方式,都要把与值一起返回的状态码和时间戳记录下来。
- 连接时为什么会出现 BadCertificateUntrusted?
- 因为客户端还没有信任服务器的证书。OPC Foundation 的 .NET 库会把拒绝的证书保存到 rejected 存储。先与服务器的管理员确认证书的指纹,再移到 trusted 存储。自动接受证书的设置仅限于开发环境。
- OPC UA 的端口号是多少?
- IANA 把 4840/TCP 注册为 opcua-tcp。不过实际的服务器也可能使用其他端口,所以要在设备的设置中确认。服务器在 Windows 上运行时要在 Windows 防火墙中,中间有网络设备时要在该设备上,允许通往该端口的通信。