Key Takeaway: Good build tooling supports engineers and keeps work simple. Practices like unit testing with mocks, modular libraries, and containerized builds reduce complexity, cut bugs, and improve collaboration. Even better, excellent free, open-source tools are available to help with these practices. While setting up these tools takes some initial effort, over time they lead to cleaner designs, smoother teamwork, and lower development costs as your product grows.
One quick warning up front: Like many engineering practices these strategies must be used with intention and precision. There are many heated debates out there about overengineering unit tests, software architecture, and build toolchains. The goal of this post isn’t to provide the perfect strategy for every team, but to highlight techniques I’ve successfully used to solve long-term architecture challenges up front.
1. Embrace Unit-Testing Frameworks with Mocks
Implementing unit testing from day one enforces clear module boundaries, documents expected behavior, and uncovers defects on a host PC before hardware is involved.
Why This Matters
- Long-Term Lower Development Costs: Automated tests catch regressions early, slashing debugging time and maintenance effort.
- Lower Immediate Hardware Costs: Mocks simulate peripherals. This minimizes prototype cycles and hardware wear. It also “shifts left” by allowing development before low-level code is ready.
- Improved Partner Coordination: Tests provide unambiguous interface contracts for external collaborators or contractors.
How It Works
- Framework Options: Ceedling (Unity + CMock), GoogleTest, CMocka, or other lightweight open-source runners.
- Mocking Benefits: Simulate error conditions (e.g., communication failures), enable parallel development, and run hundreds of tests in seconds.
- Getting Started:
- Identify the key APIs for your most important library functions.
- Create tests that ensure the correct behavior, mocking the lower level code.
- Run tests regularly as you make your changes.
- Example Use Cases:
- Mock a peripheral driver to verify logic.
- Simulate communication faults for robust error handling.
- Develop and test application code based on established module interfaces.
2. Build Features as Libraries, Not Monolithic Source
Structuring each feature or subsystem into its own compiled library naturally enforces modularity, reduces coupling, and accelerates incremental builds.
Why This Matters
- Long-Term Lower Development Costs: Modular libraries simplify refactoring and feature growth without fracturing code.
- Lower Immediate Hardware Costs: Selective linking omits unused code, optimizing flash/RAM on constrained platforms.
- Improved Partner Coordination: Defined headers serve as clear interface agreements for outside contributors.
How It Works
- API Contracts: Each library provides a clean header file defining its public interface.
- Selective Linking: Only referenced functions end up in the binary, reducing footprint.
- Incremental Compilation: Rebuilding a library takes seconds, not minutes.
- Getting Started:
- Create
lib/feature/feature.candfeature.h. - Update your Makefile to build
libfeature.a. - Link
libfeature.ainto your main firmware.
- Create
- Example Use Cases:
- A
libcontrol.afor core product logic. - A
libcomm.afor connectivity stacks.
- A
3. Containerize Your Test-Build Toolchain
Package your entire toolchain in Docker or Podman containers so that unit tests, the cross-compiler, static analysis tools, and flashing utilities run in a reproducible environment and enable rapid onboarding.
Why This Matters
- Long-Term Lower Development Costs: Consistent environments solve “works on my machine” issues and reduce support overhead.
- Lower Immediate Hardware Costs: Streamlined flashing and testing in containers minimizes failed deployment attempts.
- Improved Partner Coordination: Shared container images ensure all developers use the same toolchains and tests.
How It Works
- Unified Environment: One image bundles testing frameworks, compilers, and analysis tools (Cppcheck, GCC flags).
- Cross-Platform Consistency: Run the same container on Windows, macOS, or Linux.
- CI/CD Integration: Use the container in GitHub Actions or other pipelines for identical local and cloud builds.
- Getting Started:
- Find a basic docker image to use (Ex: minideb or Ceedling)
- Create an updated Dockerfile that builds with your toolchain
- Share the container with developers and install it on your build server
- Example Use Cases:
- ARM Cortex-M cross-compilation container.
- On-commit static analysis with Cppcheck.
- Flashing utilities container for automated deployment.
How They Work Together
- Unit Tests with Mocks validate each library’s behavior off-hardware.
- Library Architecture defines clear boundaries and contracts.
- Containers guarantee every developer can quickly use the same development environment.
Outcome for Small Teams:
- 50-90% reduction in debugging time through host-based testing.
- 30-60% lower hardware prototyping costs by catching issues before flashing devices.
- Seamless collaboration with partners via shared libraries, tests, and containers.
With expert guidance on configuring these tools, your team can focus on core product behavior while leveraging unit testing, modular libraries, and containerized toolchains to deliver reliable embedded solutions faster and at lower cost.
Need help getting your tools set up so you’re building faster, better, and cheaper? Schedule a meeting to discuss how we can help today!


Leave a Reply