Embedded Systems · Real-Time Operating Systems

Deferred Interrupt Work: Splitting Handlers from Processing

Why the top half of a handler should only acknowledge and wake, how the bottom half runs at a chosen priority, and what the split costs in latency.

A peripheral interrupt arrives, the handler runs, and the work it does is bounded only by what the handler contains. Every instruction in it executes above every task in the system, so a handler that converts a sample, filters a reading, and formats a packet has added all of that time to the response time of every task, whether or not any of them wanted the sample. The fix is structural: keep the handler minimal and move the processing into a task whose priority reflects how urgent the work actually is.

What belongs in the handler

The handler’s obligations are to clear the peripheral’s interrupt condition, to get the data out of the peripheral before it is overwritten, and to notify whatever will process it. Everything else can be deferred. The order matters: a handler that processes first and acknowledges last will re-enter on the same condition if the condition is level-triggered, and a handler that acknowledges first and reads last may lose data if the peripheral’s buffer is shallow. The usual order is read, acknowledge, notify.

Reading the data in the handler is not optional in the general case. A UART with a one-byte receive register will overwrite it if the next character arrives before the handler reads it, and the handler has to run before the next character’s arrival, which at a fast baud rate is a small number of microseconds. An SPI or DMA transfer into a buffer removes that constraint: the handler’s job becomes marking the buffer complete, and the buffer’s size sets how much slack exists.

The kernel itself imposes constraints. Most kernels require that a task-wake call from a handler use a special variant that does not block and that records whether the woken task needs a context switch, because the handler cannot switch context directly. Calling the ordinary wake function from a handler corrupts the kernel’s state in ways that surface much later; the special variant exists because the check is cheap and the failure is expensive.

The queue between the halves

The deferred half runs in a task that blocks on a queue, and the handler deposits a token or a small message. The queue’s depth is the whole design question: too shallow and the handler overwrites or blocks, too deep and the system’s worst-case processing latency grows by the queue’s backlog.

A depth chosen for the worst burst is the usual answer. A UART at a given baud rate produces bytes at a fixed rate, and the processing task’s period must be at least as fast as the arrival rate on average; a queue depth equal to the number of bytes that can arrive during one processing period gives the task one period of slack before bytes are lost. Events rather than bytes reduce the arithmetic: if the handler counts bytes and only queues one event per processing round, the queue depth can be one.

The queue’s item size is also a cost. A one-byte token per event is a word per entry on most kernels, because the queue stores items in a fixed-size slot; a stream of single-character events is not the right use of a queue when the peripheral has a FIFO. Reading the FIFO into a buffer in the handler and queueing a single “buffer ready” message is the pattern that keeps the queue shallow and the handler short.

/* top half: bounded work only */
void USART1_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    char c;

    if (usart_rx_ready()) {
        c = usart_read();                  /* clears the flag on this part */
        xQueueSendFromISR(rx_queue, &c, &woken);
    }
    portYIELD_FROM_ISR(woken);             /* pend a switch only if needed */
}

/* bottom half: priority set by the application's deadline for parsing */
void rx_task(void *arg)
{
    char c;
    for (;;) {
        if (xQueueReceive(rx_queue, &c, portMAX_DELAY) == pdPASS) {
            parser_feed(&parser, c);
        }
    }
}

The portMAX_DELAY in the task is an indefinite wait and is safe because the task has a priority and the kernel will run it when there is work. A timeout there would turn every idle period into a periodic wake-up for no reason.

Choosing the deferred task’s priority

The processing task’s priority is a design decision that the old handler hid. Inside a handler, everything ran at the highest priority; as a task, the processing runs at a level that can be compared against other tasks and analysed.

The rule is to set the priority by the consequence of the work being late, not by the fact that it was an interrupt. A frame that must be parsed before the next frame arrives needs a priority that meets that deadline; a temperature reading that is logged once per second does not need to run above a control loop just because it came from an interrupt. Deferring makes that distinction expressible, and in a system with several handlers it is what allows one of them to be more urgent than another.

The cost is latency. The path from event to processing now includes the handler’s execution, the interrupt return, the scheduler’s choice, and the task’s context load. On a Cortex-M part with the kernel’s deferred switch, what the split adds is one exception return, one scheduler decision, and one context restore — the cost of a switch rather than the cost of the work. The price is paid once per event and is the subject of the accounting in interrupt latency and kernel overhead.

A second cost is the queue operation itself. The send from the handler takes a masked critical section or an atomic operation; the receive in the task takes a scheduler entry. Both are small beside the processing but not beside an empty handler, which is why the decision depends on how much work is being moved rather than on the split alone.

Bursts and the priority of the handler itself

The model where every event is deferred identically breaks when events arrive in bursts that are much denser than the average. A queue sized for the average rate fills within the burst, and the handler has no choice but to drop data or to do the work inline. The two honest options are to size for the burst and accept the memory, or to design the protocol so that dropped data is detectable and recoverable — a counter of overruns that the processing task reads, a sequence number that reveals a gap.

The handler’s own priority relative to other handlers is set by the interrupt controller’s nesting configuration, not by the RTOS. Two handlers that both wake tasks can preempt each other if the controller allows nesting, and the deferred work then arrives out of order relative to the events. If order matters, the handlers must either be serialised by the controller or must carry sequence numbers that the processing task uses to reorder. Kernels that provide a “mask all interrupts during a kernel operation” policy avoid the nesting question at the cost of latency; ones that provide a “mask up to a configured level” policy trade the two against each other.

An event that arrives while its processing task is already running does not preempt it on a single-core system: the task that the handler would wake is the one already on the processor, so the event waits in the queue and is handled on the next iteration. That serialisation is usually what the application wants, but it means a task that takes a long time per item can accumulate a backlog even at high priority, because there is nothing to preempt it. Splitting the processing into a short critical part and a longer background part is the same decomposition as the top and bottom halves, applied one level up.

When the split is not worth it

For handlers whose work is a handful of cycles — a counter increment, a flag set — the deferral machinery costs more than the work. The queue’s send and the task’s wake are each larger than the whole handler, and the extra latency is pure loss. The break-even is around the cost of a context switch. A blocking take that only queues a token and lets the scheduler pick a ready task costs roughly the same as an inline handler followed by a preemption, so a handler whose body is a handful of instructions gains little; once the body includes a loop over samples, a conversion, or a checksum, the deferral moves work that no longer belongs above every task, and the analysis benefits even when the elapsed time per event is the same.

A handler whose work is genuinely the highest-priority work in the system — the final stage of a control loop, an emergency stop — should stay inline, because deferring it would put it behind tasks whose priority exists for other reasons. The decision is not “interrupts should be short” but “interrupts should contain only work that must run above every task.” The rest is a matter of choosing the priority, and the priority should come from the analysis in response-time analysis for fixed-priority sets.

The kernel’s own tick handler is the reference case: it does the minimum needed to advance the tick count, expire timers, and mark any woken tasks ready, and everything the application needs to do periodically is a task. That separation is what makes the tick’s cost a constant that can be measured and subtracted.

References

  1. ARMv7-M Architecture Reference Manual — ARM Limited
  2. FreeRTOS documentation: Task Notifications — FreeRTOS project
  3. Specification of Operating System (AUTOSAR Classic Platform R22-11) — AUTOSAR