C 시리얼 통신 block read 방법

이제 그동안 진행해왔던 시리얼통신의 제1막을 내려야 할 때가 온것 같습니다.

실제 이 블로그를 기획하고 시작한 의도는 제 자신의 문제로 시작되었습니다.


그동안 수 많은 프로젝트에서 시리얼통신을 사용하였음에도 잘 모르고 그저 동작만 되면
사용해 왔습니다. 그래서, 나름 정리를 한번 해 보자는 차원에서 하게 되었습니다.

실제로 해 보니, 역시 많은 부분에서 제대로 알지 못하고 사용하고 있었습니다.
엉뚱한 예제 코드로 보는 분들의 속을 썩인 곳도 있을 것이고, 개념조차 모호한 상태에서
두리뭉실 넘어간 곳도 있었을 것입니다.

다음에 또 기회가 된다면, 시리얼통신에 관련된 주제로 더 발전된 형태의 기술을 가지고
공부해 보려는 마음이 생깁니다. 일단, 생각하고 있는건 파일전송 프로토콜로서 어떻게
상위 소프트웨어 프로토콜을 만들고 동작하는지 그 원리나, MODBUS-RTU 통신같은
실제 산업현장에서 자주쓰는 프로토콜, 또는 485 통신방식 같이 232보다 더 발전된
하드웨어를 다룰수도 있겠지요. 결국 모든 통신의 궁금적인 목표는 TCP/IP 프로토콜을
사용한 Ethernet 하드웨어, 그리고 Net-BIOS 프로그램이 될 것입니다.

마음만 그렇다는 거지요. 시간이 허락할지는 모르겠습니다. 아직은 놀고 먹고 자는 시간도
부족한 형편이라 마음놓고 실험하고 연구해 볼만한 여력이 안 되는게 현실이네요. ^^;

1. 시리얼통신이란 ?

시리얼 통신은 7비트, 혹은 8비트 데이터를 비트별로 전송하는 방식입니다.
비트별로 시간을 맞춰서 전송을 하게 됩니다.

2. 동기식, 비동기식 방식이란 ?

동기식이란 어떤 신호에 맞춰서 데이터를 송.수신 하는 방식입니다. 이를 위해 동기신호가
필요합니다. 동기식 통신의 장점은 빠른 통신이며, 단점은 동기신호가 필요하다는 점 입니다.
비동기식이란 동기신호없이, 일정시간 마다 데이터를 읽는 방법으로 이를 위해 데이터가
이제 시작한다는 시작 신호가 필요합니다.

3. 전이중, 반이중 방식이란 ?

송신과 수신이 동시에 되면 전이중, 한가지씩만 할 수 있는 것이 반이중 방식입니다.
232 통신은 전이중 통신방식이며, 485통신이 대표적인 반이중 방식입니다.

4. 그렇다면, 그동안 진행해 왔던 232 통신방식은 ?

전이중 비동기 시리얼 통신방식 입니다. full-depluex asynchronous serial communication

5. 블로킹과 넌블로킹의 차이점은 ?

blocking 함수는 그 함수가 완료될때(리턴될때)까지 해당 스레드의 어떤 코드도 실행되지 못하고
기다리는 방식입니다. blocking 함수를 쓸 때는 메세지 루프를 block 하지 않도록 반드시
스레드를 사용해야 합니다.
non-blocking 함수는 그 함수가 완료되는걸 기다리지 않고 리턴됩니다. 실제 그 함수의 역활이
완료 되었는지, 에러가 났는지는 별도로 조사하는 함수 GetOverlappedResult 가 있습니다.

6. 오버랩이란 ?

입출력 함수인 WriteFile, ReadFile 함수는 블로킹 함수인데, 함수 인자로 오버랩 구조체를 전달
함으로서 넌블로킹 함수로 만들수 있습니다.

7. 시리얼 통신 프로그램 방법은 ?

7-1. 포트를 오픈합니다. 핸들을 얻는 것으로 오픈하게 됩니다.

(HANDLE) m_hComm = CreateFile(_T("\\\\.\\COM1"), GENERIC_READ | GENERIC_WRITE, 0, NULL,
OPEN_EXISTING, FILE_FILAG_OVERLAPPED, NULL);

장치명을 COM1으로 주었습니다. 앞에 \\\\.\\ 경로는 장치이름들이 있는 심볼릭 링크 경로 입니다.
파일도 하나의 장치로 간주됩니다. 파일을 다룰때는 C:/Windows/System32 와 같은 심볼릭 장치 이름을 사용하게 되지요.


리눅스를 써 보신 분들은 잘 아시겠지만, 장치도 파일로 다루어 집니다. 굳이 비유하자면 마운트 한다라고 생각하면 되겠네요.


GENERIC_READ | GENERIC_WRITE 인수는 파일을 읽고 쓰겠다는 의미로, 장치에서는 입력과 출력, 송.수신을 하겠다는 의미로 보시면 됩니다.
FILE_FLAG_OVERLAPPED 인수를 줘서 앞으로 사용할 WriteFile과 ReadFile 함수가 오버랩 방식을 사용할 것임을 알려줍니다.

if(m_hComm == INVALID_HANDLE_VALUE) return FALSE;

CreateFile 함수 사용 후 리턴값을 확인 합니다. INVALID_HANDLE_VALUE이면 장치가 없거나, 이미 오픈되었거나 몇가지 이유로 핸들을 얻지 못하는 상황입니다. 통신을 할 수 없으므로 더 이상의 작업을 하지 않고 리턴하였습니다.

장치 핸들을 얻었으면, 그 다음으로 장치 설정을 해 줍니다.

DCB dcb;

GetCommState(m_hComm, &dcb);

dcb.BaudRate = 9600;
dcb.fDtrControl = DTR_CONTROL_DISABLE;
dcb.fRtsControl = RTS_CONTROL_DISABLE;
dcb.Parity = 0;
dcb.ByteSize = 8;
dcb.StopBits = 0;

SetCommState(m_hComm, &dcb);

data control block이라고 불리워지는 DCB 구조체를 하나 선언하고, GetCommState 함수로 해당 장치의 현재값을 가져옵니다. 특별히 리턴값을 점검하지 않는 이유는 m_hComm 장치 핸들값이 정상이면 거의 성공하는 함수이기 때문입니다.
void 타입의 함수인 경우처럼 반드시 성공하는 함수나 거의 성공하는 함수는 굳이 에러체크를 하지 않았습니다.
물론, 상용프로그램은 하겠지요.
통신 속도를 초당 9600 비트로 설정하고, 흐름제어는 하지 않는 것으로 하였습니다.
패리티 비트를 사용하지 않는 것으로 하였습니다. 1데이터 크기는 8비트로 하였습니다. 스톱비트는 1비트로 하였습니다.


그리고 나서 SetCommState 함수를 사용해서 설정을 마칩니다.

COMMTIMEOUTS CommTimeout;

CommTimeout.ReadInvervalTimeout = 0;
CommTimeout.ReadTotalTimeoutMultiplier = 0;
CommTimeout.ReadTotalTimeoutConstant = 10;
CommTimeout.WriteTotalTimeoutMultiplier = 0;
CommTimeout.WriteTotalTimeoutConstant = 0;

SetCommTimeouts(m_hComm, &CommTimetous);

송수신 타임아웃 값을 설정해 줍니다. 사실, 이 부분은 처음 설명드리는 것인데, 실험적으로 알아내는게 가장 좋기 때문입니다. 이 경우에는 ReadTotalTimeoutConstant 값만 10을 주었는데, 10 밀리초 동안 입력이 없으면 타임아웃하라는 뜻입니다. 한번에 보내는 데이터 길이에 따라 조금씩 다르게 해 주면 됩니다.
송신은 크게 문제가 되지 않는데 수신의 경우, 데이터가 몇개 들어올지 모릅니다. 보내는 쪽만 알겠지요.
물론 프로토콜로서 데이터의 첫 부분이 보낼 데이터의 개수라고 정할 수도 있겠습니다만, 소프트웨어 프로토콜을 정해두면 범용에서 벗어나게 되므로 몇개의 데이터가 올지 모른다고 가정합니다.


그렇다면, 처음 데이터가 수신되고 나서 더 이상 데이터가 안 올거라는것을 알 방법은 없지요. 그래서 실제 실험을 해 보면 수신시 처음에 ERROR_IO_PENDING 이 발생하고 난 후 계속 ERROR_IO_INCOMPLETE 가 발생합니다.
IO를 종료시키기 위해서는 ERROR_IO_INCOMPLETE 발생 후 얼마후에 완료된 것으로 보느냐가 바로 타임아웃입니다.

이 타임아웃 값이 너무 짧으면 연속된 데이터임에도 불구하고 계속 수신 이벤트가 발생하게 됩니다.
그러나, 너무 길면 그만큼 대기시간이 길어집니다. 실험적으로 알아내는게 가장 좋다는 말은 이 때문입니다.
일부에서는 통신속도의 2~3배 정도라든가 해서 통신속도에 연계해서 주기도 합니다.

SetupComm(m_hComm, 1024, 1024);

송.수신할때 버퍼의 크기를 지정해 줍니다. 아시다시피 이 크기를 넘는 데이터는 한번에 전송되지 않습니다.
너무 작으면 데이터에 한계가 오고, 너무 크면 처리하는데 시간이 더 걸립니다.

PurgeComm(m_hComm, PURGE_TXABORT | PURGE_TXCLEAR | PURGE_RXABORT | PURGE_RXCLEAR);

통신 포트를 리셋합니다.

(char*) m_recvData = new char[1024];
ZeroMemory(m_recvData, 1024);
m_nRecvLength = 0;

수신할 데이터 버퍼와 길이를 초기화 합니다.

m_hWnd = GetSafeHwnd();
m_nMsg = WM_USER+100;

이 두 변수는 메세지를 발생시키기 위한 변수입니다. 데이터가 수신 되었을때 스레드에서 PostMessage나
SendMessage 함수로 원하는 윈도우에 메세지를 보낼 수 있습니다. MFC나 Visual Basic에서 제시하고 있는 방법이 이런 방법인데 때문에 어려운 말로 시리얼 클래스(소켓함수도 포함됩니다.)는 윈도우의 내부 변수이거나
윈도우를 자식 클래스로 하는 부모 클래스에 포함되어야 합니다. 그렇지 않으면 수신 이벤트가 발생했을때
메세지로 알려줄 방법이 없습니다. 예제에서는 이 변수를 설정하면 메세지가 발생되고 설정하지 않았을때
수신데이터 유무를 조사할 함수로 IsReceived() 함수를 만들어 놓았습니다. 편한대로 사용하되 메세지를
발생하도록 하였습니다.

m_ovSend.hEvent = CreateEvent(NULL, FALSE, FLASE, NULL);
m_ovRecv.hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

오버랩 데이터 구조의 이벤트 객체를 생성합니다.

DWORD err;
COMSTAT stat;
ClearCommError(m_hComm, &err, &stat);

GetLastError() 함수로 읽을 수 있는 에러를 클리어 합니다. 굳이 필요없지만 예방 차원에서 넣었습니다.

SetCommMask(m_hComm, EV_RXCHAR);

수신된 데이터가 있으면 이벤트를 받겠다고 설정합니다. 이 외에도 송신완료, 흐름제어 문자, 수신 버퍼 풀등 여러 이벤트가 있지만 예제에서 사용하는건 수신 이벤트 뿐이므로 이렇게 설정했습니다.

m_bRunEventThread = true;
m_bEndEventThread = false;
AfxBeginThread(PROC_COMM_EVENT, this);

스레드를 생성합니다. 이 스레드는 이벤트를 받는 수신 스레드 입니다. 스레드는 무한 루프를 돌면서
이벤트를 기다리게 되는데 위에 두 변수로 스레드를 제어합니다. 스레드를 제어하는 방식은 여러가지가
있는데 그중 제 나름대로 편한 방법을 썼습니다. 필요한 데이터를 전역으로 넘기지 않고 this라고 해서
해당 호출되는 클래스의 객체를 넘겼으므로 객체가 사라지기전에 반드시 스레드는 종료되어야 합니다.
그렇지 않으면 사라진 객체를 스레드에서 access 하려고 하다가 뿅 갈 것입니다.

7-2. 송.수신이 끝난 후 포트를 닫는 함수를 만듭니다.

앞서 말씀드렸다시피 스레드부터 종료시킵니다.

if(m_bRunSendThread)
{
m_bRunSendThread = false;
while(!m_bEndSendThread)
Sleep(1);
}

m_bRunSendThread라는 변수에 false를 주고나서 m_bEndSendThread 라는 변수가 true가 될때까지
기다립니다. m_bEndSendThread라는 변수가 true가 되지 않으면 영원히 종료되지 않습니다.
따라서, 스레드를 코딩할때 각별한 주의가 필요합니다.

if(m_bRunEventThread)
{
m_bRunEventThread = false;
if(m_hComm != INVALID_HANDLE_VALUE)
SetCommMask(m_hComm, 0);
while(!m_bEndEventThread)
Sleep(1);
}

예제 코드는 송신 스레드와 이벤트 스레드 2개를 생성하는데, 송신 스레드는 데이터를 송신 할 때만
생성하고 수신 스레드인 이벤트 스레드는 항상 작동하고 있습니다. 포트를 열때 이미 스레드를 생성한 것을
알고 계시겠지요. 스레드를 종료하는 다른 구조는 같은데, SetCommMask(m_hComm, 0) 이라는 함수가
들어가 있습니다. 이 함수는 앞서 수신 이벤트를 설정하는 함수라는 것을 말했습니다. 이벤트 스레드는
이벤트가 들어올때까지 대기하게 되므로 m_bRunEventThread 변수가 false가 되었는지 수신 데이터가
들어오기 전까지는 확인이 안 되어서 종료가 안 됩니다. 뒤에 보시면 아시겠지만, WaitCommEvent 함수에서 계속 대기하는게 주로 이벤트 스레드가 할 일입니다. 이벤트가 없다고 설정함으로서 WaitCommEvent 함수를 리턴시켜 버릴수 있습니다.

 if(m_hComm != INVALID_HANDLE_VALUE) {
  CloseHandle(m_hComm);
  m_hComm = INVALID_HANDLE_VALUE;
 }

 if(m_ovSend.hEvent != INVALID_HANDLE_VALUE) {
  CloseHandle(m_ovSend.hEvent);
  m_ovSend.hEvent = INVALID_HANDLE_VALUE;
 }
 if(m_ovRecv.hEvent != INVALID_HANDLE_VALUE) {
  CloseHandle(m_ovRecv.hEvent);
  m_ovRecv.hEvent = INVALID_HANDLE_VALUE;
 }

 if(m_nSendLength > 0)
  delete [] m_sendData;
 m_sendData = NULL;
 m_nSendLength = 0;

 delete [] m_recvData;
 m_recvData = NULL;
 m_nRecvLength = 0;

그 뒤는 일반적인 핸들과 할당했던 메모리를 해제하는 작업입니다.

7-3. 데이터 송신

데이터 송신은 "SERIAL COMMUNICATION TEST...!" 라는 데이터를 보내는 것으로 가정하고 설명하겠습니다.

우선 변수에 데이터를 할당 합니다.

char* data = "SERIAL COMMUNICATION TEST...!";
int nSize = strlen(data);

(char*) m_sendData = new char[nSize];
memcpy(m_sendData, data, nSize);


DWORD err;
COMSTAT stat;
ClearCommError(m_hComm, &err, &stat);

설명드린것처럼, GetLastError()로 리턴되는 에러코드를 해제합니다.


m_ovSend.Offset = 0;
m_ovSend.OffsetHigh = 0;
DWORD dwWritten;
m_nSendLength = nSize;

BOOL bRet = WriteFile(m_hComm, m_sendData, nSize, &dwWritten, &m_ovSend);


오버랩 구조체의 Offset를 0으로 만든후에 WriteFile 함수로 데이터를 보냅니다.

m_sendData는 멤버변수인데 overlapped 방식으로 데이터를 보내므로 WriteFile 함수는 곧 리턴됩니다.
대부분의 경우 bRet 값이 FALSE인 상태로 리턴됩니다. 그 후에 내부적으로 m_sendData를 계속 송신
하려고 하기 때문에 지역변수를 넣으면 큰일 납니다. 송신이 모두 완료될때까지 유지될 변수에 보내려는
데이터가 있어야 합니다. 때문에 data를 바로 보내지 않고 멤버변수에 복사한 후 보내는 방법을 썼습니다.

if(bRet)
{
 delete [] m_sendData;
 m_nSendLength = 0;
}

만일, WriteFile 함수가 TRUE를 리턴하면, 두고 볼것도 없이 송신이 다 완료 된 것입니다.
송신 변수를 초기화 하고 종료하면 됩니다.

else
{
 DWORD err = GetLastError();
 if(err != ERROR_IO_PENDING && err != ERROR_IO_INCOMPLETE)
 {
  delete [] m_sendData;
  m_nSendLength = 0;
  return FALSE;
 }
 else
 {
  m_bRunSendThread = true;
  m_bEndSendThread = false;
  AfxBeginThread(PROC_COMM_SEND, this);
  return TRUE;
 }
}

대부분의 경우 오버랩 타입의 WriteFile은 FALSE를 리턴합니다.


GetLastError() 함수로 리턴값을 조사합니다. ERROR_IO_PENDING이나 ERROR_IO_INCOMPLETE라면
어쨌건 데이터를 보내고 있는 중이라는 뜻입니다. 이 두 경우가 아니면 데이터 리셋 후 에러상태로 리턴하고
전송 중이면 송신 스레드를 작동시키고 TRUE를 리턴합니다. 남은 송신은 스레드가 알아서 해 줄 것입니다.

7-4. 데이터 수신

데이터 수신은 애매한게 언제 데이터가 올지 모른다는 점 입니다. 그래서 이벤트를 설정했고 스레드에서 항상 감시하도록 하였습니다.


UINT CJSerial::PROC_COMM_EVENT(LPVOID pParam)
{

CJSerial* parent = (CJSerial*) pParam;

pParam는 스레드를 생성할때 AfxBeginThread 함수의 2번째 인수로 보낸 객체입니다. 때문에 이 인스턴스가 절대로 스레드보다 먼저 종료되는 일이 없어야 합니다. 그럴 필요가 있다면 전역 변수로 필요한 데이터를 보내야 합니다.

while(parent->m_bRunEventThread)
{
 if(!WaitCommEvent(parent->m_hComm, &dwEvtMask, NULL))
 {
  // FALSE일때 처리
 }
 else
 {
  if(dwEvtMask & EV_RXCHAR)
  {
   // 수신 함수
  }
 }
}

루프의 기본 구조 입니다. CJSerial 객체의 m_bRunEventThread 변수가 true 인 동안 계속 이 루프를 돌게 됩니다. 앞에서 이 스레드를 종료시킬때 m_bRunEventThread = false 라고 한 것을 기억하실 겁니다.
실제로는 WaitCommEvent 함수에서 이벤트가 발생할 때까지 blocking이 걸려 있습니다. 이 함수가 계속 대기 하다가 이벤트가 발생하면 EV_RXCHAR 와 AND 연산을 해서 실제로 수신 이벤트가 발생했는지 확인한 후 수신 함수 부분을 실행하게 됩니다.

계속해서 수신 함수 부분을 체크해 보겠습니다.

DWORD err;
COMSTAT stat;
ClearCommError(parent->m_hComm, &err, &stat);

먼저 송신과 마찬가지로 에러를 미리 해제해 둡니다.

DWORD dwRead;
parent->m_ovRecv.Offset = 0;
parent->m_ovRecv.OffsetHigh = 0;

(BOOL) bRet = ReadFile(parent->m_hComm, parent->m_recvData + parent->m_nRecvLength, 1024, &parent->m_ovRecv);

데이터를 수신합니다. 대부분의 구조는 WriteFile과 같습니다.

if(bRet)
{
 parent->m_nRecvLength += dwRead;
}

TRUE가 리턴되면 Read과정이 종료이지만,... 대부분의 경우 FALSE가 리턴됩니다.

else
{
 DWORD err = GetLastError();
 while(err == ERROR_IO_PENDING || err == ERROR_IO_INCOMPLETE)
 {
 Sleep(1);
 if(GetOverlappedResult(parent->m_hComm, &parent->m_ovRead, FALSE))
 {
  parent->m_nRecvLength += dwRead;
  break;
 }
 else err = GetLastError();
 }
}

GetLastError() 함수를 호출해서 수신중인지 확인 합니다. 수신이 완료되면 GetOverlappedResult 함수는 TRUE가 리턴됩니다.

자, 핵심적인 부분은 다 했습니다.

혹시 더 궁금하신 분이 계시다면, 예제 코드를 참고하시면 될 것입니다.