RTOS Task Testing and Timing Verification
Real-time operating systems introduce a class of bugs that are nearly invisible in manual testing: priority inversions, stack overflows, missed deadlines, and race conditions between tasks. Most teams discover these in the field, after a product has shipped. With the right testing approach, you can catch them in CI — without specialized hardware.
This guide covers unit testing FreeRTOS tasks on a host machine, verifying scheduling behavior, detecting stack overflows before they corrupt memory, and measuring timing in both simulation and on real hardware.
The Core Challenge with RTOS Testing
FreeRTOS applications are concurrent. A task might read a sensor, post to a queue, and wait on a semaphore — all interleaved with five other tasks and several ISRs. Testing this correctly means:
- Isolating task logic — testing the business logic inside a task function independently of the scheduler
- Testing synchronization primitives — verifying that queues, semaphores, and mutexes are used correctly
- Verifying timing constraints — confirming that tasks meet their deadlines under worst-case scheduling
None of these require a running RTOS. Most of the work can be done with mocked FreeRTOS primitives on a host machine.
Setting Up FreeRTOS Unit Tests on Host
FreeRTOS ships a POSIX simulator port that lets you run the full scheduler on Linux or macOS. For unit testing individual task functions, however, it is simpler to mock the RTOS API using CMock and test the task's logic directly.
Create a thin FreeRTOS API mock header:
// test/support/freertos_mock.h
#ifndef FREERTOS_MOCK_H
#define FREERTOS_MOCK_H
#include <stdint.h>
#include "FreeRTOS.h"
// Declare functions that CMock will generate mocks for
BaseType_t xQueueSend(QueueHandle_t xQueue, const void *pvItemToQueue,
TickType_t xTicksToWait);
BaseType_t xQueueReceive(QueueHandle_t xQueue, void *pvBuffer,
TickType_t xTicksToWait);
void xTaskNotifyGive(TaskHandle_t xTaskToNotify);
uint32_t ulTaskNotifyTake(BaseType_t xClearCountOnExit,
TickType_t xTicksToWait);
void vTaskDelay(TickType_t xTicksToDelay);
TickType_t xTaskGetTickCount(void);
#endifRun CMock against this header to generate mock_freertos_mock.h and mock_freertos_mock.c. Your task under test links against the mock instead of the real RTOS library.
Testing a Data Acquisition Task
Consider a task that reads from an ADC, filters the result, and posts to a queue for a display task:
// src/tasks/adc_task.c
#include "FreeRTOS.h"
#include "queue.h"
#include "hal_adc.h"
#include "filter.h"
#define ADC_SAMPLE_PERIOD_MS 10
typedef struct {
uint16_t raw;
float filtered;
uint32_t timestamp_ms;
} AdcSample;
void vAdcTask(void *pvParameters) {
QueueHandle_t output_queue = (QueueHandle_t)pvParameters;
AdcSample sample;
for (;;) {
sample.timestamp_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
sample.raw = HAL_ADC_ReadChannel(0);
sample.filtered = filter_apply(sample.raw);
if (xQueueSend(output_queue, &sample, 0) != pdPASS) {
// Queue full — drop sample, increment overflow counter
adc_task_stats.dropped_samples++;
}
vTaskDelay(pdMS_TO_TICKS(ADC_SAMPLE_PERIOD_MS));
}
}The task function itself is just a C function that calls RTOS APIs and HAL functions. Both can be mocked. The test structure:
// test/test_adc_task.c
#include "unity.h"
#include "mock_hal_adc.h"
#include "mock_freertos_mock.h"
#include "adc_task.h"
#include "filter.h"
// Fake queue handle — just needs to be a non-null pointer
static uint8_t fake_queue_storage;
static QueueHandle_t fake_queue = (QueueHandle_t)&fake_queue_storage;
// Captured sample from xQueueSend callback
static AdcSample captured_sample;
BaseType_t mock_queue_send_capture(QueueHandle_t q, const void *item,
TickType_t ticks, int call_count) {
memcpy(&captured_sample, item, sizeof(AdcSample));
return pdPASS;
}
void test_adc_task_posts_filtered_sample(void) {
// Expect tick count read
xTaskGetTickCount_ExpectAndReturn(1000);
// Expect ADC read returning 2048
HAL_ADC_ReadChannel_ExpectAndReturn(0, 2048);
// Expect queue send — capture the posted item
xQueueSend_StubWithCallback(mock_queue_send_capture);
// Expect task delay for sample period
vTaskDelay_Expect(pdMS_TO_TICKS(10));
// Run one iteration of the task loop body
adc_task_run_once(fake_queue);
// Verify the posted sample
TEST_ASSERT_EQUAL_UINT16(2048, captured_sample.raw);
TEST_ASSERT_FLOAT_WITHIN(0.1f, filter_apply(2048), captured_sample.filtered);
TEST_ASSERT_EQUAL_UINT32(1000 * portTICK_PERIOD_MS, captured_sample.timestamp_ms);
}
void test_adc_task_increments_drop_counter_on_full_queue(void) {
xTaskGetTickCount_ExpectAndReturn(2000);
HAL_ADC_ReadChannel_ExpectAndReturn(0, 512);
// Simulate full queue
xQueueSend_ExpectAndReturn(fake_queue, NULL, 0, errQUEUE_FULL);
vTaskDelay_Expect(pdMS_TO_TICKS(10));
uint32_t drops_before = adc_task_stats.dropped_samples;
adc_task_run_once(fake_queue);
TEST_ASSERT_EQUAL_UINT32(drops_before + 1, adc_task_stats.dropped_samples);
}The key pattern: extract the loop body into adc_task_run_once() so tests can call it once without spinning in an infinite loop. The for (;;) wrapper in vAdcTask simply calls adc_task_run_once() in a loop.
Testing Queue Behavior
Queues are the backbone of inter-task communication. Test that producers handle full queues correctly and that consumers handle empty queues:
void test_consumer_does_not_block_on_empty_queue(void) {
// Return pdFAIL to simulate empty queue with zero timeout
xQueueReceive_ExpectAndReturn(fake_queue, NULL, 0, pdFAIL);
// Task should return without processing
int result = display_task_poll(fake_queue);
TEST_ASSERT_EQUAL_INT(DISPLAY_NO_DATA, result);
}
void test_consumer_processes_valid_sample(void) {
AdcSample injected = { .raw = 1500, .filtered = 1498.7f, .timestamp_ms = 5000 };
xQueueReceive_StubWithCallback(inject_sample_callback(&injected));
int result = display_task_poll(fake_queue);
TEST_ASSERT_EQUAL_INT(DISPLAY_OK, result);
TEST_ASSERT_EQUAL_UINT16(1500, display_get_last_raw());
}Stack Overflow Detection
FreeRTOS has built-in stack overflow checking (configCHECK_FOR_STACK_OVERFLOW). Enable it in FreeRTOSConfig.h and implement the hook:
// In FreeRTOSConfig.h
#define configCHECK_FOR_STACK_OVERFLOW 2 // Most thorough check
// In your application
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
// Log the task name before the crash
error_log_write("STACK OVERFLOW: %s", pcTaskName);
// Trigger a watchdog reset or halt
NVIC_SystemReset();
}Mode 2 watermarks every stack byte with 0xA5 at task creation. On each context switch, FreeRTOS checks whether the last 16 bytes have been overwritten. To find minimum stack sizes without guessing, use the uxTaskGetStackHighWaterMark() API in a test build:
void report_stack_usage(void) {
TaskStatus_t task_array[16];
UBaseType_t task_count = uxTaskGetSystemState(task_array, 16, NULL);
for (UBaseType_t i = 0; i < task_count; i++) {
printf("Task: %-20s High watermark: %u words\n",
task_array[i].pcTaskName,
task_array[i].usStackHighWaterMark);
}
}Run this after executing the worst-case workload. A watermark under 20 words is a red flag — double the stack allocation for that task.
Timing Verification with FreeRTOS Trace
For production timing verification, integrate Percepio TraceX or Segger SystemView. Both use a ring buffer in RAM to capture every context switch, ISR entry/exit, queue operation, and semaphore event without affecting timing significantly.
For automated timing assertions, add a lightweight trace hook:
// In FreeRTOSConfig.h
#define traceTASK_SWITCHED_IN() trace_record_switch_in(pxCurrentTCB)
#define traceTASK_SWITCHED_OUT() trace_record_switch_out(pxCurrentTCB)
// In trace.c
typedef struct {
const char *task_name;
uint32_t start_tick;
uint32_t duration_ticks;
} TraceEvent;
static TraceEvent trace_buffer[1024];
static uint32_t trace_index = 0;
void trace_record_switch_in(void *tcb) {
// Record start time for this task
}
// After test execution, analyze the trace buffer:
void assert_task_deadline(const char *task_name, uint32_t max_period_ms) {
// Find all executions of task_name in trace buffer
// Assert that the gap between consecutive executions never exceeds max_period_ms
}Priority Inversion Testing
Priority inversion occurs when a high-priority task waits on a resource held by a low-priority task that is preempted by a medium-priority task. FreeRTOS mutexes implement priority inheritance to mitigate this, but the behavior still needs testing.
Write a test that creates three tasks with known priorities and verifies that the high-priority task completes within its deadline even when the mutex is initially held by the low-priority task:
void test_mutex_priority_inheritance_prevents_inversion(void) {
// This test runs on the FreeRTOS POSIX simulator
// Create mutex, three tasks with priorities 1/2/3
// Low-priority task acquires mutex, then high-priority task tries to acquire it
// Verify high-priority task completes within 2ms despite contention
uint32_t high_prio_completion_ms = run_priority_inversion_scenario();
TEST_ASSERT_LESS_THAN_UINT32(2, high_prio_completion_ms);
}Continuous Monitoring in Production
Task timing verification in CI catches regressions before they ship. Once firmware is running on deployed devices, you need runtime observability. HelpMeTest monitors the external behavior of devices — HTTP endpoints, MQTT topics, telemetry pipelines — and alerts when response times or data freshness degrade. Pair FreeRTOS runtime stats with HelpMeTest endpoint monitoring to get full-stack visibility: from scheduler jitter inside the chip to end-to-end latency visible to users.
RTOS bugs are subtle, but they are testable. The investment in a proper test harness for task logic, synchronization primitives, and timing constraints pays dividends every time a context switch bug is caught in CI rather than during a customer demo.