Прошивки для роботов с объяснениями

Набор базовых «кирпичиков», из которых собирается программа почти любого робота. Код на Arduino C++: работает на Arduino, ESP32 и RP2040 с минимальными правками номеров выводов.

Содержание
  1. Как устроена прошивка
  2. millis() вместо delay(): делаем много дел одновременно
  3. Кнопка с антидребезгом
  4. Мотор через драйвер TB6612: скорость и направление
  5. Энкодер на прерываниях
  6. Сервоприводы и плавное движение
  7. Ультразвуковой дальномер
  8. Контроль заряда аккумулятора
  9. Конечный автомат: «мозг» робота
  10. Команды по Serial/Bluetooth
  11. Отладка и хорошие привычки

1. Как устроена прошивка

У Arduino-программы две функции: setup() выполняется один раз при включении, loop() — бесконечно, тысячи раз в секунду. Хорошая прошивка робота — это loop(), который никогда не останавливается: на каждом проходе быстро опрашивает датчики, обновляет логику и выставляет моторы.

void loop() {
  readSensors();     // прочитать всё, что изменилось
  updateLogic();     // решить, что делать
  driveOutputs();    // применить к моторам, светодиодам
}

Если где-то внутри стоит delay(1000), робот на секунду «слепнет»: не видит препятствие, не реагирует на кнопку. Поэтому первое правило — отказ от delay().

2. millis() вместо delay()

millis() возвращает количество миллисекунд с момента включения. Вместо «подожди 500 мс» мы спрашиваем «прошло ли 500 мс с прошлого раза?».

const uint8_t LED_PIN = 13;
unsigned long lastBlink = 0;
bool ledOn = false;

void setup() {
  pinMode(LED_PIN, OUTPUT);
}

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

  // Задача 1: мигать раз в 500 мс
  if (now - lastBlink >= 500) {
    lastBlink = now;
    ledOn = !ledOn;
    digitalWrite(LED_PIN, ledOn);
  }

  // Задача 2, 3... выполняются в том же проходе, ничего не ждут
}
Пишите именно now - last >= interval, а не now >= last + interval. Через ~49 суток millis() переполняется и начинает с нуля; вычитание беззнаковых чисел корректно переживает это переполнение, сложение — нет.

3. Кнопка с антидребезгом

Механический контакт при нажатии «дребезжит»: за 5–20 мс замыкается и размыкается десятки раз. Без фильтра одно нажатие засчитается как несколько. Решение: принимать новое состояние, только если оно стабильно держится заданное время.

const uint8_t BTN_PIN = 2;
const unsigned long DEBOUNCE_MS = 25;

bool stableState = HIGH;      // с INPUT_PULLUP отпущенная кнопка = HIGH
bool lastReading = HIGH;
unsigned long lastChange = 0;

void setup() {
  pinMode(BTN_PIN, INPUT_PULLUP);  // встроенная подтяжка, кнопка замыкает на GND
  Serial.begin(115200);
}

void loop() {
  bool reading = digitalRead(BTN_PIN);
  if (reading != lastReading) {          // сигнал дёрнулся — перезапускаем таймер
    lastChange = millis();
    lastReading = reading;
  }
  if (millis() - lastChange > DEBOUNCE_MS && reading != stableState) {
    stableState = reading;
    if (stableState == LOW) {            // зафиксировано нажатие
      Serial.println("Нажата");
    }
  }
}

INPUT_PULLUP включает внутренний резистор к питанию, поэтому внешний резистор не нужен, а логика инвертирована: нажата — LOW.

4. Мотор через драйвер TB6612

У TB6612 на каждый мотор три сигнала: IN1, IN2 задают направление, PWM — скорость. Вывод STBY должен быть в HIGH, иначе драйвер спит.

IN1IN2Результат
HIGHLOWВперёд
LOWHIGHНазад
LOWLOWСвободный выбег (мотор катится)
HIGHHIGHТорможение (обмотки замкнуты, мотор резко встаёт)
struct Motor {
  uint8_t in1, in2, pwm;

  void begin() {
    pinMode(in1, OUTPUT); pinMode(in2, OUTPUT); pinMode(pwm, OUTPUT);
  }

  // speed от -255 до 255: знак — направление, модуль — скорость
  void set(int speed) {
    speed = constrain(speed, -255, 255);
    digitalWrite(in1, speed > 0);
    digitalWrite(in2, speed < 0);
    analogWrite(pwm, abs(speed));
  }

  void brake() {
    digitalWrite(in1, HIGH); digitalWrite(in2, HIGH); analogWrite(pwm, 255);
  }
};

const uint8_t STBY = 9;
Motor left  {4, 5, 6};    // выводы 6 и 3 на Uno поддерживают ШИМ
Motor right {7, 8, 3};

void setup() {
  pinMode(STBY, OUTPUT);
  digitalWrite(STBY, HIGH);
  left.begin(); right.begin();
}

void drive(int forward, int turn) {   // удобное управление «газ + руль»
  left.set(forward + turn);
  right.set(forward - turn);
}

void loop() {
  drive(150, 0);   // прямо
}

Функция drive() реализует дифференциальное управление: робот поворачивает разницей скоростей колёс, как танк. При forward = 0 и ненулевом turn он крутится на месте.

Моторы одной модели всё равно чуть отличаются, поэтому робот с одинаковым ШИМ на колёсах едет дугой. Честное решение — энкодеры и регулятор скорости на каждое колесо (см. статью про ПИД).

5. Энкодер на прерываниях

Квадратурный энкодер выдаёт два сигнала, A и B, сдвинутые на четверть периода. Когда A переходит из LOW в HIGH, по уровню B видно направление вращения. Импульсов может быть тысячи в секунду, поэтому их ловят прерыванием: процессор бросает текущую работу и сразу выполняет короткую функцию-обработчик.

const uint8_t ENC_A = 2;   // на Uno прерывания есть только на выводах 2 и 3
const uint8_t ENC_B = 4;

volatile long ticks = 0;   // volatile: переменная меняется внутри прерывания

void onEncoderA() {
  // Обработчик должен быть максимально коротким: никаких Serial и delay
  if (digitalRead(ENC_B)) ticks--; else ticks++;
}

void setup() {
  pinMode(ENC_A, INPUT_PULLUP);
  pinMode(ENC_B, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(ENC_A), onEncoderA, RISING);
  Serial.begin(115200);
}

long readTicks() {
  noInterrupts();          // long — 4 байта, 8-битный МК читает его не за один такт:
  long t = ticks;          // без защиты прерывание может изменить число посередине чтения
  interrupts();
  return t;
}

void loop() {
  static unsigned long last = 0;
  static long lastTicks = 0;
  if (millis() - last >= 100) {
    long t = readTicks();
    float ticksPerSec = (t - lastTicks) * 10.0;   // за 100 мс → в секунду
    lastTicks = t;
    last = millis();
    Serial.println(ticksPerSec);
  }
}

Перевод в скорость колеса: об/с = тики_в_секунду / (импульсы_на_оборот_вала_мотора × передаточное_число). Например, энкодер 11 импульсов, редуктор 1:30 — один оборот колеса это 330 импульсов по одному фронту канала A.

6. Сервоприводы и плавное движение

Хобби-серво управляется импульсом каждые 20 мс: 1 мс — один крайний угол, 2 мс — другой, 1,5 мс — середина. Библиотека Servo делает это сама. Если просто выдать write(180), серва рванёт на полной скорости и тряхнёт всю конструкцию. Плавность делается так: цель задаём сразу, а текущий угол каждые несколько миллисекунд сдвигаем на градус к цели.

#include <Servo.h>     // для ESP32 — библиотека ESP32Servo

Servo arm;
float current = 90, target = 90;
const float SPEED_DEG_PER_SEC = 60;   // максимальная скорость сустава

void setup() {
  arm.attach(10);
  arm.write(current);
}

void moveTo(float deg) { target = constrain(deg, 0, 180); }

void loop() {
  static unsigned long last = millis();
  unsigned long now = millis();
  float dt = (now - last) / 1000.0;
  last = now;

  float step = SPEED_DEG_PER_SEC * dt;
  if (abs(target - current) <= step) current = target;
  else current += (target > current ? step : -step);
  arm.write((int)round(current));

  // пример: качаемся между 30 и 150 градусами
  if (current == target) moveTo(target == 30 ? 150 : 30);
}

Если несколько суставов должны прийти в точку одновременно, скорость каждого делают пропорциональной его пути — тогда движение руки выглядит естественным.

7. Ультразвуковой дальномер HC-SR04

const uint8_t TRIG = 11, ECHO = 12;

float readDistanceCm() {
  digitalWrite(TRIG, LOW);  delayMicroseconds(2);
  digitalWrite(TRIG, HIGH); delayMicroseconds(10);   // запуск: импульс 10 мкс
  digitalWrite(TRIG, LOW);
  // ждём эхо максимум 25 мс (~4 м), иначе считаем, что ничего нет
  unsigned long us = pulseIn(ECHO, HIGH, 25000UL);
  if (us == 0) return -1;
  return us * 0.0343 / 2;   // скорость звука 343 м/с, путь туда и обратно
}

void setup() {
  pinMode(TRIG, OUTPUT); pinMode(ECHO, INPUT);
  Serial.begin(115200);
}

void loop() {
  static unsigned long last = 0;
  if (millis() - last >= 60) {   // не чаще ~16 раз/с: иначе ловим старое эхо
    last = millis();
    Serial.println(readDistanceCm());
  }
}

Показания шумят и иногда «выстреливают». Простой фильтр — медиана из трёх последних измерений: она выбрасывает одиночные выбросы, в отличие от среднего.

float median3(float a, float b, float c) {
  return max(min(a, b), min(max(a, b), c));
}

8. Контроль заряда аккумулятора

Напряжение 2S-аккумулятора (до 8,4 В) через делитель 30 кОм / 10 кОм подаём на аналоговый вход. АЦП Arduino Uno 10-битный: 0–1023 соответствует 0–5 В.

const uint8_t VBAT_PIN = A0;
const float DIVIDER = (30.0 + 10.0) / 10.0;   // коэффициент делителя = 4
const float VREF = 5.0;

float readBattery() {
  long sum = 0;
  for (int i = 0; i < 16; i++) sum += analogRead(VBAT_PIN);   // усреднение шума
  return (sum / 16.0) / 1023.0 * VREF * DIVIDER;
}

void loop() {
  float v = readBattery();
  if (v < 6.6) {        // 3,3 В на ячейку: пора домой
    // остановить моторы, мигать светодиодом, перестать ездить
  }
}

Под нагрузкой напряжение проседает, поэтому решение «аккумулятор сел» принимают, только если низкое значение держится несколько секунд.

9. Конечный автомат — «мозг» робота

Когда поведение усложняется, цепочки if превращаются в кашу. Конечный автомат (FSM) раскладывает поведение на состояния и переходы. В каждый момент робот находится ровно в одном состоянии и знает, что в нём делать и при каких условиях переходить в другое.

Пример: робот, объезжающий препятствия.

enum State { DRIVE, BACKUP, TURN, STOPPED };
State state = DRIVE;
unsigned long stateStart = 0;

void enter(State s) {          // единая точка смены состояния
  state = s;
  stateStart = millis();
}
unsigned long inState() { return millis() - stateStart; }

void updateLogic() {
  float dist = readDistanceCm();
  float vbat = readBattery();

  if (vbat < 6.6 && state != STOPPED) enter(STOPPED);  // приоритетное условие

  switch (state) {
    case DRIVE:
      drive(160, 0);
      if (dist > 0 && dist < 20) enter(BACKUP);
      break;
    case BACKUP:
      drive(-120, 0);
      if (inState() > 400) enter(TURN);
      break;
    case TURN:
      drive(0, 140);                       // разворот на месте
      if (inState() > 350) enter(DRIVE);   // лучше — по гироскопу до нужного угла
      break;
    case STOPPED:
      drive(0, 0);
      break;
  }
}

Преимущества: поведение легко нарисовать на бумаге кружками и стрелками, легко добавить новое состояние, а таймеры каждого состояния не мешают друг другу.

10. Команды по Serial или Bluetooth

Модуль HC-05 или встроенный Bluetooth ESP32 выглядит для программы как обычный Serial-порт. Простой разборщик команд вида M 150 -40 (газ и поворот):

char buf[32];
uint8_t len = 0;

void handleCommand(char *cmd) {
  int a, b;
  if (sscanf(cmd, "M %d %d", &a, &b) == 2) drive(a, b);
  else if (strcmp(cmd, "STOP") == 0) drive(0, 0);
  else Serial.println("? unknown");
}

void pollSerial() {
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n' || c == '\r') {
      if (len > 0) { buf[len] = 0; handleCommand(buf); len = 0; }
    } else if (len < sizeof(buf) - 1) {
      buf[len++] = c;
    }
  }
}
Обязательно добавьте сторожевой таймер связи: если команд не было, например, 500 мс, робот останавливается. Иначе при обрыве связи он продолжит ехать с последней скоростью.

11. Отладка и хорошие привычки