The monolith problem

Firmware that starts as a proof of concept ends up like this:

 1// main.cpp — 1200 lines
 2int main() {
 3    HAL_Init();
 4    MX_GPIO_Init();
 5    MX_I2C1_Init();
 6
 7    // BMP280 register access mixed with display logic
 8    uint8_t id;
 9    HAL_I2C_Mem_Read(&hi2c1, 0x76<<1, 0xD0, 1, &id, 1, 10);
10
11    // SSD1306 command bytes mixed with sensor math
12    HAL_I2C_Mem_Write(&hi2c1, 0x3C<<1, 0x00, 1, initCmds, sizeof(initCmds), 10);
13
14    float temp = (id == 0x60) ? readBmp() : 25.0f;
15    char buf[32];
16    sprintf(buf, "T: %.1fC", temp);
17    drawString(0, 0, buf);  // drawString calls HAL internally
18}

Hardware details (register addresses, I2C handles, command byte sequences) are woven through business logic. Changing from BMP280 to SHT31 means hunting through 1200 lines. Writing a unit test for the alarm logic requires a real MCU.

Layered architecture separates these concerns.


The four layers

┌─────────────────────────────────────────────────────┐
│  Application layer                                   │
│  — business rules, state machines, mode switching   │
│  — knows: domain types (Temperature, AlarmLevel)    │
│  — doesn't know: HAL, I2C addresses, registers      │
├─────────────────────────────────────────────────────┤
│  Service layer                                       │
│  — aggregates drivers into usable features          │
│  — knows: driver interfaces (ISensor, IDisplay)     │
│  — doesn't know: Bmp280, SSD1306, register maps     │
├─────────────────────────────────────────────────────┤
│  Driver layer                                        │
│  — wraps one peripheral, owns its HAL handle        │
│  — implements: ISensor, IDisplay, IStorage          │
│  — knows: HAL functions, register addresses         │
│  — doesn't know: business logic, other drivers      │
├─────────────────────────────────────────────────────┤
│  HAL / CMSIS layer (generated by CubeMX)            │
│  — peripheral register access, clock init           │
│  — not hand-edited                                  │
└─────────────────────────────────────────────────────┘

Each layer depends only on the layer below it, never on layers above. The service layer calls driver interfaces — not concrete driver classes. The application layer calls service interfaces — not service implementations.


Layer 1: HAL (generated)

CubeMX generates MX_*_Init() functions and HAL handle structs. These are not edited by hand. They live in Core/Src/ and Core/Inc/.

1// Generated — don't touch
2extern I2C_HandleTypeDef hi2c1;
3extern SPI_HandleTypeDef hspi1;
4
5void MX_I2C1_Init(void);
6void MX_SPI1_Init(void);

Layer 2: Drivers

One class per peripheral. Implements a domain interface. Knows register maps and HAL calls; nothing else.

 1// include/drivers/IBmp280.h — abstract interface
 2class ITemperatureSensor {
 3public:
 4    virtual float readCelsius() = 0;
 5    virtual bool  isReady()     = 0;
 6    virtual ~ITemperatureSensor() = default;
 7};
 8
 9// src/drivers/Bmp280Driver.h — concrete driver
10class Bmp280Driver : public ITemperatureSensor {
11    I2C_HandleTypeDef* hi2c_;
12    uint8_t            addr_;
13    // compensation coefficients
14    int32_t t_fine_ = 0;
15
16public:
17    Bmp280Driver(I2C_HandleTypeDef* hi2c, uint8_t addr = 0x76)
18        : hi2c_(hi2c), addr_(addr << 1) {}
19
20    bool init();                             // read chip ID, load calibration
21    float readCelsius() override;            // trigger measurement, wait, compensate
22    bool  isReady()     override;            // check status register
23
24private:
25    uint8_t readReg(uint8_t reg);
26    void    writeReg(uint8_t reg, uint8_t val);
27    float   compensateTemp(int32_t raw);
28};

The driver knows hi2c1, register 0xF3, compensation formula. The service layer above it knows only ITemperatureSensor.

Directory layout:

firmware/
├── Core/           ← CubeMX generated
├── Drivers/        ← your driver layer
│   ├── include/
│   │   ├── ITemperatureSensor.h
│   │   ├── IDisplay.h
│   │   └── IStorage.h
│   └── src/
│       ├── Bmp280Driver.cpp
│       ├── Ssd1306Driver.cpp
│       └── W25qFlashDriver.cpp
├── Services/       ← service layer
├── App/            ← application layer
└── CMakeLists.txt

Layer 3: Services

A service aggregates one or more drivers to implement a feature. It speaks in domain terms (temperature in °C, not ADC counts or register bytes).

 1// Services/include/EnvironmentMonitor.h
 2class EnvironmentMonitor {
 3    ITemperatureSensor& tempSensor_;
 4    IHumiditySensor&    humSensor_;
 5    float               lastTemp_  = 0.0f;
 6    float               lastHumid_ = 0.0f;
 7
 8public:
 9    struct Reading { float celsius; float relativeHumidity; };
10
11    EnvironmentMonitor(ITemperatureSensor& t, IHumiditySensor& h)
12        : tempSensor_(t), humSensor_(h) {}
13
14    Reading update() {
15        if (tempSensor_.isReady())  lastTemp_  = tempSensor_.readCelsius();
16        if (humSensor_.isReady())   lastHumid_ = humSensor_.readPercent();
17        return {lastTemp_, lastHumid_};
18    }
19};

EnvironmentMonitor doesn’t mention I2C, BMP280, SHT31, or HAL. Swapping Bmp280Driver for SHT31Driver requires changing one line in main.cpp (the composition root), not touching the service.


Layer 4: Application

Business rules, state machines, user interaction. Speaks only in domain types.

 1// App/src/ThermostatApp.cpp
 2class ThermostatApp {
 3    EnvironmentMonitor& monitor_;
 4    IDisplay&           display_;
 5    AlarmService&       alarm_;
 6    float               setPoint_ = 22.0f;
 7
 8public:
 9    ThermostatApp(EnvironmentMonitor& m, IDisplay& d, AlarmService& a)
10        : monitor_(m), display_(d), alarm_(a) {}
11
12    void tick() {
13        auto [temp, humid] = monitor_.update();
14        display_.showTemperature(temp);
15        display_.showHumidity(humid);
16
17        if (temp > setPoint_ + 2.0f) alarm_.trigger(AlarmLevel::Warning);
18        else                          alarm_.clear();
19    }
20};

ThermostatApp has no #include <stm32f4xx_hal.h>. It’s fully unit-testable with mock implementations of EnvironmentMonitor, IDisplay, and AlarmService.


Composition root

All concrete types are created in one place — main.cpp or a dedicated CompositionRoot:

 1// main.cpp — the only place concrete types appear
 2int main() {
 3    HAL_Init();
 4    MX_I2C1_Init();
 5    MX_SPI1_Init();
 6
 7    // Layer 2: drivers
 8    Bmp280Driver    tempSensor(&hi2c1);
 9    Ssd1306Driver   display(&hi2c1);
10    W25qFlashDriver storage(&hspi1);
11    tempSensor.init();
12    display.init();
13
14    // Layer 3: services
15    EnvironmentMonitor monitor(tempSensor, tempSensor);  // or separate sensors
16    AlarmService       alarm(display);
17
18    // Layer 4: application
19    ThermostatApp app(monitor, display, alarm);
20
21    while (true) {
22        app.tick();
23        HAL_Delay(100);
24    }
25}

Every dependency is visible here. To swap Bmp280Driver for SHT31Driver, change two lines. To test the application with mocks, write a test main.cpp that creates mocks and passes them in.


Enforcing layer boundaries

CMake target visibility enforces the boundaries at link time:

 1# Drivers library — exposes interfaces, hides implementation
 2add_library(drivers STATIC
 3    src/Bmp280Driver.cpp
 4    src/Ssd1306Driver.cpp
 5)
 6target_include_directories(drivers
 7    PUBLIC  include/    # interfaces: ITemperatureSensor.h etc.
 8    PRIVATE src/        # concrete driver headers — internal
 9)
10target_link_libraries(drivers PUBLIC stm32_hal)
11
12# Services — depends on driver interfaces, not implementations
13add_library(services STATIC src/EnvironmentMonitor.cpp)
14target_include_directories(services PUBLIC include/)
15target_link_libraries(services PUBLIC drivers)
16
17# App — depends on service interfaces
18add_library(app STATIC src/ThermostatApp.cpp)
19target_link_libraries(app PUBLIC services)

services can’t accidentally include Bmp280Driver.h — it’s not in any PUBLIC include path. The boundary is structural, not just a convention.


Testing across layers

Each layer can be tested in isolation:

 1// Test: ThermostatApp triggers alarm above setpoint
 2TEST(ThermostatApp, TriggersAlarmWhenTooHot) {
 3    MockEnvironmentMonitor monitor;
 4    MockDisplay            display;
 5    MockAlarmService       alarm;
 6
 7    monitor.setReading({28.0f, 50.0f});  // 28°C, setpoint 22°C + 2 = trigger
 8
 9    ThermostatApp app(monitor, display, alarm);
10    app.tick();
11
12    EXPECT_TRUE(alarm.wasTriggered());
13    EXPECT_EQ(alarm.lastLevel(), AlarmLevel::Warning);
14}

No hardware, no HAL headers, no FreeRTOS. The test compiles and runs on the host. The layered architecture made this possible.


Summary

  • Four layers: HAL (generated) → Driver (peripheral wrapper) → Service (feature) → Application (business logic)
  • Each layer depends only on the layer below — via interfaces, not concrete types
  • Composition root (main.cpp) is the only file that knows concrete types
  • CMake PRIVATE include paths enforce boundaries at the build level
  • Each layer is independently unit-testable with mocks

What’s next