← 전체 글
OPC UA/약 8분 읽기/— 조회

SCADA 클라이언트가 OPC UA 구조체를 바이트 덩어리로 보여줄 때, 이렇게 디코딩한다

PLC UDT가 OPC UA 클라이언트에서 ByteString으로 보인다. ExtensionObject 안에 든 것과 구조화 DataType 디코딩법.

OPC UASCADA태그문제 해결시운전

Siemens S7-1500이나 CODESYS 서버를 브라우징해서 제어 엔지니어가 말해준 태그를 찾았는데, 깔끔한 float 대신 처음 보는 DataType을 가진 노드 하나가 나오고, 값은 ByteString 또는 그냥 Unknown으로 읽힌다. 고장난 게 아니다. PLC가 구조체(structure)를 발행했고, 클라이언트가 그걸 분해할 줄 모르는 것이다.

여기서 많이들 걸린다. 대부분의 SCADA 클라이언트는 내장 타입 25종(Int16, Float, DateTime, String…)만 능숙하게 다루고, 그 밖은 전부 불투명한 바이트로 취급한다. 구조화 DataType — PLC UDT, Siemens 타입, 커스텀 struct — 은 ExtensionObject에 감싸여 전송선을 타고, 이 ExtensionObject는 설명을 직접 읽으러 갈 때만 스스로를 설명해 준다.

그 값 안에 실제로 들어 있는 것

ExtensionObject는 세 부분으로 되어 있다(OPC UA Part 6, 바이너리 인코딩):

  • TypeId — DataType 자체가 아니라 인코딩 노드의 NodeId다. 해당 struct의 "Default Binary"(또는 Default XML / Default JSON) 인코딩 객체를 가리킨다.
  • 인코딩 바이트 — 0은 본문 없음, 1은 ByteString 본문이 따라옴, 2는 XML.
  • 본문(body) — struct의 필드들을 연달아 직렬화한 것.

다들 틀리는 대목이 여기다. OPC UA 바이너리 인코딩에는 패딩도 정렬(alignment)도 없다. { Bool, Int32, Float } struct는 1 + 4 + 4 = 9바이트이지, 12바이트로 패딩되지 않는다. 필드는 선언 순서대로 기록된다. 문자열과 배열은 Int32 길이 접두어가 붙는다. 본문을 직접 파싱하다가 오프셋이 몇 바이트씩 어긋난다면 이유는 이것이다 — C struct 정렬 습관을, 정렬이 아예 없는 포맷에 가져온 것이다.

즉 전송선은 어떤 타입인지(TypeId)와 원본 바이트는 알려주지만, 필드 배치(layout)는 알려주지 않는다. 그 배치는 다른 곳에 있고, 어디에 있는지는 서버가 얼마나 최신인지에 달렸다.

클라이언트가 배치를 알아내는 두 가지 방법

최신 서버(OPC UA 1.04+): DataTypeDefinition 속성. 모든 DataType 노드는 DataTypeDefinition 속성 — AttributeId 23 — 을 가질 수 있다. struct라면 이 속성이 StructureDefinition을 담고, 여기에 순서가 매겨진 필드들이 각각 이름, DataType NodeId, valueRank와 함께 들어 있다. 이 속성 하나만 읽으면 범용 클라이언트가 타입에 대한 사전 지식 없이도 런타임에 디코더를 만들 수 있다. 1.04가 이걸 추가한 이유가 바로 이것이다. 클라이언트를 고를 수 있다면 AttributeId 23을 읽는 것을 택하라 — 어떤 준수 서버에 대해서도 UDT가 "그냥 되게" 만들어 준다.

오래된 서버: 타입 딕셔너리. 1.04 이전에는 배치가 OPC Binary 네임스페이스의 DataTypeDictionary 노드에 들어 있었다 — 서버의 모든 커스텀 타입을 담은 BSD(Binary Schema Description) XML 덩어리다. 클라이언트는 이 blob을 내려받아 XML을 파싱하고, DataTypeDescription 노드로 struct를 매칭한 뒤 codec을 만든다. 동작은 하지만 움직이는 부품이 많고, 딕셔너리가 실제 인코딩과 어긋나는 서버도 봤다. 둘이 어긋나면 전송선을 믿고 서버를 고쳐라.

"필드 값이 이상하게 읽힌다"를 디버깅하기 전에 알아둘 두 가지 함정:

  • 선택 필드(StructureWithOptionalFields) 는 본문 앞에 32비트 비트마스크를 둔다 — 선택 필드 하나당 한 비트다. 비트가 0이면 그 필드는 바이트에서 아예 빠지므로, 그 뒤의 모든 필드가 밀린다. 마스크를 놓치면 이후 전부가 쓰레기로 디코딩된다.
  • 유니온(StructureType = Union) 은 32비트 SwitchField로 시작하며, 존재하는 단 하나의 멤버를 선택한다. 그 멤버의 바이트만 뒤따른다.

시운전에서 실제로 할 일

SCADA 플랫폼이 DataTypeDefinition을 읽을 수 있으면 struct를 가리키고 넘어가라. 못 읽는다면 — 오래된 HMI 상당수가 못 읽는다 — 세 가지 선택지가 있고, 내가 신뢰하는 순서로 적는다.

  1. 서버에서 펼쳐라(flatten). struct의 멤버들을 하나의 struct 노드 대신 개별 스칼라 노드(Motor1.Speed, Motor1.Running)로 노출한다. S7-1500이면 멤버를 그대로 매핑할 수 있는 경우가 많고, CODESYS면 심볼 구성에 멤버를 개별로 추가한다. SCADA 클라이언트는 평범한 float와 bool만 보고 ExtensionObject를 건드릴 일이 없다. 지루하지만 항상 된다.
  2. 디코딩하는 클라이언트를 써라. Ignition의 OPC UA 드라이버, UaExpert, 그리고 대부분의 최신 스택은 정의를 읽어 필드를 보여준다. 당신 플랫폼이 그중 하나면 끝난 것이다.
  3. ByteString을 직접 파싱하라. 최후의 수단이다. 원본 본문을 읽어 필드를 직접 잘라낸다. 타입이 고정되고 버전이 잠겨 있을 때만 하라. UDT에 필드가 하나 추가되고 다시 빌드되는 날, 모든 오프셋이 밀려서 파서가 오류 없이 그럴듯하지만 틀린 숫자를 돌려준다. 만드는 것에 DataTypeVersion을 넣어서, 최소한 변경이 데이터를 조용히 망가뜨리는 대신 알람을 울리게 하라.

밤을 날려먹는 실패 모드는 3번을 대충 한 경우다. 여섯 달 동안 잘 디코딩되던 struct, SCADA에는 아무도 알리지 않은 UDT 변경, 그리고 맨 앞에 Bool 하나가 끼어들며 32비트 마스크를 모두의 계산 속으로 밀어넣은 탓에 미묘하게 다 어긋난 숫자로 가득한 화면. 서버에서 펼칠 수 있으면 펼쳐라 — 전송선 위의 struct는 PLC 프로그래머에겐 편의지만 통합 담당자에겐 부채다.