注目イベント!
春の新人向け連載2025開催中!
今年も春の新人向け連載が始動しました!!
現場で役立つ考え方やTipsを丁寧に解説、今日から学びのペースを整えよう。
詳細はこちらから!
event banner

Comprehensive Guide to ESP-IDF Project Structure and CMake Mechanisms! (VSCode + ESP-IDF Extension)

| 19 min read
Author: shuichi-takatsu shuichi-takatsuの画像
Information

To reach a broader audience, this article has been translated from Japanese.
You can find the original version here.

Introduction

#

It’s been quite some time since I wrote the article “Trying Out the ESP-IDF Extension 'Espressif IDF' for VSCode”.
This time, I will focus on how ESP-IDF projects are structured and how the CMake mechanism works.

ESP-IDF uses CMake, a “build system generator.”
With CMake, you can describe and manage the build process in a tool- and environment-agnostic way (cross-platform support).
By properly understanding files like CMakeLists.txt that you may not often touch, ESP-IDF’s unique sdkconfig, and the idf.py mechanism, you can develop in a more flexible and extensible manner.

Also, although it’s not directly related to this article, ESP-IDF can use Ninja as its build system.
If Ninja is installed, it will be used by default.
Ninja is a super-fast build system. Based on the build rules generated by tools like CMake, it efficiently compiles and links source code to create executables.

I’ve been using PlatformIO extensively, but since detailed configurations require invoking ESP-IDF features, I’ve recently been studying ESP-IDF.
When I used ESP-IDF before, I was fed up with slow builds, but after switching to Ninja, builds became very fast and I can use it without stress.

Target Audience

#
  • Those who have just started ESP32 development with ESP-IDF
  • Those who are uneasy about editing CMakeLists.txt
  • Those who want to add custom libraries or reusable components to their projects

Basic Structure of an ESP-IDF Project

#

Assuming from the previous article that you have already completed the ESP-IDF development environment installation, we will proceed.

Select “New Project” and create a project.

Set the “Project Name”, “Project Directory”, “Board”, and “Serial Port” accordingly.
(The component directory does not need to be set.)

Select “template-app” and click “Create project using template template-app.”

The created ESP-IDF project structure is as follows.

my_project/ 
├── CMakeLists.txt      ← Project-wide CMake configuration
├── sdkconfig           ← Configuration file reflecting menuconfig settings
├── build/              ← Build outputs are stored here (auto-generated)
├── main/ 
│ ├── CMakeLists.txt    ← CMake configuration specifying source files
│ └── main.c            ← Entry point (main program)
├── .gitignore          ← Git ignore settings

Meaning of main/CMakeLists.txt

#

By default, main/CMakeLists.txt looks like the following.

idf_component_register(SRCS "main.c"
                       INCLUDE_DIRS "")

This idf_component_register() is important.
idf_component_register() is a function used to register information about the component to which the current CMakeLists.txt belongs.

It has the following meanings.

Argument Specified Value Description
SRCS "main.c" Specifies the C/C++ source files that make up this component. In this example, the file main.c is registered as a source file for this component. You can specify multiple files. In ESP-IDF, 'main' is also treated as a component.
INCLUDE_DIRS "" Specifies the directory containing private header files needed when compiling this component's source file (main.c). Directories specified here are not normally referenced by other components. An empty string means there are no private include directories.

Meaning of the CMakeLists.txt in the Project Root

#

By default, the CMakeLists.txt in the project root looks like the following.

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

It has the following meanings.

Command Description
cmake_minimum_required(VERSION 3.5) Specifies the minimum version of CMake required to process this CMakeLists.txt. In this example, CMake version 3.5 or higher is required. If the installed CMake version is older, an error occurs and processing stops.
include($ENV{IDF_PATH}/tools/cmake/project.cmake) Loads a CMake script file that defines basic settings and custom functions/macros essential for running the ESP-IDF build system. $ENV{IDF_PATH} refers to the value of the environment variable IDF_PATH, which points to the directory where ESP-IDF is installed. Therefore, IDF_PATH must be set correctly before running the build. This include enables ESP-IDF specific functions such as idf_component_register.
project(my_project) Defines the name of the project to be built. In this example, the project name is my_project. When this command is executed, CMake sets important variables such as the project name (PROJECT_NAME), project source directory (PROJECT_SOURCE_DIR), and build directory (PROJECT_BINARY_DIR).

Normally, you don’t need to edit this file, but you can modify it when you want to add custom settings.

Meaning of sdkconfig

#

The sdkconfig file is the central configuration file of an ESP-IDF project, managing build options and enabling/disabling features during development. sdkconfig is generated using the Kconfig system. (We will discuss the Kconfig system later, so for now, just think of it as the file that manages build options and feature toggles during development.)

The sdkconfig file begins with the following lines, so you should avoid editing it manually.

#
# Automatically generated file. DO NOT EDIT.
# Espressif IoT Development Framework (ESP-IDF) 5.4.1 Project Configuration
#

When you configure using the following menuconfig subcommand, the settings are reflected in sdkconfig. (We will explain the idf.py command later.)

idf.py menuconfig 

You can also access “menuconfig” from the following menu in VSCode.

The menuconfig in VSCode provides a GUI for easy operation.
To save the configurations, press the “Save” button.

Examples of settings in sdkconfig are as follows. (They may vary slightly depending on the ESP-IDF version.)
For example, UART baud rate, enabling Wi-Fi, etc., are defined here.

Category Example Settings
Board and chip settings Chip type (ESP32/ESP32-S3, etc.)
Peripheral features Enabling UART, SPI, I2C, Wi-Fi, BLE, etc.
FreeRTOS settings Number of tasks, stack size, tick rate, etc.
Log output LOG_LEVEL settings (DEBUG, INFO, WARN, etc.)
Component-specific settings e.g., whether to use SPIFFS, maximum Wi-Fi connections, etc.

※ You can also add custom definitions to sdkconfig (how to do this will be explained later).

Conditional Compilation

#

The build system (CMake) conditionally compiles the source code based on the settings in sdkconfig.

For example, suppose sdkconfig has the following settings:

CONFIG_MY_LED_ENABLE=y
CONFIG_MY_LED_GPIO=2

You can then use conditional compilation in your code as follows:

#include <stdio.h>
#include "driver/gpio.h"

void app_main(void)
{
#ifdef CONFIG_MY_LED_ENABLE
    gpio_reset_pin(CONFIG_MY_LED_GPIO);
    gpio_set_direction(CONFIG_MY_LED_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(CONFIG_MY_LED_GPIO, 1);  // LED ON
#endif
}
#

Besides the sdkconfig file, there are two closely related files.

File Description
sdkconfig (main config file) The actual configuration values are saved here
sdkconfig.defaults Default values (useful for managing initial settings in team development via repository)
build/config/sdkconfig.h Header referenced at compile time (auto-generated from sdkconfig)

When developing in a team and managing artifacts with Git, it is recommended to extract the initial settings needed by the team into sdkconfig.defaults.

Overview of sdkconfig.defaults

#

The sdkconfig.defaults file applies default values only when sdkconfig does not yet exist, for example when running idf.py menuconfig or idf.py build.
If sdkconfig already exists, sdkconfig.defaults is completely ignored, and sdkconfig takes precedence.
Note that even if sdkconfig.defaults contains settings not in sdkconfig, they will not be loaded.

your_project/
├── sdkconfig.defaults        ← Write initial settings here (used only when sdkconfig is absent)
├── sdkconfig                 ← Actual configuration file generated by menuconfig, etc.
├── main/
│   ├── CMakeLists.txt
│   └── ...

The format and content are the same as sdkconfig. For example, you can write:

CONFIG_LOG_DEFAULT_LEVEL=3
CONFIG_PROJECT_USE_LED=y
CONFIG_MY_DRIVER_GPIO_NUM=13

What is the Kconfig System?

#

The Kconfig system is a mechanism that automatically generates sdkconfig based on configuration options chosen by the user (e.g., whether to use Wi-Fi).

You create Kconfig files in each component or directory to define which configuration options are available.

Below is an example of a Kconfig file for a custom LED driver.
(The default LED GPIO on a typical ESP32 is 2, but on the ESP32 LOLIN D32 it is 5, so we specify “5.”)

menu "My LED Driver Configuration"

    config MY_LED_ENABLE
        bool "Enable LED driver"
        default y

    config MY_LED_GPIO
        int "GPIO number for LED"
        default 5
        depends on MY_LED_ENABLE

endmenu

When you run idf.py menuconfig, the settings are automatically generated in sdkconfig.
The options set by the user in menuconfig are ultimately written to the sdkconfig file and reflected in the build.

Set the options as shown in the menuconfig screen below, and then save.

Definitions like the following are created in the sdkconfig file:

#
# My LED Driver Configuration
#
CONFIG_MY_LED_ENABLE=y
CONFIG_MY_LED_GPIO=5
# end of My LED Driver Configuration
# end of Component config

Differences Between Kconfig and Kconfig.projbuild

#

There are two types of Kconfig files: Kconfig and Kconfig.projbuild.

Kconfig Files

Kconfig files are used as follows:

  • Placed in each component
  • Displayed in menuconfig for the user to select settings

Below is an example of a Kconfig file in a component:

menu "My Custom Driver Configuration"

    config USE_MY_DRIVER
        bool "Use my custom driver"
        default y

endmenu

Kconfig.projbuild Files

Kconfig.projbuild files are used as follows:

  • Used in the main/ directory or any project scope
  • When you want to collectively define additional settings for that project
  • Automatically loaded at build time and reflected in menuconfig

Below is an example of a Kconfig.projbuild file in main/:

menu "Project-wide Options"

    config PROJECT_USE_LED
        bool "Enable LED feature for the whole project"
        default y

endmenu

Notes on Writing

When you define USE_XXXX in a Kconfig or Kconfig.projbuild file, it is registered as CONFIG_USE_XXXX in sdkconfig. (The prefix CONFIG_ is automatically added when expanding sdkconfig.)

Behavioral Differences Between Kconfig and Kconfig.projbuild

#
Feature Kconfig Kconfig.projbuild
Source of loading CMakeLists.txt of each component Automatically loaded under the main directory
Main purpose Defining settings per component Defining settings that apply to the entire project
Automatically used? Requires idf_component_register() Automatically loaded (just having it in main is enough)
menuconfig display Displays automatically (if the component is used) Displays automatically (just being in main/ is enough)
Scope Component-level Project-wide or application-level

You can use them as follows:

  • Libraries and reusable components: Kconfig
  • Project-specific settings: Kconfig.projbuild
my_project/
├── main/
│   ├── CMakeLists.txt
│   ├── my_code.c
│   └── Kconfig.projbuild   ← ※Project-specific settings
├── components/
│   └── my_led_driver/
│       ├── Kconfig         ← ※Component settings
│       └── CMakeLists.txt
├── sdkconfig
└── build/
    └── config/
        └── sdkconfig.h

Overview of idf.py

#

In ESP-IDF projects, idf.py serves as the central tool for managing builds, flashing, monitoring, and more.
It is a Python-based CLI (command-line interface) that internally calls various tools like CMake and Ninja.

Here are some commonly used idf.py subcommands:

Subcommand Description
idf.py set-target esp32 Sets the target chip (ESP32, ESP32-C3, etc.)
idf.py menuconfig Edits sdkconfig in a GUI format (Kconfig-based)
idf.py build Builds using CMake and Ninja
idf.py flash Writes the compiled binary to the ESP32
idf.py monitor View UART logs with a serial monitor
idf.py flash monitor Run flash and monitor together
idf.py menuconfig Opens the configuration screen (ncurses-based)

Thus, idf.py acts as a “hub” for ESP-IDF development, simplifying the bridging of various tools and project management.

In the VSCode extension, you can invoke the same subcommands from the following screen:

Structure When Creating a Custom Component

#

For example, let's consider creating a custom component (a homemade LED driver) in components/my_led_driver/.

Arrange the directory structure as follows:

components/
└── my_led_driver/
    ├── CMakeLists.txt
    ├── my_led_driver.c
    └── include/
        └── my_led_driver.h

Configure components/my_led_driver/CMakeLists.txt as follows:

idf_component_register(SRCS "my_led_driver.c"
                    INCLUDE_DIRS "include"
                    REQUIRES <required libraries; omit REQUIRES if none>)

In main.c, which calls this custom component, include the following:

#include "my_led_driver.h"

In main/CMakeLists.txt, specify the custom component by setting REQUIRES my_led_driver as follows:

idf_component_register(SRCS "main.c"
                    INCLUDE_DIRS "."
                    REQUIRES my_led_driver)

Blinking LED Sample Program (Using Custom Component)

#

Below is a simple sample code for the custom component my_led_driver for ESP-IDF.
This is the basic structure for controlling an LED via GPIO (making the LED blink), commonly called “Blink”.

Below is an example project structure diagram including the custom component:

my_project/
├── CMakeLists.txt          ← Entry point defining the entire project
├── Makefile                ← Wrapper that just invokes the CMake build
├── sdkconfig               ← Build options set by menuconfig
├── build/                  ← Build artifacts (auto-generated)
├── main/
│   ├── CMakeLists.txt      ← Defines build targets in this directory (e.g., main.c)
│   └── main.c              ← Application entry point
│   └── Kconfig.projbuild   ← Project-specific definitions
├── components/             ← Location for custom components
│   └── my_led_driver/
│       ├── CMakeLists.txt  ← Build settings for the custom component
│       ├── my_led_driver.c
│       ├── Kconfig         ← Custom component definitions
│       └── include/
│           └── my_led_driver.h

Custom Component Sample

#

Below are examples of the Kconfig, header, C file, and CMakeLists.txt.

components/my_led_driver/Kconfig
(Setting the LED GPIO to 5)

menu "My LED Driver Configuration"

    config MY_LED_GPIO
        int "GPIO number for LED"
        default 5

endmenu

components/my_led_driver/my_led_driver.h
(Defines three functions: my_led_init, my_led_on, and my_led_off)

#pragma once

#include "driver/gpio.h"

#ifdef __cplusplus
extern "C" {
#endif

// Initialization function
void my_led_init(gpio_num_t gpio_num);

// ON/OFF control
void my_led_on(void);
void my_led_off(void);

#ifdef __cplusplus
}
#endif

components/my_led_driver/my_led_driver.c
(Implementation of the GPIO operations)

#include "my_led_driver.h"

static gpio_num_t led_gpio = GPIO_NUM_NC;

void my_led_init(gpio_num_t gpio_num)
{
    led_gpio = gpio_num;

    gpio_config_t io_conf = {
        .pin_bit_mask = 1ULL << led_gpio,
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    gpio_config(&io_conf);

    my_led_off(); // Initial state OFF
}

void my_led_on(void)
{
    if (led_gpio != GPIO_NUM_NC) {
        gpio_set_level(led_gpio, 1);
    }
}

void my_led_off(void)
{
    if (led_gpio != GPIO_NUM_NC) {
        gpio_set_level(led_gpio, 0);
    }
}

components/my_led_driver/CMakeLists.txt
(Since it controls GPIO, the esp_driver_gpio library is required)

idf_component_register(SRCS "my_led_driver.c"
                       INCLUDE_DIRS "include"
                       REQUIRES esp_driver_gpio)

Main Program Sample

#

Below are the Kconfig.projbuild, C file, and CMakeLists.txt.

main/Kconfig.projbuild
(Defines a flag for whether to use the LED driver)

menu "My LED Driver Configuration"

    config MY_LED_ENABLE
        bool "Enable LED driver"
        default y

endmenu

main/main.c
(Calls the custom component)

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "my_led_driver.h"

void app_main(void)
{
    my_led_init(CONFIG_MY_LED_GPIO);

    #ifdef CONFIG_MY_LED_ENABLE
    while (1) {
        my_led_on();
        vTaskDelay(pdMS_TO_TICKS(500));
        my_led_off();
        vTaskDelay(pdMS_TO_TICKS(500));
    }
    #endif    
}

main/CMakeLists.txt
(Requires the custom component)

idf_component_register(SRCS "main.c"
                    INCLUDE_DIRS "."
                    REQUIRES my_led_driver)

This way, components/my_led_driver is integrated into the ESP-IDF build as a custom component, resulting in an extensible project structure.

Conclusion

#

In this article, we explained the components of an ESP-IDF project and their roles, the position of the idf.py command, and how to create custom components.
ESP-IDF projects are centered around CMakeLists.txt and sdkconfig, allowing flexible management of builds and settings.
Additionally, for beginners, using idf.py means you can start development easily without worrying about environment dependencies or the complexities of CMake.

Compared to PlatformIO, ESP-IDF may seem difficult at first glance, but once you understand the purpose of each file and tool, you can confidently engage in development.

I hope this helps your IoT development.

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。