Decoupling Embedded Systems: Layers, Event‑Driven Design, and Dependency Injection
This guide explains how to achieve clean decoupling in embedded software by employing a systematic layered architecture, leveraging event‑driven and pipeline‑filter patterns for data flow, and applying dependency injection, complete with practical C code examples and best‑practice recommendations for resource‑constrained devices.
1. System Layering
In embedded software development, the first step toward decoupling is a well‑defined layered architecture. A typical stack from top to bottom includes:
Application layer (business logic, UI) Middleware layer (common components) Operating‑system layer (e.g., RTOS kernel) Hardware‑abstraction layer (HAL that wraps low‑level peripherals) Hardware layer (physical components and external devices)
Layering follows three rules:
Each layer has a specific role; the application layer implements business logic without touching hardware interfaces.
Requests must travel sequentially through layers; direct cross‑layer access is prohibited.
Changes in one layer should not affect others; when unavoidable, compile‑time or runtime guards should be added.
Even on resource‑constrained MCUs, a minimal two‑layer design (hardware abstraction + business) is recommended.
2. Submodule Design
2.1 Event‑Driven Pattern
The event‑driven model separates "event producers" from "event consumers" using an event queue. An event scheduler pulls events from the queue and dispatches them to appropriate handlers, eliminating periodic polling.
1. Design an event‑filtering mechanism to drop unnecessary or duplicate events. 2. Merge high‑frequency events or defer processing to reduce load. 3. Prioritize critical events with dedicated queues. 4. Split long‑running handlers into multiple steps. 5. Use a static event pool on constrained systems; otherwise, use RTOS queues.
// 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;
}This code shows registration and publishing of events, achieving decoupling between producers and consumers.
2.2 Data‑Flow (Pipeline‑Filter) Pattern
For scenarios such as sensor data acquisition, a pipeline‑filter architecture splits processing into independent components linked by a data pipe. Each filter receives a standard input, transforms it, and passes it downstream.
1. Define a uniform filter interface. 2. Implement concrete filters according to requirements and order them correctly. 3. Connect filters via a linked list to form the processing chain.
typedef struct {
char *rawData; // NMEA raw string
void *parsedData; // Parsed structure
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 (p->parsedData && dataError) { p->parsedData = NULL; p->flag = xx; } }
void dispatchNmeaData(NmeaDataPacket *p) { if (p->parsedData) { /* forward to other modules */ } }
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 packet;
while (1) {
memset(&packet, 0, sizeof(packet));
FilterNode *cur = pipeline;
while (cur) { cur->filter(&packet); cur = cur->next; }
}
return 0;
}The pipeline can be extended by inserting additional filters without touching existing code.
2.3 Dependency Injection
Dependency injection moves the creation of a component’s dependencies outside the component, allowing the caller to supply concrete implementations. This improves testability and reuse.
typedef struct { void (*userFunction)(); } Dependency;
typedef struct { Dependency *dependency; } Component;
void useDependency(Component *c) {
if (c->dependency && c->dependency->userFunction)
c->dependency->userFunction();
}
void dependencyCustomerHandle(void) { /* custom logic */ }
int test(void) {
Dependency dep = { .userFunction = dependencyCustomerHandle };
Component comp = { .dependency = &dep };
useDependency(&comp);
return 0;
}A concrete LED driver example demonstrates how the same controller can work with different hardware implementations simply by injecting a different driver interface.
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 Practices
When resources are tight, simpler techniques can also achieve decoupling:
Conditional compilation using #if defined CUSTOM_FEATURE to isolate optional code.
Hook insertion – expose empty function pointers that users can set at runtime.
Callback functions – notify other modules when specific events occur.
Scripted configuration – drive module behavior from external config files.
Object‑oriented style in C – emulate classes with structs and function pointers to improve modularity.
Version‑control best practices (Git) and code‑maintenance guidelines are recommended to keep the architecture sustainable.
4. Conclusion
For IoT and other embedded products, fragmented requirements demand a source base that can serve many scenarios. By clearly separating input, processing, and output, and by applying layered design, event‑driven or pipeline‑filter patterns, and dependency injection, developers can build flexible, maintainable firmware that scales across hardware variations.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.)
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
