개요 Overview
시리얼 통신은 오래된 기술로 여겨지지만, 여전히 장비 제어, 임베디드 시스템, 테스트 장비 등에서 널리 쓰이는 주요한 기술 중 하나입니다. 또한, 한 번 설정하면 그 이후에 크게 신경 쓸 부분이 없다는 점에서도 입지를 견고히 한 대표적인 통신 규격 중 하나이기도 합니다.
Windows 환경에서는 이를 제어하기 위하여 WinAPI를 활용하여 구현하는 것이 일반적입니다.
대표적인 함수로는 ‘CreateFile’이 있는데 이는 Windows PC에 장착된 주변 기기의 시리얼 포트(COM)와 통신을 개시 및 필요한 설정을 준비하여 안정적으로 데이터를 송수신하게 할 수 있습니다.
오늘은 이 ‘CreateFile’ 함수를 중심으로 이에 필요한 몇 가지 설정 함수 및 그 용례에 대해서 프로그래밍 흐름과 의도를 중심으로 풀어내고자 합니다.
WinAPI에서 COM 포트는 왜 ‘파일’처럼 다뤄질까?
WinAPI를 사용하여 시리얼 통신 프로그래밍을 처음 접하는 분들에게는 아래의 코드가 낯설거나 의아하게 보이실 수도 있을 것 같습니다.
CreateFile(
szPortName, // 포트 이름
GENERIC_WRITE | GENERIC_READ, // 쓰기 권한
0, // 공유 모드
NULL, // 보안 속성
OPEN_EXISTING, // 기존 파일 열기
FILE_ATTRIBUTE_NORMAL, // 파일 속성
NULL // 템플릿 파일 핸들
);
Windows는 다양한 리소스를 하나의 추상 개념인 HANDLE 로 다루게 됩니다.
파일, 이벤트, 심지어 시리얼 포트도 마찬가지라서 이러한 개념은 다른 인터페이스의 통신 기능이나 이벤트 핸들링에도 적용됩니다.
즉 HANDLE은 커널 리소스를 가리키는 식별자라고 정의할 수 있습니다.
일반적으로, COM 포트를 얻게 되면 이후에는 모든 함수가 이 HANDLE을 통해 작업하도록 하면 됩니다.
여기서 핵심 포인트는 이 HANDLE을 통해 Windows 커널과 통신 상태, 읽기/쓰기, 이벤트 등을 제어할 수 있도록 하는 진입점이 된다는 사실입니다.
또한, 이러한 핸들은 저수준에서 주변 장치에 대한 제어권을 획득하는 행위이므로 반드시 CloseHandle로 닫아줘야 오동작이나 과도한 점유를 막을 수 있습니다.
OS의 개입이 없다는 전제 하에, HANDLE을 닫지 않은 상태가 유지된다면
해당 시스템에서 HANDLE이 가리키는 장치에 대한 제어권은 배타적입니다.
CreateFile : 시리얼 포트에 연결된 장치(Device)를 획득한다.
CreateFile은 함수의 이름처럼 실제로 파일을 생성하는 기능도 담당합니다.
윈도우의 시스템 경로를 활용하면 이 함수는 파일 생성기의 역할도 수행할 수 있습니다.
반면에, 이 함수는 Windows에서 시리얼 포트로 연결된 장치 접근을 개시하는 함수이기도 합니다.
이름만 보면 파일 생성이지만 내부적으로 포함된 기능은 다양하다고 볼 수 있습니다.
📌 COM 포트 예시: "\\\\.\\\\COM3"
👉 Windows 커널에게 “COM3 포트와 통신하겠다” 라고 알리는 것입니다.
여기서 짚어볼 점
동기 I/O vs 비동기 I/O (OVERLAPPED)
→ 후자는 이벤트 기반 메시지 처리와 함께 성능적 이점이 있습니다.
→ 하지만, 실패 시 GetLastError()로 원인 파악을 해야합니다.
시리얼 통신의 여러가지 설정들
- COM 포트를 연 뒤에는 곧바로 통신 설정을 적용해줄 필요가 있습니다.
- 왜냐면 알려진 포트(COM3 등)로 통신을 개시했더라도, 기본 드라이버 상태가 불확정 상태로 남아 있을 수 있기 때문입니다.
- 그렇기에 WinAPI 제공하는 시리얼 통신 설정과 관련 여러 함수들은 궁극적으로 이러한 불완정성을 보완해줄 수 있습니다.
DCB (Device Control Block) : 시리얼 통신의 물리·논리적 설정을 담는 구조체.
| 속성 | 설명 |
| BaudRate | 통신 속도(bps). 장치와 반드시 동일해야 함 |
| ByteSize | 한 프레임당 데이터 비트 수 (보통 7 또는 8) |
| Parity | 패리티 비트 설정 (NOPARITY, EVENPARITY, ODDPARITY 등) |
| StopBits | 스톱 비트 개수 (ONESTOPBIT, TWOSTOPBITS 등) |
| fBinary | 바이너리 모드 사용 여부 (반드시 TRUE) |
| fParity | 패리티 체크 사용 여부 |
| fDtrControl | DTR(Data Terminal Ready) 제어 방식 |
| fRtsControl | RTS(Request To Send) 제어 방식 |
일부 시리얼 장비는 DTR / RTS 설정이 맞지 않으면 물리적으로 연결되어 있어도
전혀 통신이 되지 않는 경우가 있으니 유의해야 합니다.
DCB dcb = { 0 };
dcb.DCBlength = sizeof(DCB);
// 1. 현재 시리얼 포트 상태 조회
if (!GetCommState(hComm, &dcb)) {
// error handling
return false;
}
// 2. 통신 규격 설정
dcb.BaudRate = CBR_115200; // 통신 속도
dcb.ByteSize = 8; // 데이터 비트 수
dcb.Parity = NOPARITY; // 패리티 없음
dcb.StopBits = ONESTOPBIT; // 스톱 비트 1개
dcb.fBinary = TRUE; // 반드시 TRUE
dcb.fParity = FALSE; // 패리티 체크 비활성화
// 3. 설정 적용
if (!SetCommState(hComm, &dcb)) {
// error handling
return false;
}
시리얼 장비는 정확한 통신 규격이 중요하기 때문에 이 설정이 제대로 되어 있지 않으면 데이터가 전송이 되지 않거나 그 결과가 손상될 수 있습니다.
통신 안정화를 위한 기본 제어 요소
- 시리얼 통신은 단순히 ReadFile / WriteFile을 호출한다고 해서 안정적으로 동작하지 않습니다.
- 타임아웃, 내부 버퍼, 큐 상태를 어떻게 관리하느냐에 따라 통신 품질이 크게 달라질 수 있으므로 사용에 유의해야 합니다.
- 이번 챕터에서는 이에 따라 실제 통신 안정성과 직결되는 대표적인 세 가지 요소에 대해 정리하고자 합니다.
COMMTIMEOUTS : Read / Write의 반환 시점의 제어
COMMTIMEOUTS는 읽기(ReadFile)와 쓰기(WriteFile) 호출이 언제 반환될지를 정의하는 구조체입니다.
즉, 스레드가 통신 함수에서 얼마나 대기할지를 제어하는데,
실제로 여기에 설정된 값에 따라서 응답 속도나 대기 속도를 정의할 수 있습니다.
💡 COMMTIMEOUTS이 왜 중요한가요?
타임아웃 설정이 없거나 잘못되면 무한 대기 상태에 빠질 수 있습니다.
반대로 너무 공격적인 설정은 즉시 반환만 반복되는 바쁜 루프를 만들기 때문에 시스템 상황에 맞게 적절한 조정이 필요합니다.
실전에서는 보통 다음과 같은 패턴을 사용합니다.
- 최소한의 타임아웃 설정 → 안정적인 버퍼링
- 장비 응답 시간에 맞춘 동적 조절
COMMTIMEOUTS timeouts = { 0 };
// ReadFile 타임아웃 설정
timeouts.ReadIntervalTimeout = 50; // 수신 간 최대 간격
timeouts.ReadTotalTimeoutMultiplier = 10; // 바이트당 추가 대기 시간
timeouts.ReadTotalTimeoutConstant = 100; // 기본 대기 시간
// WriteFile 타임아웃 설정
timeouts.WriteTotalTimeoutMultiplier = 10;
timeouts.WriteTotalTimeoutConstant = 100;
if (!SetCommTimeouts(hComm, &timeouts)) {
// error handling
return false;
}
SetupComm : 통신 버퍼 크기의 조정
SetupComm은 시리얼 포트의 송수신 내부 버퍼 크기를 지정하는 함수입니다.
Windows 기본 버퍼는 고속 통신이나 대량 데이터 환경에서는 부족할 수 있기 때문에
개발자가 이를 적절히 늘려주어 최적화를 해줄 수 있습니다.
주의할 점
- 요청한 버퍼 크기가 그대로 보장되지는 않습니다
- 실제 크기는 시스템 정책이나 드라이버에 의해 조정될 수 있습니다
- 보통 통신 안정성을 위해 여유 있는 크기로 요청하고, 테스트를 통해 확인하는 방식으로 적용합니다.
// 수신 버퍼 4KB, 송신 버퍼 4KB 요청
if (!SetupComm(hComm, 4096, 4096)) {
// error handling
return false;
}
PurgeComm : 통신 큐의 정리
연결된 장치와 통신 중 오류가 발생하거나 설정을 변경하게 되면,
수신 큐(Receive Queue)와 송신 큐(Transmit Queue)에 이전 데이터가 남아 있을 수 있습니다.
PurgeComm은 이러한 잔여 데이터를 강제로 정리할 수 있도록 도와주는 API입니다.
실제로 런타임 시에 이러한 설정이 적절히 제어되지 못했다면,
이전 통신의 찌꺼기가 남아 다음 통신 기능에 영향을 주는 상황을 쉽게 확인할 수 있습니다.
따라서 보통 재연결, 재설정 시 안정적인 초기 상태를 확보하기 위해 아래와 같이 사용합니다.
// 송수신 큐 모두 비우기
if (!PurgeComm(hComm, PURGE_RXCLEAR | PURGE_TXCLEAR)) {
// error handling
return false;
}
일반적으로 통신 재시작 전에는 항상 PurgeComm을 호출하는 패턴을 권장하고 있습니다.
DCB / Timeout 변경 직후에도 큐 정리를 해주면 예기치 않은 데이터로 인한 문제를 줄이는데 도움이 될 수 있습니다.
이벤트 기반 시리얼 통신 : SetCommMask , WaitCommEvent
시리얼 통신을 구현하는 방식은 아래와 같이 크게 두 가지로 나눌 수 있습니다.
개발자가 둘 중 어떤 방식을 선택 하느냐에 따라 전체 구조와 성능 특성이 달라질 수 있습니다.
| 방식 | 특징 |
| 폴링(Polling) | 루프에서 지속적으로 상태를 확인 |
| 이벤트 기반(Event-driven) | OS가 이벤트 발생을 알려줌 |
Windows의 시리얼 API는 이벤트 기반 통신을 공식적으로 지원하며,
그 핵심이 바로 SetCommMask와 WaitCommEvent 조합에 있습니다.
SetCommMask : 어떤 이벤트를 감지할 것인가
SetCommMask는 시리얼 포트에서 어떤 통신 이벤트를 감지할지 지정하는 함수입니다.
이 설정이 올바르게 되어 있지 않으면, WaitCommEvent는 의미 있는 동작을 하지 않습니다.
자주 사용하는 이벤트
| 이벤트 | 의미 |
| EV_RXCHAR | 수신 버퍼에 데이터가 도착함 |
| EV_TXEMPTY | 송신 버퍼가 비어 있음 |
| EV_ERR | 통신 에러 발생 |
| EV_BREAK | Break 신호 감지 |
보통 이중에서 이벤트 기반 수신에는 EV_RXCHAR를 가장 많이 사용합니다.
// 수신 데이터 이벤트만 감지
if (!SetCommMask(hComm, EV_RXCHAR)) {
// error handling
return false;
}
WaitCommEvent : 이벤트가 발생할 때까지 대기
WaitCommEvent는 SetCommMask로 지정한 이벤트 중 하나가 발생할 때까지
스레드를 블로킹하거나(동기), 비동기로 대기하도록 하는 함수입니다.
이 API를 사용하면 불필요한 폴링 루프 없이 OS 주도의 효율적인 통신 구조를 만들 수 있다는 것이 장점입니다.
DWORD eventMask = 0;
// 이벤트 발생 대기
if (!WaitCommEvent(hComm, &eventMask, NULL)) {
// error handling
return false;
}
if (eventMask & EV_RXCHAR) {
// 수신 데이터 처리
}
이와 같은 방식은 구조가 단순하지만, 함수의 호출 후, 대기중인 상태에서는 해당 스레드가 '블로킹'된다는 점을 유의해야 합니다.
OVERLAPPED와 함께 사용하는 비동기 구조
WaitCommEvent는 OVERLAPPED 구조체와 함께 사용하면 비동기 이벤트 기반 통신으로 확장할 수 있습니다.
OVERLAPPED ov = { 0 };
ov.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
DWORD eventMask = 0;
BOOL result = WaitCommEvent(hComm, &eventMask, &ov);
if (!result && GetLastError() == ERROR_IO_PENDING) {
// 이벤트 발생까지 대기
WaitForSingleObject(ov.hEvent, INFINITE);
}
if (eventMask & EV_RXCHAR) {
// 수신 데이터 처리
}
위 구조의 장점은 다음과 같이 정리할 수 있습니다.
- UI 스레드와 통신 스레드 분리 가능
- 여러 이벤트를 하나의 루프에서 효율적으로 처리 가능
- 고속 통신 환경에서도 CPU 사용률을 낮게 유지
CloseHandle : 시리얼 통신의 마무리
지금까지 소개한 함수들을 활용한 시리얼 통신이 끝났다면, 마지막으로 반드시 CloseHandle 함수를 호출해야 합니다.
이 함수는 COM 포트와 연결된 커널 리소스를 정상적으로 해제하는 역할을 수행하므로 시스템 전체를 효율적으로 정돈할 수 있도록 합니다.
만약 이 함수를 호출하지 않으면 COM 포트가 계속 점유된 상태로 남아 다른 프로그램에서 열 수 없게 됩니다.
OS 차원에서 일정 시간이 지나거나 여러가지 스케줄링 알고리즘에 의해 인터럽트가 발생하는 경우도 있겠지만,
대게는 그 주기가 매우 길 수 밖에 없고 OS 차원에서도 너무 빠른 시점에 함부로 연결을 해제시켜버리면 문제가 될 수 있기 때문에 100% 안전하진 않습니다.
따라서, 시스템으로부터 HANDLE 리소스를 정상적으로 전달 받았다면,
사용자는 모든 작업이 종료된 이후에 이를 깨끗하게 정리할 책임이 있습니다.
if (hComm != INVALID_HANDLE_VALUE) {
CloseHandle(hComm);
hComm = INVALID_HANDLE_VALUE;
}
만약 HANDLE 리소스를 정상적으로 반납하지 않으면, 커널 객체가 해제되지 않아 리소스 누수로 이어질 수 있습니다.
프로그램 상에서 런타임 시에 에러가 발생 가능한 경로에서도 CloseHandle이 호출되도록 구조를 잡는 것이 중요합니다.
이는 프로그램의 안정성을 확보해줄 뿐만 아니라 OS와 조화롭게 주변 장치를 다루는 노하우가 되기도 합니다.
이 함수의 호출로 통신 로직의 끝나는 것이 아니라, HANDLE 생명주기의 끝이라고 생각하면 보다 이해하기 쉬울 것입니다
요약하자면
지금까지 논의한 내용을 정리하면, WinAPI를 사용할 때 다음과 같은 순서대로 점검하는 것이 권장됩니다.
CreateFile
→ SetupComm
→ GetCommState / SetCommState (DCB)
→ SetCommTimeouts
→ PurgeComm
→ SetCommMask
(사용 종료 후)
CloseHandle
CreateFile3 function (fileapi.h) - Win32 apps
Creates or opens a file or I/O device.
learn.microsoft.com
'CS BASIC' 카테고리의 다른 글
| 시리얼 통신(Serial Communication) 정리: 하드웨어 규격과 그 응용 사례 (0) | 2025.12.27 |
|---|