대상: 우분투에서 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다.
우리가 해야 할 일은 대략 이렇게 정리할 수 있다.
open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NONBLOCK)
→ 장치를 파일처럼 연다.tcgetattr(fd, &tty)
→ 현재 설정을 읽어온다.cfsetispeed(&tty, B115200);/cfsetospeed(&tty, B115200);
→ 통신 속도를 설정한다.- 패리티, 데이터 비트, 정지 비트, 흐름 제어 등 옵션을 설정한다.
tcsetattr(fd, TCSANOW, &tty);
→ 설정을 적용한다.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. 예제 실행 전 체크리스트
실행하기 전에, 아래 항목을 한 번씩 확인해보자.
- 시리얼 장치 연결됨
- USB-시리얼 케이블, 보드 콘솔 포트 등
- 장치 이름 확인
dmesg | tail -n 30- 또는
ls /dev/ttyUSB* /dev/ttyACM*
- 권한 문제 해결됨
ls -l /dev/ttyUSB0dialout그룹에 속해 있는지 (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]
이 상태에서:
- 보드/장치가 115200 8N1로 설정되어 있고,
- 해당 장치가 받은 데이터를 다시 에코하거나, 주기적으로 메시지를 보내고 있다면,
아래처럼 [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;
이 부분은 다음과 같은 매크로로 바꿀 수 있다.
B9600B19200B38400B57600B115200등
당연히 보드/장치 쪽 설정과 동일하게 맞춰야 통신이 제대로 된다.
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++로 시리얼 포트를 열고,
터미널 ↔ 장치 간에 문자열을 주고받는 최소 예제를 만들었다.”
앞으로는:
- 새 보드/장치를 받으면,
- 앞에서 정리한 순서대로 포트/권한을 체크하고,
- 이번 글의
serial_example같은 테스트 코드를 돌려보면서
하드웨어가 정상인지, 통신 설정이 맞는지,
그리고 이후에 쓸 상위 로직(프로토콜/명령/응답 설계)까지 자신 있게 진행할 수 있을 거다.