Skip to content
Home » Software Design » Building a Driver Interface in C — A Complete Example

Building a Driver Interface in C — A Complete Example

Driver Interface in C featured image with purple background, Architecture badge, Dr icon, A Complete Example subtitle in the Software Design in C series by NerdyElectronics
Software Design Principles in C
Part 31 of 31View Full Path →

KEY TAKEAWAYS

  • A driver interface standardizes how your application interacts with hardware
  • Define the interface as a struct of function pointers — the C equivalent of an abstract class
  • Each hardware device gets its own implementation file
  • Use a factory function to create the right driver based on configuration
  • The application code never needs to know which specific hardware is connected
  • This pattern combines Strategy, Factory, DIP, OCP, SRP, and Encapsulation into one practical architecture

What Is a Driver Interface?

A driver interface is a standardized API that lets your application work with different hardware devices through the same set of functions. Instead of writing separate code for each sensor, display, or communication chip, you define a common interface and implement it for each device.This is where multiple design principles come together: the Strategy pattern (swappable implementations), Factory pattern (creating the right driver), Dependency Inversion (depending on abstractions), and Open/Closed Principle (extending without modifying).

A Complete Example: Sensor Driver Interface

Let us build a sensor system that supports multiple temperature sensor types (thermistor, BMP280, DHT22) through one common interface.

Step 1: Define the Interface

// sensor_driver.h — the abstract interface
#ifndef SENSOR_DRIVER_H
#define SENSOR_DRIVER_H

#include <stdbool.h>

typedef enum {
    SENSOR_OK = 0,
    SENSOR_ERR_INIT,
    SENSOR_ERR_READ,
    SENSOR_ERR_TIMEOUT,
    SENSOR_ERR_RANGE,
} SensorStatus;

typedef struct SensorDriver SensorDriver;

struct SensorDriver {
    const char *name;                                    // Human-readable name
    SensorStatus (*init)(SensorDriver *self);            // Initialize hardware
    SensorStatus (*read)(SensorDriver *self, float *value); // Read a value
    SensorStatus (*self_test)(SensorDriver *self);       // Run self-diagnostics
    void         (*destroy)(SensorDriver *self);         // Cleanup resources
    void *ctx;                                           // Private driver data
};

#endif
This interface does not know or care about any specific sensor. It defines what a sensor driver must do, not how.

Step 2: Implement for a Thermistor (ADC-Based)

// thermistor_driver.c
#include "sensor_driver.h"
#include "hal_adc.h"
#include <stdlib.h>
#include <math.h>

typedef struct {
    int adc_channel;
    float r_series;      // Series resistor value
    float b_coefficient; // Beta coefficient
} ThermistorCtx;

static SensorStatus thermistor_init(SensorDriver *self) {
    ThermistorCtx *ctx = (ThermistorCtx *)self->ctx;
    hal_adc_init(ctx->adc_channel);
    return SENSOR_OK;
}

static SensorStatus thermistor_read(SensorDriver *self, float *value) {
    ThermistorCtx *ctx = (ThermistorCtx *)self->ctx;
    int raw = hal_adc_read(ctx->adc_channel);
    if (raw = 4095) return SENSOR_ERR_RANGE;

    float resistance = ctx->r_series * (4095.0f / raw - 1.0f);
    float steinhart = log(resistance / 10000.0f) / ctx->b_coefficient;
    steinhart += 1.0f / 298.15f;
    *value = 1.0f / steinhart - 273.15f;
    return SENSOR_OK;
}

static SensorStatus thermistor_self_test(SensorDriver *self) {
    float val;
    SensorStatus st = thermistor_read(self, &val);
    if (st != SENSOR_OK) return st;
    if (val  125.0f) return SENSOR_ERR_RANGE;
    return SENSOR_OK;
}

static void thermistor_destroy(SensorDriver *self) {
    free(self->ctx);
    free(self);
}

SensorDriver *thermistor_create(int channel, float r_series, float beta) {
    SensorDriver *drv = malloc(sizeof(SensorDriver));
    ThermistorCtx *ctx = malloc(sizeof(ThermistorCtx));
    ctx->adc_channel = channel;
    ctx->r_series = r_series;
    ctx->b_coefficient = beta;

    drv->name = "Thermistor";
    drv->init = thermistor_init;
    drv->read = thermistor_read;
    drv->self_test = thermistor_self_test;
    drv->destroy = thermistor_destroy;
    drv->ctx = ctx;
    return drv;
}

Step 3: Implement for BMP280 (I2C-Based)

// bmp280_driver.c
#include "sensor_driver.h"
#include "hal_i2c.h"
#include <stdlib.h>

typedef struct {
    int i2c_bus;
    uint8_t address;
    int32_t calib_data[6];  // Calibration coefficients
} BMP280Ctx;

static SensorStatus bmp280_init(SensorDriver *self) {
    BMP280Ctx *ctx = (BMP280Ctx *)self->ctx;
    hal_i2c_init(ctx->i2c_bus);

    uint8_t chip_id;
    hal_i2c_read_reg(ctx->i2c_bus, ctx->address, 0xD0, &chip_id, 1);
    if (chip_id != 0x58) return SENSOR_ERR_INIT;

    // Read calibration data from registers 0x88-0x9F
    read_calibration(ctx);
    return SENSOR_OK;
}

static SensorStatus bmp280_read(SensorDriver *self, float *value) {
    BMP280Ctx *ctx = (BMP280Ctx *)self->ctx;
    uint8_t raw[3];

    // Trigger measurement
    uint8_t ctrl = 0x27;  // Normal mode, oversampling x1
    hal_i2c_write_reg(ctx->i2c_bus, ctx->address, 0xF4, &ctrl, 1);

    // Wait and read
    delay_ms(50);
    hal_i2c_read_reg(ctx->i2c_bus, ctx->address, 0xFA, raw, 3);

    int32_t adc_T = (raw[0] << 12) | (raw[1] <> 4);
    *value = compensate_temperature(adc_T, ctx->calib_data);
    return SENSOR_OK;
}

SensorDriver *bmp280_create(int i2c_bus, uint8_t addr) {
    SensorDriver *drv = malloc(sizeof(SensorDriver));
    BMP280Ctx *ctx = malloc(sizeof(BMP280Ctx));
    ctx->i2c_bus = i2c_bus;
    ctx->address = addr;

    drv->name = "BMP280";
    drv->init = bmp280_init;
    drv->read = bmp280_read;
    drv->self_test = bmp280_self_test;
    drv->destroy = bmp280_destroy;
    drv->ctx = ctx;
    return drv;
}

Step 4: Factory for Sensor Selection

// sensor_factory.c
#include "sensor_driver.h"

SensorDriver *sensor_create(const char *type) {
    if (strcmp(type, "thermistor") == 0) {
        return thermistor_create(0, 10000.0f, 3950.0f);
    }
    if (strcmp(type, "bmp280") == 0) {
        return bmp280_create(0, 0x76);
    }
    if (strcmp(type, "dht22") == 0) {
        return dht22_create(GPIO_PORT_A, 4);
    }
    return NULL;
}

Step 5: Application Code — Hardware Agnostic

// main.c — does not know or care which sensor is connected
#include "sensor_driver.h"

void run_sensor_system(SensorDriver *sensor) {
    if (sensor->init(sensor) != SENSOR_OK) {
        printf("Failed to init %s\n", sensor->name);
        return;
    }

    if (sensor->self_test(sensor) != SENSOR_OK) {
        printf("Self-test failed for %s\n", sensor->name);
        return;
    }

    while (1) {
        float temperature;
        SensorStatus st = sensor->read(sensor, &temperature);
        if (st == SENSOR_OK) {
            printf("%s: %.1f Cn", sensor->name, temperature);
        } else {
            printf("%s: read error %d\n", sensor->name, st);
        }
        delay_ms(1000);
    }
}

int main(void) {
    // Configuration determines which sensor is used
    SensorDriver *sensor = sensor_create("bmp280");
    run_sensor_system(sensor);
    sensor->destroy(sensor);
    return 0;
}
The application code is completely independent of the sensor hardware. Swapping from a thermistor to a BMP280 means changing one string. The run loop, error handling, and display logic remain untouched.

Design Principles at Work

PrincipleWhere It Appears
Strategy PatternDifferent read() implementations behind the same interface
Factory Patternsensor_create() builds the right driver from a type string
Dependency InversionApplication depends on SensorDriver interface, not BMP280 or thermistor
Open/Closed PrincipleAdding a new sensor means adding one .c file — no changes to application code
Single ResponsibilityEach driver file handles one sensor type; the factory handles creation; the app handles business logic
EncapsulationThe void *ctx hides driver-specific data from the application

Testing the Driver Interface

// mock_sensor.c — for unit testing
SensorDriver *mock_sensor_create(float fixed_value) {
    // Returns a driver that always produces fixed_value
    // No hardware needed — perfect for testing application logic
}
By injecting a mock sensor, you can test the entire application — error handling, display logic, threshold checks — on your development machine without any hardware.

Key Takeaways

  • A driver interface standardizes how your application interacts with hardware
  • Define the interface as a struct of function pointers — the C equivalent of an abstract class
  • Each hardware device gets its own implementation file
  • Use a factory function to create the right driver based on configuration
  • The application code never needs to know which specific hardware is connected
  • This pattern combines Strategy, Factory, DIP, OCP, SRP, and Encapsulation into one practical architecture

Leave a Reply

Your email address will not be published. Required fields are marked *