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, a firmware project built as one giant directory quickly becomes unmanageable. It creates compilation bottlenecks (change one sensor driver, recompile everything), dependency hell (unclear what depends on what, and adding a feature breaks something unrelated), team collaboration friction (multiple developers can't work on separate modules without constant merge conflicts), and 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 requiring precise sensor fusion, real-time data processing, and reliable wireless communication. Our firmware needs to be as modular and maintainable as our cloud infrastructure, which meant treating firmware components like microservices, isolated, testable, and independently deployable.
ESP-IDF's component-based CMake architecture
ESP-IDF uses CMake as its build system, designed specifically for modular firmware development. Unlike a traditional Makefile or a monolithic build script, CMake in ESP-IDF enforces a component-driven architecture where each functional module is self-contained.
The core principles: component isolation, where each hardware driver or logical module lives in its own directory with explicit dependencies. Incremental builds, where only modified components and their dependents get recompiled. Kconfig integration for runtime configuration without touching code. And dependency graph resolution, where CMake automatically figures out build order.
This isn't just organization, it's 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
Our firmware architecture mirrors modern software engineering practices. The root CMakeLists.txt is minimal, it sets up the project and points at component directories:
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(EXTRA_COMPONENT_DIRS components)
project(everRTOS)That tells ESP-IDF to look in components/ for all modules, and every subdirectory there becomes a discoverable component.

Each component registers itself with 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")SRCS lists source files, INCLUDE_DIRS exposes public headers other components can use, REQUIRES declares public dependencies visible to dependents, and PRIV_REQUIRES declares private, implementation-only dependencies. This explicit declaration is crucial, the build system now knows that changing bus means recompiling accelerometer and anything that depends on it.
For hardware variants, our MPU 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 get selected via menuconfig, and the build system injects the right preprocessor flags, no code changes needed when switching sensor models, just a configuration change.
The main application component composes 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, explicitly stating what capabilities the application needs. CMake resolves the entire dependency tree, if accelerometer needs bus and bus needs driver, everything gets built in the correct order automatically.
What worked, and where we're still improving
Granular components isolate each sensor, letting us unit test the barometer without loading GPS firmware at all. Splitting public and private dependencies keeps implementation details hidden, so changing how the accelerometer talks to I2C doesn't break components that just read acceleration values. And Kconfig-driven variants turn hardware changes into configuration switches rather than code forks.
There's room to keep improving too. Using FILE(GLOB_RECURSE) in main forces CMake to scan the filesystem on every configuration, and for large projects, explicit source lists are faster. Interface libraries could make shared abstractions clearer, if multiple sensors share common interfaces like I2C or SPI, a CMake interface library makes those dependencies more explicit. And ESP-IDF's component manager, through idf_component.yml, handles third-party libraries, we use it for some components but could lean on it more systematically for version pinning and reproducible builds.
Why this matters at Hoomanely
Our mission is health monitoring technology that's both powerful and invisible, wearables that fade into daily life while delivering clinical-grade insights. That requires firmware that's rock solid and rapidly evolvable.
Our sensor team, connectivity team, and power management team work in parallel without stepping on each other. When optimizing GPS power consumption, we don't risk breaking barometer calibration, and build times dropped from minutes to seconds for incremental changes. When a component shortage forced us to switch accelerometer models mid-project, we swapped the component implementation and changed a Kconfig option, zero changes to application logic. And each component carries its own CI tests, catching sensor driver bugs before they reach integration testing.
This modular architecture is what lets us move as fast as software companies while building physical hardware, the same principle that makes cloud microservices scalable, applied to embedded firmware.
Key takeaways
Component boundaries enforce clean architecture, since physical directory structure prevents accidental coupling. Explicit dependencies make systems understandable, looking at REQUIRES tells you exactly what a module needs. Configuration should replace compilation for hardware variants, they shouldn't require code forks. Build systems are force multipliers, the hour spent learning CMake saves weeks of technical debt down the line. And start modular and stay modular, refactoring monolithic firmware into components later is genuinely painful, design for it from day one.
These principles aren't unique to ESP-IDF, they apply whether you're using Zephyr, Arduino, or bare-metal development. Treating firmware components as isolated, testable modules changes how you build, and lets teams focus on what matters instead of wrestling with build systems.