hello, robot

파이썬으로 시리얼 프로토콜을 다룰 때는 “완성된 줄만 해석하고 비정상 입력은 통째로 무시한다”는 원칙만 지키면 됐어요. C++로 오면 두 가지가 달라져요. 보이지 않던 비용(문자열 복사, 힙 할당, 예외)이 전부 선택 사항이 되고, 소유하지 않는 참조가 도구로 들어오면서 수명 감각이 문자열에도 적용돼요.

복사 없이 자르는 창

std::string_view는 남의 문자열을 복사 없이 가리키는 읽기 전용 창이에요. 내부는 포인터 하나와 길이 하나뿐이라 값으로 복사해도 16바이트 안팎이고, 문자들 자체는 소유하지 않아요.

파싱에서 이게 왜 중요하냐면, 파싱은 큰 문자열에서 조각을 계속 잘라내는 작업이기 때문이에요. std::string::substr은 부를 때마다 새 string을 만들어요. 힙 할당과 복사가 조각 수만큼 생겨요. 수신 스레드가 초당 수십 줄을 파싱하면 이 할당이 그대로 주기 흔들림이 돼요. 반면 view의 substr은 포인터와 길이만 조정해요.

std::string line = "J1:90;J2:45";         // 수신 버퍼에서 꺼낸 한 줄 — 문자들을 소유
std::string_view sv = line;               // 같은 문자들을 가리키기만 함 (할당 없음)
std::string_view head = sv.substr(0, 5);  // "J1:90" — 역시 포인터·길이 조정뿐
변수들고 있는 것힙 할당
line문자 11개를 담은 자기 버퍼 (소유)있음
svline 버퍼의 시작 주소 + 길이 11없음
headline 버퍼의 시작 주소 + 길이 5없음

인자로 받을 때도 view가 기본이에요. const std::string&로 받으면 리터럴을 넘길 때 임시 string이 만들어지는데, view는 리터럴·string·다른 view를 전부 복사 없이 받아요.

원본보다 오래 살면 안 돼요

view는 소유하지 않으니 원본이 사라지는 순간 해제된 메모리를 가리켜요. C++ 소유권과 수명 — 스마트 포인터의 참조 캡처 댕글링과 정확히 같은 뿌리예요. 가장 잘 나는 자리가 임시 string이에요.

std::string make_frame();                  // 값 반환 — 임시가 만들어진다
 
std::string_view sv = make_frame();        // 임시의 내부 버퍼를 가리키는 view
// ← 이 문장이 끝나는 순간 임시가 소멸. sv는 이미 죽은 메모리를 가리킴
std::cout << sv;                           // 댕글링

const std::string&는 임시에 묶으면 수명이 참조만큼 연장되지만, view는 참조가 아니라 포인터를 담은 값이라 연장 규칙이 없어요. 컴파일은 조용히 되고 증상은 고약해요. 해제된 자리에 옛 문자가 남아 있으면 한동안 잘 돌다가 빌드 설정이 바뀌는 날 쓰레기 문자열이 나와요.

원본이 죽지 않아도 나는 변형이 하나 더 있어요.

std::string rx = "J1:90";             // 수신 누적 버퍼
std::string_view first = rx;          // 지금 버퍼를 가리키는 view
rx += ";J2:45;J3:10;J4:120";          // 덧붙이다 재할당 — 내부 버퍼가 이사
// first는 이사 전 옛 버퍼를 가리킴 — 원본이 살아 있어도 댕글링

벡터에 원소를 넣으면 이전 반복자가 무효화되는 것과 같은 규칙이에요. 정리하면 셋이에요. 함수 인자는 view가 기본이고, 임시에서 만든 view는 그 문장 안에서만 안전하며, 멤버나 컨테이너에 오래 담을 거면 std::string으로 복사해요.

하나 더 있어요. view는 널 종료를 보장하지 않아요. 큰 버퍼의 중간 구간일 수 있거든요.

std::string_view field = sv.substr(0, 5);   // "J1:90" — 뒤에 ";J2:45"가 이어짐
std::atoi(field.data());                     // 위험 — 널을 만날 때까지 계속 읽는다

널을 찾아 멈추는 C 함수에 data()를 그냥 넘기면 구간 밖까지 읽어요. 다음 절의 변환 함수가 시작·끝 포인터를 받는 이유가 이거예요. 구간을 명시하니 널 종료가 필요 없어요.

split은 직접 씁니다

C++17 표준에는 split이 없어요. C++20에 들어왔지만 툴체인이 17에 머무는 환경이 많아서, 현업 코드는 짧은 루프를 관용구로 써요. view 기반이면 할당은 결과 벡터 하나뿐이에요.

std::vector<std::string_view> split(std::string_view s, char delim) {
    std::vector<std::string_view> out;
    std::size_t start = 0;
    while (true) {
        std::size_t pos = s.find(delim, start);
        if (pos == std::string_view::npos) {
            out.push_back(s.substr(start));           // 마지막 조각
            break;
        }
        out.push_back(s.substr(start, pos - start));
        start = pos + 1;
    }
    return out;
}

빈 조각을 지우지 않는 게 요점이에요. "J1:90;;J2:45"처럼 구분자가 연달아 오면 가운데에 빈 조각이 남는데, 이건 형식이 어긋났다는 증거라 위쪽 검증이 발견해 줄을 버리는 데 써요. 편의로 빈 조각을 걸러버리면 불량 프레임이 정상처럼 통과해요.

숫자 변환은 검증까지 한 번에

std::stoi는 편하지만 외부 입력 파싱에는 셋이 불리해요. 실패를 예외로 던지고, 앞부분만 숫자여도 성공으로 치고("90abc" → 90), string을 요구해서 view 조각을 넘기려면 새로 만들어야 해요.

std::from_chars는 이 셋을 뒤집은 도구예요. 에러코드를 돌려주고, 할당이 없고, 어디까지 읽었는지를 포인터로 알려줘요.

std::optional<int> parse_num(std::string_view s) {
    int v = 0;
    auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{}) return std::nullopt;          // 숫자 아님·범위 초과
    if (p != s.data() + s.size()) return std::nullopt;   // 끝까지 못 읽음 — "90abc" 거부
    return v;
}

두 검사를 모두 통과했을 때만 값을 돌려주는 게 외부 입력 파싱의 완결형이에요. 변환 자체가 성공해도 멈춘 위치가 끝에 못 미쳤으면 뒤에 쓰레기가 붙어 있었다는 뜻이거든요.

stoi("90abc")from_chars("90abc")
결과90 (성공으로 취급)v=90, p는 'a' 위치, ec 비어 있음
파서의 판정통과 — 오염된 값이 로봇으로 감p 검사로 거부
실패 표현예외에러코드
할당string 필요없음

불량 프레임이 일상인 자리에서 예외를 흐름 제어로 쓰면 비용도 들고 코드도 흐려져요. “못 읽었으면 nullopt”가 파서의 기본형이에요.

절반 성공이라는 상태가 없게

파이프라인은 다섯 단계예요. 공백 제거 → split → 필드 형식 확인 → 값 범위 확인 → 전부 통과했을 때만 반환.

struct Joint { int index; int angle; };
 
std::optional<std::vector<Joint>> parse_frame(std::string_view line) {
    std::vector<Joint> out;
    for (std::string_view field : split(line, ';')) {    // "J1:90" 꼴
        std::size_t colon = field.find(':');
        if (colon == std::string_view::npos) return std::nullopt;
        std::string_view tag = field.substr(0, colon);   // "J1"
        std::string_view val = field.substr(colon + 1);  // "90"
        if (tag.size() < 2 || tag[0] != 'J') return std::nullopt;
        auto idx = parse_num(tag.substr(1));
        auto ang = parse_num(val);
        if (!idx || !ang) return std::nullopt;
        if (*ang < 0 || *ang > 180) return std::nullopt; // 서보 가동 범위
        out.push_back({*idx, *ang});
    }
    if (out.empty()) return std::nullopt;
    return out;
}

포인트는 값을 쓰기 전에 검증이 전부 끝난다는 거예요. 부분 입력이 절반쯤 해석되어 관절이 0도로 꺾이는 사고는 절반 성공이라는 상태가 존재하는 구조에서 나는데, 이 파이프라인에는 그 상태가 없어요.

불량 줄 "J1:90;J2:4x"가 들어오면 이렇게 흘러요.

단계결과
split{"J1:90", "J2:4x"}
필드 1태그 J1·각도 90 — 통과
필드 2parse_num("4x") — 끝까지 못 읽음 → nullopt
전체nullopt — 줄 폐기, 로봇 상태는 이전 값 유지

이 파서 앞에는 프레이밍 단계가 하나 더 있어요. 수신 버퍼에 계속 덧붙이고 개행이 보일 때만 한 줄을 떼어내는 구조인데, C++ 시리얼 IO — termios와 프레임 재조립에 자세히 있어요.

한 줄을 view가 아니라 std::string으로 떠내는 게 의도적이라는 점만 짚어 둘게요. 다음 줄에서 버퍼를 바로 지우기 때문에 view로 들고 있으면 앞서 본 재할당 댕글링에 그대로 걸려요.

체크섬은 한 바이트 오염을 잡아요

프레임에 체크섬을 붙이면 전송 중 오염까지 걸러져요. 본문 바이트를 전부 XOR로 접은 한 바이트를 "본문*HH"처럼 16진 두 자리로 붙이는 방식이 관례이고, 위성 항법 문장이 정확히 이 형식이에요.

unsigned char xor_checksum(std::string_view body) {
    unsigned char sum = 0;
    for (char c : body) sum ^= static_cast<unsigned char>(c);
    return sum;
}

unsigned char 캐스트가 붙는 이유가 있어요. char가 음수인 바이트를 그대로 연산하면 부호 확장으로 값이 꼬여요. 바이트 연산은 부호 없는 타입으로 하는 게 규칙이에요.

만드는 쪽

제어 주기 안에서 프레임을 만들 때는 고정 버퍼에 쓰는 snprintf가 맞아요. 할당이 없고, 반환값이 “잘리지 않았다면 썼을 길이”라 버퍼가 모자랐는지도 한 번에 알아요.

char buf[32];                                   // 스택 위 고정 버퍼
int n = std::snprintf(buf, sizeof(buf), "J1:%d;J2:%d", 90, 45);
if (n < 0 || n >= (int)sizeof(buf)) {
    // 포맷 실패 또는 잘림 — 반쪽 프레임을 보내느니 폐기가 맞다
}

"%+06.1f" 같은 정밀도 포맷은 넷으로 읽어요. 부호 항상 표기, 빈 칸을 0으로 채움, 전체 최소 6칸, 소수 1자리. 90.5를 넣으면 "+090.5"가 돼요. 고정폭 프레임은 수신 쪽 파싱이 쉬워지고 로그를 눈으로 훑을 때 자릿수가 맞아 이상값이 바로 보여요.

스트림으로 이어붙이는 방식은 타입별 처리가 편하고 버퍼 크기 걱정이 없지만, 내부 버퍼가 힙이고 정밀도 설정이 스트림에 계속 남는 방식이라 한 줄 포맷의 의도가 잘 안 읽혀요. 로깅이나 설정 덤프처럼 주기 루프 밖이 어울려요.

줄끝 문자 하나에 프레임이 버려져요

시리얼 한 줄은 보통 "J1:90\r\n"처럼 줄끝 문자를 달고 도착해요. 캐리지 리턴을 안 떼면 마지막 필드가 "45\r"이 돼서 숫자 검증이 실패하고, 멀쩡한 프레임이 통째로 버려져요. 파이프라인 맨 앞에 공백 제거가 서는 이유예요.

std::string_view trim(std::string_view s) {
    const char* ws = " \t\r\n";
    std::size_t b = s.find_first_not_of(ws);
    if (b == std::string_view::npos) return {};    // 전부 공백
    std::size_t e = s.find_last_not_of(ws);
    return s.substr(b, e - b + 1);                 // 복사 없음
}

대소문자 변환에도 함정이 하나 있어요. std::toupperchar를 바로 넣으면 음수 바이트에서 정의되지 않은 동작이라, 부호 없는 타입으로 캐스트해 넣는 게 규칙이에요. 그리고 바이트 단위 함수라 한글 같은 멀티바이트 텍스트에는 쓰지 않아요. 문자를 깨뜨려요.

시리얼 파싱의 안전은 도구 셋과 계약 하나, 태도 하나로 완성돼요. 복사 없이 자르고, 검증하며 바꾸고, 고정폭으로 만들되, view는 원본보다 오래 살지 않고, 불량 줄은 절반 해석 없이 통째로 버려요.