RTU와 ASCII 통신
이 튜토리얼은 실제 케이블 대신 PDComm 가상 시리얼 포트 한 쌍을 씁니다. 두 포트는 널 모뎀(null modem) 케이블로 이은 것처럼 동작합니다. 한쪽에는 헤드리스 시뮬레이터를 RTU 서버로, 다른 쪽에는 C++ 예제 콘솔을 RTU 클라이언트로 붙입니다. 마지막 절은 같은 과정을 실제 PLC와 USB-RS232 어댑터로 하는 방법을 설명합니다.
터미널 A 터미널 V 터미널 B헤드리스 RTU 서버 ── modbus-a ══ pdcomm-vserial pair ══ modbus-b ── C++ RTU 클라이언트- 직렬 구성(
transport,port,baud,parity,stop_bits)으로 RTU 서버와 클라이언트를 연결합니다. - 직렬 전용 기능, 브로드캐스트, 응답 없는 주소를 확인합니다.
trace로 RTU와 ASCII의 실제 바이트를 봅니다.- 문자 하나를 손상시켜 CRC 오류가 서버 진단 카운터와 클라이언트 시간 초과로 나타나는 것을 확인합니다.
준비물
섹션 제목: “준비물”- PDComm Modbus SDK와 빠른 시작에서 빌드한 C++ 콘솔
- PDComm 가상 시리얼 도구
pdcomm-vserial. 설치와 다른 구성 방법은 도구에서 안내합니다. - 터미널 세 개. 세 터미널 모두 같은 작업 폴더에서 시작합니다.
1. 가상 포트 쌍을 만듭니다
섹션 제목: “1. 가상 포트 쌍을 만듭니다”터미널 V에서 실행합니다.
pdcomm-vserial pair --link-a "$PWD/modbus-a" --link-b "$PWD/modbus-b"{"ports":[{"backend":"pty","link":".../modbus-a","node":"a","path":"/dev/ttys011"},{"backend":"pty","link":".../modbus-b","node":"b","path":"/dev/ttys016"}],"ready":true}ready가 true이면 작업 폴더에 modbus-a, modbus-b 링크가 생깁니다. 링크는 운영체제가 만든 의사 터미널(pseudo terminal, path)을 가리킵니다. pair는 표준 입력을 명령 채널로 쓰므로 터미널 V를 닫지 않습니다. 입력을 닫으면 가상 포트도 사라집니다.
2. RTU 서버를 엽니다
섹션 제목: “2. RTU 서버를 엽니다”터미널 A에서 헤드리스 시뮬레이터를 시작하고 modbus-a에 RTU 서버를 엽니다. port의 경로는 작업 폴더의 절대 경로로 바꿉니다.
"$MODBUS_SDK/bin/pdcomm-modbus-headless"{"action":"configure","config":{"transport":"rtu","role":"server","address":"127.0.0.1:0","port":"/absolute/path/modbus-a","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"connect"}{"action":"data_set","table":"holding_registers","address":0,"values":[1200,1185,230,50]}connect 결과가 "connected":true이면 서버가 포트를 열고 요청을 기다립니다. 직렬 구성에서도 address는 형식상 필요하지만 쓰이지 않습니다.
3. RTU 클라이언트로 요청합니다
섹션 제목: “3. RTU 클라이언트로 요청합니다”터미널 B에서 C++ 콘솔을 시작하고, 운영체제가 아는 직렬 포트를 먼저 확인합니다.
./build/modbus-tutorial/pdcomm-modbus-tutorial{"action":"serial_ports"}serial_ports는 USB 어댑터 같은 실제 장치 목록(예: macOS의 /dev/cu.usbserial-..., Windows의 COM3)을 돌려줍니다. 가상 포트 쌍의 링크는 목록에 나오지 않으므로 경로를 직접 지정합니다.
{"action":"configure","config":{"transport":"rtu","role":"client","address":"127.0.0.1:0","port":"/absolute/path/modbus-b","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"connect"}{"action":"request","pdu":"0300000004"}{"action":"request","pdu":"0600010064"}{"action":"request","pdu":"0800001234"}{"action":"request","pdu":"11"}{"ok":true,"result":{"response":[3,8,4,176,4,161,0,230,0,50],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[6,0,1,0,100],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[8,0,0,18,52],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[17,2,1,255],"source":"wire","status":"success"}}- 서버가 넣은 1200, 1185, 230, 50을 읽었습니다(
0x04B0,0x04A1,0x00E6,0x0032). - 08(Diagnostics)과 17(Report Server ID)은 직렬 전용 기능이므로 TCP에서는 쓸 수 없습니다.
4. 실제 바이트를 봅니다
섹션 제목: “4. 실제 바이트를 봅니다”{"action":"trace_clear"}{"action":"request","pdu":"0300000001"}{"action":"trace"}{"ok":true,"result":{"entries":[ {"bytes":[1,3,0,0,0,1,132,10],"direction":"tx",...,"timestamp_scope":"host_io_batch","transport":"rtu",...}, {"bytes":[1,3,2,4,176,187,48],"direction":"rx",...,"timestamp_scope":"host_io_batch","transport":"rtu",...}, {"request":[3,0,0,0,1],"response":[3,2,4,176],"sequence":15,"source":"wire","status":"success","unit":1}]}}보기 쉽게 줄을 나눴습니다. 실제 출력은 한 줄입니다.
tx는 보낸 RTU 프레임입니다. 주소01, PDU03 0000 0001, CRC84 0A(132, 10)입니다.rx는 받은 프레임입니다. 주소01, PDU03 02 04B0, CRCBB 30입니다.timestamp_scope가host_io_batch인 시각은 PC가 읽기를 마친 시각입니다. UART의 비트 시각이 아니므로 t1.5, t3.5 측정에 쓰지 않습니다.
5. 브로드캐스트와 없는 주소를 확인합니다
섹션 제목: “5. 브로드캐스트와 없는 주소를 확인합니다”{"action":"request","unit":0,"pdu":"0600020007"}{"action":"request","pdu":"0300020001"}{"action":"request","unit":0,"pdu":"0300000001"}{"action":"request","unit":2,"pdu":"0300000001"}| 요청 | 결과 | 설명 |
|---|---|---|
| 유닛 0, 레지스터 2에 7 쓰기 | {"response":null,"source":"wire","status":"no_response"} |
브로드캐스트는 응답이 없습니다. 클라이언트는 turnaround_ms(기본 100ms)를 기다린 뒤 끝냅니다. |
| 레지스터 2 읽기 | [3,2,0,7] |
브로드캐스트가 적용됐습니다. |
| 유닛 0으로 읽기 | Command error: {"error":"InvalidConfig","ok":false} |
브로드캐스트는 쓰기(05, 06, 15, 16, 21, 22)만 보낼 수 있습니다. |
| 유닛 2로 읽기 | 약 1초 뒤 Command error: {"error":"Timeout","ok":false} |
주소 2인 서버가 없어 아무도 응답하지 않습니다. |
6. 문자 하나를 손상시킵니다
섹션 제목: “6. 문자 하나를 손상시킵니다”터미널 V에 다음 명령을 입력합니다. 노드 a(서버 쪽)가 받는 다음 문자의 최하위 비트를 뒤집습니다.
{"command":"inject","node":"a","fault":{"xor":1}}{"ok":true}가 나오면 터미널 B에서 요청을 보냅니다.
{"action":"request","pdu":"0300000001"}클라이언트는 Timeout으로 실패합니다. 서버가 CRC가 맞지 않는 요청을 버리고 응답하지 않았기 때문입니다. 터미널 A에서 진단 카운터를 확인합니다.
{"action":"diagnostics"}{"ok":true,"result":{"counters":{"bus_messages":8,"busy":0,"communication_errors":1,...},...}}communication_errors가 1입니다. 같은 요청을 다시 보내면 정상 응답이 옵니다. 실제 회선에서 잡음이나 패리티 불일치로 생기는 CRC 오류도 이렇게 클라이언트에는 시간 초과로, 서버에는 통신 오류 카운터로 나타납니다.
7. ASCII로 바꿉니다
섹션 제목: “7. ASCII로 바꿉니다”양쪽 모두 연결을 끊고 transport만 ascii로 바꿔 다시 연결합니다. 터미널 A(서버)부터 합니다.
{"action":"disconnect"}{"action":"configure","config":{"transport":"ascii","role":"server","address":"127.0.0.1:0","port":"/absolute/path/modbus-a","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"connect"}터미널 B(클라이언트)도 같게 합니다.
{"action":"disconnect"}{"action":"configure","config":{"transport":"ascii","role":"client","address":"127.0.0.1:0","port":"/absolute/path/modbus-b","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"connect"}{"action":"trace_clear"}{"action":"request","pdu":"0300000002"}{"action":"trace"}{"ok":true,"result":{"response":[3,4,4,176,0,100],"source":"wire","status":"success"}}trace의 tx 바이트 [58,48,49,48,51,...,70,65,13,10]는 문자 :010300000002FA + CR LF입니다. 응답은 rx 두 항목으로 나뉘어 들어올 수 있습니다. 운영체제가 받은 문자를 묶어 넘기는 단위이며, PDComm은 :부터 LF까지를 한 프레임으로 조립합니다. ASCII 모드에서 데이터 비트는 자동으로 7입니다.
8. 정리합니다
섹션 제목: “8. 정리합니다”- 터미널 B:
{"action":"disconnect"}뒤quit. 실패한 명령이 있었으므로 종료 코드는 1입니다. - 터미널 A:
{"action":"shutdown"}. - 터미널 V:
{"command":"stop"}. 가상 포트와 링크가 사라집니다.
포트를 다른 프로그램이 이미 열고 있으면 connect가 실패합니다. 같은 포트를 서버와 클라이언트가 동시에 열지 않습니다.
자주 겪는 오류
섹션 제목: “자주 겪는 오류”| 출력 | 원인과 해결 |
|---|---|
Invalid endpoint configuration |
parity가 none인데 stop_bits가 1입니다. 2로 바꾸거나 패리티를 씁니다. 유닛이 1~247 밖이어도 같은 오류입니다. |
connect가 Io(Other, "No such file or directory") |
port 경로가 없습니다. 가상 포트라면 터미널 V가 살아 있는지, 경로가 절대 경로인지 확인합니다. |
connect가 InvalidConfig |
port가 비어 있습니다. |
모든 요청이 Timeout |
양쪽의 transport, baud, parity, stop_bits, 유닛 주소가 같은지 확인합니다. 서버가 connect된 상태인지 봅니다. |
자세한 원인은 직렬 통신 문제에 있습니다.
실제 장비와 연결하기
섹션 제목: “실제 장비와 연결하기”가상 포트 대신 PLC나 계측기를 USB-RS232/RS-485 어댑터로 연결할 때는 다음 순서를 따릅니다.
- 장비 설정을 확인합니다. 장비 매뉴얼에서 전송 모드(RTU 또는 ASCII), 속도, 패리티, 정지 비트, 국번(unit)을 확인합니다. 장비가 8N1이면 8E1(권장) 또는 8N2로 바꾸고, 장비에 설정을 쓴 뒤 필요하면 통신 모듈을 재시작합니다.
- 배선합니다. RS-232는 PC와 장비의 송신·수신이 경로 전체에서 정확히 한 번 엇갈려야 합니다(9핀 DTE 둘이면 2↔3, 3↔2, 5↔5 널 모뎀). RS-485는 D0/D1 극성, 종단, 공통선을 장비 지침대로 연결합니다. RS-232 어댑터로 RS-485 포트에 직접 연결할 수 없습니다.
- 포트 이름을 찾습니다.
serial_ports의 목록에서 어댑터를 꽂은 뒤 새로 생긴 이름을 고릅니다. macOS는/dev/cu.usbserial-...처럼cu.로 시작하는 이름을 씁니다. - 읽기부터 시험합니다. 첫 요청은 장비 매뉴얼의 읽기 전용 주소에 기능 03이나 04로 보냅니다. 쓰기는 매뉴얼에서 안전하다고 확인한 주소에만 하고, 시험 뒤 원래 값으로 되돌립니다.
- 응답이 없으면 속도·패리티·국번·전송 모드를 다시 확인하고 직렬 통신 문제를 따릅니다.
USB 어댑터 지연, macOS FTDI 드라이버의 패리티 처리 같은 세부 사항은 가이드: 직렬 포트 구성에 있습니다.