Principles of Embedded Software Design

There are many theories of object-oriented development that can also be referenced in embedded C software development. My knowledge is limited, and this is just a humble attempt to inspire.

1. Design Principles

SRP Single Responsibility Principle – Each function or functional block should have only one responsibility, and only one reason should cause it to change.

OCP Open-Closed Principle – Open for extension, but closed for modification.

DIP Dependency Inversion Principle – High-level modules should not depend on low-level modules. Both should depend on abstractions (i.e., interfaces), and details should depend on abstractions.

ISP Interface Segregation Principle – Interfaces should be as fine-grained as possible, and methods should be minimal; do not try to create a powerful interface for all dependent interfaces to call.

LKP Least Knowledge Principle – A sub-module should have minimal knowledge of other modules.

Principles of Embedded Software Design

On WeChat public account 【Embedded Systems】, my personal thoughts on 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 to change. The Single Responsibility Principle is the simplest yet hardest principle to apply; it requires dividing large modules by responsibility. If a sub-module 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 division is based on having only one reason that affects its change, not simply understanding that a module only implements one function, which is also true at the function level.

1. What is Responsibility?

In SRP, responsibility is defined as “a reason for change.” If there are multiple motivations to change a sub-module, it indicates that this module has multiple responsibilities. Sometimes it is challenging to notice this, as we tend to think of responsibilities in groups. For example, in the modem program interface, most people would think that this interface looks reasonable.

//interface Modem violates SRP
void connect();
void disconnect();
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 connections, while the send and recv functions handle data communication.

Should these two responsibilities be separated? It depends on how the application changes. If the application change affects the connection functions, such as hot-swapping peripherals with the host, where data transmission occurs after connecting, then it needs to be separated. If it is a socket, the connection state is inherently tied to data interaction, and changes in the application will always lead to changes in both responsibilities simultaneously, then there is no need to separate them; forcibly splitting them may introduce complexity.

2. Separation of Coupling

Coupling of multiple responsibilities is not desired, but sometimes it is unavoidable. Some hardware or operating system-related reasons force the coupling of things that are unwilling to be coupled. However, for the application part, we should strive to decouple. Much of the early module design in software is about discovering responsibilities and separating those responsibilities from each other.

3. Open-Closed Principle (OCP)

If we expect that the software developed will not be discarded after the first version, we must keep this point in mind. What kind of design can face changes in demand while maintaining relative stability, allowing the system to continuously release new versions after the first version? 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, leading to changes in related modules, then the design has a rigidity smell. OCP suggests that the system should be refactored so that future changes to the system only require adding new code without modifying already functioning code.

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 requirements.
  2. Closed for modification – The source code of the module cannot be infringed upon; modifications to existing source code are not allowed.

These two characteristics may seem contradictory; extending the behavior of a module typically involves modifying its source code. 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 altering its source code? The key is abstraction.

2. Abstract Isolation

In object-oriented design techniques such as C++, one can create fixed yet capable abstract entities that describe a set of potentially arbitrary behaviors. This abstract entity is the abstract base class, and this set of arbitrary behaviors manifests as potential derived classes. Modules can operate on abstract entities; since the module depends on a fixed abstract entity, it can be closed for modification. Simultaneously, by deriving from this abstract entity, the behavior of this module can be extended.

While 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 solidify related logic but rather open up the specific implementation details to be extensible, facilitating the addition of functionality later without affecting other functionalities.

3. Violating OCP

An application needs to draw circles and squares on the window, and circles and squares will be created in the same list while maintaining the appropriate order. The program traverses the list in order and draws all the circles and squares.

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 the function is to be capable of drawing a list that includes triangles, it must change the function, expanding the switch to include triangles. In fact, every time a new shape type is added, this function must be modified. In such an application, adding a new shape type means finding all functions that contain the above switch (or if-else statements) and adding checks for the newly added shape type in every instance.

In embedded data streams, data parsing is a common scenario. If a novice developer is involved, it might be a universal long function that completes all parsing functionality. For example, different types of data parsing errors:

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 public account 【Embedded Systems】, different types of data are concentrated together, using switch-case processing, just like the previous 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;
}

After adjusting the above data parsing example:

// Complying with OCP principle
// WeChat public 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 instructions, just add it here without affecting the 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 overall achieves the effect of OCP, allowing the extension of cmd_handle_table_map without modifying handle_cmd_body_v2. This pattern is actually a generic table-driven method. For more on this, refer to the WeChat public account 【Embedded Systems】 article Design Patterns in Embedded Software (Part 2), Chapter 4. OCP can sometimes also be achieved via callbacks, where the underlying remains unchanged while the application layer itself extends the differentiated parts.

5. Strategic Closure

The above examples are not 100% closed. In general, no matter how “open-closed” a module is, there will always be some changes that cannot be closed off, and there is no model that fits all situations. Since complete closure is impossible, a strategic approach must be taken when addressing this issue. This means that designers must choose which changes the module should be closed to. They must first estimate the most likely changes to occur and then construct abstractions that isolate these changes, requiring designers to possess some industry experience and predictive capability.

Following OCP can also be costly. Recklessly abstracting from a software perspective, creating abstract isolation takes development time and code space, while also increasing the complexity of software design. For instance, the previous handle_cmd_body_v1 is more straightforward and suitable for scenarios with limited resources and fixed requirements, while handle_cmd_body_v2 is more reasonable from a design principle perspective. For embedded software, one should extract abstractions from frequently changing parts of the program.

4. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules (callers) should not depend on low-level modules (called). Both should depend on abstractions.

Structured program analysis and design always tend to create high-level modules that depend on low-level modules, with the strategy relying on the details of the structure. This is the structure of most embedded software, from the business layer to the component layer, and down to the driver layer, reflecting a top-down design thinking. A well-designed object-oriented program has its dependency structure “inverted” compared to traditional procedural design.

When high-level modules depend on low-level modules, changes in low-level modules will directly affect high-level modules, forcing them to make changes in sequence, making it difficult to reuse high-level modules in different contexts.

1. Inverted Interface Ownership

“Don’t call us, we’ll call you.” Low-level modules implement interfaces declared in high-level modules and are called by high-level modules, meaning low-level modules implement functionality according to the needs of high-level modules. Through this inverted interface ownership, high-level modules can be reused in any context.

In fact, even in embedded software, the focus of development is often on changing high-level modules, which are generally similar upper-level application software running on different hardware environments. Therefore, the reuse of high-level modules can significantly enhance software quality.

2. Example Comparison

Assume the software controls a furnace regulator, reading the current temperature from an external channel and sending commands to control the furnace’s heating on or off through another channel. The data flow structure is roughly as follows:

// Temperature regulator scheduling algorithm
// Detects if the current temperature is out of 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 control algorithm code unusable across different hardware. Although the code is minimal and the algorithm implementation is easy, it does not seem to cause significant harm. If a complex temperature control algorithm needs to be ported to different platforms or requires additional alerts when temperature anomalies occur, the situation becomes complicated.

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 depend on any specific details of thermometers or furnaces. The algorithm now possesses better reusability, as it does not depend on details.

The Dependency Inversion Principle can particularly address the issues of frequent hardware changes affecting software reuse in embedded software. For example, in a motion bracelet pedometer, if developed in a procedural manner following a top-down call relationship, later changes to the accelerometer due to material reasons would require modifications at the upper level, especially without internal encapsulation, where the application layer directly calls the driver interfaces, necessitating replacements one by one. If the sensors used later are uncertain, the software would need to automatically adjust based on sensor characteristics, requiring a large number of switch-case statements for replacements.

app  -> drv_pedometer_a
// All call relationships replaced with
app  -> drv_pedometer_b

If dependency inversion is adopted, 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 abstract interfaces, while upper-level business also relies on abstraction layers. All development revolves around get_pedometer_interface, ensuring that hardware changes do not affect the reuse of upper-level software. This implementation is essentially a common proxy pattern. For more on this, refer to the WeChat public account 【Embedded Systems】 article Design Patterns in Embedded Software (Part 1), Chapter 2.2, where realizing abstract isolation is achieved through function pointers.

3. Conclusion

The dependency structure created by traditional procedural program design relies on details, making strategies susceptible to changes in those details. In fact, it does not matter which language is used to write the program. 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; because abstractions and details are isolated from each other, the code is also easier to maintain.

5. Interface Segregation Principle (ISP)

Use multiple specialized interfaces rather than a single general-purpose interface. Clients should not be forced to depend on interfaces they do not need. In object-oriented development, when a base class contains interfaces that are not needed, the originally specific demand for extending interfaces becomes general, resulting in all derived classes needing to implement meaningless interfaces, leading to interface pollution.

1. Interface Pollution

The focus of the Interface Segregation Principle is on the word “interface.” In the context of embedded C, there are two interpretations:

The first interpretation: If “interface” is understood as a set of API interfaces, it can be a series of interfaces for a sub-function. If some interfaces are only used by some callers, those interfaces need to be isolated and provided separately for those callers, without forcing other callers to depend on interfaces they would not use. It is similar to shopping, where bundling sales is unnecessary; one should only buy what they need.

The second interpretation: If “interface” is understood 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 function they need. That is, a function should not take too many parameters; it is better to split into multiple similar interfaces to simplify calls, rather than providing a universal interface requiring unrelated parameters.

2. Risks and Solutions

If a program depends on methods it does not use, it faces changes brought about by those unused methods, inadvertently leading to coupling between all related programs. In other words, if a client program depends on methods it does not use, but other client programs do, then when other clients request changes to those methods, it affects the client program. Coupling should be avoided as much as possible by separating interfaces.

In embedded C, with iterative upgrades, new functionalities will also be expanded, either by directly adding parameters to functions or increasing additional processing within functions, leading to interface redundancy, which is unfriendly to callers of different versions. If the functionality is simply an iteration, it is acceptable; however, avoiding differences across versions should maintain a peer relationship. The cost and impact of changes become unpredictable, and the risks associated with changes also increase. Changing a function unrelated to one’s own functionality may also produce effects; superficially, modifying functionality A could lead to exceptions in functionality B, causing a “fire in the city gate, with fish in the moat” scenario, which is difficult to manage in unit testing coverage.

At the module level, unrelated interfaces can be masked using precompiled macros, thus saving 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, avoiding merging through parameter passing unless it is clear that the two are in a progressive rather than parallel relationship.

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 dependencies. Regardless of how complex the logic of a dependent sub-function is, it should encapsulate that logic internally as much as possible. In simple terms, when using a sub-module, one should not need to focus on its internal implementation, calling as few API interfaces as possible.

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 improved by merging interfaces, encapsulating actions A and B, executing specific details internally, and hiding them from the outside, allowing external users to use them without concern.

The intention behind the Least Knowledge Principle (Demeter’s Principle) is to reduce coupling between modules, enhancing information hiding and minimizing information overload. However, excessive closure also has drawbacks; if a customization requirement changes, and a new operation C requires 4-3-2-1, a new interface must be extended.

7. Refactoring

Refactoring is a continuous process, akin to cleaning the kitchen after a meal. Skipping the cleaning after the first meal may speed things up, but without cleaning the dishes and the dining environment, preparation time for the next day will be longer. This will again discourage cleaning. Indeed, skipping cleaning can hasten the meal, but mess accumulates over time. Ultimately, one will spend a lot of time searching for suitable cooking utensils, scraping hardened food residues off dishes, and washing them clean. Meals are to be eaten daily; neglecting cleaning work does not genuinely speed up cooking. A one-sided pursuit of speed will eventually lead to failure; haste makes waste. The purpose of refactoring is to clean the code daily and maintain its cleanliness.

8. Reflections

There are many design principles in object-oriented design, with various general guiding rules based on class inheritance, encapsulation, and polymorphism. However, these design principles do not fully apply to embedded C. Embedded C is structured program design, following a top-down approach, which has its drawbacks when requirements change. Its characteristic is speed but disorder. Therefore, refactoring is essential to improve the internal structure of the code without changing external behavior; what style to modify into can refer to the five rules mentioned earlier.

Nowadays, embedded software development rarely resembles the past, where a byte was divided into eight pieces for use. With sufficient resources, embedded application development can appropriately reference object-oriented methods to achieve high-quality software; two specific solution ideas: function pointers and abstract isolation.

There is no problem that cannot be solved by adding an abstract layer. If there is, add another layer.”

Principles of Embedded Software Design

END

Source: Embedded Systems

Copyright belongs to the original author. If there is infringement, please contact for removal.

Recommended Reading

The Operating System of the Japanese, Almost Dominated the World…

Changing a few lines of code reduces the for loop time from 3.2 seconds to 0.3 seconds

VSCode and SourceInsight, which is better for reading source code?

→ Follow for more updates ←

Leave a Comment

×