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_addressis inclusive (can access this address)end_addressis 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 devicei2c_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
thispointer 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)
- Hardware Access: Has permission to access I2C peripheral registers
- Device Management: Manages multiple I2C devices on the same bus
- RPC Server: Exposes devices through RPC interface
- Abstraction: Hides I2C implementation details from applications
Application Process (sensor_app)
- RPC Client: Makes calls to driver’s RPC interface
- Data Sharing: Uses
SHAREDkeyword for data buffers - Device Agnostic: Doesn’t need to know about I2C specifics
- Safe Access: Cannot directly access hardware or corrupt driver state
CMRX Kernel
- Memory Protection: Enforces MMIO range restrictions
- RPC Routing: Routes calls between applications and driver
- 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.