hello, robot

바퀴 두 개에 같은 PWM 값을 주면 로봇이 직진할 것 같은데 실제로는 한쪽으로 휘어요. 그 문제에서 시작해 엔코더와 인터럽트, PID까지 이어서 배웠어요.

같은 모델 모터라도 권선 저항과 브러시 마찰, 기어박스의 유격이 조금씩 달라요. 같은 전압을 걸어도 나오는 회전수가 다르고, 무게 중심이 한쪽으로 쏠려 있으면 회전수가 같아도 진행 거리가 갈려요. 그래서 명령만 주고 결과를 확인하지 않는 개루프 제어로는 직진이 원리적으로 안 돼요.

보드아두이노 우노 · 외부 인터럽트 2·3번 핀 센서광학 엔코더 · 구멍 20개 제어바퀴별 PID + 좌우 결합 보정 구동모터 드라이버 PWM 0~255

세 단계로 나뉘어요

단계하는 일도구안 하면
재고바퀴가 얼마나 돌았는지 측정광학 엔코더결과를 모름
세고펄스를 하나도 안 놓치고 카운트외부 인터럽트거리 오차 누적
되돌리고오차만큼 출력 보정PID목표에 못 닿거나 진동
(묶고)두 바퀴를 서로 연결좌우 차이 보정한쪽이 먼저 멈춰 피벗 회전

바퀴가 얼마나 돌았는지 재요

엔코더는 회전량을 전기 신호로 바꾸는 센서예요. 수업에서 쓴 광학식은 구조가 단순해요. 바퀴 축에 구멍이 일정 간격으로 뚫린 원판을 끼우고, 원판을 사이에 두고 한쪽에 발광부, 반대쪽에 수광부를 둬요.

광학 엔코더가 사각파를 만드는 방식
구멍이 오면 빛이 통과하고 막힌 부분이 오면 끊겨요. 펄스를 세면 회전량이 나와요.
구멍 20개 원판 · 펄스 하나 = 18°발광부수광부수광부 출력HIGHLOW초당 5회전이면 펄스 하나가 10 ms반복문에 시리얼 출력이 끼면 이 사이에 펄스가 지나가 놓쳐요.그래서 폴링이 아니라 인터럽트로 세요. 우노는 2·3번 핀만 외부 인터럽트를 받아요.

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

세는 걸 놓치면 안 돼요

문제는 펄스가 빠르다는 거예요. 바퀴가 초당 5회전하고 구멍이 20개면 초당 100펄스, 하나에 10밀리초예요. 반복문 안에 시리얼 출력이나 다른 센서 읽기가 끼면 그 사이에 펄스가 지나가서 놓쳐요.

방식동작맞는 신호
폴링반복문에서 계속 값을 물어봄버튼처럼 사람이 누르는 느린 입력
인터럽트신호가 오면 하던 일을 멈추고 먼저 실행엔코더처럼 하나도 놓치면 안 되는 신호

아두이노 우노에서 외부 인터럽트를 쓸 수 있는 핀은 2번과 3번뿐이에요. 칩 안에 외부 인터럽트 채널이 두 개만 하드웨어로 배선돼 있어서 그래요. 엔코더를 2번에 꽂는 이유가 이거예요.

ISR은 빨리 끝나야 해요
인터럽트가 걸렸을 때 실행되는 함수를 ISR이라고 하는데, ISR이 도는 동안에는 메인 코드가 멈춰 있고 다른 인터럽트도 막혀 있어요. ISR이 길면 그동안 들어온 펄스를 통째로 놓치고, 카운트로 계산한 위치가 실제와 어긋나요. 로봇은 자기가 어긋난 줄도 몰라요.

그래서 ISR 안에서는 delay()Serial.print()도 쓰면 안 돼요. 둘 다 인터럽트에 기대는 구조라 멈추거나 깨져요. millis()도 ISR 안에서는 값이 증가하지 않아요.

해법은 ISR에서 표시만 남기고 실제 처리는 반복문에서 하는 거예요.

volatile bool tick = false;      // ISR과 loop이 함께 쓰는 변수
volatile long pulseCount = 0;
 
void countPulse() {              // ISR — 두 줄로 끝내요
    pulseCount++;
    tick = true;
}
 
void loop() {
    if (tick) {                  // 무거운 일은 전부 여기서
        tick = false;
        Serial.println(pulseCount);
    }
}

volatile은 이 변수가 컴파일러 모르게 바뀔 수 있다는 표시예요. 안 붙이면 컴파일러가 값을 레지스터에 캐싱해 버려서, 반복문이 ISR이 바꾼 값을 영영 못 봐요.

어긋난 만큼 되돌려요

좌우 펄스 수의 차이를 오차로 두고 그걸 0으로 만들면 직진이에요. 여기 쓰는 표준 제어기가 PID예요.

무엇에 반응있으면없으면
비례 (P)현재 오차 크기크게 틀어지면 크게 되돌림대응 자체가 없음
적분 (I)오차의 누적마지막 한 끗을 밀어 정상상태 오차 제거조금 못 미친 채 멈춤
미분 (D)오차의 변화율미리 브레이크를 걸어 오버슈트 억제목표를 지나쳐 떨림

적분은 누적이 과하면 목표를 지나쳐 느린 진동이 생기고, 미분은 변화율을 보기 때문에 센서 값이 튀면 같이 튀어요. 세 계수를 맞추는 걸 게인 튜닝이라고 하는데 이론값이 아니라 실험으로 잡아요. 순서는 대체로 비례를 진동 직전까지 올리고, 미분으로 진동을 잡고, 적분을 마지막에 조금 넣는 식이에요.

바퀴별 PID만으로는 아직 직진이 아니에요

여기까지 하고 바퀴를 두 개로 늘렸는데 또 안 됐어요. 각 바퀴가 자기 목표를 향해 제대로 가는데도, 종반에 한쪽이 먼저 목표에 닿아 멈추면 나머지 하나가 계속 돌아서 로봇이 제자리에서 도는 피벗 회전이 돼요. 바퀴마다 따로 도는 제어기는 서로를 모르니까요.

그래서 두 바퀴를 서로 묶었어요. 좌우 카운트 차이를 실시간으로 보고 앞선 쪽을 감속시키는 보정을 따로 더해요. 여기서 한참 헤맨 게 이 보정을 어디에 넣느냐였어요.

보정을 출력 제한 앞에 넣느냐 뒤에 넣느냐
순항 중에는 두 바퀴 출력이 이미 최댓값에 붙어 포화돼 있어요.
보정을 제한 전에 더하면PID 출력250+ 보정250 - 30 = 2200~255로 제한255 <- 잘림효과 없음보정을 제한 후에 더하면PID 출력2500~255로 제한250+ 보정250 - 30 = 220앞선 바퀴가 감속순항 중에는 두 바퀴 출력이 이미 최댓값에 붙어 있어서, 제한 전에 더한 차이는 잘려 나가요. 같은 수식인데 순서만 바뀌어도 동작 여부가 갈려요.

보정을 제어기 출력에 먼저 더한 다음 잘라내면 그 차이가 같이 잘려 나가 아무 효과가 없어요. 잘라낸 뒤에 더해야 앞선 바퀴가 포화 구간 아래로 내려가요. 같은 수식인데 순서만 바뀌어도 순항 구간에서 동작하느냐 마느냐가 갈렸어요.

적분은 멈춰 있을 때만 쌓기로 했어요

교과서대로면 적분 누적값이 넘지 못할 최댓값을 정해 와인드업을 막아요. 그런데 감속 구간에서 적분이 계속 개입해 정지가 지저분했어요.

바꾼 방식은 조건을 다는 거예요. 바퀴가 도는 동안에는 적분을 아예 하지 않고 비례와 미분만 써요. 한 샘플 동안 카운트가 하나도 안 늘었을 때, 즉 멈췄는데 오차가 남아 있을 때만 적분이 차오르기 시작해요. 다시 움직이면 빠르게 방전시키고요.

이러면 정작 필요한 순간, 마찰에 걸려 못 가고 있을 때만 밀어붙여요. 바퀴를 손으로 잡아보면 출력값이 눈에 띄게 올라가는 게 로그로 보여요.

안 될 때는 계층을 잘라냈어요

중간에 모터가 아예 안 도는 구간이 있었는데, 원인을 추측하는 대신 진단용 스케치를 따로 만들었어요.

진단 스케치남긴 것결과로 갈리는 것
LED만 깜빡임배선과 무관한 최소 코드안 깜빡이면 업로드 문제
모터 풀파워만PID·엔코더·시리얼 전부 제거조용하면 드라이버 주변
둘 다 정상상위 코드 문제

“작고 잘게, 하나씩 디버깅”이 이런 뜻이었어요. 층을 하나씩 잘라내면 남은 층에 범인이 있어요.

정리하면

재고, 놓치지 않고 세고, 어긋난 만큼 되돌리는 거예요. 그리고 바퀴가 둘이면 그 둘을 서로 묶는 게 하나 더 붙어요.

디지털 입력을 읽을 때 저항이 왜 따라오는지는 플로팅과 풀업·풀다운 — 버튼 하나에 저항이 필요한 이유에 정리해뒀어요.