Crafting Robust Software Architectures for Embedded Systems (Part II)

Crafting Robust Software Architectures for Embedded Systems (Part II)

Introduction

As a software engineer working with embedded systems, navigating strict hardware limitations requires specific architectural approaches. Creating content and documentation for these systems demands clear explanations and practical examples. This post explores the fundamentals of embedded software architecture, focusing on handling hardware constraints and ensuring code maintainability. The structure and concepts detailed here provide an overview of how to approach these specialized development environments, manage concurrent tasks, and apply scalable design principles.

Background / Context

Embedded programming is fundamentally distinct from standard desktop or web development due to severe constraints on memory, processing power, and the specific hardware involved. Failures in these environments are often highly critical; unlike a desktop application that might simply crash and restart, an embedded failure can physically compromise connected machinery or sensors.

A core concept in this domain is “real-time” execution, which emphasizes being strictly punctual rather than purely fast. This concept relies heavily on determinism, meaning the system guarantees a process will execute within a highly predictable, constant timeframe. For example, in a “soft” real-time system like streaming audio, a delay of a few milliseconds simply causes an imperceptible buffer drop. Conversely, in a “hard” real-time system like a car’s Anti-lock Braking System (ABS), missing a timing deadline can result in a physical accident. Therefore, embedded design strongly favors consistent, predictable latencies over occasional rapid execution.

Main Content / Code Explanation

The core of architectural implementation for firmware involves managing execution times, structuring logic into isolated components, and adhering to strict memory rules.

Step 1: Managing Execution Models

Concurrent tasks in embedded systems require the processor to context-switch between processes. Depending on the application’s complexity, execution is typically structured in one of several ways.

• Super Loop (while(1)): Frequently used for simple applications, such as basic Arduino projects, this model executes tasks sequentially in an endless loop.
• Real-Time Operating System (RTOS): Provides lightweight, highly optimized control over task scheduling to meet strict deadlines, though it requires deep hardware and driver knowledge.
• Event-Driven Architecture: Suspends operations entirely until a specific trigger or event occurs. This approach is highly efficient for power consumption and timing but requires an underlying framework to manage the subscribers and triggers.

Explanation: This basic loop executes tasks endlessly. However, because timing is managed manually, it becomes difficult to scale as system complexity increases.

Step 2: Implementing Architectural Patterns

A predefined architectural structure prevents the codebase from degrading into tightly coupled, unmaintainable “spaghetti code”. Instead, systems should be partitioned into small, independent blocks, an approach sometimes referred to as “ravioli code”.

One effective pattern is the Layered Architecture, which divides logic into distinct tiers (e.g., Application, Services/Middleware, and Hardware Abstraction). Dependencies in this model must be unidirectional from top to bottom, which isolates changes and simplifies testing. Another structural option is a component-based architecture, where independent modules interact over a central software bus.

Step 3: Adapting SOLID and Design Principles

Modern software design principles adapt directly to firmware when interfaces are used to decouple logic from concrete hardware implementations.

• Single Responsibility Principle (SRP): A module must have only one reason to change. For example, instead of maintaining a massive SensorManager class, responsibilities should be divided into a SensorReader and a UartSender.
• Open-Closed Principle (OCP): Systems should be open for extension but closed for modification. When extending a base sensor to handle specific light wavelengths, a new inherited class should be created rather than modifying the base class.
• Liskov Substitution Principle (LSP): Subclasses must seamlessly replace base classes without breaking the system. A classic violation is a duck abstraction where a mechanical subclass requires batteries, fundamentally breaking the expected interface behavior. While compilers in C++ often catch missing method implementations, interpreted languages like Python might fail silently until runtime.

• Interface Segregation Principle (ISP): Interfaces should be highly specific. A 2D square should not be forced to implement a volume calculation belonging to a 3D shape interface. In laboratory hardware, generic device interfaces should be segregated so pumps are not forced to implement valve methods they do not possess.
• Dependency Inversion Principle (DIP): Code should depend on abstractions (interfaces), not concretions.

Explanation: By relying on the ICommunication interface, swapping a serial connection for an OPC protocol requires zero modifications to the core SensorLogic block.

Other relevant principles include DRY (Don’t Repeat Yourself), POLA (Principle of Astonishment, meaning code behavior should never surprise the user or developer), YAGNI (avoiding the over-engineering of hypothetical features), and favoring Composition over Inheritance to avoid rigid class trees.

Step 4: Strict Memory Management

Dynamic memory allocation (via malloc, free, or new) introduces significant risks in embedded environments and should be limited. The heap causes memory fragmentation over time and introduces unpredictable latency during memory searches, which violates real-time constraints. Memory should preferably be managed on the stack, which is highly optimized and ordered by the compiler, or via statically allocated memory pools created at startup to prevent total system failures.

Step 5: Testing and Release Consistency

Testing embedded software is complicated by the requirement to execute code on microcontrollers that often lack standard outputs. By designing the architecture for testability and mocking hardware via interfaces, unit tests can be executed on a host PC. These tests can then be integrated into automated CI/CD pipelines, such as GitLab CI.

For releases, strict semantic versioning is vital for bug tracking. Furthermore, development teams must synchronize their compilation toolchains, Makefiles, and IDE configurations (such as Qt Creator .user files and shared DLLs) to prevent irreproducible build inconsistencies across different workstations.

Conclusion

This post covered execution models, architectural patterns, memory restrictions, and testing strategies relevant to embedded software architecture. Handling the physical constraints of hardware requires disciplined task management and the careful application of design principles. By structuring code with unidirectional dependencies, relying on interfaces, statically managing memory, and strictly controlling compilation environments, firmware projects can maintain stability and modularity.

Written by Nahuel Markevich
Edited by Adrian Evaraldo
For further inquiries, contact us: info@emtech.com.ar