[카테고리:] 기술

  • 베트남 프로젝트의 추억

    2016년과 2017년, 베트남 박닌의 삼성디스플레이 공장(SDV)에 머물렀다. 오성LST의 매엽식 오토클레이브를 깔고, 돌리고, 고쳤다. 오토클레이브는 편광필름을 붙인 패널을 압력과 열로 눌러 기포를 빼는 설비다. 그때 쓴 업무일지가 엑셀 한 권으로 남았다. 아래는 그 일지에서 옮긴 것이다.

    항목일지의 기록
    체류2016년 7월, 8~10월, 12월 / 2017년 9~12월
    장소베트남 SDV 모듈 라인
    설비매엽식 오토클레이브 (로더 · 베셀 · 언로더, 최대 65인치)
    한 일설치 · 배선 · I/O 체크, 양산 대응, 이설, PLC 프로그램 수정, CIM 보고 수정, 현지 교육

    2017년 1월부터 8월까지의 시트는 파일에 없다. 그 사이의 일은 적지 않는다.

    설치

    2016년 7월 1일 금요일

    1호기 배선을 시작했다. 기존에 깔려 있던 케이블을 걷어냈다.

    7월 4일 월요일

    1호기 전장함 배선 가운데 깔끔하지 못한 곳을 다시 했다. 케이블타이, 찬넬, 덕트, 장갑을 샀다.

    7월 5일 화요일

    베셀 도어가 유압으로 열리고 닫혔다. 3바까지 가압했다. 배관의 새는 곳을 잡아 나갔다.

    7월 12일 화요일

    2호기 배선을 마치고 전기를 넣었다.

    7월 23일 토요일

    2호기 가압 시험을 하려 했다. 붙여 둔 패드가 떨어져 하지 못했다. 현지인 여덟 명과 협력사 직원 여섯 명에게 설비 조작을 가르쳤다.

    7월 28일 목요일

    한국으로 돌아왔다.

    긁힘

    8월 9일 화요일

    다시 나갔다.

    8월 11일 목요일

    2호기 글라스에 긁힌 자국이 났다. 10층에서 났다. 원인은 몰랐다.

    8월 17일 수요일

    40인치 양산에 들어갔다. 글라스에 잔 스크래치가 나왔다.

    8월 20일 토요일

    아이들 롤러를 뺐다. 컨베이어 가속 시간을 모두 1초로 맞췄다. 스크래치가 사라졌다. 조금은 나왔다. 양산을 막을 정도는 아니었다.

    롤러 하나를 빼고, 가속을 늦췄다.

    8월 27일 토요일

    비자 문제로 한국에 나갔다. 이틀 뒤 다시 들어왔다.

    8월 30일 화요일

    65인치를 밤새 돌렸다. 베셀 짝수층 여섯 단의 헬리컬 기어가 부서졌다. 짝수층을 막고 홀수층만 썼다.

    양산

    9월 1일 목요일

    베셀 컨베이어 감속 시간을 0.05초에서 0.1초로 늘렸다.

    9월 5일 월요일

    1호기 알람 0건.

    9월 15일 목요일

    패킹 오프 타임오버 알람이 났다. 기존 구리스를 닦아 내고 새 구리스를 발랐다. 두 구리스의 점도가 달랐다.

    9월 20일 화요일

    감속기와 패킹을 고온용으로 바꿨다. 히터 온도가 출렁였다. 히터를 켜는 시점을 옮겼다. 도어를 닫고 가압을 시작할 때 켜던 것을, 미세 가압에 들어설 때 켜도록 했다.

    9월 24일 토요일

    2호기 로더 리프터의 볼스크류가 부러졌다. 설비가 섰다.

    10월 6일 목요일

    1호기 도어 개조를 마쳤다. 이오나이저를 달았다. P/N 결선이 거꾸로 물려 고장 났다.

    10월 10일 월요일

    배기 소리가 컸다. 처음에는 가늘게 빼도록 프로그램을 고쳤다. 3바까지는 미세 배기, 1바에서 메인 밸브를 연다.

    10월 12일 수요일

    후임에게 오토 스텝 흐름과 센서 배선 자리를 넘겼다. PC는 협력사가 가져갔다.

    10월 14일 금요일

    한국으로 돌아왔다.

    이설

    12월 12일 월요일

    새벽 네 시에 나갔다.

    12월 15일 목요일

    밴디드 오토클레이브 한 대를 뜯었다. 작업 허가서 없이 했다.

    12월 16일 금요일

    담당자가 허가서를 받고 하라고 했다. 하루를 기다렸다.

    12월 18일 일요일

    3호기 도어 보강판이 반쯤 떨어졌다. 판이 글라스를 건드렸다. 글라스가 틀어져 깨졌다. 치우고 다시 돌렸다.

    12월 31일 토요일

    30일에 들어온 설비가 앞으로 1500mm 옮겨졌다. 해 둔 배선을 다시 걷었다.

    도어

    2017년 9월 7일 목요일

    다시 나갔다. 이튿날 앞 사람에게서 일을 넘겨받았다.

    9월 18일 월요일

    보안 업데이트를 한 노트북이 PLC와는 통신했다. 엑셀 파일은 열지 못했다.

    10월 1일 일요일

    2호기의 도어 로직을 고쳤다. 투입 도어와 배출 도어가 함께 열리고 닫혔다. 투입 도어가 끝난 뒤에 배출 도어가 움직이도록 바꿨다.

    10월 3일 화요일

    도어를 하나씩 움직이면 택트가 얼마나 느는지 재는 프로그램을 넣었다. 24장이 들어왔을 때는 챔버 공정이 먼저 끝났다.

    도어가 느려져도 기다리는 쪽은 도어가 아니었다.

    10월 12일 목요일

    로더 리프터축 원점 복귀에 인터록을 넣었다. 이튿날까지 열세 대에 넣었다.

    10월 23일 월요일

    언로더 리프터에서 패널이 나가지 않았다. 알람도 없었다. 작업자가 레시피를 바꾸다 잘못된 값을 넣었다. 언로더 얼라인이 그 자리로 가다 리밋 센서를 쳤다. 리밋 센서 알람은 떠야 했다. 프로그램의 리밋 센서 비트가 틀려 있었다. 작업자를 가르치고 비트를 고쳤다.

    두 번 보고

    11월 3일 금요일

    휴대폰에 보안 스티커를 붙이지 않아 검색에 걸렸다.

    11월 6일 월요일

    본사에서 도장을 받고 다시 나갔다.

    11월 26일 일요일

    21라인에 글라스 두 장이 한 층으로 들어갔다. 상류 레이저 설비는 Send Complete를 보낸 뒤 패널 ID를 넘겼다. 글라스가 로더에 들어서는 순간 레이저 설비에 에러가 나면 ID가 넘어오지 않았다. 로더는 같은 층에 한 번 더 받았다. 넘기는 조건을 바꿨다.

    기존  Send Complete 수신            → 패널 ID 넘김
    수정  Send Start 수신
          → 진입 감지 센서 상승 엣지
          → 진입 감지 센서 하강 엣지      → 패널 ID 넘김

    11월 30일 목요일

    CEID 4번과 17번이 두 번씩 올라간다고 했다. CEID는 설비가 상위 CIM에 올리는 사건 번호다. 전 설비가 그랬다. 26호기가 가장 심했다. 조건문을 고쳤다.

    12월 2일 토요일

    여전히 두 번 올라간다고 했다. 조건문을 버리고 스텝으로 다시 짰다.

    스텝1  배출 Send Start
    스텝2  배출 감지 센서 ON
    스텝3  배출 감지 센서 OFF
    스텝4  CEID 17 보고   (Buffer_Send 펄스)
    스텝5  17 완료 비트 → CEID 4 보고   (Buffer_Send 펄스)
    스텝6  4 완료 비트  → 스텝1

    12월 4일 월요일

    중복 보고가 사라졌다고 했다. 21라인과 28라인에 같은 로직을 넣었다.

    조건은 동시에 참이 될 수 있다. 스텝은 한 번에 하나다.

    컨베이어 스무 개

    11월 2일 목요일

    65인치를 시험 생산했다. 패널이 스토퍼에 부딪혔다. 로더 컨베이어 스무 개와 언로더 컨베이어 스무 개에 같은 속도가 들어갔다. 기구 조건은 층마다 달랐다.

    11월 17일 금요일

    30장을 시험 생산했다. 베셀 스토퍼에 닿기만 해도 불량이었다. 30장이 모두 불량이었다.

    11월 19일 일요일

    컨베이어 속도를 낮췄다. 로더 컨베이어 여섯 개를 건너뛰었다. 27장을 흘렸다.

    12월 1일 금요일

    속도를 층마다 따로 넣게 했다. 상류에서 로더로 들어오는 속도와 로더에서 베셀로 넘기는 속도를 갈랐다. 언로더도 받는 속도와 내보내는 속도를 갈랐다.

    12월 12일 화요일

    글라스 사이 간격의 편차가 200mm를 넘었다. 50mm 안팎으로 줄였다. 그 아래는 쉽지 않았다.

    12월 13일 수요일

    21라인의 건너뛰던 컨베이어 여섯 개를 모두 풀었다. 스토퍼에 닿지 않는 속도를 찾았다. 그 조건이 얼마나 갈지는 몰랐다.

    12월 14일 목요일

    25라인은 여덟 개를 건너뛰고 있었다. 모두 풀었다. 처음에는 스무 장에 일고여덟 장이 부딪혔다. 퇴실할 때는 한두 장이었다. 고객사 담당자와 현지 직원이 하루 종일 붙어 있었다.

    12월 15일 금요일

    컨베이어 모터가 서도 패널은 관성으로 조금 더 갔다. 그 사이에 리프터가 움직이면 편차가 났다. 리프터 지령에 인터록을 넣었다. 컨베이어가 서고 1초 뒤에 움직인다. 스토퍼에 한 장도 닿지 않았다.

    모터는 섰다. 패널은 서지 않았다.

    12월 16일 토요일

    25라인에서 간간이 한두 장이 베셀 스토퍼에 닿았다.

    가르친 일

    11월 8일 수요일

    같이 일하던 직원에게 서보의 기본 개념과 리프터 서보 모니터링을 가르쳤다. 문제를 냈다.

    1. 터치에서 자연수 N을 받아 1부터 N까지 더하라. for 문을 쓴다.
    2. 위 문제를 레시피 3개로 늘려라. for 문은 한 번만 쓴다.

    11월 9일 목요일

    광통신 인터페이스 사양서 읽는 법과 리프레시 영역 설정을 가르쳤다.

    11월 28일 화요일

    허리가 아파 직접 고치기 어려웠다. 현지 직원이 직접 고치게 했다. 나는 옆에서 설명했다.

    몸

    2017년 9월 12일 화요일

    몸이 좋지 않아 두 시간 일하고 나왔다.

    11월 27일 월요일

    허리 통증으로 한 시간 만에 퇴실했다. 병원에 갔다.

    12월 11일 월요일

    허리는 많이 나았다. 바닥에 앉아 오래 프로그램을 짜면 다시 아팠다. 현지에서 작업용 책상과 의자를 사려 했다. 구하지 못했다. 한국에서 접이식 책상을 사 와 달라고 적었다.

    바닥에 앉아 스무 개의 컨베이어를 맞췄다.

    12월 20일 수요일

    협력사 설비 개조를 도왔다.

    일지는 여기서 끝난다. 21일부터 30일까지는 날짜만 있다.

    그 라인의 컨베이어 스무 개가 지금도 제각각의 속도로 돌고 있는지 한 번 보고 싶다.

  • PID 제어를 슬라이더로 이해하기

    제어공학 수업에서 PID 를 배울 때 대부분은 전달함수와 근궤적을 먼저 만난다. 그러나 현장에서 PID 를 쓰는 방식은 그것과 거리가 멀다. 실제로는 이득 세 개를 만지면서 응답이 어떻게 변하는지 눈으로 보고 결정한다. 이 글은 그 감각을 먼저 얻게 하려는 목적으로 쓴다. 본문 중간에 직접 만질 수 있는 시뮬레이터를 두었으니, 읽으면서 계속 슬라이더를 움직여 보기를 권한다.

    1. 제어 문제의 형태

    제어란 원하는 값과 실제 값의 차이를 줄이는 일이다. 용어부터 정리한다.

    • 설정값 (SP, set point) r — 우리가 원하는 값. 목표 속도, 목표 위치, 목표 온도.
    • 측정값 (PV, process variable) y — 센서가 읽은 실제 값.
    • 오차 (error) e = r − y
    • 조작량 (MV, manipulated variable) u — 제어기가 내보내는 출력. 모터 전압, 히터 전력.
    • 제어 대상 (plant) — 조작량을 받아 측정값을 만들어 내는 물리계.
       r ──→(+)──→ e ──→ ┌─────────┐ ──→ u ──→ ┌─────────┐ ──→ y ──┬──→
             ↑           │ 제어기   │           │ 제어 대상 │        │
             │           │  PID    │           │  plant  │        │
             │           └─────────┘           └─────────┘        │
             └──────────────────── 센서 ────────────────────────────┘
                                     (−)

    <그림 1> 되먹임 제어 루프 (feedback control loop)

    PID 제어기는 오차 e 를 세 가지 방식으로 보고, 그 합을 조작량으로 내보낸다.

    u(t) = Kp·e(t)  +  Ki·∫e(τ)dτ  +  Kd·de(t)/dt
        비례(P)      적분(I)      미분(D)

    말로 바꾸면 이렇다. 비례항은 지금의 오차를 본다. 적분항은 그동안 쌓인 오차를 본다. 미분항은 오차가 변하는 기세를 본다. 세 항이 각각 현재·과거·미래를 담당한다고 설명하는 교재가 많은데, 그 비유는 외우기에는 좋으나 실제 튜닝에는 별 도움이 되지 않는다. 각 항이 응답의 어느 부분을 어떻게 바꾸는지를 몸으로 아는 편이 낫다.

    2. 직접 만져 보기

    아래가 그 시뮬레이터다. 슬라이더를 움직이면 계단 응답(step response)이 즉시 다시 그려진다. 파란 선이 측정값, 주황 선이 조작량이며, 회색 점선이 설정값이다. 흐린 선은 직전에 그려졌던 곡선이라 변화 방향을 확인할 수 있다.


    <그림 2> PID 놀이터. 화면이 좁으면 가로가 넉넉한 기기에서 여는 편이 낫다.

    먼저 해 볼 것은 「P 만」 버튼을 누르고 Kp 만 키워 보는 일이다. 응답이 빨라지는 대신 설정값에 닿지 못하고 멈추는 현상이 보일 것이다. 그것이 다음 절의 주제다.

    3. 비례 제어 — 지금의 오차

    가장 단순한 제어기는 오차에 상수를 곱해 내보내는 것이다.

    u = Kp·e = Kp·(r − y)

    제어 대상이 1차 지연계(first-order lag)라고 하자. 모터에 전압을 걸었을 때의 속도 응답이 이 형태다.

    τ·dy/dt + y = K·u   (τ: 시정수, K: 정상상태 이득)

    정상상태(steady state)에서는 dy/dt = 0 이므로 y = K·u 이다. 여기에 u = Kp(r − y) 를 대입하면

    y = K·Kp·(r − y)
    y·(1 + K·Kp) = K·Kp·r
    ∴ ess = r − y = r / (1 + K·Kp)

    즉 비례 제어만으로는 오차가 절대 0 이 되지 않는다. 오차가 0 이 되면 조작량도 0 이 되어 버리기 때문이다. 모터를 목표 속도로 돌리려면 전압이 계속 걸려 있어야 하는데, 그 전압을 만들어 낼 오차가 필요한 것이다. 이 값을 정상상태 오차(steady-state error) 또는 오프셋(offset)이라 한다.

    시뮬레이터의 「모터 속도」 대상은 K = 1 이므로 ess = r/(1+Kp) 이다. Ki 와 Kd 를 0 으로 두고 Kp 만 바꾸어 실제로 확인해 보면 다음과 같다.

    Kp 이론값 r/(1+Kp) 시뮬레이션 결과
    1 0.500 0.5000
    3 0.250 0.2500
    9 0.100 0.1000

    <표 1> 비례 제어의 정상상태 오차 (설정값 r = 1)

    표에서 보듯 Kp 를 키우면 오차는 줄어든다. 그렇다면 Kp 를 아주 크게 잡으면 될 것 같지만 그렇지 않다. Kp 를 키울수록 응답이 점점 크게 출렁이기 때문이다. 시뮬레이터에서 「모터 위치」 대상을 고르고 Ki 와 Kd 를 0 으로 둔 채 Kp 만 바꾸어 보면 다음과 같다.

    Kp 1 2 3 5 7 9
    오버슈트 0.1 % 7.0 % 14.2 % 24.5 % 31.4 % 36.6 %

    <표 2> 비례 이득을 키울수록 오버슈트가 커진다 (모터 위치, Ki=Kd=0)

    ★주 1. 이 대상에서 Kp 를 12 이상으로 더 올려 보면 오버슈트가 41 % 부근에서 더 늘지 않는데, 조작량이 출력 제한에 걸리기 때문이다. 제한을 풀면 Kp = 45 에서 65 % 까지 올라간다. 실제 장비에서도 이런 이유로 이득을 올려도 응답이 기대만큼 나빠지지 않는 것처럼 보이는 경우가 있다. 이 문제는 6-(1) 절에서 다시 다룬다.

    덧붙여, 이 대상은 2 차계이므로 이득을 아무리 키워도 수학적으로는 발산하지 않고 출렁임만 심해진다. 진짜 발산은 지연시간이 끼어들 때 일어난다 — 6-(2) 절에서 확인할 수 있다.

    4. 적분 제어 — 쌓인 오차

    정상상태 오차를 없애려면, 오차가 작더라도 그것이 오래 지속되면 조작량을 계속 키워 주는 장치가 필요하다. 오차를 시간에 대해 누적하면 된다.

    uI = Ki·∫e(τ)dτ

    오차가 남아 있는 한 적분값은 계속 커지므로, 조작량도 계속 커진다. 이 과정은 오차가 정확히 0 이 될 때까지 멈추지 않는다. 그래서 적분항을 넣으면 정상상태 오차가 사라진다.

    대가는 있다. 적분은 과거를 보는 항이므로 본질적으로 느리고, 위상 지연(phase lag)을 만들어 안정도를 떨어뜨린다. Ki 를 과하게 넣으면 설정값을 넘어갔다가 되돌아오기를 반복하는 출렁임이 생긴다. 시뮬레이터에서 Ki 만 계속 키워 보면 응답이 점점 요동치는 것을 볼 수 있다.

    5. 미분 제어 — 변화의 기세

    미분항은 오차의 변화율을 본다. 측정값이 설정값을 향해 빠르게 접근하고 있으면 미리 제동을 건다.

    uD = Kd·de/dt

    여기서 흔한 오해를 하나 짚는다. 미분항의 값어치는 「오버슈트를 줄이는 것」이 아니다. 오버슈트만 줄이려면 Kp 를 낮추면 된다. 미분항의 진짜 값어치는 오버슈트를 키우지 않으면서 Kp 를 더 올릴 수 있게 해 주는 것, 즉 같은 안정도에서 더 빠른 응답을 얻게 해 주는 데 있다.

    시뮬레이터에서 확인할 수 있는 수치로 보면 분명하다. 「모터 속도」 대상에서 「PI」 프리셋과 「PID」 프리셋을 번갈아 눌러 보라.

    프리셋 Kp Ki Kd 오버슈트 정정시간
    PI 3 6 0 0.0 % 0.65 s
    PID 5 8 0.25 1.4 % 0.47 s

    <표 3> 미분항을 넣으면 이득을 더 높이고도 안정하다

    미분항 덕분에 Kp 를 3 에서 5 로, Ki 를 6 에서 8 로 올리고도 오버슈트가 1.4 % 에 그쳤고, 정정시간은 0.65 초에서 0.47 초로 27 % 빨라졌다. 이것이 D 를 쓰는 이유다.

    ★주 2. 실제 구현에서 미분은 오차 e 가 아니라 측정값 y 에 걸고 부호를 뒤집는다(derivative on measurement). 설정값이 계단으로 변할 때 de/dt 가 순간적으로 무한대가 되어 조작량에 충격이 실리는 현상(derivative kick)을 막기 위해서다. 이 시뮬레이터도 그렇게 구현되어 있다.

    6. 현장에서 반드시 걸리는 세 가지

    교과서의 PID 와 현장의 PID 를 가르는 것은 대부분 아래 세 가지다. 시뮬레이터 하단의 옵션으로 각각을 켜고 끄며 확인할 수 있다.

    (1) 출력 포화와 적분 와인드업

    실제 액추에이터의 출력에는 한계가 있다. 모터 드라이버의 최대 전류, 히터의 최대 전력 같은 것이다. 제어기가 계산한 조작량이 이 한계를 넘으면 잘려 나간다. 이것을 포화(saturation)라 한다.

    문제는 이때 적분항이다. 출력은 이미 한계에 걸려 더 낼 것이 없는데도 오차는 여전히 남아 있으므로 적분값은 계속 쌓인다. 나중에 측정값이 설정값에 도달해도, 부풀어 오른 적분값을 도로 깎아 내리는 데 시간이 걸려 크게 넘어갔다 돌아온다. 이 현상이 적분 와인드업(integral windup)이다.

    대책은 포화 중에 적분을 멈추는 것이다(안티 와인드업, anti-windup). 시뮬레이터에서 「출력 제한」을 ±1.2 정도로 낮추고 Ki 를 크게 올린 뒤 「안티 와인드업」 체크를 껐다 켜 보라. 같은 이득에서 오버슈트가 19.3 % 에서 0.8 % 로 떨어진다.

    (2) 지연시간

    조작량을 바꿔도 측정값이 즉시 반응하지 않고 일정 시간 뒤에야 움직이기 시작하는 경우가 있다. 이를 지연시간(dead time, 낭비시간)이라 한다. 컨베어로 재료가 이송되는 시간, 배관 내 유체의 이동 시간, 통신 주기, 센서의 처리 시간이 모두 여기 해당한다.

    지연시간은 PID 가 가장 다루기 어려워하는 상대다. 제어기는 이미 지나간 정보를 보고 판단하게 되므로, 이득을 조금만 올려도 쉽게 발진한다. 시뮬레이터에서 「지연시간」 슬라이더를 0.3 초로 올려 보면 멀쩡하던 응답이 무너지는 것을 볼 수 있다. 지연시간이 시정수에 비해 크면 PID 로는 한계가 있고, 스미스 예측기(Smith predictor) 같은 별도 구조가 필요해진다.

    (3) 측정 잡음과 미분

    미분은 잡음을 증폭한다. 센서 신호에 섞인 작은 떨림도 미분하면 큰 값이 되어 조작량을 요동치게 만든다. 이 요동은 액추에이터의 수명을 깎고 소음과 발열을 만든다.

    시뮬레이터에서 「측정 잡음」을 2 % 로 올린 뒤 Kd 를 넣었다 뺐다 해 보라. Kd 가 있을 때 조작량 그래프가 눈에 띄게 거칠어진다. 실제로 측정해 보면 조작량의 총 변동량이 10 배 이상 차이 난다.

    그래서 실무의 미분항에는 반드시 저역통과 필터(low-pass filter)가 함께 붙는다. 상용 제어기에서 「미분 필터 계수」나 N 값으로 노출되는 항목이 그것이다. 잡음이 심한 공정에서는 아예 Kd 를 0 으로 두고 PI 로만 운전하는 경우도 흔하다.

    ★주 3. 온도 제어처럼 느리고 잡음이 있는 공정에서는 PI 가 표준이고, 모션 제어처럼 빠르고 센서가 깨끗한 경우에 PID 가 제 값을 한다. 「PID 니까 세 항을 다 써야 한다」는 생각은 버리는 편이 좋다.

    7. 플랜트가 이미 적분기를 가진 경우

    이 절은 학부 수업에서 잘 다루지 않지만 현장에서는 자주 만나는 상황이다.

    모터의 위치를 제어한다고 하자. 속도를 적분하면 위치이므로, 제어 대상 안에 이미 적분기가 하나 들어 있다. 이런 계를 1형 계통(type 1 system)이라 한다.

    이 경우 비례 제어만으로도 정상상태 오차가 0 이 된다. 오차가 0 이 되어 조작량이 0 이 되더라도, 적분기가 그때까지의 값을 그대로 붙들고 있기 때문이다. 앞 절에서 본 「오차가 있어야 조작량이 나온다」는 논리가 여기서는 성립하지 않는다.

    따라서 이 경우 Ki 는 필요 없을 뿐 아니라 해롭다. 적분기를 하나 더 얹는 셈이라 위상 지연이 겹치면서 출렁임만 커진다. 시뮬레이터의 「모터 위치」 대상에서 Kp 를 12, Kd 를 2.0 으로 고정하고 Ki 만 바꾸어 보면 다음과 같다.

    Ki 오버슈트 정정시간 정상상태 오차
    0 0.7 % 0.61 s 0.0000
    0.5 1.5 % 0.59 s −0.0071
    1.5 3.2 % 2.90 s −0.0137
    3 5.6 % 4.36 s −0.0134
    6 10.1 % 3.61 s −0.0055

    <표 4> 적분형 플랜트에서 Ki 를 키울수록 나빠진다 (Kp=12, Kd=2.0)

    Ki 를 0 에서 6 으로 올리는 동안 정상상태 오차는 전혀 개선되지 않았고(원래 0 이었으므로), 오버슈트는 0.7 % 에서 10.1 % 로, 정정시간은 0.61 초에서 3.61 초로 나빠지기만 했다. 위치 제어 루프를 PD 로 짜는 관행에는 이런 근거가 있다.

    ★주 4. 실제 장비에서 위치 루프에 Ki 를 조금 넣는 경우가 있는데, 이는 계단 응답을 개선하려는 것이 아니라 마찰이나 중력 같은 외란을 보상하기 위한 것이다. 목적이 다르므로 값도 아주 작게 잡는다.

    8. 튜닝 순서

    정해진 정답은 없으나, 아래 순서로 하면 대체로 실패하지 않는다.

    1. Ki = 0, Kd = 0 으로 두고 Kp 만 올린다. 응답이 출렁이기 시작하는 지점을 찾고, 그 값의 절반 정도로 되돌린다.
    2. Kd 를 넣어 출렁임을 잡는다. 잡음이 심한 계에서는 이 단계를 건너뛴다.
    3. Kd 덕분에 여유가 생겼으면 Kp 를 다시 올린다. 2~3 단계를 몇 번 오간다.
    4. 마지막으로 Ki 를 조금씩 넣어 정상상태 오차를 없앤다. 필요한 최소량만 쓴다. 플랜트가 적분형이면 이 단계는 생략한다.
    5. 포화·지연·잡음을 실제 조건으로 놓고 재확인한다. 이상적인 조건에서 맞춘 이득은 현장에서 대개 그대로 쓰이지 않는다.

    지글러-니콜스(Ziegler–Nichols) 같은 고전적 정식화 방법도 있다. 시뮬레이터에 해당 프리셋을 넣어 두었는데, 눌러 보면 알겠지만 응답이 빠른 대신 오버슈트가 상당히 크다. 이 방법은 1942 년에 공정 제어를 대상으로 제안된 것으로, 출발점을 잡는 용도로는 쓸 만하나 최종값으로 쓰기에는 공격적이다.

    맺으며

    PID 는 이론이 어려워서가 아니라 세 이득이 서로 얽혀 있어서 어렵게 느껴진다. 하나를 바꾸면 나머지 둘의 적정값도 함께 움직이기 때문이다. 이런 종류의 감각은 수식을 들여다봐서는 좀처럼 생기지 않고, 손으로 돌려 보아야 붙는다.

    시뮬레이터는 계속 열어 둘 것이니 필요할 때 다시 와서 만져 보아도 된다. 다뤄 주었으면 하는 주제나 이상한 점이 있으면 댓글로 남겨 주기 바란다.

  • 무선조종기 개발의 추억

    무선조종기 개발의 추억

    2022년 가을에 시작해 2025년 겨울까지 손에서 놓지 못한 물건이 있다. AGV 무선 조종기다. 펌웨어부터 회로, 케이스, 양산, 그리고 현장 A/S 까지 혼자 끌고 갔다. 잘 만든 이야기는 아니다. 다만 오래 붙들었던 이야기다.

    완성된 AGV 무선 조종기
    <그림 1> 최종 형태. 조이스틱, 펑션키 8개, OLED, 비상정지

    무엇을 만들려 했나

    구조는 단순하다. 세 덩어리가 줄지어 선다.

    구분내용
    조종기ATmega328P + 블루투스 모듈 + OLED + 리튬전지 3.7 V / 2,000 mA
    수신기STM32F429 + 이더넷 + 와이어 엔코더(I2C) + RS232 · CAN
    상위B&R PLC 가 TCP/IP 로 수신기에 커맨드를 내린다

    B&R 이 커맨드 10 을 내리면 조종기 데이터가, 11 을 내리면 와이어 엔코더의 길이와 각도가 올라온다. 사람이 조이스틱을 밀면 그 값이 블루투스를 타고 수신기로, 이더넷을 타고 PLC 로, 그리고 바퀴로 간다. 글로 쓰면 세 줄이다.

    클럭 하나에 사흘

    첫 벽은 부트로더였다. 외부 클럭을 8 MHz 로 쓰기로 해 놓고 퓨즈 비트를 맞추지 않으면 다운로드가 아예 안 된다. 어렵게 올렸더니 이번엔 시리얼 글자가 깨졌다.

    원인은 같은 자리였다. 보율(BPS)은 클럭을 기준으로 계산된다. MCU 는 멀쩡히 돌고 다운로드도 되는데 통신만 깨지니, 통신 코드만 붙들고 한참을 헤맸다. 퓨즈 비트는 잘못 건드리면 MCU 가 그대로 죽는다. 죽인 칩이 몇 개인지는 세지 않았다.

    방향

    PCB 가 처음 조립되어 왔다. 전원을 넣었는데 MCU 가 아예 동작하지 않았다. 하루를 꼬박 회로도와 대조한 끝에 찾은 원인은 IC 를 반대로 꽂은 것이었다.

    조종기 PCB
    <그림 2> 조종기 기판. 아래쪽이 펑션키 8개

    며칠 뒤에는 펑션키 여덟 개 중 일곱 개가 먹지 않았다. 디코더를 의심하며 코드를 뒤졌는데, 스위치를 거꾸로 조립한 것이었다. 그리고 그 디코더는 결국 내 설계 실수로 드러났다. 경우의 수를 잘못 세어 스위치 하나를 영영 쓸 수 없게 만들어 놓았다.

    그해 가을에 배운 것은 이것이다.

    코드를 의심하기 전에 방향을 보라.

    보이지 않는 것들

    조종기가 슬립 모드에 들어갔는데도 배터리가 줄었다. MCU 는 자고 있었지만 5 V 부스터와 3.3 V 레귤레이터가 깨어 있었다. 자는 척만 하고 있었던 셈이다. 전원단을 통째로 끊는 오토 파워 오프 회로를 새로 그렸다.

    배터리 잔량은 정밀 ADC 를 쓰지 않고 다섯 칸으로만 나눴다. 하드웨어를 늘리는 대신 쓰는 사람이 알아볼 정도면 충분하다고 봤다.

    수신기에서는 이더넷 클럭이 발진하지 않았다. 25 MHz 크리스털을 몇 번이나 바꿔 끼웠다. 원인은 LAN8742A 바닥면의 그라운드 처리를 빠뜨린 것이었다. 보이지 않는 면이었다. 같은 시기에 MAX3232 의 차지펌프 캐패시터도 하나 누락되어 있었다.

    LAN8742A 회로도
    <그림 3> 발목을 잡았던 이더넷 PHY 회로

    스위치를 지우다

    초기 설계에는 페어링할 AGV 주소를 고르는 로터리 딥 스위치가 있었다. 그런데 1번부터 9번까지밖에 못 고른다. 대수를 늘리려면 하드웨어를 늘려야 했다.

    결국 스위치를 지우고 화면으로 옮겼다. 오른쪽 버튼 두 개를 3초 누르면 주소 선택 화면이 뜨고, 조이스틱을 위아래로 움직여 고른 뒤 다시 두 버튼을 누르면 끝난다. 부품은 하나 줄었고 고를 수 있는 대수는 늘었다.

    비슷한 판단이 하나 더 있었다. MCU 를 32 KB 짜리에서 128 KB 짜리로 올렸다. 값 차이는 천 원이었는데, 메모리가 빠듯해 한글을 화면에 띄우기조차 어려웠기 때문이다. 양산용 부품을 고를 때는 가격과 스펙만 보지 않았다. 국내에 물량이 있는가, 몇 년 뒤에도 구할 수 있는가가 더 중요했다. 성능이 가장 좋은 안을 두고 수급이 안정된 안을 택했다.

    하드웨어로 풀던 것을 펌웨어로 옮기면 대개 이런 일이 생긴다. 원가는 줄고 유연해진다. 대신 코드가 늘고, 늘어난 코드는 언젠가 나에게 돌아온다.

    수신기가 죽었다

    2023년 4월, 현장에서 수신기 CPU 가 죽어서 돌아왔다. 왜 죽었는지 알 수 없었다. 회로도를 들고 전자과 교수님을 찾아갔다.

    답은 전원 입력단에 있었다. 24 V 에서 5 V, 다시 3.3 V 로 내려가는 구간의 전압 마진이 너무 얇아 역으로 전류가 흐를 수 있다는 것이었다. 입력단에 전류 제한 코일을 넣으라는 조언을 받았다. 전류의 변화량이 클 때 그 변화를 눌러 주는 역할이다.

    그 나이에 회로도를 들고 학교를 찾아가는 일이 부끄럽지 않았다고는 못 하겠다. 다만 모르는 걸 모른다고 말하지 않으면, 죽은 CPU 는 계속 돌아온다.

    6천 원

    원가를 줄이려고 블루투스 안테나를 딥 타입(14,000원)에서 SMD 타입(6,000원)으로 바꿨다. 2호기의 신호가 유난히 약했다. 스마트폰 앱으로 재 보니 1호기는 −50 dB, 2호기는 그에 한참 못 미쳤다.

    제조사에 물으니 답이 한 줄이었다. 안테나 반경 5 mm 안에는 어떤 부품도 있으면 안 된다. 이미 배치를 끝낸 기판이었다. 8,000원을 아끼려다 결국 딥 타입으로 되돌렸다.

    조종기 OLED 표시
    <그림 4> 페어링된 상태

    양산, 그리고 그 뒤

    2023년 8월에 50대를 양산했다. 그런데 송신기에서 또 블루투스가 말썽이었고, 원인은 다시 클럭 설정이었다. 1년 전에 배운 것을 1년 뒤에 다시 배웠다.

    그해 12월에는 군산에 내려가 7대를 백업했다. 이듬해 5월과 6월에는 수신기 소자가 연달아 나갔다. 7월에는 부품을 교체해 입고된 27대 중 8대가 불량이었다. 2025년 11월에도 나는 수신기 CAN 포트를 확인하고 있었다.

    끝난 적이 없는 물건이었다. 3년 동안 이 조종기는 내게 계속 돌아왔고, 돌아올 때마다 내가 놓친 것을 하나씩 들고 왔다. IC 방향, 그라운드 한 면, 캐패시터 하나, 안테나 옆 5 mm.

    그래도 지금 어딘가의 공장에서는 누군가 이 조종기를 쥐고 AGV 를 세우고 있을 것이다. 펑션키 여덟 개 중 하나는 내 계산 실수로 비어 있는 채로.

    그 비어 있는 자리가, 이상하게도 가장 오래 기억에 남는다.

  • Teensy 4.1 로 WS2812 LED Strip 구동하기

    Teensy 4.1 로 WS2812 LED Strip 구동하기

    Teensy 4.1 로 WS2812(네오픽셀) LED 링을 구동하는 기본기를 정리한다. 개발환경 설정부터 R·G·B 패턴 코드까지 훑는다.

    1. Teensy 4.1 개발환경 설정

    Arduino IDE 대신 VSCode + PlatformIO 조합을 쓴다. 자동완성과 헤더 추적이 제대로 되고, 프로젝트마다 라이브러리 버전이 독립적으로 관리된다.

    VSCode 를 설치한 뒤 확장 세 가지를 넣는다.

    • PlatformIO IDE — 빌드·업로드·라이브러리 관리를 전담한다
    • C/C++ — 코드 탐색과 자동완성
    • Arduino — 아두이노 문법 보조
    VSCode 확장 마켓플레이스에서 PlatformIO IDE 설치
    <그림 1> 확장 탭에서 PlatformIO IDE 를 검색해 설치한다

    프로젝트 루트의 platformio.ini 가 설정의 전부다.

    [env:teensy41]
    platform      = teensy
    board         = teensy41
    framework     = arduino
    monitor_speed = 115200
    

    화면 아래 상태 표시줄의 체크 표시가 빌드, 화살표가 업로드다. Teensy 는 업로드 시 Teensy Loader 가 자동으로 뜬다.

    ★주) 시리얼 모니터에 글자가 깨져 나오면 platformio.ini 의 monitor_speed 와 코드의 Serial.begin() 속도가 어긋난 것이다. J-Tag 없이 디버깅할 때는 결국 Serial.print() 가 주력이므로 이 둘을 반드시 맞춰 둔다.

    Teensy 4.1 보드
    <그림 2> Teensy 4.1. NXP i.MX RT1062(600 MHz)에 SD 슬롯을 갖췄다

    2. LED Strip 기본 개념

    흔히 네오픽셀이라 부르는 WS2812B 는 LED 안에 제어 IC 가 들어 있는 소자다. 그래서 선이 세 가닥뿐이다 — 5 V, GND, DATA.

    12개 WS2812 LED 링
    <그림 3> 12구 링타입 LED. 빨강 5 V, 검정 GND, 노랑 DATA 세 가닥이 전부다

    데이터는 한 줄로 흘러간다. 첫 픽셀이 앞의 24 비트(GRB 각 8 비트)를 먹고, 나머지를 다음 픽셀로 흘려보낸다. 그래서 픽셀 수만큼 연달아 보내면 각자 제 몫을 가져간다. 주의할 점은 순서가 RGB 가 아니라 GRB 라는 것이다.

    <표 1> 배선 시 확인할 것

    항목내용
    전류픽셀당 최대 약 60 mA(백색 최대 밝기). 12구면 0.7 A 남짓이므로 USB 로 가능하지만, 수십 개를 넘으면 별도 5 V 전원을 쓴다
    신호 레벨Teensy 출력은 3.3 V, WS2812 는 5 V 기준 약 3.5 V 이상을 요구한다. 대개는 동작하지만 경계선이므로 레벨 시프터를 넣는 편이 안전하다
    보호 소자DATA 선에 300~500 Ω 직렬 저항, 전원단에 1000 µF 커패시터
    접지별도 전원을 쓸 때 GND 를 반드시 공통으로 묶는다

    3. Teensy 4.1 네오픽셀 라이브러리

    WS2812 는 타이밍이 빡빡해서 보통 라이브러리는 전송 중 인터럽트를 막는다. 모터 제어나 통신을 함께 하면 그 순간 놓치는 것이 생긴다.

    Teensy 에는 이를 피하는 WS2812Serial 이 있다. 하드웨어 시리얼과 DMA 로 파형을 내보내므로 CPU 를 붙잡지 않는다. 대신 시리얼 TX 로 쓸 수 있는 핀에서만 동작하니, 라이브러리 예제의 핀 목록을 확인하고 배선한다.

    라이브러리특징
    WS2812SerialTeensy 전용. DMA 비차단. 핀 제약 있음 — 권장
    Adafruit_NeoPixel어느 보드에서나 동작. 전송 중 인터럽트 차단
    FastLED색 보정·패턴 함수가 풍부. Teensy 에서 WS2812Serial 을 백엔드로 쓸 수 있다

    4. R·G·B 패턴 코드

    12구 링을 빨강 → 초록 → 파랑 순으로 한 칸씩 채우는 예제다.

    #include <WS2812Serial.h>
    
    const int numled = 12;
    const int pin    = 1;          // 시리얼 TX 로 쓸 수 있는 핀
    
    byte drawingMemory[numled * 3];         // 작업용
    DMAMEM byte displayMemory[numled * 12]; // DMA 전송용
    
    WS2812Serial leds(numled, displayMemory, drawingMemory, pin, WS2812_GRB);
    
    #define RED   0xFF0000
    #define GREEN 0x00FF00
    #define BLUE  0x0000FF
    #define OFF   0x000000
    
    void setup() {
      leds.begin();
      leds.setBrightness(60);      // 0~255. 눈이 부시고 전류도 크므로 낮춰 둔다
    }
    
    // 한 칸씩 차오르게 칠한다
    void colorWipe(int color, int wait_ms) {
      for (int i = 0; i < leds.numPixels(); i++) {
        leds.setPixel(i, color);
        leds.show();               // 여기서 DMA 전송이 시작된다
        delay(wait_ms);
      }
    }
    
    void loop() {
      colorWipe(RED,   60);
      colorWipe(GREEN, 60);
      colorWipe(BLUE,  60);
      colorWipe(OFF,   60);
    }
    

    setPixel() 은 버퍼에 쓰기만 하고 실제 출력은 show() 에서 일어난다. 여러 픽셀을 바꿀 때는 전부 써 넣은 뒤 show() 를 한 번만 부르는 것이 맞다. 위 예제는 차오르는 모습을 보이려 일부러 매번 호출했다.

    밝기는 setBrightness() 로 낮춰 쓴다. 12구를 백색 최대로 켜면 0.7 A 가 흐르고 눈으로도 지나치게 밝다. 실제 장비에서는 60 안팎이 적당했다.

    다음에는 링을 회전시키는 패턴과, 상위 제어기에서 패턴 번호를 받아 바꾸는 구조를 다룬다.

  • 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 명