FreeRTOS Co-routines – Lightweight Multitasking on Tiny MCUs
1. Introduction
When you write applications with FreeRTOS, the first tool you reach for is usually a task. Tasks make it easy to split the system into independent pieces that the scheduler can run in parallel. Each task gets its own stack, its own priority, and the kernel takes care of switching between them.
But sometimes a full task is overkill. Imagine you’re running on a tiny microcontroller with just a few kilobytes of RAM, and you need to blink an LED, poll a button, or handle a handful of simple background jobs. Creating a separate task for each one burns memory on stacks you don’t really need.
This is where co-routines come in. A co-routine looks like a task, but it’s much lighter. All co-routines share a single stack, and they only switch when they explicitly yield. You still get the clean, sequential style of writing code instead of juggling state machines, but with a fraction of the memory cost.
They are not as popular today as they once were, since most modern designs use tasks and software timers, but co-routines are still a neat feature of FreeRTOS that every embedded developer should know about.
2. What Are Co-routines in FreeRTOS?
Co-routines in FreeRTOS are a special type of lightweight thread of execution. They let you write code that looks like a task but does not need its own stack. Instead, all co-routines share a single stack, which makes them extremely memory-efficient and attractive when you are working with tiny microcontrollers.
The key difference from tasks is that co-routines are cooperative, not preemptive. That means the scheduler never interrupts them on its own. A co-routine runs until it explicitly yields back to the kernel. If you forget to yield, you block the others. Because of this cooperative model, co-routines always run at the lowest priority, so any real task in the system will preempt them.

Fig. 1 – FreeRTOS co-routine scheduling
The life of a co-routine is simple, and it follows three states as shown in Fig. 2. When a co-routine is waiting to be scheduled, it sits in the Ready state. Once the scheduler runs it, it moves into the Running state. If the co-routine calls a blocking API macro, it yields control back to the kernel and enters the Blocked state. It stays blocked until the event it was waiting for occurs, at which point the scheduler moves it back to Ready.

Fig. 2 – FreeRTOS co-routine state machine
To make co-routines practical, FreeRTOS provides a set of macros that manage their flow. You wrap a co-routine with crSTART() and crEND(), and in between you can use macros like crDELAY() to pause execution or crQUEUE_SEND() and crQUEUE_RECEIVE() to interact with queues. These macros make it possible to write code that looks sequential, but under the hood it resumes exactly where it left off the last time the scheduler ran it. This avoids the complexity of manually writing state machines and keeps the code readable while still being very light on memory.
3. Using Co-routines in Practice
To enable co-routines, set configUSE_CO_ROUTINES in your FreeRTOSConfig.h and call vCoRoutineSchedule() regularly, usually from the idle hook. Each co-routine is defined with crSTART() and crEND(), and it yields with macros like crDELAY() or crQUEUE_RECEIVE(). This lets your code look sequential while the kernel keeps track of where to resume.
static void vBlinkCoRoutine(CoRoutineHandle_t xHandle, UBaseType_t uxIndex) {
crSTART(xHandle);
for (;;) {
toggleLED();
crDELAY(xHandle, pdMS_TO_TICKS(500));
}
crEND();
}
Now that we’ve seen what co-routines are conceptually, let’s look at how you actually use them in practice.
4. Co-routines Example Application: System Resource Usage Tracking
Imagine a compact industrial controller that has to operate for months in the field with no direct supervision. Its main tasks handle real-time jobs like motor control and network communication, but the system integrator also wants a way to track long-term health: CPU load, heap availability, and queue depths. This information should survive resets and be available later for diagnostics.
On a small MCU, dedicating full tasks for such monitoring would waste scarce RAM, since each task needs its own stack. Instead, the design uses co-routines. A co-routine runs at the lowest priority and only consumes CPU cycles when no other task is ready. Every second, it collects system statistics and writes them into flash memory, building a timeline of system health over hours or days.
static void vResourceMonitorCR(CoRoutineHandle_t xHandle, UBaseType_t uxIndex)
{
crSTART(xHandle);
for (;;)
{
SystemStats stats;
stats.cpuLoad = sampleCpuLoad();
stats.heapFree = xPortGetFreeHeapSize();
stats.queueFill = uxQueueMessagesWaiting(xCriticalQueue);
/* persistent storage for later diagnostics */
storeStatsToFlash(&stats);
crDELAY(xHandle, pdMS_TO_TICKS(1000));
}
crEND();
}
This co-routine effectively turns into a black box recorder. If the controller resets in the field, engineers can read back the flash log to see if resources were trending toward failure. For example, a steady drop in free heap might point to a memory leak, while a consistently full queue could indicate a bottleneck. By offloading this job to co-routines, the controller gains valuable observability without burning RAM or interfering with time-critical tasks.
5. Conclusion
Co-routines may feel like a niche feature, but in the right context they shine. They let you add background logic, monitoring, or diagnostics without burning precious RAM on full task stacks. While they’ll never replace tasks for real-time control, co-routines are a lightweight tool for keeping your system observable and efficient on resource-constrained devices.