docs/user-manual/design-patterns/isr-driver.md
The ISR-based device driver pattern enables F´ components to interface with hardware devices that use Interrupt Service Routines (ISRs) for event-driven communication. This pattern is common in embedded RTOSes (VxWorks, RTEMS, Integrity, FreeRTOS) and baremetal systems where hardware interrupts signal data availability or device state changes.
[!NOTE] This document focuses on the ISR-based driver pattern. For general device driver architecture, see the Application-Manager-Driver Pattern. For a complete how-to guide on implementing device drivers, see How-To: Develop a Device Driver.
Interrupt Service Routines (ISRs) are functions registered as callbacks that execute when hardware interrupts occur. ISRs do not run in a task context, which imposes constraints that hold on every platform:
Interrupt masking and nesting are platform-specific: many interrupt controllers allow a higher-priority interrupt to preempt a running ISR, so do not assume the ISR cannot be reentered or preempted. Consult the platform's ISR documentation for what is masked while the handler runs.
Best practices for ISRs:
ISR-based drivers are appropriate when:
Each interrupt cause needs a decision: handle it in ISR context, or defer it to the driver's thread.
sync and the receiving handler itself obeys every ISR constraint listed above, because a sync input runs the downstream handler inside the ISR. An async input enqueues on the receiving component's message queue and a guarded input takes that component's mutex — both are subject to ISRs and F´ Queuing.The driver should copy data in the thread of the driver to minimize time in the ISR. Use the Svc::BufferManager pattern to manage buffers efficiently.
The driver needs to use the OS C API to register the ISR. The OS API typically requires a C function pointer, hence the static function pattern.
FW_ASSERT is not ISR-safe: the default hook writes to the console and the assert path terminates or restarts the system. An ISR must therefore validate its inputs (interrupt vector, user context, hardware status) and return rather than assert on them. Keep asserts in the deferred, task-context handlers.
The ISR and the component thread share the driver's counters. F´ components serialize their state through their message queue, and an ISR bypasses that mechanism entirely, so shared state needs explicit protection:
std::atomic for counters touched by both contexts, and use a width that is lock-free on the target — a non-lock-free std::atomic falls back to a lock and is unusable in an ISR. A plain U32/U64 increment is a data race, and a 64-bit access can tear on a 32-bit target even when the platform claims atomic word writes.The examples below name registers (INT_EN, INT_PEND, TIMER_VAL, the FIFOs) as if they were variables to keep the pattern readable. Real memory-mapped accesses must go through volatile-qualified pointers of a fixed-size type, or the compiler is free to reorder, merge, or elide them.
The ISR-based driver pattern bridges the gap between ISR context (C callback) and F´ component context (C++ object). Since F´ components are C++ objects and the OS ISR API typically requires C callbacks, the pattern uses a static function as the ISR entry point that then invokes a member function on the component.
sequenceDiagram
participant HW as Hardware Device
participant OS as OS
participant ISR as Static ISR Function
participant Driver as Driver Component
participant User as User Component
Note over HW,User: Setup
Driver->>OS: registerISR(static_function)
Note over HW,User: Runtime
HW->>ISR: Hardware Interrupt
ISR->>ISR: Clear interrupt
ISR->>Driver: doISR(vector, user_ctx)
Driver->>Driver: Copy data to buffer
Driver->>User: Send buffer via port
To illustrate the ISR-based driver pattern, this guide walks through the setup of a hypothetical ISR-based driver component with the following characteristics:
The device exposes memory-mapped registers at the following addresses:
| Register | Address | Bits | Function | Usage |
|---|---|---|---|---|
INT_EN | 0x1000 | 0 | Timer interrupt enable | 1=enable, 0=disable |
| 1 | Buffer A done interrupt enable | 1=enable, 0=disable | ||
| 2 | Buffer B done interrupt enable | 1=enable, 0=disable | ||
INT_PEND | 0x1004 | 0 | Timer interrupt pending | Write 1 to clear |
| 1 | Buffer A done interrupt pending | Write 1 to clear | ||
| 2 | Buffer B done interrupt pending | Write 1 to clear | ||
TIMER_VAL | 0x1008 | 0-31 | Timer value | Write integer timer value in microseconds |
TIMER_CNTL | 0x100C | 0 | Timer enable | 1=enabled, 0=disabled |
BUFF_A_FIFO | 0x1010 | 0-31 | BUFF_A_FIFO head | Read 4 times (4 words, 16 bytes) to empty FIFO |
BUFF_B_FIFO | 0x1014 | 0-31 | BUFF_B_FIFO head | Read 4 times (4 words, 16 bytes) to empty FIFO |
The ISR-based driver for this device:
An ISR-based driver component should have:
Svc::BufferManager)Here's an example FPP definition for the ISR-based driver component:
# Notification port type, no argument
port TimerPort()
@ Device driver using ISRs for data reception
active component MyDriver {
# Scheduler port (rate group)
async input port run: Svc.Sched
# Internal interface for ISR reporting
# `drop` queue-full behavior: a full queue must drop (not FW_ASSERT) in ISR context
internal port IsrReport(interrupts: U32) drop
# Port to send timer ticks
# NOTE: invoked in ISR context, so it must be connected to a `sync` input
# port whose handler is ISR-safe (see "Choose Where the Work Runs")
output port TimerDone: TimerPort
# Buffer management ports
output port AllocateBuffer: Fw.BufferGet
output port SendBuffer: Fw.BufferSend
# Telemetry: types match the atomic counters in the implementation
telemetry DataBytes: U32
telemetry TimerTicks: U32
@ Buffer allocation failed, incoming data was dropped
event BufferAllocationFailed($size: U32) \
severity warning high \
format "Failed to allocate {} bytes for incoming device data"
# Not displayed: Standard ports for commands, events, telemetry
# ...
}
The internal port declares the drop queue-full behavior. The default behavior is assert, which calls FW_ASSERT when the message queue is full — unacceptable in ISR context (see Asserts and ISR Context). With drop, the generated invoke increments the component's dropped-message counter and returns instead. Size the component's message queue for the worst-case interrupt burst so drops do not occur in normal operation.
The driver header declares key elements for ISR handling:
// In: MyDriver.hpp
#include <atomic>
#include "Drv/MyDriver/MyDriverComponentAc.hpp"
namespace Drv {
class MyDriver : public MyDriverComponentBase {
public:
// Component construction and destruction
MyDriver(const char* compName);
~MyDriver();
// Enable driver
void enableDriver(const U32 timerVal);
private:
// Handler implementation for rate group to report telemetry
void run_handler(FwIndexType portNum, U32 context) override;
// Handler implementation for IsrReport internal interface
void IsrReport_internalInterfaceHandler(U32 interrupts) override;
//! static Interrupt service routine - required for OS API
//! *** invoked in ISR context ***
//! `int` is used here only because the OS callback signature requires it
static void driverISR(int vector, void* user_ctx);
//! Member function to handle ISRs
void doISR(U32 vector);
//! Counters shared between ISR and component thread: must be lock-free
std::atomic<U32> m_dataBytes{0}; // Counter for data bytes received
std::atomic<U32> m_timerTicks{0}; // Counter for timer ticks
};
} // namespace Drv
The enable function registers the ISR with the OS before enabling any interrupt, so that no interrupt can assert while the vector is unhandled:
// In: MyDriver.cpp
void MyDriver::enableDriver(const U32 timerVal) {
// register ISR before any interrupt can assert
registerISR(DRIVER_VECTOR, MyDriver::driverISR, this);
// clear stale pending interrupts (INT_PEND is write-1-to-clear)
INT_PEND = INT_PEND_TIMER | INT_PEND_BUFF_A_FULL | INT_PEND_BUFF_B_FULL;
// set timer interval
TIMER_VAL = timerVal;
// enable timer
TIMER_CNTL = TIMER_CNTL_ENABLE;
// enable interrupts
INT_EN = INT_EN_TIMER | INT_EN_BUFF_A_FULL | INT_EN_BUFF_B_FULL;
}
The static function serves as the entry point from the OS and casts the user context back to the component:
// In: MyDriver.cpp
//! Static interrupt service routine - required for OS API
//! *** invoked in ISR context ***
//! Inputs are validated by returning, not asserting: FW_ASSERT is not ISR-safe
void MyDriver::driverISR(int vector, void* user_ctx) {
if (user_ctx == nullptr) {
return;
}
// Cast the user_ctx pointer back to the component object
MyDriver* comp_ptr = static_cast<MyDriver*>(user_ctx);
// Invoke the member ISR function
comp_ptr->doISR(static_cast<U32>(vector));
}
The member ISR function handles the interrupt, determines the source, and dispatches work. Every asserted cause is dispatched: the write-back clears all of them at once, so a cause that is cleared but not handled is lost.
// In: MyDriver.cpp
//! Member ISR function does:
//! - Reads the interrupt pending bits to see which interrupts are asserted
//! - Writes back just the bits that are asserted (clears interrupts)
//! - Handles the timer interrupt in ISR context for minimal jitter
//! - Dispatches any buffer interrupt to the driver thread
void MyDriver::doISR(U32 vector) {
if (DRIVER_VECTOR != vector) {
return;
}
// Get interrupts
U32 ints = INT_PEND;
// Write back bits to clear interrupts
// This avoids a race if a new interrupt is asserted
INT_PEND = ints;
// Dispatch calls based on interrupts. Each cause is tested independently:
// the timer and a buffer interrupt can be pending at the same time.
if ((ints & INT_PEND_TIMER) != 0) {
this->m_timerTicks++;
// Runs the connected `sync` handler in ISR context
this->TimerDone_out(0);
}
if ((ints & (INT_PEND_BUFF_A_FULL | INT_PEND_BUFF_B_FULL)) != 0) {
// FIFO A/B full - dispatch to driver thread for further processing
// Use internal port to move processing off ISR context
// NOTE: requires an ISR-safe queue, see "ISRs and F´ Queuing"
this->IsrReport_internalInterfaceInvoke(ints);
}
}
The internal interface handler executes on the driver's thread (not in ISR context) and performs the data copy. Each FIFO is drained independently, since both can be reported by a single ISR invocation:
// In: MyDriver.cpp
void MyDriver::IsrReport_internalInterfaceHandler(U32 interrupts) {
const U32 fifos[2] = {INT_PEND_BUFF_A_FULL, INT_PEND_BUFF_B_FULL};
for (U32 fifo : fifos) {
if ((interrupts & fifo) == 0) {
continue;
}
// get a buffer to fill: allocation can fail (e.g. an interrupt storm
// draining Svc::BufferManager), so drop the data rather than assert
Fw::Buffer buff = this->AllocateBuffer_out(0, FIFO_DEPTH);
if ((buff.getSize() < FIFO_DEPTH) || (buff.getData() == nullptr)) {
this->log_WARNING_HI_BufferAllocationFailed(static_cast<U32>(FIFO_DEPTH));
continue;
}
auto serTo = buff.getSerializer();
for (FwSizeType word = 0; word < FIFO_DEPTH / sizeof(U32); word++) {
const U32 data = (fifo == INT_PEND_BUFF_A_FULL) ? BUFF_A_FIFO : BUFF_B_FIFO;
const Fw::SerializeStatus stat = serTo.serializeFrom(data);
// There should always be room
FW_ASSERT(stat == Fw::FW_SERIALIZE_OK, static_cast<FwAssertArgType>(stat));
}
// send copied data to user
buff.setSize(FIFO_DEPTH);
this->SendBuffer_out(0, buff);
// add data to counter
this->m_dataBytes += FIFO_DEPTH;
}
}
The run handler reports telemetry on a schedule:
// In: MyDriver.cpp
//! The run_handler() function writes the counters to telemetry channels
//! Note that this runs on the thread of the driver since it is an async port
void MyDriver::run_handler(FwIndexType portNum, U32 context) {
this->tlmWrite_DataBytes(this->m_dataBytes.load());
this->tlmWrite_TimerTicks(this->m_timerTicks.load());
}
Queuing messages in F´ happens using an implementation of the Os::Queue class. When queuing messages, it is imperative that the queue selection does not use mutexes or other forms of locking in its implementation. On some platforms this is a hard-error and on others this can cause deadlock.
The default implementation, Os::Generic::PriorityQueue, uses Os::Mutex and condition variables and is therefore not ISR-safe. Any component whose queue is written from ISR context — including a driver invoking its own internal port, and any downstream component reached through an async input — must run on an ISR-safe queue implementation. F´ ships two, and some platforms supply OS-supported lock-free queues of their own:
Os::Generic::LocklessPriorityQueue - lock-free, full priority range, allocation only at create timeOs::Generic::PriorityMemQueue - lock-free, per-priority memory pools and configurationBoth allocate all memory up front. LocklessPriorityQueue's non-blocking send/receive paths are fully lock-free and ISR-safe; PriorityMemQueue's non-blocking send posts a counting semaphore, so its ISR safety is platform-dependent — verify Os_CountingSemaphore ISR safety for the target (see its SDD). The blocking variants of both must not be called from an ISR.
The implementation is selected at build time with the CHOOSES_IMPLEMENTATIONS directive (Os_Generic_LocklessPriorityQueue or Os_Generic_PriorityMemQueue), either in the platform definition or as a per-deployment override. See CMake Implementations.
The ISR-based device driver pattern enables F´ components to interface with interrupt-driven hardware while respecting ISR execution constraints. By using a static function as the ISR entry point and deferring work to the component thread via internal ports, the pattern maintains F´'s component architecture while achieving low-latency interrupt handling.