[C#] SerialPort로 RS-232 통신 구현하기: COM Port 설정·송수신·DataReceived 처리
C# SerialPort로 RS-232 통신 구현하기: COM Port 설정·송수신·DataReceived 처리#
1장 C# 프로그램에서 RS-232 장비와 통신하려면#
PC에 RS-232 장비를 연결했다고 해서 프로그램이 바로 장비와 통신할 수 있는 것은 아닙니다.
소프트웨어에서는 최소한 다음 조건을 알아야 합니다.
COM Port
Baud Rate
Data Bits
Parity
Stop Bits
Flow Control
Protocol Format예를 들어 장비 Manual에 다음과 같이 적혀 있다고 하겠습니다.
COM Port
환경에 따라 결정
Baud Rate
19200
Data Bits
8
Parity
None
Stop Bits
1흔히 다음과 같이 표현합니다.
19200-8-N-1C# 프로그램은 이 조건을 장비와 동일하게 맞춰야 합니다.
전체 통신 흐름은 다음과 같습니다.
C# Application
│
▼
SerialPort
│
▼
COM Port
│
▼
RS-232 Cable
│
▼
Device여기서 한 단계라도 잘못되면 사용자 화면에서는 단순히:
장비 응답 없음으로 보일 수 있습니다.
따라서 RS-232 프로그램에서는 물리 연결과 Serial 설정, byte 송수신, Protocol 해석을 각각 분리해서 확인하는 습관이 중요합니다.
2장 System.IO.Ports.SerialPort를 이해하자#
C#에서는 System.IO.Ports의 SerialPort 클래스를 이용해 Serial Port를 다룰 수 있습니다.
기본 Namespace는 다음과 같습니다.
using System.IO.Ports;SerialPort에서는 대표적으로 다음 작업을 수행할 수 있습니다.
COM Port 지정
Baud Rate 설정
Data Bits 설정
Parity 설정
Stop Bits 설정
Flow Control 설정
Port Open / Close
byte 송수신
문자열 송수신
DataReceived 이벤트 처리
Read Timeout / Write Timeout 설정장비 통신 프로그램에서는 이 클래스를 직접 사용하거나 별도의 Wrapper Class로 감싸 사용하는 경우가 많습니다.
예:
SerialPortManager
│
├─ Open
├─ Close
├─ Send
├─ Receive
└─ Reconnect처럼 관리하면 규모가 커졌을 때 유지보수가 쉬워집니다.
3장 먼저 COM Port를 찾아야 한다#
Windows에서 Serial Port는 일반적으로 다음과 같은 이름을 사용합니다.
COM1
COM3
COM5
COM10USB-RS232 Adapter를 사용하면 연결 위치나 Driver 상태에 따라 COM 번호가 달라질 수도 있습니다.
C#에서는 현재 사용 가능한 Port를 확인할 수 있습니다.
string[] ports = SerialPort.GetPortNames();
foreach (string portName in ports)
{
Console.WriteLine(portName);
}출력 예:
COM1
COM4
COM7그러나 중요한 점이 있습니다.
COM4가 존재한다.와:
COM4가 내가 통신하려는 장비다.는 같은 의미가 아닙니다.
현장에서는 다음 정보를 함께 확인하는 것이 좋습니다.
장치 관리자
USB-RS232 Adapter 모델
COM Port 번호
장비명
Cable 번호
Switch 또는 제어반 위치
운영 문서USB Serial Adapter를 여러 개 사용하는 환경이라면 프로그램 설정에 단순히 COM4만 저장하는 것보다 장비와 Port의 매핑 정보를 별도로 관리하는 것이 좋습니다.
4장 Baud Rate·Parity·Stop Bits를 정확하게 설정한다#
예를 들어 장비 설정이:
19200-8-N-1이라면 C#에서는 다음과 같이 설정할 수 있습니다.
SerialPort port = new SerialPort();
port.PortName = "COM4";
port.BaudRate = 19200;
port.DataBits = 8;
port.Parity = Parity.None;
port.StopBits = StopBits.One;
port.Handshake = Handshake.None;각 항목의 의미는 다음과 같습니다.
| 항목 | 의미 | 예 |
|---|---|---|
| Baud Rate | Serial 전송 속도 | 9600, 19200, 115200 |
| Data Bits | 한 문자 데이터 Bit 수 | 7, 8 |
| Parity | 간단한 오류 검사용 Bit | None, Even, Odd |
| Stop Bits | 문자 끝을 나타내는 Bit | 1, 2 |
| Handshake | Flow Control | None, RTS/CTS, XON/XOFF |
여기서 가장 중요한 원칙은:
일반적인 값을 추측하는 것이 아니라 장비 Manual의 설정값과 정확히 일치시키는 것입니다.
장비가:
9600-8-E-1인데 프로그램이:
9600-8-N-1이면 정상 통신하지 않을 수 있습니다.
5장 Port를 열고 byte를 송신한다#
설정이 끝났다면 Port를 엽니다.
try
{
port.Open();
Console.WriteLine("Serial port opened.");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}Port가 정상적으로 열렸다면 장비에 데이터를 보낼 수 있습니다.
예를 들어 Test Frame을 다음과 같이 만들 수 있습니다.
byte[] request =
{
0x02,
0x10,
0x00,
0x03
};전송:
port.Write(
request,
0,
request.Length
);이때 중요한 것은 실제 장비 Protocol입니다.
장비마다 다음 구조가 전혀 다를 수 있습니다.
STX
Address
Command
Length
Data
Checksum
ETX또는:
Header
Length
Command
Payload
CRC일 수 있습니다.
따라서 위 byte 배열은 구조를 이해하기 위한 예일 뿐 실제 장비 Command로 사용해서는 안 됩니다.
6장 문자열보다 byte 기준으로 생각하자#
RS-232 장비 통신을 처음 구현할 때 흔히 다음처럼 접근합니다.
port.WriteLine("OPEN");장비가 ASCII Protocol을 사용한다면 가능할 수 있습니다.
하지만 Binary Protocol이라면 다릅니다.
예:
02 31 00 04 A7 03처럼 byte 자체가 의미를 가질 수 있습니다.
따라서 먼저 확인해야 합니다.
ASCII Protocol인가?
Binary Protocol인가?
문자 Encoding은 무엇인가?
Packet 시작은 어떻게 찾는가?
Length Field가 있는가?
CRC·Checksum이 있는가?Binary Protocol이라면 프로그램 내부에서도 가능한 한:
byte[]기준으로 처리하는 것이 안전합니다.
문자열 변환은 실제 Protocol이 문자열 기반일 때만 명확한 Encoding을 지정해서 수행하는 편이 좋습니다.
7장 DataReceived 이벤트로 비동기 수신하기#
장비는 항상 프로그램이 Request를 보낸 직후에만 데이터를 보내는 것은 아닙니다.
예를 들어:
센서 상태 변경
장비 Alarm
Card Reader Event
Button 입력
주기적인 상태 보고처럼 장비가 먼저 데이터를 보내기도 합니다.
C# SerialPort에서는 DataReceived 이벤트를 사용할 수 있습니다.
port.DataReceived += OnDataReceived;간단한 Handler:
private static void OnDataReceived(
object sender,
SerialDataReceivedEventArgs e)
{
SerialPort port = (SerialPort)sender;
int count = port.BytesToRead;
byte[] buffer = new byte[count];
int read = port.Read(
buffer,
0,
count
);
Console.WriteLine(
$"RX: {ToHex(buffer, read)}"
);
}여기서 중요한 점은 DataReceived 이벤트가 발생했다고 해서 Packet 하나가 완성되어 있다는 보장은 없다는 것입니다.
8장 DataReceived 한 번이 Packet 하나는 아니다#
장비가 다음 Frame을 보냈다고 하겠습니다.
02 10 04 41 42 43 44 7A 03프로그램에서는 다음처럼 들어올 수 있습니다.
첫 번째 Event:
02 10 04두 번째 Event:
41 42 43세 번째 Event:
44 7A 03반대 상황도 가능합니다.
PACKET 1
PACKET 2두 개가 한 번의 Read에서 같이 들어올 수도 있습니다.
따라서:
DataReceived
=
Packet Received라고 구현해서는 안 됩니다.
권장 구조는 다음과 같습니다.
SerialPort
│
▼
DataReceived
│
▼
Receive Buffer
│
▼
Frame Boundary 탐색
│
▼
Packet Parser
│
▼
Command / Response 처리Serial Layer는 byte를 전달하고 Packet Parser가 Protocol을 해석하도록 분리하는 것이 좋습니다.
9장 Receive Buffer에서 Packet을 복원한다#
다음 데이터가 먼저 들어왔다고 하겠습니다.
02 10 03 41아직 Packet이 완성되지 않았습니다.
조금 뒤:
42 43 7A 03이 추가로 들어옵니다.
Receive Buffer에서는:
02 10 03 41 42 43 7A 03이 됩니다.
그다음 Protocol 규칙을 이용해 Packet을 추출합니다.
대표적인 Frame 구분 방식은 다음과 같습니다.
STX·ETX 방식#
STX
DATA
ETXLength 방식#
Header
Length
Data
ChecksumFixed Length 방식#
항상 16 byteDelimiter 방식#
COMMAND\r\nProtocol마다 방식이 다르므로 Parser를 SerialPort 코드에 직접 섞기보다 별도 Class로 분리하는 것이 좋습니다.
SerialTransport
ReceiveBuffer
PacketParser
DeviceProtocol처럼 역할을 나눌 수 있습니다.
10장 UI 프로그램에서는 Thread를 특히 조심해야 한다#
WinForms나 WPF Application에서 DataReceived Handler가 실행될 때 바로 UI Control을 수정하면 문제가 발생할 수 있습니다.
예를 들어:
private void OnDataReceived(
object sender,
SerialDataReceivedEventArgs e)
{
statusLabel.Text = "Received";
}처럼 직접 UI를 수정하는 방식은 Thread 문제를 일으킬 수 있습니다.
WinForms에서는 필요한 경우 UI Thread로 넘겨야 합니다.
예:
BeginInvoke(() =>
{
statusLabel.Text = "Received";
});WPF에서는 Dispatcher 등을 사용할 수 있습니다.
따라서 구조적으로는:
Serial Thread
↓
Receive Queue
↓
Parser
↓
Application Event
↓
UI Thread처럼 통신 처리와 화면 처리를 분리하는 것이 좋습니다.
장비가 많아질수록 이런 구조가 중요해집니다.
11장 Timeout·Exception·재연결을 설계한다#
장비 통신에서는 항상 응답이 오는 것이 아닙니다.
가능한 상황:
장비 전원 OFF
Cable 단선
USB Adapter 분리
잘못된 Baud Rate
장비 Busy
Protocol 오류따라서 Timeout을 설정할 수 있습니다.
port.ReadTimeout = 1000;
port.WriteTimeout = 1000;다만 1000ms가 모든 장비에 적절하다는 뜻은 아닙니다.
Timeout은 다음 요소를 기준으로 결정합니다.
장비 최대 응답 시간
Command 종류
Protocol 규격
Retry 횟수
업무 중요도Exception도 반드시 기록해야 합니다.
try
{
port.Write(
request,
0,
request.Length
);
}
catch (TimeoutException ex)
{
Console.WriteLine(
$"Timeout: {ex.Message}"
);
}
catch (IOException ex)
{
Console.WriteLine(
$"IO Error: {ex.Message}"
);
}재연결도 상태를 나누어야 한다#
USB-RS232 Adapter가 빠졌다가 다시 연결될 수 있습니다.
이때:
Port Open 성공만 확인해서는 부족합니다.
다음처럼 보는 것이 좋습니다.
COM Port 존재
↓
Port Open
↓
Serial 설정 적용
↓
Test Request
↓
정상 Response
↓
Device Online즉 Port가 열렸다는 것과 장비가 정상이라는 것은 다른 상태입니다.
12장 Hex Dump 로그를 반드시 남기자#
현장에서 다음 Log만 있다면:
Device communication failed원인을 찾기 어렵습니다.
반면 다음처럼 기록하면 훨씬 유용합니다.
2026-09-24 14:32:10.125
PORT=COM4
CONFIG=19200-8-N-1
TX
02 31 00 04 35 03
2026-09-24 14:32:10.214
RX
02 06 31 37 03기록하면 좋은 항목은 다음과 같습니다.
Timestamp
Port Name
Baud Rate
TX / RX
Hex Dump
Packet Length
Device ID
Timeout
Exception
Retry CountHex 변환 함수는 간단하게 만들 수 있습니다.
static string ToHex(
byte[] data,
int length)
{
return string.Join(
" ",
data.Take(length)
.Select(b => b.ToString("X2"))
);
}이런 Raw Log가 있으면:
송신하지 못한 것인가?
장비가 응답하지 않은 것인가?
byte는 왔는데 Parser가 실패한 것인가?
Checksum이 틀린 것인가?를 구분할 수 있습니다.
13장 C# RS-232 프로그램 구조를 분리하자#
작은 Test Program에서는 하나의 Class에 모든 기능을 넣어도 동작합니다.
하지만 실제 장비 프로그램은 빠르게 복잡해집니다.
Port Open
Receive
Packet Parse
Command 생성
Retry
UI
Database
Logging이 모든 것이 하나의 Form이나 Class에 들어가면 유지보수가 어려워집니다.
다음처럼 분리할 수 있습니다.
SerialPortManager
│
├─ Port 검색
├─ Open
├─ Close
└─ Reconnect
SerialTransport
│
├─ Send
└─ Receive
ReceiveBuffer
│
└─ byte 누적
PacketParser
│
├─ Frame Boundary
├─ Length 검사
└─ Checksum 검사
DeviceProtocol
│
├─ Command 생성
└─ Response 해석
DeviceService
│
└─ 실제 업무 처리이런 구조라면 나중에 Transport가:
RS-232
↓
RS-485또는:
Serial
↓
TCP로 변경되어도 Protocol Layer를 재사용하기 쉬워집니다.
14장 간단한 통합 예제#
다음은 Port를 열고 Test Frame을 보내고 비동기로 데이터를 받는 기본 구조입니다.
using System;
using System.IO.Ports;
using System.Linq;
public class SerialExample
{
private readonly SerialPort port;
public SerialExample(string portName)
{
port = new SerialPort
{
PortName = portName,
BaudRate = 19200,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Handshake = Handshake.None,
ReadTimeout = 1000,
WriteTimeout = 1000
};
port.DataReceived += OnDataReceived;
}
public void Open()
{
if (!port.IsOpen)
{
port.Open();
}
}
public void Send(byte[] data)
{
if (!port.IsOpen)
{
throw new InvalidOperationException(
"Serial port is not open."
);
}
port.Write(
data,
0,
data.Length
);
Console.WriteLine(
$"TX: {ToHex(data, data.Length)}"
);
}
private void OnDataReceived(
object sender,
SerialDataReceivedEventArgs e)
{
try
{
int count = port.BytesToRead;
if (count <= 0)
{
return;
}
byte[] buffer = new byte[count];
int read = port.Read(
buffer,
0,
count
);
Console.WriteLine(
$"RX: {ToHex(buffer, read)}"
);
HandleReceivedBytes(
buffer,
read
);
}
catch (Exception ex)
{
Console.WriteLine(
$"Receive error: {ex.Message}"
);
}
}
private void HandleReceivedBytes(
byte[] data,
int length)
{
// Receive Buffer에 누적한 뒤
// Protocol 규칙에 따라 Packet을 추출한다.
}
private static string ToHex(
byte[] data,
int length)
{
return string.Join(
" ",
data.Take(length)
.Select(b => b.ToString("X2"))
);
}
public void Close()
{
if (port.IsOpen)
{
port.Close();
}
}
}사용 예:
SerialExample serial =
new SerialExample("COM4");
serial.Open();
byte[] request =
{
0x02,
0x10,
0x00,
0x03
};
serial.Send(request);이 예제의 핵심은 특정 장비를 제어하는 것이 아닙니다.
다음 구조를 만드는 것입니다.
Port 설정
↓
Open
↓
byte 송신
↓
비동기 수신
↓
Buffer
↓
Packet Parser
↓
Application15장 장애는 계층별로 확인한다#
C# 프로그램에서 장비 응답이 없다고 하겠습니다.
바로 Code를 수정하기보다 다음 순서로 확인합니다.
물리 연결#
TX
RX
GND
DTE / DCE
Cable
USB-RS232 Adapter
장비 전원운영체제#
COM Port 존재 여부
Driver 상태
Port 점유
USB 연결 상태Serial 설정#
Baud Rate
Data Bits
Parity
Stop Bits
Flow Control송수신#
실제로 TX byte가 나갔는가?
RX byte가 들어오는가?
Timeout인가?Protocol#
STX / ETX
Address
Command
Length
Data
Checksum / CRC이 순서로 보면:
C# SerialPort 문제라고 생각했던 장애가 실제로는 Cable이나 Baud Rate 문제였다는 것을 빠르게 찾을 수 있습니다.
16장 흔히 하는 구현 실수#
DataReceived 한 번을 Packet 하나라고 생각한다#
Event 발생 단위와 Protocol Packet 단위는 다를 수 있습니다.
Receive Buffer가 필요합니다.
Binary 데이터를 ReadExisting으로 처리한다#
ReadExisting()은 문자열 중심 처리에 편리하지만 Binary Protocol에서는 byte 단위 Read()를 사용하는 편이 명확합니다.
Encoding만 바꾸면 모든 데이터가 해결된다고 생각한다#
Binary Protocol에는 Encoding 개념을 적용할 필요가 없는 영역이 많습니다.
먼저 Raw byte를 확인해야 합니다.
UI에서 SerialPort를 직접 관리한다#
작은 프로그램에서는 가능하지만 Device가 늘어나면 Form Code가 복잡해집니다.
통신 Layer를 분리하는 것이 좋습니다.
Timeout을 무조건 길게 설정한다#
장애 감지가 늦어질 수 있습니다.
장비의 정상 Response Time을 기준으로 설계해야 합니다.
Port Open 성공을 장비 Online으로 판단한다#
COM Port가 열렸다는 사실과 Device가 Protocol에 정상 응답한다는 사실은 다릅니다.
17장 자기 점검#
C#에서 사용할 수 있는 COM Port는 어떻게 확인하는가#
SerialPort.GetPortNames()를 이용해 운영체제가 인식한 Serial Port 목록을 가져올 수 있습니다.
19200-8-N-1은 무엇을 의미하는가#
Baud Rate 19200, Data Bits 8, Parity None, Stop Bits 1을 의미합니다.
DataReceived가 발생하면 Packet이 완성된 것인가#
아닙니다. 여러 Event에 하나의 Packet이 나뉘거나 여러 Packet이 한꺼번에 들어올 수 있습니다.
Binary Protocol에서 ReadExisting을 그대로 사용해도 되는가#
문자열 기반 Protocol에서는 편리하지만 Binary Protocol에서는 byte 단위 Read()와 Receive Buffer를 사용하는 편이 안전합니다.
Port가 정상적으로 열렸는데 장비가 응답하지 않는다면#
Cable·TX/RX·Baud Rate·Parity·Flow Control·전송 Frame·Device Address와 Protocol을 순서대로 확인해야 합니다.
18장 이 글을 마치며#
C#에서 RS-232 장비와 통신하는 전체 흐름은 다음과 같습니다.
COM Port 탐색
↓
Port 식별
↓
Baud·Data·Parity·Stop 설정
↓
SerialPort Open
↓
byte 송신
↓
DataReceived
↓
Receive Buffer
↓
Packet Parser
↓
Device Protocol
↓
Application특히 다음 내용을 기억하면 됩니다.
C# SerialPort에서 가장 먼저 해야 할 일은 장비와 동일한 Baud Rate·Data Bits·Parity·Stop Bits·Flow Control을 설정하는 것입니다.
RS-232 장비가 Binary Protocol을 사용한다면 문자열보다 Raw byte를 기준으로 송수신하고 Hex Dump 로그를 남기는 것이 장애 분석에 유리합니다.
DataReceived 이벤트는 Packet 완성을 의미하지 않으므로 byte를 Receive Buffer에 누적한 뒤 STX·ETX·Length 등 Protocol 규칙을 기준으로 Packet을 추출해야 합니다.
WinForms·WPF 같은 GUI 프로그램에서는 Serial 수신 Thread와 UI Thread를 분리하고 필요한 경우 UI Thread로 안전하게 전달해야 합니다.
Port Open 성공, byte 수신 성공, Protocol Response 성공, 실제 Device 동작 성공은 서로 다른 상태이므로 단계별로 구분해서 판단해야 합니다.
결국 C#으로 RS-232 장비를 제어한다는 것은 SerialPort.Write()와 Read()를 호출하는 것만을 의미하지 않습니다.
COM Port에서 들어오는 불규칙한 byte Stream을 안정적으로 수집하고, 장비 Protocol의 Packet으로 복원하며, Timeout·Exception·재연결까지 포함해 장시간 운영할 수 있는 통신 구조를 만드는 것이 핵심입니다.