
In real-time embedded systems, efficient data transfer between tasks is critical for meeting deadlines and minimizing CPU overhead. Traditional approaches often involve copying data from one buffer to another, which consumes CPU cycles and introduces non-deterministic delays. This article explores how FreeRTOS memory pools enable zero-copy data transfer, eliminating memcpy overhead and improving system predictability.
We’ll cover:
When tasks communicate by copying data through intermediate buffers, each byte must be read and written, consuming CPU time proportional to the data size. For large data structures or high-frequency communication, this overhead can become significant. Moreover, memcpy execution time varies with data size, making timing analysis more complex and potentially causing deadline misses in hard real-time systems.
Consider a system where Task A produces 1KB sensor readings every millisecond (1,000 Hz) and Task B processes them:
In safety-critical systems following ISO 26262 or IEC 61508, such unpredictability can violate ASIL or SIL requirements. Even in non-safety applications, variable latency causes jitter that degrades control loop performance.
While FreeRTOS does not provide a native memory pool API in its core kernel (unlike some other RTOSes or CMSIS-RTOS wrappers), we can implement a highly efficient, deterministic memory pool using standard FreeRTOS queues. Unlike the general-purpose heap implementation in heap_4.c, a custom memory pool offers:
xQueueReceive and xQueueSend) execute in constant time for pointer-sized items, with no heap traversal or search involved.The memory pool consists of:
uint8_t pool_memory[POOL_SIZE * BLOCK_SIZE];QueueHandle_t memory_pool;When a block is allocated:
xQueueReceive to retrieve a pointer from the free list.When a block is freed:
xQueueSend to push the pointer back into the queue.This implementation ensures allocation and deallocation take constant time regardless of pool usage, avoiding the variable execution time of pvPortMalloc().
The zero-copy pattern uses FreeRTOS queues to pass pointers to memory pool blocks, ensuring data resides in a single memory location throughout its lifetime.
+--------------------------------------------------------------------+| FREERTOS ZERO-COPY MEMORY POOL FLOW |+--------------------------------------------------------------------+| || +--------------------------------------------------------------+ || | Static RAM: uint8_t pool_memory[POOL_SIZE * BLOCK_SIZE] | || | (Stationary storage: payload is never copied in memory) | || +--------------------------------------------------------------+ || | || initialize_memory_pool() slices & || fills queue with block pointers || v || +----------------------+ +----------------------+ || | memory_pool |--[1. Alloc]--->| vProducerTask | || | (Free Pointer Queue) | xQueueReceive | * Writes payload | || | Holds: void* [10] | | directly to *pBlock| || +----------------------+ +----------------------+ || ^ | || | | || [4. Return Block] [2. Handoff Pointer] || xQueueSend(pool, &p) xQueueSend(data, &p) || | | || | v || +----------------------+ +----------------------+ || | vConsumerTask |<--[3. Read]----| xDataQueue | || | * Reads payload | xQueueReceive | (Zero-Copy Data Q) | || | in-place (no copy) | | Holds: void* [10] | || +----------------------+ +----------------------+ || |+--------------------------------------------------------------------+
#define POOL_SIZE 10#define BLOCK_SIZE 1024 // Must match maximum message size// Statically allocate the memory for the pool blocks// Aligned to 32 bytes for cache line optimization and DMA compatibilityuint8_t pool_memory[POOL_SIZE * BLOCK_SIZE] __attribute__((aligned(32)));// Queue handle to hold pointers to free blocksQueueHandle_t memory_pool = NULL;// Queue handle to transfer active data pointers between tasksQueueHandle_t xDataQueue = NULL;BaseType_t initialize_memory_pool(void){// Create queues to manage pointer handles (sizeof(void*))memory_pool = xQueueCreate(POOL_SIZE, sizeof(void *));xDataQueue = xQueueCreate(POOL_SIZE, sizeof(void *));if ((memory_pool == NULL) || (xDataQueue == NULL)) {return pdFAIL;}// Fill the free list queue with pointers to our static blocksfor (int i = 0; i < POOL_SIZE; i++) {void *block_ptr = &pool_memory[i * BLOCK_SIZE];if (xQueueSend(memory_pool, &block_ptr, 0) != pdPASS) {return pdFAIL;}}return pdPASS;}
void vProducerTask(void *pvParameters){const TickType_t xBlockTime = pdMS_TO_TICKS(5);void *pBlock = NULL;uint8_t *pData = NULL;for (;;) {// Wait for event trigger or periodic hardware timeruint32_t ulNotification = ulTaskNotifyTake(pdTRUE, xBlockTime);if (ulNotification > 0) {// Allocate block from memory pool (get pointer from free list)if (xQueueReceive(memory_pool, &pBlock, portMAX_DELAY) == pdPASS) {// Write directly into the allocated block in-placepData = (uint8_t *)pBlock;fill_sensor_data(pData, BLOCK_SIZE);// Send pointer via data queue (zero-copy transfer!)if (xQueueSend(xDataQueue, &pBlock, 0) != pdPASS) {// Queue full: return block to pool to prevent buffer leaksxQueueSend(memory_pool, &pBlock, 0);}}}}}
The FreeRTOS queue stores only the pointer value (4 bytes on Cortex-M):
xQueueSend/xQueueReceive internally copy only the pointer (4 bytes) into/out of the queue’s storage — the 1 KB payload is never touchedsizeof(void*) regardless of actual data sizevoid vConsumerTask(void *pvParameters){void *pBlock;uint8_t *pData;for (;;) {// Wait for data with timeoutif (xQueueReceive(xDataQueue, &pBlock, pdMS_TO_TICKS(10)) == pdPASS) {// Use data directly - NO COPY NEEDEDpData = (uint8_t *)pBlock;process_sensor_data(pData, BLOCK_SIZE);// Return block to pool for reusexQueueSend(memory_pool, &pBlock, 0);}}}
Let’s quantify the benefits on a Cortex-M4 running at 168MHz:
The table below contrasts zero-copy pointer passing against naive C loops and CMSIS-optimized assembly memcpy (LDMIA/STMIA bursts) on an ARM Cortex-M4 @ 168MHz (1 cycle ≈ 5.95 ns):
| Payload Size | Zero-Copy (Pointer) | CMSIS memcpy (Optimized) | Naive Loop (*dst++ = *src++) | Savings vs CMSIS |
|---|---|---|---|---|
| 64 B | ~42 cycles (0.25 μs) | ~70 cycles (0.42 μs) | ~320 cycles (1.90 μs) | 40.0% |
| 256 B | ~42 cycles (0.25 μs) | ~210 cycles (1.25 μs) | ~1,280 cycles (7.62 μs) | 80.0% |
| 1 KB (1024 B) | ~42 cycles (0.25 μs) | ~806 cycles (4.80 μs) | ~5,120 cycles (30.48 μs) | 94.8% |
| 4 KB (4096 B) | ~42 cycles (0.25 μs) | ~3,225 cycles (19.20 μs) | ~20,480 cycles (121.90 μs) | 98.7% |
On STM32F407 (Cortex-M4 @ 168MHz) running FreeRTOS V10:
For a system handling 10K messages/second: | Approach | CPU Usage for Copying | Available for Application | |----------|----------------------|---------------------------| | Zero-copy | 0.2% | 99.8% | | 64B memcpy | 0.8% | 99.2% | | 1KB memcpy | 9.6% | 90.4% | | 4KB memcpy | 38.4% | 61.6% |
This difference can mean:
Choose block size based on:
Example calculation for variable-sized messages:
// For messages ranging from 32B to 1024B#define SMALL_BLOCK_SIZE 64 // For headers, small commands#define MEDIUM_BLOCK_SIZE 256 // For typical sensor packets#define LARGE_BLOCK_SIZE 1024 // For maximum payloads// Create three pools for different size rangesQueueHandle_t small_pool;QueueHandle_t medium_pool;QueueHandle_t large_pool;
Calculate based on:
Formula:
Pool_Size = (Max_Producer_Rate * Max_Consumer_Latency) + Safety_Margin
Establish unambiguous rules:
Always check return values:
// Producer sideif (xQueueReceive(memory_pool, &pBlock, 0) == pdPASS) {fill_data(pBlock);if (xQueueSend(xQueue, &pBlock, 0) != pdPASS) {// Handle queue full - return bufferxQueueSend(memory_pool, &pBlock, 0);// Optional: notify application of dropped sample}}// Consumer sideif (xQueueReceive(xQueue, &pBlock, pdMS_TO_TICKS(5)) == pdPASS) {process_data(pBlock);xQueueSend(memory_pool, &pBlock, 0);} else {// Handle timeout - optional recovery}
Add runtime checks:
// Validate pointer belongs to our poolbool is_valid_pool_pointer(void *ptr) {uintptr_t addr = (uintptr_t)ptr;uintptr_t pool_start = (uintptr_t)&pool_memory[0];uintptr_t pool_end = pool_start + (POOL_SIZE * BLOCK_SIZE);return (addr >= pool_start) && (addr < pool_end) &&((addr - pool_start) % BLOCK_SIZE == 0);}// Use in debug builds or via assertionsconfigASSERT(is_valid_pool_pointer(pBlock));
For maximum performance:
__attribute__((aligned(32))) for pools that interact with DMA or cached memoryCombine zero-copy task transfer with DMA:
TaskHandle_t xDMATaskHandle;// Task transfers memory pool buffer to DMA peripheralvoid vDMATransmitTask(void *pvParameters) {void *tx_block;for (;;) {if (xQueueReceive(xTxQueue, &tx_block, portMAX_DELAY) == pdPASS) {// Start DMA transfer directly from memory pool bufferHAL_UART_Transmit_DMA(&huart1, (uint8_t *)tx_block, BLOCK_SIZE);// Wait for DMA completion interrupt notificationulTaskNotifyTake(pdTRUE, portMAX_DELAY);// Return block to pool once DMA hardware completes transmissionxQueueSend(memory_pool, &tx_block, 0);}}}// DMA Transmission Complete Interrupt Callbackvoid HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {if (huart->Instance == USART1) {BaseType_t xHigherPriorityTaskWoken = pdFALSE;vTaskNotifyGiveFromISR(xDMATaskHandle, &xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}}
For data larger than single block size:
typedef struct {void *first_block;void *second_block; // Optional for >2x block sizeuint32_t total_length;} chained_message_t;// Sender chains blocks, receiver reassembles
Be aware of priority inversion risks when tasks of different priorities share a pool:
xQueueReceive(memory_pool, ...) waiting for a low-priority task to free a bufferxSemaphoreCreateMutex() do), so this blocking is not automatically resolvedxQueueSendToFront() for high-priority buffer returns to ensure they are recycled firstIMU + GPS + Barometer data at 1KHz:
I2S input → FFT → Effects → I2S output:
Ethernet/IP packet processing:
Encoder + current sensing → PID → PWM update:
| Feature | Message Buffers | Memory Pools + Queues |
|---|---|---|
| Zero-copy | No (data is copied into/out of internal stream) | Yes (only pointers are exchanged) |
| Variable size | Yes | No (fixed block size) |
| Multi-task safety | Single writer, single reader only | Multiple writers and readers (queue-based) |
| Overhead | Lower (single buffer operation) | Slightly higher (two queue operations) |
| Flexibility | High | Medium (need multiple pools for varying sizes) |
| Determinism | Variable (depends on message size) | Excellent (fixed-time pointer operations) |
| Aspect | Traditional Queue (copy) | Zero-Copy Queue (pointer) |
|---|---|---|
| Data safety | Good (own copy per task) | Requires careful lifetime mgmt |
| CPU usage | Scales with data size | Constant |
| Implementation | Simpler | More complex |
| Use case | Small, infrequent messages | Large, frequent transfers |
Problem: Using buffer after returning to pool Solution:
Problem: Running out of blocks under peak load Solution:
Problem: Misaligned pointers causing hard faults Solution:
uint32_t, 32 bytes for cache-line-aligned DMA buffers)uintptr_t for pointer arithmetic to avoid undefined behavioris_valid_pool_pointer() check shown above__attribute__((aligned(N))) on the pool array or place it in a dedicated linker section with alignment constraintsProblem: On high-performance cores with L1 Data Caches (such as ARM Cortex-M7 or Cortex-M55), the CPU and DMA access physical memory asynchronously. If Task A updates a buffer, the data may reside only in L1 D-Cache while DMA transmits stale data from SRAM. Conversely, when DMA writes into SRAM, the CPU may read stale cached lines. Solution:
__attribute__((aligned(32)))) and block sizes are multiples of 32 bytes to avoid false sharing across cache lines.SCB_CleanDCache_by_Addr((uint32_t *)tx_block, BLOCK_SIZE);
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_block, BLOCK_SIZE);
Add these diagnostics to your production code:
typedef struct {volatile uint32_t allocations;volatile uint32_t frees;volatile uint32_t allocation_failures;volatile uint32_t max_used;} pool_stats_t;static pool_stats_t pool_stats;// In allocation path (called within a single critical section or// from a single task context; use taskENTER_CRITICAL() if multi-task):if (success) {pool_stats.allocations++;uint32_t used = pool_stats.allocations - pool_stats.frees;if (used > pool_stats.max_used) pool_stats.max_used = used;} else {pool_stats.allocation_failures++;}// In free path:pool_stats.frees++;
Before deploying:
Zero-copy data transfer using FreeRTOS memory pools provides a deterministic, high-performance mechanism for inter-task communication in real-time systems. By eliminating memcpy overhead and ensuring fixed-time operations, developers can achieve better CPU utilization, meet tighter timing constraints, and build more responsive embedded applications.
The key to success lies in:
When implemented correctly, this technique can reduce CPU overhead by 90-99% for large data transfers, transforming resource-constrained systems from struggling to capable. The determinism benefits are equally valuable, simplifying timing analysis and improving system reliability.
💡 Tip: Start with a single pool size matching your most common message type, measure performance, then optimize for your specific use case. ⚠️ Warning: Never access a memory pool block after returning it to the pool without re-allocation—this leads to undefined behavior and difficult-to-debug issues.
Quick Links
Legal Stuff





