가상 RS-485 버스에서 Modbus RTU 시험하기
이 튜토리얼에서는 실제 RS-485 배선 없이 Modbus RTU 버스를 만듭니다. 인버터 두 대(drive1, drive2)를 흉내 내는 서버와 마스터 클라이언트 하나를 가상 2선식 버스에 연결합니다. 그다음 읽기, 쓰기, 브로드캐스트를 실행하고 패리티 오류와 케이블 분리가 통신에 주는 영향을 확인합니다.
- 가상 RS-485 버스에 Modbus RTU 장치 세 대를 연결합니다.
- 브로드캐스트(unit 0) 쓰기가 모든 서버에 적용되는지 확인합니다.
- 결함 주입과 케이블 분리로 클라이언트의 시간 초과(timeout)를 재현하고 추적 기록에서 원인을 찾습니다.
준비물
섹션 제목: “준비물”- macOS 또는 Linux와 터미널 네 개
PATH에 있는pdcomm-vserial(설치)- Modbus SDK의
bin/pdcomm-modbus-headless. 아래 명령은 SDK 폴더를 환경 변수MODBUS_SDK로 가리킵니다. - 예제 폴더의
vserial/. 모든 명령은 이 폴더에서 실행합니다.
export MODBUS_SDK="$HOME/PDComm/pdcomm-modbus" # 실제 SDK 폴더로 바꿉니다cd <예제 폴더>/vserial1단계: 버스 시작하기
섹션 제목: “1단계: 버스 시작하기”랩 파일은 노드 세 개를 19200bps 8E1로 설정하고 2선식 버스에 연결합니다. 각 노드는 고정 링크가 있는 의사 터미널로 엽니다. 회선 사건은 rs485-trace.jsonl에 기록합니다.
{ "engine": { "pacing": "realistic", "nodes": [ {"name": "master", "port": {"line": {"baud": 19200, "data_bits": 8, "parity": "even", "stop_bits": "1"}}}, {"name": "drive1", "port": {"line": {"baud": 19200, "data_bits": 8, "parity": "even", "stop_bits": "1"}}}, {"name": "drive2", "port": {"line": {"baud": 19200, "data_bits": 8, "parity": "even", "stop_bits": "1"}}} ], "media": [{"kind": "two_wire", "nodes": ["master", "drive1", "drive2"]}] }, "ports": [ {"node": "master", "backend": {"type": "pty", "link": "/tmp/pdcomm-master"}}, {"node": "drive1", "backend": {"type": "pty", "link": "/tmp/pdcomm-drive1"}}, {"node": "drive2", "backend": {"type": "pty", "link": "/tmp/pdcomm-drive2"}} ], "trace": "rs485-trace.jsonl"}-
터미널 1에서 랩을 시작합니다.
터미널 창 pdcomm-vserial run --config rs485-bus.json -
준비가 끝나면 포트 목록이 한 줄로 나옵니다.
path는 실행할 때마다 다릅니다.{"ports":[{"backend":"pty","link":"/tmp/pdcomm-master","node":"master","path":"/dev/ttys011"},{"backend":"pty","link":"/tmp/pdcomm-drive1","node":"drive1","path":"/dev/ttys016"},{"backend":"pty","link":"/tmp/pdcomm-drive2","node":"drive2","path":"/dev/ttys017"}],"ready":true}
2단계: 서버 두 대 시작하기
섹션 제목: “2단계: 서버 두 대 시작하기”헤드리스 시뮬레이터(pdcomm-modbus-headless)는 표준 입력의 JSON 동작(action)을 한 줄씩 처리합니다. 서버 파일은 RTU 서버를 구성하고, 보유 레지스터(holding register) 0에 값을 넣고, 포트를 엽니다.
{"action":"configure","config":{"transport":"rtu","role":"server","address":"","port":"/tmp/pdcomm-drive1","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"data_set","table":"holding_registers","address":0,"values":[1500]}{"action":"connect"}-
터미널 2에서 unit 1 서버를 시작합니다.
cat 파일 -은 파일을 보낸 뒤 키보드 입력을 계속 넘깁니다. 서버는 입력이 끝날 때까지 실행합니다.터미널 창 cat modbus-rtu/drive1.jsonl - | "$MODBUS_SDK/bin/pdcomm-modbus-headless"세 줄의 응답이 나오고 마지막 응답에
"connected":true가 있습니다. -
터미널 3에서 unit 2 서버를 시작합니다. 레지스터 0의 값은 1800입니다.
modbus-rtu/drive2.jsonl {"action":"configure","config":{"transport":"rtu","role":"server","address":"","port":"/tmp/pdcomm-drive2","unit":2,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"data_set","table":"holding_registers","address":0,"values":[1800]}{"action":"connect"}터미널 창 cat modbus-rtu/drive2.jsonl - | "$MODBUS_SDK/bin/pdcomm-modbus-headless"
3단계: 클라이언트로 읽고 쓰기
섹션 제목: “3단계: 클라이언트로 읽고 쓰기”{"action":"configure","config":{"transport":"rtu","role":"client","address":"","port":"/tmp/pdcomm-master","unit":1,"baud":19200,"parity":"even","stop_bits":1,"timeout_ms":1000}}{"action":"connect"}-
터미널 4에서 클라이언트를 시작합니다.
터미널 창 cat modbus-rtu/master.jsonl - | "$MODBUS_SDK/bin/pdcomm-modbus-headless"두 번째 응답에
"connected":true가 있으면 준비가 끝났습니다. -
터미널 4에 다음 줄을 하나씩 입력합니다.
pdu는 Modbus PDU의 16진수 표기이고unit은 장치 주소입니다.{"action":"request","unit":1,"pdu":"0300000001"}{"action":"request","unit":2,"pdu":"0300000001"}FC03(보유 레지스터 읽기)으로 각 서버의 레지스터 0을 읽습니다. 응답은 다음과 같습니다. 1500은
05 DC(5, 220), 1800은07 08(7, 8)입니다.{"ok":true,"result":{"response":[3,2,5,220],"source":"wire","status":"success"}}{"ok":true,"result":{"response":[3,2,7,8],"source":"wire","status":"success"}} -
unit 1의 레지스터 1에 100을 쓰고(FC06), unit 0으로 레지스터 2에 7을 브로드캐스트합니다.
{"action":"request","unit":1,"pdu":"0600010064"}{"action":"request","unit":0,"pdu":"0600020007"}쓰기는 요청을 그대로 돌려받습니다. 브로드캐스트에는 응답이 없으므로
no_response가 정상입니다.{"ok":true,"result":{"response":[6,0,1,0,100],"source":"wire","status":"success"}}{"ok":true,"result":{"response":null,"source":"wire","status":"no_response"}} -
unit 2의 레지스터 0~2를 읽어 브로드캐스트가 적용되었는지 확인합니다.
{"action":"request","unit":2,"pdu":"0300000003"}마지막 레지스터가 7입니다.
{"ok":true,"result":{"response":[3,6,7,8,0,0,0,7],"source":"wire","status":"success"}}
4단계: 패리티 오류 재현하기
섹션 제목: “4단계: 패리티 오류 재현하기”-
터미널 1(랩)에 입력합니다. 마스터가 다음에 받는 문자 하나에 패리티 오류를 표시합니다.
{"command":"inject","node":"master","fault":{"error":"parity"}} -
터미널 4에서 unit 1을 다시 읽습니다.
{"action":"request","unit":1,"pdu":"0300000001"}응답 프레임의 첫 문자가 손상되어 클라이언트는 응답을 받아들이지 않고 1초 뒤 시간 초과로 끝납니다.
{"error":"Timeout","ok":false} -
같은 요청을 한 번 더 보내면 정상 응답이 돌아옵니다. 결함은 한 문자에만 적용되었습니다.
5단계: 케이블 분리하기
섹션 제목: “5단계: 케이블 분리하기”-
터미널 1에서
drive2의 케이블을 분리합니다.{"command":"attach","node":"drive2","attached":false} -
터미널 4에서 unit 2를 읽으면
{"error":"Timeout","ok":false}로 끝납니다. 분리한 장치는 요청을 받지 못합니다. -
케이블을 다시 연결하고(
"attached":true) 같은 요청을 보내면[3,2,7,8]이 돌아옵니다.
6단계: 추적 기록 읽기
섹션 제목: “6단계: 추적 기록 읽기”랩을 시작한 폴더의 rs485-trace.jsonl에서 4단계의 패리티 오류를 찾습니다.
grep '"error":"parity"' rs485-trace.jsonl{"t_ns":842903959,"node":"master","event":"rx","byte":1,"error":"parity"}node가 master이고 byte가 1(unit 1의 주소 문자)입니다. 응답의 첫 문자가 오류로 표시되었음을 확인할 수 있습니다. 케이블 분리는 "event":"attached" 줄로 남습니다.
- 터미널 4, 3, 2에서 Ctrl+D를 눌러 입력을 끝냅니다. 각 헤드리스 시뮬레이터가 포트를 닫고 종료합니다. 실패한 동작이 있었던 클라이언트는 종료 코드 1로 끝납니다.
- 터미널 1에
{"command":"stop"}을 입력합니다. 랩이/tmp/pdcomm-*링크를 지우고 종료합니다.
자주 나는 오류
섹션 제목: “자주 나는 오류”| 증상 | 원인과 해결 |
|---|---|
connect가 Io(Other, "No such file or directory")로 실패 |
랩이 실행 중이 아니거나 링크 경로가 다릅니다. 터미널 1의 포트 목록을 확인합니다. |
configure가 Invalid endpoint configuration으로 실패 |
패리티 없음("none")에 정지 비트 1을 지정했습니다. 짝수 패리티나 정지 비트 2를 씁니다. |
모든 요청이 Timeout |
서버의 unit, 속도, 패리티를 확인합니다. 설정이 다르면 가상 버스도 실제 회선처럼 문자를 깨뜨립니다. |
unknown node 응답 |
랩 명령의 node에 master, drive1, drive2 중 하나를 씁니다. |
다음 단계
섹션 제목: “다음 단계”- Modbus RTU·ASCII와 SECS-I에 가상 포트 쓰기에서 ASCII와 SECS-I 설정을 확인합니다.
- 진단 로그 남기고 읽기에서 같은 요청을 SDK 로그로 추적합니다.