Quick Cap
In an RTOS, the ISR should do the absolute minimum -- acknowledge the hardware and signal a task to do the real work. This "deferred processing" pattern (sometimes called "bottom half" processing, borrowed from Linux kernel terminology) keeps interrupt latency low while the RTOS scheduler manages the actual processing as a regular task. Interviewers test whether you understand why deferral matters, which signaling mechanism to choose, and the critical FromISR API rules that prevent hard faults.
Key Facts:
- ISRs must be short: they block all lower-priority interrupts and delay task scheduling for the entire duration
FromISRvariants are mandatory:xSemaphoreGiveFromISR,xQueueSendFromISR,xTaskNotifyFromISR-- regular APIs must never be called from ISR context because they can blockpxHigherPriorityTaskWokenpattern: everyFromISRcall returns whether a higher-priority task was unblocked;portYIELD_FROM_ISR()must be called at the end to trigger an immediate context switch if needed- Task notifications are the fastest: lighter than semaphores in FreeRTOS (no kernel object, built into the TCB), but limited to a single consumer
- Double-buffering with DMA + ISR signal: the gold standard for high-throughput data acquisition -- zero-copy, maximum throughput
configASSERT(): catchesFromISRmisuse during development -- always enable it in debug builds
Deep Dive
At a Glance
| Aspect | Detail |
|---|---|
| Core idea | ISR signals, task processes |
| Also called | Deferred processing, bottom-half, two-part interrupt handling |
| ISR role | Acknowledge hardware, capture minimal data, signal task |
| Task role | Block on signal, perform actual processing with full RTOS API access |
| Key FreeRTOS APIs | xSemaphoreGiveFromISR(), xQueueSendFromISR(), xTaskNotifyFromISR() |
| Critical rule | Never call blocking RTOS APIs from ISR context |
| Performance pattern | DMA double-buffer + ISR notification = zero-copy throughput |
Why Defer Processing?
An ISR executes at interrupt priority, which is above every RTOS task. While the ISR runs, no task can execute -- not even the highest-priority one. Lower-priority interrupts are also blocked (assuming the default nesting behavior). The longer the ISR runs, the worse the system's overall latency becomes.
Deferring work to a task solves this in two ways. First, the ISR finishes quickly and re-enables lower-priority interrupts. Second, the RTOS scheduler decides when the processing task runs relative to other tasks, enabling proper prioritization. A high-priority processing task will run immediately after the ISR if portYIELD_FROM_ISR() is used; a low-priority one will yield to more urgent work first.
This is the same principle behind the Linux kernel's "top half" and "bottom half" interrupt architecture, adapted for the RTOS world.
The Deferred Processing Pattern
The fundamental flow is always the same: the ISR fires, does minimal work, signals a task, and exits. The task blocks until signaled, then does the real processing.
Time ──────────────────────────────────────────────────────►ISR: ██ ██Task: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░^ ^ ^ ^│ │ │ │IRQ fires IRQ fires again│ │ ││ ISR gives semaphore, ISR gives semaphore,│ task unblocks and task unblocks again│ processes data│Hardware event(timer, UART RX, DMA complete, etc.)
The ISR (solid blocks) is tiny -- just a few microseconds. The task (shaded blocks) does the heavy lifting at task priority, interruptible by higher-priority tasks and interrupts.
FromISR API Rules
Inside an ISR, you are in a special execution context. The RTOS scheduler is not running. You cannot call any API that might block (wait on a semaphore, receive from a queue with a timeout, delay). Doing so would attempt to invoke the scheduler from interrupt context, which corrupts the kernel state.
FreeRTOS enforces this by providing separate FromISR variants for every API that an ISR might need:
| Regular API (task context) | FromISR variant (ISR context) | Why different |
|---|---|---|
xSemaphoreGive() | xSemaphoreGiveFromISR() | Cannot evaluate scheduler state in ISR |
xSemaphoreTake() | No ISR variant | ISR must never block -- waiting is not allowed |
xQueueSend() | xQueueSendFromISR() | Cannot block if queue is full |
xQueueReceive() | xQueueReceiveFromISR() | Cannot block if queue is empty |
xTaskNotifyGive() | vTaskNotifyGiveFromISR() | Must use ISR-safe scheduler interaction |
xEventGroupSetBits() | xEventGroupSetBitsFromISR() | Defers bit-setting to the timer daemon task |
Every FromISR call takes an extra parameter: a pointer to BaseType_t xHigherPriorityTaskWoken. The API sets this to pdTRUE if the operation unblocked a task with higher priority than the currently interrupted task. At the end of the ISR, you pass this flag to portYIELD_FROM_ISR(), which requests a context switch if needed:
void UART_IRQHandler(void) {BaseType_t xHigherPriorityTaskWoken = pdFALSE;uint8_t byte = UART->DR;xQueueSendFromISR(rx_queue, &byte, &xHigherPriorityTaskWoken);/* If a higher-priority task was waiting on this queue,switch to it immediately when the ISR exits */portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}
Without portYIELD_FROM_ISR(), the unblocked task would not run until the next tick interrupt -- potentially milliseconds of unnecessary delay.
Calling xSemaphoreGive() (instead of xSemaphoreGiveFromISR()) from an ISR can cause a hard fault. The regular API may invoke taskYIELD() internally, which attempts a context switch while the scheduler context is invalid. This crash is timing-dependent and may only appear under high load, making it extremely difficult to reproduce on the bench.
Signaling Patterns Comparison
Choosing the right signaling mechanism depends on what information the ISR needs to convey to the task:
| Mechanism | What it conveys | RAM cost | Speed | Limitations |
|---|---|---|---|---|
| Binary semaphore | "An event happened" (boolean) | ~80 bytes (kernel object) | Fast | Multiple rapid signals can be lost (latches, does not count) |
| Counting semaphore | "N events happened" (count) | ~80 bytes (kernel object) | Fast | No data payload; just a count |
| Task notification (as binary sem) | "An event happened" | 0 extra (built into TCB) | Fastest (~45% faster than semaphore) | Single consumer only; single producer recommended |
| Task notification with value | 32-bit value | 0 extra (built into TCB) | Fastest | Single consumer; value can be overwritten if task is slow |
| Queue | Structured data (any size) | Item size x queue length | Moderate | Copies data; higher overhead per item |
| Stream/message buffer | Variable-length byte stream | Buffer size | Moderate | Single reader, single writer only |
Rule of thumb: Use task notifications when you have a dedicated processing task. Use a queue when the ISR needs to pass data. Use a binary semaphore when multiple producers must signal the same event. Use a counting semaphore when the task must not miss any event count.
Task Notifications (FreeRTOS)
Task notifications deserve special attention because they are the most efficient signaling mechanism in FreeRTOS. Every task control block (TCB) contains a built-in 32-bit notification value and a notification state -- no separate kernel object (semaphore, queue) is needed.
xTaskNotifyFromISR() is approximately 45% faster than xSemaphoreGiveFromISR() according to FreeRTOS benchmarks, and it uses zero additional RAM. The notification value can be used as a lightweight binary semaphore (vTaskNotifyGiveFromISR), as a counting semaphore (increment on give, decrement on take), or to pass a 32-bit data value directly (sensor reading, status register, bit mask).
The tradeoff is architectural: only one task can wait on a given notification (it is a property of the task, not a shared object), and while multiple ISRs can notify the same task, using multiple producers with eSetValueWithOverwrite risks losing values. For broadcast scenarios (one event, multiple listeners) or multi-producer patterns, semaphores or event groups remain necessary.
Double-Buffering with DMA
For high-throughput data acquisition (ADC sampling, audio capture, high-speed serial), the gold standard is DMA double-buffering combined with ISR signaling:
Buffer A Buffer B┌──────────┐ ┌──────────┐│ DMA fills │ │Task reads││ (active) │ │(process) │└─────┬─────┘ └─────┬────┘│ │▼ ▼DMA-complete ISR: Task finishes,swap pointers, waits for nextsignal task notification│ │▼ ▼┌──────────┐ ┌──────────┐│Task reads│ │ DMA fills ││(process) │ │ (active) │└──────────┘ └──────────┘◄── cycle repeats (ping-pong) ──►
The DMA peripheral fills buffer A while the processing task works on buffer B. When the DMA transfer completes, the DMA-complete ISR swaps the active and processing buffer pointers, then signals the task. The task wakes up and processes the newly filled buffer while DMA begins filling the other one.
This pattern achieves zero data copying (DMA writes directly to the final buffer), maximum throughput (no gap between acquisitions), and minimal CPU involvement (the ISR is just a pointer swap and a notification). Many modern MCUs (STM32, NXP, etc.) have hardware double-buffer support in their DMA controllers, making the pointer swap automatic.
Debugging Story: The Intermittent Hard Fault
A team was developing a motor controller with FreeRTOS on an STM32F4. The system ran perfectly during bench testing at moderate speeds but crashed with a hard fault under high load -- specifically when the motor was driven near its maximum RPM, causing the encoder ISR to fire at very high frequency.
The root cause: the encoder ISR called xSemaphoreGive() instead of xSemaphoreGiveFromISR(). The non-ISR-safe API internally evaluated the scheduler state and called taskYIELD(). Most of the time, this happened to work because the timing was loose enough that the ISR did not preempt a context switch in progress. But at high RPM, the ISR fired frequently enough to occasionally preempt the scheduler mid-context-switch. The taskYIELD() call then corrupted the saved register state, and the next context switch restored garbage into the program counter -- hard fault.
The fix was a one-character change (adding FromISR to the API call and adding the pxHigherPriorityTaskWoken parameter). But the lesson runs deeper: enable configASSERT() during development. FreeRTOS can detect when a non-ISR-safe API is called from interrupt context and trap immediately with a clear error, instead of letting it silently corrupt state until the timing is just wrong enough to crash.
What Interviewers Want to Hear
Demonstrate that you understand the why behind deferred processing (interrupt latency, scheduler integrity), the rules (FromISR variants, no blocking, yield-from-ISR pattern), and the tradeoffs between signaling mechanisms. Mention double-buffering with DMA as the high-performance pattern. Reference a real debugging experience involving ISR-context API misuse. Show that you think about system-level behavior, not just individual API calls.
Interview Focus
Classic Interview Questions
Q1: "Why should ISR processing be deferred to a task?"
Model Answer Starter: "An ISR runs at interrupt priority, above all RTOS tasks. While it executes, no task scheduling occurs and lower-priority interrupts are blocked. A long ISR directly increases worst-case interrupt latency for the entire system. By deferring the real work to a task, the ISR finishes in microseconds -- just acknowledge hardware, grab minimal data, signal the task. The scheduler then runs the processing task at its assigned priority, allowing proper interleaving with other tasks. This is the same principle as the Linux kernel's top-half/bottom-half split, adapted for RTOS."
Q2: "What is the difference between xSemaphoreGive and xSemaphoreGiveFromISR?"
Model Answer Starter: "xSemaphoreGive is for task context -- it may internally call taskYIELD to switch to a higher-priority task that was waiting on the semaphore. xSemaphoreGiveFromISR is for interrupt context -- it cannot invoke the scheduler directly because the scheduler state is not valid inside an ISR. Instead, it sets a flag (pxHigherPriorityTaskWoken) that tells the ISR to call portYIELD_FROM_ISR() before returning, which requests a context switch at the safe point when the ISR exits. Calling the regular version from an ISR can corrupt scheduler state and cause a hard fault, especially under load."
Q3: "When would you use a task notification vs a semaphore vs a queue?"
Model Answer Starter: "Task notifications are the fastest and lightest mechanism in FreeRTOS -- zero extra RAM, roughly 45% faster than semaphores. I use them when there is a dedicated processing task and a single event source. I use a binary semaphore when multiple ISRs or tasks need to signal the same event, since semaphores are shared kernel objects. I use a queue when the ISR needs to pass actual data to the task -- a sensor reading, a received byte, a status register value. The queue copies the data, so the ISR's local variable can go out of scope safely. For counting events where every occurrence must be tracked, a counting semaphore or task notification used in counting mode works."
Q4: "Explain the pxHigherPriorityTaskWoken pattern."
Model Answer Starter: "Every FromISR API takes a pointer to a BaseType_t variable, initialized to pdFALSE. If the API call unblocks a task with higher priority than the task that was running when the interrupt fired, the API sets this variable to pdTRUE. At the end of the ISR, I pass this flag to portYIELD_FROM_ISR(). If it is pdTRUE, the port layer arranges for the scheduler to switch to the unblocked task as soon as the ISR returns, instead of resuming the interrupted (lower-priority) task. Without this call, the unblocked task would have to wait until the next tick to get scheduled, adding up to one full tick period of unnecessary latency."
Q5: "How would you implement high-throughput ADC sampling with RTOS?"
Model Answer Starter: "I would use DMA double-buffering. Configure DMA to transfer ADC results into buffer A. When the DMA transfer-complete interrupt fires, the ISR swaps the active buffer pointer so DMA starts filling buffer B, then sends a task notification to the processing task. The task wakes up and processes buffer A (filtering, averaging, threshold checks) while DMA fills buffer B in parallel. This gives zero-copy operation, no gap between acquisition windows, and the CPU is free for other tasks during DMA transfers. The ISR itself is just a pointer swap and a vTaskNotifyGiveFromISR call -- a few microseconds at most."
Trap Alerts
- Don't say "just do everything in the ISR" -- this defeats the purpose of having an RTOS and creates unbounded interrupt latency
- Don't forget
portYIELD_FROM_ISR()-- without it, the signaled task waits until the next tick interrupt to run (up to one full tick period of wasted latency) - Don't assume task notifications replace semaphores everywhere -- they are single-consumer only and not suitable for broadcast or multi-producer scenarios with data
Follow-up Questions
- "How would you handle a scenario where the processing task cannot keep up with the ISR rate?"
- "What happens if the ISR fires again before the task finishes processing the previous event?"
- "How do you decide the priority of the deferred processing task relative to other tasks?"
- "What is the difference between
eSetValueWithOverwriteandeSetValueWithoutOverwritein task notifications?"
Practice
❓ Why must you use FromISR API variants inside an interrupt service routine?
❓ What is the purpose of the pxHigherPriorityTaskWoken parameter in FromISR calls?
❓ Which signaling mechanism uses zero additional RAM in FreeRTOS?
❓ In a DMA double-buffer scheme, what does the DMA-complete ISR do?
❓ What happens if you forget to call portYIELD_FROM_ISR() after unblocking a high-priority task from an ISR?
Real-World Tie-In
High-Speed Sensor Fusion System -- An IMU (inertial measurement unit) sampled accelerometer and gyroscope data at 8 kHz via DMA double-buffering. The DMA-complete ISR swapped buffers and sent a task notification to the sensor fusion task, which ran a Kalman filter on the 125-microsecond data batch. Using task notifications instead of semaphores saved enough overhead that the fusion task consistently met its deadline with 15% margin, whereas the semaphore-based prototype occasionally missed deadlines under peak load.
Industrial Motor Controller -- A brushless DC motor drive used three ISR-to-task channels simultaneously: an ADC-complete ISR sent current samples via a queue to the PID control task, an encoder ISR used task notifications to signal the speed calculation task, and a fault-detect ISR gave a binary semaphore to the safety shutdown task. The deferred processing architecture kept worst-case ISR duration under 2 microseconds, ensuring the PWM update never glitched even when the diagnostic logging task consumed significant CPU time.