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

while 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 time
import machine
from 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 == 0

def 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 и двустороннюю связь с телефоном при полном отсутствии интернета.

Комментарии (1)


  1. CrashLogger
    01.08.2026 21:43

    Вот мы и дожили до времен, когда микроконтроллеры программируют на Питоне. Что дальше ? Javascript ?