리눅스에서 C++로 Serial 통신 시작하기

대상: 우분투에서 C++로 직접 시리얼 포트를 열고, 데이터를 주고받는 최소 예제를 보고 싶은 개발자
환경: Ubuntu 18.04, g++(또는 clang++), 터미널 사용 가능, /dev/ttyUSB0 또는 /dev/ttyACM0 포트 존재

이전 글(A3)에서 우분투에서 시리얼 장치를 연결하고,
/dev/ttyUSB0 / /dev/ttyACM0 포트 이름을 찾고, 권한까지 설정하는 방법을 정리했었다.

이제 한 단계 더 나가서,
C++ 코드로 직접 시리얼 포트를 열고, 데이터를 주고받는 최소 예제를 만들어보자.

이 글에서는:

  • 리눅스에서 시리얼 통신을 다룰 때 자주 쓰는 termios 개념을 간단히 보고
  • C++로 포트를 열고, 속도(baudrate)와 옵션들을 설정하고
  • 간단한 송·수신 루프를 도는 샘플 코드를 작성하고
  • 실제로 빌드/실행하는 방법까지

를 한 번에 정리한다.


1. 리눅스 시리얼 통신과 termios 개념 간단 정리

리눅스에서 시리얼 포트를 제어할 때는 보통 POSIX API를 사용한다.

대표적으로:

  • open() / close()
  • read() / write()
  • tcgetattr() / tcsetattr()
  • cfsetispeed() / cfsetospeed()

이 함수들이 들어 있는 구조체가 바로 termios다.

우리가 해야 할 일은 대략 이렇게 정리할 수 있다.

  1. open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NONBLOCK)
    → 장치를 파일처럼 연다.
  2. tcgetattr(fd, &tty)
    → 현재 설정을 읽어온다.
  3. cfsetispeed(&tty, B115200); / cfsetospeed(&tty, B115200);
    → 통신 속도를 설정한다.
  4. 패리티, 데이터 비트, 정지 비트, 흐름 제어 등 옵션을 설정한다.
  5. tcsetattr(fd, TCSANOW, &tty);
    → 설정을 적용한다.
  6. read() / write()를 이용해서 데이터를 주고받는다.

이번 글에서는 가장 많이 사용하는 설정인 115200 8N1(115200 baud, 8 data bits, No parity, 1 stop bit)을 기준으로 예제를 작성할 것이다.


2. 예제 프로젝트 디렉토리 만들기

먼저 샘플 코드를 넣을 디렉토리를 하나 만들자.

mkdir -p ~/dev/serial-sample
cd ~/dev/serial-sample

이 안에 serial_example.cpp 파일을 하나 만들고,
아래 코드를 그대로 넣어주면 된다.


3. C++ 전체 샘플 코드 (serial_example.cpp)

이 코드는 /dev/ttyUSB0 포트를 115200 8N1로 열고,
터미널에서 입력한 문자열을 시리얼 포트로 보내고,
동시에 시리얼 포트에서 들어오는 데이터를 읽어서 화면에 출력하는 최소 예제다.

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>     // read, write, close
#include <fcntl.h>      // open
#include <termios.h>    // termios
#include <errno.h>      // errno

// 시리얼 포트를 설정하는 함수
bool setupSerialPort(int fd, speed_t baudrate)
{
    struct termios tty;
    std::memset(&tty, 0, sizeof(tty));

    // 현재 포트 설정 읽기
    if (tcgetattr(fd, &tty) != 0)
    {
        std::cerr << "[ERROR] tcgetattr() failed: " << std::strerror(errno) << std::endl;
        return false;
    }

    // 입력/출력 보드레이트 설정
    if (cfsetispeed(&tty, baudrate) != 0 || cfsetospeed(&tty, baudrate) != 0)
    {
        std::cerr << "[ERROR] cfsetispeed/cfsetospeed() failed: " << std::strerror(errno) << std::endl;
        return false;
    }

    // 로컬 모드, 수신 가능 설정
    tty.c_cflag |= (CLOCAL | CREAD);

    // 데이터 비트: 8비트
    tty.c_cflag &= ~CSIZE;
    tty.c_cflag |= CS8;

    // 패리티 비트: 없음
    tty.c_cflag &= ~PARENB;

    // 정지 비트: 1비트
    tty.c_cflag &= ~CSTOPB;

    // 하드웨어 흐름 제어: 사용 안 함
    tty.c_cflag &= ~CRTSCTS;

    // Canonical 모드(라인 단위 처리) 끄기 → Raw 모드
    tty.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);

    // 소프트웨어 흐름 제어 끄기
    tty.c_iflag &= ~(IXON | IXOFF | IXANY);

    // 출력 처리 옵션 끄기
    tty.c_oflag &= ~OPOST;

    // 읽기 타임아웃 설정 (Non-blocking 모드와 조합 가능)
    tty.c_cc[VMIN]  = 0;   // 읽기 최소 문자 수
    tty.c_cc[VTIME] = 10;  // 타임아웃 시간 (단위: 0.1초 → 10 = 1초)

    // 변경된 설정을 즉시 적용
    if (tcsetattr(fd, TCSANOW, &tty) != 0)
    {
        std::cerr << "[ERROR] tcsetattr() failed: " << std::strerror(errno) << std::endl;
        return false;
    }

    return true;
}

int main()
{
    const char* device = "/dev/ttyUSB0";  // 필요한 경우 /dev/ttyACM0 등으로 변경
    speed_t baudrate = B115200;

    std::cout << "[INFO] Opening serial port: " << device << std::endl;

    // O_NOCTTY: 이 장치를 컨트롤 터미널로 사용하지 않음
    // O_NONBLOCK: Non-blocking 모드 (read/write가 즉시 반환)
    int fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK);
    if (fd < 0)
    {
        std::cerr << "[ERROR] Failed to open " << device
                  << ": " << std::strerror(errno) << std::endl;
        return -1;
    }

    if (!setupSerialPort(fd, baudrate))
    {
        close(fd);
        return -1;
    }

    std::cout << "[INFO] Serial port opened and configured (115200 8N1)." << std::endl;
    std::cout << "[INFO] Type something and press Enter to send. Type 'exit' to quit." << std::endl;

    // 표준입력 non-block 모드는 사용하지 않고, 간단히 getline 사용
    // 시리얼 수신은 폴링 방식으로 짧게 읽어오는 구조
    bool running = true;
    char rxBuf[256];

    while (running)
    {
        // 1) 시리얼에서 들어온 데이터 읽기
        ssize_t bytesRead = read(fd, rxBuf, sizeof(rxBuf));
        if (bytesRead > 0)
        {
            std::string received(rxBuf, rxBuf + bytesRead);
            std::cout << "[RX] " << received << std::flush;
        }

        // 2) 터미널에서 사용자 입력이 있는지 확인 (blocking getline)
        //    간단한 예제에서는 "입력 → 전송 → 수신" 패턴으로 사용해도 충분하다.
        std::string line;
        std::cout << "
[TX] ";
        if (!std::getline(std::cin, line))
        {
            // 입력 스트림이 끊어진 경우 (Ctrl+D 등)
            std::cout << "
[INFO] stdin closed. Exiting..." << std::endl;
            break;
        }

        if (line == "exit")
        {
            std::cout << "[INFO] 'exit' command received. Exiting..." << std::endl;
            running = false;
        }

        line += "\r\n";  // CRLF 종료 (보드에서 이 형식을 기대하는 경우 많음)

        ssize_t bytesWritten = write(fd, line.data(), line.size());
        if (bytesWritten < 0)
        {
            std::cerr << "[ERROR] Failed to write: " << std::strerror(errno) << std::endl;
        }
        else
        {
            std::cout << "[INFO] Sent " << bytesWritten << " bytes." << std::endl;
        }
    }

    close(fd);
    std::cout << "[INFO] Serial port closed." << std::endl;

    return 0;
}

이 코드는 테스트용으로는 충분히 쓸 수 있는 수준이고,
나중에 클래스 형태로 리팩터링할 때도 그대로 가져다가 사용할 수 있다.


4. 빌드 방법 (g++ 사용 예시)

위 예제를 저장했다면, 같은 디렉토리에서 아래 명령으로 빌드할 수 있다.

cd ~/dev/serial-sample

g++ serial_example.cpp -o serial_example

에러 없이 빌드가 되면 serial_example 실행 파일이 생긴다.

필요하다면 -Wall -Wextra -O2 같은 옵션을 추가해서 경고를 더 보고,
최적화 옵션도 켜줄 수 있다.

예:

g++ -Wall -Wextra -O2 serial_example.cpp -o serial_example

5. 예제 실행 전 체크리스트

실행하기 전에, 아래 항목을 한 번씩 확인해보자.

  1. 시리얼 장치 연결됨
    • USB-시리얼 케이블, 보드 콘솔 포트 등
  2. 장치 이름 확인
    • dmesg | tail -n 30
    • 또는 ls /dev/ttyUSB* /dev/ttyACM*
  3. 권한 문제 해결됨
    • ls -l /dev/ttyUSB0
    • dialout 그룹에 속해 있는지 (groups 명령)
    • 필요시 sudo usermod -aG dialout $USER 후 재로그인

이 조건이 만족됐다면 이제 실행해보자.


6. 예제 실행 및 동작 확인

실행은 단순하다.

./serial_example

정상적으로 포트를 열었다면, 대략 이런 로그가 나올 것이다.

[INFO] Opening serial port: /dev/ttyUSB0
[INFO] Serial port opened and configured (115200 8N1).
[INFO] Type something and press Enter to send. Type 'exit' to quit.

[TX]

이 상태에서:

  1. 보드/장치가 115200 8N1로 설정되어 있고,
  2. 해당 장치가 받은 데이터를 다시 에코하거나, 주기적으로 메시지를 보내고 있다면,

아래처럼 [RX] 로그가 출력될 수 있다.

[RX] Hello from device!

[TX] test
[INFO] Sent 6 bytes.

보드에서 다시 에코를 돌려주도록 설정되어 있다면,
다시 [RX] test 같은 문자열이 보일 것이다.

만약 아무 데이터도 안 들어온다면,
보드의 시리얼 설정(baudrate, 패리티) 또는 핀 연결(Tx/Rx 크로스, GND 공통) 등을 다시 확인해보자.

종료는 exit라고 입력한 뒤 Enter를 누르면 된다.


7. 코드에서 바꾸기 쉬운 포인트들

실제 프로젝트에 맞추려면 아래 부분들을 자주 바꾸게 될 것이다.

7-1. 포트 이름

const char* device = "/dev/ttyUSB0";

필요에 따라:

  • /dev/ttyACM0
  • /dev/ttyS0 (온보드 시리얼 포트)
  • udev 규칙으로 만들어 둔 /dev/ttyMOTOR, /dev/ttyGPS 같은 이름

으로 바꿔주면 된다.

7-2. 통신 속도(baudrate)

speed_t baudrate = B115200;

이 부분은 다음과 같은 매크로로 바꿀 수 있다.

  • B9600
  • B19200
  • B38400
  • B57600
  • B115200

당연히 보드/장치 쪽 설정과 동일하게 맞춰야 통신이 제대로 된다.

7-3. 블로킹/논블로킹 모드

지금은 open()O_NONBLOCK을 주고,
read()VMIN=0, VTIME=10 설정으로 “최대 1초 대기 후 리턴” 형태로 사용하고 있다.

좀 더 정교한 동작이 필요하다면:

  • select()poll()을 사용해서
    • 시리얼 fd와 stdin(표준입력)을 동시에 감시하거나,
  • 완전 블로킹 모드(O_NONBLOCK 제거, VMIN/VTIME 조정)로 사용하도록 바꾸는 것도 가능하다.

8. 실제 프로젝트에서 클래스로 감싸고 싶을 때

실제 회사 코드나 샘플 프로젝트에서는
위처럼 모든 내용을 main() 안에 넣기보다는, 보통 아래와 같이 구조를 나눈다.

  • SerialPort 클래스
    • open(), close()
    • read(), write()
    • setBaudrate(), setOption()

다음 단계에서는 이 예제를 기반으로:

  • SerialPort 클래스로 분리하고
  • 나중에 카메라 + 시리얼 통합 샘플 코드에서 바로 가져다 쓸 수 있게

리팩터링한 구조도 한 번 정리해볼 예정이다.


9. 정리

이번 글에서 한 일은 한 줄로 요약하면 이거다.

“우분투 18.04에서 C++로 시리얼 포트를 열고,
터미널 ↔ 장치 간에 문자열을 주고받는 최소 예제를 만들었다.”

앞으로는:

  1. 새 보드/장치를 받으면,
  2. 앞에서 정리한 순서대로 포트/권한을 체크하고,
  3. 이번 글의 serial_example 같은 테스트 코드를 돌려보면서

하드웨어가 정상인지, 통신 설정이 맞는지,
그리고 이후에 쓸 상위 로직(프로토콜/명령/응답 설계)까지 자신 있게 진행할 수 있을 거다.

댓글 남기기