Fundamentals 16 min read

How to Structure Embedded Software: Layered Architecture and Decoupling Patterns

This article explains how to design decoupled embedded software by using a layered architecture, event‑driven mechanisms, pipeline‑filter data flows, and dependency injection, providing concrete C code examples and practical guidelines for each technique to improve maintainability and scalability.

Liangxu Linux
Liangxu Linux
Liangxu Linux
How to Structure Embedded Software: Layered Architecture and Decoupling Patterns

1. System Layering

In resource‑rich embedded projects, a clear layered architecture is the first step toward decoupling. The typical stack from top to bottom includes:

Application layer (business layer) : implements user‑level logic and UI.

Middleware layer (common component layer) : reusable modules.

Operating‑system layer : e.g., an RTOS kernel.

Hardware‑abstraction layer (HAL / BSP) : wraps low‑level hardware with a uniform interface.

Hardware layer : physical peripherals.

Layered architecture diagram
Layered architecture diagram

Each layer has a single responsibility, and communication must flow strictly from one layer to the next; cross‑layer calls are prohibited. Changes in one layer should not affect others, and any unavoidable large changes must be coordinated through compile‑time checks or explicit communication.

2. Submodule Design

2.1 Event‑Driven Architecture

Event‑driven design separates "event producers" from "event consumers" via an event queue, allowing asynchronous response without polling. Typical rules include filtering redundant events, merging high‑frequency events, prioritising critical events, and using static pools on constrained systems.

Design an event‑filtering mechanism to avoid unnecessary or duplicate events. Consider event merging or delayed handling for high‑frequency triggers. Prioritise critical events with dedicated queues. Break long‑running handlers into multiple steps. Prefer static event pools on resource‑limited platforms.
// Define event type
typedef struct {
    int eventType;
    void *eventData;
} Event;

// Event handler prototype
typedef void (*EventHandler)(Event *);

void registerEventHandler(int eventType, EventHandler handler);
void unregisterEventHandler(int eventType, EventHandler handler);
void publishEvent(int eventType, void *eventData);

void eventHandle_1(Event *event) { /* handle */ }
void eventHandle_2(Event *event) { /* handle */ }

int test(void) {
    registerEventHandler(EVENT_1, eventHandle_1);
    registerEventHandler(EVENT_2, eventHandle_2);
    publishEvent(EVENT_1, eventData_1);
    publishEvent(EVENT_2, eventData_2);
    return 0;
}

The code shows registration of handlers and publishing of events, fully decoupling the producer from the consumer.

2.2 Data‑Flow (Pipeline‑Filter) Pattern

For scenarios such as sensor data acquisition, a pipeline‑filter architecture splits processing into independent stages. Each filter receives a standard input structure and produces a standard output, enabling flexible composition.

Define a common filter interface. Implement concrete filters for each processing step, validating inputs/outputs. Link filters in order using a linked list to form a processing chain.
typedef struct {
    char *rawData;      // NMEA raw string
    void *parsedData;   // Parsed representation
    int   flag;         // Processing flag
} NmeaDataPacket;

void receiveNmeaData(NmeaDataPacket *p) { p->rawData = readFromSerialPort(); }
void decodeNmeaData(NmeaDataPacket *p) { if (p->rawData) p->parsedData = nmeaDecode(p->rawData); }
void filterNmeaData(NmeaDataPacket *p) { if (dataError) { p->parsedData = NULL; p->flag = ERROR; } }
void dispatchNmeaData(NmeaDataPacket *p) { if (p->parsedData) {/* forward */} }

typedef struct FilterNode {
    void (*filter)(NmeaDataPacket *);
    struct FilterNode *next;
} FilterNode;

void addFilter(FilterNode **head, void (*newFilter)(NmeaDataPacket *)) {
    FilterNode *node = malloc(sizeof(FilterNode));
    node->filter = newFilter;
    node->next = NULL;
    if (*head == NULL) *head = node; else {
        FilterNode *cur = *head; while (cur->next) cur = cur->next; cur->next = node;
    }
}

int track_gnss_task(void) {
    FilterNode *pipeline = NULL;
    addFilter(&pipeline, receiveNmeaData);
    addFilter(&pipeline, decodeNmeaData);
    addFilter(&pipeline, filterNmeaData);
    addFilter(&pipeline, dispatchNmeaData);
    NmeaDataPacket pkt;
    while (1) {
        memset(&pkt, 0, sizeof(pkt));
        for (FilterNode *cur = pipeline; cur; cur = cur->next) cur->filter(&pkt);
    }
    return 0;
}

The pipeline processes NMEA data step‑by‑step, and new filters can be inserted without touching existing code.

2.3 Dependency Injection

Dependency injection moves the creation of a component’s collaborators outside the component, typically via function pointers. This reduces coupling, eases unit testing, and allows different implementations to be swapped at compile‑ or run‑time.

Expose an interface as a function pointer and initialise it from the outside. Components depend only on the abstract interface, not on concrete implementations.
typedef struct { void (*userFunction)(); } Dependency;

typedef struct { Dependency *dep; } Component;

void useDependency(Component *c) {
    if (c->dep && c->dep->userFunction) c->dep->userFunction();
}

void dependencyCustomerHandle(void) { /* custom logic */ }

int test(void) {
    Dependency d = { .userFunction = dependencyCustomerHandle };
    Component  c = { .dep = &d };
    useDependency(&c);
    return 0;
}

A concrete LED driver example demonstrates how the same controller can work with different hardware implementations simply by injecting a different driver structure.

typedef struct { void (*on)(void); void (*off)(void); } led_driver_interface_t;
typedef struct { led_driver_interface_t *driver; } led_controller_t;

void led_controller_init(led_controller_t *c, led_driver_interface_t *d) { if (c && d) c->driver = d; }
void led_controller_turn_on(led_controller_t *c)  { if (c && c->driver && c->driver->on) c->driver->on(); }
void led_controller_turn_off(led_controller_t *c) { if (c && c->driver && c->driver->off) c->driver->off(); }

static void gpio_led_on(void)  { /* GPIO toggle */ }
static void gpio_led_off(void) { /* GPIO toggle */ }

led_driver_interface_t gpio_led_driver = { .on = gpio_led_on, .off = gpio_led_off };

int test(void) {
    led_controller_t ctrl;
    led_controller_init(&ctrl, &gpio_led_driver);
    led_controller_turn_on(&ctrl);
    led_controller_turn_off(&ctrl);
    return 0;
}

3. Non‑Pattern Techniques

When a full‑blown pattern is unnecessary, developers often rely on simpler mechanisms such as conditional compilation (#if CUSTOM_FEATURE), hook functions for user‑defined extensions, callbacks for asynchronous notifications, script‑driven configuration files, or lightweight object‑oriented techniques in C.

4. Conclusion

In IoT and other embedded products, code must serve many fragmented requirements. By separating input, processing, and output, applying layered design, and using decoupling patterns like event‑driven, pipeline‑filter, and dependency injection, developers achieve maintainable, extensible firmware that can evolve with minimal impact on stable versions.

Object‑oriented C diagram
Object‑oriented C diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Software Architecturedependency-injectionembeddedC programmingLayered Design
Liangxu Linux
Written by

Liangxu Linux

Liangxu, a self‑taught IT professional now working as a Linux development engineer at a Fortune 500 multinational, shares extensive Linux knowledge—fundamentals, applications, tools, plus Git, databases, Raspberry Pi, etc. (Reply “Linux” to receive essential resources.)

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.