Skip to main content
Summary

같은 PWM인데 왜 똑바로 가지 않을까

바퀴 로봇을 처음 만들면 누구나 비슷한 장면을 만난다. 왼쪽 모터와 오른쪽 모터에 똑같이 PWM 150을 줬다. 머릿속으로는 로봇이 반듯하게 앞으로 가야 한다. 그런데 실제 로봇은 슬금슬금 왼쪽으로 휘거나 오른쪽으로 기울어진다. 배터리를 새것으로 바꿔도, 바퀴를 다시 끼워도, 바닥을 닦아도 완전히 해결되지 않는 경우가 많다.

이유는 단순하다. 같은 PWM 값이 같은 속도를 의미하지 않기 때문이다.

PWM은 모터에 전달하는 전력을 조절하는 방법이지, 모터의 실제 회전 속도를 직접 지정하는 명령이 아니다. 모터마다 제조 편차가 있고, 기어박스 마찰도 조금씩 다르다. 왼쪽 바퀴에는 케이블이 살짝 닿고 있을 수도 있고, 오른쪽 바퀴에는 무게가 더 실릴 수도 있다. 배터리 전압이 떨어지면 같은 PWM에서도 속도가 변한다.

즉, PWM 150은 "초당 몇 바퀴로 돌아라"가 아니라 "이 정도 세기로 밀어 보자"에 가깝다. 그러니 로봇이 정말 원하는 속도로 움직이게 하려면, 밀어보고 끝낼 것이 아니라 실제로 얼마나 움직였는지 확인해야 한다.

여기서 엔코더가 등장한다. 엔코더는 바퀴가 얼마나 돌았는지 펄스 신호로 알려준다. 우리는 일정 시간 동안 들어온 엔코더 펄스를 세어 현재 속도를 계산하고, 목표 속도보다 느리면 PWM을 올리고, 빠르면 PWM을 낮춘다. 이것이 모터 속도 제어의 기본이다.

모터 속도 제어

속도 제어의 전체 흐름

앞 장에서 제어를 "목표값과 현재값을 비교하고, 오차만큼 출력을 보정하는 과정"이라고 했다. 모터 속도 제어에 그대로 적용하면 다음과 같은 구조가 된다.

  • 목표값: 원하는 모터 속도

  • 현재값: 엔코더로 측정한 실제 모터 속도

  • 오차: 목표 속도와 현재 속도의 차이

  • 출력: 모터 드라이버에 전달할 PWM 값

로봇이 해야 할 일은 복잡해 보이지만, 순서대로 나누면 아주 명확하다.

  1. 목표 속도를 정한다.

  2. 일정 시간 동안 엔코더 펄스를 센다.

  3. 펄스 수를 이용해 현재 속도를 계산한다.

  4. 목표 속도와 현재 속도의 오차를 구한다.

  5. 오차에 따라 PWM 값을 조정한다.

  6. 이 과정을 계속 반복한다.

중요한 점은 이 과정이 한 번으로 끝나지 않는다는 것이다. 로봇은 계속 움직이고, 모터 부하는 계속 바뀐다. 바닥 재질이 바뀌고, 배터리 전압이 낮아지고, 바퀴가 작은 턱을 넘으면 속도도 달라진다. 그래서 속도 제어는 짧은 주기로 반복되는 루프가 되어야 한다.

예를 들어 100ms마다 속도를 계산한다고 해보자. 로봇은 0.1초마다 "지금 목표보다 느린가? 빠른가?"를 확인한다. 느리면 PWM을 조금 올리고, 빠르면 PWM을 조금 내린다. 이렇게 하면 모터는 외부 조건이 조금 변해도 목표 속도 근처를 유지하려고 계속 보정된다.

목표 속도는 어떤 단위로 정할까

모터 속도는 여러 단위로 표현할 수 있다. 가장 익숙한 단위는 RPM이다. RPM은 Revolutions Per Minute의 약자로, 1분 동안 몇 바퀴 도는지를 뜻한다. 모터 축이나 바퀴가 1분에 120번 돌면 120 RPM이다.

하지만 마이크로컨트롤러 코드에서는 RPM보다 엔코더 펄스 기준으로 다루는 편이 더 단순할 때가 많다. 예를 들어 "100ms 동안 엔코더 펄스가 20개 들어오는 속도"처럼 목표를 정할 수 있다. 이것을 펄스/주기 방식이라고 생각하면 된다.

두 방식은 서로 변환할 수 있다. 엔코더가 바퀴 한 바퀴에 20펄스를 만든다고 해보자. 100ms 동안 10펄스가 들어왔다면 1초에는 약 100펄스가 들어오는 속도다. 한 바퀴가 20펄스이므로 1초에 5바퀴, 1분에는 300바퀴다. 따라서 속도는 300 RPM이다.

공식으로 쓰면 다음과 같다.

RPM = (측정된 펄스 수 / 한 바퀴당 펄스 수) × (60000 / 측정 시간 ms)

예를 들어 측정 시간이 100ms이고, 엔코더가 한 바퀴에 20펄스를 만들며, 그동안 8펄스를 읽었다면 다음과 같다.

RPM = (8 / 20) × (60000 / 100)
    = 0.4 × 600
    = 240 RPM

실습 초반에는 RPM으로 변환하지 않고 "100ms당 펄스 수"를 목표값으로 삼아도 괜찮다. 오히려 코드가 짧아지고, 엔코더 해상도나 기어비 계산 실수를 줄일 수 있다. 다만 나중에 바퀴 지름을 이용해 로봇의 실제 이동 속도를 계산하려면 RPM이나 초당 회전수로 바꾸는 편이 좋다.

엔코더 펄스로 현재 속도 계산하기

엔코더 속도 측정의 핵심은 "전체 누적 펄스 수"가 아니라 "최근 일정 시간 동안 증가한 펄스 수"를 보는 것이다.

예를 들어 엔코더 카운트가 처음에는 1000이었다. 100ms 뒤에 1025가 되었다면 이 100ms 동안 25펄스가 들어온 것이다. 다시 100ms 뒤에 1047이 되었다면 이번에는 22펄스가 들어온 것이다. 이 차이값이 현재 속도를 계산하는 재료가 된다.

이번 펄스 수 = 현재 누적 카운트 - 이전 누적 카운트

엔코더 신호는 빠르게 들어오기 때문에 보통 인터럽트를 사용한다. 인터럽트는 특정 핀의 신호가 변했을 때, 메인 코드가 무엇을 하고 있든 지정한 함수를 즉시 실행하는 기능이다. 엔코더 A상에 펄스가 들어올 때마다 카운트를 1씩 증가시키면, 메인 루프에서는 일정 시간마다 카운트 차이를 읽어 속도를 계산할 수 있다.

아래 코드는 한쪽 DC 모터의 속도를 계산하는 가장 기본적인 예다. 모터 드라이버의 방향 핀 두 개와 PWM 핀 하나, 엔코더 A상 핀 하나를 사용한다.

const int MOTOR_IN1 = 7;
const int MOTOR_IN2 = 8;
const int MOTOR_PWM = 5;

const int ENCODER_A = 2;

volatile long encoderCount = 0;
long prevEncoderCount = 0;

unsigned long prevTime = 0;
const unsigned long sampleTime = 100; // ms

const int pulsesPerRevolution = 20;

void onEncoderA() {
  encoderCount++;
}

void setup() {
  Serial.begin(9600);

  pinMode(MOTOR_IN1, OUTPUT);
  pinMode(MOTOR_IN2, OUTPUT);
  pinMode(MOTOR_PWM, OUTPUT);

  pinMode(ENCODER_A, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(ENCODER_A), onEncoderA, RISING);

  digitalWrite(MOTOR_IN1, HIGH);
  digitalWrite(MOTOR_IN2, LOW);
  analogWrite(MOTOR_PWM, 120);

  prevTime = millis();
}

void loop() {
  unsigned long now = millis();

  if (now - prevTime >= sampleTime) {
    noInterrupts();
    long currentCount = encoderCount;
    interrupts();

    long deltaCount = currentCount - prevEncoderCount;
    prevEncoderCount = currentCount;

    float rpm = (deltaCount / (float)pulsesPerRevolution) * (60000.0 / sampleTime);

    Serial.print("deltaCount: ");
    Serial.print(deltaCount);
    Serial.print("  rpm: ");
    Serial.println(rpm);

    prevTime = now;
  }
}

여기서 encoderCount 앞에 붙은 volatile은 중요하다. 이 변수는 메인 루프뿐 아니라 인터럽트 함수에서도 바뀐다. 컴파일러에게 "이 값은 예상하지 못한 순간에 바뀔 수 있으니 함부로 최적화하지 말라"고 알려주는 표시다.

또한 encoderCount를 읽을 때 noInterrupts()와 interrupts()로 짧게 감싼다. 엔코더 카운트처럼 여러 바이트로 된 값은 읽는 도중 인터럽트가 끼어들면 값이 어긋날 수 있다. 그래서 아주 짧은 순간 인터럽트를 막고 안전하게 복사한 뒤, 다시 인터럽트를 켠다.

이 코드는 아직 제어를 하지 않는다. 고정 PWM으로 모터를 돌리고, 실제 속도를 확인할 뿐이다. 하지만 이것만으로도 중요한 사실을 볼 수 있다. PWM을 120으로 고정해도 RPM이 완전히 일정하지 않고 조금씩 흔들린다. 모터를 손으로 살짝 눌러 부하를 주면 RPM이 내려간다. 배터리 전압이 낮아져도 RPM이 달라진다. 이제 우리는 그 변화를 숫자로 볼 수 있게 되었다.

목표 속도와 현재 속도의 오차 구하기

속도를 측정할 수 있으면 다음 단계는 오차를 구하는 것이다.

오차 = 목표 속도 - 현재 속도

목표가 300 RPM인데 현재 속도가 250 RPM이면 오차는 +50이다. 모터가 목표보다 느리다는 뜻이므로 PWM을 올려야 한다. 목표가 300 RPM인데 현재 속도가 340 RPM이면 오차는 -40이다. 모터가 목표보다 빠르므로 PWM을 내려야 한다.

가장 단순한 방법은 오차의 부호만 보고 PWM을 조금씩 바꾸는 것이다.

if (error > 0) {
  pwmValue += 1;
} else if (error < 0) {
  pwmValue -= 1;
}

이 방식은 이해하기 쉽지만 반응이 둔할 수 있다. 목표보다 많이 느려도 1만 올리고, 아주 조금 느려도 1만 올리기 때문이다. 그래서 보통은 오차 크기에 비례해서 PWM을 조정한다.

보정량 = 오차 × 비례 상수

여기서 비례 상수는 오차를 얼마나 강하게 반영할지 정하는 값이다. 예를 들어 오차가 50이고 비례 상수가 0.5라면 보정량은 25가 된다. 현재 PWM이 120이면 새 PWM은 145가 된다.

이 방식은 다음 장에서 다룰 PID 제어 중 P 제어, 즉 비례 제어와 연결된다. 이번 장에서는 일단 "오차가 크면 크게 보정하고, 오차가 작으면 작게 보정한다"는 감각을 익히는 데 집중하자.

기본 속도 제어 코드

아래 예제는 목표 RPM에 맞춰 PWM을 자동으로 조정하는 코드다. 모터 하나를 대상으로 한다. 실제 바퀴 로봇에서는 같은 구조를 왼쪽 모터와 오른쪽 모터에 각각 적용하면 된다.

const int MOTOR_IN1 = 7;
const int MOTOR_IN2 = 8;
const int MOTOR_PWM = 5;

const int ENCODER_A = 2;

volatile long encoderCount = 0;
long prevEncoderCount = 0;

unsigned long prevTime = 0;
const unsigned long sampleTime = 100; // ms

const int pulsesPerRevolution = 20;

float targetRPM = 300.0;
float currentRPM = 0.0;

int pwmValue = 0;
float Kp = 0.4;

void onEncoderA() {
  encoderCount++;
}

void setMotorPWM(int pwm) {
  pwm = constrain(pwm, 0, 255);

  digitalWrite(MOTOR_IN1, HIGH);
  digitalWrite(MOTOR_IN2, LOW);
  analogWrite(MOTOR_PWM, pwm);
}

void setup() {
  Serial.begin(9600);

  pinMode(MOTOR_IN1, OUTPUT);
  pinMode(MOTOR_IN2, OUTPUT);
  pinMode(MOTOR_PWM, OUTPUT);

  pinMode(ENCODER_A, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(ENCODER_A), onEncoderA, RISING);

  prevTime = millis();
  setMotorPWM(pwmValue);
}

void loop() {
  unsigned long now = millis();

  if (now - prevTime >= sampleTime) {
    noInterrupts();
    long currentCount = encoderCount;
    interrupts();

    long deltaCount = currentCount - prevEncoderCount;
    prevEncoderCount = currentCount;

    currentRPM = (deltaCount / (float)pulsesPerRevolution) * (60000.0 / sampleTime);

    float error = targetRPM - currentRPM;
    float correction = Kp * error;

    pwmValue += correction;
    pwmValue = constrain(pwmValue, 0, 255);

    setMotorPWM(pwmValue);

    Serial.print("target: ");
    Serial.print(targetRPM);
    Serial.print("  current: ");
    Serial.print(currentRPM);
    Serial.print("  error: ");
    Serial.print(error);
    Serial.print("  pwm: ");
    Serial.println(pwmValue);

    prevTime = now;
  }
}

이 코드를 실행하면 처음에는 PWM이 낮으므로 모터가 목표 속도보다 느리다. 오차가 양수로 나오고, PWM이 조금씩 올라간다. 어느 정도 시간이 지나면 현재 RPM이 목표 RPM 근처에 접근한다. 목표보다 빨라지면 오차가 음수가 되고 PWM이 내려간다.

여기서 Kp 값을 너무 크게 잡으면 모터가 목표 속도 주변에서 심하게 출렁일 수 있다. 예를 들어 목표보다 조금 느려졌을 뿐인데 PWM을 확 올리고, 다음 순간 너무 빨라져서 다시 확 내리는 식이다. 반대로 Kp가 너무 작으면 반응이 느리다. 부하가 걸려도 한참 뒤에야 속도가 회복된다.

처음에는 Kp = 0.2처럼 작게 시작해 보자. 목표 속도까지 너무 천천히 올라가면 조금씩 키운다. 속도가 위아래로 크게 흔들리면 줄인다. 지금 단계에서는 완벽한 튜닝보다 "오차를 보고 PWM이 스스로 바뀐다"는 구조를 이해하는 것이 더 중요하다.

PWM에는 한계가 있다

속도 제어 코드를 만들 때 반드시 기억해야 할 점이 있다. PWM 값은 무한히 커지거나 작아질 수 없다. 아두이노의 analogWrite()는 보통 0부터 255까지의 값을 사용한다. 0은 꺼짐에 가깝고, 255는 거의 최대 출력이다.

그래서 코드에서는 항상 constrain() 같은 함수로 PWM 범위를 제한해야 한다.

pwmValue = constrain(pwmValue, 0, 255);

만약 목표 속도를 너무 높게 잡으면 어떻게 될까? 모터는 아무리 PWM을 255까지 올려도 그 속도에 도달하지 못한다. 이때 오차는 계속 양수로 남는다. 제어 코드는 계속 더 밀고 싶어 하지만, PWM은 이미 최대값이다. 이런 상태에서는 목표 속도 자체를 낮춰야 한다.

반대로 목표 속도가 너무 낮아도 문제가 생긴다. DC 모터는 아주 작은 PWM에서 바로 부드럽게 돌지 않는다. 마찰 때문에 어느 정도 이상의 힘이 걸려야 움직이기 시작한다. 예를 들어 PWM 30에서는 모터가 가만히 있다가, PWM 55쯤에서야 갑자기 돌기 시작할 수 있다. 이 구간을 불감대라고 부르기도 한다. 입력을 줬지만 출력이 눈에 띄게 나오지 않는 영역이라는 뜻이다.

저속 제어가 어려운 이유가 여기에 있다. 목표 속도를 아주 낮게 잡으면 모터가 멈췄다가 갑자기 움직이고, 다시 멈추는 식으로 덜컥거릴 수 있다. 이때는 최소 PWM 값을 정해 움직일 때는 적어도 그 이상을 주거나, 기어비가 큰 모터를 사용하거나, 더 해상도가 높은 엔코더를 사용하는 방법을 고려해야 한다.

왼쪽과 오른쪽 모터를 따로 제어하기

바퀴 로봇에서 속도 제어가 빛을 발하는 순간은 좌우 모터를 함께 다룰 때다. 같은 PWM을 주었을 때 왼쪽 모터가 280 RPM, 오른쪽 모터가 320 RPM으로 돈다고 해보자. 로봇은 당연히 한쪽으로 휜다.

이 문제를 해결하는 가장 직접적인 방법은 왼쪽과 오른쪽 모터에 각각 목표 속도를 주고, 각각의 엔코더로 현재 속도를 측정한 뒤, 각각의 PWM을 따로 조정하는 것이다.

왼쪽 오차 = 왼쪽 목표 속도 - 왼쪽 현재 속도
오른쪽 오차 = 오른쪽 목표 속도 - 오른쪽 현재 속도

왼쪽 PWM = 왼쪽 PWM + 왼쪽 보정량
오른쪽 PWM = 오른쪽 PWM + 오른쪽 보정량

앞으로 직진하고 싶다면 좌우 목표 속도를 같게 둔다. 예를 들어 둘 다 300 RPM을 목표로 한다. 왼쪽 모터가 느리면 왼쪽 PWM만 올라가고, 오른쪽 모터가 빠르면 오른쪽 PWM만 내려간다. 결과적으로 두 바퀴의 실제 속도가 비슷해지고, 로봇은 더 똑바로 간다.

아래 예제는 두 모터의 속도를 각각 제어하는 구조를 보여준다. 핀 번호는 사용하는 보드와 모터 드라이버에 맞게 바꾸어야 한다.

const int L_IN1 = 7;
const int L_IN2 = 8;
const int L_PWM = 5;

const int R_IN1 = 9;
const int R_IN2 = 10;
const int R_PWM = 6;

const int L_ENC_A = 2;
const int R_ENC_A = 3;

volatile long leftCount = 0;
volatile long rightCount = 0;

long prevLeftCount = 0;
long prevRightCount = 0;

unsigned long prevTime = 0;
const unsigned long sampleTime = 100;

const int pulsesPerRevolution = 20;

float targetLeftRPM = 300.0;
float targetRightRPM = 300.0;

float Kp = 0.4;

int leftPWM = 0;
int rightPWM = 0;

void onLeftEncoder() {
  leftCount++;
}

void onRightEncoder() {
  rightCount++;
}

void setLeftMotor(int pwm) {
  pwm = constrain(pwm, 0, 255);
  digitalWrite(L_IN1, HIGH);
  digitalWrite(L_IN2, LOW);
  analogWrite(L_PWM, pwm);
}

void setRightMotor(int pwm) {
  pwm = constrain(pwm, 0, 255);
  digitalWrite(R_IN1, HIGH);
  digitalWrite(R_IN2, LOW);
  analogWrite(R_PWM, pwm);
}

void setup() {
  Serial.begin(9600);

  pinMode(L_IN1, OUTPUT);
  pinMode(L_IN2, OUTPUT);
  pinMode(L_PWM, OUTPUT);
  pinMode(R_IN1, OUTPUT);
  pinMode(R_IN2, OUTPUT);
  pinMode(R_PWM, OUTPUT);

  pinMode(L_ENC_A, INPUT_PULLUP);
  pinMode(R_ENC_A, INPUT_PULLUP);

  attachInterrupt(digitalPinToInterrupt(L_ENC_A), onLeftEncoder, RISING);
  attachInterrupt(digitalPinToInterrupt(R_ENC_A), onRightEncoder, RISING);

  prevTime = millis();
}

void loop() {
  unsigned long now = millis();

  if (now - prevTime >= sampleTime) {
    noInterrupts();
    long currentLeftCount = leftCount;
    long currentRightCount = rightCount;
    interrupts();

    long deltaLeft = currentLeftCount - prevLeftCount;
    long deltaRight = currentRightCount - prevRightCount;

    prevLeftCount = currentLeftCount;
    prevRightCount = currentRightCount;

    float leftRPM = (deltaLeft / (float)pulsesPerRevolution) * (60000.0 / sampleTime);
    float rightRPM = (deltaRight / (float)pulsesPerRevolution) * (60000.0 / sampleTime);

    float leftError = targetLeftRPM - leftRPM;
    float rightError = targetRightRPM - rightRPM;

    leftPWM += Kp * leftError;
    rightPWM += Kp * rightError;

    leftPWM = constrain(leftPWM, 0, 255);
    rightPWM = constrain(rightPWM, 0, 255);

    setLeftMotor(leftPWM);
    setRightMotor(rightPWM);

    Serial.print("L rpm: ");
    Serial.print(leftRPM);
    Serial.print(" L pwm: ");
    Serial.print(leftPWM);
    Serial.print(" | R rpm: ");
    Serial.print(rightRPM);
    Serial.print(" R pwm: ");
    Serial.println(rightPWM);

    prevTime = now;
  }
}

이제 로봇은 "왼쪽과 오른쪽에 같은 PWM을 주는 로봇"이 아니라 "왼쪽과 오른쪽이 같은 속도를 내도록 각자 PWM을 조절하는 로봇"이 된다. 이 차이는 실제 주행에서 매우 크다. 바닥이 조금 기울어졌거나, 한쪽 모터가 조금 약해도 로봇은 스스로 균형을 맞추려고 한다.

직진 보정과 속도 제어는 다르지만 연결되어 있다

로봇이 한쪽으로 휘는 문제를 해결하는 방법에는 두 가지 관점이 있다.

첫 번째는 방금 본 것처럼 좌우 모터의 목표 속도를 같게 맞추는 것이다. 왼쪽 바퀴와 오른쪽 바퀴가 같은 속도로 돌면 로봇은 대체로 직진한다. 이것은 바퀴 속도 자체를 제어하는 방식이다.

두 번째는 로봇의 방향을 직접 보는 것이다. 예를 들어 IMU로 yaw 방향이 틀어지는지 확인하거나, 라인 센서로 라인 중심에서 벗어났는지 확인할 수 있다. 이 경우에는 "오른쪽으로 틀어졌으니 왼쪽 바퀴를 조금 빠르게 하자"처럼 방향 오차를 기준으로 좌우 속도 목표를 조정한다.

두 방식은 경쟁 관계가 아니라 층이 다르다. 바퀴 속도 제어는 아래쪽에서 각 모터가 명령한 속도를 잘 따라가게 만든다. 방향 제어나 라인트레이싱 제어는 위쪽에서 "왼쪽 바퀴는 280 RPM, 오른쪽 바퀴는 320 RPM으로 가라"처럼 목표 속도를 만들어준다.

작은 로봇에서는 처음부터 모든 것을 넣으려 하지 않아도 된다. 먼저 좌우 바퀴가 각각 목표 속도를 따라가게 만들자. 그다음 라인 센서, 거리 센서, IMU 같은 정보를 이용해 좌우 목표 속도를 바꾸면 훨씬 안정적인 로봇이 된다.

속도 측정 주기가 제어 품질을 바꾼다

속도 제어에서 측정 주기는 아주 중요하다. 측정 주기란 몇 ms마다 엔코더 펄스를 모아 속도를 계산할지를 뜻한다. 예제에서는 100ms를 사용했다.

측정 주기가 너무 짧으면 문제가 생긴다. 예를 들어 10ms마다 속도를 계산한다고 해보자. 모터가 천천히 돌 때는 10ms 동안 엔코더 펄스가 0개 또는 1개만 들어올 수 있다. 그러면 계산된 속도가 0 RPM, 300 RPM, 다시 0 RPM처럼 튀어 보인다. 실제 모터가 그렇게 난폭하게 움직이는 것이 아니라, 측정 시간이 너무 짧아 데이터가 듬성듬성 들어오는 것이다.

반대로 측정 주기가 너무 길어도 좋지 않다. 1초마다 속도를 계산하면 값은 부드럽게 보일 수 있지만 반응이 느리다. 모터가 이미 목표보다 한참 느려졌는데도 1초 뒤에야 알아차린다. 로봇이 장애물을 피하거나 라인을 따라가야 하는 상황에서는 너무 늦다.

초보 실습에서는 50ms에서 200ms 사이에서 시작하면 다루기 쉽다. 엔코더 해상도가 높고 모터가 빠르게 돈다면 20ms나 50ms도 가능하다. 엔코더 펄스가 적고 저속으로 움직인다면 100ms나 200ms가 더 안정적이다.

측정 주기를 고를 때는 다음 질문을 던져보면 좋다.

  • 한 번의 측정 주기 동안 엔코더 펄스가 충분히 들어오는가?

  • 로봇이 원하는 반응 속도에 비해 측정이 너무 늦지는 않은가?

  • 속도값이 심하게 튄다면 주기를 늘리거나 평균을 낼 수 있는가?

정답은 하나가 아니다. 같은 코드라도 엔코더 해상도, 바퀴 크기, 기어비, 로봇 무게에 따라 적절한 주기가 달라진다.

엔코더 해상도와 저속 영역의 함정

엔코더 해상도는 한 바퀴를 얼마나 잘게 나누어 측정할 수 있는지를 뜻한다. 한 바퀴에 20펄스를 만드는 엔코더보다 360펄스를 만드는 엔코더가 훨씬 세밀하게 움직임을 볼 수 있다.

해상도가 낮으면 특히 저속 제어가 어려워진다. 바퀴가 천천히 돌 때 측정 주기 안에 들어오는 펄스 수가 너무 적기 때문이다. 예를 들어 100ms 동안 펄스가 0개 들어오면 속도는 0으로 계산된다. 다음 100ms 동안 1개가 들어오면 갑자기 속도가 있는 것으로 계산된다. 실제 바퀴는 조금씩 움직이고 있는데, 측정값은 계단처럼 튄다.

이것을 양자화 오차라고 생각할 수 있다. 연속적인 움직임을 펄스라는 작은 단위로 쪼개서 세기 때문에 생기는 오차다. 자로 길이를 잴 때 눈금이 1cm짜리인 자보다 1mm짜리 자가 더 정확한 것과 비슷하다.

저속에서 속도값이 심하게 흔들릴 때는 몇 가지 방법을 사용할 수 있다.

  • 측정 주기를 조금 늘려 한 번에 더 많은 펄스를 모은다.

  • 최근 여러 번의 속도값을 평균 내어 급격한 흔들림을 줄인다.

  • 더 높은 해상도의 엔코더나 기어비가 큰 모터를 사용한다.

  • 너무 낮은 목표 속도를 피하고, 모터가 안정적으로 도는 범위에서 제어한다.

평균을 내는 방법은 간단하다. 최근 속도값과 새 속도값을 섞어 부드럽게 만들 수 있다.

float filteredRPM = 0.0;
float alpha = 0.3;

filteredRPM = alpha * currentRPM + (1.0 - alpha) * filteredRPM;

alpha가 크면 새 값을 더 많이 반영하므로 반응이 빠르지만 흔들림도 커질 수 있다. alpha가 작으면 값이 부드러워지지만 반응이 느려진다. 이것도 로봇의 목적에 맞게 조정해야 한다.

방향까지 측정하려면 B상이 필요하다

지금까지 예제에서는 엔코더 A상만 사용해 펄스를 세었다. 이 방식은 속도 측정 실습에는 충분하다. 모터를 항상 한 방향으로만 돌린다면 펄스가 들어올 때마다 카운트를 증가시키면 된다.

하지만 실제 로봇에서는 전진뿐 아니라 후진도 필요하다. 바퀴가 앞으로 도는지 뒤로 도는지 알아야 이동거리와 위치를 정확히 계산할 수 있다. 이때는 엔코더의 A상과 B상을 함께 사용한다.

A상과 B상은 서로 약간 어긋난 펄스를 만든다. A상이 변하는 순간 B상의 상태를 읽으면 회전 방향을 판단할 수 있다. 예를 들어 A상 상승 에지에서 B상이 LOW이면 정방향, HIGH이면 역방향처럼 정할 수 있다. 실제 방향은 엔코더 배선과 장착 방향에 따라 반대가 될 수 있으므로 테스트하면서 맞추면 된다.

const int ENCODER_A = 2;
const int ENCODER_B = 4;

volatile long encoderCount = 0;

void onEncoderA() {
  int b = digitalRead(ENCODER_B);

  if (b == LOW) {
    encoderCount++;
  } else {
    encoderCount--;
  }
}

이렇게 하면 바퀴가 반대로 돌 때 카운트가 감소한다. 속도 계산에서는 deltaCount가 양수인지 음수인지에 따라 방향까지 알 수 있다. 목표 속도를 양수로 주면 전진, 음수로 주면 후진으로 해석하는 구조도 만들 수 있다.

다만 처음부터 너무 복잡하게 시작할 필요는 없다. 먼저 한 방향 속도 제어를 안정적으로 만든 뒤, 그다음 방향 처리와 후진 제어를 추가하는 편이 좋다.

ESP32에서 생각할 점

ESP32로도 구조는 같다. 목표 속도, 엔코더 카운트, 측정 주기, 오차, PWM 보정이라는 큰 흐름은 바뀌지 않는다. 다만 PWM 출력 함수가 아두이노 UNO의 analogWrite()와 다를 수 있다. ESP32의 Arduino 환경에서는 LEDC PWM 기능을 사용하는 경우가 많다.

개념적으로는 다음과 같은 차이가 있다.

ledcAttachPin(MOTOR_PWM, 0);      // PWM 핀을 채널 0에 연결
ledcSetup(0, 20000, 8);           // 채널 0, 20kHz, 8비트 해상도
ledcWrite(0, pwmValue);           // 0~255 출력

ESP32는 인터럽트를 사용할 수 있는 핀이 비교적 많고, Wi-Fi나 Bluetooth 같은 무선 기능도 함께 사용할 수 있어 로봇 제어에 매력적이다. 하지만 무선 통신, 웹 서버, 센서 처리, 모터 제어를 한 코드에 모두 넣으면 루프가 복잡해질 수 있다. 속도 제어 루프는 일정한 주기로 돌아야 하므로, 오래 걸리는 작업이 제어 루프를 막지 않게 구성해야 한다.

예를 들어 웹으로 목표 속도를 바꾸더라도 모터 제어는 100ms마다 계속 실행되어야 한다. 웹 요청을 처리하느라 1초 동안 속도 계산을 못 하면 로봇은 그동안 눈을 감고 달리는 셈이다.

속도 제어 디버깅 방법

속도 제어가 잘 안 될 때는 무작정 Kp 값을 바꾸기보다, 먼저 측정과 출력이 제대로 되는지 나누어 확인해야 한다. 로봇 제어 문제는 대부분 "제어 알고리즘이 틀렸다"보다 "측정값이 이상하다" 또는 "출력이 생각과 다르다"에서 시작된다.

먼저 엔코더 카운트가 증가하는지 확인한다. 바퀴를 손으로 천천히 돌렸을 때 encoderCount가 변해야 한다. 전진 방향으로 돌렸는데 카운트가 감소한다면 방향 판단이 반대인 것이다. 한쪽 모터만 카운트가 나오지 않는다면 엔코더 전원, GND, 신호선, 인터럽트 핀 설정을 확인한다.

다음으로 고정 PWM에서 RPM이 합리적으로 나오는지 본다. PWM 80, 120, 160처럼 값을 바꿨을 때 대체로 RPM이 증가해야 한다. PWM을 올려도 RPM이 변하지 않거나, 값이 갑자기 말도 안 되게 튄다면 엔코더 펄스 수 설정, 측정 주기, 노이즈 문제를 의심해야 한다.

그다음 제어를 켠다. 목표 속도를 너무 높게 잡지 말고, 모터가 여유 있게 낼 수 있는 속도로 시작한다. 출력 로그에는 최소한 목표 속도, 현재 속도, 오차, PWM을 함께 찍는 것이 좋다.

target: 300  current: 240  error: 60  pwm: 132
target: 300  current: 270  error: 30  pwm: 144
target: 300  current: 305  error: -5  pwm: 142

이런 로그를 보면 제어가 어떤 생각을 하고 있는지 알 수 있다. 현재 속도가 낮은데 PWM이 내려간다면 오차 계산 부호가 반대일 가능성이 있다. 현재 속도가 0으로만 나오는데 PWM이 계속 최대값으로 올라간다면 엔코더 측정이 안 되는 것이다. 현재 속도가 목표 주변에서 크게 출렁이면 Kp가 너무 크거나 속도 측정값이 너무 거칠 수 있다.

로봇을 똑바로 달리게 만드는 실습 순서

실제 로봇에 적용할 때는 한 번에 완성하려고 하지 말고 단계별로 확인하는 것이 좋다. 가장 좋은 순서는 한쪽 모터부터 시작하는 것이다.

먼저 왼쪽 모터 하나만 들어 올린 상태에서 고정 PWM으로 돌린다. 엔코더 카운트와 RPM이 제대로 나오는지 확인한다. 그다음 목표 RPM을 정하고 PWM이 자동으로 조정되는지 본다. 왼쪽 모터가 잘 되면 오른쪽 모터도 같은 과정을 반복한다.

두 모터가 각각 목표 속도를 따라갈 수 있게 되면 로봇을 바닥에 내려놓고 낮은 속도로 직진시킨다. 이때 처음부터 빠르게 달리게 하면 디버깅하기 어렵다. 목표 속도를 낮게 잡고, 로봇이 한쪽으로 심하게 휘는지 관찰한다.

만약 좌우 RPM은 비슷한데도 로봇이 휜다면 바퀴 지름 차이, 프레임 뒤틀림, 무게 중심, 바닥 마찰 차이를 확인해야 한다. 속도 제어는 바퀴 회전 속도를 맞추는 것이지, 물리적인 구조 문제를 마법처럼 없애지는 못한다. 하지만 대부분의 작은 편차는 속도 피드백만으로도 크게 줄어든다.

속도 명령을 바꾸는 방법

로봇은 항상 같은 속도로만 움직이지 않는다. 전진할 때, 천천히 접근할 때, 회전할 때 좌우 목표 속도가 달라진다. 속도 제어 구조를 잘 만들어두면 상위 명령은 단순해진다.

예를 들어 차동 구동 로봇이라면 다음처럼 생각할 수 있다. 차동 구동은 왼쪽 바퀴와 오른쪽 바퀴의 속도 차이로 방향을 바꾸는 구조다.

전진:   왼쪽 목표 속도 = 300, 오른쪽 목표 속도 = 300
좌회전: 왼쪽 목표 속도 = 150, 오른쪽 목표 속도 = 300
우회전: 왼쪽 목표 속도 = 300, 오른쪽 목표 속도 = 150
정지:   왼쪽 목표 속도 = 0,   오른쪽 목표 속도 = 0

여기서 중요한 점은 상위 명령이 PWM을 직접 정하지 않는다는 것이다. 상위 명령은 목표 속도만 정한다. 실제 PWM은 아래쪽 속도 제어 루프가 알아서 조정한다. 이 구조가 잡히면 나중에 거리 센서 기반 장애물 회피, 라인트레이서, 카메라 추적 로봇으로 확장하기 쉬워진다.

예를 들어 라인트레이서에서는 라인이 왼쪽으로 벗어났을 때 왼쪽 바퀴 목표 속도를 낮추고 오른쪽 바퀴 목표 속도를 높일 수 있다. 장애물 회피 로봇에서는 앞에 물체가 가까워질수록 전체 목표 속도를 줄일 수 있다. 카메라 로봇에서는 추적 대상이 화면 왼쪽에 있으면 좌우 목표 속도 차이를 만들어 회전할 수 있다.

정지 제어도 중요하다

속도 제어를 이야기하면 대부분 "목표 속도로 달리는 것"을 떠올린다. 하지만 로봇에서는 정지도 똑같이 중요하다. 목표 속도를 0으로 만들면 로봇은 멈춰야 한다.

단순히 PWM을 0으로 만들면 모터 드라이버 설정에 따라 로봇이 관성으로 조금 더 굴러갈 수 있다. 이것을 코스트 정지라고 생각하면 된다. 전원을 끊고 바퀴가 자연스럽게 굴러가게 두는 방식이다.

반대로 브레이크 모드를 사용하면 모터 단자를 특정 방식으로 연결해 더 강하게 멈추게 할 수 있다. 모터 드라이버마다 브레이크 동작 방법이 다르므로 사용하는 모듈의 입력 조합을 확인해야 한다.

속도 제어 관점에서는 목표 속도 0 근처에서 특별 처리를 넣는 경우가 많다. 예를 들어 목표 속도가 0이면 제어 계산을 계속하기보다 PWM을 바로 0으로 만들고 브레이크를 걸 수 있다.

if (targetRPM == 0) {
  pwmValue = 0;
  analogWrite(MOTOR_PWM, 0);
  // 필요하다면 여기에서 브레이크 모드 설정
  return;
}

특히 배터리로 움직이는 로봇은 통신이 끊기거나 센서값이 이상할 때 안전하게 멈추는 조건을 반드시 가져야 한다. 제어가 아무리 좋아도 멈추지 못하는 로봇은 위험하다.

이번 장의 핵심 정리

모터 속도 제어는 로봇을 "대충 움직이는 장난감"에서 "원하는 움직임을 따라가는 기계"로 바꾸는 첫 번째 단계다. 같은 PWM을 줘도 모터마다 실제 속도는 다르다. 그래서 엔코더로 현재 속도를 측정하고, 목표 속도와 비교해 PWM을 조정해야 한다.

이번 장에서 만든 구조는 앞으로 계속 사용된다. 거리만큼 이동하려면 속도를 안정적으로 제어해야 하고, 라인트레이서를 빠르게 달리게 하려면 좌우 속도 차이를 정교하게 만들어야 한다. PID 제어도 결국 이 구조 위에 올라간다.

기억해야 할 흐름은 하나다.

목표 속도 설정
→ 엔코더 펄스 측정
→ 현재 속도 계산
→ 오차 계산
→ PWM 보정
→ 반복

이 루프가 안정적으로 돌아가면 로봇은 더 이상 "PWM을 받는 모터 두 개"가 아니다. 목표를 듣고, 자기 움직임을 확인하고, 스스로 힘을 조절하는 작은 이동 시스템이 된다. 다음 장에서는 이 보정을 더 체계적으로 만드는 PID 제어를 다룬다. 지금 사용한 비례 보정에 I와 D가 더해지면, 느린 반응과 흔들림을 더 잘 다룰 수 있게 된다.

책 목록

센서와 모터로 시작하는 실전 로봇 만들기

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기