Building Modular Firmware with CMake in ESP-IDF: A Real-World Implementation

Building Modular Firmware with CMake in ESP-IDF: A Real-World Implementation

Monolithic firmware scales linearly in features but exponentially in complexity.When building IoT devices that integrate multiple sensors - accelerometers, GPS modules, barometers, LoRa communication, and more firmware projects quickly become unmanageable. A single, monolithic codebase where everything lives in one directory creates several critical issues:

Compilation bottlenecks: Change one sensor driver, recompile everything.

Dependency hell: Unclear what depends on what. Adding a new feature breaks something unrelated.

Team collaboration friction: Multiple developers can't work on separate modules without constant merge conflicts.

Testing paralysis: Unit testing individual components becomes nearly impossible when everything is tightly coupled.

At Hoomanely, we're building next-generation wearable health monitoring devices that require precise sensor fusion, real-time data processing, and reliable wireless communication. Our firmware needs to be as modular and maintainable as our cloud infrastructure. We needed a build system that treats firmware components like microservices isolated, testable, and independently deployable.


The Solution: ESP-IDF's Component-Based CMake Architecture

ESP-IDF (Espressif's IoT Development Framework) uses CMake as its build system, designed specifically for modular firmware development. Unlike traditional Makefiles or monolithic build scripts, CMake in ESP-IDF enforces a component-driven architecture where each functional module is self-contained.

Core Principles

Component isolation: Each hardware driver or logical module lives in its own directory with explicit dependencies.

Incremental builds: Only modified components and their dependents get recompiled.

Kconfig integration: Runtime configuration without touching code.

Dependency graph resolution: CMake automatically figures out build order.

This isn't just about organization-it's about build velocity and system reliability. When your accelerometer driver is independent of your GPS module, you can test them in isolation, swap implementations, and onboard new developers without them needing to understand the entire codebase.


How We Implemented It at Hoomanely

Our firmware architecture mirrors modern software engineering practices. Here's how we structured it:

Project Root: The Orchestrator

The root CMakeLists.txt is minimal—it sets up the project and points to component directories:

cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

set(EXTRA_COMPONENT_DIRS components)
project(everRTOS)

This tells ESP-IDF: "Look in the components/ directory for all modules." Every subdirectory there becomes a discoverable component.


Component Registration: The Contract

Each component declares itself using idf_component_register(). Here's our accelerometer component:

idf_component_register(
    SRCS "accelerometer.cpp" "ICM20948_ESP32.cpp"
    INCLUDE_DIRS "."
    REQUIRES driver nvs_flash bus mpu
    PRIV_REQUIRES json file_operations status_led
)
target_compile_options(${COMPONENT_LIB} PRIVATE "-std=gnu++11")

What this does:

  • SRCS: Source files to compile
  • INCLUDE_DIRS: Public headers other components can use
  • REQUIRES: Public dependencies (visible to components that depend on this)
  • PRIV_REQUIRES: Private dependencies (implementation details)

This explicit dependency declaration is crucial. The build system now knows: "If someone changes the bus component, recompile accelerometer and anything that depends on accelerometer."

Conditional Compilation: One Codebase, Multiple Targets

Our MPU (Motion Processing Unit) component supports multiple sensor chips. Instead of #ifdef spaghetti, we use CMake:

if(CONFIG_MPU_CHIP_MODEL STREQUAL "MPU9250")
    target_compile_definitions(${COMPONENT_TARGET} PUBLIC CONFIG_MPU6500)
elseif(CONFIG_MPU_CHIP_MODEL STREQUAL "ICM20948")
    target_compile_definitions(${COMPONENT_TARGET} PUBLIC CONFIG_MPU6500)
endif()

Hardware variants are selected via menuconfig, and the build system injects the right preprocessor flags. No code changes needed when switching sensor models—just change the configuration.


Main Application: Composing the System

The main component pulls everything together:

FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/main/*.*)
idf_component_register(
    SRCS ${app_sources}
    REQUIRES nvs_flash status_led flash_fs accelerometer 
             gps barometer ota wifi lora battery
)

This is our composition layer. It explicitly states: "The application needs these capabilities." CMake resolves the entire dependency tree—if accelerometer needs bus, and bus needs driver, all get built in the correct order.


Better Approaches and Trade-offs

What We Did Well

Granular components: Each sensor is isolated. We can unit test the barometer without loading GPS firmware.

Private vs. public dependencies: Implementation details stay hidden. Changing how the accelerometer talks to I2C doesn't break components that just read acceleration values.

Kconfig-driven variants: Hardware changes are configuration switches, not code forks.

Continuous Evolution: Where We're Going Next

Component discovery optimization: Using FILE(GLOB_RECURSE) in main forces CMake to scan the filesystem on every configuration. For large projects, explicit source lists are faster:

idf_component_register(SRCS "main.c" "sensors.c" "network.c" ...)

Interface libraries for shared abstractions: If multiple sensors share common interfaces (like I2C or SPI), creating CMake interface libraries makes dependencies even clearer:

add_library(sensor_interface INTERFACE)
target_include_directories(sensor_interface INTERFACE include/)

Component manager for external dependencies: ESP-IDF's component manager (via idf_component.yml) handles third-party libraries. We use it for some components but could leverage it more systematically for version pinning and reproducible builds.


Real-World Impact: Why This Matters at Hoomanely

At Hoomanely, our mission is to create health monitoring technology that's both powerful and invisible - wearables that fade into daily life while providing clinical-grade insights. This requires firmware that's rock-solid and rapidly evolvable.

Parallel development: Our sensor team, connectivity team, and power management team work simultaneously without stepping on each other's toes.

Faster iteration: When optimizing GPS power consumption, we don't risk breaking barometer calibration. Build times dropped from minutes to seconds for incremental changes.

Hardware flexibility: When component shortages forced us to switch from one accelerometer model to another mid-project, we swapped the component implementation and changed a Kconfig option. Zero changes to application logic.

Quality assurance: Each component has CI tests. We catch sensor driver bugs before they reach integration testing.

This modular architecture is the foundation that lets us move as fast as software companies while building physical hardware. It's the same principle that makes cloud microservices scalable—applied to embedded firmware.


Key Takeaways

Component boundaries enforce clean architecture: Physical directory structure prevents accidental coupling.

Explicit dependencies make systems understandable: Looking at REQUIRES tells you exactly what a module needs.

Configuration over compilation: Hardware variants shouldn't require code forks.

Build systems are force multipliers: The hour you spend learning CMake saves weeks in technical debt.

Start modular, stay modular: Refactoring monolithic firmware into components is painful. Design for modularity from day one.


This approach isn't unique to ESP-IDF—the principles apply to any embedded project. Whether you're using Zephyr, Arduino, or bare-metal development, treating firmware components as isolated, testable modules transforms how you build. At Hoomanely, this foundation lets us focus on what matters: building technology that improves lives, not wrestling with build systems.

Read more