Introduction
I've always followed a modular approach in my embedded systems projects. It's a fundamental rule: my main.c
should be clean and only call my application modules.
After working with Zephyr on real-world projects, I realized that maintaining this philosophy—creating modular, configurable, and reusable code—can be challenging for beginners. It's not rocket science, but you need to understand how CMakeLists.txt and Kconfig files work together in Zephyr.
In this post, I'll show you how to:
- Separate your custom code (sensors, cloud, etc.) into its own module
- Register your module in CMakeLists.txt and Kconfig
- Replace
#define
constants with Kconfig options
This approach transforms your project into something more modular, configurable, and reusable.
Big Picture: A Typical Zephyr Project
Here's the default layout when you create a new Zephyr app:
my_app/
├── CMakeLists.txt # Root CMake file – ties everything together
├── prj.conf # Default build configuration
├── Kconfig # Root Kconfig file – entry point for configs
├── src/
│ └── main.c # Application entry point
└── boards/ # Optional: custom board definitions
CMakeLists.txt
→ tells Zephyr which sources and modules (.h and .c files) to buildKconfig
→ declares configs and pulls in module configsprj.conf
→ provides default values forCONFIG_...
at build timesrc/
→ contains main application logic
Everything eventually connects back to the root CMakeLists.txt
and root Kconfig
.
The Starting Point: Our Messy main.c
The first red flag is usually obvious: almost all your code lives in the main.c
file. This indicates that your code needs to be split into smaller modules. Just stick to the Single Responsibility Principle: one module, one purpose.
Here's a simplified version of what I'm trying to showcase:
#include <zephyr/kernel.h>
#include <zephyr/sys/printk.h>
// Hardcoded sensor params
#define SENSOR_SAMPLING_RATE_MS 1000
#define SENSOR_OFFSET 10
#define SENSOR_CALIBRATION 42
int fake_sensor_init(void) {
// init sensor hw-gpios here
return 0;
}
int fake_sensor_read(void) {
return SENSOR_OFFSET + SENSOR_CALIBRATION;
}
int main(void) {
fake_sensor_init();
while (1) {
int value = fake_sensor_read();
printk("Sensor value: %d\n", value);
k_msleep(SENSOR_SAMPLING_RATE_MS);
}
}
When starting a new project, this approach works, but as the project grows, more and more functions will be added, making it increasingly difficult to maintain.
Step 1: Move Sensor Code into a Module
As we discussed earlier, when our codebase grows, the best approach is to create modules for our application features.
Based on our main.c
example, we'll create a new folder src/modules/sensor/
with:
sensor.c
→ implementation of the fake sensorsensor.h
→ header to expose the APICMakeLists.txt
→ register source files
src/modules/sensor/sensor.c
#include "sensor.h"
#include <zephyr/kernel.h>
// Default values (to be replaced by Kconfig)
#ifndef SENSOR_SAMPLING_RATE_MS
#define SENSOR_SAMPLING_RATE_MS 1000
#endif
#ifndef SENSOR_OFFSET
#define SENSOR_OFFSET 10
#endif
#ifndef SENSOR_CALIBRATION
#define SENSOR_CALIBRATION 42
#endif
int sensor_init(void) {
// init sensor hw-gpios here
return 0;
}
int sensor_read(void) {
return SENSOR_OFFSET + SENSOR_CALIBRATION;
}
int sensor_get_sampling_rate(void) {
return SENSOR_SAMPLING_RATE_MS;
}
src/modules/sensor/sensor.h
#ifndef SENSOR_H
#define SENSOR_H
int sensor_init(void);
int sensor_read(void);
int sensor_get_sampling_rate(void);
#endif
src/modules/sensor/CMakeLists.txt
target_include_directories(app PRIVATE .)
target_sources(app PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/sensor.c
)
Finally, register the sensor
module in your root CMakeLists.txt:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(stream)
target_sources(app PRIVATE src/main.c)
# Add your modules here
add_subdirectory(src/modules/sensor)
Step 2: Replace #define
with Kconfig
In traditional embedded systems IDEs, a common strategy for managing constants and configuration parameters is using #define
macros. Personally, I find this approach reliable for small projects, but it often requires touching the codebase, which introduces room for human errors and can cause potential firmware build errors or misconfiguration issues.
With Zephyr, instead of hardcoding parameters, we can expose them through a Kconfig file. A simple rule is: create a custom Kconfig file for each module and tie it to the root Kconfig
.
Let's move the following constants into a custom Kconfig.sensor
file:
src/modules/sensor/Kconfig.sensor:
menu "Sensor module configuration"
config SENSOR_SAMPLING_RATE_MS
int "Sensor sampling rate (ms)"
default 1000
config SENSOR_OFFSET
int "Sensor offset"
default 10
config SENSOR_CALIBRATION
int "Sensor calibration"
default 42
endmenu
Then, register Kconfig.sensor
in the root Kconfig:
rsource "src/modules/sensor/Kconfig.sensor"
Finally, in sensor.c
, we need to remove the #define constants and use Zephyr's config system:
#include "sensor.h"
#include <zephyr/kernel.h>
int sensor_read(void) {
return CONFIG_SENSOR_OFFSET + CONFIG_SENSOR_CALIBRATION;
}
int sensor_get_sampling_rate(void) {
return CONFIG_SENSOR_SAMPLING_RATE_MS;
}
Step 3: Update main.c
Now in main.c
, we just import our sensor module:
#include <zephyr/kernel.h>
#include <zephyr/sys/printk.h>
#include "sensor.h"
int main(void) {
sensor_init();
while (1) {
int value = sensor_read();
printk("Sensor value: %d\n", value);
k_msleep(sensor_get_sampling_rate());
}
}
With this approach, the main.c
file will always stay clean and focused on its primary responsibility.
New Project Structure
Here's how your project folder structure looks after modularization:
my_app/
├── CMakeLists.txt # Root CMake file
├── prj.conf # Default build configuration
├── Kconfig # Root Kconfig file
├── src/
│ ├── main.c # Clean main.c that only calls modules
│ └── modules/
│ └── sensor/ # Sensor module folder
│ ├── sensor.c # Sensor implementation
│ ├── sensor.h # Sensor API header
│ ├── CMakeLists.txt # Module build configuration
│ └── Kconfig.sensor # Module configuration options
Notice how the main.c
is now much cleaner, and all sensor-related code is organized in its own module folder with proper separation of concerns.
Why This Approach is Better
✅ Configurable: Change sampling rate, offset, and calibration values in menuconfig
without touching code.
✅ Reusable: Drop the modules/sensor/
folder into another project.
✅ Modular: main.c
stays clean, and sensor logic lives in its own world.
✅ Maintainable: Each module has a single responsibility, making debugging and updates easier.
Bonus Tip
This same structure scales well when you add cloud, peripheral, or custom drivers — just give each one a Kconfig
and CMakeLists.txt
, then register them in the root files. The modular approach ensures your project remains organized as it grows.
Scaled Project Structure
Here's how your project structure looks when you add more modules:
my_app/
├── CMakeLists.txt # Root CMake file
├── prj.conf # Default build configuration
├── Kconfig # Root Kconfig file
├── src/
│ ├── main.c # Clean main.c that only calls modules
│ └── modules/
│ ├── sensor/ # Sensor module
│ │ ├── sensor.c
│ │ ├── sensor.h
│ │ ├── CMakeLists.txt
│ │ └── Kconfig.sensor
│ ├── cloud/ # Cloud communication module
│ │ ├── cloud_client.c
│ │ ├── cloud_client.h
│ │ ├── CMakeLists.txt
│ │ └── Kconfig.cloud
│ ├── peripheral/ # Peripheral drivers module
│ │ ├── gpio_driver.c
│ │ ├── gpio_driver.h
│ │ ├── CMakeLists.txt
│ │ └── Kconfig.peripheral
│ └── storage/ # Storage module
│ ├── flash_storage.c
│ ├── flash_storage.h
│ ├── CMakeLists.txt
│ └── Kconfig.storage
Each module follows the same pattern: implementation files, header files, build configuration, and Kconfig options. This structure makes it easy to add, remove, or modify individual modules without affecting the rest of your project.
Conclusion
By following this modular approach with Zephyr, you can maintain clean, configurable, and reusable code that scales with your project's needs. The key is understanding how CMakeLists.txt and Kconfig work together to create a well-structured embedded application.
References and Further Reading
To deepen your understanding of Zephyr's modular architecture and configuration system, here are the official resources:
Official Zephyr Documentation
- Zephyr Project Homepage - Main project website with latest releases
- Zephyr Documentation - Comprehensive official documentation
- Getting Started Guide - First steps with Zephyr
Build System and Configuration
- CMake Integration - Understanding Zephyr's CMake build system
- Kconfig System - Complete guide to Kconfig in Zephyr