Tom Anderson asked this excellent question in the Embedded.fm Slack group:
Many designs start as feasibility investigations using Arduino and then move to a lower power or otherwise better embedded platform. Is there anything that these Arduino practitioners can do to make this transition easier?
As we outlined in “Musings on the Tight Coupling Between Firmware and Hardware”, we want to design for change by decoupling our firmware from the underlying platform as much as possible. We do this to protect our team from unnecessary delays and rewrites caused by future hardware or SDK migrations. While that article identified the problem and the north star we should aim for, we provided little actionable advice.
Let’s look at practical habits that we can apply as early as the prototyping stages to write firmware that we can easily change, port, and reuse.
Table of Contents:
- The Primary Constraint: Keep Prototyping Fast
- Practical Strategies for New Code
- Start by Writing Code on Your Personal Computer
- Careful Library Selection
- Refactoring for Change
- Putting it All Together
- Further Reading
The Primary Constraint: Keep Prototyping Fast
First, let’s set the stage. We’re prototyping. We don’t yet know the full story behind the product, where we’re headed, what the core features are, or even whether it’s a feasible solution.
In most prototyping cases, teams dive straight into the work without spending time on research, architecture, and design. Teams complain that time spent on architecture and design at this stage is a waste. Common concerns are:
- The team’s goals and product’s requirements will change before prototyping finishes, so any architecture will be “wrong”
- We do not fully understand the system’s constraints and requirements, so any architecture will be “wrong”
- We might throw out the entire prototype when the effort is complete, so there’s no point in attempting code reuse
We want to move quickly during our prototyping efforts, but we also want to make sure we’re not trapping our future selves. Many prototypes aren’t scrapped. Instead, they directly evolve into the shipping software. Deferring architectural work and continuing to sprint through development will cause a team to incur a significant time delay once the underlying platform needs to change.
We need to find an approach that enables us to explore core features, capabilities, and technical feasibility. We want to avoid significant time delays that hinder our prototyping efforts. We want to allow our team to use vendor SDKs and platform-specific functionality to prove out their ideas.
Our assertion is that we can do this while minimizing the rework needed to reuse our IP or migrate to a new platform.
Practical Strategies for New Code
We’ll look at practical architectural strategies that we can use when writing new code that enables us to design for change:
- Build Lightweight Abstractions
- Constrain Coupling to a Single Location
- Keep All Other Code “Generic”
Build Lightweight Abstractions
Here’s the most important principle for decoupling code and designing for change: any time you’re using a platform/SDK-specific API in your code, put it behind some kind of abstraction.
A standard approach is to build an abstraction layer that separates your “portable” code from the underlying system. When we need to migrate to a new platform, only modules that are “below” the abstraction layer need to change.
This advice applies to any SDK, OS, or other platform-specific interface. For example, don’t use native RTOS mutex APIs. Instead, create a mutex_lock(uintptr_t handle, uint32_t timeout_ms) function for portable code to use. Your code can run on a new platform or OS by supplying a new implementation of mutex_lock.
When creating abstractions, focus on the interaction points between two modules, especially one which is platform-dependent (e.g., a library interacting with an OS mutex). We recommend avoiding abstractions for complex areas such as initialization, which differ across platforms and implementations. The next strategy will address initialization and other too-complex-to-abstract cases.
Platform-specific code can extend beyond the OS and vendor SDK. Some tasks, such as printing debug output, are different for every project. You might use printf, a custom printf-like implementation, UART, BLE, USB-CDC, SWO, a circular log buffer in RAM, or a file stored in flash. You can decouple your code from the debug-printing details by creating a header which defines a debug_print() function/macro. Use this abstraction in all of your portable code. As a benefit, you can update (or remove) the debug print method for an entire program with a single change.
Often, it can be the underlying data types that come back to haunt us. Use generic types in your abstract interfaces, whether an intermediate type you define (e.g. a general structure with relevant data), or a generic type such as void* or uintptr_t. We show this in the generic mutex_lock prototype above, where we use a uintptr_t for the handle instead of a platform-specific type.
The key point: our goal is to create lightweight abstractions. We don’t need a full hardware abstraction layer (HAL). We merely want to separate portable code from the underlying system, making it easier to test without hardware and to migrate to a new platform. Use the minimum number of abstractions necessary. Use the minimum number of arguments you can get away with for those abstractions. Keep it simple.
For a wonderful lightweight abstraction example, study a C radio driver which uses a single abstract function interface to achieve portability.
Constrain Coupling to a Single Location
Plenty of platform-specific code cannot be easily abstracted. For example, mutex initialization and thread initialization APIs vary from one OS to another. Each OS provides different configurable settings. We don’t want to waste time trying to create perfect abstractions that cover every conceivable configuration.
Instead, constrain the difficult-to-abstract code to a single, isolated location. Create a module where you can declare and initialize objects using the platform-specific types and APIs. Configure the low-level processor peripherals for your use case. Use this module to hook up the different portable components with each other using the abstractions you’ve defined. You can pass around function pointers, set handlers, supply callbacks – whatever you need to get the pieces talking to each other indirectly.
This concept is a form of the Mediator pattern. We are managing the coupling of our system by defining a module or class which contains all the tightly coupled details. The rest of the system only needs to interact with our generic abstractions.
We typically create two mediator modules on our internal projects: a “Hardware Platform” module and a “Platform” module. The Hardware Platform configures and connects the various hardware peripherals, both in the processor and on the board. The Platform, which contains the Hardware Platform as one component, manages details such as initializing the operating system, initializing the heap, initializing the debug printing interface, and any other non-hardware concerns. These mediator modules also follow the “simple abstraction layer” idea. We can provide standard interfaces such as Platform.init() or Platform.setPowerState(x). We can swap implementations as long as we adhere to the interface requirements.
Keep All Other Code “Generic”
This strategy is the linchpin that brings it all together: code which lives outside of our tightly coupled mediator modules must remain generic and interact with other modules through the abstract interfaces we created.
We want to eliminate the use of platform-specific code from our generic modules. This includes vendor SDK driver interfaces, RTOS-specific APIs, direct register accesses, and platform-specific debugging functionality. Only use standard language features, your custom abstractions, or portable libraries.
By following this strategy, our generic code is decoupled from specific implementation details. When the time comes to move to a new processor, OS, or library, we only need to supply new implementations for the existing abstractions. Our generic code doesn’t need to change.
Start by Writing Code on Your Personal Computer
The best way to bias yourself toward design for change is to begin by writing and testing as much of the embedded software as possible on your personal computer. Once it’s unit tested and functional, you can move it to the embedded target.
At Embedded Artistry, we begin most driver, library, and application development this way. We set up a test suite for drivers using simple mocking/spy techniques. For example, we test our drivers against real hardware using debug adapters (such as Aardvark for I2C/SPI) and FTDI chips. Once everything is working on the personal computer, we migrate the code to the embedded platform.
There’s a simple reason we like to start on our personal computers: we know that the code will have to run somewhere else. This simple change in perspective forces us to create abstractions that separate our code from the underlying system as we learned in the previous section. We identify required abstractions by thinking about what parts of the code rely on the target hardware or operating systems. We cannot rely on the pthread library, so we create OS abstractions. We cannot directly use the Aardvark APIs on our target system, so we need to create generic I2C and SPI abstractions that support both Aardvark adapters and MCU peripherals.
Once we’ve built these abstractions, all we need to do to support new platforms or components is to supply a conforming implementation for our abstractions. As a benefit, we’ve already tested our code, so we have increased confidence that it will work correctly when we migrate it to the target system. When it doesn’t work, we’ve reduced our potential debugging problem space to the new abstraction implementation.
Careful Library Selection
We must be careful about the external code we decide to incorporate into our system. Ideally, we will select libraries designed for use on multiple platforms. Usually, the libraries we are searching for use the same principles described above. They may be built around a defined abstraction layer that you must implement for your target system. Others may require you to supply function pointers that implement specific functionality. Others will only use standard language features, and you must review these features to make sure they are available on your target system.
Libraries targeted just for one platform, such as Arduino-specific libraries, may do exactly what you need or be easier to use, but it tightly couples your implementation to a single platform. When you migrate, you will need to find or build replacements for these libraries.
We can take the ideas of design for change one step further and define abstractions that we use to decouple our code from its library dependencies. This way, even if we select a library that is platform specific, we can swap it out later when the time comes. Be careful, however, to design the abstraction generically, using only the actions and parameters that your application needs.
Refactoring for Change
We won’t always get our abstractions right the first time. Sometimes, taking the easy road is what we need to do to get the demo out faster.
We can relieve the pressure on getting everything right the first time by developing a refactoring habit. We need to review and refactor our code at regular intervals. At Embedded Artistry, we dedicate every-other Friday to refactoring.
Our code is like a garden. We must maintain it by pruning, pulling weeds, picking up leaves, and sweeping the paths. If we don’t have a habit of regular maintenance, the garden will quickly become overgrown. Then, the effort needed to get it under control will be much greater than the effort we would have spent keeping it maintained all along.
Keep this in mind when you are refactoring: any time you encounter a platform-specific API in code that could otherwise be portable, move the code behind some kind of abstraction. Put that small amount of work in now so it doesn’t become a mountain of work later.
Putting it All Together
Designing our systems to support change requires discipline, practice, and continual effort. By building these habits, we can create increasingly changeable, portable, and reusable code with little additional overhead. The rewards of code reuse and easy migration between platforms are worth the up front investment.
Remember: you don’t need a perfect and comprehensive abstraction layer. Sometimes, a single function might be enough.
If you want to learn more about the strategies we use to design embedded systems for change, take a look at our Designing Embedded Systems for Change course.
Further Reading
- Leveraging Our Build Systems to Support Portability
- Musings on the Tight Coupling Between Firmware and Hardware
- Practical Decoupling Techniques Applied to a C-based Radio Driver
- Designing Embedded Systems for Change Course
- Wikipedia: Mediator Pattern
Designing Embedded Software for Change
Are you tired of every hardware or requirements change turning into a large rewrite? Our course teaches you how to design your software to support change. This course explores design principles, strategies, design patterns, and real-world software projects that use the techniques.
