OPC UA 入门 ── 用 Windows 应用收集 PLC 与设备的数据

· · Windows, OPC UA, 制造业, PLC, C#, .NET, 通信, 证书

分享到 X 分享到微博 分享到 QQ
更新记录(仅首版,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。

OPC Classic 与 OPC UA 的不同表示 OPC Classic 以 Windows 的 COM 和 DCOM 为基础,分成 DA、A&E、HDA 等独立规范,而 OPC UA 把这些功能整合进一个不依赖平台的规范的图。整合功能OPC Classic在 COM/DCOM(Windows)上运行分成 DA、A&E、HDA 的规范OPC UA(2008 年)不依赖特定平台标准化为 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。

试用的配置表示 C# 客户端使用 OPC Foundation 的 .NET 库,以加密的连接连到在 4840 端口监听的 asyncua 模拟设备服务器的配置的图。opc.tcp、SignAndEncryptC# 客户端(LineCollector)模拟的设备服务器(asyncua)OPC Foundation 的 .NET 库 2.0.1Line1 的 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 个变量。

模拟设备的地址空间表示 Objects 文件夹下有 Line1 对象,其下有 Temperature、PartCount、Running、Setpoint 4 个变量的图。ObjectsLine1(对象)Temperature(变量)PartCount(变量)Running(变量)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。

从命名空间 URI 创建 NodeId 的流程表示客户端在服务器的 NamespaceArray 中查找命名空间的 URI 得到索引,再把该索引与标识符组合成 NodeId 的图。命名空间的 URI服务器的 NamespaceArray索引(此例中为 2)NodeId ns=2;s=Line1.Temperature标识符 Line1.Temperature

图 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 期间记录的值。

DataValue 的内容表示读取或通知返回的 DataValue 拥有值、状态码、源时间戳、服务器时间戳 4 项,状态码为 Bad 时忽略值的图。若为 BadDataValue值状态码SourceTimestamp(来源的时间)ServerTimestamp(服务器的时间)忽略值

图 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。

订阅与 MonitoredItem 的流程表示 MonitoredItem 按采样间隔采样来源的值,把通过过滤的通知放进队列,订阅按发布间隔汇总后发给客户端,没有通知时发送保活的图。采样间隔发布间隔没有通知时来源(设备的值)MonitoredItem过滤器(默认为值或状态的变化)队列(溢出时 Overflow)订阅客户端保活

图 6: MonitoredItem 采样后存入队列,订阅汇总后发给客户端。

上面的代码以采样间隔 500 毫秒、队列大小 5 订阅了 Temperature 和 PartCount15。输出中,温度大约每 0.5 秒、生产数大约每 2 秒到达一次。最初的 2 条是开始订阅时各变量所持有的值。生产数的 13:40:50.801 是开始订阅之前值变化的时间。通知的时间显示的是源时间戳,所以显示的是值变化的时间,而不是收到通知的时间。

反复读取与订阅的区分使用

反复读取的方法可以由客户端决定间隔,机制也简单。相对地,即使值没有变化,每次都会往返请求和响应,比间隔更短的变化也会漏掉。订阅由服务器采样、只发送变化,因此即使是变化少的值,通信也不容易增加。不过即使是订阅,比采样间隔更短的变化也可能不会被通知。规范允许比指定更快地采样,但并不要求11。

反复读取与订阅的不同表示反复读取的方法由客户端决定间隔并每次请求,订阅由服务器按采样间隔采样并只发送变化,两者都可能取不到比间隔更短的变化的图。跟踪值的变化反复读取订阅客户端决定间隔、每次请求服务器采样、只发送变化可能取不到比间隔更短的变化

图 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。

到信任证书为止的流程表示第一次连接时客户端拒绝服务器的证书并保存到 rejected 存储,确认指纹后移到 trusted 存储,下次连接时就会信任并能连接的图。第一次连接BadCertificateUntrusted把证书保存到 rejected 存储与服务器的管理员确认指纹移到 trusted 存储下次连接时信任并能连接

图 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。这次的实验没有试过这种情况。无论哪种情况,都要把断开与重新连接的时间记录下来,以便知道哪个时间段没有收到值。串口通信设备的重新连接与日志设计,在“串口通信应用的陷阱”中也有说明。

停止服务器时的记录表示订阅中服务器停止时,客户端会反复重新连接,服务器恢复后通知也会恢复,但停止期间的值不会送达,所以要把断开与重新连接的时间记录下来的图。订阅中服务器停止客户端反复重新连接服务器恢复、通知也恢复停止期间的值不会送达记录断开与重新连接的时间

图 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 防火墙与业务应用”中。

数据收集中容易出问题的地方把设备数据收集中容易出问题的地方,分成记录的内容(服务器停止期间的值不会送达、记录哪一个时间戳、不是 Good 的值的处理)与连接的准备(防火墙的端口、证书的期限)来表示的图。数据收集中容易出问题的地方记录的内容连接的准备停止期间的值不会送达用哪一个时间戳不是 Good 的值的处理防火墙的端口证书的期限

图 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 设备的方法”中。

与设备通信的选择方式表示设备带有 OPC UA 的服务器时用 OPC UA 浏览变量并收集,只有 Modbus/TCP 时用资料确认地址与格式并读取寄存器,只有厂商的通信时按照其资料的图。OPC UA 的服务器只有 Modbus/TCP只有厂商的通信设备提供的通信有什么?浏览变量并收集用资料确认地址与格式按照厂商的资料与 SDK读取寄存器并转换成值

图 11: 用什么收集,多半取决于设备提供了什么。

相关文章

参考链接

  1. OPC Foundation, Classic. OPC Classic 基于 COM/DCOM、DA、A&E、HDA 各规范,以及 2008 年 OPC UA 的发布与重新设计的理由。 ↩ ↩2

  2. IEC, IEC TR 62541-1:2020 OPC Unified Architecture - Part 1: Overview and concepts. 讨论 OPC UA 概念与概要的 IEC 62541 系列第 1 部分(已被后续版本取代)。 ↩

  3. IANA, Service Name and Transport Protocol Port Number Registry. 4840/TCP 的 opcua-tcp 与 502/TCP 的 mbap 的注册。 ↩ ↩2 ↩3

  4. OPC Foundation, UA-.NETStandard 2.0.1: Getting started. 2.0 系列客户端的写法、ReadValueAsync 遇到 Bad 时抛出异常、拒绝与信任的存储、服务器默认不提供 None,以及示例的端口号。 ↩ ↩2 ↩3 ↩4 ↩5

  5. FreeOpcUa, asyncua(PyPI). 用于模拟设备服务器的 Python OPC UA 库。 ↩

  6. OPC Foundation, OPC 10000-4 v1.05, 5.9.2 Browse. 沿着节点引用查找的服务。 ↩

  7. OPC Foundation, OPC 10000-3 v1.05, 8.2.2 NamespaceIndex. 在服务中用数值表示命名空间的 URI,该数值对应 NamespaceArray 中的位置。 ↩

  8. OPC Foundation, OPC 10000-4 v1.05, 5.11 Attribute Service Set. Read 与 Write 服务。 ↩

  9. OPC Foundation, OPC 10000-4 v1.05, 7.11 DataValue. 值、状态码、SourceTimestamp、ServerTimestamp 的定义。 ↩ ↩2 ↩3

  10. OPC Foundation, OPC 10000-4 v1.05, 7.38.1 StatusCode General. 高 2 位表示的 Good、Uncertain、Bad。 ↩ ↩2

  11. OPC Foundation, OPC 10000-4 v1.05, 5.13.1.2 Sampling interval. 采样间隔是尽力而为的周期。 ↩ ↩2

  12. OPC Foundation, OPC 10000-4 v1.05, 7.22.2 DataChangeFilter. 没有过滤器时按值或状态的变化通知。 ↩

  13. OPC Foundation, OPC 10000-4 v1.05, 5.13.1.5 Queue parameters. 队列大小、丢弃方式、Overflow 位。 ↩ ↩2 ↩3

  14. OPC Foundation, OPC 10000-4 v1.05, 5.14.1.1 Subscription model. 发布间隔与保活。 ↩

  15. OPC Foundation, UA-.NETStandard 2.0.1: Subscriptions and Monitored Items Service Set. SubscribeDataChangesAsync 与 MonitoredItemOptions 的采样间隔、队列大小。 ↩

  16. OPC Foundation, OPC 10000-4 v1.05, 7.20 MessageSecurityMode. None、Sign、SignAndEncrypt 的含义。 ↩

  17. OPC Foundation, UA-.NETStandard 2.0.1: Certificates. 从 rejected 存储移到受信任存储的运维方式,以及不要在生产环境中使用 AutoAcceptUntrustedCertificates。 ↩ ↩2

  18. OPC Foundation, OPC 10000-4 v1.05, 7.40 UserIdentityToken parameters. 匿名、用户名、X.509、已签发令牌 4 种。 ↩

  19. OPC Foundation, OPC 10000-4 v1.05, 7.14 EndpointDescription. 端点接受的用户令牌列表(userIdentityTokens)。 ↩

  20. OPC Foundation, UA-.NETStandard 2.0.1: Sessions, Reconnection, and Subscription Engines. ManagedSession 内置重新连接的机制。 ↩

  21. OPC Foundation, OPC 10000-4 v1.05, 5.14.6.1 Republish. 让服务器从订阅的重发用队列再次发送通知消息的服务。 ↩

  22. Modbus-IDA, MODBUS Application Protocol Specification V1.1b(放在芝加哥大学服务器上的副本). 4.3 节的数据模型(Discrete Input、Coil、Input Register、Holding Register)。 ↩

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

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 防火墙中,中间有网络设备时要在该设备上,允许通往该端口的通信。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长保留有既有资产的项目,以及原因难以看清的故障调查。

返回博客列表