Applying OOP Principles in Embedded C

Building a connected device?

Book a Free 30-min Call

Introduction

When we think of object-oriented programming (OOP), languages like C++, Java, or Python often come to mind. But you can apply many of the same OOP principles in plain C. While C is primarily procedural, it’s flexible enough to let you organize your code in a way that mimics encapsulation, abstraction, and even basic inheritance.

In this post, we’ll explore how these approaches can lead to more modular and maintainable firmware. We’ll use an Air Quality Sensor Driver as a running example.

Why Bring OOP to C?

Many embedded projects rely on C due to its small footprint, direct memory access, and wide support across microcontrollers. But as projects grow in complexity, so does the need for clear architecture. Borrowing OOP principles can help:

  • Encapsulate data and logic so changes in one module don’t break others
  • Abstract away lower-level details, making your code easier to read and maintain
  • Simplify your design by reusing code in a structured manner

Interface Definition (Header File)

In the header file, we define 4 core elements:

  • The data we wish to expose (air_quality_data_t)
  • A definition of the sensor object (air_quality_sensor_obj_t)
  • A definition of methods/functions (drv_air_quality_sensor_t)
  • A function to allow the outside world to interact with the driver (drv_air_quality_sensor_get_api())
/****************************************************************************
* Title    :  Air Quality Sensor Driver
* Filename :  drv_air_quality_sensor.h
*****************************************************************************/
#ifndef FILE_DRV_AIR_QUALITY_SENSOR_H
#define FILE_DRV_AIR_QUALITY_SENSOR_H

#define DRV_AIR_QUALITY_SENSOR  drv_air_quality_sensor_get_api()

typedef uint16_t air_quality_index_t;
typedef uint16_t temperature_t;
typedef uint16_t humidity_t;
typedef uint16_t accuracy_t;
typedef uint8_t  device_address_t;
typedef uint32_t err_code_t;

typedef struct
{
    air_quality_index_t  aq_index;
    temperature_t        temperature;
    humidity_t           humidity;
    accuracy_t           accuracy;
} air_quality_data_t;

typedef struct
{
    device_address_t i2c_address;
} air_quality_sensor_obj_t;

typedef struct
{
    err_code_t (*init)(const air_quality_sensor_obj_t* p_sensor_obj);
    err_code_t (*deinit)(const air_quality_sensor_obj_t* p_sensor_obj);
    err_code_t (*get_data)(const air_quality_sensor_obj_t* p_sensor_obj,
                           air_quality_data_t* p_out_data);
} drv_air_quality_sensor_t;

drv_air_quality_sensor_t* drv_air_quality_sensor_get_api(void);

#endif // FILE_DRV_AIR_QUALITY_SENSOR_H

Module Implementation (Source File)

In the source file, we declare static (private) functions, create a static instance of the driver, and define the public access function:

#include "drv_air_quality_sensor.h"

static err_code_t drv_air_quality_sensor_init(const air_quality_sensor_obj_t* p_sensor_obj);
static err_code_t drv_air_quality_sensor_deinit(const air_quality_sensor_obj_t* p_sensor_obj);
static err_code_t drv_air_quality_sensor_get_data(const air_quality_sensor_obj_t* p_sensor_obj,
                                                   air_quality_data_t* p_out_data);
static err_code_t __calibrate_sensor(void);
static err_code_t __read_sensor_data(void);

static drv_air_quality_sensor_t drv_air_quality_sensor_obj =
{
    .init     = drv_air_quality_sensor_init,
    .deinit   = drv_air_quality_sensor_deinit,
    .get_data = drv_air_quality_sensor_get_data
};

drv_air_quality_sensor_t* drv_air_quality_sensor_get_api(void)
{
    return &drv_air_quality_sensor_obj;
}

static err_code_t drv_air_quality_sensor_init(const air_quality_sensor_obj_t* p_sensor_obj)
{
    // Initialization logic
    return 0;
}

static err_code_t drv_air_quality_sensor_deinit(const air_quality_sensor_obj_t* p_sensor_obj)
{
    // De-initialization logic
    return 0;
}

static err_code_t drv_air_quality_sensor_get_data(const air_quality_sensor_obj_t* p_sensor_obj,
                                                   air_quality_data_t* p_out_data)
{
    // Read sensor data and populate p_out_data
    return 0;
}

Encapsulation: Hiding Internal Details

In classic OOP languages, private class members prevent direct access from outside code. In our driver, we achieve a similar result by making variables and helper functions static in the .c file.

Notice how drv_air_quality_sensor_obj is static: it isn’t visible outside this .c file. Other modules can only interact via drv_air_quality_sensor_get_api(), enforcing encapsulation.

Abstraction: Creating a Clean Interface

OOP emphasizes the separation of what the code does (interface) from how it does it (implementation). In our driver:

  • Header File (Interface): Declares the drv_air_quality_sensor_t structure and function pointers.
  • Source File (Implementation): Defines how each function pointer behaves.

Any calling code only needs to see the high-level interface, not the lower-level details. This reduces coupling and makes your code easier to maintain.

Polymorphism: Using Function Pointers for Flexible Behavior

/* main.c */
#include "drv_air_quality_sensor.h"

#define SENSOR1_ADDRESS 0x5A
#define SENSOR2_ADDRESS 0x6B

const air_quality_sensor_obj_t sensor1 = { .i2c_address = SENSOR1_ADDRESS };
const air_quality_sensor_obj_t sensor2 = { .i2c_address = SENSOR2_ADDRESS };

int main(void)
{
    drv_air_quality_sensor_t* p_driver = drv_air_quality_sensor_get_api();

    air_quality_data_t data1 = {0};
    air_quality_data_t data2 = {0};

    p_driver->init(&sensor1);
    p_driver->init(&sensor2);

    p_driver->get_data(&sensor1, &data1);
    p_driver->get_data(&sensor2, &data2);

    p_driver->deinit(&sensor1);
    p_driver->deinit(&sensor2);

    return 0;
}

Both sensors rely on the same driver interface. This corresponds to the polymorphism principle: different “objects” share the same method signatures but may behave differently internally.

Practical Techniques

  • Opaque Pointers: Hide your struct behind a pointer to avoid accidental modifications to internal fields.
  • Keep focus on data, objects, and methods.
  • Modular Organization: Keep headers clean, focusing on public interfaces. Implementation details go in .c files.
  • Coding Conventions: Consistency in naming and function pointer usage is critical.
  • Keep it Simple: Overusing OOP patterns in C can lead to complicated pointer logic and performance overhead. Only apply what truly benefits your design.
  • Function pointer calls involve indirection. Using them in ISRs might not be ideal; keep this in mind.

Conclusion

While C isn’t traditionally thought of as an OOP language, embedded developers can still benefit from OOP-inspired design patterns, especially for organizing and managing more complex codebases. By applying encapsulation, abstraction, and polymorphism via function pointers, you gain cleaner architecture and more reusable modules.

The key is to use these concepts selectively. Don’t over-engineer your code with layers of abstraction unless they genuinely solve a problem. When done right, these OOP techniques bring clarity, flexibility, and maintainability to even the most demanding embedded projects.

Tags

Best PracticesProgramming
TA

Timothy Adu

Senior embedded consultant based in Copenhagen, working with startups and product teams on connected hardware.

About Timothy →