Layered Design in Embedded Software Architecture

In actual project development, projects are often developed in parallel, which means that hardware design, low-level software design, and application software design are carried out simultaneously. For example, debugging module drivers on development boards while debugging applications on other platforms and then porting them to the current platform, etc. This also involves how to improve the portability of embedded application software, which will be discussed in detail in the next blog post, so stay tuned. To ensure that the developed application programs have high efficiency and portability across different embedded platforms, a unified interface specification is essential, similar to Android SDK.

The embedded systems mentioned in this article are actually more inclined towards microcontrollers. Because classic Linux+ARM configurations belong to resource-rich, high-end embedded systems, whose operating systems are already quite powerful, making software design almost effortless. Microcontrollers often do not have system software design. Many people would say that microcontrollers are only used for small projects, where the functionality is simple and does not require too many participants, thus software design is not emphasized. This is actually a very naive viewpoint (I thought so when I just graduated). Because currently, the processing speed of MCUs and the functionalities they can achieve can meet the requirements of many projects. Moreover, the software for these projects is becoming increasingly complex. Therefore, focusing on embedded software design for microcontrollers is a necessary consideration in the early stages of projects.

Next, I will specifically explain the layered design approach for microcontroller software development. The example used here is the Freescale K21 MCU and the IAR compiler. The theme of this article is about software layering, which means separating low-level software from application software. Of course, low-level software can be compiled into a static library to be provided to the application. However, there is a problem: if the static library changes, it needs to be recompiled and then provided to the application, which also needs to be recompiled. This is obviously a cumbersome way to handle it. Therefore, we can implement it in another way: the low-level software and application software are two independent bin files, tentatively called libdev.bin and app.bin. Non-operating system embedded systems do not have dynamic libraries such as .so, but the executable file of low-level software can be regarded as the .so of the app. These two bin files are configured through icf, mapped to different flash spaces, and allocated different RAM spaces. Clearly, the relationship between these two bin files is that app.bin will call the implementation of libdev.bin. But how do they relate to each other? This requires a function table to tell app.bin where to call the function implementations in libdev.bin. To achieve this function table, a unified function interface is needed for easier management. This function table can be implemented with a static library .a (libdev.a). The function of libdev.a is to map all interface functions of libdev so that when app calls a certain interface function, it can jump to execute it in libdev.bin. To implement the above ideas, I will explain with a specific example:

1. The function table is implemented using a structure, where the elements of the structure are function pointers.

eg:

struct libdev_ops{
    int (*dev_PortOpen)(int PortNum, char *PortParm);
};

2. In libdev.bin, assign values to the function pointers in the structure.

eg:

void libdev_ops_init(struct libdev_ops *ops){
    ops->dev_PortOpen = dev_PortOpen; // Assign the function address to the corresponding function pointer
}

3. At startup, first enter libdev.bin, then jump to app.bin. An address jump function is needed here.

eg:

struct libdev_ops ops;
void call_app(int addr)
{
    int (*startup)(struct libdev_ops *ops);
    startup = (int(*)(struct libdev_ops *))(addr);
    libdev_ops_init(&ops);
    startup(&ops);
}

In libdev.a

4. Repackage all functions as follows:

int dev_PortOpen(int PortNum, char *PortPara)
{
    return ops->dev_PortOpen(PortNum,PortPara);
}

5. Implement the function that libdev.bin needs to jump to the address.

eg:

void common_startup(struct libdev_ops *libdev_ops)
{
    ......
    ops = libdev_ops;
    // printf is a variadic function and cannot be assigned in step 2, so it is initialized in the static library.
    dev_printf = ops->printf;
    main(); // Jump to the main of app
}

In app.bin

6. Modify the startup address of app.bin program, modify IAR configuration.

Project name — options — linker — library — check override default program entry, and enter common_startup after Entry symbol.

7. Because there are two .bin programs, the icf file needs to be configured, and the addr of call_app(addr) is the address of common_startup function in app.bin. Therefore, after compiling app.bin, check the address of common_startup in the output file’s app.map (since this function is the first to execute, its address is the starting address configured in icf).

8. Then you can normally call this function in the application by including the header file of dev_PortOpen.

Since libdev.bin and app.bin run simultaneously (the implementation of the libdev function called by app.bin is in libdev.bin), RAM and ROM must be divided into two parts without overlap.

Leave a Comment

×