time.ticks_ms() в MicroPython: как не блокировать код и выполнять задачи конкурентно на ESP

от автора

Очень часто в программировании микроконтроллеров используются блокирующие задержки: 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/