Заслонка проветривания в моей теплице двигалась идеально ровно — и именно в эти две секунды скетч переставал видеть мир. Первая версия ехала классикой: цикл for по градусам, delay(15) на каждый шаг. Пока шёл ход, Arduino была занята только паузами: датчик дождя стучал зря, кнопка ручного режима молчала, и раз в несколько дней заслонка ехала, когда ей велел цикл, а не когда требовала погода. Неделю я усиливал фильтры датчика, прежде чем признал: болезнь — блокировка loop(), и паузы тут ни при чём.
Здесь соберём то же плавное движение, но без единого delay: текущий угол, цель и скорость — три переменные, а движение считает тик на millis(). Кнопка назначает цель, серво едет с выбранной скоростью, и всё это время loop() дышит — опрашивает кнопки и датчики между тиками. Шаг по градусу с паузами мы делали в уроке про плавное движение и потенциометр; этот — его неблокирующий наследник. Каркас цикла тот же, что в уроке про millis вместо delay: если он у вас ещё не прошёл, держите его открытым рядом.
Три переменные и один тик
Математика движения умещается в три переменные. cur — где вал прямо сейчас, target — куда едем, SPEED — сколько градусов проходим за один тик. Тик — это STEP_MS = 20 мс, отмеряемые millis(), а не delay: в каждом проходе loop() спрашиваем «прошло ли 20 мс», и если да — сдвигаем cur на SPEED в сторону target, не проскакивая его. Один градус за 20 мс — это 50 градусов в секунду: заслонка проходит 140° за 2,8 секунды, глазу это «парадно», а мотору легко.
Скорость меняется одним числом: 2° за тик — 100°/с, 3° за тик — 150°/с. Потолок снизу у интерполяции стоит не в коде, а в кванте: write() принимает целые градусы, поэтому медленнее 50°/с при тике 20 мс не выжать — минимальный шаг уже 1°. Хотите медленнее — растягивайте тик: 1° за 40 мс даёт 25°/с, но на близком расстоянии движение станет заметно лесенкой. Как выйти за этот квант — через writeMicroseconds — скажу ниже, а пока добейтесь уверенности на целых градусах.
Отдельная прелесть схемы — смена цели на лету. Пока вал едет к 160°, кнопка спокойно меняет target на 20°: на следующем тике сравнение cur < target развернёт шаг, и серво плавно повернёт, не доезжая и не откатываясь рывком. Оговорка clamping — «не проскочить цель» — обязательна: без него последний шаг перелетает, и вал дёргается туда-обратно, пока арифметика не угомонится. В скетче эта оговорка занимает по одной строке на каждую ветку, и я советую не выбрасывать её даже ради краткости.
#include <Servo.h> Servo srv; const int PIN_BTN = 2;const int STEP_MS = 20; // тик движения, мсconst int SPEED = 1; // градусов за тик: 50 градусов в секунду int cur = 90; // где вал сейчасint target = 90; // куда едемint lastBtn = HIGH;unsigned long tPrev = 0; // прошлый тик void setup() { pinMode(PIN_BTN, INPUT_PULLUP); srv.attach(11); srv.write(cur); Serial.begin(9600);} void loop() { int btn = digitalRead(PIN_BTN); if (btn == LOW && lastBtn == HIGH) { // нажали только что if (target == 20) { target = 160; } else { target = 20; } Serial.print("goal="); Serial.println(target); } lastBtn = btn; if (millis() - tPrev >= STEP_MS) { // тик движения tPrev = millis(); if (cur < target) { cur = cur + SPEED; if (cur > target) { cur = target; } srv.write(cur); Serial.print("ang="); Serial.println(cur); } else if (cur > target) { cur = cur - SPEED; if (cur < target) { cur = target; } srv.write(cur); Serial.print("ang="); Serial.println(cur); } }}Кнопка на пине 2 с INPUT_PULLUP назначает цель: нажали — target переехал между 20° и 160°. Ловим не «нажата», а «нажата только что» — фронт через lastBtn, без него серво переворачивала бы цель пятьдесят раз в секунду, пока палец на кнопке; механику фронта разбирали в уроке про кнопку и digitalRead. Главное здесь даже не кнопка, а то, что её опрос живёт в одном loop() с движением: каждый проход занимает микросекунды, и между тиками скетч успевает датчики, второй механизм и всё остальное.
В Serial падают пары ang= — лесенка, где каждая ступенька равна одному тику. В Serial Plotter она рисуется наклонной линией с плоскими полками на краях: полка — стоим на цели, наклон — едем. Если наклон вдруг ломается посреди хода, серво упёрлась или питание просело — график подсказывает раньше глаза.
Почему write(160) — это не плавно
Справедливости ради: write(160) тоже привезёт вал к цели — но на максимальной скорости серво, около 600°/с у SG90: весь ход займёт треть секунды, и вместо движения получится рывок с дребезгом механики. Интерполяция ограничивает скорость: write() получает каждый промежуточный угол, и серво едет так быстро, как вы разрешили. Для заслонок и шлагбаумов линейное движение выглядит правильно. А вот камера-панорама глазу без сглаживания не нравится: там я делаю первые и последние 10–15% пути с шагом вполовину — две строки в коде, а движение оживает. Это вкус, а не правило: начните с линейного, украшайте потом.
Теперь обещанный квант. Если 50°/с много, а лесенка мешает — переходите на writeMicroseconds(): импульс 1000–2000 мкс раскладывается на 180°, один микросекундный шаг — примерно 0,18°, и медленные панорамы выходят гладкими. Я всё же начинаю проекты на целых градусах: пока не проверены питание и механика, дробные градусы прячут глюки, а не лечат их. Сначала устойчивый ход, потом красота.
Про цену тика, чтобы совесть была чиста. Один write() каждые 20 мс — это одна команда в библиотеку, а импульсы на пин она шлёт фоном, силами таймера, — мы разбирали это в уроке про attach, write и detach. Сам цикл интерполяции между тиками не стоит ничего: пара сравнений и одна переменная. Поэтому три серво с независимыми cur и target живут в одном loop() без взаимных обид — просто заведите на каждую свой набор переменных и свой тик.
| Шаг за тик 20 мс | Скорость | На что похоже |
|---|---|---|
| 1° | 50°/с | парадно-медленно: шлагбаумы, заслонки |
| 2° | 100°/с | спокойный рабочий ход |
| 3° | 150°/с | мой дефолт для рук роботов |
| 5° | 250°/с | бодро, лесенка ещё не видна |
| 10° | 500°/с | почти потолок SG90 — уже похоже на write |
- Поменяйте SPEED на 3 и сравните темп. Затем верните 1 и увеличьте STEP_MS до 40: какая версия выглядит плавнее при одинаковой итоговой скорости?
- Добавьте третью позицию: пусть кнопка листает цели 20, 90 и 160 по кругу. Массив целей заводится как в уроке про массивы.
- Сделайте разгон и торможение: в первых и последних 15° хода отправляйте угол через writeMicroseconds с полушагом — сравните глазом с линейной версией.
- Замените кнопку потенциометром: map(analogRead(A0), 0, 1023, 20, 160) становится target, и серво плавно догоняет ручку — тот же тик, та же математика.
- Три переменные — cur, target, SPEED; движение считает тик на millis(), в loop() нет ни одного delay
- 1° за 20 мс = 50°/с; скорость меняется числом шага или длиной тика
- Кнопка ловится по фронту через lastBtn, опрос живёт между тиками — скетч не слеп во время хода
- write() без интерполяции — рывок на 600°/с; механизмам линейно, камере — разгон и торможение
- Медленнее 50°/с на целых градусах не выйти: квант 1°; дальше — writeMicroseconds, где 1 мкс ≈ 0,18°
- 1. Почему движение тиком на millis() лучше цикла for с delay(15)?
- 2. cur = 90, target = 20, SPEED = 1, тик 20 мс. Сколько займёт ход?
- 3. Почему 50°/с — минимальная скорость при тике 20 мс и write()?
- 4. Кнопка назначает цель. Что случится без проверки фронта через lastBtn?
- 5. Камера-панорама: глаз видит резкий старт движения. Что сделаю я?
Частые вопросы по теме урока
- Почему движение тиком на millis() лучше цикла for с delay(15)?
- Смысл неблокирующей версии не в плавности, а в свободе: каждый проход loop() занимает микросекунды, поэтому кнопки и датчики опрашиваются даже посреди хода. loop() не блокируется: между тиками живёт остальной скетч
- cur = 90, target = 20, SPEED = 1, тик 20 мс. Сколько займёт ход?
- Разница 70 градусов по одному градусу за тик — 70 тиков по 20 мс = 1400 мс. Скорость в градусах в секунду считается перемножением шага и тика. 1400 мс
- Почему 50°/с — минимальная скорость при тике 20 мс и write()?
- Квант write() — целый градус. Минимальный шаг 1° за 20 мс и есть 50°/с; медленнее — только writeMicroseconds, где шаг примерно 0,18°. write() принимает целые градусы: шаг меньше одного градуса недоступен
- Кнопка назначает цель. Что случится без проверки фронта через lastBtn?
- Кнопка — уровень, а не событие: без фронта каждый проход с зажатой кнопкой перебрасывает target. Фронт «HIGH затем LOW» делает из удержания одно нажатие. Пока палец на кнопке, цель будет переворачиваться каждый проход loop()
- Камера-панорама: глаз видит резкий старт движения. Что сделаю я?
- Линейное движение имеет постоянную скорость, и старт выглядит рывком. Полушаг на краях — простейшее сглаживание без библиотек; уменьшение SPEED делает весь ход медленнее, а не мягче. Дам первым и последним 10–15% пути полушаг — разгон и торможение