|
|
[ A4988 ] : 처음 스텝모터를 만져보는 입문 단계이거나, 28BYJ-48 등 전류 소모가 적은 소형 모터를 돌릴 때 (가성비 최고)
[ DRV8825 ] : 3D 프린터 자작, CNC, 역진자 등 힘(토크)이 필요하고 정밀·저소음 제어가 필수적일 때 (성능 최고, 본 프로젝트 채택!)
두 모듈은 핀 배열이 호환되므로 나중에 교체도 쉽습니다.
모듈로 되어있어 고장시 모듈만 교체하면 수리가 쉬운 장점이 있어 많이 사용합니다.
🎯 유튜브에 DRV8825에 관해 자세한 설명 영상이 있어 소개합니다.
영문설명이니 자막을 한글로 설정해주세요^^
2장. 마이크로스텝과 RPM — 속도 제어와 정밀 동작 제어에 필요한 기능
▶ 2.1 마이크로스텝 — 부드러움의 마법
풀스텝(1.8°)으로 저속 회전하면 '툭툭툭' 끊기며 진동이 심합니다. 드라이버가 코일 전류를 사인파로 미세 조절하여 자석이 스텝 사이에 공중부양하듯 멈추게 하는 기술이 마이크로스텝입니다.
▶ 2.2 DRV8825 마이크로스텝 진실표 (TI 공식 데이터시트)
| ⚠️ 중요 — 마이크로스텝 올바른 핀 연결 ✅ 1/8 스텝 연결: M0=HIGH, M1=HIGH, M2=LOW ✅ 1/16 스텝 연결: M0=LOW, M1=LOW, M2=HIGH 내부 풀다운 저항(100kΩ) 덕분에 M0·M1은 미연결 시 자동 LOW M2 핀만 5V에 연결하는 것이 가장 간단한 1/16 설정 방법! |
| M0 | M1 | M2 | 마이크로스텝 | 1회전 펄스 | 비고 |
| L | L | L | Full (1/1) | 200 | 풀스텝, 토크 최대 |
| H | L | L | 1/2 | 400 | |
| L | H | L | 1/4 | 800 | |
| H | H | L | 1/8 | 1,600 | |
| L | L | H | 1/16 | 3,200 | ✅ 본 프로젝트 (M2만 HIGH) |
| H | L | H | 1/32 | 6,400 | |
| L | H | H | 1/32 | 6,400 | |
| H | H | H | 1/32 | 6,400 | 최고 부드러움 |
▶ 2.3 RPM ↔ SPS 변환 — 속도 감각 익히기
처음에 조금 어려운 부분입니다. 1/16 마이크로스텝 기준으로 rpm과 sps(초당 스텝수)의 관계를 간단히 살펴보겠습니다.
| 📐 변환 공식 1회전 = 200스텝(풀스텝) × 16 = 3,200 스텝 SPS(초당 스텝수) = rpm × 3,200 / 60 스텝 간격(µs) = 1,000,000 / SPS 10rpm → SPS = 10 × 3200 / 60 = 533 → 간격 1,876µs 20rpm → SPS = 20 × 3200 / 60 = 1,067 → 간격 937µs 30rpm → SPS = 30 × 3200 / 60 = 1,600 → 간격 625µs |
| rpm | SPS | 스텝 간격 | 체감 속도 |
| 10rpm | 533 sps | 1,876µs | 느림 — 천천히 부드럽게 |
| 20rpm | 1,067 sps | 937µs | 적당히 빠름 — 시원시원 ★ |
| 30rpm | 1,600 sps | 625µs | 빠름 — 힘차게 돌아감 ★ |
| 50rpm | 2,667 sps | 375µs | 매우 빠름 — 웅웅 소리 |
| 90rpm | 4,800 sps | 208µs | 한계 근접 — 탈조 주의! |
| 📐 각도별 스텝 수 (1/16 기준) 45° = 3,200 × 45/360 = 400 스텝 90° = 3,200 × 90/360 = 800 스텝 소요 시간 (20rpm = 1,067sps 기준): 45° → 400 ÷ 1,067 ≈ 0.37초 90° → 800 ÷ 1,067 ≈ 0.75초 |
3장. 브레드보드 회로 배선
▶ 3.1 부품 목록
| 부품 | 수량 | 비고 |
| 아두이노 Uno R3 | 1 | 또는 호환 보드 |
| OLED 128x64 I2C (SSD1306) | 1 | 8편부터 계속 사용 |
| MPU-6050 모듈 (GY-521) | 1 | 8편부터 계속 사용 |
| DRV8825 스텝모터 드라이버 | 1 | 이번 편 신규 |
| NEMA 17 스텝모터 | 1 | 정격 전류 1.2~1.5A 권장 |
| 전해 커패시터 100µF | 1 | VMOT 전원 보호 (필수!) |
| 외부 전원 12V DC | 1 | 모터 전용 |
| 브레드보드 830홀 | 1 | |
| 점퍼선 | 다수 |
▶ 3.2 핀 연결표 (OLED + MPU-6050 + DRV8825 통합)
| 부품 | 부품 핀 | 아두이노 | 비고 |
| OLED | VCC·GND·SDA·SCL | 5V·GND·A4·A5 | 8편 배선 그대로 |
| MPU-6050 | VCC·GND·SDA·SCL | 5V·GND·A4·A5 | I2C 공유 |
| DRV8825 | STEP | D3 | 스텝 펄스 |
| DRV8825 | DIR | D4 | 방향 |
| DRV8825 | EN | D5 | 활성/비활성 |
| DRV8825 | SLP·RST | 서로 쇼트 | 점퍼선 연결 필수 |
| DRV8825 | M0·M1·M2 | 0V, 0V, 5V | Micro step 설정 (여기서는 1/16으로 설정함) |
| DRV8825 | VMOT | 외부 12V(+) | 100µF 커패시터 필수 |
| DRV8825 | GND | 외부 12V(-) + 아두이노 GND | 공통 GND |
브레드보드 배선 (OLED + MPU-6050 + DRV8825 통합)
아두이노 + OLED + MPU-6050 + DRV8825 통합 브레드보드 실물 배선
| 🚨 이것만 지키면 보드가 살아남습니다 ① 100µF 커패시터 — VMOT와 GND 사이 반드시 연결 ② SLP·RST — 점퍼선으로 서로 쇼트 (없으면 드라이버 동작 안 함) ③ M2 → 5V 연결 — 1/16 마이크로스텝의 핵심 ④ Vref 먼저 설정 후 모터 연결 |
4장. Vref(기준전압) 설정
▶ 4.1 Vref(기준전압) 설정
| 📐 DRV8825 Vref 설정 3단계 ① 모터를 먼저 꽂지 마세요! 아두이노·드라이버·VMOT 전원만 인가합니다. ② Vref 목표값 계산 공식: Vref = 목표전류(A) / 2 예: 1.2A로 구동 → Vref = 1.2 / 2 = 0.6V ③ 멀티미터로 측정하며 가변저항 조절 검은 탐침 → GND / 빨간 탐침 → 십자 나사(가변저항) 금속 나사를 아주 미세하게 돌려 0.6V에 맞춤 ④ 전원 끄고 모터 연결 후 재전원 |
Vref 조정 예
5장. 아두이노 메모리와 F() 매크로 — 안정적인 코드의 핵심
▶ 5.1 아두이노 Uno 메모리 구조
아두이노 Uno(ATmega328P)의 메모리는 크게 두 가지입니다. 프로그램 자체는 플래시에 저장되지만, 문자열 상수는 실행 시 RAM으로 복사됩니다.
| 메모리 종류 | 용량 | 용도 |
| 플래시 (Flash) | 32KB | 프로그램 코드 저장 (비휘발성) |
| SRAM (RAM) | 2KB | 변수·스택·문자열 상수 저장 (실행 중) |
| EEPROM | 1KB | 설정값 영구 저장 |
| ⚠️ RAM 부족이 동작 불능의 원인될수 있습니다.(경험)! 코드에 문자열 상수("Hello", "MPU Calibrating..." 등)가 많으면 컴파일 시 플래시에 저장되지만, 실행 시 자동으로 RAM에 복사됩니다. 문자열 하나가 20바이트라면, 10개 → 200바이트 RAM 소모 2KB RAM에서 200바이트는 무시할 수 없는 양입니다. RAM이 부족해지면: OLED 초기화 실패, 랜덤 리셋, 아무 동작 안 함 → 9편 예제 코드를 작성하며 처음에 테스트 중 동작을 안 했는데 이것이 원인 이었습니다. ^^ |
▶ 5.2 F() 매크로 — 문자열을 플래시에 그대로 둔다
F() 매크로는 문자열을 RAM으로 복사하지 않고 플래시에서 직접 읽어 사용합니다. 한 줄 추가로 수십~수백 바이트 RAM을 절약합니다.
| // ❌ 일반 문자열 — RAM 낭비 display.print("MPU Calibrating..."); // 19바이트 RAM 소모 Serial.print("R:"); // 2바이트 RAM 소모 // ✅ F() 매크로 — RAM 절약 display.print(F("MPU Calibrating...")); // RAM 0바이트 소모! Serial.print(F("R:")); // RAM 0바이트 소모! // → "Serial.print()·display.print() 등 Print 계열 함수에서 사용 가능 // 일반 변수나 함수 인자에는 사용 불가 |
▶ 5.3 RAM 절약 3가지 기법
| 기법 | 설명 | 효과 |
| F() 매크로 | 문자열 상수를 플래시에서 직접 읽음 | 문자열 개수 × 길이만큼 RAM 절약 |
| constrain() 대신 if문 | constrain() 내부 구현이 예상치 못한 형변환 유발 가능 | 안정적인 타입 처리 |
| Wire.setWireTimeout() | I2C 통신 응답 없을 때 무한 대기 방지 | system hang 방지 |
| // ① Wire.setWireTimeout() — I2C hang 방지 Wire.setWireTimeout(3000, true); // 3ms 후 자동 복구 // setup()에서 Wire.begin() 직후에 추가 // ② constrain() 대신 명시적 if문 // ❌ 잠재적 타입 오류 return s2us(constrain(spd, SPD_SLOW, SPD_FAST)); // ✅ 명시적 처리 if(spd < SPD_SLOW) spd = SPD_SLOW; if(spd > SPD_FAST) spd = SPD_FAST; return s2us(spd); // ③ prevStepUs += stepUs (누적 오차 제거) // ❌ nowUs 사용 — 매 루프 오차 누적 prevStepUs = nowUs; // ✅ += 사용 — 정확한 타이밍 유지 prevStepUs += stepUs; |
6장. 탈조 방지 — 비블로킹 코딩
▶ 6.1 탈조와 비블로킹 — 왜 delay()를 쓰면 안 되는가
| 🚨 탈조의 원인 1/16 마이크로스텝으로 돌리려면 STEP 핀에 수백~수천 µs마다 칼같이 펄스를 보내야 합니다. OLED display.display() 실행 시 20~30ms 동안 I2C 통신에 묶임 → 모터 펄스 수십 개 누락 → '드르륵!' 탈조 발생 delay()를 단 한 줄이라도 쓰면 그 시간동안 모터 펄스가 끊깁니다. 그러면 모터가 부드럽게 회전하지 않고, 득, 득, 득~~~~ 불연속적으로 회전하게 됩니다. 이것을 탈조라 합니다. |
| ✅ 해결책 — 4중 비블로킹 타이머 ① micros() — 스텝 펄스 (수백 µs, 최우선) ② millis() — 정지 타이머 (50ms) ③ millis() — MPU-6050 폴링 (5ms) ④ millis() — OLED 갱신 (150ms) 4가지가 loop() 안에서 각자 독립적으로 동작 delay()는 단 한 줄도 없음 (calibrate() 제외) |
7장. 동작 설명 — 12가지 시퀀스
▶ 7.1 데모 시퀀스 — 12가지 다이나믹 각도 제어
| # | 시퀀스 | rpm | 스텝 | 소요 시간 |
| 0 | +45° CW | 20rpm | 400 | 약 0.37초 |
| 1 | -45° CCW | 20rpm | 400 | 약 0.37초 |
| 2 | +90° CW | 20rpm | 800 | 약 0.75초 |
| 3 | -90° CCW | 20rpm | 800 | 약 0.75초 |
| 4 | +45° CW | 30rpm | 400 | 약 0.25초 |
| 5 | -45° CCW | 30rpm | 400 | 약 0.25초 |
| 6 | +90° CW | 30rpm | 800 | 약 0.50초 |
| 7 | -90° CCW | 30rpm | 800 | 약 0.50초 |
| 8 | +45° CW | 10rpm | 400 | 약 0.75초 |
| 9 | -45° CCW | 10rpm | 400 | 약 0.75초 |
| 10 | +90° CW | 10rpm | 800 | 약 1.50초 |
| 11 | -90° CCW | 10rpm | 800 | 약 1.50초 |
각 시퀀스 완료 후 50ms 정지 → 다음 시퀀스로 자동 전환. 11번 완료 후 자동으로 0번으로 돌아와 무한 반복합니다.
▶ 7.2 OLED 화면 레이아웃
| Y 위치 | 표시 내용 | 예시 |
| y=0 | 타이틀 | MPU6050 | STEP DEMO |
| y=13 | Roll (X축 기울기) | ROLL :+12.3° |
| y=23 | Pitch (Y축 기울기) | PITCH: -5.1° |
| y=36 | 시퀀스 이름 + 속도 | 20RPM +45 CW 1067s |
| y=46 | Roll 인디케이터 바 | ━━━●━━━ (중앙=0°) |
| y=57 | 시퀀스 진행 표시 | [***---------] 4/12 |
🛠️ 여기서 잠깐 — 하드웨어 동작 확인 테스트 코드
메인 예제 코드를 작성하기전 스텝모터의 동작을 확인하는 것이 좋습니다. 이 코드로 먼저 하드웨어(배선·Vref·마이크로스텝)가 정상인지 확인해보시고 스텝모터가 정상 회전하면 이후 원하는 코드를 작성해 보세요
| 왜 테스트 코드가 필요할까요? 메인 코드에는 MPU-6050·OLED·비블로킹 타이머가 모두 들어 있어 문제 발생 시 원인을 찾기 어렵습니다. 이 단순 코드로 "모터 자체는 잘 도는지"를 먼저 확인하면 이후 하드웨어에 대한 고민 없이 SW 코드만 진단하면 되어 원하는 결과를 정확히 얻을 수 있습니다. |
stepper_test.ino
20rpm · CW 연속 회전 · delay() 사용 (테스트 전용)
//================================================ // DRV8825 하드웨어 동작 확인용 단순 테스트 코드 // ▷ 20rpm CW 연속 회전 (delay 사용) // ▷ 1/16 마이크로스텝: M0=LOW, M1=LOW, M2=HIGH // ▷ STEP=D3 DIR=D4 EN=D5 //================================================ #define STEP_PIN 3 #define DIR_PIN 4 #define EN_PIN 5 // 20rpm 기준 계산 // SPS = 20 × 3200 / 60 = 1,067 sps // 스텝 간격 = 1,000,000 / 1,067 = 937µs // HIGH + LOW 각 절반 = 468µs씩 #define HALF_US 468 void setup() { pinMode(STEP_PIN, OUTPUT); pinMode(DIR_PIN, OUTPUT); pinMode(EN_PIN, OUTPUT); digitalWrite(EN_PIN, LOW); // 모터 활성화 digitalWrite(DIR_PIN, HIGH); // CW 방향 digitalWrite(STEP_PIN, LOW); } void loop() { digitalWrite(STEP_PIN, HIGH); delayMicroseconds(HALF_US); digitalWrite(STEP_PIN, LOW); delayMicroseconds(HALF_US); }
스텝모터 배선후 동작 점검 체크리스트
8장. 소스코드 [ 코드 작성에 AI 클로드와 제미나이를 활용했습니다.]
▶ 8.1 전체 소스코드
| //================================================ // 민선생의 전자공방 - 브레드보드 IO 9편 최종 // MPU-6050 독립 표시 + 스텝모터 RPM 각도 데모 // ▷ 20/30/10rpm × ±45°·±90° = 12가지 시퀀스 // ▷ 비블로킹 완전 분리 (micros + millis) // ▷ 1/16 마이크로스텝 (M0=LOW, M1=LOW, M2=HIGH) // ▷ F() 매크로로 RAM 최적화 // OLED: 0x3C MPU: 0x68 // DRV8825: STEP=D3 DIR=D4 EN=D5 //================================================ #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #include <math.h> #define SCREEN_W 128 #define SCREEN_H 64 #define OLED_ADDR 0x3C #define OLED_RESET -1 #define MPU_ADDR 0x68 #define GYRO_SENS 131.0f #define ALPHA 0.98f #define CALIB_N 500 #define STEP_PIN 3 #define DIR_PIN 4 #define EN_PIN 5 // ── 1/16 마이크로스텝 각도별 스텝 수 ───────── // M0=LOW, M1=LOW, M2=HIGH 로 연결 // 1회전 = 200 × 16 = 3,200 스텝 // 45° = 3200 × 45/360 = 400 스텝 // 90° = 3200 × 90/360 = 800 스텝 #define DEG_45 400 #define DEG_90 800 // ── rpm → sps 변환 ────────────────────────── // sps = rpm × 3,200 / 60 #define SPD_SLOW 533 // 10rpm #define SPD_MID 1067 // 20rpm #define SPD_FAST 1600 // 30rpm #define NUM_SEQ 12 #define SENSOR_MS 5 #define OLED_MS 150 Adafruit_SSD1306 display(SCREEN_W,SCREEN_H,&Wire,OLED_RESET); // ── MPU 변수 ───────────────────────────────── unsigned long prevSenMs=0, prevOledMs=0; float gyroOffX=0, roll=0, pitch=0; // ── 시퀀스 엔진 ────────────────────────────── uint8_t seqIdx = 0; long totalSteps = 0; long doneSteps = 0; int spdStart = 0; int spdEnd = 0; bool inPause = false; unsigned long pauseStart = 0; unsigned long pauseMs = 0; unsigned long prevStepUs = 0; long stepUs = 0; bool stepPin = false; bool motorDir = true; // ── 시퀀스 이름 ────────────────────────────── const char* NAMES[NUM_SEQ] = { "20RPM +45 CW ","20RPM -45 CCW", "20RPM +90 CW ","20RPM -90 CCW", "30RPM +45 CW ","30RPM -45 CCW", "30RPM +90 CW ","30RPM -90 CCW", "10RPM +45 CW ","10RPM -45 CCW", "10RPM +90 CW ","10RPM -90 CCW", }; // ── 속도 → 스텝 간격 변환 ──────────────────── long s2us(int spd){ return spd>0 ? 1000000L/spd : 0; } // ── 현재 스텝 간격 계산 ────────────────────── long calcUs(){ float r = 0.0f; if(totalSteps > 0){ r = (float)doneSteps / (float)totalSteps; if(r < 0.0f) r = 0.0f; // 명시적 범위 제한 if(r > 1.0f) r = 1.0f; } int spd = spdStart + (int)((spdEnd - spdStart) * r); if(spd < SPD_SLOW) spd = SPD_SLOW; // constrain 대신 명시적 if(spd > SPD_FAST) spd = SPD_FAST; return s2us(spd); } // ── 방향·모터 제어 ─────────────────────────── void setDir(bool cw){ motorDir=cw; digitalWrite(DIR_PIN,cw?HIGH:LOW); delayMicroseconds(2); // DIR 셋업 시간 } void motorOn() { digitalWrite(EN_PIN,LOW); } void motorOff() { digitalWrite(EN_PIN,HIGH); } // ── 구간 시작 ───────────────────────────────── void startSeg(bool cw,long steps,int ss,int se){ setDir(cw); totalSteps=steps; doneSteps=0; spdStart=ss; spdEnd=se; motorOn(); stepUs=calcUs(); } // ── 정지 구간 시작 ─────────────────────────── void startPause(unsigned long ms){ motorOff(); stepUs=0; inPause=true; pauseStart=millis(); pauseMs=ms; } // ── 시퀀스 진입 ────────────────────────────── void enterSeq(uint8_t idx){ seqIdx=idx%NUM_SEQ; inPause=false; switch(seqIdx){ case 0: startSeg(true, DEG_45,SPD_MID, SPD_MID); break; case 1: startSeg(false, DEG_45,SPD_MID, SPD_MID); break; case 2: startSeg(true, DEG_90,SPD_MID, SPD_MID); break; case 3: startSeg(false, DEG_90,SPD_MID, SPD_MID); break; case 4: startSeg(true, DEG_45,SPD_FAST,SPD_FAST); break; case 5: startSeg(false, DEG_45,SPD_FAST,SPD_FAST); break; case 6: startSeg(true, DEG_90,SPD_FAST,SPD_FAST); break; case 7: startSeg(false, DEG_90,SPD_FAST,SPD_FAST); break; case 8: startSeg(true, DEG_45,SPD_SLOW,SPD_SLOW); break; case 9: startSeg(false, DEG_45,SPD_SLOW,SPD_SLOW); break; case 10: startSeg(true, DEG_90,SPD_SLOW,SPD_SLOW); break; case 11: startSeg(false, DEG_90,SPD_SLOW,SPD_SLOW); break; } } // ── 구간 완료 → 50ms 정지 후 다음 ─────────── void onDone() { startPause(50); } // ── 정지 완료 → 다음 시퀀스 ───────────────── void [안내]태그제한으로등록되지않습니다-onPauseDone(){ inPause=false; enterSeq(seqIdx+1); } // ── MPU-6050 ───────────────────────────────── void mpuInit(){ Wire.beginTransmission(MPU_ADDR); Wire.write(0x6B); Wire.write(0x00); Wire.endTransmission(true); } void readMPU(int16_t*ax,int16_t*ay,int16_t*az, int16_t*gx,int16_t*gy,int16_t*gz){ Wire.beginTransmission(MPU_ADDR); Wire.write(0x3B); Wire.endTransmission(false); Wire.requestFrom(MPU_ADDR,14,true); *ax=Wire.read()<<8|Wire.read(); *ay=Wire.read()<<8|Wire.read(); *az=Wire.read()<<8|Wire.read(); Wire.read(); Wire.read(); *gx=Wire.read()<<8|Wire.read(); *gy=Wire.read()<<8|Wire.read(); *gz=Wire.read()<<8|Wire.read(); } void calibrate(){ int16_t ax,ay,az,gx,gy,gz; long sx=0; display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); // F() 매크로: RAM 절약 display.setCursor(10,18); display.print(F("MPU Calibrating...")); display.setCursor(10,32); display.print(F("DO NOT MOVE!")); display.display(); for(int i=0;i<CALIB_N;i++){ readMPU(&ax,&ay,&az,&gx,&gy,&gz); sx+=gx; delay(2); } gyroOffX=sx/(float)CALIB_N; display.clearDisplay(); display.setCursor(20,24); display.print(F("Ready! Go!")); display.display(); delay(600); } void updateAngles(float dt){ int16_t ax,ay,az,gx,gy,gz; readMPU(&ax,&ay,&az,&gx,&gy,&gz); float aR=atan2(ay,az)*180.0f/PI; float aP=atan2(-ax,sqrt((long)ay*ay+(long)az*az))*180.0f/PI; float gr=(gx-gyroOffX)/GYRO_SENS; roll =ALPHA*(roll +gr*dt)+(1-ALPHA)*aR; pitch=ALPHA*(pitch) +(1-ALPHA)*aP; } // ── OLED 대시보드 ──────────────────────────── void showDashboard(){ display.clearDisplay(); display.setTextColor(SSD1306_WHITE); display.setTextSize(1); display.setCursor(0,0); display.print(F("MPU6050 | STEP DEMO")); display.drawLine(0,10,127,10,SSD1306_WHITE); display.setCursor(0,13); display.print(F("ROLL :")); if(roll>=0) display.print(F(" ")); display.print(roll,1); display.print((char)247); display.setCursor(0,23); display.print(F("PITCH:")); if(pitch>=0) display.print(F(" ")); display.print(pitch,1); display.print((char)247); display.drawLine(0,33,127,33,SSD1306_WHITE); display.setCursor(0,36); if(inPause){ display.print(F("--- CHANGING ---")); } else { display.print(NAMES[seqIdx]); if(stepUs>0){ display.print(F(" ")); display.print(1000000L/stepUs); display.print(F("s")); } } display.drawRect(0,46,128,7,SSD1306_WHITE); display.drawFastVLine(64,44,11,SSD1306_WHITE); int pos=map((int)roll,-90,90,0,124); if(pos<0) pos=0; if(pos>124) pos=124; display.fillRect(pos,47,4,5,SSD1306_WHITE); display.setCursor(0,57); display.print(F("[")); for(int i=0;i<NUM_SEQ;i++) display.print(i==seqIdx ? F("*") : F("-")); display.print(F("] ")); display.print(seqIdx+1); display.print(F("/")); display.print(NUM_SEQ); display.display(); } // ══════════════════════════════════════════════ void setup(){ Serial.begin(115200); Wire.begin(); Wire.setWireTimeout(3000, true); // I2C hang 방지 Wire.setClock(400000); // I2C 400kHz pinMode(STEP_PIN,OUTPUT); pinMode(DIR_PIN, OUTPUT); pinMode(EN_PIN, OUTPUT); motorOff(); if(!display.begin(SSD1306_SWITCHCAPVCC,OLED_ADDR)) while(1); mpuInit(); calibrate(); enterSeq(0); prevStepUs=micros(); } // ══════════════════════════════════════════════ void loop(){ unsigned long nowMs=millis(); unsigned long nowUs=micros(); // ① 스텝 펄스 (최우선 — micros 기반) if(!inPause && stepUs>0){ if((nowUs-prevStepUs)>=(unsigned long)stepUs){ prevStepUs += stepUs; // += 로 누적 오차 제거 stepPin=!stepPin; digitalWrite(STEP_PIN,stepPin); if(!stepPin){ // LOW = 1스텝 완료 doneSteps++; stepUs=calcUs(); if(doneSteps>=totalSteps) onDone(); } } } // ② 정지 타이머 if(inPause && (nowMs-pauseStart)>=pauseMs) [안내]태그제한으로등록되지않습니다-onPauseDone(); // ③ MPU-6050 폴링 5ms if((nowMs-prevSenMs)>=SENSOR_MS){ float dt=(nowMs-prevSenMs)/1000.0f; prevSenMs=nowMs; updateAngles(dt); } // ④ OLED 갱신 150ms if((nowMs-prevOledMs)>=OLED_MS){ prevOledMs=nowMs; showDashboard(); Serial.print(F("R:")); Serial.print(roll,1); Serial.print(F(" P:")); Serial.print(pitch,1); Serial.print(F(" [")); Serial.print(NAMES[seqIdx]); Serial.print(F("] ")); if(!inPause&&stepUs>0) Serial.print(1000000L/stepUs); Serial.println(F("sps")); } } |
9장. 핵심 코드 해설
▶ 9.1 s2us() — 속도를 스텝 간격으로
우리가 아는 속도는 '1초에 몇 바퀴를 도는가(RPM)' 또는 '1초에 펄스를 몇 번 주는가(SPS)'입니다. 하지만 아두이노가 모터를 돌릴 때 필요한 것은 "스텝 신호를 주고 나서 다음 신호를 줄 때까지 몇 마이크로초(µs)를 쉬어야 하는가?"라는 '쉬는 시간(간격)' 정보입니다.
원리 파헤치기: 1000000L은 1초를 마이크로초(µs) 단위로 나타낸 숫자입니다. 만약 1초에 1,000번 펄스를 쏘고 싶다면(1000 sps), 1,000,000µs를 1,000으로 나눈 1,000µs마다 신호를 주면 됩니다.
수식으로 보는 속도 체감:
s2us(533) = 1,000,000 / 533 ≈ 1,876µs 마다 펄스 (10rpm의 느긋한 걸음)
s2us(1067) = 1,000,000 / 1,067 ≈ 937µs 마다 펄스 (20rpm의 경쾌한 걸음)
s2us(1600) = 1,000,000 / 1,600 ≈ 625µs 마다 펄스 (30rpm의 힘찬 질주)
| long s2us(int spd){ return spd>0 ? 1000000L/spd : 0; } // 1,000,000µs(=1초) ÷ SPS = 스텝 1개당 간격(µs) // s2us(1067) = 1,000,000 / 1,067 = 937µs (20rpm) // s2us(1600) = 1,000,000 / 1,600 = 625µs (30rpm) // s2us(533) = 1,000,000 / 533 = 1,876µs (10rpm) |
▶ 9.2 calcUs() — 선형 보간으로 가속·감속
자동차가 멈춰 있다가 갑자기 시속 100km로 출발하면 바퀴가 헛돌고 엔진에 무리가 가겠죠? 스텝 모터도 마찬가지입니다. 멈춰 있던 모터에 갑자기 고속 펄스를 주면 탈조(멈춤) 현상이 일어납니다. 이 코드는 현재 전체 목표 중 얼마나 움직였는지(진행률 r)를 계산하여 속도를 서서히 올려주거나 내려주는 '선형 보간' 회로입니다.
원리 파헤치기: * doneSteps(지금까지 걸은 걸음)을 totalSteps(전체 가야 할 걸음)으로 나누면 현재 몇 %나 왔는지 비율(r)이 나옵니다. 출발 직후엔 0에 가깝고, 목적지에 다다르면 1이 됩니다.
예를 들어 200 sps에서 출발해서 1,600 sps까지 가속하는 구간이라고 해봅시다. 딱 절반쯤 왔을 때(r = 0.5), 속도는 200 + (1600 - 200) × 0.5 = 900 sps가 됩니다. 움직임의 진화에 맞춰 속도가 실시간으로 매끄럽게 변하는 것이죠!
| // spdStart = spdEnd인 경우 (등속): r에 상관없이 일정 // spdStart ≠ spdEnd인 경우: 진행률에 따라 선형 변화 float r = (float)doneSteps / (float)totalSteps; // 0.0~1.0 int spd = spdStart + (int)((spdEnd - spdStart) * r); // 예: 200→1600 가속 구간, r=0.5일 때 // spd = 200 + (1600-200) × 0.5 = 900 sps // constrain 대신 명시적 if문 (타입 안전) if(spd < SPD_SLOW) spd = SPD_SLOW; if(spd > SPD_FAST) spd = SPD_FAST; |
▶ 9.3 1스텝 카운팅 — LOW 전환 시에만 카운트
아두이노가 드라이버의 STEP 핀에 주는 신호는 HIGH(올림)와 LOW(내림)가 반복되는 파형입니다. 코드가 한 바퀴 돌 때마다 stepPin = !stepPin;에 의해 HIGH와 LOW가 번갈아 출력됩니다.
원리 파헤치기: 드라이버 칩은 HIGH 신호가 들어왔을 때 코일에 전기를 찌릿 주며 자석을 당기고, LOW 신호가 될 때 비로소 한 걸음(스텝) 조율을 마무리합니다. 즉, HIGH 한 번, LOW 한 번이 합쳐져야 온전한 '1스텝'이 움직입니다.
만약 HIGH가 될 때도 카운트하고, LOW가 될 때도 카운트하면 모터는 반 걸음만 걸었는데 아두이노는 2번 움직였다고 착각하여 오차가 발생합니다. 그래서 if(!stepPin) 조건문을 통해 신호가 떨어지는 찰나(LOW)에만 진짜 1스텝이 끝났다고 판단하고 걸음 수(doneSteps)를 1 올려주는 것입니다.
| // HIGH + LOW 쌍 = 완전한 1스텝 // HIGH 전환 시 카운트하면 2배 오차 발생! stepPin = !stepPin; digitalWrite(STEP_PIN, stepPin); if(!stepPin) { // LOW 전환 = 1스텝 완료 doneSteps++; stepUs = calcUs(); if(doneSteps >= totalSteps) onDone(); } |
▶ 9.4 prevStepUs += stepUs — 미세한 시간 지연을 삭제하는 누적 오차 제거
친구와 정확히 10분마다 만나기로 약속했습니다. 1시에 만나고 다음 약속은 당연히 1시 10분입니다. 그런데 1시에 약속 장소에 가다가 길에 돌멩이가 있어서 1시 1분에 도착했습니다. 이때 다음 약속 시간을 "내가 도착한 1시 1분 기준으로 10분 뒤인 1시 11분"으로 잡으면 오차가 계속 뒤로 밀리겠죠(지연 누적)? 약속은 늦었더라도 다음 시간은 원래 계획대로 1시 10분 기준으로 계산해야 정확합니다.
원리 파헤치기: 아두이노가 센서를 읽고 화면을 그리다 보면 스텝 펄스를 주는 타이밍이 몇 마이크로초(µs)씩 미세하게 늦어질 수 있습니다.
prevStepUs = nowUs;를 쓰면 늦어진 현재 시간(nowUs)을 기준점으로 삼기 때문에, 루프를 돌 때마다 미세한 지연 시간이 계속 더해져 모터의 실제 속도가 원래 설정한 속도보다 미세하게 느려집니다.
반면 prevStepUs += stepUs;를 사용하면 "원래 펄스를 주었어야 하는 이론적인 목표 시간"을 기준으로 다음 타이밍을 계산하기 때문에, 이번 루프에서 코드가 아주 살짝 늦어졌더라도 다음 루프에서 즉시 그 오차가 상쇄되어 완벽한 속도 타이밍을 유지하게 됩니다.
| // ❌ nowUs 사용: 처리 지연만큼 다음 타이밍이 늦어짐 prevStepUs = nowUs; // 오차 누적 → 실제 속도 ≠ 설정 속도 // ✅ += 사용: 항상 정확한 간격 유지 prevStepUs += stepUs; // 이전 타이밍 기준으로 다음 타이밍 계산 // 처리가 조금 늦어져도 자동으로 보정됨 |
▶ 9.5 4중 비블로킹 구조 - 4명의 작업자가 동시에 일하는 멀티태스킹
아두이노 내부에서 각기 다른 주기를 가진 4개의 시계(타이머)가 동시에 째깍거리고 있는 모습입니다.
원리 파헤치기: 이 구조의 핵심은 if - else if가 아니라 독립적인 if - if - if - if 구조라는 점입니다. 아두이노는 엄청나게 빠른 속도로 loop() 함수를 무한 회전하며 각 시계를 검사합니다.
모터 펄스 시계는 수백 µs마다 켜져서 모터를 돌리고,
센서 시계는 5ms마다 켜져서 각도를 읽고,
화면 시계는 150ms마다 켜져서 대시보드를 그립니다.
만약 중간에 delay(50) 같은 코드가 단 한 줄이라도 들어가면, 아두이노가 그 자리에 멈춰 서서 아무 시계도 보지 못하게 됩니다. 그렇게 되면 가장 촘촘하게 움직여야 하는 모터 펄스 시계가 깨지면서 모터가 탈조를 일으키는 것이죠. "모든 타이밍은 딜레이 없이 흐른다", 이것이 바로 병렬 처리처럼 작동하는 비블로킹 코딩의 핵심 정수입니다.
| void loop() { unsigned long nowMs = millis(); unsigned long nowUs = micros(); // ① µs 단위 — 스텝 펄스 (최우선) if(!inPause && stepUs>0 && (nowUs-prevStepUs) >= (unsigned long)stepUs) { ... } // ② ms 단위 — 정지 타이머 (50ms) if(inPause && (nowMs-pauseStart) >= pauseMs) { ... } // ③ ms 단위 — MPU 폴링 (5ms) if((nowMs-prevSenMs) >= SENSOR_MS) { ... } // ④ ms 단위 — OLED 갱신 (150ms) if((nowMs-prevOledMs) >= OLED_MS) { ... } // ★ if-if-if-if 구조 (if-else 아님!) // 조건이 맞는 것은 한 루프에서 모두 실행 // delay()는 단 한 줄도 없음 } |
▶ 9.6 주요 변수·함수 정리
| 항목 | 설명 |
| s2us(spd) | SPS → µs 간격. 1,000,000 / spd |
| calcUs() | 현재 진행률로 스텝 간격 계산. F() 미적용 (계산 함수) |
| stepUs | 현재 스텝 간격(µs). 0=정지 |
| prevStepUs += stepUs | 정확한 타이밍 누적 오차 방지 |
| inPause | 방향 전환 정지 중. 50ms 후 다음 시퀀스 |
| motorOff() | EN=HIGH → 코일 전류 차단 → 발열 방지 |
| F() 매크로 | 문자열을 플래시에서 직접 읽어 RAM 절약 |
| Wire.setWireTimeout() | I2C hang 방지. setup()에서 Wire.begin() 직후 |
10장. 동작 확인
▶ 10.1 동작 확인 순서
| 단계 | 확인 내용 | 정상 동작 |
| 1 | 단순 delay 코드 업로드 먼저 | 20rpm CW 연속 회전 확인 |
| 2 | 메인 코드 업로드 | 'MPU Calibrating... DO NOT MOVE!' 표시 |
| 3 | 약 1초 후 | 'Ready! Go!' 표시 |
| 4 | Seq 0 (20rpm +45°) | CW 400스텝, 약 0.37초 후 정지 |
| 5 | CHANGING 표시 | 50ms 후 자동 전환 |
| 6 | Seq 1 (20rpm -45°) | CCW 회전 |
| 7 | Seq 2·3 (20rpm ±90°) | 45°보다 2배 오래 회전 |
| 8 | Seq 4~7 (30rpm) | 눈에 띄게 빠르게 |
| 9 | Seq 8~11 (10rpm) | 눈에 띄게 느리게 |
| 10 | Seq 11 완료 후 | 자동으로 Seq 0으로 복귀 |
| // 시리얼 모니터 출력 예시 (115200bps) R: 1.2 P:-0.8 [20RPM +45 CW ] 1067sps R: 1.2 P:-0.8 [--- CHANGING ---] R: 1.3 P:-0.7 [20RPM -45 CCW] 1067sps R: 1.2 P:-0.8 [30RPM +45 CW ] 1600sps ← 더 빠름! R: 1.1 P:-0.9 [10RPM +90 CW ] 533sps ← 더 느림! ↑ MPU-6050 Roll·Pitch는 모터 동작과 완전 독립 |
아두이노 IDE 시리얼 모니터
| ✅ 최종 점검 체크리스트 □ M2 → 5V 연결 (1/16 마이크로스텝) □ M0·M1 → GND 또는 미연결 □ SLP·RST 서로 쇼트 연결 □ 100µF 커패시터 VMOT-GND 사이 □ Vref 0.5~0.6V 설정 확인 □ 단순 코드로 회전 확인 후 메인 코드 업로드 □ 시리얼 모니터 115200bps 설정 |
| 🎁 보너스 노트 — 동작 오류 시 진단 순서 1단계: 단순 delay 코드로 모터 회전 확인 → 하드웨어 검증 2단계: I2C 스캐너로 OLED(0x3C)·MPU(0x68) 주소 확인 3단계: 아두이노 IDE 컴파일 후 '동적 메모리' 사용량 확인 → 2048바이트 중 70% 이상이면 F() 매크로 적용 필요 4단계: 센서는 반응하는데 모터가 간헐적으로 멈추면 Wire.setWireTimeout(3000, true) 코드가 setup()에 잘 들어갔는지 확인 |
민선생의 전자공방 | blog.naver.com/hansikmin
📖 에필로그 — 드디어 역진자(Inverted Pendulum)를 향한 모든 준비가 끝났습니다!
역 진자(Inverted Pendulum) 구상도
센서의 미세한 흔들림에도 모터가 멈춰버리는 숱한 시행착오 끝에, 마침내 우리는 **'흔들림 없는 센서(눈)'**와 **'부드럽고 강력한 모터(근육)'**를 완벽하게 분리하여 제어하는 기술을 테스트 했습니다.
비블로킹(Non-blocking) 타이머와 메모리 최적화까지, 오늘 여러분이 작성한 이 코드는 단순한 예제를 넘어 실제 로봇 공학과 상용 장비에 쓰이는 정통 임베디드 제어의 뼈대입니다.
다음 10편에서는 드디어 이 시리즈의 최종 보스! 8편의 가속도 센서와 오늘 배운 9편의 스텝 모터를 결합하여, 스스로 중심을 잡고 우뚝 서는 '인버티드 펜듈럼(역진자)' 프로젝트를 시작해 보려고 합니다. ㅎㅎ
잘 되려나... 조금 시간은 걸리겠지만 짬짬히 해보려 합니다. 쉬운 과제는 아닙니다. 지금까지 공부했던 센서와 액츄에이터를 사용해 스윙업을 통해 펜듈럼(막대)를 직립하게 하는것이 목표입니다. 같이 한걸음 한걸음 가보시지요^^
아자아자!!! 오늘도 화이팅 입니다.
태그#아두이노#스텝모터#DRV8825#MPU6050#A4988#마이크로스텝#스텝핑#모터#F매크로#바이폴라#NEMA17#OLED#Vref설정#기준전압#아두이노메모리#OLED대시보드#역진자#인버티드펜듈럼#민선생의전자공방#Motor#임베디드프로그래밍#스텝핑모터#stepping#include#define 태그수정