• Home
  • Applying OOP Principles in (Embedded) C

Tips and tricks to creativley apply OOP concepts in (Embedded) C.

blog-thumb


Introduction

When we think of object-oriented programming (OOP), languages like C++, Java, or Python often come to mind. But did you know 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 OOP concepts like encapsulation, abstraction, and even basic inheritance.

In this post, we’ll explore how these approaches can lead to more modular and maintainable firmware. We will also will walk through how to structure your C code to borrow some of the best parts of OOP. 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.


Now let’s look at how we can apply OOP in C. We start by defining the interface (header file).


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())


Here’s how this all this could look:




    /****************************************************************************
    * Title                     :  Air Quality Sensor Driver
    * Filename                  :  drv_air_quality_sensor.h
    * Author                    :  Sherlock Holmes
    * Origin Date/Time (Y/M/D)  :  2024/09/23 23:15:17
    *****************************************************************************/
    /** @file  drv_air_quality_sensor.h
    *  @brief Air Quality Sensor Driver
    */
    #ifndef FILE_DRV_AIR_QUALITY_SENSOR_H
    #define FILE_DRV_AIR_QUALITY_SENSOR_H

    /******************************************************************************
    * MODULE INCLUDES
    *******************************************************************************/

    // Add any necessary includes here


    /******************************************************************************
    * PREPROCESSOR CONSTANTS / MACROS / DEFINES
    *******************************************************************************/

    #define DRV_AIR_QUALITY_SENSOR          drv_air_quality_sensor_get_api()


    /******************************************************************************
    * MODULE TYPEDEFS
    *******************************************************************************/

    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;

    /******************************************************************************
    * PUBLIC FUNCTION PROTOTYPES
    *******************************************************************************/

    drv_air_quality_sensor_t* drv_air_quality_sensor_get_api(void);



    #endif // FILE_DRV_AIR_QUALITY_SENSOR_H


    /*** End of File **************************************************************/


Module Implementation (Source File)

In the source file, we

  • Declare static (private) functions

  • Create a static (private) instance of the driver functions (drv_air_quality_sensor_obj)

  • Define the function that allows access to the module (drv_air_quality_sensor_get_api())

  • Define the private and helper functions.

When following this technique, you create a systematic, consistent flow when implementing your device drivers:



    /****************************************************************************
    * Title                     :  Air Quality Sensor Driver
    * Filename                  :  drv_air_quality_sensor.c
    * Author                    :  Sherlock Holmes
    * Origin Date/Time (Y/M/D)  :  2024/09/23 23:15:17
    *****************************************************************************/
    /** @file  drv_air_quality_sensor.c
    *  @brief Air Quality Sensor Driver
    */

    /******************************************************************************
    * MODULE INCLUDES
    *******************************************************************************/

    #include "drv_air_quality_sensor.h"


    /******************************************************************************
    * MODULE TYPEDEFS
    *******************************************************************************/




    /******************************************************************************
    * PREPROCESSOR CONSTANTS / MACROS / DEFINES
    *******************************************************************************/





    /******************************************************************************
    * PRIVATE FUNCTION PROTOTYPES
    *******************************************************************************/

    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 LOCAL VARIABLES
    *******************************************************************************/

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

    /******************************************************************************
    * PUBLIC FUNCTION DEFINITIONS
    *******************************************************************************/

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

    /******************************************************************************
    * PRIVATE (STATIC) FUNCTION DEFINITIONS
    *******************************************************************************/

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

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

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


    /******************************************************************************
    * HELPER / MISC. (STATIC) FUNCTION DEFINITIONS
    *******************************************************************************/

    static  err_code_t __calibrate_sensor(void)
    {
        // Add your implementation here
    }

    static err_code_t __read_sensor_data(void)
    {
        // Add your implementation here
    }


    /*** End of File **************************************************************/


So how have we implemented OOP principles?

Encapsulation: Hiding Internal Details

In classic OOP languages, private class members prevent direct access from outside code. In our air quality sensor driver, we achieved 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 the drv_air_quality_sensor_get_api() function, enforcing a level of 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 example:

  • Header File (Interface): Declares the drv_air_quality_sensor_t structure and a pointer to functions (init, get_data, etc.).

  • 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 or replace.


Polymorphism: Using Function Pointers for Flexible Behavior

To illustrate how polymorphism could be enforced, consider this example:



    /* main.c */

    #include <stdio.h>
    #include "drv_air_quality_sensor.h"

    #define SENSOR1_ADDRESS = 0x5A
    #define SENSOR2_ADDRESS = 0x6B

    // Create two sensor objects with different I2C addresses
    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)
    {
        // Obtain the driver API (function pointer table)
        drv_air_quality_sensor_t* p_air_quality_sensor_driver = drv_air_quality_sensor_get_api();

            // Prepare structs to hold sensor data
        air_quality_data_t data1 = {0};
        air_quality_data_t data2 = {0};

        // Initialize both sensors
        p_air_quality_sensor_driver->init(&sensor1);
        p_air_quality_sensor_driver->init(&sensor2);

        // Get data from each sensor
        p_air_quality_sensor_driver->get_data(&sensor1, &data1);
        p_air_quality_sensor_driver->get_data(&sensor2, &data2);

        // Print out some dummy results (assuming the driver populates these fields)
        printf("Sensor1 - AQ Index: %d, Temp: %d, Humidity: %d\n",
            data1.aq_index, data1.temperature, data1.humidity);

        printf("Sensor2 - AQ Index: %d, Temp: %d, Humidity: %d\n",
            data2.aq_index, data2.temperature, data2.humidity);

        // De-initialize each sensor
        p_air_quality_sensor_driver->deinit(&sensor1);
        p_air_quality_sensor_driver->deinit(&sensor2);

        return 0;
    }

Both sensors rely on the same driver interface (the p_air_quality_sensor_driver function pointer). This corresponds to the polymorphism principle in OOP, where different “objects” (sensor1, sensor2) share the same method signatures (init, get_data, etc.) but may behave differently internally (e.g., referencing unique addresses or sensor parameters).

If you ever need to swap out or extend the driver, your main application code doesn’t change (beyond including a different driver header or calling a different “get_api” function). This modular structure keeps your codebase more organized and easier to evolve.

Although this particular example uses just one driver implementation, you can imagine having multiple drivers—each returning a different drv_air_quality_sensor_t with its own function pointer set. Your application code could then select which driver to use at runtime, further illustrating the flexibility that OOP-like approaches bring to C-based embedded projects.


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 to prevent confusion.

  • Keep it Simple: Overusing OOP patterns in C can lead to complicated pointer logic, difficulty in debugging and performance overhead — only apply what truly benefits your design. Keep it simple!

  • Function pointer calls involve one or more levels of indirection, and 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, however, these OOP techniques can bring clarity, flexibility, and maintainability to even the most demanding embedded projects.



Ready to Unlock Your Potential as an Embedded Engineer?


I am here to help you!

At Helix Embedded, I'm here to guide you every step of the way. Whether you’re looking for a supportive community, expert-led courses, or personalized coaching, I provide the tools, knowledge, and resources to help you build the confidence you need to excel in the embedded systems industry.

To get started, you can:

  • Join the Helix Embedded Community to connect, collaborate, and get help when you need it. Participate in community projects and level up your embedded experience.

  • Start Learning with my carefully crafted Courses, designed to deepen your expertise.

  • Work with me in 1-on-1 or group coaching sessions and gain real-world project experience to level up your career.

Or we can simply chat about your goals and how I can help you achieve them. Book a free 45-minute consultation with me to get started.