Principles of Embedded Software Design

The theory of object-oriented development is abundant and can also be referenced for embedded C software development. My knowledge is limited, and this is just a humble attempt to spark ideas.

1 Design Principles

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

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

DIP Dependency Inversion Principle High-level modules should depend on abstractions rather than 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 powerful interfaces for all dependent interfaces to call.

LKP Least Knowledge Principle A submodule should have the least knowledge about other modules.

Principles of Embedded Software Design

Personal thoughts on WeChat public account 【Embedded Systems】. 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 it to change. The Single Responsibility Principle is the simplest yet the hardest principle to apply; it requires splitting large modules by responsibility. 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 is only one reason for it to change, not merely understanding that a module implements only one function; this applies to 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 possibly multiple motivations to change a submodule, it indicates that this module has multiple responsibilities. Sometimes it is hard to notice this point, as we tend to think of responsibilities in groups. For instance, the Modem program interface looks 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, while the second 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 a host, then they need to be separated. If it is a socket, where the connection state is inherently tied to data interaction, then changes in the application always lead to changes in both responsibilities simultaneously, and there is no need to separate them; forcing a split would introduce complexity.

2.2 Separating Coupling

Coupling multiple responsibilities is undesirable, but sometimes unavoidable due to hardware or operating system reasons, leading to the coupling of elements that ideally should not be coupled. However, for the application part, efforts should be made to separate and decouple. Much of the early module design in software is about discovering responsibilities and separating them from each other.

3 Open-Closed Principle (OCP)

If we expect the software we develop will not be abandoned after the first version, we must keep this in mind. What kind of design can remain relatively stable in the face of changing requirements, allowing the system to continue releasing 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 the smell of rigidity. OCP suggests that the system should be refactored so that future changes only require adding new code without modifying existing, 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 violated; existing source code is not allowed to be modified.

These two characteristics seem contradictory; the usual way to extend module behavior is to modify the module’s 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 modifying its source code? The key is abstraction.

3.2 Abstract Isolation

In object-oriented design techniques such as C++, we can create fixed abstractions that can describe a set of possible behaviors. This abstraction is the abstract base class, and the possible behaviors are represented as derived classes. Modules can operate on abstractions; since modules depend on a fixed abstraction, they can be closed for modification. At the same time, by deriving from this abstraction, the behavior of this module can be extended.

The polymorphic features of object-oriented languages make this easy to achieve, but how can we do this in embedded C? A function interface or feature should not directly solidify related logic but rather keep the specific implementation details open for extensibility, facilitating later functionality additions without affecting other functionalities.

3.3 Violating OCP

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

If we use C language and adopt a procedural approach that does not follow OCP, we might have a data structure where the first member is the same, but the remaining members differ. Each structure’s first member is a type code indicating whether the structure represents a circle or a square. The DrawAllShapes function traverses an array, where the elements are pointers to these data structures, calling 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 and extend the switch to include triangles. In fact, every time a new shape type is added, this function must be changed. In such an application, adding a new shape type means finding all functions containing the aforementioned switch (or if-else statements) and adding conditions for the newly added shape type in each one.

In embedded data streams, data parsing is a common scenario. If a novice developer creates a universal long function to complete all parsing functions, different types of data parsing errors may occur:

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, processed using switch-case, similar to DrawAllShapes; subsequent extensions 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 Following OCP

After adjusting the previous 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 a new command, just add it 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 general table-driven method. Refer to the WeChat public account 【Embedded Systems】 article Design Patterns in Embedded Software (Part II) Chapter 4. OCP can also be implemented using callback functions, where the lower level remains unchanged while the application layer expands to implement differentiated portions.

3.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 it is impossible to be completely closed, we must strategically address this issue. This means that designers must choose which changes the module should be closed off from. They must first estimate the most likely changes and then design to isolate those changes, which requires some industry experience and predictive ability.

Following OCP can also be costly; unrestrained abstraction can consume development time and code space while increasing design complexity. For example, handle_cmd_body_v1 is simpler than handle_cmd_body_v2. If the requirements are clear or hardware resources are limited, the latter is more reasonable from a design principle perspective, but the former is more straightforward and suitable for resource-constrained and fixed-demand scenarios. For embedded software, we 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 tends to create high-level modules that depend on low-level modules, where strategies depend on details, which 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 mindset. A well-designed object-oriented program has its dependency structure inverted compared to traditional procedural design.

If high-level modules depend on low-level modules, changes in low-level modules will directly impact 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.” (Do not call us; we will 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 high-level module requirements. Through this inverted interface ownership, high-level reuse is satisfied in any context. In fact, even in embedded software, the focus of development is on changing high-level modules, which are generally similar upper-layer application software running on different hardware environments, so high-level reuse can significantly improve software quality.

4.2 Example Comparison

Assume software for controlling a furnace regulator reads the current temperature from an external channel and sends commands to control the furnace’s heating on or off. The data flow structure is roughly as follows:

// Temperature regulator scheduling algorithm
// Detecting current temperature outside the set range, turn on or off the furnace heater
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, making this code (control algorithm) not reusable across different hardware. Although the code is small and easy to implement, it appears to cause little harm. If a complex temperature control algorithm needs to be ported to a different platform, or if requirements change to issue additional alerts during temperature anomalies, what then?

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 thermometer or furnace details. The algorithm achieves better reusability, as it does not depend on details.

The Dependency Inversion Principle can especially address the issues of hardware changes frequently affecting software reuse in embedded software. For example, in the development of a pedometer for a fitness tracker, if a procedural development approach follows a top-down calling relationship, changing the acceleration sensor later due to material reasons would force the upper layers to modify the 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, leading to extensive switch-case replacements.

app -> drv_pedometer_a
// The calling relationship is entirely replaced by
app -> drv_pedometer_b

If we adopt Dependency Inversion, both depend on abstractions:

app -> get_pedometer_interface
// 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 abstract layer. All development is designed around get_pedometer_interface, so hardware changes will not affect the reuse of upper-layer software. This implementation is actually a common proxy pattern. Refer to the WeChat public account 【Embedded Systems】 article Design Patterns in Embedded Software (Part I) Chapter 2.2, where achieving abstract isolation is done through function pointers.

4.3 Conclusion

The dependency structure created by traditional procedural design is strategy-dependent on details, making 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 reflects object-oriented design thinking.

The Dependency Inversion Principle is a fundamental mechanism for achieving the benefits claimed by object-oriented technology. Correct application is essential for creating reusable frameworks, and it is also critical for building resilient code in the face of change; since abstractions and details are isolated from each other, the code is easier to maintain.

5 Interface Segregation Principle (ISP)

Use multiple specialized interfaces instead of a single general-purpose interface; that is, clients should not depend on interfaces they do not need. In object-oriented development, if the base class contains interfaces that are not needed, the originally specific demand-extended interface becomes general, causing 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”. In the context of embedded C, 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 separately to those callers without forcing other callers to depend on interfaces they will not use. This is similar to shopping; there is no need to bundle sales; just buy what you need. 2. If we understand “interface” as a single API interface or function, if some callers only need part of the functionality, 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 have 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 need those methods, when other clients request changes to those methods, it will affect the client program. 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 additional processing within functions, leading to interface redundancy, which is not friendly to different versions of callers (if the functional iteration upgrade is not an issue, avoiding differences between versions is a peer relationship). The cost and impact of changes become unpredictable, and the risks associated with changes increase. Modifying a function that is unrelated to oneself can also have repercussions; superficially modifying Function A may lead to Function B malfunctioning, which is akin to “when the city gate catches fire, the fish in the moat suffer.” This makes unit testing coverage difficult to grasp.

At the module level, unrelated interfaces can be masked using precompiled macros, saving code space; at the function level, when expanding new functionalities, new interfaces can be created, reimplementing an extension version or v2 with the same functionality as the original interface, avoiding merging through parameters unless it is clear that the two are in a sequential relationship rather than a parallel one.

WeChat public account 【Embedded Systems】 suggests that submodules be divided into multiple C files, with internal functions marked as static. Only global functions used internally can be declared as extern within the C file, and should not be added to the header file. Functions with similar functionality but different application scenarios can be grouped together, with mutual references in the comments explaining the differences. More coding specifications and techniques can be referenced in Embedded C Coding Standards and Code Maintenance.

6 Least Knowledge Principle (LKP)

The Law of Demeter, abbreviated as 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 the dependent subfunction is, it should encapsulate the logic internally as much as possible. In simple terms, when using a submodule, there is no need to be concerned about its internal implementation, and as few API interfaces should be called as possible.

For example, if operation A requires sequentially calling four interfaces 1-2-3-4, and operation B requires calling them in the order 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 the interfaces and encapsulating actions A and B, executing specific details internally and hiding them from the outside world, so that external usage does not require attention.

The intent of the Least Knowledge Principle (Demeter’s Principle) is to reduce coupling between modules, allowing better information hiding and less information overload, solidifying and encapsulating some information. However, excessive closure has drawbacks; once customization requirements change, if a new operation C requires the order 4-3-2-1, a new interface needs to be extended.

7 Refactoring

Refactoring is a continuous process, akin to cleaning the kitchen after a meal. The first time, skipping the cleaning might seem faster, but without cleaning the dishes and dining environment, the time spent preparing the next day will be longer. This may discourage cleaning altogether. Indeed, skipping cleaning allows for quicker meals, but chaos gradually accumulates. Ultimately, a lot of time will be spent searching for suitable cooking utensils, scraping off hardened food residues from dishes, and washing them clean. Meals are required daily, and neglecting cleaning does not genuinely speed up cooking; pursuing speed blindly will eventually lead to failure. The purpose of refactoring is to clean the code daily, maintaining its cleanliness.

Principles of Embedded Software Design

Most software development occurs in this chaotic iterative state, where all principles and patterns hold no value against messy code. Before applying various design principles and patterns (such as Design Patterns in Embedded Software (Part I) and Design Patterns in Embedded Software (Part II)), one must first learn to write clean code.

8 Reflections

There are many object-oriented design principles, 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 programming, following a top-down approach, which has its drawbacks when requirements are variable; it is characterized by speed but disorganization. Therefore, refactoring is essential; it improves the internal structure of the code without changing external behavior. However, determining what style is appropriate can refer to the five rules mentioned earlier.

Modern embedded software development rarely resembles the past, where a byte was divided into eight parts for use. With sufficient resources, embedded application development can appropriately reference object-oriented methods to achieve high-quality software; there are two specific approaches: function pointers and abstract isolation. “There is no problem that cannot be solved by adding an abstraction layer; if there is, add another layer.”

Leave a Comment