There are many theories regarding object-oriented development that can also be referenced for embedded C software development. My knowledge is limited, and this is merely a starting point for discussion.
1 Design Principles
SRP Single Responsibility Principle: Each function or module should have only one responsibility, and only one reason for it to change.
OCP Open-Closed Principle: Open for extension, but closed for modification.
DIP Dependency Inversion Principle: High-level modules should depend on abstractions, not on low-level modules. Details should depend on abstractions.
ISP Interface Segregation Principle: Interfaces should be as fine-grained as possible, and methods should be minimized. Do not attempt to create a powerful interface for all dependent interfaces to call.
LKP Least Knowledge Principle: A submodule should have the least knowledge possible about other modules.

WeChat Official Account 【Embedded Systems】 My personal thoughts: the design principles mainly guide the division of functional modules within a limited scope, serving as a way to improve software reusability and quality.
2 Single Responsibility Principle (SRP)
A function or feature should have only one reason for change. The Single Responsibility Principle is the simplest yet most challenging principle to apply. It requires dividing large modules based on responsibilities. If a submodule has too many responsibilities, it couples those responsibilities together, and a change in one responsibility may weaken or inhibit the module’s ability to fulfill other responsibilities. The basis for division is that there should be only one reason for it to change, which is not simply understood as a module implementing only one function; this applies at the function level as well.
2.1 What is a Responsibility
In SRP, a responsibility is defined as “a reason for change”. If there are multiple motivations to change a submodule, it indicates that the module has multiple responsibilities. Sometimes it is difficult to notice this, as we tend to think of responsibilities in groups. For example, the modem program interface seems reasonable to most people.
//interface Modem violates SRP
void connect();
void disconnect();
void send();
void recv();
However, this interface shows two responsibilities. The first responsibility is connection management, and the second responsibility is data communication. The connect and disconnect functions handle modem connection, while the send and recv functions handle data communication.
Should these two responsibilities be separated? It depends on how the application changes. If changes in the application affect the connection functions, such as hot-swapping peripherals with the host, where data transmission occurs after connection, then they need to be separated. If it is a socket, where the connection state is bound to data interaction, and changes in the application always lead to changes in both responsibilities, then there is no need to separate them; forcing separation may introduce complexity.
2.2 Separation of Coupling
Coupling multiple responsibilities is undesirable, but sometimes unavoidable due to hardware or operating system constraints that force unrelated elements to couple together. However, for the application part, efforts should be made to separate and decouple. Much of the early design of software modules is about identifying responsibilities and separating them.
3 Open-Closed Principle (OCP)
If we expect the software we develop not to be discarded after the first version, we must keep this in mind. What kind of design can face changing requirements while maintaining relative stability, allowing the system to continuously release new versions after the first? The Open-Closed Principle provides us with guidance.
Software entities (modules, functions, etc.) should be extensible but not modifiable. If a change in one part of the program causes a chain reaction that leads to changes in related modules, then the design has a rigidity problem. OCP suggests that the system should be refactored so that future changes only require adding new code without modifying the already functioning code.
3.1 Characteristics
Modules designed according to the Open-Closed Principle have two main characteristics.
1. Open for Extension: The behavior of the module can be extended. When application requirements change, the module can be extended to meet new needs.
2. Closed for Modification: The source code of the module cannot be altered; existing source code should not be modified.
These two characteristics seem contradictory. The usual way to extend the behavior of a module is to modify its source code, and modules that are not allowed to be modified are often considered to have fixed behavior. How can we change the behavior of a module without modifying its source code? The key is abstraction.
3.2 Abstract Isolation
In object-oriented design techniques like C++, we can create fixed abstractions that describe a set of possible behaviors, which are represented by abstract base classes, while the possible behaviors are represented by derived classes. Modules can operate on abstractions, and since they depend on a fixed abstraction, they can be closed for modification. At the same time, by deriving from this abstraction, we can extend the behavior of the module.
While the polymorphic features of object-oriented languages are easy to implement, how can this be achieved in embedded C? A function interface or feature should not directly hard-code related logic but rather expose specific implementation details for extensibility, allowing for future functionality additions without affecting other functionalities.
3.3 Violating OCP
An application needs to draw circles and squares on a window, and both shapes are created in the same list while maintaining the appropriate order. The program traverses the list in order and draws all circles and squares.
If using C language and adopting a procedural approach that does not follow OCP, a set of data structures is created where the first member is the same, but the remaining members differ. The first member of each structure is a type code that identifies whether the structure represents a circle or a square. The DrawAllShapes function traverses the array, which contains pointers to these data structures, and calls the corresponding function (DrawCircle or DrawSquare) based on the type code.
typedef enum
{
CIRCLE,
SQUARE,
} ShapeType;
typedef struct
{
ShapeType itsType;
} Shape;
typedef struct
{
double x;
double y;
} Point;
typedef struct
{
ShapeType itsType;
double itsSide;
Point itsTopLeft;
} Square;
typedef struct
{
ShapeType itsType;
double itsRadius;
Point itsCenter;
} Circle;
void DrawSquare(struct Square*);
void DrawCircle(struct Circle*);
void DrawAllShapes(Shape **list, int n)
{
int i;
Shape* s;
for(i = 0; i < n; i++)
{
s = (Shape*)list[i];
switch(s->itsType)
{
case SQUARE:
DrawSquare((struct Square*)s);
break;
case CIRCLE:
DrawCircle((struct Circle*)s);
break;
}
}
}
The DrawAllShapes function does not comply with OCP. If we want the function to draw a list that includes triangles, we must change this function to extend the switch statement to include triangles. In fact, every time a new shape type is added, this function must be modified. In such applications, adding a new shape type means finding all functions that contain the aforementioned switch (or if-else statements) and adding checks for the new shape type in each.
In embedded data streams, data parsing is a common scenario. If a novice developer creates a universal long function to handle all parsing functionalities, for example, different types of data parsing error samples:
typedef int int32_t;
typedef short int16_t;
typedef char int8_t;
typedef unsigned int uint32_t;
typedef unsigned short uint16_t;
typedef unsigned char uint8_t;
#define NULL ((void *)(0))
// Example violating OCP
// WeChat Official Account 【Embedded Systems】, different types of data are concentrated together, using switch-case processing, similar to DrawAllShapes, subsequent extensions will affect existing functions.
int16_t cmd_handle_body_v1(uint8_t type, uint8_t *data, uint16_t len)
{
switch(type)
{
case 0:
//handle0
break;
case 1:
//handle1
break;
default:
break;
}
return -1;
}
3.4 Complying with OCP
// Complying with OCP
// WeChat Official Account 【Embedded Systems】
typedef int16_t (*cmd_handle_body)(uint8_t *data, uint16_t len);
typedef struct
{
uint8_t type;
cmd_handle_body hdlr;
} cmd_handle_table;
static int16_t cmd_handle_body_0(uint8_t *data, uint16_t len)
{
//handle0
return 0;
}
static int16_t cmd_handle_body_1(uint8_t *data, uint16_t len)
{
//handle1
return 0;
}
// To extend new commands, just add them here without affecting previous ones
static cmd_handle_table cmd_handle_table_map[] =
{
{0, cmd_handle_body_0},
{1, cmd_handle_body_1}
};
int16_t handle_cmd_body_v2(uint8_t type, uint8_t *data, uint16_t len)
{
int16_t ret=-1;
uint16_t i = 0;
uint16_t size = sizeof(cmd_handle_table_map) / sizeof(cmd_handle_table_map[0]);
for(i = 0; i < size; i++)
{
if((type == cmd_handle_table_map[i].type) && (cmd_handle_table_map[i].hdlr != NULL))
{
ret=cmd_handle_table_map[i].hdlr(data, len);
}
}
return ret;
}
Although it is not as elegant as C++ abstraction and polymorphism, it achieves the effect of OCP overall, allowing for the extension of cmd_handle_table_map without modifying handle_cmd_body_v2. This pattern is actually a common table-driven method. OCP can also sometimes adopt a callback function approach, where the underlying remains unchanged, and the application layer itself extends to implement differentiated parts.
3.5 Strategic Closure
The above examples are not 100% closed. Generally, no matter how “open-closed” a module is, there will always be some changes that cannot be closed off. There is no model that fits all situations. Since complete closure is impossible, we must strategically address this issue. This means that designers must choose which changes the module should be closed to. They must first estimate the most likely changes and then construct abstractions to isolate those changes, which requires some industry experience and predictive ability.
Following OCP can also be costly. Recklessly abstracting and isolating can consume development time and code space, while also increasing the complexity of software design. For example, the previous handle_cmd_body_v1 is more straightforward and suitable for scenarios with fixed requirements and limited resources, while handle_cmd_body_v2 is more reasonable from a design principle perspective. For embedded software, it is essential to extract abstractions for parts of the program that change frequently.
4 Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that high-level modules (callers) should not depend on low-level modules (called), but both should depend on abstractions.
Structured program analysis and design tends to create high-level modules that depend on low-level modules, with strategies relying on the details of the structure. This is the structure of most embedded software, from the business layer to the component layer, and then to the driver layer, following a top-down design approach. A well-designed object-oriented program has its dependency structure “inverted” compared to traditional procedural methods.
When high-level modules depend on low-level modules, changes in low-level modules directly affect high-level modules, forcing them to change in sequence, making it difficult to reuse high-level modules in different contexts.
4.1 Inverted Interface Ownership
“Don’t call us, we’ll call you.” (不要调用我们,我们会调用你) means that low-level modules implement interfaces declared in high-level modules and are called by high-level modules, meaning that low-level modules implement functionality according to high-level module requirements. This inverted interface ownership allows for reuse of high-level modules in any context. In fact, even in embedded software, the focus of development is often on high-level modules that change frequently, as similar upper application software runs on different hardware environments, so reusing high-level modules can significantly improve software quality.
4.2 Example Comparison
Assuming software for controlling a furnace regulator reads the current temperature from an external channel and sends commands to another channel to control the heating of the furnace. The data flow structure might look like this:
// Temperature regulator scheduling algorithm
// Detects if the current temperature is outside the set range and turns the furnace heater on or off
void temperature_regulate(int min_temp, int max_temp)
{
int tmp;
while(1)
{
tmp = read_temperature(); // Read temperature
if(tmp < min_temp)
{
furnace_enable(); // Start heating
}
else if(tmp > max_temp)
{
furnace_disable(); // Stop heating
}
wait();
}
}
The high-level intent of the algorithm is clear, but the implementation code is mixed with low-level details. This makes the code (control algorithm) fundamentally non-reusable across different hardware. Although the code is small and the algorithm is easy to implement, it does not seem to cause significant harm. However, if a complex temperature control algorithm needs to be ported to different platforms, or if requirements change to issue additional warnings during temperature anomalies, it becomes problematic.
void temperature_regulate_v2(Thermometers *t, Heaterk *h, int min_temp, int max_temp)
{
int tmp;
while(1)
{
tmp = t->read();
if(tmp < min_temp)
{
h->enable();
}
else if(tmp > max_temp)
{
h->disable();
}
wait();
}
}
This inverts the dependency relationship, making the high-level regulation strategy no longer dependent on any specific details of thermometers or furnaces. The algorithm has better reusability, as it does not depend on details.
The Dependency Inversion Principle can especially address the issues of frequent hardware changes affecting software reuse in embedded software. For example, in a pedometer for a fitness tracker, if the procedural development follows a top-down calling relationship, changing the accelerometer due to material reasons will force the upper layers to modify their code, especially if there is no internal encapsulation and the application layer directly calls the driver interface, requiring replacements one by one. If it is uncertain which sensor will be used later, the software needs to adjust automatically based on sensor characteristics, which would require a lot of switch-case statements for replacements.
app -> drv_pedometer_a
// All calling relationships replaced with
app -> drv_pedometer_b
If we adopt Dependency Inversion, both depend on abstractions:
app -> get_pedometer_interface
// The lower layer depends on abstraction
drv_pedometer_a -> get_pedometer_interface
drv_pedometer_b -> get_pedometer_interface
Dependency Inversion means that different hardware drivers depend on an abstract interface, and the upper layer business also depends on the abstraction layer. All development revolves around get_pedometer_interface, so hardware changes will not affect the reuse of upper-layer software. This implementation is actually a common proxy pattern. Implementing abstract isolation is achieved through function pointers.
4.3 Conclusion
The dependency structure created by traditional procedural programming is strategy-dependent on details, which makes the strategy susceptible to changes in those details. In fact, the programming language used to write the program is irrelevant. Even in embedded C, if the program’s dependency structure is inverted, it embodies object-oriented design thinking.
The Dependency Inversion Principle is a fundamental mechanism for realizing the benefits claimed by object-oriented technology. Correct application is essential for creating reusable frameworks and is also crucial for building resilient code in the face of change; since abstractions and details are isolated from each other, the code is also easier to maintain.
5 Interface Segregation Principle (ISP)
Use multiple specialized interfaces instead of a single general-purpose interface. Clients should not depend on interfaces they do not need. In object-oriented development, if a base class contains interfaces that are not needed, the originally specific requirement for extending interfaces becomes general, leading all derived classes to implement meaningless interfaces, which is known as interface pollution.
5.1 Interface Pollution
The focus of the Interface Segregation Principle is on the word “interface”. At the embedded C level, there are two interpretations:
1. If we understand “interface” as a set of API interfaces, it can be a series of interfaces for a sub-function. If some interfaces are only used by certain callers, those interfaces should be isolated and provided only to those callers, rather than forcing other callers to depend on interfaces they will not use. Similar to shopping, there should be no bundling; buy only what you need.
2. If we understand “interface” as a single API interface or function, and some callers only need part of the function’s capabilities, the function can be split into multiple finer-grained functions, allowing callers to depend only on the specific fine-grained function they need. That is, a function should not take too many parameters; it is better to split it into multiple similar interfaces to simplify calls, rather than providing a universal interface that requires unrelated parameters.
5.2 Risks and Solutions
If a program depends on methods it does not use, it faces changes brought about by those unused methods, inadvertently causing coupling between all related programs. In other words, if a client program depends on methods it does not use, but other client programs do use those methods, then when other clients request changes to those methods, it will affect the client program. Such coupling should be avoided as much as possible by separating interfaces.
In embedded C, as iterations and upgrades occur, new functionalities may be added directly by increasing parameters or adding extra processing within functions, leading to interface redundancy that is not user-friendly for different versions of callers (if the functionality is iteratively upgraded, it is fine; avoiding differences between versions is a peer relationship). The cost and impact of changes become unpredictable, and the risks associated with changes also increase. Changing a function unrelated to oneself may also have an impact; superficially modifying function A may lead to function B’s abnormality, which is a classic case of “when the city gate catches fire, the fish in the moat suffer”. This makes unit test coverage difficult to grasp.
At the module level, unrelated interfaces can be masked using precompiled macros, which also saves code space. At the function level, when extending new functionalities, new interfaces can be created, re-implementing an extension version or v2 of the original interface, rather than merging through parameters unless it is clear that the two are in a progressive relationship rather than a parallel relationship.
WeChat Official Account 【Embedded Systems】 suggests that submodules be divided into multiple C files, with internal functions marked as static. Only global functions used within the module can be declared as extern in the C file, and should not be added to the header file. Functions that are similar but have different application scenarios can be grouped together, with mutual references in comments to explain the differences.
6 Least Knowledge Principle (LKP)
The Law of Demeter (LOD), also known as the Least Knowledge Principle, states that a function should know as little as possible about its dependent sub-functions. Regardless of how complex the logic of the dependent sub-functions is, the logic should be encapsulated internally as much as possible. In simple terms, when using a submodule, there should be no need to focus on its internal implementation, and as few API interfaces as possible should be called.
For example, if executing operation A requires sequentially calling interfaces 1-2-3-4, and executing operation B requires calling 1-2-4-3, the caller needs to know the internal details of the module to use it correctly. This can be avoided by merging interfaces and encapsulating actions A and B, executing the specific details internally, and hiding them from external use.
The intent of the Least Knowledge Principle (Demeter Principle) is to reduce coupling between modules, allowing for better information hiding and less information overload, solidifying and encapsulating some information. However, excessive encapsulation has drawbacks; if customization requirements change, and a new operation C requires 4-3-2-1, a new interface will need to be extended.
7 Refactoring
Refactoring is a continuous process, akin to cleaning the kitchen after a meal. The first time, not cleaning may speed up the meal, but due to the lack of cleaning the dishes and dining environment, the preparation time for the next day will be longer. This may lead to abandoning cleaning altogether. Indeed, skipping cleaning can make meal preparation faster, but mess accumulates over time. Eventually, a lot of time will be spent looking for suitable cooking utensils, scraping off hardened food residues from dishes, and cleaning them. Meals need to be prepared daily, and neglecting cleaning does not truly speed up cooking; pursuing speed at the expense of cleanliness will eventually lead to failure. The purpose of refactoring is to clean the code daily and maintain its cleanliness.

Most software development is based on this chaotic state of iteration, and all principles and patterns have no value for messy code. Before applying various design principles and patterns, one must first learn to write clean code.
8 Reflections
There are many object-oriented design principles, and various general guiding rules exist for class inheritance, encapsulation, and polymorphism. However, these design principles do not fully apply to embedded C. Embedded C is structured programming, following a top-down approach, which has its drawbacks when requirements are variable; it is characterized by speed but disorder. Therefore, refactoring is essential to improve the internal structure of the code without changing external behavior; but what style to modify it into can refer to the previous five rules.
Currently, embedded software development rarely involves splitting a byte into eight parts for use. With sufficient resources, embedded application development can appropriately reference object-oriented methods to achieve high-quality software. The specific solution ideas are twofold: function pointers and abstract isolation. “There is no problem that cannot be solved by adding an abstraction layer; if there is, add another layer.”