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:
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:
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 healthPoor names:
task1
main_loop_task
process_task
logic_taskBetter names:
input_scan_task
phase_detector_task
modem_service_task
udp_tx_task
cli_rx_task
health_monitor_task4. More tasks do not imply better architecture
An unsuitable approach:
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_taskProblems:
- 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:
input_task
-> input_snapshot_queue
phase_task
-> phase_event_queue
transport_task
-> modem_service / udp / tcp / mqtt
modem_task
cli_task
health_taskThe 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:
read_inputs();
filter_inputs();
send_udp();
event.timestamp_us = esp_timer_get_time();A better approach:
input_snapshot_t snapshot = {
.timestamp_us = esp_timer_get_time(),
.raw_mask = read_input_mask(),
};The timestamp then travels through this chain:
input_snapshot.timestamp_us
-> phase_event.timestamp_us
-> transport_event.timestamp_us
-> UDP/MQTT/TCP payload7. Task priorities
An illustrative priority scale:
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_taskDo 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:
while (1) {
poll_uart();
parse_everything();
}A better loop:
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.
typedef struct {
uint32_t raw_mask;
int64_t timestamp_us;
} input_snapshot_t;
static QueueHandle_t input_queue;Sender:
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:
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:
xQueueSend(queue, &event, portMAX_DELAY);If the queue is full, the task blocks indefinitely. A better approach:
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:
transport_task
-> modem_request_queue
modem_task
-> UART2 / EC25
<- URC / AT responsesmodem_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:
# 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:
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 watchdogExercise
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.