Testing RTOS Tasks and Interrupts in Embedded Firmware
RTOS-based firmware has a reputation for being untestable. Tasks run concurrently, interrupts fire asynchronously, queues link components together, and the whole system only makes sense when everything is running simultaneously on real hardware. But "untestable" mostly means "nobody has tried systematically." With the right techniques, you can test RTOS task logic, queue interactions, and interrupt-driven state machines on your host machine in milliseconds — no hardware required.
This post covers how to test FreeRTOS-based firmware using three strategies: thin task design with logic extraction, RTOS API mocking, and controlled scheduler simulation.
The Core Problem with RTOS Testing
RTOS APIs are a special case of hardware dependency. xQueueSend, xSemaphoreTake, vTaskDelay, xTaskCreate — these are FreeRTOS kernel calls. On a target MCU, they invoke the FreeRTOS scheduler. On your host machine, they either do not exist or require the full FreeRTOS port for your host OS (which exists but adds enormous complexity).
The solution is the same as with HAL mocking: create a seam between your logic and the RTOS API, then replace the RTOS calls in tests. You have two main options:
- Extract logic from tasks so that the task body is trivially thin and the testable logic has no RTOS dependencies
- Mock RTOS APIs so you can call task functions directly in tests while controlling what xQueue* and xSemaphore* calls return
In practice you need both.
Strategy 1: Thin Tasks with Extracted Logic
The anti-pattern looks like this:
/* BAD: untestable task — logic mixed with RTOS calls */
void TemperatureTask(void *params) {
TemperatureConfig_t *cfg = (TemperatureConfig_t *)params;
float temp_sum = 0.0f;
int sample_count = 0;
for (;;) {
float raw = ADC_Read(cfg->adc_channel);
float celsius = (raw * 3.3f / 4095.0f - 0.5f) / 0.01f;
temp_sum += celsius;
sample_count++;
if (sample_count >= cfg->avg_samples) {
float avg = temp_sum / sample_count;
TemperatureReading_t reading = { .celsius = avg, .timestamp = xTaskGetTickCount() };
xQueueSend(cfg->output_queue, &reading, 0);
temp_sum = 0.0f;
sample_count = 0;
}
vTaskDelay(pdMS_TO_TICKS(cfg->sample_period_ms));
}
}To test the averaging logic, you need FreeRTOS running, an ADC, a queue, and a running scheduler. That is a lot of overhead for testing temp_sum / sample_count.
The pattern that fixes this is extracting the logic into pure functions:
/* GOOD: extracted logic — no RTOS dependencies */
/* src/temperature_processing.h */
typedef struct {
float sum;
int count;
int target_samples;
} TempAccumulator_t;
void TempAcc_Init(TempAccumulator_t *acc, int target_samples);
float TempAcc_AddSample(TempAccumulator_t *acc, float raw_adc);
int TempAcc_IsReady(const TempAccumulator_t *acc);
void TempAcc_Reset(TempAccumulator_t *acc);
float ADC_RawToCelsius(float raw, float vref, uint16_t resolution);/* src/temperature_processing.c */
void TempAcc_Init(TempAccumulator_t *acc, int target_samples) {
acc->sum = 0.0f;
acc->count = 0;
acc->target_samples = target_samples;
}
float TempAcc_AddSample(TempAccumulator_t *acc, float raw_adc) {
acc->sum += raw_adc;
acc->count++;
return acc->sum / acc->count;
}
int TempAcc_IsReady(const TempAccumulator_t *acc) {
return acc->count >= acc->target_samples;
}
void TempAcc_Reset(TempAccumulator_t *acc) {
acc->sum = 0.0f;
acc->count = 0;
}
float ADC_RawToCelsius(float raw, float vref, uint16_t resolution) {
float voltage = raw * vref / (float)resolution;
return (voltage - 0.5f) / 0.01f; /* MCP9700A transfer function */
}The task body becomes thin — just RTOS glue:
/* src/temperature_task.c */
void TemperatureTask(void *params) {
TemperatureConfig_t *cfg = (TemperatureConfig_t *)params;
TempAccumulator_t acc;
TempAcc_Init(&acc, cfg->avg_samples);
for (;;) {
float raw = ADC_Read(cfg->adc_channel);
TempAcc_AddSample(&acc, raw);
if (TempAcc_IsReady(&acc)) {
TemperatureReading_t reading = {
.celsius = acc.sum / acc.count,
.timestamp = xTaskGetTickCount()
};
xQueueSend(cfg->output_queue, &reading, 0);
TempAcc_Reset(&acc);
}
vTaskDelay(pdMS_TO_TICKS(cfg->sample_period_ms));
}
}Now you test the logic without any RTOS involvement:
/* test/test_temperature_processing.c */
#include "unity.h"
#include "temperature_processing.h"
static TempAccumulator_t acc;
void setUp(void) {
TempAcc_Init(&acc, 4);
}
void tearDown(void) {}
void test_AccNotReadyBeforeTargetSamples(void) {
TempAcc_AddSample(&acc, 100.0f);
TempAcc_AddSample(&acc, 100.0f);
TEST_ASSERT_FALSE(TempAcc_IsReady(&acc));
}
void test_AccReadyAtTargetSamples(void) {
TempAcc_AddSample(&acc, 100.0f);
TempAcc_AddSample(&acc, 100.0f);
TempAcc_AddSample(&acc, 100.0f);
TempAcc_AddSample(&acc, 100.0f);
TEST_ASSERT_TRUE(TempAcc_IsReady(&acc));
}
void test_AccAveragesCorrectly(void) {
TempAcc_AddSample(&acc, 100.0f);
TempAcc_AddSample(&acc, 200.0f);
TempAcc_AddSample(&acc, 300.0f);
TempAcc_AddSample(&acc, 400.0f);
float avg = acc.sum / acc.count;
TEST_ASSERT_FLOAT_WITHIN(0.001f, 250.0f, avg);
}
void test_AccResetClearsState(void) {
TempAcc_AddSample(&acc, 999.0f);
TempAcc_Reset(&acc);
TEST_ASSERT_EQUAL(0, acc.count);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, acc.sum);
}
void test_ADCRawToCelsius_25Degrees(void) {
/* At 25C, MCP9700A outputs 0.75V. With 3.3V ref and 12-bit ADC: */
/* raw = 0.75 / 3.3 * 4095 = 930.7 */
float celsius = ADC_RawToCelsius(930.7f, 3.3f, 4095);
TEST_ASSERT_FLOAT_WITHIN(0.5f, 25.0f, celsius);
}Strategy 2: Mocking RTOS APIs with CMock
For testing the task glue itself — verifying that it posts to the right queue, waits on the right semaphore, delays by the right amount — you need to mock the RTOS APIs.
Create a thin wrapper header that all your code includes instead of FreeRTOS.h directly:
/* src/rtos_api.h */
#ifndef RTOS_API_H
#define RTOS_API_H
#ifdef UNIT_TEST
/* In test builds, these are mocked by CMock */
#include "mock_rtos_api.h"
#else
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#endif
/* Thin typedefs so mock_rtos_api.h can be generated from this header alone */
typedef void* QueueHandle_t;
typedef void* SemaphoreHandle_t;
typedef uint32_t TickType_t;
BaseType_t rtos_queue_send(QueueHandle_t queue, const void *item, TickType_t timeout);
BaseType_t rtos_queue_receive(QueueHandle_t queue, void *item, TickType_t timeout);
BaseType_t rtos_semaphore_take(SemaphoreHandle_t sem, TickType_t timeout);
BaseType_t rtos_semaphore_give(SemaphoreHandle_t sem);
void rtos_delay_ms(uint32_t ms);
TickType_t rtos_get_tick_count(void);
#endifIn production code, the wrapper maps to FreeRTOS calls:
/* src/rtos_api.c */
#ifndef UNIT_TEST
#include "rtos_api.h"
#include "FreeRTOS.h"
#include "queue.h"
#include "semphr.h"
BaseType_t rtos_queue_send(QueueHandle_t q, const void *item, TickType_t timeout) {
return xQueueSend(q, item, timeout);
}
/* ... etc */
#endifCMock generates mock_rtos_api.h from rtos_api.h. Now you test task coordination:
/* test/test_sensor_task_coordination.c */
#include "unity.h"
#include "mock_rtos_api.h"
#include "sensor_task.h"
void test_SensorTask_PostsToQueueWhenDataReady(void) {
QueueHandle_t fake_queue = (QueueHandle_t)0xDEADBEEF;
SensorTaskConfig_t cfg = { .output_queue = fake_queue };
/* Fake sensor returns one ready sample */
fake_sensor_inject_reading(23.5f);
/* Expect one queue post with any data, return success */
rtos_queue_send_IgnoreAndReturn(pdPASS);
rtos_delay_ms_Ignore();
SensorTask_RunOnce(&cfg); /* extracted for testability */
/* CMock verifies queue_send was called */
}
void test_SensorTask_DoesNotPostWhenNotEnoughSamples(void) {
QueueHandle_t fake_queue = (QueueHandle_t)0xDEADBEEF;
SensorTaskConfig_t cfg = { .output_queue = fake_queue, .avg_samples = 4 };
fake_sensor_inject_reading(23.5f); /* only 1 of 4 needed */
/* queue_send must NOT be called */
rtos_delay_ms_Ignore();
/* no Expect for rtos_queue_send — CMock will fail if it IS called */
SensorTask_RunOnce(&cfg);
}Testing Interrupt-Driven State Machines
Interrupt service routines should be thin: they read hardware, update state, and signal a task. The state machine logic that processes events lives in a normal function. That function is your test target.
/* src/button_sm.h */
typedef enum {
BTN_IDLE,
BTN_DEBOUNCE,
BTN_PRESSED,
BTN_HELD
} ButtonState_t;
typedef struct {
ButtonState_t state;
uint32_t state_enter_tick;
uint32_t debounce_ms;
uint32_t hold_ms;
void (*on_click)(void);
void (*on_hold)(void);
} ButtonSM_t;
/* Called from a timer task at regular intervals */
void ButtonSM_Update(ButtonSM_t *sm, uint8_t gpio_state, uint32_t tick_ms);/* src/button_sm.c */
void ButtonSM_Update(ButtonSM_t *sm, uint8_t gpio_state, uint32_t tick_ms) {
uint32_t elapsed = tick_ms - sm->state_enter_tick;
switch (sm->state) {
case BTN_IDLE:
if (gpio_state == 0) { /* active low */
sm->state = BTN_DEBOUNCE;
sm->state_enter_tick = tick_ms;
}
break;
case BTN_DEBOUNCE:
if (gpio_state != 0) {
sm->state = BTN_IDLE; /* bounced */
} else if (elapsed >= sm->debounce_ms) {
sm->state = BTN_PRESSED;
sm->state_enter_tick = tick_ms;
}
break;
case BTN_PRESSED:
if (gpio_state != 0) {
if (sm->on_click) sm->on_click();
sm->state = BTN_IDLE;
} else if (elapsed >= sm->hold_ms) {
sm->state = BTN_HELD;
if (sm->on_hold) sm->on_hold();
}
break;
case BTN_HELD:
if (gpio_state != 0) {
sm->state = BTN_IDLE;
}
break;
}
}Testing every state transition with controlled tick values:
/* test/test_button_sm.c */
#include "unity.h"
#include "button_sm.h"
static ButtonSM_t btn;
static int click_count = 0;
static int hold_count = 0;
static void on_click(void) { click_count++; }
static void on_hold(void) { hold_count++; }
void setUp(void) {
click_count = hold_count = 0;
btn = (ButtonSM_t){
.state = BTN_IDLE,
.debounce_ms = 20,
.hold_ms = 800,
.on_click = on_click,
.on_hold = on_hold,
};
}
void tearDown(void) {}
void test_ButtonSM_IdleToDebounceOnPress(void) {
ButtonSM_Update(&btn, 0, 0);
TEST_ASSERT_EQUAL(BTN_DEBOUNCE, btn.state);
}
void test_ButtonSM_BounceReturnToIdle(void) {
ButtonSM_Update(&btn, 0, 0); /* press -> debounce */
ButtonSM_Update(&btn, 1, 10); /* bounce before 20ms */
TEST_ASSERT_EQUAL(BTN_IDLE, btn.state);
}
void test_ButtonSM_ClickAfterDebounceAndRelease(void) {
ButtonSM_Update(&btn, 0, 0); /* idle -> debounce */
ButtonSM_Update(&btn, 0, 25); /* debounce -> pressed */
ButtonSM_Update(&btn, 1, 50); /* release -> fires click */
TEST_ASSERT_EQUAL(1, click_count);
TEST_ASSERT_EQUAL(BTN_IDLE, btn.state);
}
void test_ButtonSM_HoldFires(void) {
ButtonSM_Update(&btn, 0, 0); /* idle -> debounce */
ButtonSM_Update(&btn, 0, 25); /* debounce -> pressed */
ButtonSM_Update(&btn, 0, 830); /* hold threshold -> fires hold */
TEST_ASSERT_EQUAL(1, hold_count);
TEST_ASSERT_EQUAL(0, click_count); /* hold, not click */
TEST_ASSERT_EQUAL(BTN_HELD, btn.state);
}
void test_ButtonSM_NoClickOnHold(void) {
/* Even if released after hold, on_click must NOT fire */
ButtonSM_Update(&btn, 0, 0);
ButtonSM_Update(&btn, 0, 25);
ButtonSM_Update(&btn, 0, 830); /* triggers hold */
ButtonSM_Update(&btn, 1, 900); /* release from held */
TEST_ASSERT_EQUAL(0, click_count);
TEST_ASSERT_EQUAL(BTN_IDLE, btn.state);
}By controlling tick_ms, you test the 20 ms debounce and 800 ms hold threshold exactly — no waiting, no timing variance, no hardware.
Thread Safety Testing
Race conditions in RTOS code are among the hardest bugs to find in production. You can expose many of them at the design level by reviewing shared state access and checking for missing critical sections. For testing data structure behavior under concurrent-like access, you can simulate interleaving by calling state-mutating functions in sequences that represent problematic interleavings:
void test_RingBuffer_OverflowHandledCorrectly(void) {
RingBuffer_t buf;
RingBuf_Init(&buf, 4); /* capacity 4 */
/* Fill the buffer */
TEST_ASSERT_TRUE(RingBuf_Write(&buf, 0xAA));
TEST_ASSERT_TRUE(RingBuf_Write(&buf, 0xBB));
TEST_ASSERT_TRUE(RingBuf_Write(&buf, 0xCC));
TEST_ASSERT_TRUE(RingBuf_Write(&buf, 0xDD));
/* Write to full buffer — should fail gracefully */
TEST_ASSERT_FALSE(RingBuf_Write(&buf, 0xEE));
/* Existing data must be intact */
uint8_t out;
TEST_ASSERT_TRUE(RingBuf_Read(&buf, &out));
TEST_ASSERT_EQUAL_HEX8(0xAA, out);
}Summary
RTOS-based firmware is testable. The key insight is that the RTOS is glue — it provides scheduling, queues, and synchronization, but the business logic that decides what to send and when to send it belongs in plain C functions with no RTOS dependency. Extract that logic, test it with Unity. Mock the RTOS API calls to test task coordination. Test interrupt-driven state machines as pure functions with controlled tick inputs. You will not catch every race condition this way, but you will catch every logic error — and logic errors outnumber race conditions ten to one.