Features Documentation Examples Repository Blog Contact

Simple Driver

Now that you understand applications and RPC communication, let’s explore how to create device drivers in CMRX. This example shows how to build a driver that safely controls hardware peripherals while maintaining system security.

What You’ll Learn

  • How drivers work in CMRX’s userspace architecture
  • Accessing hardware memory regions safely
  • Creating drivers using RPC for inter-process communication
  • Building abstraction layers for hardware peripherals
  • Implementing multiple device instances with a single driver

Understanding CMRX Drivers

In CMRX, drivers are userspace processes - they don’t run with kernel privileges. This is different from traditional embedded systems where drivers often run in kernel space.

Benefits of Userspace Drivers

  • Security: A buggy driver can’t crash the entire system
  • Isolation: Driver memory is separate from application memory
  • Stability: Driver crashes don’t affect other processes
  • Modularity: Drivers can be developed and tested independently

How Drivers Access Hardware

Since drivers run in userspace, they need special permission to access hardware registers. CMRX provides this through MMIO (Memory-Mapped I/O) ranges.

Granting Hardware Access

Single Memory Region

For most peripherals, you need access to one memory region:

OS_APPLICATION_MMIO_RANGE(app_name, start_address, end_address);

Example:

// Grant access to UART0 registers (example addresses)
OS_APPLICATION_MMIO_RANGE(uart_driver, 0x40000000, 0x40001000);

Important Notes:

  • start_address is inclusive (can access this address)
  • end_address is exclusive (cannot access this address)
  • Hardware may require alignment (e.g., ARM requires size-aligned base addresses)
  • Compilation will fail if hardware requirements aren’t met

Multiple Memory Regions

Some peripherals have separate control and data regions:

OS_APPLICATION_MMIO_RANGES(app_name, 
    region1_start, region1_end,
    region2_start, region2_end);

Example:

// Grant access to both I2C0 and I2C1 peripherals
OS_APPLICATION_MMIO_RANGES(i2c_driver, 
    0x40044000, 0x40048000,  // I2C0 registers
    0x40048000, 0x4004C000); // I2C1 registers

Complete Driver Example: I2C Driver

Let’s create a complete I2C driver that can handle multiple I2C devices. We’ll reuse the CharacterDevVtable interface from the RPC example.

Step 1: Define the Driver Object

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

// Define our I2C device object
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
};

// Forward declare device instances so other apps can use them
extern struct I2cDevice display;
extern struct I2cDevice temp_sensor;

What This Structure Contains:

  • vtable - RPC interface (read/write/close functions)
  • device_address - The I2C address of the specific device
  • i2c_phy - Pointer to the physical I2C peripheral instance

Step 2: Implement the Driver Functions

// 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) {
    // Use SDK function to read from I2C device
    return i2c_read_blocking(this->i2c_phy, this->device_address, 
                           buffer, bufsize, false);
}

// Write data to I2C device
static int i2c_write(INSTANCE(this), const char * buffer, unsigned char datasize) {
    // Use SDK function to write to I2C device
    return i2c_write_blocking(this->i2c_phy, this->device_address, 
                            buffer, datasize, false);
}

// 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;
}

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
};

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

Now other applications can use the I2C devices through RPC calls:

#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) {
    // 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);
    }
    
    // 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);
    }
}

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

How This Architecture Works

Driver Process (i2c_driver)

  1. Hardware Access: Has permission to access I2C peripheral registers
  2. Device Management: Manages multiple I2C devices on the same bus
  3. RPC Server: Exposes devices through RPC interface
  4. Abstraction: Hides I2C implementation details from applications

Application Process (sensor_app)

  1. RPC Client: Makes calls to driver’s RPC interface
  2. Data Sharing: Uses SHARED keyword for data buffers
  3. Device Agnostic: Doesn’t need to know about I2C specifics
  4. Safe Access: Cannot directly access hardware or corrupt driver state

CMRX Kernel

  1. Memory Protection: Enforces MMIO range restrictions
  2. RPC Routing: Routes calls between applications and driver
  3. Security: Validates RPC calls and shared data access

Advantages of This Approach

Security Benefits

  • Privilege Separation: Driver can’t access application memory
  • Fault Isolation: Driver crashes don’t affect applications
  • Controlled Access: Only specified hardware regions are accessible

Design Benefits

  • Abstraction: Applications don’t need to know about I2C
  • Reusability: Same driver can support multiple devices
  • Modularity: Driver can be developed and tested independently
  • Flexibility: Easy to swap implementations or add new devices

Development Benefits

  • Type Safety: Compile-time checking of RPC calls
  • Clear Interfaces: Well-defined APIs between driver and applications
  • Easy Testing: Driver can be tested in isolation
  • Maintainability: Clear separation of concerns

This driver pattern forms the foundation for building robust, secure embedded systems where hardware access is controlled and applications remain isolated from driver implementation details.

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