Features Documentation Examples Repository Blog Contact

Interrupts

In the previous example, we showed that a simple driver that controls a hardware peripheral is just an ordinary process. Real-world drivers often need to process interrupts in order to react to hardware events and configure peripheral and pins to allow external access to peripheral functions. In this example we’ll explore how CMRX drivers handle these tasks.

What You’ll Learn

  • How interrupts fit into concept of CMRX RTOS
  • Passing control from interrupt service routine to a process

Handling peripheral interrupts

CMRX does not interfere with how you create interrupt handlers, so you continue to use the way your HAL provides for interrupt handlers to be defined. This decision was made to provide the best possible latency for cases which need it.

In many modern systems this can be done by defining a function with specific name, such as I2C0_IRQ_Handler which will automatically be referenced in Interrupt Vector Table built by the HAL startup code. This approach works well in CMRX device drivers, so for example:

void EXTI0_IRQHandler()
{
}

Will be executed for external interrupt event on many STM32 microcontrollers, once external interrupt is configured and enabled. If your SDK uses some other way to install IRQ handlers (such as shared handlers, etc.) use this way to install the handler. To get more info, consult documentation of your device SDK.

Regardless of the way how the interrupt handler was installed, its code runs with privileges of the system code (the same as kernel itself). This is a hardware limitation. In CMRX, system code is not isolated by memory isolation. Thus care has to be taken while writing code for interrupt handlers.

Similarly, the code running inside interrupt handlers does not obey the prioritization of threads of CMRX schedulers. All interrupts are hardware-triggered contexts and as such run with priority higher than all software-triggered code.

To deal with these limitations the code of the interrupt handler shall be short and do only the bare minimum of work which requires low processing latency and pass the control to the userspace code as soon as possible to release the processor to other high-priority tasks.

To motivate the developer to pass the control to normal userland as soon as possible, CMRX does not allow to use kernel services from interrupt service handlers. How can interrupt service handler transfer control to the userspace thread without access to kernel services?

Kernel provides two interrupt service handler-specific functions:

  • isr_notify_object() - notify an object, resuming any thread waiting for notification
  • isr_kill() - send signal to a thread

These mirror functions of system calls notify_object and kill. They can be called from interrupt to wake up a thread. This way interrupt can be processed in userspace.

This design allows both fast and unconstrained processing of interrupts that require low latency and low overhead while providing simple and familiar way to pass workload to a userspace thread where processing takes longer and does not require low latency, so such code can be protected by memory isolation.

Non-Blocking Driver Example: I2C Driver

What is I2C? I2C (Inter-Integrated Circuit) is a communication protocol that allows a microcontroller to talk to multiple devices (like sensors, displays) using just two wires: data (SDA) and clock (SCL). Each device has a unique address (like 0x3C for displays).

The simple driver demonstrated in the previous example had one major flaw: it blocked execution whenever I2C transmission was in progress. Letting a fast CPU wait for a slow peripheral is a waste of precious resources. Naturally, CMRX is fully capable of handling interrupt-driven loads. Let’s update the simple driver to actually use interrupts to send and receive the data.

The driver will expose the same CharacterDevVtable interface as the simple driver, so nothing will change from the application’s point of view.

Step 1: Extend data structures

We need to add a data buffer to our I2C device structure. This buffer temporarily stores data being sent or received, and gets allocated automatically for each I2C device. To keep things simple, we will only use one buffer per device as I2C bus doesn’t support duplex communication.

Code will also need a structure to do some coordination between userland and interrupt handler.

#include <cmrx/application.h>
#include <cmrx/rpc/implementation.h>
#include <hardware/i2c.h>

// Maximum payload in transmit buffer
#define I2C_BUFFER_MAX_LEN      16

// Define our I2C device object with transmit buffer
struct I2cDevice {
    const struct CharacterDevVtable * vtable;  // Must be first for RPC
    char device_address;                       // I2C slave address
    i2c_inst_t * i2c_phy;                      // Physical I2C peripheral
    char buffer[I2C_BUFFER_MAX_LEN];           // Transmit buffer
    unsigned buffer_length;                    // Length of data in read buffer
};

enum I2cDirection {
    I2C_NONE = 0,
    I2C_SEND,
    I2C_RECEIVE
};

// Global coordination between user code and interrupt handler
struct I2cBus {
    struct I2cDevice * active_device;     // Which device is currently transferring data
    enum I2cDirection transmit_direction; // Whether we're sending or receiving
    unsigned cursor;                      // Current position in the buffer
};

enum I2cErrorCode {
    I2C_E_OK = 0,
    I2C_E_SIZE = -1
};

What Has Changed:

  • buffer - temporary storage for data being sent to or received from the I2C device
  • buffer_length - number of valid bytes currently in the buffer

Step 2: Update Driver Functions

Next we implement read and write functions. This time functions will configure the bus object and enable interrupt which will do the transmission.

// Tell CMRX we're implementing CharacterDevVtable for I2cDevice
IMPLEMENTATION_OF(struct I2cDevice, struct CharacterDevVtable);

// Read data from I2C device
static int i2c_read(INSTANCE(this), char * buffer, unsigned char bufsize) {
    if (bufsize > I2C_BUFFER_MAX_LEN)
    {
        return I2C_E_SIZE;
    }
    this->buffer_length = bufsize;
    
    i2c_bus.active_device = this;
    i2c_bus.transmit_direction = I2C_RECEIVE;
    i2c_bus.cursor = 0;
    
    // Enable RX interrupts and start read transfer
    i2c_enable_rx_interrupt(this->i2c_phy);
    i2c_begin_read_transfer(this->i2c_phy, this->device_address, this->buffer_length);
    
    wait_for_object(this);
    
    memcpy(buffer, this->buffer, this->buffer_length);
    
    i2c_bus.active_device = NULL;
    i2c_bus.transmit_direction = I2C_NONE;
    
    return this->buffer_length;
}

// Write data to I2C device
static int i2c_write(INSTANCE(this), const char * buffer, unsigned char datasize) {
    if (datasize > I2C_BUFFER_MAX_LEN)
    {
        return I2C_E_SIZE;
    }
    this->buffer_length = datasize;
    memcpy(this->buffer, buffer, this->buffer_length);
    
    i2c_bus.active_device = this;
    i2c_bus.transmit_direction = I2C_SEND;
    i2c_bus.cursor = 0;
    
    // Enable TX interrupts and start write transfer
    i2c_enable_tx_interrupt(this->i2c_phy);
    i2c_begin_write_transfer(this->i2c_phy, this->device_address);
    
    return I2C_E_OK;
}

// Close the I2C device (nothing to do for I2C)
static void i2c_close(INSTANCE(this)) {
    // I2C doesn't need explicit closing, so do nothing
    return;
}

Last missing component is the I2c interrupt handler code:


void I2C0_IRQHandler() {
    ASSERT(i2c_bus.active_device != NULL);
    ASSERT(i2c_bus.transmit_direction != I2C_NONE);
    
    struct I2cDevice * dev = i2c_bus.active_device;
    
    if (i2c_bus.transmit_direction == I2C_RECEIVE)
    {
        if (i2c_get_read_available(dev->i2c_phy) > 0)
        {
            dev->buffer[i2c_bus.cursor++] = i2c_read_byte_raw(dev->i2c_phy);
        }
    }
    if (i2c_bus.transmit_direction == I2C_SEND)
    {
        if (i2c_get_write_available(dev->i2c_phy) > 0)
        {
            i2c_write_byte_raw(dev->i2c_phy, dev->buffer[i2c_bus.cursor++]);
        }
    }
    
    if (i2c_bus.cursor == dev->buffer_length)
    {
        // Stop transmission, signal STOP on the bus
        i2c_end_transfer(dev->i2c_phy);
        // Disable I2C interrupts
        i2c_disable_all_interrupts(dev->i2c_phy);
        
        if (i2c_bus.transmit_direction == I2C_RECEIVE)
        {
            isr_notify_object(dev);
        }
    }
}

Note: The example code above assumes that the I2C peripheral automatically clears interrupt flag if receive or transfer busses are empty / full so the handler does not have to clear respective flags to avoid repeating triggering of the interrupt handler.

Key Points:

  • Functions use the SDK’s I2C API internally
  • Each function receives a typed this pointer to the specific device
  • Return values follow standard conventions (bytes transferred, or negative for errors)

Step 3: Create the Driver Infrastructure

// Define our own I2C peripheral instance
// (The SDK's global instance isn't accessible in our process)
static i2c_inst_t phy_i2c0 = { i2c0_hw, false };

// Create the vtable with our function implementations
VTABLE struct CharacterDevVtable i2c_device_vtable = {
    i2c_read,
    i2c_write,
    i2c_close
};

// Create specific device instances
struct I2cDevice display = {
    &i2c_device_vtable,
    0x3C,        // SSD1306 OLED display default address
    &phy_i2c0    // Connected to I2C0 peripheral
};

struct I2cDevice temp_sensor = {
    &i2c_device_vtable,
    0x48,        // TMP117 temperature sensor default address
    &phy_i2c0    // Also connected to I2C0 peripheral
};

static struct I2cBus i2c_bus = {
    NULL,
    I2C_NONE,
    0
};

Step 4: Configure Hardware Access and Create Application

// Grant access to I2C0 and I2C1 hardware registers
// These addresses are specific to RP2040 microcontroller
OS_APPLICATION_MMIO_RANGES(i2c_driver, 
    0x40044000, 0x40048000,  // I2C0 peripheral registers
    0x40048000, 0x4004C000); // I2C1 peripheral registers

// Create the driver application
OS_APPLICATION(i2c_driver);

Using the Driver from Applications

The use of this driver remains the same as with plain, blocking driver:

#include "i2c_driver.h"  // Include device declarations
#include <cmrx/ipc/rpc.h>

// Data must be marked as SHARED for RPC access
SHARED char display_buffer[16] = "Hello, World!";
SHARED char temp_data[2];

void use_i2c_devices(void * data) {
    // Read temperature data
    int bytes_read = rpc_call(temp_sensor, read, temp_data, sizeof(temp_data));
    if (bytes_read > 0) {
        printf("Read %d bytes from temperature sensor\n", bytes_read);
    }

    // Write data to the display
    int bytes_written = rpc_call(display, write, display_buffer, sizeof(display_buffer));
    if (bytes_written > 0) {
        printf("Sent %d bytes to display\n", bytes_written);
    }
    
}

OS_APPLICATION(sensor_app);
OS_THREAD_CREATE(sensor_app, use_i2c_devices, NULL, 64);

Key Differences from Blocking Driver:

  • Read operations: Thread sleeps while transfer happens in background (CPU can run other threads)
  • Write operations: Return immediately, transfer continues using interrupts
  • CPU efficiency: No busy-waiting means better performance

How This Architecture Works

Driver Methods (instances of I2cDevice object)

  1. Purpose: Configures I2C peripheral for transmission and manages interrupts
  2. Safe Access: Have permission to access I2C peripheral registers yet still are subject to memory isolation any userspace code must obey

IRQ Handler (function I2C_IRQHandler)

  1. Purpose: Asynchronously handles transmission with I2C bus. Works using curated data provided by driver API stored in fixed buffer location
  2. Privileged Execution: Handler code runs with kernel privileges (like system code) and bypasses CMRX’s memory protection. This is why handlers must be kept short and simple.
  3. Coordination: Handler code wakes up userspace thread when transmission is over.

Advantages of Interrupt-Driven Design

Performance Benefits

  • Non-blocking operations: CPU can run other threads while I2C transfers happen in background
  • Better resource utilization: No CPU cycles wasted on busy-waiting for slow peripherals
  • Responsive system: High-priority tasks aren’t delayed by I2C communications
  • Concurrent operations: Multiple applications can work while driver handles hardware

Real-time Benefits

  • Deterministic latency: Interrupt handlers provide predictable response times
  • Priority-based execution: Hardware interrupts take precedence over software tasks
  • Fast hardware response: Critical events (like FIFO overflows) are handled immediately
  • Reduced jitter: Consistent timing for time-sensitive operations

CMRX-Specific Advantages

  • Maintained isolation: Most driver code still runs with memory protection enabled
  • Controlled privilege escalation: Only minimal interrupt handler runs with kernel privileges
  • Thread coordination: Clean handoff between interrupt context and userspace threads
  • Familiar patterns: Uses standard CMRX synchronization primitives (wait_for_object, isr_notify_object)

64kB of protected memory ought to be enough for everyone.