AEGLOSLAB Arduino UNO Q 완전 가이드

제12부 실전 프로젝트

12.3 종합 프로젝트 ③ 로봇·모터 제어

MCU가 실시간으로 바퀴 속도를 지키고, 상위 명령(어디로 갈지)은 필요한 만큼의 복잡도로 추가한다 — "실시간성은 MCU, 판단은 MPU"라는 UNO Q의 역할 분담을 이동 로봇에서 확인하는 프로젝트다.

이 장에서는 8.4의 PID 속도 제어 루프를 그대로 확장해 간단한 차동구동(differential drive) 이동 로봇을 만든다. 상위에서 로봇에 "어디로 가라"고 명령하는 방식은 ROS2 Jazzy와 순수 스케치 두 가지로 나눠 트레이드오프를 제시한다 — 어느 쪽이 맞는지는 프로젝트 목표에 달렸으므로 택일을 강요하지 않는다.

⚠️ 정직성 표기: 이 장의 PID 루프는 8.4의 검증된 참조 구현을 그대로 두 바퀴로 복제한 것이고, ROS2 연동은 7.1이 확인한 커뮤니티 저장소·아키텍처를 근거로 한다. 핀 배정은 2.2·4.3이 보고한 PWM 타이머 공유 이슈를 피하도록 의도적으로 골랐다(아래 12.3.3에서 근거를 설명한다). 다만 이 장 전체를 하나의 로봇으로 합쳐 실물에서 끝까지 검증하지는 않았다 — 8.4가 이미 밝힌 대로 attachInterrupt() 등의 정확한 동작은 실물 재확인이 필요하다.

12.3.1 이 프로젝트가 전제하는 장

  • 2.2 핀아웃과 GPIO·PWM·아날로그 — 모터 드라이버·엔코더 배선, PWM 타이머 그룹
  • 3.4 Bridge — 상위 명령을 MCU로 전달하는 경로(ROS2 경로에서)
  • 4.3 GPIO·센서 예제 모음 — PWM 핀이 다른 GPIO를 방해하는 알려진 이슈(4.3.3)
  • 7.1 ROS2 Jazzy 설치하기 (커뮤니티 방법: Docker 우선 권장)
  • 8.1~8.3 MCU 실시간성·인터럽트/타이머·Bridge 지연 — PID 루프 주기 설계의 배경
  • 8.4 실전 — PID 모터 제어 루프 (이 장이 그대로 확장하는 원본)
  • 11.1 인클로저 설계 원칙 — 섀시·모터 마운트의 방열·체결 원칙(11.2·11.3은 집필 순서상 이 장 뒤에 올 수 있다)
  • (선택) 8.5 STM32에서 PX4/ArduPilot 실시간 제어 — 확장 아이디어에서만 짧게 응용

12.3.2 무엇을 만드나 — 차동구동 로봇 구조

Bridge.call() (3.4) 상위 명령 ROS2 /cmd_vel 또는 조이스틱/시리얼 명령 수신 (경로에 따라 다름) MCU: PID 속도 루프 ×2 (8.4) 휠 엔코더 ×2 모터 드라이버 PWM (2.2) 구동 모터 ×2 (차동구동)

차동구동은 좌우 바퀴 각각의 속도만 독립 제어하면 직진·회전·제자리 회전까지 다 표현되는, 가장 단순하면서 견고한 이동 로봇 구조다. 이 장은 8.4의 "단일 모터 PID 속도 제어"를 좌우 두 바퀴로 복제하는 것을 핵심으로 삼고, 그 위에 목표 속도를 누가/어떻게 정해 주는지는 12.3.5의 트레이드오프로 분리한다.

12.3.3 하드웨어 구성 — 모터·드라이버·엔코더·섀시

  • 모터: 엔코더 내장 기어드 DC모터(쿼드러처 A/B 2채널, 또는 단일 채널 카운트만) 2개.
  • 모터 드라이버: TB6612FNG 같은 2채널 H-브리지 — 채널당 방향 핀 2개 + PWM 핀 1개(8.4.1과 동일 구성을 두 배로).
  • 전원: 모터 구동 전압은 UNO Q의 로직 전압(3.3V, 2.1.4)과 분리하는 것이 원칙이다 — 드라이버의 모터 전원(VM)은 배터리로, 로직 전원(VCC)은 UNO Q 3.3V로 따로 공급하고 GND는 반드시 공통으로 묶는다.
  • 섀시: 3D 프린팅 차동구동 섀시에 UNO Q·배터리·모터 마운트 위치를 미리 정해 설계한다(11.1) — 무게중심이 구동축에 가깝고 낮을수록 주행이 안정적이다.

12.3.3.1 핀 배정 — PWM 타이머 공유 이슈를 피해서 고른다

4.3.3은 UNO Q에서 실제로 보고된 문제를 다룬다 — 한 PWM 핀에서 analogWrite()를 켜면, 같은 STM32 타이머를 공유하는 다른 핀이 GPIO로도 동작하지 않게 되는 경우가 있다. 이 로봇은 PWM 2채널(좌·우 모터 속도) + 방향용 디지털 4채널 + 엔코더용 인터럽트 2채널을 동시에 쓰므로, 핀을 무작위로 고르면 이 문제가 그대로 발생한다. 2.2.2·4.3.1의 타이머 그룹 표를 근거로, 서로 다른 타이머 그룹에서만 PWM을 켜고, 그 타이머를 공유하는 다른 핀은 전혀 쓰지 않는 배정을 택했다.

신호핀타이머(2.2 기준)비고
좌측 모터 PWM~D9TIM4_CH3PWM. 같은 타이머 핀 D10은 이 프로젝트에서 미사용
좌측 모터 방향 IN1D4TIM1_ETR(채널 아님)PWM 비지정 핀 — 안전
좌측 모터 방향 IN2D7TIM8_CH4N(단독)그룹 내 공유 핀 없음 — 안전
우측 모터 PWM~D6TIM3_CH4PWM. 같은 타이머 핀 D3·D8은 이 프로젝트에서 미사용
우측 모터 방향 IN1D12TIM1_CH2NTIM1 그룹이지만 D5·D11(TIM1 PWM 채널)을 안 쓰므로 안전
우측 모터 방향 IN2D13TIM1_CH1N위와 동일
좌측 엔코더 AD2TIM2_CH2외부 인터럽트
우측 엔코더 AD21TIM2_CH3(I2C2_SCL과 겸용)이 프로젝트에서 I2C2는 안 쓰므로 GPIO로 전용

D0/D1(UART)과 Qwiic(I2C4, QWIIC1 커넥터)은 사용하지 않았으므로 이 로봇에 추가로 IMU 등을 Modulino로 연결할 여지가 남는다. 엔코더 B상(방향 판별)을 쓰려면 남은 아날로그 핀(A2/D16 등)을 디지털 입력으로 폴링하는 방법을 검토한다 — 다만 A2(TIM3_CH2)는 우측 PWM(D6, TIM3_CH4)과 같은 TIM3 그룹이라, PWM이 활성화된 동안 A2를 GPIO로 쓰는 것이 안전한지는 이 책이 확인하지 못했다(검증 필요) — 안전한 방법은 8.4처럼 A상 단일 채널 카운트로 단순화하거나, TIM2 그룹의 남은 핀(A0/D14, TIM2_CH1)을 B상으로 쓴다.

12.3.4 MCU에서 PID 속도 제어 루프 (8.4의 2채널 확장)

sketch/sketch.ino:

#include <Arduino_RouterBridge.h>

// ===== 핀 정의 (12.3.3.1의 타이머 그룹 분석에 따른 배정) =====
const int L_PWM = 9, L_IN1 = 4, L_IN2 = 7, L_ENC_A = 2;
const int R_PWM = 6, R_IN1 = 12, R_IN2 = 13, R_ENC_A = 21;

volatile long leftTicks = 0, rightTicks = 0;

void onLeftEncoder()  { leftTicks++; }   // 방향 판별 없이 단순 카운트(8.4.6과 같은 단순화)
void onRightEncoder() { rightTicks++; }

// 8.4.2.3의 PID 구조를 그대로 재사용 — 좌우 두 인스턴스로 복제
struct PID {
  float Kp, Ki, Kd;
  float integral = 0, prevError = 0;
  float update(float target, float measured, float dt) {
    float error = target - measured;
    integral += error * dt;
    const float INTEGRAL_LIMIT = 255.0f / max(Ki, 0.001f);   // 와인드업 방지(8.4.4)
    integral = constrain(integral, -INTEGRAL_LIMIT, INTEGRAL_LIMIT);
    float derivative = (error - prevError) / dt;
    prevError = error;
    float output = Kp * error + Ki * integral + Kd * derivative;
    return constrain(output, -255.0f, 255.0f);
  }
};

PID leftPid{2.0f, 8.0f, 0.02f};
PID rightPid{2.0f, 8.0f, 0.02f};

float targetLeftSpeed = 0, targetRightSpeed = 0;   // ticks/s — Bridge로 MPU가 갱신(12.3.5)

unsigned long lastControlTime = 0;
const unsigned long CONTROL_PERIOD_MS = 20;   // 50Hz — 8.4.2.1의 주기 설계 원칙 참고

void setMotor(int pwmPin, int in1, int in2, float cmd) {
  int pwm = constrain((int)fabs(cmd), 0, 255);
  digitalWrite(in1, cmd >= 0);
  digitalWrite(in2, cmd < 0);
  analogWrite(pwmPin, pwm);
}

bool set_target_speeds(float leftSpeed, float rightSpeed) {
  targetLeftSpeed = leftSpeed;
  targetRightSpeed = rightSpeed;
  return true;
}

void setup() {
  pinMode(L_PWM, OUTPUT); pinMode(L_IN1, OUTPUT); pinMode(L_IN2, OUTPUT);
  pinMode(R_PWM, OUTPUT); pinMode(R_IN1, OUTPUT); pinMode(R_IN2, 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);

  Bridge.begin();
  Monitor.begin(115200);
  Bridge.provide_safe("set_target_speeds", set_target_speeds);

  lastControlTime = millis();
}

void loop() {
  unsigned long now = millis();
  if (now - lastControlTime >= CONTROL_PERIOD_MS) {
    float dt = (now - lastControlTime) / 1000.0f;
    lastControlTime = now;

    noInterrupts();
    long lt = leftTicks;  leftTicks = 0;
    long rt = rightTicks; rightTicks = 0;
    interrupts();

    float leftSpeed  = lt / dt;   // ticks/s — 엔코더 분해능에 맞춰 실제 단위로 환산(8.4.2.2)
    float rightSpeed = rt / dt;

    float leftCmd  = leftPid.update(targetLeftSpeed, leftSpeed, dt);
    float rightCmd = rightPid.update(targetRightSpeed, rightSpeed, dt);

    setMotor(L_PWM, L_IN1, L_IN2, leftCmd);
    setMotor(R_PWM, R_IN1, R_IN2, rightCmd);
  }
}

set_target_speeds()만 Bridge.provide_safe()로 열어 두면(3.4.4의 안전 콜백 규칙), 상위(ROS2든 순수 스케치든)는 "목표 속도"만 주면 되고 50Hz 폐루프 추종은 MCU가 전담한다 — MPU가 느려지거나 잠깐 멈춰도(예: 부하가 큰 추론 작업 중, 12.2와 결합하는 경우) 바퀴 제어는 끊기지 않는다는 것이 이 구조의 핵심 이점이다(8.1의 결정론적 타이밍 논의와 직결). 안티와인드업·미분 필터를 더 다듬은 버전이 필요하면 8.4.4의 튜닝 팁을 그대로 적용한다.

12.3.5 상위 명령 경로 선택 — ROS2 Jazzy vs 순수 스케치

기준① ROS2 Jazzy (7.1)② 순수 스케치(시리얼/조이스틱 직접)
진입 장벽높음 — 커뮤니티 방법(주로 Docker), 공식 지원 아님(7.1.1)낮음 — 추가 설치 없이 스케치만으로 완결
확장성높음 — nav2(경로 계획), SLAM, 표준 인터페이스(/cmd_vel)로 연동낮음 — 확장하려면 직접 프로토콜을 늘려야 함
지연·오버헤드DDS/미들웨어 오버헤드 존재(7.2의 Zenoh로 일부 완화 가능)최소 — 직접 시리얼/RPC 왕복만
보드 자원 요구큼 — 4GB/32GB 모델 강력 권장(7.1.3)2GB 모델로도 충분
디버깅 도구rviz2(단, 보드 위에서 직접 실행하지 않는 것이 커뮤니티 관행, 7.1.5), ros2 topic echo 등직접 로깅·시각화를 만들어야 함
적합한 목표내비게이션·다중 로봇·연구용 확장이 목표일 때빠른 프로토타입, 교육용, 리소스를 최소로 쓰고 싶을 때

① ROS2 Jazzy 경로: 7.1.6이 이미 확인했듯 ROS2 노드는 STM32 MCU와 직접 통신하지 않는다 — ROS2는 MPU(Linux) 쪽 Docker 컨테이너 안에서만 존재하고(7.1.2의 견해대로 Docker를 먼저 권한다), MCU 쪽은 순수 Zephyr 기반 스케치로 남아 있으며, 둘 사이는 UNO Q 고유의 Bridge를 거친다. 커뮤니티 저장소 miguelgonrod/ros_arduino_uno_Q가 /led_status 토픽 → Bridge RPC → MCU LED 제어라는 최소 패턴을 이미 검증해 공개했다(7.1.5) — 이 로봇은 같은 패턴을 /cmd_vel로 확장한 것이다.

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from arduino.app_utils import *

WHEEL_BASE = 0.15        # m, 실측값으로 교체
TICKS_PER_METER = 2000   # ticks/m, 사용하는 모터의 엔코더 분해능×기어비 ÷ 바퀴 둘레로 실측 후 교체

class CmdVelBridge(Node):
    def __init__(self):
        super().__init__('cmdvel_bridge')
        self.create_subscription(Twist, '/cmd_vel', self.on_cmd_vel, 10)

    def on_cmd_vel(self, msg: Twist):
        v, w = msg.linear.x, msg.angular.z   # m/s, rad/s
        left_mps  = v - w * WHEEL_BASE / 2
        right_mps = v + w * WHEEL_BASE / 2
        # MCU의 set_target_speeds()는 ticks/s를 기대한다(12.3.4) — m/s를 그대로 넘기면 단위가 틀어진다.
        left  = left_mps * TICKS_PER_METER
        right = right_mps * TICKS_PER_METER
        Bridge.call("set_target_speeds", left, right)

rclpy.init()
rclpy.spin(CmdVelBridge())

set_target_speeds()가 받는 두 값은 ticks/s다(12.3.4의 MCU 스케치가 그 단위로 PID를 실행한다). /cmd_vel은 m/s·rad/s이므로 TICKS_PER_METER로 반드시 환산해야 한다 — 이 상수를 빼먹고 m/s 값을 그대로 넘기면 목표값의 크기가 실제 필요한 ticks/s와 수백~수천 배 어긋나 로봇이 거의 움직이지 않거나(과소) PID가 포화된 채로 폭주(과대)할 수 있다. TICKS_PER_METER는 바퀴 지름을 실측해 둘레를 구하고, 엔코더 분해능(PPR)과 기어비를 곱한 뒤 둘레로 나눠 계산한다.

이 노드는 mjs513/Arduino-Uno-Q-and-ROS2-Implementation 또는 miguelgonrod/ros_arduino_uno_Q(7.1.5)가 공개한 Docker 이미지(ros-jazzy-base, GUI 패키지 제외) 안에서 실행하는 것을 전제한다. ROS2 Jazzy 자체는 공식 지원 대상이 아니라 커뮤니티가 검증한 방법이라는 점을 다시 강조한다(BK-04, 7.1.1) — Debian Trixie는 REP 2000의 Tier 1 플랫폼(Ubuntu 24.04)이 아니므로 apt install ros-jazzy-*가 호스트에서 곧바로 동작하지 않는다.

② 순수 스케치 경로: ROS2 없이 Python 쪽에서 조이스틱/게임패드 입력을 읽어 Bridge로 바로 set_target_speeds()를 호출한다.

from arduino.app_utils import *
import pygame

pygame.init()
joystick = pygame.joystick.Joystick(0)
joystick.init()

MAX_SPEED = 300  # ticks/s

def loop():
    pygame.event.pump()
    v = -joystick.get_axis(1) * MAX_SPEED   # 전후
    w = joystick.get_axis(0) * MAX_SPEED    # 좌우 회전
    Bridge.call("set_target_speeds", v - w, v + w)

App.run(user_loop=loop)

견해: ROS2·nav2로 자율주행·SLAM까지 확장할 계획이 뚜렷하다면 처음부터 ①을 택하는 편이 나중에 다시 만드는 수고를 줄인다 — 단, 7.1.3이 경고하듯 2GB 모델에서는 시도하지 않는다. "우선 동작하는 로봇"이 목표거나 리소스가 부족하다면 ②로 시작해도 충분하고, 나중에 필요해지면 ①의 CmdVelBridge 노드만 추가해 전환할 수 있다 — set_target_speeds() RPC 인터페이스를 미리 고정해 두면 상위 경로를 교체하기 쉽다는 점이 이 설계의 장점이다.

12.3.6 엣지케이스와 트러블슈팅

  • 좌우 모터 편차: 같은 PWM 값에도 두 모터의 실제 회전 속도가 미묘하게 다르다(개체차) — PID가 이를 흡수하긴 하지만, 정지 상태에서 큰 오프셋이 있으면 진동이 생길 수 있다. 무부하 상태에서 좌우 게인을 개별 튜닝하거나 트림(offset) 보정을 추가한다.
  • 배터리 전압 처짐: 배터리가 방전되며 같은 PWM에도 모터 토크가 줄어든다 — PID의 적분(I) 항이 이를 보정하지만, 지나치게 방전된 배터리에서는 포화(saturation)가 지속될 수 있다 — 배터리 전압 모니터링과 저전압 컷오프를 5.5와 함께 설계한다.
  • 엔코더 노이즈: 저가 홀센서 엔코더는 전기적 노이즈로 틱이 튈 수 있다 — 인터럽트 핀에 소프트웨어 디바운스(짧은 시간 내 중복 트리거 무시) 또는 하드웨어 RC 필터를 추가한다(8.4.6과 동일한 주의).
  • PWM 타이머 공유 재발 확인: 12.3.3.1에서 타이머 그룹 충돌을 피하도록 핀을 골랐지만, 실제 배선을 바꾸거나 핀을 추가할 때는 2.2.2·4.3.1의 타이머 그룹 표를 다시 대조한다 — ArduinoCore-zephyr 0.55.2 이상으로 갱신하면 원래 이슈 자체가 해결됐다고 보고되므로(4.3.3), 보드 패키지 버전 확인이 가장 빠른 확인 경로다.
  • attachInterrupt 미지원 시 대안: 검증 결과 특정 핀에서 외부 인터럽트가 기대대로 동작하지 않는다면, 고정 주기 폴링(예: 1ms 타이머 인터럽트에서 엔코더 핀 상태를 읽어 에지를 소프트웨어로 검출)으로 대체할 수 있다 — 8.2에서 타이머 기반 폴링과 실제 엣지 인터럽트의 트레이드오프를 다룬다.
  • 모터 드라이버 발열: 지속 부하에서 H-브리지 IC가 뜨거워질 수 있다 — 방열판·섀시 통풍구를 11.1.4의 방열 원칙에 따라 설계한다.
  • Bridge 호출 주기와 제어 주기 불일치·연결 끊김: 상위에서 set_target_speeds()를 너무 뜸하게 부르면 그 사이 목표 속도가 고정된 채 유지된다 — 대개 의도된 동작이지만, "일정 시간 명령이 없으면 정지"하는 안전장치(fail-safe)를 추가하는 것이 실전에서는 중요하다. 이는 8.5.7이 MAVLink 워치독에서 다루는 것과 정확히 같은 설계 원칙이다 — "마지막 유효값을 얼마나 신뢰할지, 타임아웃 후 안전 상태로 전환할지"를 명시적으로 코드에 넣는다.

12.3.7 확장 아이디어 — MAVLink 실시간 제어 개념의 응용 (가능성 수준)

8.5은 STM32(MCU)가 MAVLink c_library_v2로 별도의 Pixhawk급 비행 컨트롤러에 오프보드·가이디드 setpoint를 스트리밍하는 구조를 다룬다. 같은 "MCU가 실시간으로 setpoint를 스트리밍하는" 패턴은 개념적으로 지상 로봇에도 적용할 여지가 있다 — 예를 들어 이 차동구동 로봇의 상위 제어를 ArduPilot Rover 펌웨어가 구동되는 별도의 소형 FC급 보드로 옮기고, UNO Q의 STM32U585는 그 보드에 MAVLink SET_POSITION_TARGET_LOCAL_NED(속도 성분)로 조향 명령을 실시간 스트리밍하는 역할을 맡기는 구성이다.

다만 이는 가능성 수준의 아이디어이지 검증된 구성이 아니다. 8.5.1이 이미 밝혔듯 STM32U585 자체는 ArduPilot/PX4급 펌웨어를 직접 구동할 하드웨어가 아니므로(ArduPilot의 hwdef 스크립트에 STM32U5 계열이 확인되지 않는다는 것이 근거다), 이 확장을 시도한다면 반드시 ⓐ별도의 실제 FC(또는 ArduPilot Rover가 구동되는 보드)를 두고, ⓑUNO Q는 어디까지나 8.5.8이 정리한 "MCU가 실시간 외부 제어기 역할을 맡는" 구성으로만 배치해야 한다. 이 장의 자체 PID 루프(단순 좌우 바퀴 속도 추종, 12.3.4)와 8.5의 MAVLink 오프보드 제어(위치·속도 setpoint 프로토콜)는 추상화 수준이 다르므로, 실제로 결합하려면 두 장의 내용을 각각 별도로 검증한 뒤 통합 설계가 필요하다.

더 현실적인 확장으로는 12.2의 카메라 비전 결과를 이 로봇에 결합해 "탐지한 대상을 향해 이동"하는 결합, 또는 12.1의 대시보드로 로봇의 실시간 속도·배터리 상태를 원격 모니터링하는 결합이 있다 — 둘 다 이번 부(제12부)의 다른 두 프로젝트와 자연스럽게 이어진다.