콘텐츠로 이동
무료 평가 시작

가상 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 <예제 폴더>/vserial

랩 파일은 노드 세 개를 19200bps 8E1로 설정하고 2선식 버스에 연결합니다. 각 노드는 고정 링크가 있는 의사 터미널로 엽니다. 회선 사건은 rs485-trace.jsonl에 기록합니다.

rs485-bus.json
{
"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. 터미널 1에서 랩을 시작합니다.

    터미널 창
    pdcomm-vserial run --config rs485-bus.json
  2. 준비가 끝나면 포트 목록이 한 줄로 나옵니다. 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}

헤드리스 시뮬레이터(pdcomm-modbus-headless)는 표준 입력의 JSON 동작(action)을 한 줄씩 처리합니다. 서버 파일은 RTU 서버를 구성하고, 보유 레지스터(holding register) 0에 값을 넣고, 포트를 엽니다.

modbus-rtu/drive1.jsonl
{"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"}
  1. 터미널 2에서 unit 1 서버를 시작합니다. cat 파일 -은 파일을 보낸 뒤 키보드 입력을 계속 넘깁니다. 서버는 입력이 끝날 때까지 실행합니다.

    터미널 창
    cat modbus-rtu/drive1.jsonl - | "$MODBUS_SDK/bin/pdcomm-modbus-headless"

    세 줄의 응답이 나오고 마지막 응답에 "connected":true가 있습니다.

  2. 터미널 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"
modbus-rtu/master.jsonl
{"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"}
  1. 터미널 4에서 클라이언트를 시작합니다.

    터미널 창
    cat modbus-rtu/master.jsonl - | "$MODBUS_SDK/bin/pdcomm-modbus-headless"

    두 번째 응답에 "connected":true가 있으면 준비가 끝났습니다.

  2. 터미널 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"}}
  3. 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"}}
  4. 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"}}
  1. 터미널 1(랩)에 입력합니다. 마스터가 다음에 받는 문자 하나에 패리티 오류를 표시합니다.

    {"command":"inject","node":"master","fault":{"error":"parity"}}
  2. 터미널 4에서 unit 1을 다시 읽습니다.

    {"action":"request","unit":1,"pdu":"0300000001"}

    응답 프레임의 첫 문자가 손상되어 클라이언트는 응답을 받아들이지 않고 1초 뒤 시간 초과로 끝납니다.

    {"error":"Timeout","ok":false}
  3. 같은 요청을 한 번 더 보내면 정상 응답이 돌아옵니다. 결함은 한 문자에만 적용되었습니다.

  1. 터미널 1에서 drive2의 케이블을 분리합니다.

    {"command":"attach","node":"drive2","attached":false}
  2. 터미널 4에서 unit 2를 읽으면 {"error":"Timeout","ok":false}로 끝납니다. 분리한 장치는 요청을 받지 못합니다.

  3. 케이블을 다시 연결하고("attached":true) 같은 요청을 보내면 [3,2,7,8]이 돌아옵니다.

랩을 시작한 폴더의 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" 줄로 남습니다.

  1. 터미널 4, 3, 2에서 Ctrl+D를 눌러 입력을 끝냅니다. 각 헤드리스 시뮬레이터가 포트를 닫고 종료합니다. 실패한 동작이 있었던 클라이언트는 종료 코드 1로 끝납니다.
  2. 터미널 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 중 하나를 씁니다.