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.
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).
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 **************************************************************/
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?
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.
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.
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.
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.
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.