1. Today's topic

Designing FreeRTOS tasks in ESP-IDF: how many tasks you need, what each should own, how to choose priorities, when to use queues and when to avoid unnecessary threads. The central idea: a FreeRTOS task is more than a thread. It owns a specific responsibility over time.

2. Why this matters

The ESP32 must simultaneously:

  • read traffic-light phase inputs;
  • filter noisy signals after optocouplers;
  • record the timestamp of a phase change;
  • communicate with EC25 over UART;
  • receive modem URCs;
  • send MQTT/TCP/UDP;
  • respond to the CLI;
  • control power;
  • avoid watchdog resets;
  • support HIL testing.

One task for everything introduces delays. Too many tasks introduce confusion, races and stack overflows.

3. What a task means in practice

A typical task:

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

A good task owns a process over time, rather than merely a piece of code:

text
input_task      - samples inputs regularly
phase_task      - receives snapshots and determines the phase
modem_task      - manages EC25 and its state machine
transport_task  - sends events to external systems
cli_task        - processes user commands
health_task     - monitors system health

Poor names:

text
task1
main_loop_task
process_task
logic_task

Better names:

text
input_scan_task
phase_detector_task
modem_service_task
udp_tx_task
cli_rx_task
health_monitor_task

4. More tasks do not imply better architecture

An unsuitable approach:

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

Problems:

  • many queues;
  • many locks;
  • complex dependencies;
  • unclear data ownership;
  • RAM consumed by stacks;
  • input-to-event latency becomes harder to understand.

Rule: add a task only when a component has an independent lifecycle over time.

5. A basic task arrangement

For phase detection followed by event transmission:

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

The key principle: a phase change must not wait for the modem, MQTT, TCP, logging or the CLI.

6. Where to record the timestamp

An unsuitable approach:

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

A better approach:

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

The timestamp then travels through this chain:

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

7. Task priorities

An illustrative priority scale:

text
High:
  input_task
  modem_uart_rx_task or modem_task with fast RX handling
Medium:
  phase_task
  transport_task
  cli_task
Low:
  health_task
  periodic_log_task
  telemetry_task

Do not give every task a high priority just in case. A high-priority task that spins without blocking or yielding can starve the idle task and trigger a watchdog. An unsuitable loop:

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

A better loop:

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. Queues

Use a queue when one context produces data and another consumes it.

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

Sender:

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));
    }
}

Receiver:

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. Queue overflow

An unsuitable fast path:

c
xQueueSend(queue, &event, portMAX_DELAY);

If the queue is full, the task blocks indefinitely. A better approach:

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

Queue overflow must never be silent.

10. EC25 and tasks

The modem needs its own task because it operates asynchronously: AT commands, timeouts, URCs, reconnections, PDP and MQTT states. A suitable arrangement:

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

modem_task owns UART2.

11. Common mistakes

  • a task without vTaskDelay() or a blocking wait;
  • giving every task high priority;
  • the CLI directly changing service internals;
  • logging in a fast loop;
  • blocking the fast path on the network;
  • not measuring uxTaskGetStackHighWaterMark().

12. Practical assignment

Create 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 |

Also describe:

  • what each task owns;
  • the fast path;
  • operations forbidden in the fast path;
  • diagnostic counters.

13. Brief recap

Create a task because it has a separate responsibility over time, rather than for appearance. A basic division:

text
input_task      - reads inputs quickly and records a timestamp
phase_task      - determines phase changes
modem_task      - owns EC25 and UART2
transport_task  - sends events to external systems
cli_task        - diagnostics and control
health_task     - stacks, heap, queues and watchdog
The queue is full in the fast input_task. Which action preserves bounded waiting and makes a loss observable?

Exercise

input_task samples every 5 ms. The modem may wait up to 60 seconds for a response. Design ownership, timestamp capture and data flow so input sampling can continue. Include overload behavior.

Self-check criteria: Preserve task separation and timestamp capture; exclude network waiting from input_task; specify full-queue handling.

Show the supplied answer

input_task owns sampling, captures the timestamp with the snapshot and passes it to phase_task. phase_task owns detector state and passes events to transport_task. modem_task owns UART2 and the AT state machine with bounded waits/timeouts; networking does not block sampling. Each queue needs capacity, a wait bound, failure handling and a counter. The 5 ms/60 seconds values are exercise assumptions, not guaranteed board measurements.