1. Introduction
1.1 Background and Significance
With the widespread application of embedded systems across various industries, C language has long dominated embedded development due to its efficiency, direct hardware access, and cross-platform advantages. However, as the complexity of system functions increases, the traditional procedural programming model often leads to chaotic code structure, high coupling, and maintenance difficulties. The introduction of Object-Oriented Programming (OOP) concepts has brought new design ideas to software engineering, effectively improving code reusability, maintainability, and scalability through encapsulation, inheritance, and polymorphism.
1.2 Challenges and Motivation
In embedded development, with limited resources and high real-time requirements, traditional C programming models have limitations in module division, code reuse, and interface design. Although pure C language does not have built-in object-oriented syntax, we can simulate concepts similar to classes and objects using techniques such as structures and function pointers, making system design more hierarchical and flexible. This article aims to explore how to implement object-oriented programming in C language within an embedded environment to solve complex problems in practical engineering.
2. Basic Concepts of Object-Oriented Programming
2.1 Core Features of Object-Oriented Programming
Object-oriented programming primarily includes the following three basic features:
-
Encapsulation: Bundling data and the operations on that data together, exposing only necessary interfaces to the outside, protecting internal implementations from arbitrary modifications.
-
Inheritance: Achieving code reuse and hierarchical design by reusing the properties and behaviors of existing objects in new objects, making the system easier to extend.
-
Polymorphism: The same interface can exhibit different behaviors based on the different types of objects, supporting dynamic binding, making program design more flexible.
2.2 Encapsulation
C language uses structures to encapsulate code, packaging a series of data and operations together, forming a “class”. Variables created from this structure are referred to as “objects”. For example, if we want to draw a rectangle on a screen, we need to save its top-left starting coordinates, length, and width:
typedef struct _rectangle_t{ int x; int y; int width; int height;}rectangle_t;
Then, there are two ways to create an “object”—static construction and dynamic construction.
Static construction:
void rectangle_init(rectangle_t *rectangle, int x, int y, int width, int height){ if (rectangle == NULL) { return; } rectangle->x = x; rectangle->y = y; rectangle->width = width; rectangle->height = height;}
Essentially, constructing an object is assigning values to the structure object. All values here are passed from the outside, and some objects may have initial values of 0 or a default value. Secondly, this object is created externally and passed in for initialization, which is called static construction.
Dynamic construction:
rectangle_t *rectangle_create(int x, int y, int width, int height){ rectangle_t *rectangle = (rectangle_t *)malloc(sizeof(rectangle_t)); if (rectangle == NULL) { return NULL; } rectangle->x = x; rectangle->y = y; rectangle->width = width; rectangle->height = height; return rectangle;}
Dynamic creation involves malloc being called internally, then initializing and returning. Dynamic creation requires calling a destructor to free:
void rectangle_delete(rectangle_t *rectangle){ free(rectangle);}
It is worth mentioning that dynamic creation can hide object details:
typedef void* rect_t;rect_t rectangle_create(int x, int y, int width, int height){ rectangle_t *rectangle = (rectangle_t *)malloc(sizeof(rectangle_t)); if (rectangle == NULL) { return NULL; } rectangle->x = x; rectangle->y = y; rectangle->width = width; rectangle->height = height; return (rect_t)rectangle;}
This operation allows users not to directly access the structure details, thus avoiding problems caused by direct modifications to the object.
| Construction Method | Memory Creation | Requires Destructor | Can Hide Object |
| Static Construction | Externally Created | No | Cannot Hide Object |
| Dynamic Construction | Internally Created | Yes | Can Hide Object |
Unlike traditional structure usage, the usage of “objects” is to pass the entire structure and use it without needing to understand its detailed implementation. For example:
int rectangle_get_area(rectangle_t *rect){ return rect->width * rect->height;}int rectangle_get_perimeter(rectangle_t *rect){ return (rect->width + rect->height) * 2;}
To the outside, we do not need to know the specific details of the “object”; we just need to call their functions to achieve the corresponding functionality. Therefore, these functions are also referred to as “methods”.
Below is a summary of their exclusive designations:
| Object-Oriented | Class | Object | Attribute | Method |
| Procedural | Structure | Structure Variable | Structure Member | Function |
2.3 Inheritance
When different structures (classes) have common characteristics, we can extract that part into a structure (parent class) for easy reuse. For example:
typedef struct _rectangle_t{ int x; int y; int width; int height;}rectangle_t;typedef struct _circle_t{ int x; int y; int radius;}circle_t;
At this point, we can create a new structure (parent class) where the previous structures are the first members of the new structure; this is inheritance.
typedef struct _shape_t{ int x; int y;}shape_t;typedef struct _rectangle_t{ shape_t shape; int width; int height;}rectangle_t;typedef struct _circle_t{ shape_t shape; int radius;}circle_t;
Why do we need to place it first? Because when we pass the object of the subclass to the parent class, since the parent class is placed first in the subclass, their pointer values are the same. Thus, the methods of the parent class can be seamlessly used by the subclass, similar to the concept of “combining like terms” in mathematics.
2.4 Polymorphism
In C++, polymorphism involves a very important concept, which is the virtual function. In C++, there is a keyword called virtual, and the functions it modifies are called virtual functions. When a function in the parent class is a virtual function, the subclass can override the virtual function. The virtual function in the parent class is equivalent to a “default functionality”, mainly for different subclasses to implement this method by overriding. Similarly, there are pure virtual functions, which are not implemented in the parent class and must be overridden by subclasses after inheritance. Unlike “weak definitions”, virtual functions are dynamically determined at runtime, while “weak definitions” are determined at the compilation stage as either “weak functions” or non-“weak functions”.
class Animal {public: // Virtual function virtual void sound() { cout << "Generic sound"; } // Pure virtual function virtual void eat() = 0;};
In C language, we implement this functionality through function pointers.
#include <stdio.h>// Base class (simulating virtual function)typedef struct _animal_t { void (*sound)(struct _animal_t*); // Function pointer simulating virtual function} animal_t;void animal_sound(animal_t *animal){ animal->sound(animal);}// Derived class Dogtypedef struct _dog_t { animal_t base; // Base class as the first member int age;} dog_t;// Derived class Cattypedef struct _cat_t { animal_t base; char* color;} cat_t;void animal_init(animal_t* animal){ // Default call to parent class's function animal->sound = animal_sound;}// Specific implementation functionvoid dog_sound(animal_t* animal) { dog_t* dog = (dog_t *)animal; // Type conversion printf("Dog (age=%d) barks!\n", dog->age);}void dog_init(dog_t * dog, int age){ animal_init(&dog->base); dog->age = age; // Overridden parent class method dog->base.sound = dog_sound;}void cat_sound(animal_t* animal) { cat_t * cat = (cat_t *)animal; printf("Cat (color=%s) meows!\n", cat->color);}void cat_init(cat_t * cat, char* color){ animal_init(&cat->base); cat->color = color; // Overridden parent class method cat->base.sound = cat_sound;}int main() { dog_t myDog; cat_t myCat; dog_init(&myDog, 3); cat_init(&myCat, "blue"); animal_sound((animal_t*)&myDog); animal_sound((animal_t*)&myCat); return 0;}
This achieves the functionality; if the function pointer points to NULL during initialization, it is equivalent to a pure virtual function in C++.
In summary, virtual functions are the key mechanism for achieving polymorphism, allowing programs to flexibly handle different types of objects without prior knowledge of their specific types.
2.5 Summary
To reiterate in my own words, object-oriented programming primarily includes the following three basic features:
-
Encapsulation: Bundling similar types of data together.
-
Inheritance: Extracting common parts to avoid repeated writing.
-
Polymorphism: Extracting common functionalities to the parent class, allowing stable parent class method calls without concern for subclasses.
3. How to Use in Actual Projects
First, we need to understand why to use object-oriented programming instead of procedural programming. In cases where project complexity is relatively simple, procedural programming not only has simpler code logic but also requires fewer calls. However, in cases where project functionality is complex and involves many functions and peripherals, we must learn to reuse. How can we extract useful functional parts in each project and easily transfer them to the next project? How can we achieve decoupling in the code, allowing for minimal changes when new requirements arise? This is where object-oriented programming excels.
3.1 Project Decoupling
When receiving a project, we need to analyze its functionalities. After analysis, I generally adopt a “neural conduction” analytical thinking. In a project, we can analyze it by analogizing biological “neural conduction”. In biology, neural transmission is divided into “receptors”, “afferent nerves”, “neural centers”, “efferent nerves”, and “effectors”.
In the project, list the peripherals involved and determine whether they belong to “receptors” or “effectors” through functionality. Sorting out a series of conduction pathways helps clarify the logic of the project.
For example:
Project Name: Portable Flashlight Project
Project Requirements:
1. Under shutdown, long press the button to turn on the light; under power on, long press to shut down.
2. Under power on, single press to switch modes: low brightness, medium brightness, high brightness, and 20hz flash mode.
3. When the temperature reaches a specified value, it automatically switches to low brightness mode. When the temperature drops to a certain specified value, medium and high brightness can be used.
4. When the voltage is lower than a specified value, the indicator light will flash at intervals of 0.5 seconds.
5. When charging, the indicator light will flash at intervals of 1 second. When fully charged, it stays on.
Thought Analysis:
The peripherals used in this project are: button (GPIO input), light (GPIO output), temperature sensor (one-wire), voltage reading (ADC), charging detection (GPIO input), indicator light (GPIO output).
Functional Classification:
Receptors: Button (transmitting button press, release, long press, single click, double click events), temperature sensor (transmitting temperature values), voltage reading (transmitting voltage values), charging detection (transmitting whether it is charging).
Effectors: Light (receiving brightness and flash interval), indicator light (receiving flash interval).
Neural Conduction Chain:
1. Button->transmit button event->neural center->transmit flash interval and brightness->light
2. Charging detection->transmit whether charging->neural center->transmit flash interval->indicator light
3. Voltage reading->transmit power->neural center->transmit flash interval->indicator light
4. Temperature sensor->transmit temperature->neural center->transmit flash interval and brightness->light
Priority:
2>3
4>1
The higher priority is for lower-level neural centers, similar to the knee-jerk reflex. There is no need to go through the brain; a reaction can be made directly in the gray matter of the spinal cord.
Summary of the Neural Conduction Chain:
We find that common parts can be merged. However, the merging method of the first logical chain differs slightly from that of the second. The first chain has a lower-level neural center that filters the logic provided by the upper-level neural center through the temperature sensor. The second chain shows a clear logical dependency between charging and voltage reading, so it is considered in a larger neural center.
By thinking according to the above “neural transmission” model, we can then create objects for “receptors”, “effectors”, and “neural centers”, and implement message transmission through message queues or publish-subscribe mechanisms. In the future, even if new functionalities are added, it will be clear where to add “receptors”, “effectors”, or “neural centers”. Additionally, “receptors” and “effectors” peripherals can also be reused by creating multiple objects through object-oriented programming.3.2 Cross-Platform Component Modularity
When completing a project, we often have a lot of code logic that can be reused. For example, the LED lighting effects and button state machines mentioned in section 3.1 are commonly used methods. However, if this part of the logic is written in a procedural manner, the decoupling is poor, and using global or static variables can lead to problems when trying to use them multiple times with different peripherals in the same project. However, object-oriented programming effectively solves this problem. It packages the environment; if we need to use global or static variables, we can try placing them inside a class as an attribute. Each time an object is created, it is entirely independent. Let’s verify the high reusability of object-oriented programming with an example:
Project Name: ADC Multi-Channel Voltage Acquisition
Project Requirements: Use sliding average filtering to filter the multi-channel voltages acquired by the ADC to obtain smoother output. First, let’s try writing the sliding average filter algorithm in a procedural manner:
#define BUFFER_SIZE 10int moving_average_filter(int input) { static int index = 0; static int buffer[BUFFER_SIZE] = { 0 }; int sum = 0; // Update data buffer[index] = input; // Calculate total sum for (int i = 0; i < BUFFER_SIZE; i++) { sum += buffer[i]; } // Update index position index = (index + 1) % BUFFER_SIZE; // Return average value return sum / BUFFER_SIZE;}
In this case, using object-oriented programming with static variables and macro constants can work for single-channel filtering, but not for multi-channel filtering.
For the above situation, if we use an object-oriented approach:
#include <stdlib.h>typedef struct _maf_t { int index; int *buffer; int buffer_size;} maf_t;// Dynamically create objectmaf_t* maf_create(int buffer_size) { maf_t* maf = (maf_t*)malloc(sizeof(maf_t)); if (maf == NULL) { return NULL; } maf->buffer = (int*)malloc(sizeof(int) * buffer_size); if (maf->buffer == NULL) { free(maf); // Free previously allocated memory return NULL; } maf->index = 0; maf->buffer_size = buffer_size; // Initialize buffer_size return maf;}// Destroy objectvoid maf_delete(maf_t* maf) { free(maf->buffer); // Free buffer free(maf); // Free the structure itself}int maf_run(maf_t* maf, int input) { int sum = 0; // Update data maf->buffer[maf->index] = input; // Calculate total sum for (int i = 0; i < maf->buffer_size; i++) { sum += maf->buffer[i]; } // Update index position maf->index = (maf->index + 1) % maf->buffer_size; // Return average value return sum / maf->buffer_size;}
Thus, we can create multiple independent filtering objects within the same project.
maf_t* adc1_filter = maf_create(10);maf_t* adc2_filter = maf_create(10);int filtered_adc1 = maf_run(adc1_filter, adc1_value);int filtered_adc2 = maf_run(adc2_filter, adc2_value);
This approach ensures high reusability of the code and decouples the logic, allowing the filtering logic to be used independently of specific ADC channels.
4. Conclusion
In embedded development, although C language is essentially a procedural language, we can simulate object-oriented features such as encapsulation, inheritance, and polymorphism through techniques like structures and function pointers. This approach can effectively enhance the modularity of the code, reduce coupling, and improve maintainability and reusability.
For resource-constrained, real-time demanding embedded systems, appropriately using object-oriented concepts helps build efficient, stable, and easily extensible software architectures. Through object-oriented design, we can organize code more clearly, making the system more flexible and extensible, while also easier to adapt to future changes in requirements.
Therefore, in embedded development, we should fully utilize the features of C language, combined with object-oriented concepts, to construct efficient and maintainable embedded code.