Creating maintainable embedded software requires careful consideration of architecture and design patterns. This article explores five fundamental design principles that improve readability, extensibility, and maintainability.
Design Principles Overview
SRP - Single Responsibility Principle Every function or module should have only one reason to change. Each unit should have a single, well-defined responsibility.
OCP - Open-Closed Principle Entities should be open for extension but closed for modification. Well-designed systems allow new functionality without altering existing code.
DIP - Dependency Inversion Principle High-level modules should not depend on low-level modules. Both should depend on abstractions. Details should depend on abstractions rather than concrete implementations.
ISP - Interface Segregation Principle Interfaces should be granular and focused. Avoid creating monolithic interfaces that force dependents to implement functionality they don't need.
LKP - Least Knowledge Principle Also known as the Law of Demeter. Modules should have minimal knowledge about other modules. Encapsulate complex logic internally and expose simple interfaces.
Single Responsibility Principle
A module should have only one reason to change. This principle is deceptively simple but often difficult to apply correctly. When a module handles multiple responsibilities, changes to one responsibility can unexpectedly affect others.
Defining Responsibility
In SRP, responsibility is defined as "a reason for change." If multiple motivations exist for modifying a module, it likely has multiple responsibilities. Consider a modem interface:
typedef struct {
void (*connect)(void);
void (*disconnect)(void);
void (*transmit)(uint8_t *data, uint16_t len);
void (*receive)(uint8_t *data, uint16_t *len);
} ModemInterface;
This interface actually contains two distinct responsibilities: connection management (connect/disconnect) and data communication (transmit/receive). Whether these should be separated depends on how the application evolves. If connection handling frequently changes independently from communication logic, separation improves maintainability. Conversely, if both responsibilities always change together, keeping them coupled reduces unnecessary complexity.
Decoupling Responsibilities
Sometimes responsibilities cannot be cleanly separated due to hardware constraints or real-time requirements. However, application-level code should still strive for separation. The primary goal during early module design is identifying responsibilities and keeping them independent.
Open-Closed Principle
Software that cannot adapt to changing requirements becomes obsolete quickly. The Open-Closed Principle provides guidance for creating systems that remain stable while supporting evolution.
Key Characteristics
Modules designed following OCP exhibit two key characteristics:
- Extensibility - Module behavior can be extended when requirements change
- Immutability - Existing source code remains unchanged when adding new features
These characteristics appear contradictory. Extending behavior typically requires modifying code, yet OCP prohibits this. The solution lies in abstraction.
Achieving Abstraction
In object-oriented languages, abstract base classes define interfaces that remain fixed while allowing numerous implementations. Modules depend on abstractions rather than concrete types, making them closed to modification while open to extension.
For embedded C development, apply similar principles:
typedef int16_t (*DataHandler)(uint8_t *payload, uint16_t length);
typedef struct {
uint8_t msg_id;
DataHandler handler;
} HandlerMapping;
static int16_t process_temperature_data(uint8_t *payload, uint16_t length) {
return 0;
}
static int16_t process_motion_data(uint8_t *payload, uint16_t length) {
return 0;
}
static const HandlerMapping protocol_handlers[] = {
{0x01, process_temperature_data},
{0x02, process_motion_data}
};
int16_t dispatch_message(uint8_t msg_id, uint8_t *payload, uint16_t length) {
for (uint16_t i = 0; i < sizeof(protocol_handlers) / sizeof(protocol_handlers[0]); i++) {
if (protocol_handlers[i].msg_id == msg_id) {
return protocol_handlers[i].handler(payload, length);
}
}
return -1;
}
This table-driven approach implements OCP in C. Adding new message types requires only adding entries to the handler table, without modifying dispatch logic.
Example: Protocol Handler Design
Consider a data parser handling multiple message types:
typedef struct {
uint8_t id;
void (*initialize)(void);
int16_t (*process)(uint8_t *buffer, uint16_t size);
} ProtocolEntry;
static void init_voltage_sensor(void) {}
static int16_t handle_voltage_reading(uint8_t *buf, uint16_t sz) { return 0; }
static void init_current_sensor(void) {}
static int16_t handle_current_reading(uint8_t *buf, uint16_t sz) { return 0; }
static const ProtocolEntry sensor_protocols[] = {
{0xA0, init_voltage_sensor, handle_voltage_reading},
{0xA1, init_current_sensor, handle_current_reading}
};
int16_t parse_sensor_frame(uint8_t protocol_id, uint8_t *data, uint16_t len) {
uint8_t idx;
for (idx = 0; idx < sizeof(sensor_protocols) / sizeof(sensor_protocols[0]); idx++) {
if (sensor_protocols[idx].id == protocol_id) {
return sensor_protocols[idx].process(data, len);
}
}
return -1;
}
This pattern avoids switch-case logic that would require modification for each new protocol.
Strategic Closure
No system achieves 100% closure. Designers must anticipate likely changes and build abstractions around those areas. This requires experience and domain knowledge. Applying OCP carries costs: abstractions require additional development effort and increase code complexity. For resource-constrained systems with stable requirements, simpler approaches may be more appropriate. Focus abstraction efforts on areas experiencing frequent changes.
Dependency Inversion Principle
Traditional structured design creates top-down dependencies where high-level modules depend on low-level implementations. OOP inverts this dependency structure.
High-level policy should not depend on low-level details. Both should depend on abstractions. When high-level modules depend directly on low-level modules, changes to low-level components ripple upward, forcing modifications throughout the system.
Interface Ownership Inversion
"Don't call us, we'll call you." Low-level modules implement interfaces that high-level modules define. This inverted ownership enables high-level modules to operate in diverse contexts. In embedded development, high-level application logic tends to change frequently while hardware interfaces remain more stable. Prioritizing high-level reusability yields greater software quality benefits.
Example: Temperature Control System
A temperature regulator reads sensor data and controls a heater:
typedef struct {
int16_t (*read_temp)(void);
} TemperatureSensor;
typedef struct {
void (*activate)(void);
void (*deactivate)(void);
} HeaterControl;
void regulate_temperature(TemperatureSensor *sensor, HeaterControl *heater,
int16_t min_val, int16_t max_val) {
int16_t current_temp;
while (1) {
current_temp = sensor->read_temp();
if (current_temp < min_val) {
heater->activate();
} else if (current_temp > max_val) {
heater->deactivate();
}
delay_ms(100);
}
}
The control algorithm depends on abstract interfaces rather than concrete implementations. This design permits:
- Reusing the regulation algorithm with different temperature sensors
- Swapping heater hardware without modifying control logic
- Adding new features like alarms without restructuring
Application: Hardware Abstraction
Consider a pedometer module that might use different accelerometer chips:
typedef struct {
int16_t (*init)(void);
uint32_t (*read_step_count)(void);
void (*reset)(void);
} PedometerInterface;
PedometerInterface* get_pedometer_driver(void);
int32_t get_current_steps(void) {
PedometerInterface *driver = get_pedometer_driver();
if (driver && driver->read_step_count) {
return driver->read_step_count();
}
return -1;
}
Application code depends on the abstraction rather than specific hardware drivers. Hardware changes require only implementing a new driver against the same interface, without touching application logic.
Summary
DIP enables object-oriented design in procedural languages like C. Correct application of this principle creates reusable frameworks and resilient code. When abstractions and implementations remain isolated, maintenance becomes significantly easier.
Interface Segregation Principle
Clients should not be forced to depend on interfaces they don't use. In embedded C, this principle applies in two ways:
-
Interface Collections - Group related functions for specific use cases. If only some clients need certain functions, create separate interfaces rather than forcing all clients to depend on a single comprehensive interface.
-
Function Granularity - Split large functions with many parameters into smaller, focused functions. Clients should depend only on functionality they actually need.
Avoiding Interface Pollution
When modules depend on unused functionality, changes to that functionality can unexpectedly affect dependent modules. If one client requires changes to a shared interface, all other clients using that interface become impacted.
In practice, as embedded systems evolve, functions accumulate parameters or additional logic to support new requirements. This creates interfaces that partially serve different client needs:
typedef struct {
void (*open)(void);
void (*close)(void);
void (*read)(uint8_t *dest, uint16_t count);
void (*write)(uint8_t *src, uint16_t count);
void (*configure)(uint32_t options);
} StreamInterface;
If some clients only need read operations, they shouldn't need to implement write or configure methods.
Mitigation Strategies
- Use preprocessor conditionals to hide unused interfaces, reducing code size
- Create separate interfaces for different capability levels
- Prefer composition over adding parameters to existing functions
typedef struct {
int16_t (*start)(void);
int16_t (*stop)(void);
int16_t (*read)(uint8_t *buffer, uint16_t size);
} BasicSensorInterface;
typedef struct {
BasicSensorInterface base;
int16_t (*calibrate)(int16_t reference);
int16_t (*set_range)(int16_t min, int16_t max);
} AdvancedSensorInterface;
Clients requiring advanced features depend on the extended interface, while basic clients use only the common interface.
Least Knowledge Principle
Also known as the Law of Demeter, this principle states that a module should know as little as possible about other modules. Complex logic should be encapsulated internally, exposing only essential operations.
For example, if operation A requires calling interfaces 1→2→3→4 in sequence, and operation B requires 1→2→4→3, requiring clients to manage this sequence exposes internal implementation. Instead, encapsulate both operations as single接口, hiding sequencing details:
typedef struct {
int16_t (*execute_mode_a)(void);
int16_t (*execute_mode_b)(void);
} OperationController;
int16_t run_sequence_a(OperationController *ctrl) {
return ctrl->execute_mode_a();
}
int16_t run_sequence_b(OperationController *ctrl) {
return ctrl->execute_mode_b();
}
The principle reduces coupling and improves information hiding. However, excessive encapsulation can make customization difficult when requirements change. If new operation C requires sequence 4→3→2→1, the original design may not accommodate it without modification.
Refactoring
Refactoring is an ongoing process, similar to cleaning a kitchen after cooking. Skipping cleanup saves time initially, but subsequent preparation takes longer. The mess accumulates until significant effort becomes necessary to restore order.
Refactoring improves internal structure without changing external behavior. Its goal is maintaining clean code through continuous improvement.
Most embedded development follows iterative approaches with evolving requirements. Without clean foundations, design patterns and principles lose their value. Before applying architectural patterns, establish solid coding fundamentals.
Application Considerations
Object-oriented design principles originate from class-based languages with inheritance and polymorphism. Not all principles translate directly to embedded C, which relies on procedural design.
Traditional embedded development often prioritizes speed over structure, leading to rapid but disorganized code. This makes refactoring essential for long-term maintainability.
Modern embedded platforms offer sufficient resources for structured approaches. Consider two implementation strategies:
- Function pointers for polymorphic behavior
- Abstract interfaces for hardware independence
"Most design problems can be solved by introducing an abstraction layer. If one layer isn't enough, add another."