콘텐츠로 이동
무료 평가 시작

.NET

  • PDComm.OpcUa 어셈블리로 서버와 클라이언트를 한 콘솔 응용 프로그램에서 실행합니다.
  • 설치한 SDK의 어셈블리와 네이티브 라이브러리로 예제를 빌드합니다.
  • 리소스 해제와 예외 처리 방식을 확인합니다.
  • PDComm OPC UA SDK dotnet 패키지(pdcomm-opcua-dotnet-darwin-arm64.tar.gz)
  • .NET 8 런타임과 .NET SDK(.NET 8 이상). 이 페이지는 .NET SDK 10.0.401과 .NET 8 런타임으로 실행했습니다.
  • PKI와 신뢰 설정에서 만든 $PKI의 인증서. 터미널 두 개.

dotnet 패키지에는 다음이 들어 있습니다.

경로 내용
lib/dotnet/PDComm.OpcUa.dll .NET 어셈블리
bindings/dotnet/PDComm.OpcUa/ 어셈블리의 C# 소스
lib/ 네이티브 라이브러리 libpdcomm_opcua_ffi.dylib와 OpenSSL 3 런타임
examples/opcua/dotnet/ 예제 프로젝트

실습 폴더에서 패키지를 풀고 예제 프로젝트를 작업 폴더로 복사합니다. 패키지 안의 파일은 고치지 않습니다.

터미널 창
tar -xzf pdcomm-opcua-dotnet-darwin-arm64.tar.gz
export OPCUA_DOTNET_SDK="$PWD/pdcomm-opcua-dotnet-darwin-arm64"
cp -R "$OPCUA_DOTNET_SDK/examples/opcua/dotnet" my-opcua-app
cd my-opcua-app
dotnet build OpcUaExample.csproj -c Release -p:PdcommOpcUaSdk="$OPCUA_DOTNET_SDK" -o out
Build succeeded.
0 Warning(s)
0 Error(s)

빌드하면 어셈블리, 네이티브 라이브러리, OpenSSL 런타임이 out에 함께 복사됩니다.

터미널 창
ls out
OpcUaExample
OpcUaExample.deps.json
OpcUaExample.dll
OpcUaExample.pdb
OpcUaExample.runtimeconfig.json
PDComm.OpcUa.dll
libcrypto.3.dylib
libpdcomm_opcua_ffi.dylib
libssl.3.dylib

프로젝트 파일은 PdcommOpcUaSdk 속성으로 SDK 위치를 받습니다.

examples/opcua/dotnet/OpcUaExample.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Default: the SDK root that contains examples/opcua/dotnet. -->
<PdcommOpcUaSdk Condition="'$(PdcommOpcUaSdk)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../..'))</PdcommOpcUaSdk>
</PropertyGroup>
<ItemGroup>
<Reference Include="PDComm.OpcUa">
<HintPath>$(PdcommOpcUaSdk)/lib/dotnet/PDComm.OpcUa.dll</HintPath>
</Reference>
<!-- The native SDK library and its OpenSSL runtime are loaded from the application directory. -->
<None Include="$(PdcommOpcUaSdk)/lib/*.dylib;$(PdcommOpcUaSdk)/lib/*.so*;$(PdcommOpcUaSdk)/bin/*.dll"
CopyToOutputDirectory="PreserveNewest" Visible="false" />
</ItemGroup>
<Target Name="ValidatePdcommOpcUaSdk" BeforeTargets="BeforeBuild">
<Error Condition="!Exists('$(PdcommOpcUaSdk)/lib/dotnet/PDComm.OpcUa.dll')"
Text="Set PdcommOpcUaSdk to the extracted PDComm OPC UA SDK root." />
</Target>
</Project>
examples/opcua/dotnet/Program.cs
// PDComm OPC UA .NET example: a secure server and a client in one console app.
// server BIND_ADDRESS ENDPOINT_URL SERVER_CERT_DER SERVER_KEY_PEM TRUSTED_CLIENT_DER [--seconds N]
// client ENDPOINT CLIENT_CERT_DER CLIENT_KEY_PEM TRUSTED_SERVER_DER [--policy P] [--mode M] [--node ID] [--write VALUE]
// Exit codes: 0 success, 1 a Bad OPC UA status, 2 invalid arguments, 3 failure.
// Credentials are never printed.
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using PDComm.OpcUa;
using PDComm.OpcUa.Example;
const string Usage = """
Usage:
OpcUaExample server BIND_ADDRESS ENDPOINT_URL SERVER_CERT_DER SERVER_KEY_PEM TRUSTED_CLIENT_DER [--seconds N]
OpcUaExample client ENDPOINT CLIENT_CERT_DER CLIENT_KEY_PEM TRUSTED_SERVER_DER
[--policy aes256sha256rsapss|aes128sha256rsaoaep|basic256sha256|none]
[--mode signandencrypt|sign|none] [--node ID] [--write VALUE]
""";
try
{
if (args.Length >= 6 && args[0] == "server") return RunServer(args[1..]);
if (args.Length >= 5 && args[0] == "client") return RunClient(args[1..]);
Console.Error.WriteLine(Usage);
return 2;
}
catch (ArgumentException e)
{
Console.Error.WriteLine($"Error: {e.Message}");
return 2;
}
catch (OpcUaException e)
{
Console.Error.WriteLine($"Error: SDK status {e.NativeStatus}: {e.Message}");
return 3;
}
catch (Exception e) when (e is IOException or InvalidDataException or TimeoutException or PlatformNotSupportedException)
{
Console.Error.WriteLine($"Error: {e.Message}");
return 3;
}
static byte[] Load(string path)
{
var info = new FileInfo(path);
if (!info.Exists || info.Length == 0 || info.Length > 64 * 1024) throw new IOException($"Cannot read {path}");
return File.ReadAllBytes(path);
}
static Dictionary<string, string> Options(string[] values, params string[] allowed)
{
if (values.Length % 2 != 0) throw new ArgumentException("Options need a value");
var result = new Dictionary<string, string>();
for (int i = 0; i < values.Length; i += 2)
{
if (!allowed.Contains(values[i]) || !result.TryAdd(values[i], values[i + 1]))
throw new ArgumentException($"Unknown or repeated option {values[i]}");
}
return result;
}
static int RunServer(string[] a)
{
var options = Options(a[5..], "--seconds");
uint seconds = 0;
if (options.TryGetValue("--seconds", out var text) && (!uint.TryParse(text, out seconds) || seconds is 0 or > 86400))
throw new ArgumentException("--seconds must be between 1 and 86400");
byte[] key = Load(a[3]);
Server server;
try
{
server = Server.Create(a[0], new ServerOptions
{
Endpoint = a[1], CertificateDer = Load(a[2]), PrivateKeyPem = key, TrustedClientDer = Load(a[4]),
});
}
finally { CryptographicOperations.ZeroMemory(key); } // The SDK copied the identity.
using (server)
{
server.AddVariable(UaBinary.NodeId(1001), "Value", UaBinary.Int32Variant(42), VariableFlags.Writable | VariableFlags.Public);
server.AddVariable(UaBinary.NodeId(1002), "Limit", UaBinary.Int32Variant(100), VariableFlags.Public);
server.AddVariable(UaBinary.NodeId(1003), "Protected", UaBinary.Int32Variant(7), VariableFlags.Writable);
// Register the stop signals before announcing readiness.
using var stop = new ManualResetEventSlim();
using var interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, c => { c.Cancel = true; stop.Set(); });
using var terminate = PosixSignalRegistration.Create(PosixSignal.SIGTERM, c => { c.Cancel = true; stop.Set(); });
// Windows Ctrl+Break (SIGQUIT in .NET), e.g. from a service manager.
using var breakSignal = OperatingSystem.IsWindows()
? PosixSignalRegistration.Create(PosixSignal.SIGQUIT, c => { c.Cancel = true; stop.Set(); }) : null;
using (var address = server.LocalAddress())
Console.WriteLine($"Listening on {Encoding.UTF8.GetString(address.Copy())}");
var clock = Stopwatch.StartNew();
while (!stop.IsSet && (seconds == 0 || clock.Elapsed < TimeSpan.FromSeconds(seconds)))
{
server.Poll();
stop.Wait(1);
}
using var snapshot = server.ReadValue(UaBinary.NodeId(1001));
var value = UaBinary.DecodeDataValue(snapshot.Copy());
server.Close();
Console.WriteLine($"Final Value={value.Value}");
Console.WriteLine("Server stopped");
return 0;
}
}
static int RunClient(string[] a)
{
var options = Options(a[4..], "--policy", "--mode", "--node", "--write");
var policy = options.GetValueOrDefault("--policy", "aes256sha256rsapss") switch
{
"aes256sha256rsapss" => SecurityPolicy.Aes256Sha256RsaPss,
"aes128sha256rsaoaep" => SecurityPolicy.Aes128Sha256RsaOaep,
"basic256sha256" => SecurityPolicy.Basic256Sha256,
"none" => SecurityPolicy.None,
_ => throw new ArgumentException("Unknown --policy"),
};
var mode = options.GetValueOrDefault("--mode", policy == SecurityPolicy.None ? "none" : "signandencrypt") switch
{
"signandencrypt" => SecurityMode.SignAndEncrypt,
"sign" => SecurityMode.Sign,
"none" => SecurityMode.None,
_ => throw new ArgumentException("Unknown --mode"),
};
if ((policy == SecurityPolicy.None) != (mode == SecurityMode.None))
throw new ArgumentException("--policy none and --mode none must be used together");
uint node = 1001;
if (options.TryGetValue("--node", out var nodeText) && (!uint.TryParse(nodeText, out node) || node == 0))
throw new ArgumentException("Invalid --node");
int? write = null;
if (options.TryGetValue("--write", out var writeText))
write = int.TryParse(writeText, out int parsed) ? parsed : throw new ArgumentException("Invalid --write");
Console.WriteLine($"PDComm OPC UA ABI 0x{Client.AbiVersion:x8}");
using (var endpoints = Client.Discover(a[0], 5000))
foreach (var (url, endpointMode, uri) in UaBinary.DecodeEndpoints(endpoints.Copy()))
Console.WriteLine($"Endpoint {url} mode={(SecurityMode)endpointMode} policy={uri}");
byte[] key = Load(a[2]);
Client client;
try
{
client = Client.Connect(a[0], new ClientOptions
{
Policy = policy, Mode = mode, AllowNone = policy == SecurityPolicy.None, TimeoutMs = 5000,
CertificateDer = Load(a[1]), PrivateKeyPem = key, TrustedServerDer = Load(a[3]),
});
}
finally { CryptographicOperations.ZeroMemory(key); } // The SDK copied the identity.
using (client)
{
client.ActivateAnonymous();
Console.WriteLine("Session activated (anonymous)");
int result = 0;
if (write is int value)
{
var (service, status) = UaBinary.DecodeWrite(Exchange(client, UaBinary.Write(node, value)));
Console.WriteLine($"Write ns=2;i={node} value={value} service=0x{service:x8} status=0x{status:x8}");
if (((service | status) & 0x80000000u) != 0) result = 1;
}
var (readService, data) = UaBinary.DecodeRead(Exchange(client, UaBinary.Read(node)));
string shown = data?.Value?.ToString() ?? "none";
uint itemStatus = data?.Status ?? 0;
Console.WriteLine($"Read ns=2;i={node} value={shown} service=0x{readService:x8} status=0x{itemStatus:x8}");
if (((readService | itemStatus) & 0x80000000u) != 0) result = 1;
client.Close();
Console.WriteLine("Session closed");
return result;
}
}
// Sends one request and polls the client until its response arrives.
static byte[] Exchange(Client client, byte[] request)
{
uint id = client.Begin(request);
var clock = Stopwatch.StartNew();
while (clock.Elapsed < TimeSpan.FromSeconds(5))
{
client.Poll();
using var response = client.Take(id);
if (response is not null) return response.Copy();
Thread.Sleep(1);
}
throw new TimeoutException("Response timed out");
}
  • 서버. Server.Create가 주소에 바인드하고, Server.AddVariable로 변수 세 개를 추가합니다. PosixSignalRegistration으로 Ctrl+C와 SIGTERM을 받아 루프를 끝냅니다.
  • 클라이언트. Client.Connect가 보안 채널과 세션을 엽니다. 연결 직후 CryptographicOperations.ZeroMemory로 개인 키 배열을 지웁니다.
  • 요청. Exchange가 Client.Begin, Poll, Client.Take를 반복합니다. Take는 응답이 없으면 null입니다.
  • 해제. Client, Server, Response는 IDisposable입니다. using으로 네이티브 핸들을 해제합니다.
  • 오류. C ABI 호출이 실패하면 OpcUaException이 납니다. NativeStatus가 C ABI 상태입니다.

요청 메시지는 UaBinary.cs가 만듭니다. C 예제의 ua_binary.h처럼 Read와 Write만 인코딩하는 작은 도우미입니다.

examples/opcua/dotnet/UaBinary.cs
using System.Buffers.Binary;
using System.Text;
namespace PDComm.OpcUa.Example;
// Minimal OPC UA Binary (Part 6) helpers for this example: one Read or Write of
// the Value attribute of a numeric NodeId in namespace 2, scalar Int32 only.
// Use a complete encoder in production code for every service you call.
internal static class UaBinary
{
private const uint ReadRequest = 631, ReadResponse = 634, WriteRequest = 673, WriteResponse = 676;
private const uint ServiceFault = 397, GetEndpointsResponse = 431, ValueAttribute = 13;
private const byte Int32Type = 6;
public readonly record struct DataValue(uint Status, int? Value);
public static byte[] NodeId(uint id) => Encode(w => PutNodeId(w, 2, id));
public static byte[] Int32Variant(int value) => Encode(w => { w.Write(Int32Type); w.Write(value); });
public static byte[] Read(uint id) => Encode(w =>
{
PutNodeId(w, 0, ReadRequest); PutRequestHeader(w);
w.Write(0.0); w.Write(3); w.Write(1); // maxAge, timestampsToReturn=Neither, one node
PutValueId(w, id); w.Write((ushort)0); w.Write(-1); // default dataEncoding
});
public static byte[] Write(uint id, int value) => Encode(w =>
{
PutNodeId(w, 0, WriteRequest); PutRequestHeader(w);
w.Write(1); PutValueId(w, id);
w.Write((byte)1); w.Write(Int32Type); w.Write(value); // DataValue with an Int32 value
});
// Returns (ServiceResult, DataValue). A ServiceFault has no DataValue.
public static (uint Service, DataValue? Value) DecodeRead(byte[] body)
{
var r = new Reader(body);
(uint id, uint service) = r.ResponseHeader();
if (id == ServiceFault) { r.End(); return (service, null); }
if (id != ReadResponse || r.Length() != 1) throw new InvalidDataException("Unexpected ReadResponse");
var value = r.DataValue(); r.SkipDiagnosticInfos(); r.End();
return (service, value);
}
public static (uint Service, uint Status) DecodeWrite(byte[] body)
{
var r = new Reader(body);
(uint id, uint service) = r.ResponseHeader();
if (id == ServiceFault) { r.End(); return (service, 0); }
if (id != WriteResponse || r.Length() != 1) throw new InvalidDataException("Unexpected WriteResponse");
uint status = r.UInt32(); r.SkipDiagnosticInfos(); r.End();
return (service, status);
}
public static DataValue DecodeDataValue(byte[] body)
{
var r = new Reader(body); var value = r.DataValue(); r.End(); return value;
}
// Lists (endpoint URL, security mode, security policy URI) from GetEndpoints.
public static List<(string Url, uint Mode, string Policy)> DecodeEndpoints(byte[] body)
{
var r = new Reader(body);
(uint id, uint service) = r.ResponseHeader();
if (id != GetEndpointsResponse || (service & 0x80000000u) != 0) throw new InvalidDataException("Unexpected GetEndpointsResponse");
var result = new List<(string, uint, string)>();
for (int i = r.Length(); i > 0; --i)
{
string url = r.Text();
r.Text(); r.Text(); r.SkipLocalizedText(); r.UInt32(); r.Text(); r.Text();
for (int j = r.Length(); j > 0; --j) r.Text();
r.Text(); uint mode = r.UInt32(); string policy = r.Text();
for (int j = r.Length(); j > 0; --j) { r.Text(); r.UInt32(); r.Text(); r.Text(); r.Text(); }
r.Text(); r.Take(1);
result.Add((url, mode, policy));
}
r.End();
return result;
}
private static byte[] Encode(Action<BinaryWriter> body)
{
using var stream = new MemoryStream();
using (var writer = new BinaryWriter(stream)) body(writer); // little-endian
return stream.ToArray();
}
private static void PutNodeId(BinaryWriter w, ushort ns, uint id)
{
if (ns == 0 && id <= 0xff) { w.Write((byte)0); w.Write((byte)id); }
else if (ns <= 0xff && id <= 0xffff) { w.Write((byte)1); w.Write((byte)ns); w.Write((ushort)id); }
else { w.Write((byte)2); w.Write(ns); w.Write(id); }
}
// The client replaces authenticationToken, timestamp and requestHandle.
private static void PutRequestHeader(BinaryWriter w)
{
PutNodeId(w, 0, 0); w.Write(0L); w.Write(0u); w.Write(0u); w.Write(-1); w.Write(5000u);
PutNodeId(w, 0, 0); w.Write((byte)0);
}
private static void PutValueId(BinaryWriter w, uint id) { PutNodeId(w, 2, id); w.Write(ValueAttribute); w.Write(-1); }
private sealed class Reader(byte[] data)
{
private int offset;
public ReadOnlySpan<byte> Take(int n)
{
if (n < 0 || n > data.Length - offset) throw new InvalidDataException("Truncated OPC UA message");
var span = data.AsSpan(offset, n); offset += n; return span;
}
public byte Byte() => Take(1)[0];
public ushort UInt16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint UInt32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public int Length()
{
int n = (int)UInt32();
if (n < -1 || n > data.Length - offset) throw new InvalidDataException("Invalid OPC UA length");
return n;
}
public string Text() { int n = Length(); return n <= 0 ? "" : Encoding.UTF8.GetString(Take(n)); }
public void End() { if (offset != data.Length) throw new InvalidDataException("Trailing OPC UA bytes"); }
public void SkipNodeId()
{
switch (Byte())
{
case 0: Take(1); break; case 1: Take(3); break; case 2: Take(6); break;
case 3: case 5: Take(2); Text(); break; case 4: Take(18); break;
default: throw new InvalidDataException("Invalid NodeId");
}
}
public void SkipLocalizedText() { byte mask = Byte(); if ((mask & 1) != 0) Text(); if ((mask & 2) != 0) Text(); }
public void SkipDiagnosticInfo(int depth = 0)
{
byte mask = Byte();
if (depth > 4 || (mask & 0x80) != 0) throw new InvalidDataException("Invalid DiagnosticInfo");
foreach (byte bit in new byte[] { 0x01, 0x02, 0x08, 0x04 }) if ((mask & bit) != 0) Take(4);
if ((mask & 0x10) != 0) Text();
if ((mask & 0x20) != 0) Take(4);
if ((mask & 0x40) != 0) SkipDiagnosticInfo(depth + 1);
}
public void SkipDiagnosticInfos() { for (int i = Length(); i > 0; --i) SkipDiagnosticInfo(); }
public (uint Id, uint Service) ResponseHeader()
{
uint id = Byte() switch
{
0 => Byte(), 1 => Byte() == 0 ? UInt16() : throw new InvalidDataException("Unexpected namespace"),
2 => UInt16() == 0 ? UInt32() : throw new InvalidDataException("Unexpected namespace"),
_ => throw new InvalidDataException("Invalid encoding NodeId"),
};
Take(12); uint service = UInt32(); SkipDiagnosticInfo();
for (int i = Length(); i > 0; --i) Text();
SkipNodeId(); byte encoding = Byte();
if (encoding is 1 or 2) Text(); else if (encoding != 0) throw new InvalidDataException("Invalid ExtensionObject");
return (id, service);
}
public DataValue DataValue()
{
byte mask = Byte(); int? value = null; uint status = 0;
if ((mask & 0xc0) != 0) throw new InvalidDataException("Invalid DataValue");
if ((mask & 0x01) != 0)
{
if (Byte() != Int32Type) throw new InvalidDataException("Only scalar Int32 values are shown by this example");
value = (int)UInt32();
}
if ((mask & 0x02) != 0) status = UInt32();
if ((mask & 0x04) != 0) Take(8);
if ((mask & 0x10) != 0) Take(2);
if ((mask & 0x08) != 0) Take(8);
if ((mask & 0x20) != 0) Take(2);
return new DataValue(status, value);
}
}
}

첫 번째 터미널의 my-opcua-app 폴더에서 서버를 실행합니다.

터미널 창
dotnet out/OpcUaExample.dll server 127.0.0.1:4840 opc.tcp://localhost:4840/pdcomm "$PKI/server.der" "$PKI/server.key.pem" "$PKI/client.der"
Listening on 127.0.0.1:4840

두 번째 터미널의 my-opcua-app 폴더에서 값 -5를 쓰고 읽습니다.

터미널 창
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/server.der" --write -5

엔드포인트 목록 뒤의 출력은 다음과 같습니다.

Session activated (anonymous)
Write ns=2;i=1001 value=-5 service=0x00000000 status=0x00000000
Read ns=2;i=1001 value=-5 service=0x00000000 status=0x00000000
Session closed

옵션은 C 예제와 같습니다(--policy, --mode, --node, --write). Basic256Sha256과 Sign으로 읽습니다.

터미널 창
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/server.der" --policy basic256sha256 --mode sign
Session activated (anonymous)
Read ns=2;i=1001 value=-5 service=0x00000000 status=0x00000000
Session closed

서버가 광고하지 않는 None 정책을 요청합니다.

터미널 창
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/server.der" --policy none
PDComm OPC UA ABI 0x00000002
Error: SDK status 5: Requested OPC UA endpoint not advertised

신뢰할 서버 인증서를 잘못 지정합니다.

터미널 창
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/client.der"
Error: SDK status 5: OPC UA security CertificateUntrusted

두 경우 모두 종료 코드는 3입니다.

서버 터미널에서 Ctrl+C를 누릅니다.

Final Value=-5
Server stopped
증상 원인과 조치
빌드 오류 Set PdcommOpcUaSdk to the extracted PDComm OPC UA SDK root. -p:PdcommOpcUaSdk의 경로에 lib/dotnet/PDComm.OpcUa.dll이 없습니다. 패키지를 푼 폴더를 지정합니다.
서버가 kill -INT에 멈추지 않음 스크립트가 서버를 백그라운드(&)로 실행하면 셸이 SIGINT를 무시하도록 설정하고, .NET 런타임은 그 설정을 따릅니다. kill -TERM으로 SIGTERM을 보냅니다.