hello, robot

바퀴 모터 둘에 똑같은 신호를 줘도 로봇은 직진하지 않아요. 원인은 코드가 아니라 물리예요.

같은 모델이라도 권선 저항과 마찰, 기어 유격이 조금씩 달라서 같은 전압에서 나오는 회전수가 갈려요. 무게 중심이 쏠려 있거나 바닥 마찰이 좌우로 다르면 회전수가 같아도 진행 거리가 달라지고요. 배터리가 닳으면 두 모터가 같은 비율로 느려지지도 않아요.

여기서 사고 전환이 하나 필요해요. 명령을 정확히 준다고 결과가 정확해지지는 않아요. 명령만 주고 결과를 안 보는 방식으로는 직진이 원리적으로 불가능하고, 결과를 재서 명령에 되먹여야 해요. 그러려면 먼저 바퀴가 실제로 얼마나 돌았는지를 재는 장치가 필요하죠.

구멍 뚫린 원판이 신호가 돼요

회전량을 재는 흔한 방식은 광학식이에요. 바퀴 축에 구멍이 일정 간격으로 뚫린 원판을 끼우고, 원판을 사이에 두고 한쪽에 빛을 내는 소자를, 반대쪽에 빛을 받는 소자를 둬요.

구멍이 오면 빛이 통과해 켜지고, 막힌 부분이 오면 꺼져요. 원판이 돌면 출력이 켜짐과 꺼짐을 반복하는 사각파가 되고, 이 펄스를 세면 회전량을 알 수 있어요.

구멍이 스무 개면 한 바퀴에 펄스 스무 개가 나오니 펄스 하나가 18도예요. 바퀴 지름을 알면 펄스당 진행 거리가 나오고, 단위 시간당 펄스 수를 세면 그게 속도고요.

이 방식은 회전량만 알고 방향은 몰라요. 방향까지 알려면 받는 소자를 두 개 두고 90도 어긋난 두 신호를 내요. 어느 쪽이 먼저 바뀌는지로 정방향인지 역방향인지가 갈려요.

물어보기와 알림받기

펄스는 빨라요. 바퀴가 초당 다섯 바퀴 돌고 구멍이 스무 개면 초당 백 펄스가 나오고, 펄스 하나가 10밀리초예요.

주 루프 안에서 핀 상태를 계속 확인하는 방식이 있어요. 문제는 그 루프에 다른 일이 끼는 순간이에요. 출력 한 줄, 센서 하나 읽기, 화면 갱신이 들어가면 그 사이에 펄스가 지나가 버려요.

놓친 펄스는 그대로 거리 오차가 돼요. 다음에 만회할 방법이 없어요. 한 번 안 센 것은 영원히 안 센 것이거든요.

그래서 반대 방식을 써요. 핀의 신호가 바뀌면 하드웨어가 실행 중인 코드를 강제로 멈추고 지정한 함수로 뛰어요. 그 함수가 끝나면 원래 자리로 돌아가고요. 주 루프가 무엇을 하고 있든 펄스는 반드시 세어져요.

그 함수가 도는 동안 세상이 멈춰요

여기가 이 글에서 가장 중요한 부분이에요.

인터럽트로 불리는 함수가 도는 동안 주 코드는 완전히 멈춰 있고, 기본 설정에서는 다른 인터럽트도 막혀 있어요. 그래서 이 함수가 길면 그동안 들어온 다른 펄스를 통째로 놓쳐요.

놓친 만큼 카운트가 덜 세지고, 카운트로 계산한 위치가 실제와 어긋나요. 로봇은 자기가 어긋난 줄도 몰라요. 센서는 정상이고 코드도 정상인데 위치만 조금씩 틀려져요.

그래서 이 함수 안에서 하면 안 되는 것들이 있어요.

금지이유
지연 함수타이머 인터럽트에 의존하는데 그게 막혀 있어 영원히 안 끝나요
시리얼 출력송신 완료를 인터럽트로 기다리는 구조라 멈추거나 깨져요. 게다가 느려요
경과 시간 조회타이머가 안 도니 값이 증가하지 않아요
무거운 계산나눗셈과 실수 연산, 반복문은 수십에서 수백 마이크로초를 먹어요

첫 줄이 특히 배신감이 있어요. 지연을 걸면 느려지는 게 아니라 아예 안 끝나요. 로봇이 완전히 멈춰 서는데 원인이 한 줄짜리 지연이에요.

표시만 남기고 즉시 나와요

해법은 정해져 있어요. 인터럽트 함수는 “일이 생겼다”는 표시만 남기고 즉시 빠져나오고, 실제 처리는 주 루프에서 해요.

volatile bool tick = false;
volatile long pulseCount = 0;
 
void countPulse() {          // 인터럽트 함수 — 두 줄로 끝
    pulseCount++;
    tick = true;
}
 
void loop() {
    if (tick) {              // 무거운 일은 전부 여기서
        tick = false;
        // 값을 안전하게 복사한 뒤 출력, 계산
    }
}

여기 문법 하나가 필수예요. 컴파일러가 모르는 사이에 값이 바뀔 수 있다는 표시요.

안 붙이면 컴파일러가 최적화하면서 값을 레지스터에 캐싱해 버려요. 그러면 주 루프가 인터럽트 함수가 바꾼 값을 영원히 못 봐요. 조건문이 한 번도 참이 안 되는데 코드는 완벽해 보이죠. 인터럽트와 주 루프가 함께 쓰는 변수에는 예외 없이 붙여요.

4바이트를 4번에 나눠 읽어요

작은 마이크로컨트롤러는 대개 8비트라 한 번에 1바이트만 읽어요. 그래서 4바이트짜리 카운트를 읽으려면 네 번에 나눠 읽어요.

그 중간에 인터럽트가 끼어들어 값을 바꾸면 어떻게 될까요. 앞 두 바이트는 옛날 값이고 뒤 두 바이트는 새 값인, 어느 쪽도 아닌 숫자가 나와요. 값이 255에서 256으로 넘어가는 순간이면 결과가 엉뚱한 큰 수가 되죠.

읽는 순간만 인터럽트를 잠갔다가 즉시 푸는 것으로 막아요. 잠금 구간에 출력 같은 걸 넣으면 그 시간만큼 펄스를 놓치니, 잠그는 건 복사 한 줄뿐이어야 해요.

1바이트짜리 변수는 한 번에 읽히니 이 처리가 필요 없어요. 어디까지가 한 번에 읽히는지는 칩마다 다른데, “내가 쓰는 칩에서 몇 바이트가 원자적인가”를 아는 게 시작점이에요.

짧게 유지하는 것이 원칙이에요

이 절들의 공통점이 하나예요. 한 번에 많이 하려는 코드가 문제를 만들어요.

인터럽트 함수를 짧게 유지하는 것, 잠금 구간을 한 줄로 두는 것, 무거운 일을 주 루프로 미루는 것이 전부 같은 원칙이에요. 그리고 이 원칙이 지켜지지 않았을 때의 증상이 하나같이 에러가 아니라 조용한 오차라는 것도 같아요.

주기 안에 일을 끝내는 감각은 C++ 실시간 제약 — 핫루프와 링버퍼에, 시리얼로 들어오는 데이터를 놓치지 않는 이야기는 C++ 시리얼 IO — termios와 프레임 재조립에 있어요.