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
PRIVATEinclude paths enforce boundaries at the build level - Each layer is independently unit-testable with mocks
What’s next
- HAL vs bare-metal register access — what lives in the HAL layer and what bypasses it
- Dependency injection without a framework — constructor injection as the glue between layers
- Interface-based design — writing the interfaces that cross layer boundaries