Заслонка теплицы открывалась по расписанию, и ровно до первого дождя этим можно было гордиться. Сценарий был честный: мотор делает пол-оборота, пауза, пол-оборота назад. Но в тот же loop() я повесил ещё кнопку ручного режима и мигающий светодиод статуса — и они умерли тихо: пока шёл step(1024), плата не замечала ни кнопки, ни меня, стучащего по монитору порта. Секунд десять железо было в порядке, а логика — в коме. Сервоприводы я уже приучил к millis-циклу в разделе про серво, а тут выяснилось, что библиотека Stepper тащит свой таймер внутри и наружу его не отдаёт.
Выход из тупика я нашёл ещё в уроке про качели на порциях: дробить ход на кусочки по четыре шага. Здесь доведём этот приём до системы: соберём из порций настоящий планировщик — задачи с собственными таймерами, которые делят один loop() без единого delay. Паттерн тот же, что в уроке про millis вместо delay, только одна из задач — моторная, со своей арифметикой темпа.
Разберём по косточкам, что именно происходит внутри step() и почему он не возвращается, посчитаем паузу между порциями в миллисекундах, соберём три задачи на одном millis() и честно оговорим границы: планировщик делает loop() живым, но не делает мотор быстрым.
Что именно блокирует step()
Внутри библиотеки Stepper прячется цикл: переключить обмотки, подождать, повторить. Ожидание считается из двух чисел — оборотов в минуту и шагов на оборот: интервал между шагами равен 60000000 микросекунд, поделённых на RPM и на 2048. При 8 RPM это 3,7 миллисекунды на шаг, при 5 — 5,9, при 15 — 1,95. Вызов step(2048) живёт внутри до последнего шага: при 5 RPM мотор завершит оборот через 12 секунд, и все эти секунды loop() стоит на месте — Serial молчит, кнопки не опрашиваются, светодиод застыл. Хуже то, что пауза эта не честный сон: библиотека крутится в плотном цикле ожидания, и врезаться в неё извне некуда.
Порции меняют картину: step(4) при 8 RPM занимает примерно 15 миллисекунд — это четверть кадра, а не вечность. Между вызовами loop() полностью ваш: успевает опросить кнопку, посчитать millis(), отчитаться в порт. Но сразу вырастает обязательство по темпу: между порциями должно проходить не меньше тех же 15 миллисекунд, иначе вызовы пойдут подряд, а мотор начнёт отставать от расписания — на малой скорости это просто рваный ход, на предельной — срыв шагов, после которого счёт позиции врёт молча. Проверял: закрутишь 28BYJ-48 выше 15 RPM — гудит и трясётся, а библиотека рапортует о выполнении.
Арифметика паузы простая, и я записываю её одной строкой: PORTION шагов требуют PORTION × 60000 / (RPM × 2048) миллисекунд. Для четырёх шагов при 8 RPM это 14,6 — округляю вверх до 15. Округление вверх безопасно: мотор станет чуть медленнее обещанного, но не сорвётся. А вот округление вниз — классический промах: два вызова подряд, очередь не успевает, и на грани скорости механизм начинает пропускать шаги прямо во время отладки, когда кажется, что всё уже работает.
#include <Stepper.h> const int STEPS_PER_REV = 2048;const int PORTION = 4; // шагов за один заход моторной задачи Stepper stepper(STEPS_PER_REV, 8, 10, 9, 11); long remain = 1024; // остаток маршрута: четверть оборотаint dir = 1; // 1 вперёд, -1 назадint rpm = 8; unsigned long tMotor = 0; // метки последнего запуска задачunsigned long tLed = 0;unsigned long tBtn = 0;bool ledOn = false;bool paused = false; unsigned long motorInterval() { // пауза между порциями, мс return (PORTION * 60000UL) / (rpm * STEPS_PER_REV) + 1; // запас в 1 мс} void setup() { stepper.setSpeed(rpm); pinMode(12, OUTPUT); pinMode(2, INPUT_PULLUP); Serial.begin(9600); Serial.println("Planer: motor + LED + knopka");} void loop() { unsigned long now = millis(); // Задача 1: мотор — по расписанию, порциями if (!paused && remain > 0 && now - tMotor >= motorInterval()) { stepper.step(PORTION * dir); remain -= PORTION; tMotor = now; if (remain <= 0) { dir = -dir; remain = 1024; Serial.print("Razvorot na t="); Serial.println(now); } } // Задача 2: LED-сердцебиение, 500 мс полный цикл if (now - tLed >= 250) { ledOn = !ledOn; digitalWrite(12, ledOn ? HIGH : LOW); tLed = now; } // Задача 3: кнопка-пауза, опрос не чаще 50 мс — антидребезг временем if (now - tBtn >= 50 && digitalRead(2) == LOW) { paused = !paused; Serial.println(paused ? "PAUZA" : "RABOTA"); tBtn = now; }}Три задачи делят один loop()
Схема планировщика умещается в три предложения. У каждой задачи есть период и метка прошлого запуска; loop() каждую итерацию спрашивает каждую задачу: настало ли твоё время; отработавшая задача обновляет свою метку. Ни один delay, ни один вложенный цикл ожидания — при желании задач можно насадить десяток, и каждая будет жить в своём ритме, не мешая соседям. Моторная задача отличается только тем, что её период считается из RPM, а не пишется константой: поменялась скорость — поменялся и темп порций, пересчитывать ничего вручную не нужно.
LED-сердцебиение здесь не украшение, а инструмент: пока мотор едет, диод мигает с точностью до миллисекунды — значит, loop() жив и моторная задача не захватила цикл. Погасите мигание заменой периода на delay — и увидите, как всё застынет; это лучший способ почувствовать разницу, чем любые объяснения. Кнопка опрашивается каждым проходом с вилкой в 50 миллисекунд — тот же антидребезг временем, что описан в уроке про дребезг, только без блокировки. Команды из монитора порта в такую схему добавляются тем же шаблоном — приём из урока про команды через Serial встаёт четвёртой задачей без единой правки существующих.
Отдельный бюджет у печати в порт. На 9600 бодах один символ уходит за 1,04 миллисекунды, и пока строка короче 64-байтного буфера UART, Serial.print выглядит мгновенным: байты сбрасываются в буфер, loop() бежит дальше. Строка в сотню символов на той же скорости уже держит цикл около ста миллисекунд — мотор за это время доедет восемь шагов и не заметит, а вот кнопка с коротким нажатием может промелькнуть мимо опроса. Я поднял скорость порта до 115200 — бюджет печати упал в двенадцать раз, и вопрос закрылся навсегда.
Границу метода назову прямо: планировщик управляет порядком, а не скоростью. Потолок 28BYJ-48 на 5 вольтах — те же 10-15 RPM, что и раньше; порции не помогут провернуть вал быстрее, они лишь позволяют остальному коду дышать между шагами. Если нужна скорость и разгон — это уже AccelStepper, где неблокирующее движение зашито внутрь библиотеки: сравнение run() с блокирующими вызовами мы разбирали в отдельном уроке. Ручной планировщик хорош там, где мотор — одна из нескольких задач, а не единственная цель платы.
| Организация хода | Что происходит с loop() | Где живёт |
|---|---|---|
| step(2048) одним вызовом | Заблокирован на весь ход: 12 с при 5 RPM | Простые демо-скетчи |
| step() в обвязке из delay() | Мёртв большую часть времени, кнопки не видны | Первые шаги с мотором |
| Порции по 4 шага на millis() | Живой: между порциями проходят другие задачи | Мотор плюс датчики, кнопки, Serial |
| AccelStepper.run() в loop() | Живой, разгон и торможение внутри библиотеки | Быстрые оси, NEMA 17, ЧПУ |
Планировщик в симуляторе
В симуляторе мотор подключён к пинам 8, 10, 9, 11 через ULN2003, потенциометр на A0 задаёт RPM от 4 до 12, кнопка на D2 — пауза, LED на D12 тикает каждые 500 миллисекунд. Запустите и следите за Serial: отчёты идут ровно каждые три секунды, независимо от того, едет мотор или крутится потенциометр. Это и есть главный признак планировщика: ни одна задача не монополизирует цикл.
Крутаните ползунок до 12 RPM — моторная задача участится, а отчёты не сдвинутся ни на миллисекунду; уроните до 4 — мотор замедлится, LED продолжит мигать в прежнем ритме. Затем зажмите кнопку: мотор замрёт на середине хода, а светодиод и Serial продолжат жить — пауза остановила только одну задачу из трёх, что и требовалось доказать.
- Замрите на полпути. Зажмите кнопку, дождитесь строки PAUZA и подержите пять секунд: LED и тики Serial идут, мотор стоит — планировщик остановил одну задачу, не тронув остальные.
- Поймайте темп. Установите 12 RPM и посчитайте в мониторе, как часто мелькают развороты; затем сравните с 4 RPM — разница должна совпасть с арифметикой 60000/(RPM × 512).
- Сломайте расписание. Замените stepDelay на 2 и поднимите RPM до 12: ход станет рваным — так выглядит нехватка темпа, а на настоящей плате это путь к срыву шагов.
- Добавьте четвёртую задачу. Вставьте Serial-печать положения remain каждые 100 мс по той же схеме: если расчёты верны, она не потревожит ни мотор, ни LED.
- step() блокирует loop() на все шаги хода: при 5 RPM оборот занимает 12 секунд, и всё это время Serial и кнопки мертвы
- Интервал между шагами = 60000/(RPM × 2048) мс; для порции умножить на её размер и округлять вверх, с запасом
- Планировщик — задачи с периодом и меткой millis(): loop() опрашивает всех, ни одного delay
- LED-сердцебиение — бесплатный индикатор живого цикла: мигает во время движения, значит мотор не захватил loop()
- Планировщик даёт живой цикл, а не скорость: выше 15 RPM 28BYJ-48 срывает шаги независимо от структуры кода
- 1. Сколько времени займёт stepper.step(2048) при setSpeed(5)?
- 2. Порция step(4) при 8 RPM. Какой минимальный интервал между вызовами, чтобы ход был ровным?
- 3. Что служит будильником для каждой задачи планировщика?
- 4. LED мигает раз в 500 мс и во время движения мотора. Что это доказывает?
- 5. RPM подняли с 8 до 18: планировщик отчитался о всех шагах, а вал провернулся не до конца. Виноват планировщик?
Планировщик превращает мотор в одну из задач платы, но маршрут в нём всё ещё зашит в код: разворот на границе, остаток, направление. А ведь реальный механизм живёт сценариями: отъехать на 180 градусов, подождать две секунды, вернуться на 90, снова пауза. Как записать такой сценарий массивом и выполнить его конечным автоматом, не дописав ни одного delay — следующий урок: Очередь движений: сценарий из задач и конечный автомат. Домашнее задание: добавьте к планировщику потенциометр не на скорость, а на длину маршрута — и посмотрите, как разворот начинает дышать вместе с ползунком. Заодно сверьте темп разворотов с расчётом 60000/(RPM × 512): арифметика из этого урока обязана сходиться с наблюдением до шага.
Частые вопросы по теме урока
- Сколько времени займёт stepper.step(2048) при setSpeed(5)?
- 5 RPM — один оборот за 12 секунд. step() не возвращается до последнего шага, и весь ход loop() стоит: Serial молчит, кнопки не опрашиваются. Около 12 секунд, и всё это время loop() заблокирован
- Порция step(4) при 8 RPM. Какой минимальный интервал между вызовами, чтобы ход был ровным?
- Темп порций обязан совпадать с темпом библиотеки: 4 × 3,7 мс ≈ 15 мс. Меньше — вызовы пойдут сплошняком, больше — мотор замедлится. Около 15 мс: 4 × 60000/(8 × 2048)
- Что служит будильником для каждой задачи планировщика?
- Задача хранит период и метку; loop() сравнивает millis() - метка с периодом. Никаких delay и ожиданий внутри — только проверки по времени. Сравнение millis() с меткой прошлого запуска задачи
- LED мигает раз в 500 мс и во время движения мотора. Что это доказывает?
- Ровное мигание при работающем моторе — признак того, что step() вызывается короткими порциями, а не блокирует цикл целиком. loop() живой: между порциями проходят другие задачи
- RPM подняли с 8 до 18: планировщик отчитался о всех шагах, а вал провернулся не до конца. Виноват планировщик?
- Выше 15 RPM 28BYJ-48 пропускает шаги физически. Библиотека честно доложила о выполнении — но ротор не успел, и счёт с этого момента врёт. Нет: это срыв шагов на предельной скорости, счёт позиции соврал