
Очень часто в программировании микроконтроллеров используются блокирующие задержки: delay() для Arduino, time.sleep() для MicroPython. Эти функции просты и понятны, но у них есть фундаментальный недостаток: они останавливают выполнение программы на заданное время, делая систему нечувствительной к внешним событиям. В этот промежуток времени процессор не выполняет никакой код, не проверяет таймеры, не обрабатывает события и не реагирует на прерывания. Это как повар в ресторане, который ставит суп на плиту и говорит: «Я ничего не буду делать 10 минут, пока он варится». За это время мимо проходят голодные посетители, другие кастрюли перекипают, а он просто сидит и смотрит на таймер.
В системах реального времени нам нужно управлять датчиками и сетевыми интерфейсами одновременно, без простоев.
В MicroPython есть, на мой взгляд, элегантное решение — time.ticks_ms(). Это способ заставить микроконтроллер ESP делать несколько дел одновременно — без прерываний, без Timer, без asyncio и сложных планировщиков. Достаточно просто… посмотреть на часы.
Это как повар, который включает плиту, смотрит на настенные часы, запоминает время и идёт готовить салат. Каждые пару минут он поглядывает на часы: «Прошло 5 минут — помешаю суп. Прошло 30 минут — выключу огонь». Он успевает управлять десятью кастрюлями одновременно, потому что у него есть часы на стене и система проверок.
В этой статье мы рассмотрим,
как с помощью функции time.ticks_ms() создать систему неблокирующих таймеров. Она позволит управлять реле, опрашивать датчики и отправлять SMS одновременно, без остановки основного цикла;
как устроен аппаратный счётчик времени внутри ESP, почему его переполнение через 49 дней не страшно при использовании ticks_diff();
как безопасно вычислять интервалы с помощью ticks_diff() и как построить на этом простую и надёжную архитектуру для ваших проектов.
В качестве практического примера создадим небольшой проект мониторинга воды, в котором реле включается при срабатывании датчика и работает 30 минут, датчик опрашивается каждые 3 минуты, а SMS уходит мгновенно — и все это без остановки кода.
Природа времени в микроконтроллерах ESP или «Настенные часы»
Чтобы управлять временем, нужно понять, как оно течет внутри микроконтроллера. У ESP есть встроенный RTC (часы реального времени), но на большинстве плат нет отдельной батарейки для его питания. Поэтому при отключении питания календарное время сбрасывается. Однако аппаратный счётчик, о котором мы говорим, работает независимо от RTC и начинает отсчёт с момента включения. С момента включения питания он начинает безостановочно щелкать: 1, 2, 3… миллион, два миллиона. Вместо 12 цифр на циферблате у него 32 разряда (бита). Он считает до 4 294 967 296 миллисекунд. Если перевести это в дни, получится примерно 49.7 дней непрерывной работы. Как только счетчик добирается до этого максимума, он не ломается и не вызывает ошибку. Он просто обнуляется и начинает отсчет заново, как одометр в старой машине, который перевалил за 999 999 км. Это называется переполнением.
time.ticks_ms() — это не переменная, а функция. Каждый раз, когда вы её вызываете, она возвращает новое значение, которое показывает, сколько миллисекунд прошло с момента включения микроконтроллера ESP. Чтобы это понять выполните следующий код:
# Этот код просто показывает, как растет current_timeimport timewhile True: current_time = time.ticks_ms() # Каждый раз здесь будет новое, большее число print(current_time) # Вы увидите: 0, 1, 2, 3, 5, 8, 12... (растет) time.sleep_ms(1) # МИКРО-ПАУЗА — программа "отдыхает" 0.001 секунды, почти незаметно. Она НЕ КРИТИЧНА для логики неблокирующего кода, потому что очень короткая
time.ticks_ms() как аппаратный секундомер, который не останавливается и не сбрасывается. Переменная current_time — это просто взгляд на этот секундомер в данный момент. Мы не дергаем стрелки, не останавливаем механизм — мы просто фиксируем показания в переменную current_time. Это занимает настолько мало времени, что процессор даже не замечает этого действия. Внутри микроконтроллера ESP есть специальный электронный узел — аппаратный таймер. Он представляет собой счетчик, который непрерывно тикает, увеличивая свое значение, пока на плату подается питание. Когда вы вызываете функцию time.ticks_ms(), процессор просто «смотрит» на текущее значение этого счетчика, пересчитывает его в миллисекунды и отдает вам в виде числа.
Что происходит при переполнении (обнулении)?
Когда счетчик time.ticks_ms() достигает максимального значения (4.29 миллиарда миллисекунд), происходит переполнение. Но, это может произойти если ваш микроконтроллер работал без выключения 50 дней и это НЕ критично, если вы используете time.ticks_diff()
Функция ticks_diff() специально создана для работы с переполнениями. Она «умная» — она понимает, что значение ticks может обернуться, и корректно вычисляет разницу, даже если одно число большое, а другое маленькое.
Именно поэтому разработчики MicroPython дали нам не просто счетчик, а умный калькулятор — функцию time.ticks_diff(). Она знает про «круглый циферблат» и правильно вычисляет разницу, даже если часы перевелись через ноль. Поэтому не надо вычислять на прямую: elapsed = current – last, а всегда использовать ticks_diff() elapsed = time.ticks_diff(current, last)
ticks_diff() работает корректно только если между двумя проверками прошло меньше половины максимального значения (т.е. меньше ~24.8 дней). Если вы попытаетесь измерить интервал более 24.8 дней, функция может дать неверный результат. Это ограничение связано с тем, что ticks_diff() использует знаковую арифметику: старший бит 32-битного числа отводится под знак. Поэтому корректно вычислять разницу можно только для интервалов, не превышающих половину периода — примерно 24.8 дня. Для большинства задач (опрос датчиков, управление реле) это неактуально.
Но чтобы не достигать переполнения, автоматическая еженедельная или месячная перезагрузка микроконтроллера — отличное правило! Счетчик начинает считать заново с 0, проблема переполнения откладывается еще на 49 дней.
Будильники и якоря времени
Теперь, когда мы разобрались, как устроены «настенные часы» (ticks_ms()), давайте поговорим о самом важном — как их правильно использовать, чтобы делать несколько дел одновременно.
В неблокирующем коде есть два типа переменных:
Текущее время (current_time) — это как «взгляд на часы» current_time = time.ticks_ms(). Вы смотрите на циферблат в начале каждой итерации цикла, чтобы знать, «который час сейчас». Эта переменная обновляется постоянно. Чаще всего ее используют внутри цикла.
Время последнего события (last_action_time) — это как будильник. Вы заводите его на определённое время и не трогаете, пока он не зазвонит. Если смотреть на время математически, то можно это представить как «временную» прямую, которая начинается с 0 мс (момент включения микроконтроллера) и идёт бесконечно вперёд. На этой прямой мы отмечаем важные события и фиксируем эти координаты в переменной last_action_time. Чтобы узнать, сколько времени прошло с последней проверки, мы вычисляем разницу координат time.ticks_diff(now, last_action).
last_action = 0 # Завели будильник. interval = 5000 # Интервал, через который будет срабатывать будильникwhile True: current_time = time.ticks_ms() # Проверяем: будильник прозвенел? if time.ticks_diff(current_time, last_action) >= interval: print("Действие!") # Сработает ровно через 5 секунд last_action = current_time # Перезаводим будильник!
Золотое правило: last_action обновляется ТОЛЬКО когда действие выполнено. Никогда не обновляйте его в каждой итерации цикла!
Давайте вернёмся к нашему повару. У него на плите 10 кастрюль. Как он успевает всё контролировать?
Он не держит в голове всё время, а использует записную книжку-будильник, в которой фиксирует интервалы времени от последнего события,
Повар смотрит на часы (current_time), сверяется с записной книжкой и, если время совпало, выполняет действие. После этого он вычёркивает запись и пишет новую.
# Записная книжка повараsoup_start = 0 # Когда последний раз проверили супmeat_start = 0 # Когда последний раз проверили мясоporridge_start = 0 # Когда последний раз включили кашуwhile True: current_time = time.ticks_ms() # Будильник №1: Проверка супа (каждые 5 минут) if time.ticks_diff(current_time, soup_start) >= 300000: stir_soup() # Действие soup_start = current_time # Перевели будильник на следующую проверку # Будильник №2: Проверка мяса (каждые 15 минут) if time.ticks_diff(current_time, meat_start) >= 900000: flip_meat() # Действие meat_start = current_time # Будильник №3: Выключение каши (через 30 минут) if time.ticks_diff(current_time, porridge_start) >= 1800000: turn_off_porridge() # Действие porridge_start = 0 # Будильник больше не нужен
Практический пример — система мониторинга воды
Теперь, когда мы разобрались с теорией, давайте применим знания на практике. Создадим систему, которая решает реальную задачу.
Представьте: у вас есть колодец и вам, при поднятии воды до определённого уровня, необходимо ее откачать. Нужно:
Обнаружить воду — герконовый датчик замыкается при появлении воды
Включить насос — при обнаружении воды включается реле (3.3V)
Оповестить по SMS — через телефон-шлюз (MacroDroid)
Автоматически выключить насос — через 30 минут
Сообщить о завершении — когда насос отработал и воды нет
Давайте разберём код по частям. Весь код построен так, чтобы вы видели, как именно работает неблокирующий подход. В данном коде я не буду подробно рассматривать функции по отправке оповещения (эти вопросы рассмотрим в следующей статье).
Блок 1: Импорт библиотек
import timeimport machinefrom machine import Pin
Что мы импортируем:
Time — для работы с ticks_ms() и ticks_diff(),
Machine — для управления пинами и перезагрузки,
Pin — для настройки входов/выходов.
Это минимальный набор для демонстрации неблокирующей логики. Сеть и SMS мы рассмотрим позже.
Блок 2: Конфигурация и константы
# ПиныRELAY_PIN = 4 # Реле (3.3V)WATER_SENSOR_PIN = 5 # Герконовый датчик# Тайминги – интервалы (в миллисекундах)WATER_CHECK_INTERVAL = 3 * 60 * 1000 # 3 минутыRELAY_DURATION = 30 * 60 * 1000 # 30 минутWEEKLY_RESET_INTERVAL = 7 * 24 * 60 * 60 * 1000 # 7 дней
Что мы настраиваем:
RELAY_PIN 4 — пин, к которому подключено реле,
WATER_SENSOR_PIN 5 — пин герконового датчика,
WATER_CHECK_INTERVAL 180 000 мс (3 мин) — интервал опроса датчика,
RELAY_DURATION 1 800 000 мс (30 мин) — время работы насоса,
WEEKLY_RESET_INTERVAL 604 800 000 мс (7 дней) — интервал перезагрузки,
Почему в миллисекундах? Функция ticks_ms() возвращает миллисекунды, поэтому все интервалы мы задаём в миллисекундах для удобства сравнения.
Блок 3: Инициализация пинов
# Реле — выходrelay = Pin(RELAY_PIN, Pin.OUT)relay.value(0) # Насос выключен по умолчанию# Датчик воды — вход с подтяжкой к питаниюwater_sensor = Pin(WATER_SENSOR_PIN, Pin.IN, Pin.PULL_UP)
Настраиваем реле как выход, чтобы управлять насосом.
Выключаем реле при старте, чтобы было безопасное начальное состояние.
Настраиваем датчик как вход с подтяжкой, чтобы герконовый датчик работал как выключатель
Подтяжка к питанию (PULL_UP): При разомкнутом герконе (воды нет) пин «подтянут» к 3.3V через внутренний резистор. Мы читаем 1 (HIGH). При замыкании (вода есть) пин соединяется с землёй, и мы читаем 0 (LOW).
Блок 4: Переменные состояния (будильники).
# Будильники (якоря времени)last_water_check = 0 # Когда последний раз проверяли датчикrelay_start_time = 0 # Когда включили насосsystem_start_time = 0 # Когда запустилась система# Флаги состоянияwater_present = False # Есть ли вода сейчас?relay_on = False # Включён ли насос?
last_water_check — хранит время в мс последней проверки датчика,
relay_start_time — хранит время в мс включения насоса,
system_start_time – время в мс запуска системы, один раз при старте, для еженедельной перезагрузки,
water_present — хранит булевые значения, есть ли вода сейчас,
relay_on — хранит булевые значения, включён ли насос.
Блок 5: Вспомогательные функции
def read_water_sensor(): """ Читает герконовый датчик с антидребезгом Возвращает: True (вода есть), False (воды нет) """ # Первое чтение first_value = water_sensor.value() # Повторные проверки для подавления дребезга for i in range(2): # Проверяем 3 раза с интервалом 50 мс time.sleep_ms(50) if water_sensor.value() != first_value: # Значение изменилось — перезапускаем проверку return read_water_sensor() # Все проверки прошли успешно # Замкнут (0) = вода есть, разомкнут (1) = воды нет return first_value == 0def send_sms(signal_type): """ Отправляет SMS через MacroDroid на телефоне signal_type: '1' — вода появилась, '2' — насос выключен """ # Реализация будет в следующей статье Здесь мы просто обозначаем действие print(f" Отправка SMS: сигнал {signal_type}") return True
read_water_sensor() — читает датчик с антидребезгом, предотвращает ложные срабатывания,
send_sms() — отправляет SMS (заглушка), здесь мы только обозначаем действие.
Блок 6: Основной цикл (ядро неблокирующего кода)
def main(): global water_present, relay_on, relay_start_time global last_water_check, system_start_time # Запоминаем время запуска, реальное время, а не 0 по умолчанию system_start_time = time.ticks_ms() last_water_check = time.ticks_ms() # Первое чтение датчика water_present = read_water_sensor() while True: try: # Смотрим на часы в начале каждой итерации now = time.ticks_ms() # === ЗАДАЧА 1: ЕЖЕНЕДЕЛЬНАЯ ПЕРЕЗАГРУЗКА === # Проверяем, не прошло ли 7 дней с момента запуска if time.ticks_diff(now, system_start_time) >= WEEKLY_RESET_INTERVAL: machine.reset() # Перезагружаем ESP32 # === ЗАДАЧА 2: ПРОВЕРКА ДАТЧИКА (каждые 3 минуты) === if time.ticks_diff(now, last_water_check) >= WATER_CHECK_INTERVAL: new_water_state = read_water_sensor() # Если состояние воды изменилось if new_water_state != water_present: water_present = new_water_state # Если вода появилась И реле выключено → включаем насос if water_present and not relay_on: relay.value(1) relay_on = True relay_start_time = now send_sms('1') # "вода появилась, насос включен" # Обновляем время последней проверки last_water_check = now # === ЗАДАЧА 3: УПРАВЛЕНИЕ НАСОСОМ === if relay_on: elapsed = time.ticks_diff(now, relay_start_time) if elapsed >= RELAY_DURATION: relay.value(0) relay_on = False # Если воды нет → отправляем SMS if not water_present: send_sms('2') # "насос выключен, воды нет" # === ПАУЗА ДЛЯ СНИЖЕНИЯ НАГРУЗКИ === time.sleep_ms(10) except Exception as e: # Обработка ошибок обязательна!print(f" Ошибка в основном цикле: {e}") time.sleep_ms(5)
Ключевые моменты:
Используем time.ticks_diff() для проверки интервалов
Проверяем время в каждой итерации
Каждая задача имеет свой «будильник»
Используем паузу 10 мс только для снижения нагрузки
Как это условно работает на практике:
Итерация 1: now = 0
→ Проверка датчика: 0 — 0 = 0 < 180000 → пропускаем
→ Проверка реле: relay_on = False → пропускаем
→ Пауза 10 мс
Итерация 2: now = 10
→ Проверка датчика: 10 — 0 = 10 < 180000 → пропускаем
→ Проверка реле: relay_on = False → пропускаем
→ Пауза 10 мс
… (180 000 итераций за 3 минуты)
Итерация 180 000: now = 180 000
→ Проверка датчика: 180 000 — 0 = 180 000 >= 180 000 → ДА!
→ Читаем датчик
→ Если вода есть → включаем реле
→ last_water_check = 180 000
→ Пауза 10 мс
Итерация 180 001: now = 180 010
→ Проверка датчика: 180 010 — 180 000 = 10 < 180 000 → пропускаем
→ Проверка реле: relay_on = True
→ elapsed = 180 010 — 180 000 = 10 < 1 800 000 → пропускаем
→ Пауза 10 мс
Что происходит при разных сценариях:
Вода появилась — включается насос, отправляется SMS
Вода есть, насос работает — повторно не включаем
Вода исчезла, насос работает — насос продолжает работать до 30 минут
Прошло 30 минут, воды нет — насос выключается, отправляется SMS.
Когда использовать time.ticks_ms()?
Его стоит использовать, когда:
— Вам нужно управлять несколькими задачами в одном цикле. Опрос датчика, мигание светодиодом, проверка кнопки, отправка данных — всё это легко умещается в один while True с проверкой времени через ticks_diff().
— Там, где вы сами контролируете логику и время выполнения. Код получается линейным, понятным и детерминированным.
— Если хотите экономить память. ticks_ms() потребляет ~100 байт RAM, тогда как asyncio требует 5-10 КБ.
— Интервалы не критичны к микросекундной точности. Задержка в пару миллисекунд из-за выполнения других задач в цикле обычно не играет роли.
Но выбор, что использовать всегда остаётся за вами.
На просторах интернета попалась вот такая сравнительная таблица.
|
Характеристика |
ticks_ms() |
machine.Timer |
asyncio |
|
Суть |
Циклический опрос времени в главном цикле |
Аппаратные прерывания в заданные моменты |
Кооперативная многозадачность с await |
|
Точность |
±1 мс |
Микросекундная |
±10 мс (из-за FreeRTOS) |
|
Память |
~100 байт |
~200 байт |
5-10 КБ |
|
Количество таймеров |
Не ограничено |
Ограничено (обычно 4) |
Не ограничено |
|
Сложность |
Низкая |
Средняя |
Высокая |
|
Идеально для |
Опрос датчиков, управление реле, мигание светодиодами |
Генерация ШИМ, управление шаговыми двигателями, точные измерения |
Веб-серверы, MQTT-клиенты, сложные сетевые проекты |
Надеюсь, что прочитав эту статью до конца, вы научились простой вещи: как смотреть на часы.
Время не нужно ждать. Им нужно управлять. Это можно представить следующей аналогией:
time.sleep() — это попытка остановить время. time.ticks_ms() — это умение с ним работать. Разница между ними — как между тем, чтобы сидеть у кастрюли и ждать, и тем, чтобы готовить десять блюд одновременно.
ticks_ms() — это инструмент управления временем, который решает одну конкретную задачу: измерение интервалов времени без блокировки кода и он не панацея, а один из инструментов в арсенале разработчика.
P.S. В следующей статье я опишу систему отправки SMS и двустороннюю связь с телефоном при полном отсутствии интернета.
ссылка на оригинал статьи https://habr.com/ru/articles/1065666/