Melsec PLC 데이터 엑세스하기

작성자

카테고리:

미쓰비시전기(Mitsubishi Electric)의 MELSEC PLC 가 가지고 있는 디바이스 메모리를
외부 PC 프로그램에서 직접 읽어 오는 방법을 정리한다. 통신 규약인 MC 프로토콜
(MELSEC Communication Protocol) 3E 프레임을 바이트 단위로 해부하고, 이를
C++ 소켓 클라이언트로 구현한 뒤, 실기(實機)가 없는 상태에서도 개발을 계속할 수
있도록 가상 PLC 를 파이썬으로 만들어 검증하는 전 과정을 다룬다.

대상 기종 MELSEC-Q 시리즈 Q03UDE CPU (내장 이더넷 포트 탑재형)
통신 규약 MC 프로토콜 3E 프레임, 바이너리 코드, TCP/IP
개발 환경 Windows 11, Visual Studio 2022 (x64), Python 3.9 이상
엔지니어링 툴 GX Works2 / GX Simulator2
산출물 mc_read.exe (모니터), fake_plc.py (가상 PLC)

1. 머리말

설비를 가상 공간에 그대로 옮겨 놓는 디지털 트윈(digital twin)을 만들려면, 결국
실제 설비가 지금 어떤 상태인가 하는 정보가 실시간으로 흘러 들어와야 한다.
3차원 모델이 아무리 정밀해도 그것이 움직이지 않으면 그림에 지나지 않는다. 설비의
상태를 쥐고 있는 것은 대개 PLC(Programmable Logic Controller)이고, 국내 공작기계와
물류 설비 현장에서 가장 널리 쓰이는 것이 미쓰비시전기의 MELSEC 시리즈이다.

그런데 PLC 의 데이터를 PC 로 가져오는 일은 생각보다 문턱이 높다. 벤더가 제공하는
상용 미들웨어를 쓰면 간단하지만 라이선스 비용이 들고, 배포할 때마다 설치 과정이
따라붙는다. 반대로 통신 규약을 직접 구현하면 의존성이 전혀 없는 단일 실행 파일이
나오지만, 그러려면 프레임의 바이트 배치를 정확히 알아야 한다.

다행히 MC 프로토콜은 공개된 규약이고, 구조도 그리 복잡하지 않다. 필자가 실제로
1.5톤 리프트 시스템의 디지털 트윈 작업을 진행하면서 겪은 시행착오를 포함하여,
읽고 그대로 따라 하면 동작하는 수준까지 정리하였다.

이 글의 구성은 다음과 같다.

  • 2장 — MELSEC 데이터에 접근하는 네 가지 경로를 비교하고, 왜 MC 프로토콜을 택했는지 밝힌다.
  • 3장 — 3E 프레임의 요청 · 응답 구조를 바이트 단위로 해부한다.
  • 4장 — GX Works2 에서 PLC 측을 설정한다.
  • 5장 — C++ 클라이언트 mc_read.cpp 전문과 절별 해설.
  • 6장 — 실기 없이 개발하기 위한 가상 PLC fake_plc.py.
  • 7장 — 빌드와 실행.
  • 8장 — 실제로 부딪힌 함정 다섯 가지.
  • 9장 — 디지털 트윈 연동을 위한 파이썬 클라이언트.

2. MELSEC 데이터 접근 경로의 분류

MELSEC PLC 의 디바이스 메모리(D 레지스터, M 릴레이 등)를 외부에서 읽는 방법은
크게 네 가지로 나눌 수 있다.

(1) MC 프로토콜 (MELSEC Communication Protocol)

PLC 가 서버 가 되고 PC 가 클라이언트 가 되어, 정해진 형식의 바이트열을
주고받는 방식이다. 이더넷(TCP/UDP)과 시리얼(RS-232/422) 모두 지원한다. PLC 쪽에
래더 프로그램을 한 줄도 넣을 필요가 없다는 점이 가장 큰 장점이다. CPU 의 내장
이더넷 포트나 이더넷 인터페이스 유닛(QJ71E71 등)이 요청을 받아 스스로 응답한다.

프레임 형식은 세대에 따라 1C / 2C / 3C / 4C (시리얼)와 1E / 3E / 4E (이더넷)로
나뉜다. 현재 신규 개발에서는 3E 프레임이 사실상의 표준이며, 이 글도
3E 를 다룬다.

(2) SLMP (Seamless Message Protocol)

MC 프로토콜 3E 를 CC-Link IE 계열까지 포괄하도록 확장한 상위 호환 규약이다.
디바이스 읽기·쓰기에 한정해서 보면 프레임 구조가 3E 와 동일하다.
즉 이 글에서 만드는 클라이언트는 SLMP 대응 기기에도 그대로 붙는다. iQ-R 시리즈나
CC-Link IE Field 기기를 다룰 때 SLMP 라는 이름으로 문서를 찾으면 된다.

(3) MX Component

미쓰비시전기가 제공하는 상용 통신 미들웨어이다. ActiveX/COM 인터페이스를 제공하므로
C#, VB, Excel VBA 등에서 ActUtlType.ReadDeviceBlock() 같은 함수 호출
몇 줄로 디바이스를 읽을 수 있다. 시리얼 · USB · 이더넷 · CC-Link 등 거의 모든
경로를 추상화해 주고, GX Simulator2 접속도 지원한다는 점이 중요하다.

단점은 유료 라이선스이고, 별도 설치가 필요하며, 32/64비트 및 COM 등록 문제로
배포 환경이 까다롭다는 것이다.

(4) OPC UA

상위 MES/SCADA 와 연계할 때 쓰는 표준이다. MELSEC 의 경우 별도의 OPC UA 서버
모듈(예: iQ-R 시리즈의 RD81OPC96)이나 서드파티 OPC 서버를 경유한다. 표준화 수준은
가장 높지만 도입 비용이 크고, 단순히 D 레지스터 몇 개를 읽으려는 목적에는 과하다.

<표 1> MELSEC 데이터 접근 경로 비교

구분 MC 프로토콜 / SLMP MX Component OPC UA
비용 무료 (규약 공개) 유료 라이선스 고가 (모듈 별도)
PC 측 설치 불필요 (단일 exe) 런타임 설치 필요 클라이언트 라이브러리
PLC 측 래더 불필요 불필요 설정 필요
구현 난이도 중 (프레임 직접 조립) 하 (함수 호출) 중상
언어 제약 없음 (소켓만 있으면 됨) COM 지원 언어 없음
시뮬레이터 접속 불가 가능 불가
실기 접속 가능 가능 가능
적합한 용도 경량 수집 · 임베딩 사무 자동화 · 시뮬레이션 검증 전사 시스템 연계

★주 1) GX Simulator2 는 소켓을 열지 않는다.
GX Works2 에 딸려 오는 GX Simulator2 는 CPU 의 연산(래더 실행과 디바이스
메모리)만 흉내 낸다. 내장 이더넷 포트의 MC 프로토콜 서버 기능은 시뮬레이션하지
않는다. GX Works2 가 시뮬레이터에 접속하는 것도 TCP 가 아니라 내부
전용 경로를 쓴다. 따라서 이 글에서 만드는 mc_read.exe 를 GX Simulator2 에
직접 붙이는 것은 원리적으로 불가능하다. 시뮬레이터의 디바이스 값을
외부 프로그램에서 읽으려면 MX Component 의 시뮬레이터 접속을 써야 한다.
이 문제는 8장 (2)절에서 실측 자료와 함께 다시 다룬다.

본 작업의 목표는 최종적으로 현장의 실기 Q03UDE 에 붙는 경량 수집기를 만드는 것이다.
따라서 MC 프로토콜 3E 를 직접 구현하기로 한다. 시뮬레이터에 붙지
못하는 제약은 6장의 가상 PLC 로 우회한다.


3. MC 프로토콜 3E 프레임의 구조

3E 프레임은 바이너리 코드와 ASCII 코드 두 가지 표현을
가진다. ASCII 코드는 사람이 눈으로 읽기 쉬우나 바이트 수가 두 배로 늘고 변환 비용이
든다. 여기서는 바이너리 코드를 쓴다. 바이너리 코드에서 2바이트 이상의 수치는
모두 리틀 엔디안(little endian)
으로 배치된다. 이는 뒤에서 여러 번 문제가 되는
지점이므로 미리 기억해 둔다.

(1) 요청 프레임 (PC → PLC)

D 디바이스를 워드 단위로 일괄 읽는 요청(커맨드 0x0401)의 전체 바이트 배치는
다음과 같다. 총 21바이트이다.

<표 2> 3E 요청 프레임 바이트 배치 (일괄 읽기, 워드 단위)

오프셋 길이 필드 값 (예) 설명
0 2 서브헤더 50 00 요청을 뜻하는 고정값 0x0050
2 1 네트워크 번호 00 자국(自局)은 0x00
3 1 PC 번호 FF 자국은 0xFF
4 2 요청 대상 모듈 I/O 번호 FF 03 0x03FF = 자국 CPU
6 1 요청 대상 모듈 국번 00 자국은 0x00
7 2 요청 데이터 길이 0C 00 이 필드 다음 바이트 수 = 12
9 2 감시 타이머 10 00 0x0010 × 250 ms = 4 초
11 2 커맨드 01 04 0x0401 = 일괄 읽기
13 2 서브커맨드 00 00 0x0000 = 워드 단위
15 3 선두 디바이스 번호 00 00 00 D0 이면 0
18 1 디바이스 코드 A8 D 레지스터
19 2 디바이스 점수 04 00 4점 = D0~D3

여기서 특히 주의할 점이 세 가지 있다.

가. 요청 데이터 길이의 기준점. 오프셋 7 의 2바이트는 프레임 전체
길이가 아니라 자기 자신 다음부터 끝까지 의 바이트 수이다. 위 예에서는
감시 타이머 2 + 커맨드 2 + 서브커맨드 2 + 디바이스 번호 3 + 디바이스 코드 1 +
점수 2 = 12바이트이므로 0C 00 이 된다. 이 값을 프레임 전체 길이인 21 로
잘못 넣으면 PLC 는 응답하지 않고 그대로 침묵한다.

나. 선두 디바이스 번호는 3바이트이다. 4바이트가 아니다. 리틀
엔디안이므로 하위 바이트부터 채운다. D1000 을 읽으려면 1000 = 0x0003E8 이므로
E8 03 00 이 된다.

다. 감시 타이머의 단위는 250 ms 이다. 0x0010 = 16 이므로
16 × 250 ms = 4초이다. PLC 가 이 시간 안에 처리하지 못하면 오류로 응답한다.
0 을 넣으면 무한 대기를 뜻하는데, 통신 이상 시 소켓이 영영 풀리지 않을 수 있으므로
권장하지 않는다.

(2) 응답 프레임 (PLC → PC)

<표 3> 3E 정상 응답 프레임 바이트 배치

오프셋 길이 필드 값 (예) 설명
0 2 서브헤더 D0 00 응답을 뜻하는 고정값 0x00D0
2 1 네트워크 번호 00 요청을 그대로 되돌려 준다
3 1 PC 번호 FF 〃
4 2 요청 대상 모듈 I/O 번호 FF 03 〃
6 1 요청 대상 모듈 국번 00 〃
7 2 응답 데이터 길이 0A 00 종료 코드 2 + 데이터 8 = 10
9 2 종료 코드 00 00 0 이면 정상
11 2n 읽은 데이터 02 00 ... 워드당 2바이트, 리틀 엔디안

종료 코드가 0 이 아니면 그 뒤에 데이터가 따라오지 않는다. 즉 응답 데이터 길이가
2 로 내려온다. 따라서 수신 로직은 고정 11바이트를 먼저 읽어 길이와 종료
코드를 확인한 다음, 남은 바이트를 추가로 읽는
2단계 구조가 되어야 한다.
한 번에 고정 크기를 읽으려 하면 오류 응답에서 어긋난다.

(3) 디바이스 코드

자주 쓰는 디바이스의 코드는 다음과 같다. 바이너리 코드에서는 1바이트 값이다.

<표 4> 주요 디바이스 코드 (바이너리)

디바이스 기호 코드 단위 비고
데이터 레지스터 D 0xA8 워드 가장 많이 쓴다
링크 레지스터 W 0xB4 워드 디바이스 번호가 16진수
파일 레지스터 R 0xAF 워드  
내부 릴레이 M 0x90 비트 워드 읽기 시 16점 묶음
입력 X 0x9C 비트 디바이스 번호가 16진수
출력 Y 0x9D 비트 디바이스 번호가 16진수
래치 릴레이 L 0x92 비트  
타이머 현재값 TN 0xC2 워드  
카운터 현재값 CN 0xC5 워드  

★주 2) 비트 디바이스를 워드 단위로 읽을 때.
M0 을 선두로 워드 단위 읽기를 1점 하면 M0~M15 의 16비트가 한 워드로 묶여서
온다. 이때 선두 디바이스 번호는 반드시 16의 배수여야 한다. 또한 X, Y, W, B 는
디바이스 번호를 16진수로 세는 디바이스이므로, X20 을 읽으려면
10진수 32 를 넣어야 한다. 실수하기 쉬운 부분이다.

(4) 종료 코드

응답의 종료 코드가 0 이 아닐 때 자주 만나는 값들이다. 디버깅 시간의 대부분은
이 표를 보면 줄어든다.

<표 5> 자주 만나는 종료 코드

코드 의미 흔한 원인
0x0000 정상  
0xC050 ASCII/바이너리 설정 불일치 PLC 는 ASCII 인데 바이너리로 보냈다
0xC051~0xC054 읽기/쓰기 점수 초과 한 번에 960점을 넘겼다
0xC056 디바이스 번호 범위 초과 선두 + 점수가 디바이스 끝을 넘었다
0xC059 커맨드/서브커맨드 지정 오류 커맨드 값이 틀렸거나 지원하지 않는 CPU
0xC05B 해당 디바이스 처리 불가 디바이스 코드가 틀렸다
0xC05C 요청 내용 오류 워드/비트 단위 지정이 어긋났다
0xC05F 실행 불가 요청 대상 CPU 지정(I/O 번호)이 틀렸다
0xC060 요청 데이터 오류 비트 디바이스 선두가 16의 배수가 아니다
0xC061 요청 데이터 길이 불일치 오프셋 7 의 길이 필드를 잘못 넣었다

실제 개발에서 가장 많이 보는 것은 0xC061 과 0xC059 이다.
전자는 앞서 설명한 길이 기준점 착오이고, 후자는 커맨드 바이트 순서를 뒤집어
04 01 로 넣었을 때 나온다. 리틀 엔디안이므로 0x0401 은
01 04 이다.

(5) 엔디안 정리

바이너리 코드 3E 프레임에서 엔디안을 요약하면 다음과 같다.

  • 서브헤더, 길이, 감시 타이머, 커맨드, 서브커맨드, 점수, 종료 코드 — 모두 리틀 엔디안 2바이트
  • 선두 디바이스 번호 — 리틀 엔디안 3바이트
  • 디바이스 코드, 네트워크 번호, PC 번호, 국번 — 1바이트이므로 해당 없음
  • 읽어 온 워드 데이터 — 워드마다 리틀 엔디안 2바이트

읽어 온 워드를 부호 있는 정수로 볼 것인가 부호 없는 정수로 볼 것인가는 래더가
그 값을 어떻게 쓰는지에 달려 있다. MELSEC 의 워드 디바이스는 기본적으로
16비트 부호 있는 정수(-32768 ~ 32767)로 취급하므로, 본 구현에서도
int16_t 로 해석한다. 32비트 값을 다루는 DMOV 계열 명령을 쓰는 래더라면
연속한 두 워드를 묶어 int32_t 로 재조립해야 한다.


4. PLC 측 설정

MC 프로토콜은 PLC 쪽에 래더를 요구하지 않지만, 파라미터 설정은 반드시
필요
하다. 설정하지 않으면 포트가 열리지 않아 TCP 접속 자체가 거부된다.
GX Works2 기준으로 설명한다.

(1) PC 파라미터 — 내장 이더넷 포트 설정

프로젝트 트리에서 파라미터 > PC 파라미터 > 내장 이더넷 포트 설정
탭을 연다. 여기서 정할 것은 다음과 같다.

  1. IP 주소 — 예: 192.168.3.39. PC 와 같은 서브넷에 두어야 한다.
  2. 서브넷 마스크 — 예: 255.255.255.0.
  3. 교신 데이터 코드 — 바이너리 코드 를 선택한다.
    기본값이 ASCII 인 경우가 있으므로 반드시 확인한다. 여기가 어긋나면 종료 코드
    0xC050 이 돌아온다.
  4. RUN 중 쓰기 허용 — 읽기만 할 것이라면 체크하지 않아도 된다.
    쓰기까지 하려면 체크한다.

(2) 오픈 설정

같은 화면의 오픈 설정 버튼을 누르면 최대 16개의 커넥션을 정의할 수 있다.
1번 항목에 다음과 같이 넣는다.

<표 6> 오픈 설정 예

항목 설정값 비고
프로토콜 TCP UDP 도 가능하나 TCP 가 다루기 쉽다
오픈 방식 MC 프로토콜  
자국 포트 번호 1388 (16진수) 10진수로 5000
교신 상대 지정하지 않음 어느 PC 에서든 붙을 수 있게 한다

자국 포트 번호가 16진수 입력란이라는 점에 유의한다. 우리가 쓸 10진수 5000 은
16진수로 0x1388 이다. 이 값을 그대로 10진수 1388 로 착각해 넣으면 PC 쪽에서
5000 번으로 접속했을 때 연결이 거부된다.

설정을 마치면 온라인 > PC 쓰기 로 파라미터를 PLC 에 전송하고,
CPU 를 리셋한다. 파라미터는 리셋해야 반영된다.

(3) 동작 확인용 래더

통신이 살아 있는지 눈으로 확인하려면, 값이 변하는 디바이스가 하나 있으면 편하다.
본 작업에서는 다음과 같은 간단한 래더를 넣었다.

    X1
 ───┤↑├──────────────[ INCP  D0 ]

    X2
 ───┤ ├──────────────[ MOV  K0  D0 ]

X1 의 상승 에지마다 D0 이 1 씩 증가하고, X2 를 켜면 D0 이 0 으로 돌아간다.
이 래더를 M_Gentry_Test.gxw 로 저장해 두었다. PC 쪽 모니터를 띄워
놓고 X1 을 눌렀을 때 숫자가 따라 올라가면 경로 전체가 정상이라는 뜻이다.


5. C++ 클라이언트 구현

(1) 설계 방침

현장에 들고 갈 도구이므로 다음 네 가지를 원칙으로 삼았다.

  1. 의존성 없음. 외부 라이브러리를 쓰지 않는다. Winsock2 만 쓴다.
    결과물은 단일 실행 파일 하나이므로 USB 로 복사해 어느 PC 에서나 바로 돌릴 수 있다.
  2. 변화가 있을 때만 출력. 200 ms 주기로 폴링하면 1분에 300줄이 쌓인다.
    이전 값과 비교해 달라졌을 때만 찍으면 화면이 로그가 아니라 사건 기록이 된다.
  3. 끊기면 스스로 다시 붙는다. 현장에서 랜선이 잠깐 빠지거나 CPU 를
    리셋해도 프로그램이 죽지 않아야 한다.
  4. 파이프로 넘길 수 있어야 한다. 나중에 파이썬이나 Blender 가 이
    출력을 그대로 받아 쓸 것이므로, 표준 출력을 버퍼링하지 않는다.

(2) 전체 소스 — mc_read.cpp

// mc_read.cpp
// ------------------------------------------------------------------
// Mitsubishi Q03UDE built-in Ethernet  -  MC protocol 3E frame (binary)
// Reads D registers with "batch read in word units" (command 0x0401)
// and prints them with printf whenever a value changes.
//
//   mc_read <PLC_IP> [port=5000] [start=0] [count=2] [interval_ms=200] [polls=0]
//
//   ex) mc_read 192.168.3.39            -> D0, D1 every 200 ms
//       mc_read 192.168.3.39 5000 0 10  -> D0 ~ D9
//
// PLC side (GX Works2 > PC parameter > Built-in Ethernet port):
//   binary code communication, Open setting: TCP / MC protocol / port 1388h (5000)
// ------------------------------------------------------------------
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <vector>

#pragma comment(lib, "ws2_32.lib")

static volatile bool g_run = true;

static BOOL WINAPI on_ctrl(DWORD) { g_run = false; return TRUE; }

// Receive exactly n bytes (false on timeout / disconnect)
static bool recv_all(SOCKET s, uint8_t* buf, int n)
{
    int got = 0;
    while (got < n) {
        int r = recv(s, (char*)buf + got, n - got, 0);
        if (r <= 0) return false;
        got += r;
    }
    return true;
}

// Build a 3E binary request: batch word read of D[start .. start+count-1]
static std::vector<uint8_t> build_read_d(uint32_t start, uint16_t count)
{
    std::vector<uint8_t> f = {
        0x50, 0x00,             // subheader (3E request)
        0x00,                   // network No.
        0xFF,                   // PC No.
        0xFF, 0x03,             // request destination module I/O No. (0x03FF = own CPU)
        0x00,                   // request destination module station No.
        0x0C, 0x00,             // request data length = 12 bytes (timer .. points)
        0x10, 0x00,             // monitoring timer 0x0010 x 250 ms = 4 s
        0x01, 0x04,             // command 0x0401 : batch read
        0x00, 0x00,             // subcommand 0x0000 : word units
        (uint8_t)(start), (uint8_t)(start >> 8), (uint8_t)(start >> 16),  // head device No.
        0xA8,                   // device code : D
        (uint8_t)(count), (uint8_t)(count >> 8)                           // number of points
    };
    return f;
}

// One request/response. Returns 0 on success, end code (>0) on PLC error, -1 on link error.
static int read_d(SOCKET s, uint32_t start, uint16_t count, std::vector<int16_t>& out)
{
    std::vector<uint8_t> req = build_read_d(start, count);
    if (send(s, (const char*)req.data(), (int)req.size(), 0) != (int)req.size()) return -1;

    uint8_t hdr[11];            // subheader .. end code
    if (!recv_all(s, hdr, sizeof(hdr))) return -1;
    if (hdr[0] != 0xD0 || hdr[1] != 0x00) return -1;                  // not a 3E response
    uint16_t len = hdr[7] | (hdr[8] << 8);                             // bytes after this field
    uint16_t end = hdr[9] | (hdr[10] << 8);
    std::vector<uint8_t> body(len > 2 ? len - 2 : 0);
    if (!body.empty() && !recv_all(s, body.data(), (int)body.size())) return -1;
    if (end != 0) return end;
    if (body.size() != (size_t)count * 2) return -1;

    out.resize(count);
    for (uint16_t i = 0; i < count; ++i)
        out[i] = (int16_t)(body[i * 2] | (body[i * 2 + 1] << 8));    // little endian
    return 0;
}

static SOCKET connect_plc(const char* ip, uint16_t port)
{
    SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (s == INVALID_SOCKET) return s;
    DWORD tmo = 3000;           // 3 s receive / send timeout
    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tmo, sizeof(tmo));
    setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tmo, sizeof(tmo));
    BOOL nodelay = TRUE;
    setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)&nodelay, sizeof(nodelay));

    sockaddr_in a = {};
    a.sin_family = AF_INET;
    a.sin_port = htons(port);
    if (inet_pton(AF_INET, ip, &a.sin_addr) != 1 ||
        connect(s, (sockaddr*)&a, sizeof(a)) == SOCKET_ERROR) {
        int err = WSAGetLastError();                   // closesocket() would clear it
        closesocket(s);
        WSASetLastError(err);
        return INVALID_SOCKET;
    }
    return s;
}

static void stamp(char* buf, size_t n)
{
    time_t t = time(nullptr);
    tm lt;
    localtime_s(&lt, &t);
    strftime(buf, n, "%H:%M:%S", &lt);
}

int main(int argc, char** argv)
{
    if (argc < 2) {
        printf("usage: mc_read <PLC_IP> [port=5000] [start=0] [count=2] [interval_ms=200] [polls=0]\n");
        return 1;
    }
    const char* ip   = argv[1];
    uint16_t port    = argc > 2 ? (uint16_t)atoi(argv[2]) : 5000;
    uint32_t start   = argc > 3 ? (uint32_t)atoi(argv[3]) : 0;
    uint16_t count   = argc > 4 ? (uint16_t)atoi(argv[4]) : 2;
    int interval_ms  = argc > 5 ? atoi(argv[5]) : 200;
    int polls        = argc > 6 ? atoi(argv[6]) : 0;       // 0 = forever
    if (count < 1 || count > 960) { printf("count must be 1..960\n"); return 1; }

    setvbuf(stdout, nullptr, _IONBF, 0);                  // print immediately (also when piped)

    WSADATA wsa;
    if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { printf("WSAStartup failed\n"); return 1; }
    SetConsoleCtrlHandler(on_ctrl, TRUE);

    printf("MC protocol 3E  %s:%u  D%u..D%u  every %d ms  (Ctrl+C to quit)\n",
           ip, port, start, start + count - 1, interval_ms);

    SOCKET s = INVALID_SOCKET;
    std::vector<int16_t> val, last;
    char ts[16];
    int n = 0;

    while (g_run && (polls == 0 || n < polls)) {
        if (s == INVALID_SOCKET) {
            s = connect_plc(ip, port);
            if (s == INVALID_SOCKET) {
                int err = WSAGetLastError();           // read before any CRT call clears it
                stamp(ts, sizeof(ts));
                printf("[%s] connect failed (WSA %d) - retry in 1 s\n", ts, err);
                Sleep(1000);
                continue;
            }
            stamp(ts, sizeof(ts));
            printf("[%s] connected\n", ts);
            last.clear();
        }

        int rc = read_d(s, start, count, val);
        ++n;
        if (rc < 0) {
            stamp(ts, sizeof(ts));
            printf("[%s] link error - reconnecting\n", ts);
            closesocket(s);
            s = INVALID_SOCKET;
            Sleep(500);
            continue;
        }
        if (rc > 0) {
            stamp(ts, sizeof(ts));
            printf("[%s] PLC end code 0x%04X\n", ts, rc);
        } else if (val != last) {                          // print only on change
            stamp(ts, sizeof(ts));
            printf("[%s]", ts);
            for (uint16_t i = 0; i < count; ++i)
                printf("  D%u = %6d", start + i, val[i]);
            printf("\n");
            last = val;
        }
        Sleep(interval_ms);
    }

    if (s != INVALID_SOCKET) closesocket(s);
    WSACleanup();
    printf("bye\n");
    return 0;
}

(3) 절별 해설

가. 프레임 조립 — build_read_d()

3장 <표 2> 를 그대로 코드로 옮긴 부분이다. 초기화 리스트에 바이트를 나열하는
방식을 택했는데, 이렇게 하면 표와 코드를 나란히 놓고 대조하기가 쉽다.
구조체에 #pragma pack(1) 을 걸어 캐스팅하는 방법도 있으나, 컴파일러와
플랫폼에 따라 패딩 동작이 달라질 여지가 있어 피했다.

        (uint8_t)(start), (uint8_t)(start >> 8), (uint8_t)(start >> 16),  // 선두 디바이스 번호 3바이트
        0xA8,                                                              // 디바이스 코드 D
        (uint8_t)(count), (uint8_t)(count >> 8)                            // 점수 2바이트

선두 디바이스 번호를 3바이트로 잘라 넣는 부분이 핵심이다.
start >> 16 까지만 쓰고 start >> 24 는 쓰지 않는다.
4바이트를 넣으면 뒤따르는 디바이스 코드가 한 칸 밀려 종료 코드 0xC061 이 돌아온다.

나. 정확히 n 바이트 수신 — recv_all()

static bool recv_all(SOCKET s, uint8_t* buf, int n)
{
    int got = 0;
    while (got < n) {
        int r = recv(s, (char*)buf + got, n - got, 0);
        if (r <= 0) return false;
        got += r;
    }
    return true;
}

TCP 는 스트림이지 메시지 단위가 아니다. 21바이트를 보냈다고 해서
상대가 recv() 한 번에 21바이트를 받는다는 보장이 없다. 네트워크
사정에 따라 11바이트와 10바이트로 나뉘어 도착할 수 있고, 반대로 연속한 두 응답이
한 번에 붙어서 올 수도 있다.

이 점을 간과하고 recv() 한 번의 반환값을 그대로 믿는 코드는
사무실 랜에서는 멀쩡히 돌다가 현장에 나가면 간헐적으로 깨진다. 재현이 어려운
버그의 전형이다. 처음부터 recv_all() 로 감싸 두는 것이 옳다.

다. 요청과 응답 1회 — read_d()

반환값 설계에 신경을 썼다. 세 가지 결과를 구분해야 하기 때문이다.

  • 0 — 정상. out 에 값이 채워진다.
  • > 0 — PLC 가 오류로 응답했다. 값 자체가 <표 5> 의 종료 코드이다.
    소켓은 살아 있다. 설정이나 요청 내용을 고쳐야 한다.
  • -1 — 링크 오류. 소켓이 끊겼거나 응답 형식이 깨졌다.
    재접속해야 한다.

이 둘을 뭉뚱그려 false 하나로 처리하면, 디바이스 번호를 잘못 넣어
0xC056 이 오는 상황에서도 프로그램이 끝없이 재접속을 시도하며 원인을 감춘다.

    uint8_t hdr[11];            // 서브헤더 .. 종료 코드
    if (!recv_all(s, hdr, sizeof(hdr))) return -1;
    if (hdr[0] != 0xD0 || hdr[1] != 0x00) return -1;                  // 3E 응답이 아니다
    uint16_t len = hdr[7] | (hdr[8] << 8);                            // 이 필드 다음 바이트 수
    uint16_t end = hdr[9] | (hdr[10] << 8);
    std::vector<uint8_t> body(len > 2 ? len - 2 : 0);

앞서 말한 2단계 수신이 여기에 있다. 고정 11바이트를 먼저 받아 응답 데이터 길이
len 과 종료 코드 end 를 확보한다. len 은
종료 코드 2바이트를 포함한 값이므로, 아직 받지 않은 데이터부는 len - 2
바이트이다. 오류 응답이면 len 이 2 이므로 추가 수신이 0 바이트가 되어
자연스럽게 넘어간다.

서브헤더를 0x00D0 인지 확인하는 줄도 중요하다. 엉뚱한 서비스가 그 포트를 잡고
있을 때 쓰레기 데이터를 값으로 오인하는 것을 막아 준다.

라. 접속과 타임아웃 — connect_plc()

    DWORD tmo = 3000;           // 송수신 3초 타임아웃
    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tmo, sizeof(tmo));
    setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tmo, sizeof(tmo));
    BOOL nodelay = TRUE;
    setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)&nodelay, sizeof(nodelay));

소켓 옵션 세 가지를 건다.

수신 타임아웃이 없으면 PLC 가 응답하지 않을 때
recv() 가 영원히 블록된다. 프로그램이 멎은 것처럼 보이고 Ctrl+C 도
듣지 않는다. 3초로 잡았다. 감시 타이머를 4초로 두었으므로, PLC 가 오류라도
응답할 시간은 충분하다.

TCP_NODELAY 는 네이글 알고리즘(Nagle’s algorithm)을 끈다.
네이글은 작은 패킷을 모아서 한 번에 보내 대역폭을 아끼는 최적화인데, 우리처럼
21바이트짜리 요청을 200 ms 마다 주고받는 용도에서는 수십 ms 의 불필요한
지연
만 낳는다. 실시간성이 필요한 산업 통신에서는 거의 항상 꺼야 한다.

★주 3) WSAGetLastError() 는 즉시 읽어야 한다.

        int err = WSAGetLastError();                   // closesocket() 이 지워 버리기 전에
        closesocket(s);
        WSASetLastError(err);

Winsock 의 오류 코드는 스레드 지역 변수 하나에 담겨 있고, 뒤이어 호출하는
어떤 소켓 함수나 C 런타임 함수라도 이 값을 덮어쓸 수 있다.
closesocket() 을 먼저 부르고 나서 WSAGetLastError() 를
읽으면 엉뚱한 코드가 나오거나 0 이 나온다. 실제로 “connect failed (WSA 0)”
같은 무의미한 메시지를 보게 된다. 오류를 먼저 퍼 담아 두고, 정리 작업 뒤에
WSASetLastError() 로 되돌려 놓는다.

마. 메인 루프

메인 루프는 “소켓이 없으면 붙고, 있으면 읽는다” 는 단순한 상태 기계이다.

    while (g_run && (polls == 0 || n < polls)) {
        if (s == INVALID_SOCKET) {
            s = connect_plc(ip, port);
            if (s == INVALID_SOCKET) {
                ...
                Sleep(1000);
                continue;
            }
            ...
            last.clear();           // 재접속 후에는 첫 값을 무조건 출력한다
        }

        int rc = read_d(s, start, count, val);
        ...
    }

재접속 직후 last.clear() 를 부르는 이유는, 끊겨 있던 동안 값이
그대로였다면 “변화가 없다”는 이유로 아무것도 출력되지 않아 사용자가
복구 여부를 알 수 없기 때문이다. 재접속 후 첫 값은 항상 보여 준다.

종료 처리는 콘솔 컨트롤 핸들러로 받는다.

static volatile bool g_run = true;
static BOOL WINAPI on_ctrl(DWORD) { g_run = false; return TRUE; }
...
    SetConsoleCtrlHandler(on_ctrl, TRUE);

Ctrl+C 를 누르면 g_run 이 false 가 되어 루프를 빠져나오고,
closesocket() 과 WSACleanup() 을 거쳐 정상 종료한다.
핸들러가 없으면 프로세스가 즉사하여 소켓이 TIME_WAIT 로 남는다. 곧바로 다시
실행할 때 PLC 의 커넥션 수를 잡아먹는 원인이 된다.

마지막으로 출력 버퍼링을 끈다.

    setvbuf(stdout, nullptr, _IONBF, 0);      // 파이프로 넘겨도 즉시 출력

표준 출력이 콘솔이면 줄 단위 버퍼링이지만, 파이프로 연결하면 C 런타임이 자동으로
전(全) 버퍼링으로 바꾼다. 4 KB 가 찰 때까지 아무것도 나오지 않는다는
뜻이다. 9장에서 이 출력을 파이썬으로 받을 것이므로 미리 꺼 둔다.


6. 가상 PLC 구현

(1) 필요성

2장 ★주 1) 에서 밝혔듯이 GX Simulator2 는 MC 프로토콜 서버 소켓을 열지 않는다.
실기는 아직 현장에 있고, 사무실에서는 시뮬레이터밖에 없다. 그렇다면 개발을 멈춰야
하는가? 그렇지 않다.

우리가 검증하려는 것은 두 가지로 나뉜다.

  1. 프레임을 제대로 만들고 해석하는가 — 이것은 상대가 규약대로
    응답하기만 하면 확인할 수 있다. 상대가 진짜 PLC 일 필요는 없다.
  2. 실기의 이더넷 설정이 맞는가 — 이것은 실기 앞에서만 확인할 수 있다.

1번을 사무실에서 끝내 두면, 현장에서 할 일은 IP 주소를 바꾸는 것뿐이다.
그래서 3E 프레임으로 응답하는 가짜 PLC 를 파이썬으로 만든다.
100줄이 채 되지 않는다.

(2) 전체 소스 — fake_plc.py

# -*- coding: utf-8 -*-
"""
fake_plc.py - MC protocol 3E (binary) 로 응답하는 가짜 PLC.
실제 Q03UDE 없이 mc_read.exe 를 시험할 때 쓴다.

    py fake_plc.py            (127.0.0.1:5000)

D0 는 1 초마다 1 씩 증가한다 (X1 을 누를 때 D0 가 증가하는 래더를 흉내).
"""
import socket
import struct
import threading
import time

D = [0] * 1024


def ticker():
    while True:
        time.sleep(1.0)
        D[0] = (D[0] + 1) & 0x7FFF


def handle(conn):
    with conn:
        while True:
            hdr = conn.recv(9)
            if len(hdr) < 9:
                return
            sub, net, pc, io, st, ln = struct.unpack("<HBBHBH", hdr)
            body = b""
            while len(body) < ln:
                chunk = conn.recv(ln - len(body))
                if not chunk:
                    return
                body += chunk
            timer, cmd, subcmd = struct.unpack("<HHH", body[:6])
            head = body[6] | body[7] << 8 | body[8] << 16
            dev, pts = body[9], struct.unpack("<H", body[10:12])[0]
            if sub != 0x0050 or cmd != 0x0401 or subcmd != 0 or dev != 0xA8:
                resp = struct.pack("<HBBHBHH", 0x00D0, net, pc, io, st, 2, 0xC059)
            else:
                data = b"".join(struct.pack("<h", D[head + i]) for i in range(pts))
                resp = struct.pack("<HBBHBHH", 0x00D0, net, pc, io, st, 2 + len(data), 0) + data
            conn.sendall(resp)


def main():
    threading.Thread(target=ticker, daemon=True).start()
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", 5000))
    srv.listen()
    print("fake PLC listening on 127.0.0.1:5000")
    while True:
        c, _ = srv.accept()
        threading.Thread(target=handle, args=(c,), daemon=True).start()


if __name__ == "__main__":
    main()

(3) 해설

가. 요청 해석

            hdr = conn.recv(9)
            if len(hdr) < 9:
                return
            sub, net, pc, io, st, ln = struct.unpack("<HBBHBH", hdr)

포맷 문자열 "<HBBHBH" 가 <표 2> 의 앞부분 9바이트에 정확히
대응한다. 맨 앞의 < 가 리틀 엔디안 지정이다. 이것을 빼면 파이썬은
플랫폼 기본 정렬과 엔디안을 쓰므로 패딩이 끼어들어 크기가 달라진다.
< 를 빠뜨리는 것이 파이썬으로 산업 프로토콜을 다룰 때 가장
흔한 실수
이다.

이어서 요청 데이터 길이 ln 만큼을 마저 읽는다. C++ 쪽의
recv_all() 과 같은 이유로, 여기서도 루프를 돌며 모일 때까지 받는다.

나. 응답 생성

            if sub != 0x0050 or cmd != 0x0401 or subcmd != 0 or dev != 0xA8:
                resp = struct.pack("<HBBHBHH", 0x00D0, net, pc, io, st, 2, 0xC059)
            else:
                data = b"".join(struct.pack("<h", D[head + i]) for i in range(pts))
                resp = struct.pack("<HBBHBHH", 0x00D0, net, pc, io, st, 2 + len(data), 0) + data

지원하지 않는 요청에는 종료 코드 0xC059 로 응답한다. 단순히 무시하거나
소켓을 끊어 버리면 클라이언트의 오류 처리 경로를 시험할 수 없다.
가짜 서버가 오류도 규약대로 낼 수 있어야 클라이언트가 현장에서
어떻게 반응할지 미리 볼 수 있다.

응답 데이터 길이 필드에 2 + len(data) 를 넣는 부분이 <표 3> 의
“종료 코드 2 + 데이터” 규칙이다. 오류 응답에서는 데이터가 없으므로 2 가 된다.

값을 "<h"(부호 있는 16비트)로 포장하는 것도 의도적이다.
MELSEC 워드 디바이스의 기본 해석과 맞추어, C++ 쪽 int16_t 와 짝을 이룬다.

다. 값이 변하게 만들기

def ticker():
    while True:
        time.sleep(1.0)
        D[0] = (D[0] + 1) & 0x7FFF

데몬 스레드가 1초마다 D0 을 1 씩 올린다. 4장 (3)절의 INCP D0 래더를
사람이 X1 을 계속 누르는 상황으로 흉내 낸 것이다. & 0x7FFF 로
32767 에서 0 으로 돌아가게 하여 16비트 부호 있는 정수 범위를 넘지 않게 했다.

★주 4) 이 D0 는 PLC 의 D0 가 아니다.
당연한 말 같지만, 실제로 필자가 한동안 착각했던 지점이다. 화면에 숫자가
올라가는 것을 보면 통신이 되고 있다고 믿게 된다. 그러나
fake_plc.py 의 D0 는 파이썬 리스트 D 의 0번
원소
일 뿐이며, 같은 PC 에서 돌고 있는 GX Simulator2 의 디바이스 메모리와는
아무 관계가 없다. 시뮬레이터에서 D0 을 2 로 만들어 놓아도 모니터에는 288, 289,
290 … 이 찍힌다. 값이 1초마다 정확히 1씩 올라간다면 그것은 래더가 아니라
파이썬 타이머를 보고 있다는 신호다. 확인 방법은 8장 (1)절에 있다.

라. 127.0.0.1 로 묶은 이유

    srv.bind(("127.0.0.1", 5000))

루프백 주소에만 바인딩하면 같은 PC 안에서만 접속할 수 있다. 가짜 PLC 가 사내
네트워크에 노출되어 다른 사람이 실기로 오인하는 사고를 막는다. 다른 PC 에서
시험해야 한다면 ("0.0.0.0", 5000) 으로 바꾸되, 시험이 끝나면 되돌려
놓는 것이 안전하다.

SO_REUSEADDR 를 켜 둔 것은 프로그램을 껐다 바로 켤 때
TIME_WAIT 상태의 포트 때문에 “Address already in use” 가 나는 것을
피하기 위해서이다. 개발 중에는 이 재시작을 수십 번 반복하게 된다.


7. 빌드와 실행

(1) 빌드 — build.bat

@echo off
rem Build mc_read.exe with Visual Studio 2022 (x64)
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" >nul
cd /d "%~dp0"
cl /nologo /EHsc /O2 /W4 mc_read.cpp /Fe:mc_read.exe

Visual Studio 의 통합 개발 환경을 열 필요 없이, vcvars64.bat 로
컴파일러 환경 변수를 잡은 뒤 cl 을 직접 부른다. 옵션의 뜻은 다음과 같다.

옵션 뜻
/nologo 컴파일러 배너 출력을 끈다
/EHsc 표준 C++ 예외 처리 모델. std::vector 를 쓰므로 필요하다
/O2 속도 최적화
/W4 경고 수준을 높게. 통신 코드는 형 변환 실수가 잦으므로 경고를 많이 받는 편이 낫다
/Fe: 출력 실행 파일 이름 지정

ws2_32.lib 링크는 소스 안에서 처리했다.

#pragma comment(lib, "ws2_32.lib")

이렇게 해 두면 빌드 명령이 짧아지고, 나중에 이 파일을 다른 프로젝트에 옮겨도
링크 설정을 다시 해 줄 필요가 없다.

(2) 실행 — run_monitor.bat

mc_read.exe 는 IP 를 인자로 받는 콘솔 프로그램이다. 탐색기에서
그냥 더블클릭하면 인자가 없으므로 사용법 한 줄을 찍고 창이 즉시
닫힌다.
더블클릭으로 쓸 수 있도록 런처를 만들어 둔다.

@echo off
rem Double-click launcher for mc_read.exe
rem Edit PLC_IP below: 127.0.0.1 = fake_plc.py,  192.168.3.39 = real Q03UDE
cd /d "%~dp0"

set PLC_IP=127.0.0.1
set PLC_PORT=5000
set D_START=0
set D_COUNT=4
set INTERVAL=200

title MC protocol monitor - %PLC_IP%
if not exist "%~dp0mc_read.exe" (
  echo mc_read.exe not found. Run build.bat first.
  pause
  exit /b 1
)
"%~dp0mc_read.exe" %PLC_IP% %PLC_PORT% %D_START% %D_COUNT% %INTERVAL%

echo.
pause

맨 아래의 pause 가 있어야 프로그램이 끝난 뒤에도 창이 남아 마지막
메시지를 읽을 수 있다. 접속에 실패하고 창이 닫혀 버리면 원인을 볼 수 없다.

★주 5) 실행 파일을 절대 경로로 부를 것.
처음에는 cd /d "%~dp0" 로 폴더를 옮긴 뒤 mc_read.exe 를
이름만으로 불렀다. 대개는 동작하지만, 환경 변수
NoDefaultCurrentDirectoryInExePath 가 1 로 설정된 환경에서는
현재 폴더를 실행 파일 검색 경로에서 제외하므로
“내부 또는 외부 명령이 아닙니다” 오류가 난다. 이 변수는 보안 목적으로
일부 개발 도구(Git Bash 등)가 설정해 두며, 그런 셸에서 배치 파일을 부르면 값이
그대로 상속된다. "%~dp0mc_read.exe" 처럼 배치 파일 자신의 경로를
붙여 절대 경로로 부르면 환경에 관계없이 동작한다.

(3) 실행 결과

가상 PLC 를 먼저 띄운다.

C:\...\plc_monitor> py fake_plc.py
fake PLC listening on 127.0.0.1:5000

이 한 줄이 나오고 아무 반응이 없는 것이 정상이다. 서버는 접속이
들어와야 일을 하는데, 접속 로그를 찍는 코드를 넣지 않았기 때문이다. 이 상태에서
다른 창을 열어 모니터를 실행한다.

C:\...\plc_monitor> mc_read.exe 127.0.0.1 5000 0 4 200
MC protocol 3E  127.0.0.1:5000  D0..D3  every 200 ms  (Ctrl+C to quit)
[08:39:34] connected
[08:39:34]  D0 =     82  D1 =      0  D2 =      0  D3 =      0
[08:39:35]  D0 =     84  D1 =      0  D2 =      0  D3 =      0
[08:39:36]  D0 =     85  D1 =      0  D2 =      0  D3 =      0
[08:39:37]  D0 =     86  D1 =      0  D2 =      0  D3 =      0

200 ms 마다 폴링하지만 출력은 1초에 한 줄씩만 나온다. D0 이 1초마다 바뀌고,
변화가 있을 때만 찍기 때문이다. 설계 방침 2번이 의도대로 동작하고 있음을 보여 준다.

인자를 바꾸면 읽는 범위와 주기를 조절할 수 있다.

명령 동작
mc_read 192.168.3.39 실기의 D0, D1 을 200 ms 주기로
mc_read 192.168.3.39 5000 0 10 D0 ~ D9
mc_read 192.168.3.39 5000 100 20 1000 D100 ~ D119 를 1초 주기로
mc_read 127.0.0.1 5000 0 4 200 50 50회만 읽고 종료 (스크립트용)

8. 검증과 함정

여기까지 따라 하면 화면에 숫자가 올라간다. 그러나 숫자가 올라간다는 것이
곧 PLC 를 읽고 있다는 뜻은 아니다.
이 장에서는 실제로 부딪혔던 다섯 가지
함정을 정리한다.

(1) 지금 읽고 있는 것이 무엇인가

가장 위험한 착각이다. GX Simulator2 에서 D0 을 2 로 만들어 놓고 모니터를 띄웠더니
D0 이 288, 289, 290 … 으로 올라갔다. 통신이 되는 것처럼 보이지만, 실제로는
가상 PLC 의 파이썬 카운터를 읽고 있었다.

확인은 포트의 주인을 직접 보는 것이 확실하다. 명령 프롬프트에서
다음을 실행한다.

C:\> netstat -ano | findstr :5000
  TCP    127.0.0.1:5000         0.0.0.0:0              LISTENING       12808

C:\> tasklist /FI "PID eq 12808"
이미지 이름                     PID 세션 이름
========================= ======== ================
python.exe                   12808 Console

포트 5000 을 잡고 있는 것이 python.exe 이다. 즉
fake_plc.py 이다. 만약 실기에 붙고 있다면 로컬에는 LISTENING 이
없고, 대신 다음과 같이 원격으로 나가는 ESTABLISHED 연결이 보여야 한다.

  TCP    192.168.3.10:51234     192.168.3.39:5000      ESTABLISHED     7412

더 간단한 확인법도 있다. 가상 PLC 를 끄고 모니터를 다시 실행해 본다.
그래도 값이 나온다면 진짜 PLC 를 읽고 있는 것이고, 다음과 같이 접속 실패가
반복된다면 지금까지 읽던 것은 가상 PLC 였다.

[09:12:03] connect failed (WSA 10061) - retry in 1 s
[09:12:04] connect failed (WSA 10061) - retry in 1 s

WSA 10061 은 WSAECONNREFUSED, 즉 “그 주소에서 아무도 듣고 있지
않다”는 뜻이다.

(2) GX Simulator2 는 소켓을 열지 않는다

2장 ★주 1) 에서 언급한 내용을 실측으로 확인한다. GX Simulator2 를 켠 상태에서
전체 LISTENING 포트를 훑어보면, 미쓰비시 시뮬레이터로 볼 만한 프로세스가
포트를 하나도 열지 않는다. 5000 번은 물론이고 0x1388 에 해당하는
어떤 포트도 없다.

GX Works2 가 시뮬레이터에 접속하여 디바이스 모니터를 보여 주는 것은 TCP 가 아니라
프로세스 간 내부 전용 경로를 쓰기 때문이다. 외부 프로그램이 끼어들 틈이 없다.

따라서 선택지는 다음 세 가지로 정리된다.

<표 7> 시뮬레이터 환경에서의 선택지

방안 읽는 값 필요 조건 적합한 상황
가상 PLC (본 글) 내가 정한 값 없음 클라이언트 · 상위 연동 로직 검증
MX Component 시뮬레이터의 실제 디바이스 유료 설치, COM 코드 별도 작성 래더 로직까지 함께 검증
실기 접속 실제 설비 장비 · 결선 · 파라미터 최종 확인

주의할 점은 MX Component 를 택하면 mc_read.cpp 를 쓸 수 없고 COM
호출 코드를 따로 만들어야 하며, 현장에 나가면 다시 MC 프로토콜로 돌아와야 하므로
코드가 두 벌이 된다는 것이다. 래더 자체를 검증할 목적이 아니라면
가상 PLC 쪽이 경제적이다.

(3) 배치 파일의 실행 파일 탐색

7장 ★주 5) 에서 다룬 NoDefaultCurrentDirectoryInExePath 문제이다.
현상은 다음과 같다.

'mc_read.exe'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는
배치 파일이 아닙니다.

같은 폴더에 파일이 분명히 있는데도 이 메시지가 나온다. 진단은 배치 파일 안에서
현재 폴더와 파일 존재를 함께 찍어 보면 된다.

@echo off
cd /d "%~dp0"
echo CWD=%CD%
dir /b mc_read.exe

CWD 도 맞고 dir 도 파일을 찾는데 실행만 안 된다면
이 환경 변수를 의심한다. 확인은 echo %NoDefaultCurrentDirectoryInExePath%
이다. 해결은 절대 경로 호출이다.

★주 6) 배치 파일은 ASCII 영문 + CRLF 로.
한국어 윈도우의 cmd 는 기본 코드 페이지가 CP949 이다. 배치 파일을 UTF-8 한글로
저장하거나 줄바꿈을 LF 로 두면, cmd 가 파일을 읽는 오프셋을 잃어 주석 조각을
명령으로 실행하는 기묘한 오류가 발생한다. 배치 파일 본문은 영문 ASCII
로 쓰고, 줄바꿈은 반드시 CRLF 로 저장한다. 파일 안에
chcp 65001 을 넣는 것도 같은 이유로 피한다. 한글 메시지가 꼭 필요하면
배치 대신 PowerShell 스크립트(UTF-8 BOM)를 쓴다.

(4) 감시 타이머와 소켓 타임아웃의 관계

두 시간 값이 따로 논다는 점을 놓치기 쉽다.

  • 감시 타이머(프레임 안의 값) — PLC 가 “이 안에 처리하지
    못하면 오류로 답하라”는 지시. 본 구현은 4초.
  • 소켓 수신 타임아웃(SO_RCVTIMEO) — PC 가
    “이 안에 아무것도 안 오면 포기한다”는 한계. 본 구현은 3초.

소켓 타임아웃이 감시 타이머보다 짧으면, PLC 가 오류 응답을 보내는
도중에 PC 가 먼저 포기해 버린다. 그러면 원인을 알려 주는 종료 코드를 영영 보지
못하고 “link error” 만 반복된다. 반대로 소켓 타임아웃이 지나치게
길면 케이블이 빠졌을 때 복구가 느려진다.

본 구현은 3초 / 4초로 두었는데, 엄밀히는 소켓 쪽을 감시 타이머보다 조금 길게
(예: 5초) 잡는 편이 종료 코드를 놓치지 않는다. 다만 폴링 주기가 200 ms 인
용도에서는 4초를 기다리는 시점에 이미 비정상이므로, 빠른 재접속을 택했다.
현장 상황에 따라 조정하면 된다.

(5) 커넥션을 유지할 것인가 매번 열 것인가

요청할 때마다 TCP 연결을 새로 여는 구현도 가능하다. 코드는 단순해지지만 다음
문제가 있다.

  1. 연결 · 해제 왕복이 폴링 주기에 그대로 더해진다. 200 ms 주기에서는 부담이 크다.
  2. MELSEC 의 커넥션 수는 유한하다(내장 이더넷 포트는 16개). 빠르게 열고 닫으면
    TIME_WAIT 로 남은 소켓이 쌓여 새 접속이 거부될 수 있다.

따라서 한 번 연결해 두고 계속 쓰는 방식을 택했다. 대신 끊겼을 때
스스로 복구하는 로직이 반드시 있어야 한다. 5장 (3)-마 의 상태 기계가 그 역할을 한다.


9. 디지털 트윈 연동을 위한 파이썬 클라이언트

(1) 왜 파이썬 판이 따로 필요한가

최종 목표는 PLC 의 D 레지스터 값에 따라 Blender 안의 3차원 리프트가 실제와 같이
움직이는 것이다. Blender 는 파이썬을 내장하고 있으므로, 외부 프로세스의 출력을
파싱하는 것보다 같은 프로세스 안에서 직접 소켓을 여는 편이 지연도
적고 구조도 단순하다.

앞서 만든 C++ 판과 동일한 규약을 파이썬으로 구현한다. 쓰기(커맨드 0x1401)도
함께 넣었다.

(2) 전체 소스 — mc_client.py

# -*- coding: utf-8 -*-
"""
mc_client.py - MELSEC MC 프로토콜 3E (바이너리) 클라이언트.

mc_read.cpp 와 같은 일을 파이썬으로 한다. Blender 등 파이썬이 내장된 환경에서
PLC 디바이스를 직접 읽기 위한 것이다.

    from mc_client import McClient
    with McClient("192.168.3.39") as plc:
        print(plc.read_d(0, 4))          # [2, 0, 0, 0]

단독 실행하면 mc_read.exe 와 같은 모니터로 동작한다.

    py mc_client.py 127.0.0.1 5000 0 4 0.2
"""
import socket
import struct
import sys
import time

# 디바이스 코드 (바이너리)
DEV_D = 0xA8
DEV_W = 0xB4
DEV_R = 0xAF
DEV_M = 0x90
DEV_X = 0x9C
DEV_Y = 0x9D

END_CODE = {
    0xC050: "ASCII/바이너리 설정 불일치",
    0xC056: "디바이스 번호 범위 초과",
    0xC059: "커맨드/서브커맨드 지정 오류",
    0xC05B: "해당 디바이스 처리 불가",
    0xC05C: "요청 내용 오류",
    0xC05F: "실행 불가 요청",
    0xC060: "요청 데이터 오류",
    0xC061: "요청 데이터 길이 불일치",
}


class McError(Exception):
    """PLC 가 0 이 아닌 종료 코드로 응답했다. 소켓은 살아 있다."""

    def __init__(self, code):
        self.code = code
        super().__init__("PLC end code 0x%04X (%s)"
                         % (code, END_CODE.get(code, "알 수 없음")))


class McClient:
    def __init__(self, ip, port=5000, timeout=3.0):
        self.ip = ip
        self.port = port
        self.timeout = timeout
        self.sock = None

    # --- 접속 -------------------------------------------------------
    def connect(self):
        s = socket.create_connection((self.ip, self.port), self.timeout)
        s.settimeout(self.timeout)
        s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)   # 네이글 끄기
        self.sock = s
        return self

    def close(self):
        if self.sock is not None:
            try:
                self.sock.close()
            finally:
                self.sock = None

    def __enter__(self):
        return self.connect()

    def __exit__(self, *exc):
        self.close()

    # --- 저수준 ----------------------------------------------------
    def _recv_all(self, n):
        """정확히 n 바이트를 받는다. TCP 는 스트림이므로 쪼개져 온다."""
        buf = b""
        while len(buf) < n:
            chunk = self.sock.recv(n - len(buf))
            if not chunk:
                raise ConnectionError("상대가 접속을 끊었다")
            buf += chunk
        return buf

    def _request(self, payload):
        """payload = 감시 타이머 이후의 바이트열. 응답 데이터부를 돌려준다."""
        frame = struct.pack("<HBBHBH",
                            0x0050,      # 서브헤더 (요청)
                            0x00,        # 네트워크 번호
                            0xFF,        # PC 번호
                            0x03FF,      # 요청 대상 모듈 I/O 번호 (자국 CPU)
                            0x00,        # 요청 대상 모듈 국번
                            len(payload) + 2)     # 요청 데이터 길이 (감시 타이머 포함)
        frame += struct.pack("<H", 0x0010) + payload   # 감시 타이머 16 x 250ms = 4s
        self.sock.sendall(frame)

        hdr = self._recv_all(11)
        if hdr[0] != 0xD0 or hdr[1] != 0x00:
            raise ConnectionError("3E 응답이 아니다")
        ln, end = struct.unpack("<HH", hdr[7:11])
        body = self._recv_all(ln - 2) if ln > 2 else b""
        if end != 0:
            raise McError(end)
        return body

    # --- 고수준 ----------------------------------------------------
    def read_words(self, device, start, count):
        """워드 단위 일괄 읽기 (커맨드 0x0401). 16비트 부호 있는 정수 리스트."""
        if not 1 <= count <= 960:
            raise ValueError("점수는 1~960 이어야 한다")
        payload = struct.pack("<HH", 0x0401, 0x0000)                  # 커맨드/서브커맨드
        payload += bytes((start & 0xFF, (start >> 8) & 0xFF, (start >> 16) & 0xFF))
        payload += bytes((device,)) + struct.pack("<H", count)
        body = self._request(payload)
        if len(body) != count * 2:
            raise ConnectionError("응답 데이터 길이가 맞지 않다")
        return list(struct.unpack("<%dh" % count, body))

    def read_d(self, start, count=1):
        return self.read_words(DEV_D, start, count)

    def write_words(self, device, start, values):
        """워드 단위 일괄 쓰기 (커맨드 0x1401)."""
        count = len(values)
        if not 1 <= count <= 960:
            raise ValueError("점수는 1~960 이어야 한다")
        payload = struct.pack("<HH", 0x1401, 0x0000)
        payload += bytes((start & 0xFF, (start >> 8) & 0xFF, (start >> 16) & 0xFF))
        payload += bytes((device,)) + struct.pack("<H", count)
        payload += struct.pack("<%dh" % count, *values)
        self._request(payload)

    def write_d(self, start, values):
        self.write_words(DEV_D, start, values)


def main():
    ip = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 5000
    start = int(sys.argv[3]) if len(sys.argv) > 3 else 0
    count = int(sys.argv[4]) if len(sys.argv) > 4 else 2
    interval = float(sys.argv[5]) if len(sys.argv) > 5 else 0.2
    polls = int(sys.argv[6]) if len(sys.argv) > 6 else 0

    print("MC protocol 3E  %s:%d  D%d..D%d  every %.0f ms  (Ctrl+C to quit)"
          % (ip, port, start, start + count - 1, interval * 1000), flush=True)

    plc, last, n = None, None, 0
    try:
        while polls == 0 or n < polls:
            if plc is None:
                try:
                    plc = McClient(ip, port).connect()
                    print("[%s] connected" % time.strftime("%H:%M:%S"), flush=True)
                    last = None
                except OSError as e:
                    print("[%s] connect failed (%s) - retry in 1 s"
                          % (time.strftime("%H:%M:%S"), e), flush=True)
                    time.sleep(1.0)
                    continue
            try:
                val = plc.read_d(start, count)
                n += 1
            except McError as e:                      # 소켓은 살아 있다
                print("[%s] %s" % (time.strftime("%H:%M:%S"), e), flush=True)
                time.sleep(interval)
                continue
            except OSError:                           # 링크 오류 -> 재접속
                print("[%s] link error - reconnecting" % time.strftime("%H:%M:%S"), flush=True)
                plc.close()
                plc = None
                time.sleep(0.5)
                continue

            if val != last:
                print("[%s]" % time.strftime("%H:%M:%S")
                      + "".join("  D%d = %6d" % (start + i, v) for i, v in enumerate(val)),
                      flush=True)
                last = val
            time.sleep(interval)
    except KeyboardInterrupt:
        pass
    finally:
        if plc is not None:
            plc.close()
        print("bye")


if __name__ == "__main__":
    main()

(3) 사용법

라이브러리로 쓸 때는 with 문으로 감싼다. 접속과 해제가 자동으로
처리된다.

from mc_client import McClient, McError

with McClient("192.168.3.39") as plc:
    d = plc.read_d(0, 4)          # D0 ~ D3
    print(d)                      # [2, 0, 0, 0]
    plc.write_d(100, [1, 0])      # D100 = 1, D101 = 0

오류는 두 종류로 나뉜다. McError 는 PLC 가 규약대로 거절한 것이므로
소켓은 살아 있고, OSError 는 링크가 끊긴 것이므로 재접속해야 한다.
C++ 판의 반환값 설계와 같은 구분이다.

try:
    v = plc.read_d(0, 4)
except McError as e:              # 소켓은 살아 있다. 요청을 고쳐야 한다
    print(e.code, e)              # 0xC059 커맨드/서브커맨드 지정 오류
except OSError:                   # 링크가 끊겼다. 다시 붙어야 한다
    reconnect()

단독 실행하면 mc_read.exe 와 같은 모니터로 동작한다.

C:\...\plc_monitor> py mc_client.py 127.0.0.1 5000 0 4 0.2
MC protocol 3E  127.0.0.1:5000  D0..D3  every 200 ms  (Ctrl+C to quit)
[08:59:35] connected
[08:59:35]  D0 =   1283  D1 =      0  D2 =      0  D3 =      0
[08:59:36]  D0 =   1284  D1 =      0  D2 =      0  D3 =      0
[08:59:37]  D0 =   1285  D1 =      0  D2 =      0  D3 =      0

(4) Blender 연동의 얼개

Blender 에서는 모달 오퍼레이터(modal operator)나 타이머 핸들러로 주기적으로
디바이스를 읽고, 그 값을 오브젝트의 변환(transform)에 반영한다. 뼈대만 보이면
다음과 같다.

import bpy
from mc_client import McClient

PLC_IP   = "192.168.3.39"
D_HEIGHT = 100          # 리프트 현재 높이 [mm] 가 들어 있는 디바이스


class PlcSyncOperator(bpy.types.Operator):
    bl_idname = "wm.plc_sync"
    bl_label  = "PLC Sync"

    _timer = None
    _plc   = None

    def modal(self, context, event):
        if event.type == "ESC":
            return self.cancel(context)
        if event.type == "TIMER":
            try:
                mm = self._plc.read_d(D_HEIGHT, 1)[0]
            except Exception:
                return {"PASS_THROUGH"}          # 다음 틱에서 재시도
            cage = bpy.data.objects.get("LiftCage")
            if cage is not None:
                cage.location.z = mm / 1000.0    # mm -> m
        return {"PASS_THROUGH"}

    def execute(self, context):
        self._plc  = McClient(PLC_IP).connect()
        self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
        context.window_manager.modal_handler_add(self)
        return {"RUNNING_MODAL"}

    def cancel(self, context):
        context.window_manager.event_timer_remove(self._timer)
        self._plc.close()
        return {"CANCELLED"}

여기서 주의할 점이 두 가지 있다.

가. 통신을 렌더 스레드에서 블록하지 말 것. 위 코드는 타임아웃이
3초이므로, PLC 가 응답하지 않으면 Blender 화면이 3초 동안 멈춘다. 실제 운용에서는
별도 스레드에서 읽어 최신 값을 공유 변수에 담아 두고, 타이머는 그 변수만 참조하도록
바꾸는 것이 좋다.

나. 단위를 명시할 것. PLC 의 D 레지스터는 단위가 없는 정수일
뿐이다. 그것이 mm 인지 0.1 mm 인지 펄스 수인지는 래더 설계자와 합의해야 한다.
16비트 부호 있는 정수는 32767 이 한계이므로, 6.5 m 행정을 0.1 mm 단위로 표현하려면
32비트가 필요하고 그러면 연속한 두 워드를 묶어 읽어야 한다.


10. 맺음말

MC 프로토콜 3E 프레임을 직접 구현하여 MELSEC PLC 의 디바이스를 읽는 과정을
정리하였다. 요점을 다시 추리면 다음과 같다.

  1. 3E 바이너리 프레임은 21바이트의 요청과 11바이트 + 데이터의 응답으로 이루어진다.
    2바이트 이상의 수치는 모두 리틀 엔디안이고, 요청 데이터 길이는
    자기 다음부터를 센다.
  2. PLC 측은 래더가 필요 없으나 파라미터 설정과 CPU 리셋이 필요하다.
    교신 데이터 코드를 바이너리로 맞추고, 포트 번호는 16진수 입력란임을 기억한다.
  3. TCP 는 스트림이므로 정확히 n 바이트를 받는 루프가 반드시 있어야 한다.
    응답은 고정 11바이트를 먼저 읽는 2단계 수신으로 처리한다.
  4. 오류는 PLC 가 규약대로 거절한 것과 링크가 끊긴 것을
    구분해야 한다. 전자는 요청을 고칠 일이고 후자는 재접속할 일이다.
  5. GX Simulator2 는 소켓을 열지 않으므로 가상 PLC 로 클라이언트를 먼저
    완성
    해 두고, 현장에서는 IP 만 바꾼다.
  6. 화면에 숫자가 올라간다고 통신이 되는 것이 아니다. 포트의 주인을
    확인
    하는 습관을 들인다.

다음 단계는 이 파이프라인 위에 실제 움직임을 얹는 일이다. 1.5톤 리프트 시스템의
3차원 모델은 이미 IGES 에서 변환하여 부품 1,533개, 삼각형 346만 개 규모로
Blender 씬에 올라와 있다. 여기에 승강 리그를 붙이고 D 레지스터 값을 물리면
디지털 트윈의 첫 형태가 나온다. 그 과정은 별도의 글로 정리할 예정이다.


부록 A. 3E 프레임과 4E 프레임

4E 프레임은 3E 에 시리얼 번호(serial number) 4바이트가 앞에 붙은
형태이다. 요청마다 번호를 매겨 두면, 응답이 뒤섞여 도착해도 어느 요청에 대한
것인지 짝을 지을 수 있다. UDP 를 쓰거나 여러 요청을 파이프라인으로 던질 때 유용하다.

<표 8> 3E 와 4E 의 머리 부분 비교

  3E 요청 4E 요청
서브헤더 50 00 54 00
시리얼 번호 없음 xx xx (2바이트)
고정값 없음 00 00 (2바이트)
이후 네트워크 번호부터 동일

응답의 서브헤더는 3E 가 D0 00, 4E 가 D4 00 이다.
본 글의 구현을 4E 로 바꾸려면 프레임 앞에 4바이트를 더 붙이고, 응답에서
고정 11바이트 대신 15바이트를 먼저 읽도록 고치면 된다. 요청·응답을 1:1 로
주고받는 동기식 폴링에서는 3E 로 충분하다.

부록 B. 자주 쓰는 커맨드

커맨드 서브커맨드 기능 비고
0x0401 0x0000 일괄 읽기 (워드) 본 글에서 사용
0x0401 0x0001 일괄 읽기 (비트) 1점 = 1비트
0x1401 0x0000 일괄 쓰기 (워드) RUN 중 쓰기 허용 필요
0x1401 0x0001 일괄 쓰기 (비트)  
0x0403 0x0000 랜덤 읽기 (워드) 흩어진 디바이스를 한 번에
0x1402 0x0000 랜덤 쓰기 (워드)  
0x0619 0x0000 CPU 형명 읽기 접속 확인용으로 편리
0x1001 0x0000 원격 RUN 취급 주의
0x1002 0x0000 원격 STOP 취급 주의

한 번에 읽을 수 있는 점수는 워드 단위 960점, 비트 단위
7168점이 상한이다. 이를 넘기면 종료 코드 0xC051~0xC054 가 돌아온다.
더 많은 디바이스가 필요하면 요청을 나누어 보낸다.

부록 C. 참고 자료

  • 미쓰비시전기, MELSEC-Q/L MELSEC Communication Protocol Reference Manual (SH-080008)
  • 미쓰비시전기, MELSEC iQ-R Ethernet/CC-Link IE Function Block Reference — SLMP 관련
  • 미쓰비시전기, QnUCPU User’s Manual (Communication via Built-in Ethernet Port) (SH-080811)
  • Microsoft, Winsock2 Reference — setsockopt, WSAGetLastError

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

어제 방문자 : 3 명오늘 방문자 : 1 명전체 방문자 : 584 명