Chapter 285: Implementing Custom Components

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the purpose and structure of an ESP-IDF component.
  • Create a new, self-contained component from scratch.
  • Write a CMakeLists.txt file to register a component with the build system.
  • Define a public API for a component using header files.
  • Add custom configuration options using a Kconfig file.
  • Use a custom component within a main application.
  • Manage dependencies between different components.

Introduction

As your projects grow in complexity, a single, monolithic main.c file becomes unmanageable. Good software engineering practice, in any domain, relies on modularity: breaking a large problem down into smaller, self-contained, and reusable pieces. In the ESP-IDF ecosystem, the mechanism for achieving this is the component.

Think of a component as a library or module. It could be a driver for a specific sensor (like a BME280), a helper library for a communication protocol (like Modbus), or a collection of utility functions you use across many projects. By encapsulating logic within a component, you create a “black box” with a well-defined public interface. This simplifies your main application logic, promotes code reuse, and makes collaboration and testing far easier. This chapter will teach you how to build these fundamental blocks of an ESP-IDF application.

Theory

1. The Anatomy of a Component

At its core, a component is simply a directory containing source code and a build script. The ESP-IDF build system (based on CMake) automatically discovers and integrates these components into your final application.

A typical component has the following structure:

my_project/
└── components/
    └── my_component/
        ├── CMakeLists.txt            // (Required) The build script for this component.
        ├── Kconfig                   // (Optional) Defines configuration options for menuconfig.
        ├── include/
        │   └── my_component.h        // (Recommended) Public header file defining the API.
        └── my_component.c            // Source file(s) containing the implementation.
  • CMakeLists.txt: This is the only mandatory file. It tells the build system what source files are part of the component and what other components it depends on.
  • include/ directory: This directory is special. Any header file placed here is considered public. The build system automatically adds this directory to the global include path for your entire project, meaning main.c or any other component can include it directly (e.g., #include "my_component.h").
  • Source Files: Your .c or .cpp files contain the component’s internal logic and implementation of the public API.
  • Kconfig: This file allows you to add custom options for your component that will appear in the menuconfig interface, making your component configurable by the user.
graph TD
    subgraph "Your Project"
        A[main_app.c] --> B(Custom: Sensor Driver);
        A --> C(Custom: Utils Library);
        B --> D(ESP-IDF: I2C Driver);
        B --> E(ESP-IDF: esp_log);
        C --> E;
    end

    %% Styling
    style A fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6
    style B fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style C fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    style D fill:#D1FAE5,stroke:#059669,stroke-width:1px,color:#065F46
    style E fill:#D1FAE5,stroke:#059669,stroke-width:1px,color:#065F46

    linkStyle 0 stroke:#5B21B6,stroke-width:1.5px;
    linkStyle 1 stroke:#5B21B6,stroke-width:1.5px;
    linkStyle 2 stroke:#2563EB,stroke-width:1.5px;
    linkStyle 3 stroke:#2563EB,stroke-width:1.5px;
    linkStyle 4 stroke:#2563EB,stroke-width:1.5px;

2. The Component Build Script: CMakeLists.txt

The heart of the component is its CMakeLists.txt file. The primary function used here is idf_component_register.

Plaintext
# CMakeLists.txt for my_component

idf_component_register(SRCS "my_component.c"
                       INCLUDE_DIRS "."
                       REQUIRES "esp_log")

Let’s break down the parameters:

Parameter Purpose Example
SRCS Lists all source files (.c, .cpp, .S) to be compiled as part of this component. SRCS “driver.c” “helpers.c”
INCLUDE_DIRS Lists directories containing private header files, used only by this component’s sources. INCLUDE_DIRS “private_include”
REQUIRES Lists other components this component depends on. This is a public dependency. REQUIRES “esp_log” “driver”
PRIV_REQUIRES Lists other components this component depends on, but this dependency is private (not exposed to other components). PRIV_REQUIRES “json_parser”
  • SRCS: A list of all source files (.c.cpp.S) that belong to this component.
  • INCLUDE_DIRS: A list of directories to be added to the component’s private include path. This is for headers used only internally by the component’s source files. The public include directory is handled automatically.
  • REQUIRES: A list of other components that this component depends on. This is a public dependency. It tells the build system two things:
    1. This component needs to be linked against the esp_log component.
    2. The public include directories of esp_log will be available to any other component that uses my_component.
  • PRIV_REQUIRES: This defines a private dependency. The component will be linked against the required component, but the dependency is not propagated to components that depend on this one. It’s an internal implementation detail.

3. Making a Component Configurable with Kconfig

To make your component flexible, you can add options to menuconfig. This is done by creating a Kconfig file in the component’s root directory.

A simple Kconfig file might look like this:

Plaintext
# Kconfig for my_component

menu "My Component Settings"

    config MY_COMPONENT_ENABLE_FEATURE_X
        bool "Enable Feature X"
        default y
        help
            Enable this to add support for the amazing Feature X.

    config MY_COMPONENT_RETRY_COUNT
        int "Number of Retries"
        range 0 10
        default 3
        help
            Specifies how many times to retry an operation before failing.

endmenu

Keyword Purpose Example
menu / endmenu Creates a submenu in the `menuconfig` interface to group related options. menu “My Component Settings”
config Defines a new configuration option. The name is automatically prefixed with CONFIG_. config MY_COMPONENT_TIMEOUT
bool / int / string Specifies the data type of the configuration option. bool “Enable Feature”
default Sets the default value for the option if the user doesn’t change it. default 1000
range Constrains the allowable values for an int option. range 0 100
help Provides descriptive help text that appears in `menuconfig`. help
Sets the timeout in milliseconds.

To use this in your C code, you simply reference the generated macro:

C
#if CONFIG_MY_COMPONENT_ENABLE_FEATURE_X
    // Code for Feature X is compiled
#endif

void some_function() {
    for (int i = 0; i < CONFIG_MY_COMPONENT_RETRY_COUNT; i++) {
        // ...
    }
}

Practical Example: Creating a Reusable Utilities Component

Let’s build a simple component named math_utils that provides a function to calculate the average of an integer array.

1. Set Up the Project Structure

First, create a standard project. Then, create a components directory, and inside it, the directory for our new component.

my_project/
├── main/
│   ├── main.c
│   └── CMakeLists.txt
├── components/
│   └── math_utils/
│       ├── include/
│       │   └── math_utils.h
│       ├── math_utils.c
│       └── CMakeLists.txt
└── CMakeLists.txt
2. Tell the Project Where to Find Components

Edit the top-level CMakeLists.txt file in your project root (my_project/CMakeLists.txt) and add the following line near the top, after cmake_minimum_required:

Plaintext
# my_project/CMakeLists.txt
...
set(EXTRA_COMPONENT_DIRS components)

This tells the build system to look for components in the components directory.

3. Write the Component Code

components/math_utils/include/math_utils.h (Public API):

C
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

#include <stdint.h>

/**
 * @brief Calculates the average of an array of integers.
 *
 * @param data Pointer to the array of integers.
 * @param len The number of elements in the array.
 * @return The calculated average as a float. Returns 0 if len is 0.
 */
float average_int_array(const int32_t *data, uint32_t len);

#endif // MATH_UTILS_H

components/math_utils/math_utils.c (Implementation):

C
#include "math_utils.h"
#include <stddef.h>

float average_int_array(const int32_t *data, uint32_t len) {
    if (data == NULL || len == 0) {
        return 0.0f;
    }

    int64_t sum = 0;
    for (uint32_t i = 0; i < len; i++) {
        sum += data[i];
    }

    return (float)sum / len;
}

4. Create the Component’s Build Script

components/math_utils/CMakeLists.txt:

Plaintext
# Register the component with the build system.
idf_component_register(SRCS "math_utils.c"
                       INCLUDE_DIRS "."
                       REQUIRES "")

We list the source file. We don’t have any dependencies for this simple component, so REQUIRES is empty.

5. Use the Component in main

Now, let’s use our new component in the main application.

main/main.c:

C
#include <stdio.h>
#include "math_utils.h" // Include the public header of our component
#include "esp_log.h"

static const char *TAG = "MAIN_APP";

void app_main(void) {
    int32_t my_data[] = {10, 20, 30, 40, 55};
    uint32_t data_len = sizeof(my_data) / sizeof(my_data[0]);

    float avg = average_int_array(my_data, data_len);

    ESP_LOGI(TAG, "The average of the array is: %.2f", avg);
}

Notice how we can directly include math_utils.h.

6. Build and Run
  1. Save all files.
  2. Run idf.py build flash monitor.
Observe

The output on the serial monitor should be:

Plaintext
I (XXX) MAIN_APP: The average of the array is: 31.00

You have successfully created, integrated, and used a custom component!

Variant Notes

The component mechanism itself is a feature of the ESP-IDF build system and is identical across all ESP32 variants. The code inside your component, however, may need to be variant-aware.

For example, if you were writing a peripheral driver component, you might need to handle differences in pin numbers or register addresses. The standard way to do this is with C preprocessor directives that check the target configuration.

C
// Inside a component's source file
#include "soc/soc_caps.h" // Provides hardware capability macros

#if CONFIG_IDF_TARGET_ESP32S3
    // Code specific to ESP32-S3
    #define MY_SENSOR_I2C_PORT I2C_NUM_0
#elif CONFIG_IDF_TARGET_ESP32C3
    // Code specific to ESP32-C3
    #define MY_SENSOR_I2C_PORT I2C_NUM_0
#elif CONFIG_IDF_TARGET_ESP32
    // Code specific to the original ESP32
    #define MY_SENSOR_I2C_PORT I2C_NUM_1
#else
    #error "This component does not support the selected target"
#endif

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
“fatal error: my_component.h: No such file or directory” The build fails when trying to compile `main.c`. The public header is in the wrong place OR the component directory isn’t registered. Solution: Ensure the header is in components/my_component/include/ and that the main CMakeLists.txt has set(EXTRA_COMPONENT_DIRS components).
“undefined reference to `my_function`” The build compiles but fails at the final linking stage. The component that uses your function is missing the dependency. Solution: In the `CMakeLists.txt` of the component calling the function (e.g., `main`), add your component to the REQUIRES list.
“Circular dependency detected” The build fails with an explicit error about a circular dependency. Component A requires B, and component B requires A. This is a design flaw. Solution: Refactor your code. The component with lower-level logic (e.g., `led_driver`) should not depend on a higher-level one (`status_indicator`).
Kconfig options don’t appear in `menuconfig`. You run `menuconfig` but your new component settings menu is not there. The build system did not find your `Kconfig` file. Solution: Ensure the file is named exactly Kconfig (case-sensitive) and is in the root of your component directory, e.g., components/my_component/Kconfig.

Exercises

  1. LED Driver Component: Create a component named led_driver. It should have a public API (led_driver.h) with three functions: led_driver_init(gpio_num_t pin)led_driver_on(), and led_driver_off(). The init function should configure the GPIO pin, and the other two should control the LED state. Use this component in app_main to blink an LED.I
  2. Configurable Blink Component: Extend the led_driver component from Exercise 1. Add a Kconfig file with an integer option called CONFIG_LED_BLINK_INTERVAL_MS with a default value of 500. Add a new function to your component, led_driver_blink(), which blinks the LED in a loop using the interval from menuconfig.
  3. Dependent Components: Create a new component called status_indicator. This component should depend on your led_driver component (use REQUIRES led_driver in its CMakeLists.txt). It should have a function status_indicator_set_error(bool is_error). If is_error is true, it should turn the LED on solid; otherwise, it should turn it off. Your main application should only interact with the status_indicator component, not the led_driver component directly.
graph TD
    A[main.c] -- "Public API Call<br><i>#include \status_indicator.h\</i>" --> B(status_indicator);
    B -- "Public API Call<br><i>#include \led_driver.h\</i>" --> C(led_driver);
    C -- "IDF API Call" --> D[ESP-IDF<br>GPIO Driver];

    subgraph "Your Application"
        A
    end

    subgraph "Custom Components"
        B
        C
    end
    
    subgraph "ESP-IDF"
        D
    end

    %% Styling
    style A fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px
    style B fill:#DBEAFE,stroke:#2563EB,stroke-width:1.5px
    style C fill:#DBEAFE,stroke:#2563EB,stroke-width:1.5px
    style D fill:#D1FAE5,stroke:#059669,stroke-width:1.5px

Summary

  • Components are the key to modularity and code reuse in ESP-IDF.
  • A component’s structure typically includes source files, a public include directory, a CMakeLists.txt file, and an optional Kconfig file.
  • The project’s root CMakeLists.txt must point to your custom components directory via set(EXTRA_COMPONENT_DIRS ...).
  • idf_component_register is the core function for defining a component’s sources and dependencies in its CMakeLists.txt.
  • Public APIs are defined in headers placed in the component’s include/ directory.
  • Kconfig files make your components flexible and configurable through the menuconfig system.
  • The component architecture is a powerful tool for building clean, maintainable, and scalable embedded applications.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top