FreeRTOS Queues and Semaphores: Patterns for Sensor-to-Radio Pipelines

Most sensor-to-BLE firmware starts the same way. A sensor ISR writes to a global buffer. A flag gets set. Somewhere in the main loop, the BLE stack reads that buffer and fires off a notification. It ships. It works. Then the sample rate goes from 1 Hz to 100 Hz, or a second sensor gets bolted on, and the whole thing starts dropping data, corrupting reads, or mysteriously disconnecting under load.
Global buffers guarded by volatile flags are just semaphores and queues with none of the safety and all of the bugs. FreeRTOS hands you the right primitives for this problem. But the API docs give you a catalog, not a blueprint. What you actually need is an opinionated pipeline pattern that assigns each primitive its correct job, from sensor ISR all the way to BLE characteristic notification.
That’s what we’re building here: one reusable architecture, with C code, that you can adapt to your next BLE peripheral project on any Cortex-M SoC.
Queue vs. Semaphore vs. Mutex: A Quick Refresher
You already know what these are. The question is which one goes where, and why picking wrong creates bugs that only show up at scale.
Queues carry data between execution contexts. They copy data in and out, have configurable depth for buffering bursts, and have ISR-safe variants. This is how you move sensor readings.
Binary semaphores carry signals, not data. They say “something happened” and unblock a waiting task. No payload. Think of them as a one-bit flag that plays nicely with the scheduler.
Counting semaphores do the same thing, but track multiple outstanding events. Useful when events can stack up before the consumer runs.
Mutexes protect shared state. They support priority inheritance (so a high-priority task doesn’t starve waiting for a low-priority one holding the lock). You can never use them from an ISR.
Here’s the cheat sheet:
+---------------------+------------+----------+-----------+-------------+
| Primitive | Carries | ISR-safe | Buffering | Pri. Inher. |
+---------------------+------------+----------+-----------+-------------+
| Queue | Data | Yes (*) | Yes (N) | No |
| Binary Semaphore | Signal | Yes (*) | No (0/1) | No |
| Counting Semaphore | Signal (N) | Yes (*) | Yes (N) | No |
| Mutex | Ownership | No | No | Yes |
+---------------------+------------+----------+-----------+-------------+
(*) Must use xQueueSendFromISR / xSemaphoreGiveFromISR variantsThe key rule: if you’re stuffing data into a global and using a semaphore to signal it, you’ve reinvented a worse queue. You get all the race conditions of shared memory with none of the buffering or atomic copy semantics that queues provide for free.
The Pipeline Architecture
Here’s the full pattern, ISR to radio:
[Sensor HW]
|
v (ISR)
xQueueSendFromISR()
|
v
+-----------+ +------------------+ +----------------+
| SensorQ | ----> | Processing Task | ----> | BLE Notify Task|
| (Queue, | | - filter/pack | | - calls BLE |
| depth=N) | | - format payload | | stack API |
+-----------+ +------------------+ +----------------+
| |
xSemaphoreGive() Mutex protects
(binary sem signals shared config
"data ready for BLE") (e.g., conn handle,
notify enabled)Three stages. Three primitives, each with a distinct job.
The sensor ISR enqueues raw readings into SensorQ. The processing task blocks on that queue, wakes when data arrives, filters or packages it into a BLE-ready payload, then gives a binary semaphore to wake the BLE notification task. The BLE task takes the semaphore, grabs a mutex to safely read connection state (handle, whether notifications are enabled), and calls the BLE stack’s notify API.
Why two tasks instead of one? BLE stacks are picky about context. On many platforms, BLE APIs must run in a specific task or at a specific priority. The radio has its own scheduler with connection events happening at intervals from 7.5 ms to 4 seconds. If your processing logic blocks or runs long, the BLE task misses its window. Decoupling processing from radio timing keeps the radio happy.
Queue depth matters. Your queue needs to absorb bursts that accumulate while the BLE stack is busy with a connection event. If your sensor produces 100 samples/second and BLE connection events happen every 50 ms, that’s 5 samples per interval (100 × 0.05). You need at least 5 slots, probably 8 to 10 with margin.
Implementation Walkthrough
These aren’t pseudocode; they’re realistic C snippets you can drop into a project and adapt.
ISR to Queue
void sensor_isr_handler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
sensor_sample_t sample;
sample.timestamp = get_tick_count();
sample.value = read_sensor_register();
/* Never call xQueueSend() from ISR — hard fault or undefined behavior */
if (xQueueSendFromISR(sensorQ, &sample, &xHigherPriorityTaskWoken) != pdPASS) {
/* Queue full. Decide your policy: drop this sample, or overwrite oldest. */
dropped_count++;
}
/* If we woke a higher-priority task, yield immediately on ISR exit */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}The pxHigherPriorityTaskWoken / portYIELD_FROM_ISR pair is critical. Without it, the processing task won’t run until the next tick, adding up to 1 ms of latency per sample (at default tick rate). With it, the scheduler switches to the processing task as soon as the ISR returns.
Processing Task
void processing_task(void *pvParams)
{
sensor_sample_t raw;
ble_payload_t payload;
for (;;) {
/* Block forever until the ISR enqueues a sample */
if (xQueueReceive(sensorQ, &raw, portMAX_DELAY) == pdPASS) {
/* Filter, average, pack into BLE-ready format */
payload.avg = apply_moving_average(raw.value);
payload.ts = raw.timestamp;
/* Store payload where BLE task can read it */
set_pending_payload(&payload);
/* Signal: "new data is ready for BLE" */
xSemaphoreGive(bleDataReadySem);
}
}
}portMAX_DELAY means this task draws zero CPU when the queue is empty. The MCU can sleep between samples, which is exactly what you want for battery-powered BLE devices.
BLE Notification Task
void ble_notify_task(void *pvParams)
{
ble_payload_t payload;
for (;;) {
/* Block until processing task signals data is ready */
if (xSemaphoreTake(bleDataReadySem, portMAX_DELAY) == pdTRUE) {
/* Mutex protects conn_handle and notify_enabled,
which get written from BLE event callbacks */
if (xSemaphoreTake(bleStateMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (ble_state.notify_enabled && ble_state.conn_handle != BLE_CONN_INVALID) {
get_pending_payload(&payload);
ble_gatts_hvx(ble_state.conn_handle, &payload, sizeof(payload));
}
xSemaphoreGive(bleStateMutex);
}
}
}
}The mutex here protects slow-changing configuration state: the connection handle and the notification-enabled flag. These get written from BLE event callbacks (connect/disconnect, CCCD writes) and read here. The mutex is not in the hot data path; it guards config that changes maybe once per connection.
Mutex for Shared BLE State
void ble_event_handler(ble_event_t *evt)
{
switch (evt->type) {
case BLE_EVT_CONNECTED:
/* Safe to take mutex here — this runs in task context, not ISR */
xSemaphoreTake(bleStateMutex, portMAX_DELAY);
ble_state.conn_handle = evt->conn_handle;
xSemaphoreGive(bleStateMutex);
break;
case BLE_EVT_CCCD_WRITE:
xSemaphoreTake(bleStateMutex, portMAX_DELAY);
ble_state.notify_enabled = evt->cccd_value & 0x01;
xSemaphoreGive(bleStateMutex);
break;
case BLE_EVT_DISCONNECTED:
xSemaphoreTake(bleStateMutex, portMAX_DELAY);
ble_state.conn_handle = BLE_CONN_INVALID;
ble_state.notify_enabled = false;
xSemaphoreGive(bleStateMutex);
break;
}
}Confirm that your BLE stack fires these callbacks in task context (most do). If they fire from ISR context, you can’t use a mutex. You’d need to defer the state update through a queue to a task.
Common Pitfalls and How to Avoid Them
1. Using a semaphore where you need a queue. Works fine at 1 Hz. At 100 Hz, the producer gives the semaphore 10 times before the consumer runs, but the binary semaphore only stores one signal. Nine samples vanish. If you’re transferring data, use a queue.
2. Ignoring errQUEUE_FULL. xQueueSendFromISR can fail silently if you don’t check the return value. Decide your policy up front: drop the newest sample, overwrite the oldest (use xQueueOverwrite for depth-1 queues), or increment a drop counter for diagnostics.
3. Calling a mutex from ISR context. Mutexes depend on task ownership and priority inheritance, which don’t exist in interrupt context. The result is a hard fault or deadlock. If you need to protect shared state from an ISR, use a critical section or defer to a task via queue.
4. Priority inversion between processing and BLE tasks. If your processing task runs at higher priority than the BLE task, semaphore signals pile up while the BLE task starves. The radio misses connection events and the central disconnects. Give the BLE notify task higher priority than the processing task. The processing task does the heavy compute; the BLE task does a quick, time-sensitive send.
5. Blocking inside BLE stack callbacks. Some stacks (Nordic SoftDevice, for example) run callbacks in a context where you must not block. Calling xQueueSend with a non-zero timeout from that context can freeze the stack. Use FromISR variants or zero timeouts in any callback whose execution context you don’t fully control.
Adapting the Pattern to Your Platform
Nordic nRF5 SDK / nRF Connect SDK: The SoftDevice has its own event scheduler. BLE callbacks come through sd_ APIs and must be handled promptly. On Zephyr (nRF Connect SDK), replace FreeRTOS queues with k_msgq and semaphores with k_sem, but the pattern maps 1:1. The Nordic SoftDevice reference application for Hubble shows a real-world implementation of BLE task coordination.
ESP-IDF (ESP32): Both Bluedroid and NimBLE run in dedicated FreeRTOS tasks internally. Your BLE notify task posts to their API, which handles the actual radio scheduling. ESP-IDF’s queues are FreeRTOS queues. Watch out for ESP32’s dual-core SMP; pin your BLE task to the same core as the BLE stack (typically core 0).
STM32WB: The BLE stack runs on a separate M0+ core. The Inter-Processor Communication Controller (IPCC) mailbox acts as the “queue” between cores, and you don’t control its internals. But the M4 application side still benefits from this pattern: your processing task formats payloads, then hands them to an IPCC shim task that bridges to the M0+.
For TI-based designs using FreeRTOS, the TI CC2340 FreeRTOS reference application provides another concrete starting point.
Instrument the Pipeline From Day One
Don’t just build it and trust it. Call uxQueueMessagesWaiting() periodically and log the high-water mark. Track your dropped_count from the ISR. Monitor how often the BLE task finds notifications disabled when it wakes (that’s wasted work you can optimize with a flag check before xSemaphoreGive).
When something goes wrong at 3 AM in a field test, these three counters give you the data to find it.
Hubble Network enables direct satellite connectivity from low-power embedded devices — no gateways, no infrastructure buildout. See how it works →