How to Structure Embedded Firmware for Maintainability

Engineer examining modular firmware code structure on multiple computer screens

Your prototype works. The sensor reads, the LED blinks, the UART spits out data. Then your client asks you to support a second sensor variant, and you realize your SPI init code is tangled into your application logic across four functions. Or a chip goes end-of-life, and swapping to the pin-compatible replacement means touching every file in your project because register addresses are hardcoded everywhere. You wrote firmware that works, but you didn’t write firmware that bends.

This is the gap between hobby firmware and production embedded firmware architecture, and almost every developer crosses it the hard way. The structure you choose on day one determines how painful every change is for months or years afterward. Production firmware gets patched, ported to new hardware, extended by engineers who didn’t write it, and maintained long after you’ve moved on to another project.

Here’s a concrete, opinionated framework you can apply to your next project, covering layered architecture, module design, RTOS alignment, testing, and documentation.

The Layered Architecture That Makes Everything Else Possible

The single most important structural decision you can make is separating your firmware into layers with strict dependency rules. Three layers handle the vast majority of projects:

Hardware Abstraction Layer (HAL): Wraps register access and peripheral configuration. The goal isn’t a perfect, vendor-agnostic abstraction of every peripheral. It’s making the rest of your codebase portable and testable. Your HAL might expose a function like hal_spi_transfer(uint8_t *tx, uint8_t *rx, size_t len) that hides whether you’re on an STM32 or a Nordic chip.

Driver / Service Layer: Implements device-specific logic on top of the HAL. A temperature sensor driver speaks the sensor’s protocol (commands, timing, data format) using the HAL’s SPI or I2C functions. The driver owns the protocol, not the bus.

Application Layer: Business logic, state machines, task orchestration. This layer decides what the system does: when to sample, how to filter, what thresholds trigger alerts. It should contain zero register-level code.

The mental model is a strict stack:

┌─────────────────────────┐
│    Application Layer    │  ← Business logic, state machines
├─────────────────────────┤
│   Drivers / Services    │  ← Device protocols, data processing
├─────────────────────────┤
│          HAL            │  ← Register access, peripheral setup
├─────────────────────────┤
│       Hardware          │  ← The actual chip
└─────────────────────────┘
       Dependencies point DOWNWARD only.

The critical rule: dependencies point downward only. The HAL never calls the application layer. A driver never reaches up to ask the application what mode it’s in. If you need upward communication, you use callbacks or message queues, never direct function calls.

This isn’t abstract software engineering dogma. It’s what lets you swap a sensor by replacing one driver file instead of hunting through fifty #ifdef blocks. It’s what makes off-target testing possible: your application logic compiles on your laptop because it’s never seen a register address.

Both FreeRTOS and Zephyr encourage this separation, which we’ll return to shortly.

Designing Modules With Clean Interfaces

Within each layer, code should be organized into modules, typically one .c / .h pair per module in C. The principles here are the embedded equivalent of encapsulation, and they’re entirely achievable without C++ or an OOP language.

Hide internal state. Use file-scoped static variables. If a driver maintains a calibration offset, that variable lives static inside the .c file, never in the header.

Expose narrow interfaces. An init(), a few action functions, maybe a status query. If a module’s header has 15 public functions, it’s doing too much.

Make dependencies explicit. A module should receive its dependencies rather than reaching across the codebase. Pass a function pointer to a HAL transmit function instead of having the driver #include a specific HAL implementation directly.

Here’s what this looks like in practice:

/* ---- temp_sensor.h (clean) ---- */
typedef struct {
    int (*spi_transfer)(uint8_t *tx, uint8_t *rx, size_t len);
} temp_sensor_config_t;

int  temp_sensor_init(const temp_sensor_config_t *config);
int  temp_sensor_read_mdegc(int32_t *temperature);

Compare that to the tangled alternative:

/* ---- temp_sensor.h (messy) ---- */
#include "stm32f4xx_hal_spi.h"
#include "app_config.h"
#include "global_flags.h"

extern SPI_HandleTypeDef hspi2;
extern volatile int new_data_ready;
extern float last_temperature;
extern int raw_adc_buffer[128];

void temp_init(void);
float temp_read(void);
void temp_set_offset(float o);
void temp_calibrate(void);
void temp_debug_dump(void);
void temp_spi_irq_handler(void);
/* ... 9 more functions ... */

The clean version can be tested on a PC by passing a mock SPI function. The messy version drags in STM32 headers, global state, and application concerns. It’s welded to one specific board and one specific project. When your client asks you to port to a different MCU, the clean module moves with you. The messy one doesn’t.

Working With an RTOS: FreeRTOS and Zephyr Structures

Many hobby projects live in a bare-metal while(1) loop, and that’s fine for simple systems. But once you have concurrent responsibilities—reading sensors, handling communication, updating a display—an RTOS gives you structured concurrency instead of handwritten round-robin scheduling that gets brittle fast.

FreeRTOS: Freedom With Responsibility

FreeRTOS gives you tasks (independent threads of execution), queues (typed message pipes between tasks), and semaphores (synchronization primitives). The typical approach:

  • Map each major subsystem to a task: a sensor task, a communications task, a UI task.
  • Use queues for inter-task communication instead of shared globals. A sensor task pushes readings into a queue; the application task pulls from it. No mutexes, no race conditions on shared buffers.
  • FreeRTOS doesn’t impose a project structure, so you create your own:
/project
├── /app           ← Application tasks and logic
├── /drivers       ← Sensor, actuator, comms drivers
├── /hal           ← MCU-specific peripheral wrappers
├── /freertos      ← FreeRTOS kernel source (or submodule)
├── /config        ← FreeRTOSConfig.h, pin maps, board defs
├── /test          ← Off-target unit tests
└── /docs          ← ARCHITECTURE.md, pinout, build instructions

Zephyr: Structure as a Feature

Zephyr’s build system (west, Kconfig, devicetree) is more opinionated, and that’s a feature for maintainability. Devicetree separates hardware description from driver code, so porting to a new board means editing a .dts file rather than rewriting C. Zephyr’s device driver model provides HAL abstraction at the framework level, meaning you often don’t need to write your own.

Zephyr also ships subsystems for logging, shell, settings, and power management. Instead of rolling your own debug console or key-value storage, you use the built-in infrastructure. Less custom code means less code to maintain.

The practical tradeoff: FreeRTOS gives you freedom and expects you to impose structure. Zephyr gives you structure and expects you to work within it. Developers coming from bare-metal often find Zephyr’s learning curve steeper but its long-term maintenance burden lighter. If you’re starting a new production project and aren’t locked into a specific RTOS, Zephyr’s guardrails are worth the upfront investment.

Either way, the layered model from earlier maps directly onto both: your RTOS tasks live in the application layer, your drivers sit below them, and your HAL (whether hand-written or Zephyr-provided) sits at the bottom.

Testing Embedded Firmware Without Losing Your Mind

Most hobbyists test by flashing firmware and watching serial output. That works until it doesn’t. Specifically, it works until you make a change that breaks something you aren’t actively watching, and you don’t discover it until the device is in a customer’s hands.

Unit testing on host (off-target). This is the big payoff of layered architecture. Because your application logic doesn’t #include "stm32f4xx.h", you can compile it on your laptop and test it with mocked HAL functions. Frameworks like Unity and CMock (from ThrowTheSwitch) or Zephyr’s built-in ztest make this straightforward. You can validate state machines, protocol parsers, and data processing without touching hardware.

A concrete example: if your application layer has a function that converts raw ADC values to temperature using calibration coefficients, that function can be compiled and tested on x86 with known inputs and expected outputs. If you later change the calibration formula, the test catches regressions instantly.

Hardware-in-the-loop (HIL) testing. For driver-level validation where real hardware behavior matters (timing, signal integrity, interrupt handling) you need the actual target. HIL setups automate flashing and verification, but they’re a deeper topic for another article.

Static analysis. Tools like cppcheck or PC-lint catch buffer overflows, type mismatches, unused variables, and null pointer dereferences. They cost minutes to set up and catch entire categories of bugs that unit tests miss. Run them in your build process.

The key insight: the layered architecture from earlier is what makes off-target testing possible. If you skip the architecture, you skip the ability to test efficiently. They’re the same decision.

Documentation That Future-You Will Actually Need

Embedded documentation needs differ from web or app software. Nobody needs a line-by-line commented getter function. What they need:

Pin mappings and hardware interface docs. Which UART is the debug console? What GPIO drives the status LED? What’s the I2C address of the EEPROM? A README that omits this is useless to anyone who didn’t wire the prototype.

Module interface documentation. What does each module do? What’s its public API? Is it thread-safe? Can it be called from an ISR? This matters more than internal implementation comments.

Build and flash instructions. Explicit toolchain versions, flash procedures, debug probe setup. Hobby projects assume one developer and one board. Production firmware gets built by CI servers and flashed by manufacturing technicians.

What to skip. Don’t Doxygen-comment every trivial function. Focus effort on boundaries, architectural decisions, and anything that would confuse a new team member.

Keep a lightweight ARCHITECTURE.md in your repo root describing the layer model, module responsibilities, and RTOS task mapping. A 50-line file that saves a new developer two days of codebase archaeology is worth more than 500 inline comments.

A Starter Project Structure You Can Copy

Here’s a concrete directory tree applicable to either a FreeRTOS or Zephyr project (adjust the RTOS-specific folder accordingly):

/firmware
├── /app                    ← Application tasks, state machines, business logic
│   ├── main.c
│   ├── app_sensor_task.c
│   └── app_comms_task.c
├── /drivers                ← Device-specific drivers (sensor, modem, display)
│   ├── drv_temp_sensor.c
│   ├── drv_temp_sensor.h
│   ├── drv_lte_modem.c
│   └── drv_lte_modem.h
├── /hal                    ← MCU peripheral abstraction (SPI, I2C, GPIO, UART)
│   ├── hal_spi.c
│   ├── hal_spi.h
│   └── ...
├── /config                 ← Board pin maps, RTOS config, build-time settings
│   ├── board_pinmap.h
│   └── FreeRTOSConfig.h
├── /test                   ← Off-target unit tests
│   ├── test_temp_sensor.c
│   └── test_app_logic.c
├── /docs
│   └── ARCHITECTURE.md     ← Layer model, module map, task descriptions
├── CMakeLists.txt
└── README.md               ← Build/flash instructions, toolchain requirements

This is a starting point, not dogma. The exact folder names matter less than the principle: layered, modular, testable. If your team prefers src/ and include/ splits, fine. If Zephyr’s west workspace dictates a different top-level layout, adapt. The architecture underneath is what counts.

The One Change to Make This Week

You don’t need to restructure your entire codebase in a weekend. Good embedded firmware architecture is maintained through ongoing habits: code review, incremental refactoring, team norms. Not a single heroic rewrite.

Pick one structural improvement and apply it to your current project. Separate your HAL from your application code. Wrap one peripheral’s register access behind a clean interface. Move one module’s internal state behind static and narrow its header. Write one off-target unit test for a function that currently only gets tested by flashing and squinting at serial output.

Each of these takes an afternoon. Each one makes the next change easier. And each one moves you from firmware that works on your bench to firmware that survives in the field, maintained by people who didn’t write it, running on hardware that didn’t exist when you started.

From here, natural next steps are build system automation (CMake, west), CI/CD pipelines for firmware, and advanced RTOS patterns like publish-subscribe messaging between tasks. But none of that matters if the foundation is a single 3,000-line main.c. Start with the layers. Everything else follows.


Hubble Network connects your embedded devices to satellite networks directly from a Bluetooth chip—no extra hardware, no gateway infrastructure. See how it works →