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.
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 notificationisr_kill() - send signal to a threadThese 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.
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.
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 devicebuffer_length - number of valid bytes currently in the bufferNext 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:
this pointer to the specific device// 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
};
// 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);
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:
I2cDevice object)I2C_IRQHandler)wait_for_object, isr_notify_object)