본문 바로가기

CS BASIC

[Windows] 시리얼 통신과 WinAPI의 활용

개요 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 기본 버퍼는 고속 통신이나 대량 데이터 환경에서는 부족할 수 있기 때문에

개발자가 이를 적절히 늘려주어 최적화를 해줄 수 있습니다.

 

주의할 점

  1. 요청한 버퍼 크기가 그대로 보장되지는 않습니다
  2. 실제 크기는 시스템 정책이나 드라이버에 의해 조정될 수 있습니다
  3. 보통 통신 안정성을 위해 여유 있는 크기로 요청하고, 테스트를 통해 확인하는 방식으로 적용합니다.
// 수신 버퍼 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

 

 

 

https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfile3?devlangs=cpp&f1url=%3FappId%3DDev17IDEF1%26l%3DKO-KR%26k%3Dk(FILEAPI%2FCreateFile)%3Bk(CreateFile)%3Bk(DevLang-C%2B%2B)%3Bk(TargetOS-Windows)%26rd%3Dtrue

 

CreateFile3 function (fileapi.h) - Win32 apps

Creates or opens a file or I/O device.

learn.microsoft.com