1. Тема дня

Как проектировать задачи FreeRTOS в ESP-IDF: сколько задач нужно, какие у них должны быть ответственности, как выбирать приоритеты, где использовать очереди, а где не плодить лишние потоки. Главная мысль: FreeRTOS task - это не “просто поток”. Это владелец конкретной ответственности во времени.

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

ESP32 должен одновременно:

  • читать входы фаз светофора;
  • фильтровать шумные сигналы после оптопар;
  • фиксировать timestamp изменения фазы;
  • общаться с EC25 по UART;
  • принимать URC от модема;
  • отправлять MQTT/TCP/UDP;
  • отвечать на CLI;
  • управлять питанием;
  • не ловить watchdog;
  • быть тестируемым через HIL.

Одна задача на всё даст задержки. Слишком много задач даст хаос, гонки и переполнения стеков.

3. Что такое task на практике

Типовая задача:

c
void my_task(void *arg)
{
    while (1) {
        do_work();
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

Но хорошая задача отвечает не за “кусок кода”, а за процесс во времени:

text
input_task      - регулярно снимает входы
phase_task      - принимает снимки и определяет фазу
modem_task      - обслуживает EC25 и state machine
transport_task  - отправляет события наружу
cli_task        - обрабатывает команды пользователя
health_task     - следит за состоянием системы

Плохие названия:

text
task1
main_loop_task
process_task
logic_task

Хорошие названия:

text
input_scan_task
phase_detector_task
modem_service_task
udp_tx_task
cli_rx_task
health_monitor_task

4. Много задач не равно хорошая архитектура

Плохая идея:

text
red_input_task
yellow_input_task
green_input_task
mqtt_task
tcp_task
udp_task
modem_rx_task
modem_tx_task
cli_rx_task
cli_tx_task
log_task
watchdog_task
adc_task
gpio_task
filter_task

Проблемы:

  • много очередей;
  • много блокировок;
  • сложные зависимости;
  • непонятно, кто владеет данными;
  • расход RAM на стеки;
  • сложнее понять задержку от входа до события.

Правило: Новая task нужна только тогда, когда у блока есть независимая временная жизнь.

5. Базовая схема задач

Для проекта “детектирование фазы + отправка события”:

text
input_task
  -> input_snapshot_queue
phase_task
  -> phase_event_queue
transport_task
  -> modem_service / udp / tcp / mqtt
modem_task
cli_task
health_task

Ключевой принцип: Изменение фазы не должно ждать модем, MQTT, TCP, логи или CLI.

6. Где ставить timestamp

Плохо:

c
read_inputs();
filter_inputs();
send_udp();
event.timestamp_us = esp_timer_get_time();

Лучше:

c
input_snapshot_t snapshot = {
    .timestamp_us = esp_timer_get_time(),
    .raw_mask = read_input_mask(),
};

Дальше timestamp проходит через цепочку:

text
input_snapshot.timestamp_us
  -> phase_event.timestamp_us
  -> transport_event.timestamp_us
  -> UDP/MQTT/TCP payload

7. Приоритеты задач

Примерная шкала:

text
Высокий:
  input_task
  modem_uart_rx_task или modem_task с быстрой обработкой RX
Средний:
  phase_task
  transport_task
  cli_task
Низкий:
  health_task
  periodic_log_task
  telemetry_task

Не ставь всем высокий приоритет “на всякий случай”. Если задача высокого приоритета крутится без блокировки/yield, она может мешать idle task и привести к watchdog. Плохо:

c
while (1) {
    poll_uart();
    parse_everything();
}

Лучше:

c
while (1) {
    int len = uart_read_bytes(
        MODEM_UART,
        buf,
        sizeof(buf),
        pdMS_TO_TICKS(100)
    );
    if (len > 0) {
        modem_parser_feed(buf, len);
    }
}

8. Очереди

Очередь нужна, когда один контекст производит данные, а другой потребляет.

c
typedef struct {
    uint32_t raw_mask;
    int64_t timestamp_us;
} input_snapshot_t;
static QueueHandle_t input_queue;

Отправитель:

c
void input_task(void *arg)
{
    while (1) {
        input_snapshot_t s = {
            .raw_mask = input_driver_read_mask(),
            .timestamp_us = esp_timer_get_time(),
        };
        xQueueSend(input_queue, &s, 0);
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

Получатель:

c
void phase_task(void *arg)
{
    input_snapshot_t s;
    while (1) {
        if (xQueueReceive(input_queue, &s, portMAX_DELAY) == pdTRUE) {
            phase_event_t ev;
            if (phase_detector_update(&s, &ev)) {
                xQueueSend(phase_event_queue, &ev, 0);
            }
        }
    }
}

9. Переполнение очередей

Плохой быстрый путь:

c
xQueueSend(queue, &event, portMAX_DELAY);

Если очередь полна, задача зависнет. Лучше:

c
if (xQueueSend(phase_event_queue, &ev, 0) != pdTRUE) {
    diagnostics.phase_event_queue_drops++;
}

Переполнение очереди не должно быть молчаливым.

10. EC25 и задачи

Модему нужна отдельная задача, потому что он живёт асинхронно: AT-команды, таймауты, URC, переподключения, PDP, MQTT-состояния. Правильная схема:

text
transport_task
  -> modem_request_queue
modem_task
  -> UART2 / EC25
  <- URC / AT responses

UART2 принадлежит modem_task.

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

  • task без vTaskDelay() или блокирующего ожидания;
  • высокий приоритет всем задачам;
  • CLI напрямую меняет внутренности сервисов;
  • логи в быстром цикле;
  • блокировка быстрого пути на сети;
  • не измеряется uxTaskGetStackHighWaterMark().

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

Создай TASKS.md:

markdown
# FreeRTOS task map
| Task | Responsibility | Priority | Period / wake source | Owns | Must not do |
|---|---|---:|---|---|---|
| input_task | Read physical inputs and timestamp snapshots | 12 | every 5 ms | input driver | send network packets |
| phase_task | Convert input snapshots to phase events | 10 | input_snapshot_queue | phase detector state | read GPIO directly |
| modem_task | Own EC25 UART and modem state machine | 11 | UART / request queue / timers | UART2, AT parser | know traffic-light logic |
| transport_task | Send events over UDP/TCP/MQTT | 9 | phase_event_queue | network send queue | read ADC/GPIO |
| cli_task | Handle UART0 CLI commands | 7 | UART0 input | CLI parser | modify globals directly |
| health_task | Monitor heap, stacks, queue drops, watchdog counters | 5 | every 1 s | diagnostics | block fast path |

Дополнительно опиши:

  • чем владеет каждая задача;
  • быстрый путь;
  • что запрещено в быстром пути;
  • диагностические счётчики.

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

Task должна существовать не потому, что “так красивее”, а потому что у неё есть отдельная ответственность во времени. Базовое разбиение:

text
input_task      - быстро читает входы и ставит timestamp
phase_task      - определяет изменение фазы
modem_task      - владеет EC25 и UART2
transport_task  - отправляет события наружу
cli_task        - диагностика и управление
health_task     - стеки, heap, очереди, watchdog
В быстром input_task очередь заполнена. Какое действие сохраняет ограниченное ожидание и делает потерю наблюдаемой?

Задание

input_task читает вход каждые 5 мс. Модем может ждать ответ до 60 секунд. Составьте схему владения, timestamp и передачи данных, позволяющую входам продолжать работу. Добавьте поведение при перегрузке.

Критерии самопроверки: Сохраните разделение задач и timestamp; исключите ожидание сети из input_task; укажите обработку полной очереди.

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

input_task владеет чтением, фиксирует timestamp при получении снимка и передаёт снимок phase_task. phase_task владеет состоянием детектора и передаёт событие transport_task. modem_task владеет UART2 и автоматом AT с ограниченным ожиданием/таймаутом; сеть не блокирует чтение. Для каждой очереди задаются ёмкость, ограничение ожидания, обработка отказа и счётчик. Числа 5 мс/60 секунд здесь условия задания, а не гарантированные показатели платы.