The Easiest Way to Write Modular and Configurable Zephyr Code

August 21, 2025

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 build
  • Kconfig → declares configs and pulls in module configs
  • prj.conf → provides default values for CONFIG_... at build time
  • src/ → 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 sensor
  • sensor.h → header to expose the API
  • CMakeLists.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

Build System and Configuration


Profile picture

Written by Marco Ciau who is apassionate about providing solutions by using software. I thoroughly enjoy learning new things and am always eager to embrace new challenges. You can follow me on Twitter.