ISR-to-Task Communication Made Simple: Queues vs Semaphores
In every real-time embedded system, interrupts are where the action begins. Whether it's a camera sensor signaling a frame ready, a CAN packet arriving, or a GPIO line pulsing high, the first code to run is almost always the Interrupt Service Routine. But ISRs should be fast, lightweight, and non-blocking, they exist to acknowledge the event, not process it. That means most real work has to be handed off to background tasks.
Here's where many firmware engineers hit a design fork: should you use a flag, a semaphore, or a queue to communicate between the ISR and the task? Choosing the wrong mechanism can lead to lost events, race conditions, or jitter spikes.
What is ISR-task communication?
An ISR runs in response to asynchronous hardware events, timers, sensors, DMA transfers, GPIO triggers. Its job is to capture or acknowledge the event, notify the system that something needs handling, and exit quickly so other interrupts aren't blocked. To hand off work, ISRs typically use one of three tools, and picking the right one depends on how often events occur and whether data has to cross the ISR boundary.

Using flags for a high-frequency signal risks missing interrupts. Using semaphores for data risks losing payload context. Using queues incorrectly risks overflowing memory or increasing latency. The right pattern isn't about API familiarity, it's about architectural fit.
Flags: when "something happened" is enough
Best for rare or low-impact events, using volatile variables or event bits. An ISR sets a volatile flag, and the main loop or background task periodically checks and clears it:
volatile bool sensor_triggered = false;
void EXTI_IRQHandler(void) {
sensor_triggered = true;
}
void loop(void) {
if (sensor_triggered) {
sensor_triggered = false;
process_sensor();
}
}It's fast and simple with no RTOS dependency, great for low-frequency or diagnostic events. But polling wastes CPU cycles, misses rapid back-to-back interrupts, and gets hard to scale with multiple ISR sources. Use flags only when lost events are acceptable, for anything periodic or bursty, move to semaphores or queues.
Semaphores: when you need to wake a task
Best for event-driven task activation with no data transfer, using xSemaphoreGiveFromISR() and xSemaphoreTake(). The ISR gives a semaphore to signal the task; the task, blocked on xSemaphoreTake(), wakes up immediately:
void TimerISR(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(periodicSem, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void vPeriodicTask(void *arg) {
for (;;) {
if (xSemaphoreTake(periodicSem, portMAX_DELAY)) {
run_periodic_task();
}
}
}This is perfect for time-sensitive triggers or edge counters, the task wakes immediately with no polling, and it supports counting behavior for rapid interrupts. It's not suitable for transferring data, though, and signals can be lost if no task is waiting. Rule of thumb: use semaphores when you care that something happened, not what happened.
Queues: when data must cross contexts
Best for passing event data or messages from ISR to task, using xQueueSendFromISR() and xQueueReceive(). The ISR pushes event structures or message pointers into a thread-safe queue, and the task dequeues and processes them sequentially:
typedef struct {
uint8_t sensor_id;
uint16_t value;
} SensorEvent;
void ADC_IRQHandler(void) {
SensorEvent evt = { .sensor_id = 2, .value = ADC1->DR };
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendFromISR(sensorQueue, &evt, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void vSensorTask(void *arg) {
SensorEvent evt;
for (;;) {
if (xQueueReceive(sensorQueue, &evt, portMAX_DELAY)) {
process_sensor_event(&evt);
}
}
}Queues give lossless, ordered data transfer, handle bursts gracefully if sized properly, and work across multiple ISR sources. They need careful queue size tuning and carry slightly higher overhead than semaphores. Rule of thumb: use queues when you must transfer data or preserve event order.

Practical design patterns
Interrupt-driven data capture: a sensor signals data ready via an interrupt, the ISR pushes event info into a queue, a task dequeues and handles bulk transfer, and shared bus access gets protected by a mutex. Timer-based task activation: a hardware timer triggers work every 10ms, the ISR gives a binary semaphore, the task wakes, executes, and waits again, ideal for time-deterministic periodic loops. Multi-source event dispatching: multiple peripherals trigger different actions, each ISR pushes a context object into a shared queue, and a dispatcher task routes commands to target modules.
Implementation and debug tips
Use ISR-safe APIs only (the FromISR variants). Never block inside an ISR, signal and exit fast. Size your queue for at least event_rate x worst_case_processing_delay. Stress-test under burst load to catch overflow or jitter early. Use diagnostics like uxQueueMessagesWaiting() to tune behavior, and log semaphore counts or queue drops for visibility.
Common pitfalls: missed events usually trace to polling or flag misuse, fixed by counting semaphores or queues. Data corruption usually comes from shared buffer access inside the ISR, fixed by moving data handling to task context and protecting with a mutex. Task starvation often comes from a task priority set too low, fixed by adjusting priorities and preemption settings. And overflowed queues come from under-sizing or unbounded bursts, fixed by tuning queue depth and adding diagnostic monitoring.
Why it matters at Hoomanely
We build embedded systems connecting sensing, computation, and control at the edge. Reliable ISR-task communication is the foundation of that performance, ensuring data integrity, responsiveness, and long-term stability across products. The patterns here reflect the same engineering discipline used throughout our firmware architecture, where predictable ISR handoff enables scalable, resilient, and maintainable real-time systems.
Key takeaways
Flags are simple and fast but lossy, use only for low-impact signals. Semaphores are ideal for signaling events, not for carrying data. Queues are the safest and most flexible choice for structured, ordered event transfer. Always use ISR-safe APIs and keep ISRs minimal. Tune queue depth and task priorities under real workloads. And protect shared resources only in task context, never inside ISRs. Get this right once and you'll eliminate half your future debugging sessions.