C언어 rs232 시리얼 통신 프로토콜 주의 사항

2021. 11. 18. 08:51 컴퓨터/프로그래밍

시리얼 통신 패킷에 데이터 길이?

업체로부터 rs232 시리얼 통신 장비를 받았는데요, 프로토콜 문서에 언급된 패킷 구성을 보니 걱정스러운 부분이 있네요. 바로 데이터의 바이트 개수를 알려 주는 길이(Length) 요소인데요, 외부 영향에 약한 시리얼 통신에서 데이터의 크기를 알려 주고 그만큼 받으라는 것은 옳지 않다고 생각합니다.

rs232 프로토콜 LEN

시리얼 통신에서 길이를 넣어도 문제없다면 ETX를 사용할 이유가 없습니다. 만일 길이는 데이터 검증용이라고 한다면 CRC가 있으므로 이것도 이유가 못 됩니다.

그러나 LEN을 사용하는 시리얼 장비가 의외로 많습니다. 데이터가 모두 텍스트라면 더욱 이해가 안 되는데요, 이런 경우 굳이 LEN을 알려주지 않아도 ETX까지만 받으면 되거든요.

그렇다면 통신에서는 LEN을 사용해서는 안 될까요? TCP/IP 소켓 통신에서는 LEN이 편하고 사용해도 안전합니다. TCP 프로토콜이 데이터 손상 없이 송수신되도록 보장해 주거든요. 그래서 TCP/IP 프로토콜에서는 STX·ETX뿐만 아니라 CRC도 필요 없습니다. LEN·DATA·LEN·DATA·LEN·DATA···· 이런 식으로 계속해서 패킷을 송수신할 수 있습니다.

그러나 시리얼은 안전한 통신을 보장해 주는 프로토콜이 없으므로 그 안전 보장을 프로그래머가 직접 코딩으로 구현해야 합니다.

그렇다면 LEN을 왜 사용할까요? Data가 텍스트가 아닌 바이너리를 보낼 때 STX·ETX 코드인 0x02와 0x03이 포함될 수 있어서입니다. 길이 내에 있는 0x02와 0x03은 흐름 제어하는 STX·ETX가 아니라 데이터의 일부라는 것이죠.

이런 이유가 있다고 하더라도 외부 영향을 쉽게 받는 시리얼 통신에서 LEN을 사용하는 것은 특성을 정확히 모르거나 따지지 않고 단순하게 생각하지 않았나 싶습니다.

rs232 시리얼 프로토콜 stx etx

가장 편한 시리얼 통신 패킷은 STX로 시작해서 ETX로 끝나고 CRC로 검증하는 것입니다. 문제는 Data를 수신 중에 STX를 만나면 새롭게 패킷을 수신하도록 초기화해야 하고 ETX가 있다면 수신을 종료해야 합니다. ETX만 확인해도 될 것 같은데 STX도 확인하는 것은 상대방이 언제든지 패킷 전송을 중지하고 새로운 패킷을 전송할 수 있기 때문입니다.

rs232 패킷

이런저런 문제를 피하기 위해서 추천드리는 방법은 DLE 코드를 이용하는 것입니다. STX·ETX만으로 패킷의 시작과 종료로 판단하지 않고 DLE·STX(0x10·0x02)가 연속으로 와야 패킷의 시작이고 DLE·ETX(0x10·0x03)가 같이 와야 패킷의 종료로 판단하는 것이죠. 그래서 Data 내에 0x02이나 0x03이 와도 데이터로 처리할 수 있습니다.

흠~ 이해는 되지만, Data 내에 0x10·0x02 또는 0x10·0x03도 있을 수 있지 않는가? 그렇게 되면 이것도 STX·ETX만 사용했을 때와 다를 것이 없을 것 같은데...

맞습니다. 그래서 전송하는 쪽에서는 Data 내에 DLE 코드에 해당하는 0x10이 있다면 두 번 보냅니다. 0x0f·0x07·0x10·0x02는 DLE·STX로 취급하지만, 0x0f·0x07·0x10·0x10·0x02는 데이터 0x10·0x02로 취급합니다.

시리얼 통신 패킷에는 길이 요소가 필요 없나?

그렇다고 시리얼 통신 패킷에는 길이 요소는 전혀 쓸모가 없느냐? 그것은 아닙니다. 앞서 언급드렸듯이 데이터의 전체 길이를 알려 주는 것이라면 별로 소용이 없겠지만, 데이터 내에 구분이 필요하다면 길이를 사용할 수 있습니다.

rs232 통신 패킷

패킷 안에 데이터가 두 개가 있고 이를 구분하려고 길이 데이터를 사용할 수 있습니다. 이렇게 길이 요소를 사용할 경우 수신자는 반드시 대기 시간(time-out)을 준수해야 합니다. STX를 만났고 길이 값을 확인해 보니 100바이트라고 하겠습니다. 그러면 100바이트를 기다리겠지만, 대기 시간 이상 수신이 없다면 처음부터 다시 STX를 기다리거나 재 요청 등을 해야 합니다.

대기 시간이 너무 짧으면 다음 패킷이 오는 것도 모르고 통신을 중지할 수 있고, 너무 길면 재요청하는데 시간이 걸립니다. 그러므로 적절한 대기 시간을 설정해야 하며, 무책임하게 길이만큼 무한 루프를 돌려서는 안 되겠습니다.

아무리~ 무한대기로 짜는 사람이 있을려고... 하실지 모르지만, 실제로 무작정 기다리는 시스템을 경험했습니다. 통신이 잘 되다가도 먹통이 되는데, 여러 번 연속해서 보내면 그때서야 다시 통신이 되는 장비들이 이런 경우입니다. 길이를 잘못 수신해서 100개를 0xffff로 받으면 65535개를 받을 때까지 대기하는 것이죠. 길이가 두 바이트이면 65535이지만, 만일 4바이트이면...

그래서 최대 길이를 정하고 그 이상의 길이가 들어오면 오류로 판단해서 STX부터 다시 기다리게 하기도 하는데요, 상대방이 무책임하거나 깜빡하고 최대 길이보다 더 많이 보내는 경우가 있습니다. 이렇게 되면 통신이 잘 되다가도 꼭 어떤 명령에서는 실행이 안 됩니다. 로그를 보니 분명히 패킷을 받았는데 처리를 안 했네요. 체크섬까지 이상이 없는데 응답도 안 군요. 뭐가 문제지? 엉뚱한 부분을 뒤적이다가 최대 길이보다 많아서 걸러낸 것을 알게 되었을 때는 이미 아까운 시간을 낭비한 것입니다.

이런 이유로 시리얼 통신으로 데이터를 수신할 때 LEN이 있다고 해도 그 길이만큼 받으려 하지 말고 ETX까지 수신하는 것이 좋습니다. 데이터를 CRC까지 확인해서 옳게 받은 후에 길이를 따지고 데이터를 파싱합니다. 만일, 바이너리 통신이라면 앞서 소개한 DLE 코드를 이용하는 방법 등을 사용합니다.

체크섬은 CRC16

시리얼 통신은 외부 영향에 취약해서 패킷 손상을 항상 염두해야 하는데요, 이를 위해 수신 데이터가 이상이 없는지 확인하기 위한 체크섬(checksum)을 패킷에 담아 보냅니다. 체크섬이 단어 뜻에서 알 수 있듯이 전송하려는 데이터의 바이트 합을 구하는 것인데, 통신 초기에 데이터의 무결성을 확인하는 데 사용해서 인지는 몰라도 지금은 포괄적인 뜻이 된 것 같아요. 즉, 바이트 합뿐만 아니라, XOR 비트 계산, CRC(순환 중복 검사)도 체크섬이라고 하는 것 같은데, 흠~ 이렇게 얘기하면 혼란스러울 것 같은데...

저 같은 경우 체크섬, XOR 비트 연산, CRC16으로 분명히 구분해서 말합니다. 물어볼 때도 "데이터 무결성 확인은 뭐로 할까요?" 이렇게 어려운 말 하지 않습니다. "데이터 뭐로 확인할까요? CRC16?" 이렇게 얘기하지 "체크섬을 뭐로 할까요? CRC16?" 이렇게 물어보지도, 들은 적도 없는 것 같아요. 어쩌면 "체크섬을 하자며 CRC16은 뭐야?" 이렇게 오해하는 분이 있을지도 모르겠습니다. 우물 안에 개구리라서 경험이 없는지도요.

여하튼 문제는 체크섬이 데이터를 검증하는 중요한 값인데 너무 단순하게 계산하는 경우가 많습니다. 체크섬의 단어 뜻대로 바이트 단위로 합계를 구하는 방법은 신뢰도가 그렇게 높지 못합니다. 그것도 상위 바이트는 모두 버리고 하위 바이트 하나만 보내는데 당연히 신뢰성은 더욱 떨어집니다.

더 간단하게는 바이트 단위로 XOR 하기도 합니다. XOR만 하니 너무 단순한 것 같아서 +1을 하기도 하는데, 더하기를 하나 빼기를 하나 허술하기는 마찬가지입니다. XOR 비트 계산이나 합계를 구하는 체크섬이나 도긴개긴입니다.

체크섬이라면 꼭 CRC16을 사용하시기를 권합니다. 복잡할 것 같다? 어떻게 만들지? 걱정하실 것 하나 없습니다. 이미 많이들 사용해서 조금만 검색하면 공개된 CRC16 함수를 쉽게 구할 수 있습니다. 할 일이 정말 없어서 심심하다면 모를까 CRC16 함수 구현 방법을 학습해서 직접 짤 필요가 없습니다. 이미 공개되고 검증된 루틴이 많은데 헛수고입니다.

CRC16을 사용하는 경우가 많아서 무결성 확인 방법을 물어볼 때도 간단히 "CRC16 사용하시나요?" 이렇게 확인하듯 물어봅니다. 다만, CRC16을 구하는 알고리듬이 여러 개라서 상대방하고 맞추어야 합니다. CRC16-CCITT, MODBUS, KERMIT 등등 여러 가지인데요, 저의 경우 CRC16-MODBUS를 많이 사용했고 요청받았습니다. 아래의 링크는 모드버스에서 사용하는 CRC16 루틴이 들어있습니다.

위 소스에서 아래의 항목만 소스에 붙여 넣기 해서 CRC16 코드만 사용할 수 있습니다.

static const unsigned char auchCRCHi = {}
static const char auchCRCLo[] = {}
void CRC16 ( uint8_t *puchMsg, int usDataLen, uint8_t *CRCHi, uint8_t *CRCLo ) {}

52행에서 112행까지 루틴을 사용하면 됩니다. 아래는 CRC16() 함수를 호출한 예입니다.

typedef unsigned char      uint8_t;

int main( void)
{
    char rs_data[] =  { 0x63, 0x00, 0x00, 0x00, 0x0f, 0x27, 0x00, 0x00, 0x62, 
                       0x61, 0x64, 0x61, 0x79, 0x61, 0x6b, 0x2e, 0x63, 0x6f, 
                       0x6d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
                       0x00};   // 전송할 데이터
    uint8_t     h_crc;
    uint8_t     l_crc;

    CRC16( ( uint8_t *)rs_data, sizeof( rs_data), &h_crc, &l_crc);
    printf( "crc=0x%x 0x%x\n", h_crc & 0xff, l_crc & 0xff);
}

실행하면 아래와 같이 CRC 상위와 하위 값이 출력됩니다.

$ ./a.out
crc=0x30 0x2e
$

데이터를 수신했는데 CRC에러가 발생한다면 온라인 CRC 계산기로 확인해 보세요. 아래의 사이트는 한 번에 여러 알고리즘의 값을 확인할 수 있습니다.

CRC16 계산기

▲ 16진수로 확인할 때는 16진수 값을 공백 없이 주욱~ 입력하면 됩니다. 위 예제 코드에 사용한 데이터를 넣고 계산해 보니 모드버스 항목의 값이 일치합니다. 만일 0x302E가 아니고 0x34E5가 나온다면 모드버스가 아니라 CCITT 알고리듬을 사용한 것입니다.

정리하면 바이너리 데이터를 보낸다고 해서 데이터 길이 LEN을 보내지 말고 DLE 코드를 활용하고 체크섬은 반드시 CRC16을 사용하는 것이 좋습니다. 만일 LEN이 꼭 필요하다면 ETX로 데이터 수신을 완료할 수 있도록 패킷을 구성합니다. 데이터를 모두 수신하기 전까지 타임아웃을 확인하고 재전송을 요청하는 등의 프로토콜의 원활한 흐름을 유지하는데 노력해야 합니다.

이 댓글을 비밀 댓글로
  1. 포스팅 잘보고갑니다
    편안한 하루 보내세요 ^^