[Python] pySerial로 RS-232 통신하기: COM Port 검색·Hex 송수신·패킷 로그

Python pySerial로 RS-232 통신하기: COM Port 검색·Hex 송수신·패킷 로그#

1장 Python으로 RS-232 장비와 통신하려면#

RS-232 Cable로 장비와 PC를 연결했다고 가정해보겠습니다.

물리적인 연결은 다음처럼 단순해 보입니다.

Python Application
        │
        ▼
Serial Port
        │
        ▼
COM Port
        │
        ▼
RS-232 Cable
        │
        ▼
Device

하지만 프로그램이 장비와 정상적으로 통신하려면 여러 조건이 동시에 맞아야 합니다.

올바른 COM Port

Baud Rate

Data Bits

Parity

Stop Bits

Flow Control

장비 Protocol

Packet 구조

하나라도 다르면 화면에서는 단순히:

응답 없음

또는:

알 수 없는 데이터 수신

으로 보일 수 있습니다.

따라서 Python Serial 프로그램에서는 다음 흐름을 차례대로 확인하는 것이 중요합니다.

COM Port 탐색
↓
Port 설정
↓
Port Open
↓
Raw byte 송신
↓
Raw byte 수신
↓
Receive Buffer
↓
Packet Parser
↓
로그·업무 처리

2장 pySerial은 무엇인가#

Python에서 Serial Port를 제어할 때 널리 사용하는 Library가 pySerial입니다.

기본적으로 다음과 같은 기능을 제공합니다.

Serial Port Open / Close

Baud Rate 설정

Data Bits 설정

Parity 설정

Stop Bits 설정

Read / Write Timeout

Software Flow Control

Hardware Flow Control

byte Read / Write

COM Port 검색

설치:

pip install pyserial

Import:

import serial

Port 검색에는 다음 Module을 사용할 수 있습니다.

import serial.tools.list_ports

장비 통신 프로그램에서는 문자열보다 byte 중심으로 설계하는 것이 중요합니다.

RS-232는 문자 통신만을 의미하지 않으며, 실제 산업 장비에서는 Binary Protocol도 흔하게 사용하기 때문입니다.

3장 COM Port부터 정확하게 찾는다#

Windows에서는 Serial Port가 일반적으로 다음과 같이 나타납니다.

COM1
COM3
COM7
COM12

Linux에서는 다음과 같은 Device 이름을 볼 수 있습니다.

/dev/ttyS0
/dev/ttyUSB0
/dev/ttyACM0

pySerial을 사용하면 현재 인식된 Port를 검색할 수 있습니다.

import serial.tools.list_ports

ports = serial.tools.list_ports.comports()

for port in ports:
    print(
        port.device,
        port.description,
        port.hwid
    )

출력 예:

COM3 USB Serial Port USB VID:PID=0403:6001
COM5 Communications Port
COM7 USB-RS232 Adapter USB VID:PID=067B:2303

여기에서 중요한 것은:

COM7이 존재한다.

와:

COM7이 내가 찾는 장비다.

는 다른 문제라는 것입니다.

USB-RS232 Adapter가 여러 개라면#

COM 번호만 믿으면 장비를 잘못 선택할 수 있습니다.

가능하면 다음 정보도 함께 기록합니다.

Port 이름

Description

VID / PID

Serial Number

USB 위치

연결 장비명

현장용 프로그램이라면 프로그램 시작 시 Port 목록을 보여주고 사용자가 선택하도록 하는 것도 좋은 방법입니다.

4장 Serial 통신 설정을 장비와 맞춘다#

장비 Manual에 다음과 같이 적혀 있다고 하겠습니다.

9600-8-N-1

의미는:

Baud Rate
9600

Data Bits
8

Parity
None

Stop Bits
1

입니다.

pySerial에서는 다음과 같이 설정할 수 있습니다.

import serial

ser = serial.Serial(
    port='COM3',
    baudrate=9600,
    bytesize=serial.EIGHTBITS,
    parity=serial.PARITY_NONE,
    stopbits=serial.STOPBITS_ONE,
    timeout=1,
    write_timeout=1
)

Flow Control이 필요한 경우 추가할 수 있습니다.

ser = serial.Serial(
    port='COM3',
    baudrate=9600,
    bytesize=serial.EIGHTBITS,
    parity=serial.PARITY_NONE,
    stopbits=serial.STOPBITS_ONE,
    timeout=1,
    xonxoff=False,
    rtscts=True
)

여기서 중요한 원칙은:

통신이 안 된다고 임의로 Baud Rate나 Parity를 바꾸는 것이 아니라 장비 Protocol 문서를 먼저 확인하는 것입니다.

Baud Rate가 틀리면#

장비:

9600

프로그램:

19200

이면 다음과 같은 현상이 나타날 수 있습니다.

깨진 byte

이상한 문자

Checksum 오류

완전히 응답 없음

Parity와 Stop Bit도 마찬가지입니다.

5장 Hex 데이터를 송신하고 수신한다#

장비 Protocol에서는 다음과 같은 데이터를 만날 수 있습니다.

02 10 01 A0 00 03

이것은 문자열 "02 10 01 A0 00 03" 자체를 보내라는 뜻이 아닙니다.

실제 byte 값:

0x02
0x10
0x01
0xA0
0x00
0x03

을 보내야 하는 경우가 많습니다.

Python에서는 다음처럼 만들 수 있습니다.

request = bytes.fromhex(
    '02 10 01 A0 00 03'
)

ser.write(request)

수신:

data = ser.read(64)

print(data)

Raw byte를 Hex로 보면 훨씬 이해하기 쉽습니다.

print(data.hex(' '))

예:

02 06 01 00 07 03

대문자로 보고 싶다면:

print(data.hex(' ').upper())

출력:

02 06 01 00 07 03

이 방식은 Binary Protocol 분석에서 매우 유용합니다.

6장 ASCII와 Binary 데이터를 구분한다#

장비가 다음 데이터를 보냈다고 하겠습니다.

48 45 4C 4C 4F

ASCII로 해석하면:

HELLO

입니다.

Python에서는:

data = bytes.fromhex(
    '48 45 4C 4C 4F'
)

print(data.decode('ascii'))

처럼 볼 수 있습니다.

하지만 모든 Serial 데이터가 문자열인 것은 아닙니다.

예:

02 81 FF 00 A7 03

같은 Binary Frame을 무작정:

data.decode('ascii')

로 변환하면 오류가 발생하거나 의미 없는 결과가 나올 수 있습니다.

따라서 장비 통신 로그는 기본적으로:

HEX

를 기준으로 기록하고, 사람이 읽을 수 있는 데이터만 선택적으로 ASCII 등으로 표현하는 방식이 좋습니다.

예:

print("HEX :", data.hex(' ').upper())

print(
    "ASCII:",
    ''.join(
        chr(b)
        if 32 <= b <= 126
        else '.'
        for b in data
    )
)

출력 예:

HEX : 02 48 45 4C 4C 4F 03
ASCII: .HELLO.

이렇게 하면 Binary와 Text를 동시에 확인할 수 있습니다.

7장 read 한 번이 Packet 하나라는 보장은 없다#

Serial 통신 구현에서 가장 중요한 부분 중 하나입니다.

장비가 다음 Packet을 보냈다고 하겠습니다.

02 10 04 41 42 43 44 8A 03

Python에서:

ser.read(64)

를 호출했다고 해서 항상 전체 Packet이 한 번에 반환되는 것은 아닙니다.

첫 번째 Read:

02 10 04

두 번째:

41 42 43

세 번째:

44 8A 03

처럼 나뉠 수도 있습니다.

반대로:

PACKET A
PACKET B
PACKET C

가 한 번에 들어올 수도 있습니다.

따라서:

read()
=
Packet 하나

라고 가정해서는 안 됩니다.

Receive Buffer가 필요한 이유#

기본 구조는 다음과 같습니다.

Serial Port
↓
read()
↓
Receive Buffer
↓
Packet 경계 검색
↓
완전한 Packet 추출
↓
Packet Parser

예:

rx_buffer = bytearray()

while True:
    data = ser.read(64)

    if data:
        rx_buffer.extend(data)

        print(
            "RX:",
            data.hex(' ').upper()
        )

        # 여기에서 Packet 추출

Buffer에 데이터를 누적한 뒤 Protocol 규칙에 맞춰 완전한 Packet만 꺼내야 합니다.

8장 Packet 경계를 어떻게 찾을까#

Packet 구조는 장비 Protocol에 따라 다릅니다.

대표적인 방식은 네 가지입니다.

STX·ETX 방식#

STX
DATA
ETX

예:

02
10 20 30 40
03

이 경우:

0x02

부터:

0x03

까지를 하나의 Frame으로 볼 수 있습니다.

Length 기반 방식#

Header
Length
Data
Checksum

예:

AA 55 04 10 20 30 40 8F

Length를 읽어 필요한 byte 수가 들어올 때까지 기다립니다.

Fixed Length 방식#

모든 Packet이:

16 bytes

처럼 동일한 길이를 가질 수도 있습니다.

Timing 방식#

일정 시간 동안 추가 byte가 들어오지 않으면 하나의 Frame이 끝났다고 판단하는 Protocol도 있습니다.

따라서 Packet Parser는 Serial Port 코드와 분리하는 것이 좋습니다.

SerialTransport
→ Raw byte 송수신

ReceiveBuffer
→ byte 누적

PacketParser
→ Frame 추출

DeviceProtocol
→ Command·Response 해석

9장 Timeout을 이해해야 프로그램이 멈추지 않는다#

pySerial의 timeout 설정은 read() 동작에 직접 영향을 줍니다.

예:

ser = serial.Serial(
    'COM3',
    9600,
    timeout=1
)

이 경우 필요한 byte가 들어오지 않으면 무한정 기다리지 않고 설정된 시간 이후 현재까지 들어온 데이터를 반환할 수 있습니다.

Timeout을 너무 짧게 하면#

정상 응답인데도 Timeout

이 발생할 수 있습니다.

Timeout을 너무 길게 하면#

장애 감지가 늦어진다.

따라서 장비의 정상 Response Time을 알아야 합니다.

예:

정상 Response
평균 40ms

최대 예상
200ms

라면 이를 기준으로 Timeout과 Retry 정책을 설계해야 합니다.

단순히 모든 장비에:

timeout=1

을 적용하는 것이 정답은 아닙니다.

Blocking 방식도 구분한다#

개념적으로:

timeout=None
→ 필요한 데이터가 올 때까지 대기

timeout=0
→ Non-blocking

timeout=지정값
→ 일정 시간까지 대기

처럼 동작 방식이 달라집니다.

프로그램 구조에 맞는 방식을 선택해야 합니다.

10장 Packet Logger를 만들면 장애 분석이 쉬워진다#

장비 통신 프로그램에서는 Raw Packet Log가 매우 중요합니다.

다음 Log만 있다면:

Device error

원인을 찾기 어렵습니다.

반면:

2026-09-24 10:14:31.120
PORT=COM3
CONFIG=9600-8-N-1

TX
02 10 01 A0 00 03

2026-09-24 10:14:31.184

RX
02 06 01 00 07 03

처럼 기록하면 통신 흐름을 재구성할 수 있습니다.

기본 Logger 예제#

from datetime import datetime

def log_packet(direction, data):
    timestamp = datetime.now().isoformat(
        timespec='milliseconds'
    )

    print(
        f"{timestamp} "
        f"{direction} "
        f"{data.hex(' ').upper()}"
    )

사용:

request = bytes.fromhex(
    '02 10 01 A0 00 03'
)

ser.write(request)

log_packet(
    'TX',
    request
)

response = ser.read(64)

if response:
    log_packet(
        'RX',
        response
    )

Log에는 가능하면 다음 정보를 포함합니다.

Timestamp

Port

Serial 설정

TX / RX

Packet Length

Hex Dump

Device ID

Timeout

Retry Count

Exception

이런 정보가 있어야 현장에서:

송신 자체가 안 됐는가?

장비가 응답하지 않았는가?

응답은 왔는데 Parser가 실패했는가?

Checksum 오류인가?

를 구분할 수 있습니다.

11장 예외 처리와 재연결을 준비한다#

Serial 통신은 다양한 이유로 중단될 수 있습니다.

USB Adapter 제거

Cable 단선

Device 전원 OFF

Port 점유

Driver 문제

권한 문제

따라서 Port Open부터 예외 처리가 필요합니다.

import serial

try:
    ser = serial.Serial(
        port='COM3',
        baudrate=9600,
        timeout=1
    )

except serial.SerialException as e:
    print(
        f"Serial open failed: {e}"
    )

재연결은 상태를 구분한다#

다음 두 상태는 다릅니다.

COM Port 열림

과:

실제 Device 정상

Port가 열려도 장비가 응답하지 않을 수 있습니다.

따라서 재연결은 다음처럼 설계하는 것이 좋습니다.

통신 실패
↓
Port Close
↓
대기
↓
Port 다시 검색
↓
Port Open
↓
Serial 설정 적용
↓
Test Request
↓
Response 확인
↓
Device Online

무한히 빠르게 재접속을 반복하는 것보다는 Retry 간격을 둘 수 있습니다.

예:

1초
2초
4초
8초

처럼 점차 간격을 증가시키는 Backoff 전략을 적용할 수도 있습니다.

12장 범용 RS-232 진단 도구로 확장해보자#

Python의 장점은 작은 Test Tool을 빠르게 만들 수 있다는 점입니다.

최소한 다음 기능을 가진 Serial Test Tool을 만들면 현장에서 상당히 유용합니다.

COM Port 검색

Port 선택

Baud Rate 설정

Parity 설정

Stop Bits 설정

Flow Control 설정

Hex 입력

Hex 송신

HEX 수신 표시

ASCII 수신 표시

Timestamp

TX / RX 구분

Log File 저장

구조는 다음과 같이 나눌 수 있습니다.

SerialPortManager
│
├─ Port 검색
├─ Open
├─ Close
└─ Reconnect

SerialTransport
│
├─ Send
└─ Receive

PacketLogger
│
├─ Hex
├─ ASCII
└─ Timestamp

ReceiveBuffer
│
└─ byte 누적

PacketParser
│
├─ STX / ETX
├─ Length
├─ Checksum
└─ CRC

최소 통합 예제#

import serial
import serial.tools.list_ports
from datetime import datetime


def list_ports():
    for port in serial.tools.list_ports.comports():
        print(
            port.device,
            port.description
        )


def to_hex(data):
    return data.hex(' ').upper()


def log_packet(direction, data):
    timestamp = datetime.now().isoformat(
        timespec='milliseconds'
    )

    print(
        f"{timestamp} "
        f"{direction} "
        f"{to_hex(data)}"
    )


def main():
    list_ports()

    ser = serial.Serial(
        port='COM3',
        baudrate=9600,
        bytesize=serial.EIGHTBITS,
        parity=serial.PARITY_NONE,
        stopbits=serial.STOPBITS_ONE,
        timeout=1,
        write_timeout=1
    )

    try:
        request = bytes.fromhex(
            '02 10 01 A0 00 03'
        )

        ser.write(request)

        log_packet(
            'TX',
            request
        )

        response = ser.read(64)

        if response:
            log_packet(
                'RX',
                response
            )
        else:
            print(
                'Response timeout'
            )

    finally:
        ser.close()


if __name__ == '__main__':
    main()

이 프로그램에서 중요한 것은 특정 Device Command가 아닙니다.

전체 흐름입니다.

Port 검색
↓
Port 설정
↓
Open
↓
Hex → byte 변환
↓
TX
↓
RX
↓
Hex Log
↓
Close

이 기본 구조에 Receive Buffer와 Packet Parser를 추가하면 실제 장비 분석 도구로 확장할 수 있습니다.

13장 장애는 물리 계층부터 Protocol까지 순서대로 본다#

Python 프로그램에서 아무 데이터도 받지 못한다고 하겠습니다.

바로 Python Code를 수정하면 안 됩니다.

물리 연결#

먼저 확인합니다.

Device Power

TX / RX

GND

DTE / DCE

Cable

USB-RS232 Adapter

운영체제#

그다음 확인합니다.

COM Port 존재

Driver 정상

Port 권한

다른 프로그램의 Port 점유

Serial 설정#

Baud Rate

Data Bits

Parity

Stop Bits

Flow Control

Raw 데이터#

TX byte가 실제로 맞는가?

RX byte가 들어오는가?

Hex Dump가 정상인가?

Protocol#

마지막으로 봅니다.

STX / ETX

Length

Address

Command

Checksum

CRC

즉:

Python에서 응답이 없다.

는 사실만으로 Python 코드가 원인이라고 판단하면 안 됩니다.

전체 경로는:

Python
↓
Serial Driver
↓
COM Port
↓
USB-RS232 Adapter
↓
Cable
↓
RS-232 Interface
↓
Device
↓
Device Protocol

입니다.

각 경계를 하나씩 확인해야 합니다.

14장 자기 점검과 마무리#

COM Port가 여러 개라면 어떻게 장비를 찾는가#

Port 이름뿐 아니라 Description·VID/PID·USB 위치와 실제 Cable Label을 함께 확인하는 것이 좋습니다.

Hex 문자열을 실제 byte로 보내려면 어떻게 하는가#

bytes.fromhex()를 사용할 수 있습니다.

예:

data = bytes.fromhex(
    '02 10 01 A0 00 03'
)

read 한 번으로 Packet 하나를 처리해도 되는가#

아닙니다. 하나의 Packet이 여러 Read로 나뉘거나 여러 Packet이 한 Read에 들어올 수 있으므로 Receive Buffer와 Packet Parser가 필요합니다.

Binary Protocol을 바로 문자열로 변환해도 되는가#

권장되지 않습니다. 먼저 Raw byte와 Hex 기준으로 확인하고 Protocol이 Text 기반일 때만 적절한 Encoding으로 Decode해야 합니다.

Port가 열렸는데 장비가 응답하지 않는다면#

TX·RX·GND, Baud Rate·Parity·Stop Bits·Flow Control, 전송 Packet과 Device Protocol을 순서대로 확인해야 합니다.

이 글에서 다룬 전체 흐름을 다시 정리하면 다음과 같습니다.

COM Port 검색
↓
장비 Port 식별
↓
Baud·Data·Parity·Stop 설정
↓
Port Open
↓
Hex Data → byte
↓
TX
↓
RX
↓
Receive Buffer
↓
Packet Parser
↓
Hex·ASCII Log
↓
Application

특히 다음 다섯 가지를 기억하면 됩니다.

pySerial을 이용하면 Python에서 COM Port 검색부터 RS-232 byte 송수신까지 비교적 간단하게 구현할 수 있지만, 프로그램이 짧다고 해서 장비 통신 자체가 단순한 것은 아닙니다.

Serial Protocol을 분석할 때는 문자열보다 Raw byte와 Hex Dump를 먼저 확인해야 ASCII Protocol과 Binary Protocol을 혼동하지 않을 수 있습니다.

한 번의 read()와 하나의 Packet은 같은 개념이 아니므로 Receive Buffer에서 데이터를 누적한 뒤 STX·ETX·Length 등의 규칙으로 Frame을 분리해야 합니다.

Port Open 성공과 Device 정상 통신은 별개의 상태이므로 재연결 후에는 실제 Request·Response까지 확인해야 합니다.

장비 통신 장애는 Python Code만 볼 것이 아니라 COM Port → Serial 설정 → Cable → Raw byte → Packet Protocol 순으로 경계를 나누어 추적해야 합니다.

결국 Python으로 RS-232 통신을 구현한다는 것은 단순히 ser.write()와 ser.read()를 사용하는 것이 아닙니다.

현장에서 들어오는 Raw byte를 그대로 관찰하고 기록할 수 있는 도구를 만든 뒤, 그 데이터를 Packet 단위로 복원하고 장비 Protocol의 의미로 해석할 수 있도록 전체 통신 흐름을 구성하는 것이 핵심입니다.

이 페이지의 목차