hello, robot

파이썬 라이브러리가 대신 해 주던 시리얼 계층을 C++로 직접 지으면, 그 한 줄이 실제로 무엇을 하고 있었는지가 눈에 보이는 코드가 돼요. 포트를 열고, 설정하고, 흘러오는 바이트를 프레임으로 복원해 위로 올리는 일이에요.

리눅스에서 시리얼 포트는 파일이에요

USB-시리얼 어댑터를 꽂으면 /dev/ttyUSB0, 보드가 USB CDC-ACM으로 잡히면 /dev/ttyACM0 같은 장치 파일이 생기고, 이 파일을 여는 것이 곧 포트를 여는 거예요.

int fd = ::open("/dev/ttyACM0", O_RDWR | O_NOCTTY);
if (fd < 0) {
    // errno에 이유가 담긴다 — EACCES면 권한, ENOENT면 장치 없음
}

O_NOCTTY는 “이 포트를 내 제어 터미널로 삼지 말라”는 뜻이에요. 시리얼 장치는 역사적으로 로그인 단말이라, 이 플래그가 없으면 프로세스가 포트를 자기 터미널로 붙잡는 부작용이 생길 수 있어요.

처음 겪는 실패는 대부분 권한이에요. 장치 파일의 소유 그룹이 dialout이라 계정이 그 그룹에 없으면 열리지 않아요. 그룹에 넣고 재로그인하는 게 정석이고, 매번 관리자 권한으로 실행하는 임시방편은 습관이 되면 곤란해요. 프로그램 전체가 루트로 도는 것과 그룹 하나에 드는 것은 크기가 다른 조치예요.

윈도우와 갈리는 점이 하나 있어요. 리눅스 tty는 기본이 배타가 아니라서 두 프로세스가 같은 포트를 동시에 열 수 있어요. 이때는 에러 대신 더 고약한 일이 생겨요. 도착한 바이트가 두 리더에게 나뉘어 들어가 양쪽 다 반쪽짜리 데이터를 봅니다. ioctl(fd, TIOCEXCL)로 배타 잠금을 걸면 두 번째 열기가 실패해서, 조용한 데이터 분실이 시끄러운 에러로 바뀌어요.

기본 모드는 로봇 데이터에 재앙이에요

포트를 열었다고 끝이 아니에요. tty 드라이버의 기본은 사람이 타이핑하는 단말을 위한 모드라, 켜져 있는 가공이 데이터를 망가뜨려요. 입력을 그대로 되돌려 보내는 에코가 있어서 내가 보낸 명령이 수신 데이터에 섞여 돌아오고, 캐리지 리턴을 개행으로 바꾸는 변환이 바이너리 바이트를 소리 없이 바꾸고, 줄이 완성될 때까지 read가 돌아오지 않아요.

“데이터가 깨진다”, “이상한 게 섞여 온다”, “read가 안 돌아온다” 증상이 나면 십중팔구 이 기본 가공이 원인이에요.

설정은 읽어 와서 고치고 되쓰는 세 단계예요. 구조체를 0에서 만들어 채우면 모르는 필드가 엉뚱한 값이 되니 읽어 오는 쪽이 안전해요.

termios tio{};
tcgetattr(fd, &tio);            // 현재 설정을 읽어 와서 고친다
cfmakeraw(&tio);                // 에코·개행 변환·줄 편집 전부 끔
tio.c_cflag |= CREAD | CLOCAL;  // 수신 활성화 · 모뎀 제어선 무시
tio.c_cflag &= ~PARENB;         // 패리티 없음
tio.c_cflag &= ~CSTOPB;         // 스톱 비트 1 → 8N1
cfsetispeed(&tio, B115200);
cfsetospeed(&tio, B115200);
tio.c_cc[VMIN]  = 0;            // read가 기다릴 최소 바이트 수
tio.c_cc[VTIME] = 1;            // 타임아웃, 0.1초 단위
tcsetattr(fd, TCSANOW, &tio);

8N1은 데이터 8비트·패리티 없음·스톱 1비트라는 뜻이고, 시작 비트까지 합치면 한 바이트가 선 위에서 10비트를 차지해요. 그래서 115200이면 바이트 하나가 약 87마이크로초, 9600이면 약 1밀리초가 걸려요. 이 시간 감각이 다음 절의 함정을 이해하는 열쇠예요.

표기 함정도 하나 있어요. B115200은 숫자 115200이 아니라 termios 상수라, 숫자를 그대로 넣으면 컴파일은 되는데 엉뚱한 설정이 돼요.

VMINVTIMEread가 언제 돌아올지를 정하는 두 손잡이예요.

VMINVTIMEread의 행동어울리는 구조
101바이트라도 올 때까지 무한 대기전용 수신 스레드
010최대 1초 기다리다 빈손으로 반환타임아웃 있는 폴링
00기다리지 않고 즉시 반환제어 루프 안 논블로킹 폴링

read는 프레임 단위로 돌아오지 않아요

이 주제에서 가장 중요한 자리예요. 시리얼은 바이트 스트림이라 read가 돌려주는 단위와 프로토콜의 메시지 단위는 아무 관계가 없어요. 송신 쪽이 "MOVE B 90\n"을 한 번에 보냈어도 수신 쪽 read 한 번이 그걸 통째로 받는다는 보장은 어디에도 없어요.

한 번의 read가 줄 수 있는 것은 셋이에요. 반 줄("MOVE B"), 여러 줄에 반 줄이 붙은 덩어리("OK\nPOS 90\nMO"), 그리고 빈손이요.

앞 절의 시간 감각으로 설명돼요. 바이트는 115200에서 87마이크로초 간격으로 하나씩 도착해 커널 버퍼에 쌓여요. read는 지금 버퍼에 있는 만큼을 집어올 뿐이라, 메시지 중간 시점에 부르면 거기까지 도착한 반 줄만 와요. 반대로 루프가 잠깐 바빴다면 그 사이 도착한 여러 메시지가 한 덩어리로 옵니다.

“명령이 반쯤 도착해서 관절 글자만 오고 숫자가 늦어 0도로 꺾이는” 증상이 이 성질을 무시한 코드의 말로예요.

해법은 프레임 재조립이에요. read가 주는 대로 버퍼에 쌓고, 구분자가 있을 때만 완성 프레임을 잘라내고, 반 줄은 다음 read를 위해 남겨 둬요.

constexpr std::size_t kMaxFrame = 256;
std::string rx_buf_;                     // 부분 수신 상태가 사는 곳
 
void poll_serial(int fd) {
    char tmp[256];
    ssize_t n = ::read(fd, tmp, sizeof(tmp));   // 지금 도착해 있는 만큼만
    if (n > 0) rx_buf_.append(tmp, n);          // 판단 없이 일단 쌓는다
 
    std::size_t pos;
    while ((pos = rx_buf_.find('\n')) != std::string::npos) {   // 완성분만
        std::string frame = rx_buf_.substr(0, pos);
        if (!frame.empty() && frame.back() == '\r') frame.pop_back();
        rx_buf_.erase(0, pos + 1);              // 꺼낸 만큼 삭제
        handle_frame(frame);                    // 내용 해석은 위층으로
    }
    if (rx_buf_.size() > kMaxFrame)             // 개행 없이 한없이 자란다면
        rx_buf_.clear();                        // 폭주 방어
}

이 구조는 상태가 하나뿐인 상태머신이에요. 버퍼에 남은 반 줄이 그 상태고, read가 몇 바이트를 주든 “쌓는다 → 완성분을 전부 꺼낸다 → 나머지를 상태로 남긴다”라는 같은 전이를 반복하니 세 경우가 분기 없이 처리돼요.

이번 read가 준 것처리 후 버퍼위로 올라간 프레임
"OK\nPOS ""POS ""OK"
"90\nPO""PO""POS 90"
"" (빈손)"PO"없음
"S 45\nERR\n""""POS 45", "ERR"

whileif가 아니라는 점이 중요해요. 마지막 행처럼 한 덩어리에 완성 프레임이 여럿 들어 있을 수 있어서, if로 하나만 꺼내면 나머지가 밀려 처리 지연이 눈덩이처럼 커져요.

폭주 방어도 실전 필수예요. 보드레이트가 어긋났거나 잡음이 섞이면 구분자가 영영 오지 않아서, 방어가 없으면 버퍼가 무한히 자라요.

write도 다 못 쓰고 돌아와요

read의 짝으로 write에도 같은 성질이 있어요. 반환값은 실제로 커널에 넘긴 바이트 수이고, 송신 버퍼에 여유가 그만큼밖에 없으면 요청보다 적게 쓰고 돌아와요. 반환값을 무시하는 습관은 “가끔 명령 뒷부분이 잘리는” 재현 안 되는 버그가 돼요.

bool write_all(int fd, const char* data, std::size_t len) {
    std::size_t off = 0;
    while (off < len) {
        ssize_t n = ::write(fd, data + off, len - off);
        if (n < 0) {
            if (errno == EINTR) continue;   // 시그널로 끊긴 것 — 재시도
            return false;
        }
        off += n;                           // 쓴 만큼만 전진
    }
    return true;
}

하나 더 구분할 게 있어요. write 성공은 “커널 버퍼에 들어갔다”이지 “선으로 다 나갔다”가 아니에요. 115200에서 100바이트는 나가는 데 9밀리초쯤 걸리니, 마지막 명령을 쓰자마자 포트를 닫거나 장치를 리셋하면 꼬리가 잘려요. 송신 버퍼가 실제로 배출될 때까지 기다리는 tcdrain을 닫기 직전에 한 번 부르면 이 부류가 사라져요.

연결이 끊겨도 알려주지 않아요

시리얼의 고약한 점이에요. 상대 보드가 죽거나 신호선이 헐거워져도 read는 에러를 내지 않아요. 그저 데이터가 안 올 뿐이에요. 어댑터가 통째로 뽑히면 시끄럽게 죽어 주지만, 그것만 믿으면 “포트는 멀쩡히 열려 있는데 로봇이 응답하지 않는” 조용한 고장을 놓쳐요.

믿을 수 있는 신호는 하나, 침묵의 길이예요.

using Clock = std::chrono::steady_clock;   // 단조 시계
Clock::time_point last_rx_ = Clock::now();
 
bool link_lost() {
    return Clock::now() - last_rx_ > std::chrono::milliseconds(500);
}

시계는 반드시 단조 시계여야 해요. 벽시계는 시각 동기화로 점프할 수 있어서 경과 측정에 쓰면 워치독이 오판해요. 임계값은 정상 수신 주기의 5–10배가 감각이에요. 15Hz 텔레메트리라면 정상 간격이 67밀리초니 500밀리초 침묵은 명백한 이상이고, 너무 빡빡하게 잡으면 스케줄링 흔들림에도 오탐이 나요.

상실로 판정한 다음 순서도 정해 둬야 해요. 먼저 출력을 안전값으로 돌리고, 그다음 포트를 닫고 재연결을 시도해요. 재시도 간격과 실패 전파는 C++ 에러 처리 — 로봇 노드의 실패 정책의 하드웨어 실패 정책과 이어져요.

fd도 소유권이 있어요

fd는 닫지 않으면 새는 자원이라 C++ 소유권과 수명 — 스마트 포인터의 커스텀 해제 함수와 같은 원리로 감싸요. 다만 포인터가 아니라 정수라서 작은 래퍼 클래스가 자연스러워요.

class SerialPort {
    int fd_ = -1;
public:
    explicit SerialPort(const char* dev) { fd_ = ::open(dev, O_RDWR | O_NOCTTY); }
    ~SerialPort() { if (fd_ >= 0) ::close(fd_); }       // 어떤 경로로 나가도 닫힘
    SerialPort(const SerialPort&) = delete;             // 복사 금지
    SerialPort& operator=(const SerialPort&) = delete;
    int fd() const { return fd_; }
};

복사 금지가 형식 절차가 아니라 안전장치예요. fd가 복사되면 소멸자 둘이 같은 번호를 두 번 닫는데, 첫 close 뒤 커널이 그 번호를 다른 파일에 재사용했다면 두 번째 close는 엉뚱한 파일을 닫아요. 자기 포트가 아니라 남의 로그 파일이 닫히는, 원인 추적이 지옥인 사고예요.

수신 구조는 두 갈래예요. 제어 루프 안에서 매 주기 폴링하는 쪽은 잠금이 없고 코드가 단순해서 낮은 주기 텔레메트리에는 충분해요. 전용 수신 스레드를 두는 쪽은 도착 즉시 받아 C++ 동시성 — 센서 수신과 제어 루프의 최신값 공유로 제어 루프에 넘겨요. 수신이 빨라지거나 프레임 처리가 무거워져 주기가 흔들리기 시작하면 그때 분리하면 되고, 시작은 언제나 단순한 쪽이에요.

시리얼 IO의 신뢰성은 설정 한 번과 수신 버퍼 규율에서 나와요. read는 스트림을 줄 뿐이고, 프레임은 언제나 내 버퍼가 만들어요.