로봇의 두뇌와 반사신경을 나누기
카메라가 달린 이동 로봇을 떠올려 봅시다. 로봇은 웹캠으로 앞을 보고, 화면 속 빨간 공을 찾고, 공이 왼쪽에 있으면 왼쪽으로 돌고, 가까워지면 멈춰야 합니다. 여기까지만 보면 라즈베리파이 하나로 모든 일을 처리할 수 있을 것 같습니다. 실제로 라즈베리파이는 카메라 영상 처리, OpenCV, 웹 서버, 네트워크 통신, 간단한 AI 추론까지 할 수 있는 강력한 작은 컴퓨터입니다.
그런데 로봇이 실제 바닥 위를 달리기 시작하면 이야기가 달라집니다. 모터는 일정한 간격으로 PWM 신호를 받아야 하고, 엔코더 펄스는 놓치지 않고 세어야 하며, 장애물이 갑자기 가까워지면 즉시 멈춰야 합니다. 이 일들은 빠르고 규칙적인 반응이 중요합니다. 라즈베리파이는 똑똑하지만, 운영체제 위에서 여러 작업을 동시에 처리하기 때문에 항상 정확한 시간 간격으로 모터를 제어한다고 보장하기 어렵습니다.
그래서 실전 로봇에서는 역할을 나누는 구조를 자주 사용합니다. 라즈베리파이는 큰 판단을 맡고, 아두이노나 ESP32 같은 마이크로컨트롤러는 빠른 하드웨어 제어를 맡습니다. 비유하자면 라즈베리파이는 눈과 두뇌이고, 마이크로컨트롤러는 반사신경과 손발입니다.
이 장에서는 두 보드를 연결해 더 안정적인 로봇 제어 구조를 만드는 방법을 배웁니다. 핵심은 단순합니다. 라즈베리파이는 "왼쪽 바퀴 120, 오른쪽 바퀴 120으로 가라" 같은 명령을 보내고, 마이크로컨트롤러는 그 명령을 실제 모터 출력으로 바꾸며 센서 상태를 다시 알려줍니다.

라즈베리파이가 잘하는 일, 마이크로컨트롤러가 잘하는 일
라즈베리파이와 마이크로컨트롤러는 생김새가 비슷해 보여도 성격이 다릅니다. 라즈베리파이는 리눅스 운영체제가 올라가는 작은 컴퓨터입니다. 파일을 저장하고, 카메라 영상을 처리하고, 파이썬 프로그램을 실행하고, Wi-Fi로 웹 페이지를 띄우는 일이 자연스럽습니다. 반면 아두이노나 ESP32는 운영체제 없이 또는 매우 가벼운 실행 환경에서 사용자가 작성한 코드가 거의 직접 하드웨어를 제어합니다. 그래서 정해진 주기로 핀을 켜고 끄거나 센서 값을 읽는 일에 강합니다.
실전 로봇에서는 보통 다음처럼 역할을 나눕니다.
라즈베리파이: 카메라 입력, 영상 처리, AI 추론, 웹 서버, 원격 조종 화면, 고수준 주행 판단
마이크로컨트롤러: 모터 PWM 출력, 엔코더 카운트, 서보 제어, 거리 센서 읽기, 긴급 정지 처리
두 보드 사이의 통신: 속도 명령, 방향 명령, 센서 상태, 배터리 상태, 오류 메시지 전달
예를 들어 카메라 로봇에서 라즈베리파이가 화면 중앙보다 왼쪽에 물체가 있다고 판단했다고 합시다. 라즈베리파이가 직접 모터 드라이버의 모든 핀을 제어할 수도 있지만, 더 안정적인 방식은 마이크로컨트롤러에게 이렇게 말하는 것입니다.
V,-80,120이 메시지는 "왼쪽 바퀴는 -80, 오른쪽 바퀴는 120으로 움직여라"라는 뜻으로 정할 수 있습니다. 그러면 마이크로컨트롤러는 이 값을 받아 모터 드라이버에 PWM과 방향 신호를 출력합니다. 동시에 엔코더를 읽고, 거리 센서 값을 확인하고, 너무 가까운 장애물이 있으면 라즈베리파이의 명령보다 안전을 우선하여 멈출 수도 있습니다.
이 구조의 장점은 로봇이 복잡해질수록 더 커집니다. 라즈베리파이 쪽 프로그램이 잠깐 느려져도 마이크로컨트롤러는 마지막 명령을 일정 시간만 유지하다가 자동으로 정지할 수 있습니다. 영상 처리 코드가 무거워져도 엔코더 카운트는 계속 정확히 세어집니다. 웹 페이지가 새로고침되어도 모터 제어 루프는 흔들리지 않습니다.
라즈베리파이만으로 정밀 제어가 어려운 이유
라즈베리파이는 매우 빠른 보드입니다. 하지만 빠르다는 것과 실시간으로 동작한다는 것은 다릅니다. 실시간 제어에서 중요한 것은 "언젠가는 빠르게 처리한다"가 아니라 "정해진 시간 안에 반드시 처리한다"입니다.
라즈베리파이는 리눅스 운영체제를 사용합니다. 운영체제는 사용자가 실행한 로봇 프로그램뿐 아니라 네트워크, 파일 시스템, 화면 출력, USB 장치, 백그라운드 서비스 등을 함께 관리합니다. 그래서 우리가 작성한 파이썬 코드가 10ms마다 정확히 실행되기를 기대해도, 어느 순간 다른 작업 때문에 30ms, 100ms, 그 이상 늦어질 수 있습니다.
모터 제어에서는 이런 작은 지연이 실제 움직임으로 드러납니다. 예를 들어 소프트웨어로 PWM을 만들고 있는데 운영체제가 잠깐 다른 일을 처리하면, 모터에 들어가는 펄스 폭이 흔들릴 수 있습니다. 엔코더 펄스를 직접 읽는 중이라면 빠른 회전에서 일부 펄스를 놓칠 수도 있습니다. 카메라 영상 처리 중 CPU 사용량이 갑자기 올라가면 모터 명령 갱신 주기가 불규칙해질 수도 있습니다.
물론 라즈베리파이로도 간단한 로봇을 움직일 수 있습니다. 느린 속도의 로봇, 정밀도가 크게 중요하지 않은 장난감 수준의 프로젝트라면 라즈베리파이 GPIO만으로도 충분할 때가 있습니다. 하지만 다음과 같은 상황에서는 마이크로컨트롤러를 함께 쓰는 편이 훨씬 안정적입니다.
엔코더 펄스를 빠짐없이 세어야 할 때
일정한 주기로 모터 속도를 보정해야 할 때
서보나 모터 PWM 신호를 안정적으로 유지해야 할 때
카메라, 웹 서버, AI 처리처럼 CPU를 많이 쓰는 작업이 함께 돌아갈 때
통신이 끊기거나 프로그램이 멈췄을 때 로봇이 자동으로 정지해야 할 때
여기서 중요한 감각은 "라즈베리파이가 부족해서 마이크로컨트롤러를 붙이는 것"이 아닙니다. 서로 잘하는 일이 다르기 때문에 역할을 분리하는 것입니다. 좋은 로봇 구조는 하나의 보드가 모든 일을 억지로 떠안는 구조가 아니라, 각 부품이 가장 잘하는 일을 맡는 구조입니다.
가장 쉬운 연결 방법은 USB 시리얼이다
라즈베리파이와 아두이노 또는 ESP32를 연결하는 가장 쉬운 방법은 USB 케이블입니다. 아두이노를 PC에 연결해 스케치를 업로드할 때 쓰던 바로 그 방식입니다. 라즈베리파이의 USB 포트에 아두이노나 ESP32를 꽂으면, 라즈베리파이에서는 보통 /dev/ttyACM0 또는 /dev/ttyUSB0 같은 시리얼 장치로 보입니다.
USB 시리얼 방식은 초보자에게 특히 좋습니다. 별도의 TX/RX 배선을 하지 않아도 되고, 전압 레벨 문제도 대부분 USB 인터페이스 안에서 처리됩니다. 라즈베리파이에서 파이썬의 serial 라이브러리를 사용하면 PC에서 아두이노와 통신하던 것처럼 명령을 주고받을 수 있습니다.
기본 연결은 다음과 같습니다.
라즈베리파이 USB 포트 ── USB 케이블 ── 아두이노 또는 ESP32
마이크로컨트롤러 ── 모터 드라이버 ── DC 모터
마이크로컨트롤러 ── 센서들
배터리 ── 모터 전원, 보드 전원 변환기USB 케이블 하나로 데이터 통신은 해결됩니다. 다만 모터 전원까지 라즈베리파이 USB에서 끌어오면 안 됩니다. 모터는 순간적으로 큰 전류를 요구하고, 그 전류가 라즈베리파이 전원을 흔들면 보드가 재부팅되거나 USB 연결이 끊길 수 있습니다. 모터는 별도 배터리와 모터 드라이버를 통해 구동하고, 보드 전원도 안정적인 전압 변환기를 사용하는 것이 좋습니다.
USB 대신 라즈베리파이의 GPIO UART 핀을 직접 사용할 수도 있습니다. 이때는 반드시 TX와 RX를 교차 연결합니다.
라즈베리파이 TX ──> 마이크로컨트롤러 RX
라즈베리파이 RX <── 마이크로컨트롤러 TX
라즈베리파이 GND ── 마이크로컨트롤러 GND하지만 GPIO UART를 직접 연결할 때는 전압 레벨을 꼭 확인해야 합니다. 라즈베리파이 GPIO는 3.3V 신호를 사용하며, 5V 입력에 안전하지 않습니다. 아두이노 Uno나 Nano의 TX 핀은 보통 5V로 동작하므로 라즈베리파이 RX에 직접 연결하면 위험합니다. 이 경우 로직 레벨 변환기나 저항 분압 회로를 사용해야 합니다. ESP32는 3.3V 로직을 사용하므로 라즈베리파이와 전압 레벨이 맞지만, 그래도 각 보드의 핀 사양을 확인하는 습관이 필요합니다.
통신은 복잡할수록 좋은 것이 아니다
두 보드를 연결하면 곧바로 이런 유혹이 생깁니다. "메시지를 JSON으로 보낼까? 체크섬도 넣고, 시간 정보도 넣고, 명령마다 ID도 넣고, 나중에 확장할 수 있게 길게 만들까?" 물론 큰 프로젝트에서는 그런 설계가 필요할 수 있습니다. 하지만 처음에는 너무 복잡한 통신 규칙이 오히려 디버깅을 어렵게 만듭니다.
실습용 로봇에서는 사람이 시리얼 모니터로 읽을 수 있는 간단한 텍스트 프로토콜이 좋습니다. 한 줄에 하나의 메시지를 보내고, 줄 끝은 n으로 끝내는 방식입니다. 예를 들어 다음처럼 정할 수 있습니다.
V,120,120첫 글자 V는 velocity, 즉 속도 명령을 뜻합니다. 두 번째 값은 왼쪽 모터 출력, 세 번째 값은 오른쪽 모터 출력입니다. 값의 범위는 프로젝트에 따라 다르지만, 여기서는 -255부터 255까지로 정해 보겠습니다. 양수는 전진, 음수는 후진, 0은 정지입니다.
정지 명령은 더 단순하게 만들 수 있습니다.
STOP상태 요청은 다음처럼 정할 수 있습니다.
Q마이크로컨트롤러는 상태를 다음과 같은 한 줄로 응답합니다.
STAT,1234,1210,35,7400이 메시지는 왼쪽 엔코더 카운트가 1234, 오른쪽 엔코더 카운트가 1210, 거리 센서 값이 35cm, 배터리 전압이 7400mV라는 뜻으로 약속할 수 있습니다. 아직 엔코더나 배터리 측정을 붙이지 않았다면 값 일부를 0으로 보내도 됩니다.
이처럼 메시지 규칙을 정할 때는 몇 가지 원칙을 지키면 좋습니다.
한 줄에 하나의 명령만 보낸다.
각 메시지는
n으로 끝낸다.명령 이름은 짧고 사람이 읽을 수 있게 만든다.
숫자의 단위를 미리 정한다.
잘못된 명령을 받으면 무시하거나
ERR메시지로 응답한다.일정 시간 새 명령이 없으면 모터를 자동으로 정지한다.
마지막 원칙이 특히 중요합니다. 무선 조종 로봇이나 카메라 로봇에서 통신이 잠깐 끊겼는데 마지막 명령이 "전진"이었다면 로봇은 계속 전진할 수 있습니다. 이를 막기 위해 마이크로컨트롤러는 마지막 명령을 받은 시간을 기억하고, 예를 들어 300ms 동안 새 속도 명령이 없으면 자동으로 정지하도록 만들어야 합니다. 이것을 타임아웃 또는 페일세이프라고 부릅니다.
명령과 상태 메시지 설계하기
이 장의 예제에서는 다음과 같은 간단한 메시지 형식을 사용하겠습니다.
라즈베리파이에서 마이크로컨트롤러로 보내는 명령은 다음과 같습니다.
V,left,right
STOP
Q각 명령의 의미는 다음과 같습니다.
| 명령 | 예 | 의미 |
|---|---|---|
V,left,right | V,120,120 | 왼쪽, 오른쪽 모터 출력 지정 |
STOP | STOP | 즉시 정지 |
Q | Q | 현재 상태 요청 |
마이크로컨트롤러에서 라즈베리파이로 보내는 응답은 다음과 같습니다.
OK
ERR,message
STAT,encL,encR,dist,batt각 응답의 의미는 다음과 같습니다.
| 응답 | 예 | 의미 |
|---|---|---|
OK | OK | 명령을 정상 처리함 |
ERR,message | ERR,BADCMD | 잘못된 명령 또는 처리 실패 |
STAT,encL,encR,dist,batt | STAT,1234,1210,35,7400 | 센서와 상태값 보고 |
이런 텍스트 기반 메시지는 속도가 아주 빠르지는 않지만, 로봇 제어 입문 단계에서는 충분히 좋습니다. 무엇보다 시리얼 모니터에서 직접 입력해 시험할 수 있다는 장점이 큽니다. 로봇이 움직이지 않을 때 V,100,100을 손으로 보내 보고, 응답이 오는지 바로 확인할 수 있습니다.
프로젝트가 커지면 나중에 체크섬, 순번, 바이너리 패킷 등을 추가할 수 있습니다. 하지만 처음부터 완벽한 통신 규약을 만들려고 하기보다, 눈으로 읽히고 쉽게 고칠 수 있는 방식으로 시작하는 편이 훨씬 빠릅니다.
마이크로컨트롤러 쪽 예제 코드
다음 예제는 아두이노와 ESP32에서 모두 비슷하게 사용할 수 있는 구조입니다. 실제 모터 핀 제어는 사용하는 드라이버에 따라 달라지므로, 여기서는 setMotorOutput() 함수 안에 들어갈 부분을 단순화했습니다. 앞에서 배운 TB6612, L298N 같은 모터 드라이버 제어 코드를 이 함수 안에 넣으면 됩니다.
const unsigned long COMMAND_TIMEOUT_MS = 300;
const int MAX_PWM = 255;
String lineBuffer = "";
unsigned long lastCommandTime = 0;
int leftOutput = 0;
int rightOutput = 0;
void setup() {
Serial.begin(115200);
// TODO: 모터 드라이버 핀을 OUTPUT으로 설정한다.
// pinMode(..., OUTPUT);
stopMotors();
lastCommandTime = millis();
Serial.println("OK,READY");
}
void loop() {
readSerialLines();
if (millis() - lastCommandTime > COMMAND_TIMEOUT_MS) {
stopMotors();
}
// TODO: 필요하다면 여기에서 엔코더, 거리 센서 등을 주기적으로 읽는다.
}
void readSerialLines() {
while (Serial.available() > 0) {
char c = Serial.read();
if (c == 'r') {
continue;
}
if (c == 'n') {
handleLine(lineBuffer);
lineBuffer = "";
} else {
lineBuffer += c;
if (lineBuffer.length() > 60) {
lineBuffer = "";
Serial.println("ERR,TOOLONG");
}
}
}
}
void handleLine(String line) {
line.trim();
if (line.length() == 0) {
return;
}
if (line == "STOP") {
stopMotors();
lastCommandTime = millis();
Serial.println("OK");
return;
}
if (line == "Q") {
sendStatus();
return;
}
if (line.startsWith("V,")) {
int firstComma = line.indexOf(',');
int secondComma = line.indexOf(',', firstComma + 1);
if (secondComma < 0) {
Serial.println("ERR,BADFMT");
return;
}
int left = line.substring(firstComma + 1, secondComma).toInt();
int right = line.substring(secondComma + 1).toInt();
left = constrain(left, -MAX_PWM, MAX_PWM);
right = constrain(right, -MAX_PWM, MAX_PWM);
leftOutput = left;
rightOutput = right;
setMotorOutput(leftOutput, rightOutput);
lastCommandTime = millis();
Serial.println("OK");
return;
}
Serial.println("ERR,BADCMD");
}
void setMotorOutput(int left, int right) {
// 이 함수 안에서 실제 모터 드라이버 핀을 제어한다.
// left와 right는 -255 ~ 255 범위라고 가정한다.
// 양수: 전진, 음수: 후진, 0: 정지
// 예:
// setOneMotor(LEFT_IN1, LEFT_IN2, LEFT_PWM, left);
// setOneMotor(RIGHT_IN1, RIGHT_IN2, RIGHT_PWM, right);
}
void stopMotors() {
leftOutput = 0;
rightOutput = 0;
setMotorOutput(0, 0);
}
void sendStatus() {
long encL = 0; // TODO: 엔코더 값을 연결하면 실제 값으로 바꾼다.
long encR = 0;
int dist = 0; // TODO: 거리 센서 값을 연결하면 cm 단위로 바꾼다.
int batt = 0; // TODO: 배터리 전압을 mV 단위로 바꾼다.
Serial.print("STAT,");
Serial.print(encL);
Serial.print(",");
Serial.print(encR);
Serial.print(",");
Serial.print(dist);
Serial.print(",");
Serial.println(batt);
}이 코드에서 가장 중요한 부분은 모터 제어 함수 자체보다 구조입니다. 시리얼로 한 줄씩 명령을 받고, V 명령이면 모터 출력을 갱신하고, STOP이면 멈추고, 일정 시간 동안 새 명령이 없으면 자동으로 멈춥니다. 이 구조만 제대로 잡아도 로봇의 안전성이 크게 올라갑니다.
delay()를 길게 사용하는 코드는 피하는 것이 좋습니다. delay(1000)이 들어가면 그 1초 동안 새 명령을 빨리 처리하지 못할 수 있습니다. 로봇 제어 코드는 가능하면 millis()를 이용해 시간을 확인하면서 짧게 반복되는 구조로 만드는 편이 좋습니다.
라즈베리파이 쪽 파이썬 예제
라즈베리파이에서는 파이썬으로 시리얼 포트를 열고 문자열 명령을 보내면 됩니다. USB로 연결한 아두이노나 ESP32가 /dev/ttyACM0 또는 /dev/ttyUSB0로 잡혔다고 가정해 보겠습니다.
import serial
import time
PORT = "/dev/ttyACM0"
BAUD = 115200
ser = serial.Serial(PORT, BAUD, timeout=0.1)
time.sleep(2.0) # 일부 아두이노는 시리얼 연결 시 리셋되므로 잠깐 기다린다.
def send_line(text):
message = text + "n"
ser.write(message.encode("utf-8"))
def read_line():
raw = ser.readline()
if not raw:
return None
return raw.decode("utf-8", errors="ignore").strip()
def set_velocity(left, right):
send_line(f"V,{left},{right}")
def stop():
send_line("STOP")
def request_status():
send_line("Q")
return read_line()
try:
set_velocity(120, 120)
start = time.time()
while time.time() - start < 2.0:
status = request_status()
if status:
print("status:", status)
time.sleep(0.1)
set_velocity(-80, 80)
time.sleep(1.0)
finally:
stop()
ser.close()이 코드는 2초 동안 전진한 뒤, 1초 동안 제자리 회전하고, 마지막에는 반드시 정지 명령을 보냅니다. try와 finally를 사용한 이유는 프로그램 실행 중 오류가 나거나 사용자가 중간에 종료하더라도 STOP 명령을 보내기 위해서입니다. 실제 로봇에서는 이런 작은 안전장치가 매우 중요합니다.
포트 이름은 환경에 따라 달라질 수 있습니다. USB 포트를 바꾸거나 보드를 여러 개 연결하면 /dev/ttyACM0가 /dev/ttyACM1로 바뀔 수도 있습니다. 라즈베리파이에서는 /dev/serial/by-id/ 아래의 장치 이름을 사용하면 더 안정적으로 특정 보드를 찾을 수 있습니다.
ls /dev/serial/by-id/여기에 보이는 긴 이름을 포트 경로로 사용하면 USB 순서가 바뀌어도 같은 장치를 가리키는 경우가 많습니다.
카메라 판단을 속도 명령으로 바꾸기
이제 앞 장에서 다룬 카메라 기반 로봇과 연결해 봅시다. 라즈베리파이가 OpenCV로 화면 속 물체의 중심 좌표를 찾았다고 합시다. 화면 너비가 640픽셀이고, 물체 중심이 cx라고 할 때 화면 중앙은 320입니다. 물체가 중앙보다 왼쪽에 있으면 왼쪽으로 돌고, 오른쪽에 있으면 오른쪽으로 돌아야 합니다.
가장 단순한 방식은 오차를 계산하는 것입니다.
error = cx - 320error가 음수이면 물체가 왼쪽, 양수이면 오른쪽에 있습니다. 이 오차를 이용해 좌우 바퀴 속도를 조금 다르게 만들 수 있습니다.
base = 100
turn = int(error * 0.3)
left = base + turn
right = base - turn
left = max(-255, min(255, left))
right = max(-255, min(255, right))
set_velocity(left, right)물체가 오른쪽에 있으면 error가 양수이고, turn도 양수가 됩니다. 그러면 왼쪽 바퀴가 더 빨라지고 오른쪽 바퀴가 느려져 로봇이 오른쪽으로 방향을 바꿉니다. 물체가 왼쪽에 있으면 반대가 됩니다.
이때 라즈베리파이는 "화면에서 물체가 어디에 있는지"만 판단합니다. PWM을 몇 번 핀에 출력할지, 모터 방향 핀을 어떻게 바꿀지, 엔코더를 어떻게 읽을지는 마이크로컨트롤러가 맡습니다. 역할이 깔끔하게 나뉘면 프로그램도 이해하기 쉬워집니다.
실제 로봇에서는 카메라 처리 주기가 일정하지 않을 수 있습니다. 어떤 순간에는 초당 30번 명령을 보낼 수 있고, 어떤 순간에는 영상 처리 때문에 잠깐 늦어질 수 있습니다. 그래서 마이크로컨트롤러 쪽 타임아웃이 필요합니다. 라즈베리파이가 새 명령을 보내지 못하는 상황이 생기면 로봇은 마지막 명령을 계속 수행하는 대신 스스로 정지해야 합니다.
센서 상태를 다시 받는 이유
처음에는 라즈베리파이가 명령만 보내도 충분해 보입니다. 하지만 조금만 로봇을 굴려 보면 상태 피드백이 필요해집니다. 라즈베리파이는 카메라 화면은 볼 수 있지만, 바퀴가 실제로 얼마나 돌았는지, 모터가 막혔는지, 앞쪽 장애물이 갑자기 가까워졌는지, 배터리가 낮아졌는지는 마이크로컨트롤러가 더 잘 알 수 있습니다.
그래서 마이크로컨트롤러는 주기적으로 상태를 보내는 것이 좋습니다. 예를 들어 100ms마다 다음과 같은 메시지를 보낼 수 있습니다.
STAT,2450,2438,42,7630라즈베리파이는 이 값을 읽고 로봇의 현재 상태를 화면에 표시하거나, 주행 판단에 반영할 수 있습니다. 거리 센서 값이 너무 작으면 카메라 판단과 관계없이 정지 명령을 보낼 수 있습니다. 배터리 전압이 낮으면 속도를 제한하거나 사용자에게 경고할 수 있습니다. 엔코더 값은 뒤에서 배울 속도 제어, 위치 제어, PID 제어의 기반이 됩니다.
상태 메시지를 보낼 때 너무 많은 값을 너무 자주 보내면 통신이 복잡해집니다. 처음에는 꼭 필요한 값만 보내는 것이 좋습니다. 바퀴 로봇이라면 왼쪽 엔코더, 오른쪽 엔코더, 거리 센서, 배터리 전압 정도면 충분합니다. 나중에 IMU, 모터 전류, 오류 코드 등을 추가할 수 있습니다.
직접 UART로 연결할 때 주의할 점
USB 시리얼이 가장 쉽지만, 로봇 내부 배선을 깔끔하게 만들고 싶거나 USB 포트를 아껴야 할 때는 라즈베리파이 GPIO UART와 마이크로컨트롤러 UART를 직접 연결할 수 있습니다. 이때는 몇 가지를 반드시 확인해야 합니다.
첫째, GND를 공통으로 연결해야 합니다. 신호선은 전압의 높고 낮음을 기준으로 0과 1을 구분합니다. 두 보드의 GND 기준이 서로 다르면 상대방의 신호를 올바르게 해석하기 어렵습니다. 라즈베리파이 TX와 마이크로컨트롤러 RX를 연결했다면, GND도 반드시 함께 연결해야 합니다.
둘째, 전압 레벨을 확인해야 합니다. 라즈베리파이 GPIO는 3.3V입니다. 라즈베리파이 RX 핀에 5V 신호를 넣으면 보드가 손상될 수 있습니다. 5V 아두이노의 TX를 라즈베리파이 RX에 연결할 때는 로직 레벨 변환기를 사용하거나 저항 분압으로 3.3V 수준으로 낮춰야 합니다. 반대로 라즈베리파이 TX의 3.3V 신호가 아두이노 RX에서 HIGH로 인식되는 경우가 많지만, 안정성을 위해 양방향 레벨 변환기를 사용하는 편이 좋습니다.
셋째, TX와 RX는 서로 교차 연결해야 합니다. TX는 transmit, 즉 보내는 핀이고 RX는 receive, 즉 받는 핀입니다. 보내는 쪽은 받는 쪽으로 연결되어야 하므로 TX-TX, RX-RX가 아니라 TX-RX, RX-TX가 됩니다. 통신이 되지 않을 때 가장 흔한 실수 중 하나가 이 배선을 반대로 하지 않은 경우입니다.
넷째, 라즈베리파이의 UART 포트가 다른 기능과 충돌하지 않는지 확인해야 합니다. 라즈베리파이 설정에 따라 UART가 로그인 콘솔로 사용되고 있을 수 있습니다. 이 경우 우리가 작성한 프로그램과 시스템 콘솔이 같은 포트를 동시에 사용하려 하면서 이상한 문자가 오가거나 통신이 불안정해질 수 있습니다. GPIO UART를 사용할 때는 라즈베리파이 설정에서 시리얼 콘솔을 끄고 UART 인터페이스를 켜는 식으로 정리해야 합니다.
다섯째, 한 포트를 두 프로그램이 동시에 열 수 없다는 점을 기억해야 합니다. 파이썬 프로그램이 /dev/ttyACM0를 사용 중인데, 다른 터미널에서 시리얼 모니터를 열면 충돌이 생길 수 있습니다. 디버깅할 때는 현재 어떤 프로그램이 포트를 사용 중인지 확인해야 합니다.
전원 연결은 통신보다 더 중요하다
두 보드가 통신으로 연결되면 마치 프로그램 문제만 신경 쓰면 될 것 같지만, 실제 로봇 고장의 상당수는 전원에서 시작됩니다. 특히 라즈베리파이는 전압 강하에 민감합니다. 모터가 출발하는 순간 배터리 전압이 출렁이고, 그 영향으로 라즈베리파이가 재부팅되면 통신도 끊기고 로봇도 예측하기 어렵게 움직입니다.
안정적인 구조는 보통 다음과 같습니다.
배터리
├─ 모터 드라이버 전원 입력
├─ 5V 전압 변환기 ── 라즈베리파이 전원
└─ 5V 또는 3.3V 전압 변환기 ── 마이크로컨트롤러 전원
라즈베리파이 GND ── 마이크로컨트롤러 GND ── 모터 드라이버 GND여기서 중요한 것은 모터 전류가 라즈베리파이 보드를 통과하지 않게 하는 것입니다. 라즈베리파이의 5V 핀이나 USB 포트에서 모터 전원을 끌어오면 안 됩니다. 모터는 모터 드라이버와 배터리 쪽에서 필요한 전류를 받아야 하고, 라즈베리파이는 안정적인 5V 전원을 따로 받아야 합니다.
GND 공통 연결도 잊으면 안 됩니다. 모터 드라이버, 마이크로컨트롤러, 라즈베리파이가 서로 신호를 주고받는다면 기준점인 GND를 공유해야 합니다. 다만 배선은 아무렇게나 길게 늘어뜨리기보다, 전류가 큰 모터 전원선과 신호선을 가능하면 분리하고, 굵은 전원선과 짧은 GND 경로를 사용하는 편이 좋습니다.
로봇이 모터를 켤 때마다 라즈베리파이 연결이 끊긴다면 코드를 고치기 전에 전원을 의심해야 합니다. 배터리 용량, 전압 변환기의 전류 용량, GND 연결, 모터 드라이버의 전원 배선, 노이즈 억제용 커패시터를 차례대로 확인하는 것이 좋습니다.
포트와 통신 문제 디버깅
시리얼 통신은 단순하지만, 처음 연결할 때는 작은 실수로 막히기 쉽습니다. 로봇이 반응하지 않을 때는 한 번에 모든 것을 의심하기보다 단계별로 좁혀 가야 합니다.
먼저 라즈베리파이가 장치를 인식했는지 확인합니다.
ls /dev/ttyACM*
ls /dev/ttyUSB*아무것도 보이지 않는다면 USB 케이블이 데이터 전송을 지원하지 않는 충전 전용 케이블일 수 있습니다. 다른 케이블을 사용해 보거나, 보드 전원이 제대로 들어오는지 확인합니다.
장치가 보이는데 통신이 되지 않는다면 보드레이트를 확인합니다. 마이크로컨트롤러 코드에서 Serial.begin(115200)을 사용했다면 라즈베리파이 파이썬 코드도 115200으로 열어야 합니다. 보드레이트가 맞지 않으면 깨진 문자가 나오거나 아무 응답도 받지 못할 수 있습니다.
다음으로 줄바꿈을 확인합니다. 이 장의 예제는 n이 들어와야 한 줄의 명령이 끝났다고 판단합니다. 파이썬에서 ser.write(b"V,100,100")처럼 줄바꿈 없이 보내면 마이크로컨트롤러는 아직 명령이 끝나지 않았다고 생각할 수 있습니다. 항상 V,100,100n처럼 끝에 줄바꿈을 붙입니다.
아두이노 Uno 계열은 USB 시리얼 포트를 여는 순간 자동으로 리셋될 수 있습니다. 라즈베리파이에서 포트를 열자마자 명령을 보내면 보드가 아직 준비되지 않았을 수 있습니다. 그래서 예제 코드에서는 time.sleep(2.0)으로 잠깐 기다렸습니다.
마지막으로, 모터가 움직이지 않는다고 해서 통신이 실패한 것은 아닐 수 있습니다. OK 응답은 오는데 모터가 돌지 않는다면 통신은 된 것입니다. 이때는 모터 드라이버 전원, 모터 배선, PWM 핀, 방향 핀, GND 공통 연결을 확인해야 합니다. 반대로 모터는 움직이는데 상태 메시지가 오지 않는다면 라즈베리파이 쪽 읽기 코드나 마이크로컨트롤러의 Serial.println() 부분을 확인합니다.
좋은 로봇 구조는 안전하게 멈춘다
로봇을 만들 때 "어떻게 움직일까"만큼 중요한 질문이 있습니다. "문제가 생기면 어떻게 멈출까?"입니다. 특히 라즈베리파이와 마이크로컨트롤러를 나누어 쓰는 구조에서는 이 질문이 더 중요합니다.
라즈베리파이 프로그램이 종료될 수 있습니다. Wi-Fi 연결이 끊길 수 있습니다. 카메라가 멈출 수 있습니다. USB 케이블 접촉이 불안정할 수 있습니다. 이 모든 상황에서 로봇이 마지막 속도 명령을 계속 수행하면 위험합니다. 따라서 마이크로컨트롤러는 라즈베리파이를 완전히 믿으면 안 됩니다. 일정 시간 동안 새 명령이 없으면 스스로 모터를 멈추는 규칙을 가져야 합니다.
라즈베리파이 쪽에서도 안전을 챙겨야 합니다. 프로그램이 끝날 때 STOP을 보내고, 카메라에서 목표물을 잃어버리면 속도를 0으로 만들고, 센서 상태가 일정 시간 들어오지 않으면 조종 명령을 중단해야 합니다. 로봇 제어에서 안전은 한 군데에만 두는 것이 아니라 여러 단계에 겹쳐 두는 편이 좋습니다.
실전에서는 다음과 같은 안전 규칙을 추천합니다.
마이크로컨트롤러는 300~500ms 동안 새 속도 명령이 없으면 자동 정지한다.
라즈베리파이는 프로그램 종료 시 반드시
STOP명령을 보낸다.거리 센서가 위험 거리를 감지하면 마이크로컨트롤러가 직접 모터를 멈출 수 있게 한다.
배터리 전압이 너무 낮으면 속도를 제한하거나 정지한다.
통신 오류가 반복되면 이전 명령을 유지하지 말고 안전 상태로 들어간다.
이런 규칙은 로봇이 똑똑해 보이게 만들지는 않습니다. 하지만 로봇을 오래, 안정적으로 굴릴 수 있게 해 줍니다. 좋은 로봇은 빠르게 달리는 로봇이 아니라, 문제가 생겼을 때 예측 가능한 방식으로 멈출 수 있는 로봇입니다.
전체 흐름 정리
이제 라즈베리파이와 마이크로컨트롤러가 함께 일하는 구조를 하나의 흐름으로 정리해 봅시다.
카메라 영상 입력
↓
라즈베리파이: 영상 처리, 목표 위치 판단
↓
시리얼 명령 전송: V,left,right
↓
마이크로컨트롤러: 모터 PWM 출력, 엔코더/센서 읽기
↓
상태 메시지 전송: STAT,encL,encR,dist,batt
↓
라즈베리파이: 다음 판단에 반영이 구조는 앞으로 더 복잡한 로봇으로 확장하기 좋습니다. 라인트레이서에서는 라즈베리파이 없이 마이크로컨트롤러만으로도 충분할 수 있습니다. 반대로 카메라 추적 로봇에서는 라즈베리파이가 영상을 보고, 마이크로컨트롤러가 모터를 제어하는 조합이 강력합니다. 나중에 ROS2나 AI 모델을 올리더라도 기본 구조는 크게 다르지 않습니다. 고수준 판단과 저수준 제어를 나누고, 두 영역 사이에 명확한 메시지를 주고받는 것입니다.
이 장까지 우리는 로봇의 하드웨어 구성, 전원, 모터, 센서, 통신을 차례대로 살펴보았습니다. 이제 다음 단계는 "원하는 대로 움직이게 만드는 법"입니다. 목표 속도와 실제 속도를 비교하고, 오차를 계산하고, 출력을 보정하는 과정이 필요합니다. 이것이 바로 제어입니다. 다음 장부터는 로봇이 단순히 명령을 받는 장치를 넘어, 스스로 상태를 보고 움직임을 조절하는 방법을 배워 보겠습니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.