Поворотный столик для съёмки мелочи я описал в первом черновике четырьмя строками: step(1024), delay(2000), step(-512), delay(1000). Через неделю заказчик попросил третью точку остановки, и строки превратились в восемь; потом — «а добавь паузу между вторым и третьим» — и нумерация delay поехала, потому что каждый новый шаг сдвигал все нижние. К пятой правке сценарий был лапшой из двадцати строк, в которой я боялся трогать середину: любое движение вспять ломало хвост. Классический признак того, что сценарий пора вытащить из кода и переложить в массив чисел.
Выход — очередь движений: массив пар «сколько шагов, какая пауза после», плюс исполнитель, который ведёт мотор от строки к строке. Исполнитель этот — конечный автомат: у него три состояния (едет, ждёт, стоит) и жёсткие правила перехода между ними. Такая структура держала у меня и столик на четыре точки, и кормушку с расписанием из урока про дозатор, и шторный привод из урока про шторы — правка сценария сводилась к правке чисел, код не открывался месяцами.
Соберём очередь на массиве, разложим автомат по состояниям, научимся запускать сценарий кнопкой по фронту нажатия и проверим всё в симуляторе: четыре задачи — 180°, пауза 2 с, минус 90°, пауза, 45°, возврат в ноль — поедут по одной кнопке.
Сценарий как массив пар
Каждая строка сценария — это два числа: signed-количество шагов (плюс — по часовой, минус — против) и пауза в миллисекундах после этого движения. В C++ пары удобнее всего держать структурой: struct Move { long steps; int pauseMs; }; массив из шести таких структур вмещает полноценный сценарий столика и весит около двадцати байт ОЗУ — копейки даже для Uno. Тип steps — именно long: int подписан только до 32767, а винтовой привод из пары разделов легко просит 40000 шагов на ход, переполнение превратит подъём в падение, и искать это по листингу — то ещё удовольствие.
Обращаю внимание на вырожденный случай, который экономит половину сценариев: задача с нулём шагов и ненулевой паузой — это просто пауза, без движения. Весь хореографический словарь столика укладывается в два примитива: «повернуться» и «постоять». Добавлять третий не спешите: угол, скорость и направление раскладываются в шаги ещё на этапе заполнения массива — пересчёт из градусов я собирал в уроке про свои функции поворота, там же готовый turnByAngle.
Заполнение очереди выносится в отдельные строки setup(): addMove(1024, 2000), addMove(-512, 1000) — сценарий читается сверху вниз как список действий, и правка середины не задевает ни хвост, ни голову. Именно за это я и платил пятьюдесятью байтами кода: сценарий стал массивом чисел, а числа правятся без страха.
Если структуры не хочется, те же пары живут в двух параллельных массивах — stepsQ и pauseQ: на Uno раскладка в памяти выходит одинаковой, а в учебных скетчах так даже нагляднее. Но что важнее всего в addMove — проверка значений на входе: очередь не переполнена, пауза не отрицательна, число шагов в разумных пределах. Мусорная строка, пойманная при добавлении, стоит одной строки в мониторе; та же строка, доехавшая до исполнителя, останавливает механизм в случайном месте, и вы ищете причину по журналу задним числом. Я печатаю номер и значения каждой добавленной задачи — пять байт отладки на сценарий, который потом правится вслепую.
#include <Stepper.h> const int STEPS_PER_REV = 2048;const int PORTION = 4; // шагов за один заход struct Move { // одна строка сценария long steps; // плюс по часовой, минус против int pauseMs; // пауза после движения}; Move queue[6];int qlen = 0; // сколько задач в очередиint qi = 0; // номер текущей задачи long remain = 0; // остаток шагов текущей задачиint state = 0; // 0 стоит, 1 едет, 2 ждёт паузуunsigned long tMotor = 0;unsigned long tPause = 0; void addMove(long steps, int pauseMs) { if (qlen >= 6) return; queue[qlen].steps = steps; queue[qlen].pauseMs = pauseMs; qlen = qlen + 1;} void queueStart() { qi = 0; remain = queue[0].steps; state = 1; digitalWrite(12, HIGH); Serial.println("--- Scenarij start");} void setup() { stepper.setSpeed(8); pinMode(12, OUTPUT); Serial.begin(9600); addMove(1024, 2000); // 180 gradusov, pauza 2 s addMove(-512, 1000); // 90 nazad addMove(256, 500); // 45 vpered addMove(-768, 0); // 135 nazad, v nol queueStart();} void loop() { unsigned long now = millis(); if (state == 1) { // ЕДЕТ: порции по расписанию if (now - tMotor >= 15 && remain != 0) { int p = PORTION; if (remain < 0) p = -PORTION; if (remain > 0 && remain < PORTION) p = remain; if (remain < 0 && remain > -PORTION) p = remain; stepper.step(p); remain -= p; tMotor = now; } if (remain == 0) { // задача кончилась Serial.print("zadacha "); Serial.print(qi); Serial.println(" gotova"); state = 2; tPause = now; } } else if (state == 2) { // ЖДЁТ: пауза из строки сценария if (now - tPause >= queue[qi].pauseMs) { qi = qi + 1; if (qi >= qlen) { state = 0; digitalWrite(12, LOW); Serial.println("Scenarij zavershen"); } else { remain = queue[qi].steps; state = 1; } } }}Автомат: три состояния и переходы
Автомат выглядит скромно: переменная state и три ветки if. Но за скромностью стоит дисциплина. В состоянии «едет» автомат делает одно-единственное дело — выдаёт мотору порции, пока остаток не кончится; он не смотрит на кнопки и не печатает ничего лишнего. В состоянии «ждёт» он держит таймер паузы и не трогает мотор. В состоянии «стоит» он вообще ничего не делает — и это тоже состояние, а не отсутствие жизни: в реальных механизмах «стоит» перерастает в «дежурит и ждёт кнопку» за одну правку. Переходы односторонние и описаны явно: едет → ждёт → едет → … → стоит. Оказавшись в ветке state == 1, вы точно знаете, что остаток валиден, а таймер паузы вам не принадлежит.
Соблазн вместо автомата навесить флагов я знаю на своей шкуре: два-три булевых переменных держатся в голове легко, но уже четвёртый переход рождает комбинации, которые никто не тестировал — флаг «едем» плюс флаг «пауза» плюс «разворот» дают восемь сочетаний, из которых легальны четыре. Автомат убивает недопустимые сочетания сам: состояние одно, и в нём определённые дела. Это та же медицина, что с goto в больших скетчах, только встроенная в структуру кода.
Отладка автомата — это журнал переходов. При каждой смене состояния я печатаю в порт старое и новое значение вместе с номером задачи: одна строка на переход стоит копейки, а поведение сценария разворачивается в читаемую ленту «старт — задача 0 — пауза — задача 1». Когда столик встал не там, лента показывает точный переход, после которого всё пошло вразрез с планом, и искать остаётся одну ветку, а не весь loop(). Второй приём — контрольный сценарий из двух задач по два шага: он пробегает весь автомат за секунды и ставится в начало setup() на время отладки.
Движение внутри состояния «едет» — ровно тот порционный шаблон из урока про планировщик: step(PORTION) по таймеру millis(). Знак задачи обрабатывается направлением порции: мотор едет в сторону знака, пока остаток не обнулится. Кнопка запуска в симуляторе проверяется по фронту: реакция только на момент нажатия, а не на каждый проход loop() — иначе один зажим кнопки перезапустит сценарий сорок раз подряд.
| Способ записать сценарий | Как правится | Где ломается первым |
|---|---|---|
| Лапша из step() и delay() | Правкой середины, с риском для хвоста | Любая вставка паузы сдвигает все нижние строки |
| Флаги и вложенные if | Новой булевой переменной | Четвёртый переход: непротестированные сочетания |
| Очередь пар «шаги, пауза» | Числами в setup(), код не трогается | Переполнение очереди — проверять qlen |
| Сценарий из Serial или карты | Внешними числами на ходу | Валидация: шаги 0 без паузы — мёртвая задача |
Очередь в симуляторе
В симуляторе сценарий из четырёх задач стартует кнопкой на D2: 180° по часовой, пауза 2 секунды, минус 90°, пауза 1,5 секунды, 45°, возврат на минус 135° в исходную. LED на D12 горит, пока очередь исполняется, и гаснет в состоянии «стоит» — это живой индикатор состояния автомата, повторяющий digitalWrite из queueStart и из ветки завершения.
Понажимайте кнопку в момент, когда очередь уже едет: фронт-детектор не даст перезапустить сценарий по сто раз за удержание — новый старт случится только после отпускания и нового нажатия. Обратите внимание: во время паузы между задачами LED продолжает гореть — очередь ещё не закончилась, автомат просто ждёт; погасание диода означает переход в дежурное состояние, а не конец сценария, и спутать эти два события легко, если судить только по мотору. Следите за Serial: сообщения «zadacha N gotova» идут по одному на строку сценария, паузы видны как тишина нужной длительности. Пошевелите числа в массиве — сценарий поменяется, а код исполнителя останется нетронутым: это и есть главный выигрыш очереди.
- Удлините сценарий. Заведите пятую задачу: 2048 шагов и пауза 3000 — полный оборот с долгим отдыхом; qlen и размер массива подрастут вместе, исполнитель не изменится.
- Сделайте чистую паузу. Вставьте задачу с шагами 0 и паузой 1500 между второй и третьей: автомат проведёт мотор через полторы секунды тишины без единого шага.
- Сломайте фронт. Уберите проверку btnWas — и зажатая кнопка будет перезапускать сценарий десятки раз: наглядная цена реакции на уровень вместо фронта.
- Вынесите сценарий в Serial. Принимайте числа пары через parseInt и заполняйте очередь на лету — заготовка разбора чисел лежит в уроке про Serial и числа.
- Строка сценария — пара «signed шаги, пауза мс»: struct Move или два параллельных массива
- steps — только long: int кончается на 32767, а реальные маршруты просят десятки тысяч шагов
- Автомат из трёх состояний (едет, ждёт, стоит) исключает недопустимые сочетания флагов сам, структурой кода
- Задача с шагами 0 и паузой N — легальная пауза: весь сценарий строится на «повернуться» и «постоять»
- Старт по фронту кнопки (btnWas) отличает нажатие от удержания — иначе один зажим даёт десяток перезапусков
- 1. Что хранит одна строка очереди движений?
- 2. Сколько состояний у автомата в этом уроке и какие?
- 3. Пауза после задачи — 0 мс. Что сделает автомат?
- 4. Почему для шагов в очереди взят long, а не int?
- 5. Зачем в старте по кнопке переменная btnWas?
Очередь превратила сценарий в числа, но осталась слепой: исполнитель честно ведёт мотор к последней строке, не зная, что за окном. А в реальном механизме на пути всегда есть край — упор, крышка, край хода, и мотор, уверенно едущий в стену, превращает сценарий в акт вандализма за две секунды. Как поставить концевик на D2, останавливать ход раньше жёсткого упора и отъезжать от границы — финальный урок серии: Стоп по датчику: концевик на D2 и границы хода.
Частые вопросы по теме урока
- Что хранит одна строка очереди движений?
- Пара «шаги, пауза» закрывает и движение, и ожидание: пауза после задачи — часть той же строки, отдельный таймер не нужен. Число шагов со знаком и паузу после движения
- Сколько состояний у автомата в этом уроке и какие?
- Едет — мотор получает порции; ждёт — таймер паузы; стоит — дежурный режим. Переходы односторонние и прописаны явно. Три: едет, ждёт паузу, стоит
- Пауза после задачи — 0 мс. Что сделает автомат?
- Сравнение таймера с нулём истинно немедленно: переход в следующую задачу случится на ближайшем проходе loop() — задержки нет по сценарию. Сразу перейдёт к следующей задаче
- Почему для шагов в очереди взят long, а не int?
- Винтовые приводы легко просят десятки тысяч шагов. Переполнение int переворачивает знак хода — редкий и коварный вид сбоя сценария. int подписан только до 32767: маршрут на 40000 шагов переполнится и мотор поедет не туда
- Зачем в старте по кнопке переменная btnWas?
- Без фронта зажатая кнопка перезапустит сценарий на каждом проходе loop() — десятки раз в секунду. btnWas сравнивает нынешний уровень с прошлым. Для антидребезга паузой 50 мс