In short: FreeRTOS tasks are independent functions scheduled by a small real-time kernel that uses fixed priorities and preemption to decide which one runs next. Understanding task creation, priority assignment, and the synchronization primitives (queues and semaphores) is the foundation of writing responsive, deterministic embedded firmware.
In FreeRTOS, a task is simply a C function that the kernel treats as an independent thread of execution. Each task has its own stack, its own copy of processor register context, and a priority. On a single-core microcontroller only one task actually executes at any instant, but the FreeRTOS scheduler switches between them so quickly that they appear to run concurrently. This model lets you decompose a complex embedded application into small, cooperating units of work rather than one monolithic super-loop.
A task function has a fixed signature: it takes a single void * parameter and, by convention, never returns. Instead it runs an infinite loop. If a task ever needs to finish, it must delete itself explicitly with vTaskDelete(NULL) rather than falling off the end of the function.
You create a task with xTaskCreate() (dynamic allocation) or xTaskCreateStatic() (you supply the stack and task control block buffers yourself). The call registers the task with the kernel and places it in the Ready state. The key arguments are:
void * passed to the task, useful for reusing one function across several task instances.configMAX_PRIORITIES - 1.Nothing runs until you call vTaskStartScheduler(), which hands control of the processor to the kernel. From that point on, the scheduler — not main() — decides what executes.
Every task sits in one of several states: Running, Ready (able to run but not currently scheduled), Blocked (waiting for an event or timeout), or Suspended. The scheduler's job is to pick, at every scheduling point, the highest-priority task that is in the Ready state.
Scheduling points occur on every tick interrupt (driven by a periodic timer, often 1 ms) and whenever an API call changes task readiness — for example when data arrives on a queue a blocked task was waiting on. If that event unblocks a task of higher priority than the one currently running, FreeRTOS switches immediately. This is what makes the system real-time: a high-priority task responds predictably, bounded only by interrupt latency and context-switch time.
When two or more Ready tasks share the same priority, the scheduler time-slices between them on each tick using round-robin, provided configUSE_TIME_SLICING is enabled. A critical design rule follows: a high-priority task must block (via a delay, a queue, or a semaphore) rather than spin in a busy loop, otherwise it starves every lower-priority task and the idle task never runs.
Tasks need to communicate without corrupting shared data. The primary FreeRTOS mechanism is the queue — a thread-safe FIFO buffer created with xQueueCreate(). A producer task calls xQueueSend() to copy an item into the queue; a consumer calls xQueueReceive() to copy it out.
Queues matter because they handle both data transfer and synchronization at once. If a task tries to receive from an empty queue, it can block for a specified timeout; the kernel removes it from the Ready list so it consumes no CPU, then unblocks it the instant an item arrives. Crucially, FreeRTOS queues pass data by copy, not by reference, which avoids many ownership and lifetime bugs — though for large payloads you typically enqueue a pointer instead.
Where queues move data, semaphores coordinate access and signal events. FreeRTOS offers several flavours:
A mutex must always be taken and given by the same task, whereas a binary semaphore can be given and taken by different contexts — this distinction trips up many newcomers. From an interrupt you must use the ...FromISR() variants, which never block and signal whether a context switch should follow.
Real projects and industry tool flows, with certification.
Explore CourseTron Courses →It is undefined behaviour and usually causes a crash, because the kernel expects task functions to run forever. A task that must terminate should call vTaskDelete(NULL) to delete itself cleanly, which frees its TCB and stack (when dynamically allocated) via the idle task.
Priority inversion occurs when a high-priority task is blocked waiting on a resource held by a low-priority task, which itself gets preempted by medium-priority tasks. FreeRTOS mutexes implement priority inheritance: the low-priority holder is temporarily raised to the waiting task's priority so it finishes and releases the lock sooner. Note that binary semaphores do not provide this.
Regular API calls can block the caller, which is illegal in interrupt context. The FromISR variants never block and instead return a pxHigherPriorityTaskWoken flag; you then call portYIELD_FROM_ISR() so the scheduler switches to the newly unblocked higher-priority task immediately on exit.
Estimate the deepest call chain plus local variables and interrupt frame overhead, then size generously and verify empirically. Use uxTaskGetStackHighWaterMark() during testing to see how close a task came to overflowing, and trim from there. Stack depth is specified in words, so multiply by the word size to reason in bytes.
Related: RTOS vs bare-metal firmware · the VLSI Glossary · all courses.