1. Тема дня

Как правильно мыслить embedded-прошивку: слои, события, драйверы и сервисы. Главная идея: прошивка для ESP32 или STM32 - это не один большой файл с GPIO, UART и таймерами, а маленькая система, где каждый блок отвечает за свою область. Типичный набор подсистем в реальном проекте:

  • входы светофора через оптопары, ADC или GPIO-расширители;
  • ESP32 с ESP-IDF;
  • EC25-модем по UART и AT-командам;
  • MQTT/TCP/UDP;
  • логирование;
  • watchdog;
  • питание камеры, радара или Jetson;
  • HIL-тесты через Raspberry Pi;
  • возможный перенос части логики на STM32.

2. Зачем это нужно

Многие проблемы вроде задержек, watchdog reset, зависаний UART, ложных событий, сложного отключения мигания и непонятных состояний появляются не потому, что разработчик “плохо написал один if”, а потому что в проекте смешаны:

  • чтение железа;
  • фильтрация;
  • бизнес-логика;
  • транспорт;
  • логирование;
  • диагностика;
  • тайминги;
  • восстановление после ошибок.

Плохая логика:

c
while (1) {
    read_adc();
    filter_ema();
    detect_phase();
    if (changed) {
        send_tcp();
        log_everything();
    }
    check_modem();
    vTaskDelay(pdMS_TO_TICKS(10));
}

Поначалу это работает. Потом появляются разные типы входов, UDP-подтверждения, timestamp, watchdog, HIL, CLI, EC25 URC, восстановление связи - и один цикл превращается в клубок. Правильная мысль: Драйвер входов только измеряет сигнал. Детектор фаз только принимает очищенные события. Транспорт только доставляет событие. Модем только поддерживает связь. CLI только управляет и диагностирует.

3. Теория

Удобное деление прошивки на уровни:

text
Application layer
  логика устройства: фаза изменилась, нужно отправить событие,
  режим диагностики, аварийная логика
Service layer
  modem_service, phase_service, transport_service,
  health_service, cli_service
Driver layer
  adc_driver, gpio_expander_driver, uart_driver,
  opto_input_driver, timer_driver
Hardware / BSP layer
  номера GPIO, UART-пины, частоты, особенности платы

Правило: Верхний слой может знать о нижнем, но нижний не должен знать о верхнем. Примеры:

  • adc_driver не знает, что такое красный/жёлтый/зелёный;
  • phase_detector не знает, пришёл сигнал через ADS1115, MCP23017 или GPIO;
  • transport_service не знает, как устроена оптопара;
  • modem_service не знает, что событие относится к светофору;
  • cli_service не ковыряет GPIO напрямую, а вызывает публичные API сервисов.

4. Пример архитектуры для светофора

Плохой путь:

text
Считать ADS1115 -> отфильтровать -> понять фазу -> отправить по TCP -> залогировать

Хороший путь:

text
input_driver
  читает физические каналы
signal_filter
  превращает грязный сигнал в стабильный уровень
phase_detector
  понимает RED/YELLOW/GREEN/OFF/UNKNOWN
event_bus
  рассылает событие "phase changed"
transport_service
  отправляет событие по UDP/TCP/MQTT
health_service
  следит за ошибками и задержками
cli_service
  позволяет посмотреть состояние

5. Событийный подход

Polling-подход:

c
while (1) {
    check_inputs();
    check_modem();
    check_uart();
    check_timeout();
}

Event-driven подход:

text
INPUT_LEVEL_CHANGED
PHASE_CHANGED
MODEM_CONNECTED
MODEM_LOST
UDP_ACK_TIMEOUT
WATCHDOG_WARNING

Минимальная структура события:

c
typedef enum {
    APP_EVENT_PHASE_CHANGED,
    APP_EVENT_MODEM_READY,
    APP_EVENT_MODEM_LOST,
    APP_EVENT_UDP_ACK_TIMEOUT,
    APP_EVENT_INPUT_FAULT,
} app_event_type_t;
typedef struct {
    app_event_type_t type;
    int64_t timestamp_us;
    union {
        struct {
            uint8_t channel;
            uint8_t old_phase;
            uint8_t new_phase;
        } phase;
        struct {
            int error_code;
        } fault;
    } data;
} app_event_t;

6. Быстрый и медленный путь

Для задач со светофором и камерой главный принцип: Момент изменения фазы должен фиксироваться максимально близко к входу, а всё медленное - модем, TCP, MQTT, логи и CLI - должно происходить после этого и не задерживать timestamp. Плохая цепочка:

text
прочитал вход
  -> долго фильтровал
  -> долго логировал
  -> полез в модем
  -> потом записал timestamp

Правильная цепочка:

text
прочитал вход
  -> быстро зафиксировал timestamp
  -> положил событие в очередь
  -> транспорт отправляет, повторяет, ждёт ACK и логирует отдельно

7. Типичные ошибки

2.7.1 Ошибка 1. Драйвер знает бизнес-логику

Плохо:

c
if (adc_value > threshold) {
    send_red_phase_to_camera();
}

Лучше:

c
input_level_t level = input_driver_get_level(CH_RED);
phase_detector_process(CH_RED, level, timestamp);

2.7.2 Ошибка 2. Одна большая task на всё

Одна задача читает ADC, парсит UART, отправляет MQTT, печатает логи и обслуживает CLI. Это почти гарантированный путь к задержкам и watchdog.

2.7.3 Ошибка 3. Логи внутри быстрого пути

Логи через UART могут сильно влиять на тайминги. В быстром пути лучше считать счётчики, а печатать агрегированно раз в секунду или по CLI-команде.

2.7.4 Ошибка 4. Событие хранит мало информации

Плохо:

c
phase = GREEN;

Лучше:

c
phase_event = {
    .old_phase = RED,
    .new_phase = GREEN,
    .timestamp_us = esp_timer_get_time(),
    .source = INPUT_SOURCE_OPTO,
    .confidence = 95,
    .raw_mask = 0b0010,
};

2.7.5 Ошибка 5. Логика зависит от ESP-IDF/STM32 HAL напрямую

Если phase_detector.c включает driver/gpio.h, esp_log.h или stm32_hal_gpio.h, его сложно тестировать на ПК и переносить. Лучше чистая функция:

c
phase_state_t phase_detector_update(
    phase_detector_t *det,
    input_snapshot_t input,
    int64_t timestamp_us
);

8. Практическое задание

Создай файл ARCHITECTURE.md. Минимальная структура:

markdown
# Firmware architecture
## Layers
### BSP
Pins, UART numbers, board-specific constants.
### Drivers
ADC, GPIO expander, UART, timers.
### Services
Phase detector, modem, transport, CLI, health monitor.
### Application
Starts services and connects events.
## Fast path
Input sample -> timestamp -> phase detector -> event queue.
## Slow path
Transport, logging, diagnostics, retries.
## Rules
1. Drivers do not know business logic.
2. Services communicate through events.
3. Timestamp is captured before network sending.
4. Logs must not block fast path.

Затем найди в текущем проекте 3 нарушения архитектуры:

  • драйвер вызывает сетевую отправку;
  • логика фазы лежит внутри ADC-кода;
  • transport читает глобальные переменные;
  • CLI напрямую меняет внутренние поля сервисов;
  • timestamp ставится слишком поздно.

9. Что попробовать дальше

  • Посмотреть официальную документацию ESP-IDF по components и build system.
  • Посмотреть esp_event как основу событийного подхода.
  • Начать отделять phase_detector от ESP-IDF, чтобы его можно было тестировать на ПК.

10. Короткий итог

Хорошая embedded-прошивка строится не вокруг GPIO, UART и ADC, а вокруг ответственности модулей и событий между ними. Правильный быстрый путь:

text
вход изменился
  -> timestamp зафиксирован
-> новое состояние определено
-> событие положено в очередь
-> сеть, модем и логи работают отдельно
В условной трассе вход прочитан в 1000 мкс, UART-лог завершился в 4000 мкс, отправка по сети - в 9000 мкс. Какой timestamp описывает момент чтения входа?

Задание

Разложите чтение входа, timestamp, определение фазы, постановку события в очередь, AT-команды, повтор сетевой отправки и печать диагностических логов по быстрому и медленному пути. Назовите владельца каждой операции и одно условие, без которого сама очередь не гарантирует быстрый путь.

Критерии самопроверки: Назовите все семь операций, сохраните timestamp перед медленной работой, не поручайте драйверу транспорт и объясните обработку полной очереди.

Показать ответ автора

Быстрый путь: драйвер читает вход, фиксируется timestamp, детектор обновляет фазу, событие ставится в очередь. Медленный путь: modem_service ведёт AT-команды, transport_service отправляет и повторяет, диагностика агрегирует логи. Быстрый путь не должен неограниченно блокироваться на полной очереди; нужна явная политика переполнения и ограничение ожидания.