[작성자:] admin

  • 나에게는 어려웠던 개발 프로젝트

    2018년, 나는 8축 와이어 벤딩 머신 “프리스타일(FreeStyle)”을 처음부터 끝까지 혼자 만들었다. PLC도, HMI도, 모션도, 고객 대응도 전부 처음 해 보는 일이었다. 지금까지 올린 글 중에서 가장 오래 붙잡고 있었고, 가장 많이 막혔던 프로젝트다.

    항목내용
    프로젝트명FreeStyle — 8축 와이어 벤딩 머신
    기간2018년 개발, 2019년 초까지 수정
    제어기B&R Automation PC910 (파워링크)
    축 구성회전·이송·투피드·클램프·벤드·푸시·수평·수직 8축 서보 + 히터 4계통
    고객미국 소재 업체 — 영문 이메일·현지 출장으로 협의

    혼자 처음부터

    업무일지 첫 줄에 내가 스스로 나눠 놓은 순서가 있다. 환경설정, IO 매뉴얼 프로그램, 히터 매뉴얼 프로그램, 모션 매뉴얼 프로그램, UI 작업, 자동 프로그램, 알람 및 예외처리, 파일·레시피·로그, 그리고 야스카와 로봇 프로그램. 마지막 항목 옆에는 (0%)라고 적어 놓았다. 그때는 로봇 연동까지 갈 길이 멀다는 걸 알고 있었다는 뜻이다.

    터치스크린 HMI로 레시피를 짜서 와이어를 굽히는 기계였다. 스텝 하나에 회전·이송· 클램프·벤드 같은 축들의 목표값을 넣고, 그 스텝을 순서대로 이어 붙이면 하나의 형상이 나왔다. 이 구조를 화면 설계부터 PLC 로직까지 혼자 짰다.

    레시피가 20개에서 막혔다

    고객이 보내온 수정 요청 목록 맨 위에 이런 게 있었다. “21번째부터 40번째 스텝이 따로 작동하지 않는다. 21~40번을 고치면 1~20번도 같이 바뀐다.” 레시피 한 줄에 쓸 수 있는 스텝을 20개로 잡아 놓은 내 자료구조의 한계였다. 100개까지 늘려 달라는 요청에 나는 이렇게 답을 적어 뒀다.

    단순노동이라 쉽지만 시간이 걸리는 문제.

    쉽다고 적어 놓고도 실제로는 시간이 오래 걸렸다. 복사·붙여넣기도, 스텝 삽입도, 되돌리기(undo)도 없는 화면이었다. 고객은 반복되는 동작을 매번 손으로 새로 입력해야 했다. 그가 요청한 기능 하나하나가 지금이라면 당연히 있어야 할 것들이었다. 그때는 그 당연한 것들을 하나씩 다 만들어야 했다.

    말이 안 통해서 더 어려웠다

    기술보다 더 힘들었던 건 요구사항을 읽는 일이었다. 고객은 영어로 스크린샷과 함께 길게 적어 보냈고, 나는 그 밑에 한국어로 답을 달고 다시 영어로 옮겼다. 수정 목록에는 내가 그때 적어 둔 문장들이 그대로 남아 있다.

    나는 당신의 요구사항을 정확히 이해하지 못하겠다.
    내 생각에 다음 미국출장 때 대화하면서 이 문제 뿐 아니라 전반적으로 업데이트해야 될 것 같다.

    같은 문장이 목록 안에서 세 번 반복된다. 화면 캡처와 화살표만으로는 안 풀리는 요구가 있었고, 그런 항목은 결국 출장을 가서 기계 앞에 같이 서야 풀렸다.

    헬릭스

    고객이 만들고 싶어 한 형상 중에는 나선(helix)이 있었다. 그가 보내온 문서에는 두 가지 방법이 적혀 있었다. 하나는 파라메트릭 방정식으로 나선을 통째로 계산하는 방법, 다른 하나는 굽힘을 짧은 구간으로 잘라 이어 붙이는 방법이었다. 후자를 택하면 이송량과 굽힘각을 단순한 값 몇 개로 넣을 수 있었지만, z축은 이 계산에 들어가지 않았다. z축은 사람이 손으로 움직이거나, 나중에 로봇이 맡아야 하는 몫이었다.

    평면 위의 수학은 고객이 직접 풀어서 보내 줬다. 그 수식을 기계가 이해하는 스텝 값으로 바꾸는 일이 내 몫이었다. 로봇이 z축을 대신하는 날은 이 프로젝트 안에서 오지 않았다.

    PLC도 HMI도 모션도 혼자 배워 가며 만들었고, 말이 통하지 않는 고객과 문서를 주고받으며 요구사항 읽는 법을 배웠다. 그 몇 달이 지금 내가 새로운 프로젝트 앞에서 겁내지 않는 이유 중 하나다.

  • ROS의 입문계기

    2022년 말, 우리 회사는 거동이 불편한 사람을 알아서 뒤따라가는 ‘추종형(Follow-on) 이동 휠체어’ 국책과제를 맡게 되었다. 카메라로 대상을 인식해 추종하는 핵심 기술은 H대 대학원 연구실과 협업하기로 했다. 결과보고서를 제출한 2023년 3월까지의 치열했던 몇 달은, 내가 개발자로서 ‘ROS(Robot Operating System)’를 처음 마주한 시간이기도 하다.

    항목내용
    과제거동 불편자 대상 추종(follow-on) 이동형 휠체어 개발
    사업2022년 로봇융합비즈니스지원사업 (국책과제)
    협업H대 대학원 연구실 — 보호자 추종 카메라 솔루션
    목표 양산가200~250만 원대
    기간2022년 하반기 ~ 2023년 3월

    비용과 현실의 괴리

    가장 큰 문제는 단가였다. H대 연구실의 카메라 추종 솔루션은 부품값만 대당 500만 원에 달했다. 우리가 서류에 적어낸 휠체어 완제품의 목표 양산가가 200~250만 원대였 으니, 카메라 한 대 값이 휠체어 한 대 값보다 비싼 기형적인 구조였다.

    성능의 한계도 명확했다. 카메라 시야에 사람이 둘 이상 잡히면 시스템은 곧바로 오류를 냈다. 통제된 실험실에서는 잘 굴러갔지만, 사람이 붐비는 요양원과 병원 현장에서는 실효성이 없었다. 대학원생들과 함께 만든 것은 훌륭한 ‘시제품’이었을 뿐 시장에서 팔릴 ‘제품’은 아니었다. 그들과의 협업은 과제 완수를 도왔지만, 우리를 상용화의 문턱 너머로 데려가지는 못했다.

    고립무원, 그리고 남은 것들

    프로젝트 도중 기획 담당자가 퇴사하는 일까지 겹쳤다. 졸지에 개발, 산더미 같은 페이퍼워크, 그리고 정부 부처 앞에서의 발표까지 모든 짐이 내게 쏟아졌다. 혼자서 개발자, 기획자, 발표자의 1인 3역을 해내야 했다.

    당시 회사는 서로를 터치하지 않고 각자 일에만 파묻혀 지내는 분위기였다. 주말까지 출근해 매달리는 나를 보고 동료들은 “그만 좀 나오라”고 했다. 걱정이 담긴 위로의 핀잔이었다.

    결국 그 휠체어는 시장의 빛을 보지 못했다. 하지만 아무것도 남지 않은 것은 아니었다.

    H대 대학원생들이 쓰던 ROS, 어깨너머로 처음 주워듣던 ‘토픽’과 ‘노드’라는 낯선 단어들. 카메라가 현장에서 두 사람을 가려내지 못해 쩔쩔맬 때, 그 한계를 넘어서고자 나는 직접 ROS를 파고들기 시작했다.

    프로젝트는 실패로 끝났지만 내 안에 새로운 기술은 남았다. 언젠가 그 기술이 충분히 무르익어, 합리적인 가격의 카메라가 수많은 인파 속에서도 원하는 사람을 거뜬히 가려내는 그날을 보고 싶다.

  • 베트남 프로젝트의 추억

    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일까지는 날짜만 있다.

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

  • ROS2의 데이터 모니터링

    펌웨어를 하던 시절, 장비 안의 값 하나를 화면에서 보려면 일이 거기서부터 시작됐다. 프레임을 정하고, 양쪽에 송수신 코드를 넣고, 포트를 열고, 수신 인터럽트를 달고, 파서를 짰다. 정작 보고 싶었던 것은 좌표 하나였는데 시간의 대부분은 통신 코드에 들어갔다.

    ROS2 에서 같은 일을 하면 명령 한 줄로 끝난다. 너무 간단해서 오히려 무슨 일이 벌어지는지 보이지 않는다. 이 글은 그 한 줄 뒤에서 무엇이 돌아가는지를, turtlesim 거북이의 X · Y 좌표를 읽는 예로 정리한다.

    1. 무선조종기에서 프로토콜을 설계하던 흔적

    2022년에 만든 AGV 무선조종기(무선조종기 개발의 추억)가 좋은 예다. 조종기에서 블루투스로 수신기까지 오고, 수신기(STM32F429)는 이더넷으로 B&R PLC 와 TCP/IP 로 붙는 구조였다. 당시 자료를 다시 보면 프로토콜을 설계한 흔적이 그대로 남아 있다.

    • PLC 가 수신기로 Command10 을 내리면 조종기 데이터를, Command11 을 내리면 와이어 엔코더 데이터(길이 mm, 각도 degree)를 올려 보낸다.
    • 조종기는 페어링 모드에서 5 바이트를 실시간으로 보내고, 수신기는 1 바이트마다 인터럽트로 개수를 세다가 5 바이트가 차면 수신 버퍼를 리셋한다.
    • LED 표시 하나를 바꾸려고 TCP 패킷 규약을 여러 번 새로 만들었다.

    핵심은 데이터 종류마다 커맨드 번호를 사람이 배정했다는 점이다. 항목이 하나 늘면 번호를 정하고 문서를 고치고 양쪽 코드를 같이 수정해야 했다. 한쪽만 고친 채로 붙이면 값이 조용히 틀어졌다.

    [그때]  조종기 ──BT──→ 수신기 ──TCP/IP──→ B&R PLC
                                  ↑
                        Command10 = 조종기 데이터
                        Command11 = 와이어 데이터     ← 번호를 사람이 배정
                        Command12 = ...(추가할 때마다 양쪽 수정)
    
    [ROS2]  /joystick   ← 토픽 이름이 곧 식별자
            /wire_encoder
            /turtle1/pose                              ← 추가해도 기존 코드 무수정

    <그림 1> 커맨드 번호 배정과 토픽 이름

    2. open · read · write · close 를 매번 만들었다

    프로토콜만 설계한 것이 아니다. 그 아래 입출력 살림도 매번 직접 만들었다. 포트를 여는 함수, 읽는 함수, 쓰는 함수, 닫는 함수를 만들고, 수신 인터럽트 핸들러에서 한 바이트씩 받아 링버퍼에 넣고, 오버런과 프레임 동기를 처리했다.

    /* 펌웨어 쪽 — 프로젝트마다 다시 만들던 것 */
    int  uart_open (uint32_t baud);          /* 클럭, 핀, 보레이트, 패리티 설정 */
    int  uart_read (uint8_t *buf, int len);  /* 링버퍼에서 꺼내 프레임 조립 */
    int  uart_write(const uint8_t *buf, int len);
    void uart_close(void);
    
    void USART1_IRQHandler(void)             /* 1 바이트 수신 인터럽트 */
    {
        uint8_t b = USART1->DR;
        ring_put(&rx_ring, b);
        if (++cnt == 5) { cnt = 0; frame_ready = 1; }   /* 5 바이트 프레임 */
    }

    물론 맨바닥부터 만든 것은 아니다. STM32 는 HAL 이 HAL_UART_Init(), HAL_UART_Receive_IT() 같은 드라이버와 인터럽트 콜백을 제공하고, 이더넷은 lwIP 가 TCP 스택을 올려 준다. 레지스터를 직접 두드릴 일은 없다. 그래도 남는 일이 있었다.

    • 클럭·핀·DMA 설정을 맞추고, 수신 콜백에서 링버퍼로 옮기는 코드
    • 바이트 흐름을 프레임으로 자르고 동기를 복구하는 코드
    • 소켓을 열고, 접속을 기다리고, 끊기면 다시 붙고, 살아 있는지 확인하는 코드
    • 그리고 무엇보다 어떤 값을 어떤 순서와 단위로 실을 것인가 — 프로토콜 설계

    즉 HAL 이 없애 준 것은 드라이버 계층이고, ROS2 가 없애 주는 것은 그 위층이다. 위 목록이 통째로 사라진다. ROS2 에서 같은 일을 하는 코드는 이것이 전부다.

    // ROS2 쪽 — 구독자 한 줄과 콜백 하나
    subscription_ = this->create_subscription<turtlesim::msg::Pose>(
      "pose", 10, std::bind(&PoseMonitor::onPose, this, std::placeholders::_1));
    
    void onPose(const turtlesim::msg::Pose::SharedPtr msg)
    {
      RCLCPP_INFO(this->get_logger(), "x=%.3f  y=%.3f  theta=%.3f",
                  msg->x, msg->y, msg->theta);
    }
    펌웨어에서 만들던 것 ROS2 에서
    uart_open() / socket(), connect() — 포트·보레이트·IP 설정 create_subscription() 한 줄. 전송 설정은 QoS 값 하나
    uart_read() — 링버퍼에서 꺼내 프레임 조립 콜백이 완성된 메시지로 호출된다
    uart_write() — 송신 버퍼, DMA 큐 publish()
    uart_close() — 자원 반납, 재접속 처리 노드 소멸자. 재연결은 디스커버리가 알아서 한다
    수신 인터럽트 핸들러, 바이트 카운트, 동기 복구 실행기(executor)가 콜백을 대신 불러 준다
    링버퍼 크기 결정, 오버런 처리 QoS depth (예: 10), RELIABLE / BEST_EFFORT
    엔디안·구조체 패킹 합의 .msg 파일 → 직렬화 코드 자동 생성
    ★ 주. 규율까지 없어지는 것은 아니다. 수신 인터럽트 안에서 오래 걸리는 일을 하면 안 되는 것과 똑같이, 구독 콜백 안에서 블로킹 작업을 하면 그 실행기의 다른 콜백이 전부 밀린다. 자리만 옮겨졌을 뿐 같은 규칙이 그대로 적용된다.

    3. ROS2 에서는 명령 한 줄이다

    turtlesim 을 띄워 두고 다른 터미널에서 다음을 실행하면 좌표가 바로 흐른다.

    $ ros2 topic echo /turtle1/pose
    
    x: 7.549399852752686
    y: 3.549400091171265
    theta: 0.0
    linear_velocity: 0.0
    angular_velocity: 0.0
    ---

    파서도, 포트 설정도, 체크섬도 쓰지 않았다. 중요한 것은 시뮬레이터 코드 어디에도 이 값을 화면에 찍는 코드가 없다는 점이다. 시뮬레이터는 발행(publish)만 하고, 출력은 ros2 topic echo 라는 별개의 프로세스가 한다. 발행하는 쪽은 누가 받는지조차 모른다.

    4. 그 한 줄이 하는 일

      ros2 topic echo /turtle1/pose
            │
            ├ (1) 파이썬 플러그인 실행     ros2topic/verb/echo.py
            ├ (2) DDS 참가자 생성          UDP 소켓 7400 / 7414 / 7415 개방
            ├ (3) 디스커버리               "누가 /turtle1/pose 를 내는가"
            │        └ turtlesim 응답      "내가. 타입은 turtlesim/msg/Pose"
            ├ (4) 구독자 연결              같은 PC 이므로 공유메모리로 전송
            └ (5) 역직렬화 후 출력          24 바이트 → YAML

    <그림 2> 명령 한 줄이 거치는 다섯 단계

    (3)이 옛날에 사람이 하던 일의 대부분을 대신한다. 상대 IP 와 포트를 하드코딩하고 커맨드 번호를 배정하던 자리에, 토픽 이름과 타입이 모두 맞을 때만 연결한다는 규칙이 들어갔다. 이름이나 타입이 어긋나면 값이 틀어지는 것이 아니라 아예 연결되지 않는다. 조용히 틀어지던 옛 방식보다 이쪽이 낫다.

    (2)의 포트는 도메인 ID 로 계산되므로 ROS_DOMAIN_ID 가 다르면 서로 보이지 않는다. (4)에서 같은 PC 안의 통신은 네트워크를 타지 않고 공유메모리로 오간다. 다른 PC 로 나갈 때만 UDP 가 된다.

    5. 바이트는 사라지지 않았다

    프레임이 없어진 것이 아니라 규약으로 고정되고 코드가 자동 생성될 뿐이다. Pose 메시지를 실제로 직렬화하면 24 바이트가 나온다.

    00 01 00 00  AF 94 F1 40  5F 29 63 40  00 00 00 00  00 00 C0 3F  00 00 00 00
    └─ 헤더 ──┘  └─ x ─────┘  └─ y ─────┘  └─ theta ─┘  └─ lin_vel ┘  └─ ang_vel ┘
                    7.5494       3.5494         0.0          1.5          0.0

    <그림 3> turtlesim/msg/Pose 의 실제 직렬화 결과 (CDR, 24 바이트)

    앞 4 바이트가 CDR 캡슐화 헤더이고 00 01 이 리틀엔디안을 뜻한다. 엔디안 합의를 문서가 아니라 페이로드 안에 넣어 둔 것이다. 나머지는 float32 다섯 개가 그대로 나열된다. CRC 는 없다. 하위 계층에 맡긴다. 5 바이트를 세어 동기를 잡던 그 일을, 지금은 이 형식과 시퀀스 번호가 대신한다.

    6. 모니터링에 쓰는 명령

    ros2 topic echo /turtle1/pose --once     # 한 번만 보고 종료
    ros2 topic echo /turtle1/pose --field x  # x 값만
    ros2 topic echo /turtle1/pose --csv      # 7.549,3.549,0.0,...  로그 저장용
    ros2 topic hz   /turtle1/pose            # 발행 주기
    ros2 topic info /turtle1/pose -v         # 타입, 발행자·구독자, QoS 전부

    오실로스코프 대신 --csv 로 받아 스프레드시트에 붙이면 그대로 그래프가 된다. hz 는 주기가 흔들리는지 보는 용도로, 실기에서 센서 노드가 밀리는지 확인할 때 먼저 친다. turtlesim 의 pose 는 약 62 Hz 로 나온다.

    7. 좌표의 기준을 먼저 확인한다

    값이 나온다고 해서 그 값의 기준까지 알려 주지는 않는다. turtlesim 의 좌표는 화면 왼쪽 아래가 원점인 0 ~ 11.10 m 범위이고, 거북이는 한가운데인 (5.5494, 5.5494) 에서 시작한다. 시작 위치를 원점으로 보고 싶다면 그 값을 빼야 한다.

    ros2 topic echo /turtle1/pose --csv | \
      awk -F, '{printf "X:%7.2f  Y:%7.2f  각도:%7.1f\n", $1-5.5494, $2-5.5494, $3*57.2958}'
    ★ 주. theta 는 라디안이다. 도(°)로 보려면 57.2958 을 곱한다. 실제로 시뮬레이터 화면에 좌표를 표시하는 기능을 만들다가, 내부 좌표는 y 축이 화면 아래쪽으로 증가하는데 이를 그대로 쓰는 바람에 Y 부호가 반대로 나오는 실수를 했다. 값이 나오는 것과 값이 맞는 것은 다른 문제다. 텔레포트 서비스로 아는 위치에 놓고 표시값과 대조해서 잡았다.

    8. 편해진 것의 값

    공짜는 아니다. 거북이 하나를 그리는 turtlesim 프로세스가 스레드 17 개, 메모리 약 114 MB 를 쓴다. 펌웨어 감각으로는 납득하기 어려운 수치다. PC 급 자원을 전제로 개발 시간을 산 구조이기 때문이다.

    그래서 실제 설비에서는 층을 나눈다. 마이크로초 단위의 주기를 지켜야 하는 제어 루프는 EtherCAT 과 PLC 쪽에 두고, ROS2 는 그 위에서 명령을 내리고 상태를 모니터링하는 계층으로 쓴다. 인터럽트와 링버퍼를 다루던 실력이 쓸모없어진 것이 아니라, 그 일을 해야 하는 자리가 아래층으로 내려간 것이다.

    값 하나 보려고 커맨드 번호를 새로 배정하고 양쪽 코드를 고치던 입장에서 보면, 명령 한 줄로 좌표가 흐르는 것은 분명 편하다. 다만 그 한 줄이 무엇을 대신해 주고 있는지는 알고 쓰는 편이 낫다. 문제가 생기는 지점은 언제나 그 아래층이기 때문이다.

  • 반복되는 꿈

    2026년 9월 21일

    꿈은 대개 무작위다. 앞뒤가 없고 아침이면 사라진다. 그런데 가끔 같은 세계로 돌아간다. 지난번의 장소가 그대로 있다. 내가 쓰던 능력도 그대로다. 거기서 또 하나의 인생이 조금씩 이어진다.

    설정

    그 세계에는 설정이 둘 있다.

    나는 날 수 있다. 내 대학 졸업은 취소되어 있다.

    그 안에서는 어느 쪽도 이상하지 않다. 사람이 날 수 없다는 생각이 들지 않는다. 마흔이 넘어 졸업이 취소될 리 없다는 생각도 들지 않는다. 렘수면에서는 모순을 잡아내는 자리가 꺼진다. 이야기를 짓는 자리는 켜져 있다.

    높이와 자세

    높이는 늘 같다. 지면에서 1미터와 2미터 사이다. 그보다 높이 오르지 않는다.

    자세는 배영이다. 얼굴이 위를 보고 등이 아래를 향한다.

    1~2 m
    <그림 1> 높이와 자세

    뇌에는 나는 동작의 운동 프로그램이 없다. 걷기가 있고 수영이 있다. 없는 것을 만들 때는 있는 것을 가져다 쓴다. 그래서 배영이다.

    높이 오르지 못하는 것은 나만의 일이 아니라고 한다. 사람들은 대개 구름 위를 날지 않는다. 지면을 스치듯 난다.

    속도

    속도는 날마다 다르다. 어떤 날은 오토바이만큼 나간다. 어떤 날은 기어간다.

    힘껏 젓는다. 풍경은 그대로다.

    그런 날은 자세를 바꾼다. 팔의 각도를 눕힌다. 몸을 편다. 가끔 통하고 가끔 통하지 않는다. 꿈을 꾸면서도 나는 원인을 찾고 있었다.

    렘수면에서 몸은 잠긴다. 근육으로 가는 길이 끊긴다. 명령은 나가고 응답은 오지 않는다. 물엿 속을 젓는 느낌이 거기서 나온다.

    이 꿈은 나쁘지 않았다.

    서른여섯

    문제는 다른 쪽이다.

    꿈에서 내 졸업은 취소되어 있다. 학력은 고졸이다. 나는 수능을 다시 친다. 한 번이 아니다. 4년을 친다. 5년을 친다.

    꿈속의 나는 서른여섯이다. 깨어 있는 나는 마흔여섯이다.

    고통스러웠다.

    꿈이었다. 그러나 렘수면에서는 감정을 만드는 자리가 깨어 있을 때보다 더 활발하고, 그것을 누르는 자리만 꺼진다. 조절 장치가 빠진 채로 감정만 돈다. 줄어든 고통이 아니었다. 완충 없이 그대로 받은 고통이었다.

    점수

    견디기 어려운 것은 떨어지는 일이 아니었다.

    해마다 점수가 낮아졌다.

    준비하지 못해 떨어지는 꿈이 있다. 그것은 불안이다. 내 꿈은 그것이 아니다. 준비했고, 여러 해를 쳤고, 결과는 나빠졌다.

    불안이 아니라 쇠퇴다.

    스무 살은 해낼 수 있는지를 묻는다. 마흔여섯은 그것을 묻지 않는다. 예전만 못해지고 있는지를 본다. 쏟는 것은 그대로인데 나오는 것이 줄어든다. 꿈은 그것을 숫자 하나로 바꾸어 해마다 조금씩 깎아 보여주었다.

    거기에 졸업 취소가 붙는다. 종이 한 장을 잃은 것이 아니다. 서 있던 자리가 없어진 것이다. 그래서 꿈속의 나는 고졸 신분이 된다.

    같은 꿈

    적어 놓고 보니 두 꿈이 하나였다.

     나는 꿈수능 꿈
    장면힘껏 젓는다. 기어간다.해마다 친다. 졸업하지 못한다.
    감각넣은 것이 나오지 않는다
    차이자세를 바꾸면 가끔 반응한다무엇을 해도 내 손 밖이다

    같은 감정에 옷을 두 벌 입힌 것이었다. 차이는 하나다.

    나는 꿈에는 손잡이가 있다. 자세가 내 쪽에 있다. 돌리면 가끔 반응한다. 수능 꿈에는 손잡이가 없다.

    괴로움의 크기를 정하는 것은 상황의 나쁨이 아니었다. 돌릴 손잡이가 하나라도 있느냐였다.

    덮어쓰기

    반복되는 악몽에는 이미지 리허설 요법이라는 것이 있다고 한다. 절차는 짧다.

    1. 깨어 있을 때 그 꿈을 적는다
    2. 결말을 고쳐 쓴다
    3. 고친 것을 자기 전에 5분에서 10분, 몇 주 동안 재생한다

    지우는 것이 아니라 덮어쓰는 것이다. 한 번 꾼 꿈은 기억이 되고, 그 기억이 다음 꿈의 재료가 된다. 같은 세계로 돌아가게 되는 까닭이 거기 있다. 그렇다면 이쪽에서 재료를 넣을 수도 있다.

    내 것이라면 이렇게 고쳐 쓰겠다. 시험장에 들어가 앉는다. 감독관이 명단을 확인하고 말한다. 선생님은 이 시험 대상이 아닙니다. 이미 끝내셨습니다. 가방을 들고 나온다. 복도가 길고 조용하다.

    시험을 잘 보는 결말로 고치지 않는다. 점수를 잘 받는 것으로 고치면 점수로 증명해야 한다는 전제를 그대로 두는 것이 된다. 시험이 나와 무관해져야 전제가 깨진다.

    꿈은 근거를 모아 결론을 내지 않는다. 감정에서 시작해 이야기를 뒤로 짓는다. 검증하는 자리가 꺼져 있다. 반박할 사람이 없다.

    그러니 그 성적표는 자료가 아니다. 다만 그 숫자가 어디에서 나왔는지는 안다.

    오늘 밤에도 그 세계로 갈지 모른다. 가면 조금 빨리 날고 싶다.

  • 와이어 엔코더의 추억

    2022년 여름, 책상 위에 자동감김 릴 하나가 올라왔다. 잡아당기면 풀리고 놓으면 되감기는, 사원증 줄 같은 그 물건이다. 여기에 자석 하나와 칩 하나를 붙여서 AGV 가 앞차를 따라가게 만드는 것이 그해의 숙제였다. 2023년 3월에 손을 뗐다.

    줄 하나면 된다

    추종제어(following control)의 요구는 단순했다. 앞차와 뒤차를 와이어로 잇고, 뒤차가 앞차를 일정한 거리에서 따라가게 한다. 그러려면 두 가지만 알면 된다.

    • 와이어가 얼마나 풀려 있는가 — 길이 L
    • 와이어가 어느 방향으로 당겨지고 있는가 — 각도 θ

    L 이 목표보다 길어지면 가속하고 짧아지면 감속한다. θ 만큼 조향한다. 실제로 제어는 어렵지 않았다. 어려운 것은 이 두 값을 싸게, 그리고 튼튼하게 재는 일이었다.

    자석 하나, 칩 하나

    릴의 회전축에 지름 방향으로 착자된 자석(diametric magnet)을 붙인다. 그 위 1–2 mm 떨어진 자리에 AS5600 을 올린다. AS5600 은 홀 소자 배열로 자기장의 방향을 읽어 각도를 내놓는 12 비트 자기 각도 센서다. 한 바퀴를 4,096 으로 나누고, I2C 로 읽는다.

    AS5600 기판과 릴 허브
    <그림 1> 왼쪽이 AS5600 기판, 오른쪽이 자석을 물린 릴 허브

    와이어를 당기면 릴이 돌고, 릴이 돌면 자석이 돌고, 자석이 돌면 각도가 바뀐다. 그 각도에 드럼 원주를 곱하면 길이가 된다. 이 구조의 좋은 점은 닿는 데가 없다는 것이다. 축과 센서가 접촉하지 않으니 마모가 없다.

    대신 에어갭을 지켜야 한다. 1–2 mm 를 벗어나면 센서가 읽는 자기장 세기가 범위 밖으로 나간다. 기구적으로는 이 1 mm 를 유지하는 것이 전부였다.

    릴 뚜껑에 취부한 센서 기판
    <그림 2> 릴 뚜껑 위에 센서 기판을 얹어 에어갭을 맞춘다

    길이축과 조향축, 두 군데를 재야 하므로 AS5600 은 두 개를 썼다.

    다회전을 세는 스무 줄

    AS5600 은 절대각을 준다. 그런데 그 절대각은 한 바퀴까지다. 4,095 다음은 0 이다. 와이어가 열 바퀴 풀렸다면 그 열 바퀴는 따로 세어 주어야 한다.

    degAngle = output * 0.087890625;
    // 한 샘플 사이에 반 바퀴 넘게 튀었다면, 돈 것이 아니라 경계를 넘은 것이다
    if ((lastOutput - output) >  2047) revolutions++;
    if ((lastOutput - output) < -2047) revolutions--;
    position = revolutions * 4096 + output;

    이 가정이 성립하려면 샘플링이 충분히 빨라야 한다. 와이어가 갑자기 풀릴 때가 위험하다. 한 샘플 사이에 반 바퀴 이상 돌아 버리면 회전수를 통째로 놓치고, 그 뒤의 길이는 전부 틀린다. 이 방식의 한계는 분해능이 아니라 속도에 있다.

    각도 환산 계수 0.087890625 는 360 을 4,096 으로 나눈 값이다. 코드에 이 숫자가 그대로 박혀 있다.

    0.038

    길이 환산은 실측으로 맞췄다.

    WireLength = position * 0.038 - 80;  // -80 은 오프셋 → 향후 파라미터로 조정

    0.038 은 카운트당 밀리미터다. 한 바퀴가 4,096 카운트이므로 0.038 × 4,096 ≈ 155.6 mm, 드럼 지름으로 환산하면 약 50 mm 가 된다. 보고서에는 설명용으로 지름 100 mm 를 가정해 원주 314 mm 라고 적어 두었지만, 실제 릴은 그 절반쯤이었다.

    뒤에 붙은 -80 은 오프셋이다. 와이어를 끝까지 감았을 때가 0 이 아니라서 뺀 값이다. 주석에 “향후 파라미터로 조정”이라고 써 두었고, 실제로 나중에 밖으로 뺐다.

    디버거에 찍힌 한 순간
    revolutions1
    output (원시 각도)2,000
    position1 × 4,096 + 2,000 = 6,096
    degAngle2,000 × 0.087890625 = 175.78°
    WireLength6,096 × 0.038 − 80 = 151.6 mm

    숫자가 손으로 계산한 값과 맞아떨어지는 것을 확인하고 나서야 다음으로 넘어갔다.

    릴을 열다

    그런데 그 0.038 이 처음부터 맞지는 않았다. 환산 상수가 어긋나서, 드럼 지름을 직접 재려고 릴을 분해했다.

    릴 내부의 판 스프링
    <그림 3> 뚜껑 안쪽. 이 얇은 판 스프링이 되감기의 전부다

    안에는 태엽처럼 감긴 얇은 판 스프링이 들어 있었다. 와이어를 되감는 힘은 전부 여기서 나온다. 뚜껑을 여는 순간 그 스프링이 풀렸고, 다시 감는 요령을 몰라 끝내 복원하지 못했다. 릴을 새로 주문해야 했다.

    그때 배운 것은 이것이다.

    재려고 분해했다가, 잴 대상을 잃었다.

    지금 생각하면 열 필요가 없었다. 와이어를 일정 길이만큼 뽑아 자로 재고, 그동안 올라간 카운트로 나누면 계수가 나온다. 그 다음부터는 그렇게 했다.

    자를 대고 와이어 길이를 실측하는 모습
    <그림 4> 뚜껑을 열지 않고도 구할 수 있었던 값

    상수는 밖으로

    값을 올리는 쪽은 블루투스였다. 상위 B&R PLC 가 커맨드를 내리면 수신 보드가 답하는 구조다.

    커맨드응답
    −10조이스틱 값
    −11와이어 엔코더의 길이와 각도 (float)
    −12파라미터 쓰기 — 길이축·조향축의 드럼 직경과 오프셋

    세 번째가 중요했다. 드럼 직경과 오프셋을 펌웨어에 박지 않고 PLC 에서 써 넣을 수 있게 해 두었다. 릴을 교체하면 직경이 미세하게 달라지는데, 그때마다 펌웨어를 다시 구울 수는 없다. 이건 잘한 결정이었다.

    블루투스 모듈의 전원은 MCU 가 트랜지스터로 끊었다 붙일 수 있게 오픈 컬렉터로 물려 두었다. 모듈이 먹통이 되면 사람이 가서 전원을 뽑는 대신 펌웨어가 재기동한다.

    같은 수신 보드 이야기는 무선조종기 개발의 추억 에 따로 적었다. 거기서 커맨드 10 과 11 을 언급했는데, 그 11 이 바로 이 와이어 엔코더다.

    굴러갔다

    2023년 초, 프로토타입으로 AGV 추종 구동에 성공했다. 만능기판 위의 Nucleo-F429ZI, 점퍼선, 그리고 테이프로 붙인 릴이 전부였다.

    AGV 추종 구동에 성공한 프로토타입
    <그림 5> 굴러간 날의 책상

    돌아보면 제어가 가장 쉬운 부분이었다. 남은 질문은 전부 기구 쪽이었다. 와이어가 늘어지면 어떻게 되는가. 사람이 걸려 넘어지면. 릴 스프링의 수명은 몇 만 번인가. 현장의 분진 속에서 에어갭 1 mm 가 유지되는가.

    그 질문들에 답하기 전에 다른 문제가 먼저 왔다.

    이미 걸려 있었다

    이 방식에 이미 특허가 걸려 있다는 것을 뒤늦게 알았다. 변리사에게 상담을 받았고, 회피가 쉽지 않다는 답을 들었다. 보고했지만 그대로 추진하라는 답이 돌아왔다. 결국 제품으로는 나가지 않았다.

    기술적으로 되는 것과 팔아도 되는 것은 다른 문제다. 그리고 그 둘 사이의 거리는 대개 구현이 끝난 다음에야 보인다. 선행 특허 조사는 착수 전에 하는 것이다. 다 만들어 놓고 하는 조사는 조사가 아니라 확인이다.

    3월에 손을 뗐다. 릴은 서랍에 들어갔다.

    남은 것은 몇 개다. 자석 하나로 각도를 읽는 방법, 다회전을 세는 스무 줄, 열기 전에 생각하라는 것, 그리고 만들기 전에 찾아보라는 것.

    요즘도 자동감김 릴을 보면 손이 먼저 간다. 다만 뚜껑은 열지 않는다.

  • CAN 하던 사람을 위한 ROS2

    CAN 을 다뤄 본 사람이 ROS2 의 토픽을 처음 보면 낯익은 느낌을 받는다. 각 장치가 자기 식별자를 달아 계속 내보내고, 필요한 쪽은 그 식별자만 알면 집어 간다 — 딱 CAN 버스다.

    그 직관은 옳다. 다만 어디까지 옳고 어디서부터 어긋나는지를 알아야 ROS2 를 제대로 쓸 수 있다. 이 글은 CAN 을 이미 아는 사람을 위해 두 방식을 나란히 놓고 비교한다.

    1. 왜 닮아 보이는가 — 데이터 중심 주소지정

    두 방식이 닮은 것은 우연이 아니다. 둘 다 데이터 중심 주소지정(data-centric addressing)이라는 같은 계열에 속한다. 「누구에게 보낼 것인가」가 아니라 「무슨 데이터인가」로 주소를 삼는다는 뜻이다.

    [연결 중심 — TCP 소켓, ROS1 의 연결 단계]
    
        노드 A ────── 직접 연결 ──────→ 노드 B
        "B 에게 보낸다"   상대를 알아야 한다
    
    
    [데이터 중심 — CAN, ROS2 토픽]
    
                          ┌──→ 노드 B   (0x1A0 을 받아 간다)
        노드 A ──0x1A0──→ ├──→ 노드 C   (0x1A0 을 받아 간다)
        "0x1A0 을 낸다"    └──✕  노드 D   (관심 없음, 버린다)
         받는 쪽을 모른다

    <그림 1> 연결 중심과 데이터 중심

    CAN 노드는 자기 프레임을 누가 받는지 모르고, 알 필요도 없다. ROS2 퍼블리셔도 마찬가지로 구독자가 몇인지 모른 채 그냥 발행한다. 송신 쪽 코드를 전혀 건드리지 않고 수신 노드를 하나 더 붙일 수 있다는 것, 이것이 두 방식이 공유하는 가장 큰 미덕이다.

    여기서 파생되는 성질도 같다. 다대다가 자연스럽고, 연결 수립 절차가 응용 코드에 보이지 않으며, 주된 쓰임새가 요청·응답이 아니라 주기적인 상태 방송이다.

    2. ROS1 은 오히려 덜 닮았다

    재미있는 것은 ROS1 보다 ROS2 가 CAN 에 훨씬 더 가깝다는 점이다.

    ROS1 에는 roscore 라는 중앙 마스터가 있었다. 퍼블리셔가 마스터에 등록하면, 구독자가 마스터에게 물어 퍼블리셔의 주소를 받아 온 뒤 그쪽으로 직접 TCP 연결을 맺는 구조였다. 이름만 데이터 중심이지 실제 데이터는 점대점으로 흘렀다. 버스라기보다 전화 교환대에 가까웠고, 마스터가 죽으면 새 연결을 맺을 수 없었다.

    ROS2 는 마스터를 없앴다. 그 자리에 DDS 의 분산 디스커버리가 들어왔다. 노드들이 멀티캐스트로 자기 존재와 관심사를 알리고, 조건이 맞는 짝끼리 알아서 연결한다. 각자 떠들고 각자 골라 듣는 구조가 된 것이다. CAN 버스의 감각이 정확히 여기서 나온다.

    ★주 1. 대신 ROS2 는 디스커버리 트래픽이라는 새 문제를 얻었다. 노드가 수백 개로 늘면 서로를 찾는 패킷만으로 망이 시끄러워진다. 이를 다루려고 도입된 것이 ROS_DOMAIN_ID 로 망을 나누는 방법, 그리고 디스커버리 서버(discovery server)를 두는 방법이다. CAN 에는 디스커버리라는 개념 자체가 없으므로 이런 고민도 없다 — 대신 배선도와 식별자 표를 사람이 관리해야 한다.

    3. 결정적인 차이 — 식별자는 주소이자 우선순위다

    여기가 두 방식이 갈라지는 지점이고, CAN 을 아는 사람이 ROS2 에서 가장 먼저 허전함을 느끼는 부분이다.

    CAN 의 식별자는 주소인 동시에 우선순위다.

    CAN 은 여러 노드가 같은 순간에 송신을 시작해도 충돌로 프레임이 깨지지 않는다. 물리 계층이 와이어드 AND 로 동작하기 때문이다. 한 노드라도 0(도미넌트)을 내보내면 버스 전체가 0 이 된다. 1(리세시브)은 아무도 0 을 내지 않을 때만 유지된다.

    송신 중인 노드는 자기가 낸 비트와 버스에서 읽히는 비트를 계속 비교한다. 자기는 1 을 냈는데 버스가 0 으로 읽히면, 나보다 우선순위 높은 노드가 말하고 있다는 뜻이므로 즉시 물러난다. 이때 이미 나간 비트들은 이긴 노드의 비트와 똑같았으므로 프레임은 전혀 손상되지 않는다. 진 노드는 버스가 비는 즉시 다시 시도한다.

    이것을 비파괴 중재(non-destructive arbitration)라 하며, 결과적으로 식별자가 작은 노드가 항상 이긴다. 아래에서 직접 돌려 볼 수 있다. 세 노드의 식별자를 바꿔 가며 「한 비트」로 한 칸씩 진행해 보라.


    <그림 2> CAN 비트 중재. 식별자가 작을수록 먼저 말한다.

    이 성질 덕분에 CAN 은 고우선순위 메시지의 최악 지연시간을 계산으로 보증할 수 있다. 식별자 배분이 곧 실시간 설계가 되는 것이다. 차량 안전계통이 수십 년째 CAN 을 쓰는 이유가 여기 있다.

    ROS2 의 토픽 이름에는 이런 성질이 전혀 없다. 이름은 그냥 이름이다. /cmd_vel 이 /camera/image_raw 보다 먼저 간다는 보장이 없고, 커다란 영상 메시지가 망을 채우는 동안 짧은 제어 명령이 뒤에서 기다릴 수 있다. DDS QoS 에 TRANSPORT_PRIORITY 항목이 있으나 일반적인 이더넷·UDP 환경에서는 CAN 같은 보증이 되지 않는다.

    정리하면 이렇다. CAN 의 식별자는 설계 결정이고, ROS2 의 토픽 이름은 작명이다.

    ★주 2. ROS2 에서 실시간성이 필요하면 토픽 이름이 아니라 다른 층에서 풀어야 한다. 실시간 커널(PREEMPT_RT), 같은 프로세스 안에서 복사를 없애는 인트라프로세스 통신, 실행기(executor) 의 콜백 그룹과 스레드 우선순위, 그리고 이더넷이라면 TSN 이 그 수단이다. 망 계층이 알아서 우선순위를 지켜 주던 CAN 과 달리, ROS2 에서는 사람이 여러 층을 직접 조립해야 한다.

    4. 나머지 차이

    항목 CAN ROS2 토픽
    페이로드 8 바이트 (CAN FD 는 64) 사실상 제한 없음
    속도 1 Mbit/s (FD 데이터 구간은 수 Mbit/s) 이더넷 그대로. 기가비트 이상
    형식 계약 DBC 파일 — 버스 밖의 약속 .msg — 디스커버리 때 형식이 맞아야 연결된다
    디스커버리 없음. 설계 시 고정 동적. 붙었다 떨어졌다 한다
    늦게 켠 노드 다음 주기까지 아무것도 모른다 TRANSIENT_LOCAL 이면 마지막 값을 즉시 받는다
    재전송 하드웨어가 자동. 에러 카운터·버스오프 RELIABLE / BEST_EFFORT 선택
    실시간성 해석 가능한 하드 실시간 일반적으로 보증 없음
    물리 계층 버스 하나에 종단 저항 120 Ω. 길이와 속도가 맞물린다 전송 방식과 무관. 보통 UDP, 같은 PC 안이면 공유 메모리

    <표 1> CAN 과 ROS2 토픽의 차이

    실무에서 의외로 크게 체감되는 것이 「늦게 켠 노드」 항목이다. CAN 에서는 나중에 전원을 넣은 장치가 모터 상태 프레임이 올 때까지 아무것도 모른 채 기다려야 한다. ROS2 는 퍼블리셔가 TRANSIENT_LOCAL 로 발행하면 구독을 시작하는 순간 마지막 값을 받는다. 지도, 로봇 기술(URDF), 설정값처럼 자주 변하지 않지만 항상 알아야 하는 데이터에 쓴다. CAN 에는 대응물이 없어서, 보통 새 노드가 SDO 로 직접 읽어 오거나 주기를 짧게 가져가는 식으로 우회한다.

    5. CANopen 으로 보면 정확히 맞아떨어진다

    날것의 CAN 보다 CANopen 과 비교하면 훨씬 깔끔하게 대응된다. CANopen 이 CAN 에 없던 것 — 형식 계약과 상태 관리 — 을 얹은 규약이기 때문이다.

    CANopen ROS2 공통점
    PDO
    Process Data Object
    토픽 주기적·무확인. 빠르고 가볍다
    SDO
    Service Data Object
    서비스 요청에 응답이 따라온다. 느리지만 확실하다
    NMT
    Pre-op → Operational
    라이프사이클 노드 상태 전이를 거쳐야 데이터가 흐른다
    하트비트 / 노드 가딩 LIVELINESS QoS 상대가 살아 있는지 주기적으로 확인
    EMCY (긴급 메시지) /diagnostics 이상 상황을 따로 알린다
    오브젝트 딕셔너리 (EDS) .msg + 파라미터 주고받을 내용의 형식 계약
    SYNC (대응물 없음) 여러 노드의 동시 샘플링·동시 구동

    <표 2> CANopen 과 ROS2 의 대응

    특히 NMT ↔ 라이프사이클 노드 대응이 볼만하다. CANopen 장치는 전원을 넣었다고 바로 PDO 를 쏟아내지 않는다. 초기화를 거쳐 Pre-operational 에 머물다가, 마스터가 Operational 로 올려 줘야 비로소 데이터를 낸다. ROS2 의 라이프사이클 노드도 똑같이 Unconfigured → Inactive → Active 를 거치며, Active 가 되기 전에는 발행하지 않는다.

    둘 다 「켜자마자 떠들기 시작하면 곤란하다」는 현장 경험에서 나온 구조다. 센서 보정이 끝나기 전 값, 모터 드라이버가 준비되기 전 지령은 없느니만 못하다. ROS2 를 쓰면서 라이프사이클 노드를 왜 쓰는지 모르겠다는 사람에게는 이 비유가 가장 잘 통한다.

    6. 토픽인가 서비스인가 — PDO 인가 SDO 인가

    ROS2 입문자가 가장 오래 헤매는 물음이 「이건 토픽으로 해야 하나 서비스로 해야 하나」다. CAN 을 아는 사람에게는 이 물음을 바꿔 물으면 답이 바로 나온다.

    이것은 PDO 인가, SDO 인가?

    • 주기적으로 계속 흘려보낼 상태인가 → PDO → 토픽
      모터 현재 속도, 엔코더 값, 센서 측정치, 조인트 상태
    • 어쩌다 한 번 물어보거나 바꾸는 설정인가 → SDO → 서비스
      가속도 한계 설정, 원점 복귀 실행, 모드 전환, 파라미터 읽기

    판별 기준도 같다. 「응답을 기다려야 하는가」와 「한 번 놓쳐도 다음 주기에 만회되는가」이다. 모터 속도는 한 번 놓쳐도 10 ms 뒤에 또 온다 — 토픽이다. 원점 복귀 명령은 놓치면 안 되고 완료 여부를 알아야 한다 — 서비스, 혹은 오래 걸린다면 액션(action)이다.

    ★주 3. ROS2 에는 CANopen 에 없는 세 번째 기구인 액션(action)이 있다. 오래 걸리고, 중간 경과를 알려 주어야 하며, 도중에 취소할 수 있어야 하는 작업에 쓴다. 로봇 팔을 목표 자세로 보내는 일이 전형적이다. SDO 로 억지로 흉내 내려면 「시작 명령 SDO + 진행률 PDO + 중단 명령 SDO」를 손으로 조립해야 하는데, 액션은 그 조합을 하나의 규약으로 묶어 둔 것이라 보면 된다.

    7. 계약이 틀렸을 때 무슨 일이 생기는가

    날마다 가장 크게 체감되는 차이는 성능도 구조도 아니고 틀렸을 때의 증상이다.

    CAN — 조용히 틀린 값이 나온다

    CAN 프레임에는 형식 정보가 없다. 8 바이트의 의미는 전적으로 DBC 파일이라는 버스 밖의 약속에 달려 있다. 스케일 인자를 잘못 넣으면 온도가 25 ℃ 대신 250 ℃ 로 나오는데, 아무도 오류를 내지 않는다. 바이트 순서를 거꾸로 읽어도, 부호 있는 값을 없는 값으로 읽어도 마찬가지다. 그럴듯하게 틀린 숫자가 태연히 흘러간다.

    그래서 CAN 디버깅은 파형과 원시 바이트를 눈으로 좇는 일이 된다. 오실로스코프, CAN 분석기, DBC 를 놓고 대조한다.

    ROS2 — 아예 연결이 안 된다

    ROS2 는 형식이 디스커버리 단계에서 대조된다. 퍼블리셔가 sensor_msgs/msg/Temperature 를 내고 구독자가 std_msgs/msg/Float32 를 기다리면 둘은 애초에 이어지지 않는다. 잘못된 값이 흘러갈 일이 없다.

    대신 ROS2 에는 CAN 에 없던 함정이 하나 생긴다. 형식이 맞는데도 QoS 가 안 맞아 연결되지 않는 경우다. 초보자를 가장 많이 잡는 대목이고, 증상이 「퍼블리시는 되는데 아무도 못 받는다」라서 원인을 짐작하기 어렵다.

    퍼블리셔가 제공 구독자가 요구 결과
    RELIABLE RELIABLE 연결됨
    RELIABLE BEST_EFFORT 연결됨
    BEST_EFFORT RELIABLE 연결 안 됨
    TRANSIENT_LOCAL VOLATILE 연결됨
    VOLATILE TRANSIENT_LOCAL 연결 안 됨

    <표 3> QoS 짝 맞추기 — 제공이 요구보다 약하면 연결되지 않는다

    규칙은 하나다. 퍼블리셔가 제공하는 품질이 구독자가 요구하는 품질보다 약하면 연결되지 않는다.

    가장 흔한 사고가 이것이다. 라이다나 카메라 드라이버는 대개 SensorDataQoS, 즉 BEST_EFFORT 로 발행한다. 그런데 직접 짠 노드에서 기본값(RELIABLE)으로 구독하면 토픽 목록에는 보이는데 콜백이 한 번도 안 불린다. ros2 topic echo 로는 잘 보여서 더 헷갈리는데, echo 가 자동으로 QoS 를 맞춰 주기 때문이다.

    확인 방법은 간단하다.

    $ ros2 topic info -v /scan
    
    Publishers: 1
      ...
      QoS profile:
        Reliability: BEST_EFFORT     ← 여기
        Durability: VOLATILE
    
    Subscribers: 1
      ...
      QoS profile:
        Reliability: RELIABLE        ← 여기. 짝이 안 맞는다
        Durability: VOLATILE

    <그림 3> QoS 불일치 확인

    CAN 으로 치면 「배선도 다 맞고 종단 저항도 맞는데, 통신 속도만 서로 다르게 잡아 놓은 상태」에 가깝다. 물리적으로는 다 연결되어 있는데 한 마디도 오가지 않는다.

    8. 도구 대응표와 실제 연결

    쓰던 도구를 기준으로 짝지으면 적응이 빠르다.

    CAN (SocketCAN) ROS2
    candump can0 ros2 topic echo /토픽
    cansniffer can0 ros2 topic hz /토픽
    candump -l ros2 bag record
    canplayer ros2 bag play
    cansend can0 123#DEADBEEF ros2 topic pub /토픽 …
    DBC 파일 .msg 파일
    버스 부하율 측정 ros2 topic bw /토픽
    노드 목록 (설계도) ros2 node list

    <표 4> 도구 대응

    그리고 둘을 실제로 이어 붙일 수도 있다. 리눅스의 SocketCAN 위에서 동작하는 다리 역할 패키지들이 있다.

    • 원시 프레임 수준 — CAN 프레임을 그대로 can_msgs/msg/Frame 토픽으로 올리고 내린다. 해석은 직접 해야 하지만 무엇이든 붙일 수 있다.
    • CANopen 수준 — EDS 파일을 읽어 오브젝트 딕셔너리를 파악하고, PDO 를 토픽으로 SDO 를 서비스로 자동 노출해 준다. 표 2 의 대응을 코드로 구현해 둔 셈이다.

    기존에 CANopen 서보 드라이브를 쓰던 장비를 ROS2 로 올릴 때는 후자가 압도적으로 편하다. 다만 3 절에서 말한 실시간성은 다리를 건너는 순간 사라진다. CAN 버스 안에서 보장되던 우선순위는 이더넷과 리눅스 스케줄러를 지나며 흩어진다. 안전 관련 정지 회로를 ROS2 토픽으로 옮기는 것은 거의 언제나 나쁜 생각이다. 그런 신호는 CAN 이나 하드와이어로 남겨 두고, ROS2 에는 상태 보고만 올리는 구성이 안전하다.

    맺으며

    정리하면 이렇다.

    • ROS2 토픽과 CAN 은 데이터 중심 주소지정이라는 같은 뿌리를 공유한다. 그 직관은 믿어도 된다.
    • 다만 CAN 의 식별자는 우선순위까지 겸하고 ROS2 의 토픽 이름은 그렇지 않다. 실시간성이 필요하면 다른 층에서 따로 풀어야 한다.
    • 대응을 정확히 보려면 CANopen 과 견주는 편이 낫다. PDO ↔ 토픽, SDO ↔ 서비스, NMT ↔ 라이프사이클이다.
    • 틀렸을 때 CAN 은 조용히 틀린 값을, ROS2 는 연결 실패를 준다. 후자의 원인은 형식 아니면 QoS 다.

    CAN 을 오래 만진 사람이 ROS2 를 배울 때 가장 큰 자산은 문법이 아니라 「버스에 데이터를 흘린다」는 사고방식이다. 그건 이미 몸에 있다. 새로 익힐 것은 형식 계약과 QoS, 그리고 잃어버린 우선순위를 어디서 되찾을 것인가 하는 문제뿐이다.

    다뤄 주었으면 하는 주제나 이상한 점이 있으면 댓글로 남겨 주기 바란다.

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

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

  • 무인지게차 연재-1

    삼성전자 P4 현장에 무인지게차(AFL, Automated Fork Lift) 세 대를 넣고 2년을 붙들었다. 기록할 것이 많아 다섯 편으로 나눈다. 1편은 2024년 2월, 개발자 세 사람이 한꺼번에 사표를 낸 시점부터 그해 7월 내가 지게차와 함께 현장에 들어가기까지의 이야기다.

    12024년 2월, 세 사람이 한꺼번에 나갔다

    이 프로젝트를 만든 개발자 세 명이 같은 시기에 퇴사를 신청했다. 삼성전자 P4 현장에 지게차 세 대를 반입하기 여섯 달 전이었다.

    일정은 이미 박혀 있었다. 72시간 연속 테스트를 통과한 뒤 7월에 현장 반입. 그 일정을 그대로 둔 채 만든 사람들이 빠졌다. 인수인계 기간으로 잡힌 것은 약 한 달, 후임은 두 명이었다. 나와, 원래 지게차팀에 있던 사원 한 명.

    다른 사람이 개발한 코드를 분석하기란 결코 쉬운 일이 아니다. 그것을 한 달 안에 끝내라는 일정이었다. 설계서는 없었다. 받은 것은 IAR 워크스페이스 하나와, 현장에서 값을 만지며 남긴 메모장 파일 하나였다.

    2두 달, 읽기만 했다

    2월부터 3월까지 약 두 달을 인수인계와 공부에 썼다. 타깃은 STM32F407VET6, Cortex-M4 단일 칩이다. 컴파일러는 IAR EWARM, RTOS는 없다. 파일 헤더마다 2017년부터 2020년까지의 날짜가 박혀 있었다. UserDriver 폴더에만 모듈이 서른 개 남짓, 소스와 헤더를 합쳐 2만 5천 줄가량이었다.

    덩치 큰 파일부터 열었다. 주행 제어를 담은 LGV_Control.c가 4,248줄, 상태 관리인 LGV_State.c가 2,276줄, 반사판 항법을 처리하는 NAV350.c가 1,130줄. 한 파일이 4천 줄이라는 것은 함수 하나가 수백 줄이라는 뜻이고, 그 안에 지난 몇 년의 현장 대응이 층층이 쌓여 있다는 뜻이다.

    반대로 main.c는 290줄뿐이었다. 메인 루프가 하는 일은 워치독을 물고 현재 좌표를 백업 레지스터에 쓰는 것이 전부다.

    낯선 구조는 아니었다. 로보스타에 있을 때 DSP의 RTOS 구조에서 메인 루프 안에 코드가 한 줄도 없는 프로그램을 다뤄본 적이 있다. 메인이 비어 있다는 것은 실제 일이 다른 문맥에서 돈다는 뜻이다. 그래서 바로 인터럽트 쪽을 찾아갔다.

    while(1)
    {
        IWDG_ReloadCounter();
    
        pos = POS_GetEstPosition();
        RTC_WriteBackupRegister(RTC_BKP_DR2, (s32)pos.x);
        RTC_WriteBackupRegister(RTC_BKP_DR3, (s32)pos.y);
        RTC_WriteBackupRegister(RTC_BKP_DR4, (s32)(pos.phi*RAD_TO_DEG10));
    }

    main.c — 메인 루프는 위치를 백업할 뿐, 제어는 하지 않는다

    이 백업 레지스터의 뜻은 나중에 알았다. 전원이 나가도 마지막 좌표와 레이어를 기억해 두었다가 재부팅하면 그 자리에서 다시 시작하기 위한 장치다. 현장에서 지게차 전원을 내렸다 올려도 위치를 잃지 않는 이유가 이 네 줄에 있었다.

    3진짜 심장은 SysTick 안에 있었다

    1ms 주기 SysTick_Handler 안에 시분할 스케줄러가 통째로 들어 있었다. RTOS 없이 나머지 연산 하나로 태스크를 배분하는 구조다.

    if((SysTickCount%100)==1) EXTI_GenerateSWInterrupt(SW_IRQ_STATE_UPDATE);
    if((SysTickCount%25) ==2) NAV_TransCheck();
    if((SysTickCount%50) ==4) Gyro_SendCmdCheck();
    if((SysTickCount%50) ==6) EXTI_GenerateSWInterrupt(SW_IRQ_POSITION);
    if((SysTickCount%50) ==8) EXTI_GenerateSWInterrupt(SW_IRQ_AGV_CONTROL);
    if((SysTickCount%50)==10) State_CanSendNavi();

    나머지 값 1, 2, 4, 6, 8, 10 — 같은 주기 안에서 시점을 어긋내 충돌을 피한다

    주기가 같아도 나머지 값을 하나씩 어긋나게 두어 한 틱에 두 가지 일이 겹치지 않게 만들어 놓았다. 뒤에 기능을 추가할 때마다 나는 이 빈 나머지 값을 찾아 끼워 넣어야 했다.

    더 흥미로운 것은 EXTI_GenerateSWInterrupt였다. 소프트웨어 인터럽트를 일부러 발생시켜 우선순위가 다른 컨텍스트에서 무거운 연산을 돌린다. EXTI 라인 5번부터 9번까지가 각각 상태 갱신, 항법 수신 처리, 위치 추정, 주행 제어, 명령 해석을 맡고 있었다. RTOS의 태스크를 인터럽트 우선순위로 흉내 낸 셈이다.

    ★ 이런 구조에서는 타이밍 설계가 코드가 아니라 나머지 값 안에 숨는다. 왜 25인지, 왜 나머지가 6인지는 소스 어디에도 적혀 있지 않다. 문서가 없으면 만든 사람의 머릿속을 역산하는 수밖에 없다.

    4하드웨어는 CAN 버스에 매달려 있었다

    소프트웨어 구조가 잡히자 배선을 따라갔다. 이 모션컨트롤러 보드는 혼자 일하지 않는다. CAN 버스에 여러 보드가 물려 있고 각자 고유한 ID로 대화한다.

    차량 상태를 올려 보내는 VIU 보드가 0x220, 포크의 리프트·틸트 센서를 담당하는 FIU 보드가 0x241, 조향을 맡은 EPS 보드가 별도 ID를 쓴다. 안전 스캐너와 디지털 입출력은 CREVIS I/O 링크가 CANopen 방식으로 0x181, 0x281, 0x601, 0x701을 오간다. 컨트롤러가 상위로 올려 보내는 상태 패킷은 0x260부터 0x271까지 열두 개였다.

    위치 인식은 SICK NAV350 레이저 스캐너가 기둥에 붙은 반사판을 읽어 삼각측량하는 방식이다. 여기에 자이로와 휠 엔코더를 섞어 좌표를 추정한다. 별도 NX 보드를 붙여 SLAM으로 대체하는 경로도 Position.c 안에 준비되어 있었지만, 현장에서 쓰는 것은 반사판 방식이었다.

    5상태 기계와 알람 16종

    지게차의 동작은 상태 기계로 표현된다. 수동 모드가 0번대, 자동 모드가 10번대다. 정지(10), 준비(11), 정상 주행(12), 감속 정지(13), 일시정지(14), 이동 완료(15), 알람 정지(16), 안전 감속(17), 포킹(18), 충전(19), 그리고 비상정지(20).

    장애가 나면 알람 번호가 뜬다. 정의된 알람은 16가지. 비상정지, 범퍼 접촉, 모션 에러, 맵 에러, 경로 이탈, 헤딩 초과, 항법 에러, 적재 실패⋯. 뒤에 현장에서 내가 가장 많이 보게 될 것이 9번 ALARM_NAV_ERROR와 8번 ALARM_LOCAL_TARGET_OVER다. 이때는 그저 번호였다.

    헤더를 읽다가 11번, 12번, 13번 옆에 “정의만 되어 있음”이라고 적힌 주석을 봤다. 선언만 하고 구현하지 않은 것들이다. 이런 흔적이 코드 곳곳에 흩어져 있었다.

    6호기마다 다른 빌드, 그리고 남겨진 메모

    가장 당황스러웠던 것은 상수 헤더였다. 차량 고유 값이 전부 #ifdef로 갈려 있었다.

    //#define SOOSUNG_NO1
    //#define SOOSUNG_NO2
    #define DOOSAN_NO1
    //#define DOOSAN_CB
    
    #ifdef DOOSAN_NO1
    #define STEER_WHEEL_LENGTH          (1525)
    #define WHEEL_SPEED_SCALE_FACTOR    (1.52)
    #define NAV_CENTER_POS_OFFSET_X     (1172)
    #define NAV_CENTER_POS_OFFSET_THETA (6)   // 2024.08.21 = -6
    #endif

    LGV_Constant.h — 호기를 바꾸려면 주석을 옮기고 다시 빌드해야 한다

    1호기용 펌웨어와 2호기용 펌웨어가 다른 바이너리라는 뜻이다. 현장에서 급하게 올리다가 호기를 착각하면 지게차가 엉뚱한 곳으로 간다. 이 구조를 파라미터 방식으로 바꾸는 일은 한참 뒤에야 손댈 수 있었다.

    프로젝트 폴더에는 메모장 파일도 하나 있었다. 만든 사람이 현장에서 값을 만지며 남긴 기록이다.

    *수정한 파라미터
    1. gDiffAng : 40 → 45로 변경
    2. AFL_GetEpsCurAng : 150 → 50 변경
    3. NAV_CENTER_POS_OFFSET_THETA : -6 → +6으로 변경
    4. steer = 850 // 변경 시도했으나 결과가 더 안 좋아 850 유지

    인수인계로 받은 유일한 문서 — 설계서가 아니라 현장 기록이었다

    “결과가 더 안 좋아 유지”라는 문장을 한참 들여다봤다. 이 값들이 이론이 아니라 수십 번의 시도에서 나왔다는 뜻이다. 근거는 없고 결과만 있는 숫자. 함부로 건드릴 수 없는 숫자다. 그리고 앞으로 내가 만들 숫자도 결국 같은 종류일 것이라는 예감이 들었다.

    7그리고 나는 프로젝트에서 빠졌다

    두 달을 읽고 나니 지도가 대강 그려졌다. 그 무렵 나는 이 프로젝트에서 제외되었다.

    기술적인 이유는 아니었다. 조직 안의 문제였고, 여기에 길게 적을 일은 아니라고 본다. 다만 결과만 적자면, 두 달간 만든 지도를 덮고 다른 일로 갔다. 남은 후임은 한 명이 되었다.

    87월, 대타로 다시 불려 갔다

    2024년 7월, 반입을 코앞에 두고 남아 있던 그 사원마저 퇴사를 신청했다. 만든 사람도, 넘겨받은 사람도 모두 빠진 자리에 내가 대타로 들어갔다.

    2024.02개발자 3명 퇴사 신청 (반입 6개월 전). 인수인계 1개월, 후임 2명
    2024.02~03인수인계와 코드 분석 약 2개월
    2024 봄프로젝트에서 제외됨
    2024.07남은 후임 1명도 퇴사 신청 → 대타 투입, 지게차 3대와 함께 P4 반입
    2024 연말코드를 어느 정도 파악. 안정화 시작

    1편이 다루는 구간 — 2024년 2월부터 7월까지

    7월에 지게차 세 대와 내가 삼성전자 현장에 들어갔다. 문제는 엄청나게 많았다. 그리고 나는 소스코드를 100퍼센트 파악한 상태가 아니었다. 두 달 읽고 넉 달 손을 뗀 코드였다.

    현장에서는 원인을 묻는다. 언제 고쳐지느냐고 묻는다. 나는 그 질문에 답할 수 있는 상태가 아니었다. 아는 것과 모르는 것의 경계가 어디인지조차 흐릿한 채로, 매일 알람 번호를 들여다봤다. 그때 받은 압박과 스트레스는 지금도 또렷하다.

    돌아보면 안정화가 시작된 것은 2024년 연말부터다. 코드가 좋아져서가 아니라, 내가 코드를 어느 정도 알게 된 시점이 거기였기 때문이다. 결국 이 프로젝트에서 가장 오래 걸린 일은 고치는 일이 아니라 읽는 일이었다.

    다음 편에서는 2024년 7월의 현장을 적는다. 반사판을 놓쳐 좌표를 잃는 문제, 랙 앞에서 포크가 빗나가는 문제, 그리고 로그 한 줄 없이 원인을 찾아야 했던 시간에 대해.

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