본문 바로가기
Hardware/MCU(Arduino,ESP8266)

UART 시리얼 통신의 기본

by lovey25 2023. 1. 25.
반응형

아두이노에서 여러 기기들 간의 통신에 시리얼 통신(Serial Communication)을 많이 사용합니다. 저도 시리얼(Serial or UART)로 정보를 주고받는 기기들을 종종 사용하긴 하지만 대부분의 경우 라이브러리를 사용하기 때문에 깊이 있는 이해가 부족했습니다. 그러다 보니 문제가 생기면 어디서부터 접근해야 할지 막막한 경우가 생기더군요.  저도 이번에 시리얼 포트로 연속적으로 수신되는 데이터를 처리해야 하는데 생각처럼 잘 되지 않았고 그래서 시리얼에 대해서 되도록이면 기초부터 공부를 해야 할 필요를 느껴서 정리를 좀 했습니다.

시리얼 통신은 직렬 통신

시리얼(Serial) 통신이라는 건 전달해야 할 데이터를 직렬로 전달하는 방법을 지칭하는데요. 직렬이라는 건 데이터를 순서대로 차례차례 주고받는 방식을 의미합니다. 직렬이라는 용어가 있다는 건 병렬도 있다는 건데 병렬은 여러 개의 채널을 이용해서 데이터를 한꺼번에 보내는 방식을 의미합니다. 아래 그림을 보면 그 차이가 어떤 건지 바로 감이 오겠죠.

직렬 vs 병렬 통신

그리고 시리얼 통신 방법에는 UART, SPI, I2C 등 여러 가지 방법이 있습니다. MCU는 0과 1의 디지털 신호를 처리하는데 내부적으로는 이 값이 0V(GND)와 5V(or 3.3V 등 VCC)의 전압값으로 처리됩니다. 전선으로 연결된 양단에 어떤 데이터가 전송된다면 한쪽에 걸린 전압을 다른 쪽에서 확인하는 방법으로 지금 신호가 0(GND)인지 1(VCC)인지 확인할 수 있습니다. 그러나 정확한 데이터를 수신하기 위해서는 보내는 쪽과 받는 쪽에 어떤 규칙이 있어야 합니다.

예를 들어 A에서 "10101"이라는 데이터를 B로 보낸다고 생각을 해보겠습니다. 송신자는 스위치를 껐다가 켜는 동작으로 0과 1을 만들어냅니다. 발생되는 데이터를 시간에 따른 전압의 변화로 그려보면 송신되는 원본 데이터는 왼쪽 차트의 빨간색 그래프와 같습니다. 이때 송신자는 1초 간격으로 5초간 켜고 끄는 동작을 반복했습니다. 그러나 만약 수신 측에서 2초에 한 번씩 전압을 확인한다면 어떨까요? 같은 차트에 전압을 측정하는 시점을 표시해 보면 파란색 화살표와 같겠죠. 처음에는 5V 전압을 인식하고 다음 전압 확인 시간인 2초 후에도 똑같이 5V를 인식합니다. 소스 전압은 이미 한번 꺼졌다 켜지는 동작이 있었지만 수신자는 알 방법이 없고 여전히 켜져 있는 걸로 인식할 수밖에 없습니다. 반대로 2초가 아니라 0.5초에 한 번씩 더 자주 확인한다면 어떨까요? 이경우 실제로 보낸 데이터는 "10101"이지만 수신된 데이터는 "1100110011"로 원래 데이터가 외곡이 됩니다.

보율(baudrate)

따라서 UART 시리얼 통신은 스위치를 켜고 끄는데 어느 정도 간격을 둘지 약속을 정하는데 이 규칙을 보율(baudrate)라고 합니다. 스위치가 꺼졌다가 켜지고 다시 꺼지는 동작을 변조라고 하는데 보율은 1초 동안 전송되는 데이터의 비트 수인 변조 속도를 나타내는 단위입니다. 처음에는 데이터 전송 속도 단위인 bps(bit per second)와 같은 의미로 사용되었지만 최근에는 변조 1번에 1비트 이상의 정보 전달이 가능해져서 bps는 보율보다 더 큰 값을 가지기도 합니다.

Start bit, Stop bit

시리얼 통신에서는 변조 시간을 동기화하는 것 말고도 또 하나 중요한 약속이 있습니다. 바로 데이터를 언제 보내는 지와 보낸 데이터의 끝이 어디인지를 정하는 건데요. 이를 위해서 UART에서는 전송하려는 데이터 앞에 시작 비트인 '0'을 추가하고 끝에 정지 비트 '1'을 추가해서 사용합니다. UART의 통신 단위는 1Byte(8bit)이니까 앞뒤에 2bit가 추가되어서 10bit 단위로 데이터가 전달됩니다.

데이터를 보내지 않는 대기 상태에서는 항상 '1' 상태를 유지하다가 데이터가 전송될 때 시작 비트인 '0'으로 변해서 데이터의 시작을 알립니다. 그리고 전달되는 정보에 따라 '0'과 '1' 8개가 전송된 후 마지막으로 10번째를 정지 비트인 '1'을 전송함으로써 데이터 전송의 완료를 알립니다.

UART는 전이중(Full duplex) 통신

앞에서 얘기한 방법으로 데이터를 주거나 받기 위해서 1개의 전선(채널)이 필요합니다. 이 말은 1개의 전선으로는 데이터를 보내거나 받는걸 동시에 할 수 없다는 말이죠. 그래서 UART는 2개의 채널을 하나로 묶어서 동시 송수신을 구현합니다. 이런 방법을 전이중(Full duplex) 방식이라고 합니다. 그리고 전선에서 데이터를 보내는 쪽 핀을 Tx(Transmit), 데이터를 받는 쪽 핀을 Rx(Receive)로 표시합니다. 그래서 장치 간에 시리얼 통신을 연결하려면 Tx는 Rx로 그리고 Rx는 Tx로 서로 교차되도록 연결합니다.

아두이노의 시리얼 통신

일단 아두이노에서 사용하는 시리얼 관련하여 기본적인 정보는 아래 문서에서 시작하는 게 좋을 것 같습니다. 각 보드 별로 어떤 시리얼 포트가 사용 가능하며 아두이노 플랫폼에서 지원하는 관련 함수는 어떤 것이 있는지 알 수 있습니다. 

 

Serial - Arduino Reference

Description Used for communication between the Arduino board and a computer or other devices. All Arduino boards have at least one serial port (also known as a UART or USART), and some have several. Board USB CDC name Serial pins Serial1 pins Serial2 pins

www.arduino.cc

아두이노로 들어오는 데이터는 차례대로 버퍼에 저장이 되는데 이때 데이터가 들어오는 속도는 시리얼 포트가 연결된 보율에 따라서 결정됩니다. 만약 아스키코드의 문자를 사용한다면 9600 baud rate는 76.8 kbps와 같다고 볼 수 있습니다.

문자 데이터 전송

아래 예제를 보겠습니다. 시리얼 포트로 들어오는 정보가 있으면 'buf'라는 배열에 문자열로 저장하도록 하는 코드입니다.

const byte num = 32;
char buf[num];   // an array to store the received data
static byte i = 0;
char rc;

Serial.begin(9600);

while (Serial.available() > 0) {
  rc = Serial.read();

  if (rc != '\n') {
    buf[i] = rc;
    i++;
    if (i >= num) {
      i = num - 1;
    }
  }
  else {
    buf[i] = '\0'; // terminate the string
    i = 0;
  }
}

시리얼 통신으로 들어오는 데이터는 아두이노의 버퍼에 차례대로 저장이 된다고 했는데 "Serial.available()" 함수는 그 버퍼에 얼마나 저장이 되어 있는지를 확인하는 함수입니다. 반환 값은 버퍼에 저장된 데이터의 양입니다. 그래서 반환 값이 "0" 이상이라는 건 뭔가가 접수되었다는 거죠.

그리고 Serial.read() 함수는 버퍼에 저장된 첫 번째 문자 값을 확인하고 반환합니다. 그리고 나서가 중요한데 읽어온 문자는 날아갑니다. 그러니까 위 코드에서 read() 함수는 while문 안에서 계속 반복이 되고 있는데 버퍼에 저장된 데이터를 앞에서부터 차례대로 읽어서 하나씩 지워 가다 보면 언젠가 버퍼의 끝을 만날 것이고 그러면 available() 함수는 0을 반환할 겁니다. 그러면 while문을 벗어날 수 있는 거죠.

이렇게 읽어온 데이터는 "buf"라는 배열에 복사가 됩니다. 그리고 buf의 크기는 32로 설정했습니다. 32바이트 이내의 데이터를 받는 경우라면 위 코드를 사용하는데 아무런 문제가 없습니다. 그런데 만약 그 이상의 데이터를 받아야 하는 경우라면 어떨까요? 물론 필요한 만큼 그 숫자를 키워서 사용할 수 있을 텐데요. 문제는 아두이노의 코어 코드는 버퍼의 크기를 기본적으로 64byte로 제한하고 있습니다. 만약 64byte 이상의 큰 데이터가 한꺼번에 들어오면 atmega 프로세서가 처리하지 못하기 때문에 제한을 했다고 볼 수 있겠죠. 물론 이마저도 더 늘려서 쓸 수는 있습니다만 거기까지는 제가 필요한 경우가 아니라서 그냥 넘어가고 만약 관련 내용이 필요하시다면 아래 링크 참고해 보세요.

 

https://www.hobbytronics.co.uk/arduino-serial-buffer-size

Arduino Serial Port Buffer Size Mod Arduino Serial Port Buffer Size Mod Whilst developing the software for our Arduino based Serial Graphic TFT Display a problem with transmitting too much data at once occured. When using our TFT display we expected the co

www.hobbytronics.co.uk

시리얼에 대한 내용을 보다 보니 문득 이런 생각이 드네요. 만약 시리얼로 들어오는 데이터의 속도가 엄청 빨라서 아두이노가 처리하는 데이터 양을 넘어서서 위의 예제 코드를 사용했을 때 버퍼가 줄어들지 않고 오히려 늘어나는 경우라면 어떻게 될까요? 1 채널인 시리얼로 들어오는 데이터조차 처리하지 못한다는 건 왠지 말이 되지 않는 상황인 거 같긴 하지만 혹시나 해서 그런 내용이 있는지 검색을 해 봤습니다. 그런데 역시나 그런 이슈는 쉽게 찾을 수는 없었습니다. 쓸데없는 걱정이었나 봐요. 

바이너리 데이터 전송

앞에서는 문자 데이터를 받아오는 방법을 알아봤습니다. 문자 데이터라는 말은 예를 들어 107이라는 숫자는 문자 1, 0, 7으로 이루어져 있습니다. 그런데 107은 아스키코드에서 영어 소문자 'k'를 나타내고 16진수와 2진수로는 각각 0x68,  01101011로 표현할 수 있습니다. 이런 식으로 문자로 해석될 수 있는 규칙을 가지고 구성된 데이터를 문자 데이터라고 합니다. 그런데 이 문자 데이터도 실제로는 0과 1로만 된 이진데이터로 저장되고 전송되겠죠. 바로 이 이진수로 된 데이터를 바이너리 데이터라고 합니다.

바이너리 데이터를 처리하기 위해서는 한 가지 중요한 규칙이 필요합니다. 바로 데이터의 시작점과 데이터의 크기입니다. 1과 0으로만 된 줄줄이 소시지를 해석하기 위해서는 데이터가 시작하는 위치를 정확히 특정할 수 있어야 합니다. 그리고 동시에 어디까지를 잘라서 읽어야 하는지도 알아야겠죠. 1비트만으로 boolean 데이터를 표현할 수도 있고 8비트로 된 UTF8 데이터로도 표현할 수 있기 때문에 송신자와 수신자 간의 약속이 필요합니다.

아래 코드는 바이너리 데이터를 수신하는 예제입니다. 엄밀히 말하면 시작과 끝이 정의된 패킷 수신하면 패킷 단위로 분리하고 분리된 패킷을 16진수로 화면에 표시합니다.

const byte numBytes = 32;
byte receivedBytes[numBytes];
byte numReceived = 0;

boolean newData = false;

void setup() {
    Serial.begin(9600);
}

void loop() {
    recvBytesWithStartEndMarkers();
    showNewData();
}

void recvBytesWithStartEndMarkers() {
    static boolean recvInProgress = false;
    static byte ndx = 0;
    byte startMarker = 0x3C;
    byte endMarker = 0x3E;
    byte rb;

    while (Serial.available() > 0 && newData == false) {
        rb = Serial.read();

        if (recvInProgress == true) {
            if (rb != endMarker) {
                receivedBytes[ndx] = rb;
                ndx++;
                if (ndx >= numBytes) {
                    ndx = numBytes - 1;
                }
            }
            else {
                receivedBytes[ndx] = '\0'; // terminate the string
                recvInProgress = false;
                numReceived = ndx;  // save the number for use when printing
                ndx = 0;
                newData = true;
            }
        }

        else if (rb == startMarker) {
            recvInProgress = true;
        }
    }
}

void showNewData() {
    if (newData == true) {
        Serial.print("This just in (HEX values)... ");
        for (byte n = 0; n < numReceived; n++) {
            Serial.print(receivedBytes[n], HEX);
            Serial.print(' ');
        }
        Serial.println();
        newData = false;
    }
}

앞에서와 동일하게 시리얼 포트로 들어오는 데이터 스트림을 한 바이트씩 읽어내는 건 동일합니다. 다만 이번에는 "recvBytesWithStartEndMarkers()" 함수가 Start/End Maker를 인식해서 데이터셋을 구분해주고 있습니다. 이 코드에서는 Start End marker를 각각 '0x3C', '0x3E'로 정의했는데 아스키코드로는 "<"과 ">" 기호를 의미합니다. 따라서 데이터 스트림을 하나씩 읽다가 "<"기호가 나타나면 새로운 데이터 인식해서 문자열에 복사를 하다가 ">"기호를 만나면 하나의 데이터 세트가 끝난 것으로 보고 "\0"값을 넣어서 문자열을 종료합니다. 이렇게 복사된 문자열은 showNewData() 함수가 16진수로 시리얼 모니터에 출력합니다.

마무리

시리얼 통신에 대한 가장 기본적인 내용들을 살펴봤습니다. 시리얼 통신을 활용하는 방법에는 매우 다양한 방법이 있고 깊이 들어가면 이해하기 힘든 어려운 부분도 많이 있지만, 기초적인 몇 가지만 알아도 프로젝트에 충분히 유용하게 사용할 수 있습니다. 그래서 아두이노를 다루게 되면 기기간에 데이터 전송은 일단 시리얼 통신으로 시작하게 되는데 이런 기본적인 개념에 좀 익숙해지고 나니 예제 코드를 보는 것도 더 수월해지는 것 같습니다. 다시 말해 코드를 더 잘 베끼게 된다는 얘기였습니다. ㅎ

 

끝!

반응형

댓글