Blink LED: ESP-IDF Edition
Purpose
This document describes the complete setup process for developing an ESP32 firmware project using ESP-IDF.
The goal of this project is to establish a working ESP-IDF development environment before moving to SmartDB firmware development.
This includes:
ESP-IDF installation verification
Toolchain configuration
Project creation
Component dependency setup
Build system configuration
Firmware flashing
Debugging workflow
Development Environment
Host Machine
Component |
Version |
|---|---|
OS |
Ubuntu 24.04 |
Framework |
ESP-IDF v6.x |
Build System |
CMake + Ninja |
Language |
C/C++ |
Python Environment |
ESP-IDF managed Python |
ESP-IDF location:
~/esp/esp-idf
Project location:
~/projects/SmartDB/esp_idf/blink
ESP-IDF Environment Setup
Load ESP-IDF
Before using ESP-IDF commands:
source ~/esp/esp-idf/export.sh
This configures:
ESP-IDF tools
Compiler
Python environment
CMake integration
Ninja build system
Verify:
idf.py --version
Expected:
ESP-IDF v6.x
Creating ESP-IDF Project
Create project:
idf.py create-project blink
Enter directory:
cd blink
Initial structure:
blink/
├── CMakeLists.txt
└── main/
├── CMakeLists.txt
└── blink.c
Code
#include <stdio.h>
#include <stdbool.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "led_strip.h"
#include "sdkconfig.h"
static const char *TAG = "blink";
#define BLINK_GPIO CONFIG_BLINK_GPIO
static uint8_t s_led_state = 0;
#ifdef CONFIG_BLINK_LED_STRIP
static led_strip_handle_t led_strip;
static void blink_led(void) {
if (s_led_state) {
led_strip_set_pixel(led_strip, 0, 16, 16, 16);
led_strip_refresh(led_strip);
} else {
led_strip_clear(led_strip);
}
}
static void configure_led(void) {
ESP_LOGI(TAG, "Configuref to blink addressable LED");
led_strip_config_t strip_config = {
.strip_gpio_num = BLINK_GPIO,
.flags,with_dma = true
};
#if CONFIG_BLINK_LED_STRIP_BACKEND_RMT
led_strip_rmt_config_t rmt_config = {
.resolution_hz = 10 * 1000 * 1000,
.flags.with_dma = false
};
ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip));
#elif CONFIG_BLINK_LED_STRIP_BACKEND_SPI
led_strip_spi_config_t spi_config = {
.spi_bus = SPI2_HOST,
.flags.with_dma = true
};
ESP_ERROR_CHECK(led_strip_new_spi_device(&strip_config, &spi_config, &led_strip));
#else
#error "unsupported LED strip backend"
#endif
led_strip_clear(led_strip);
}
#elif CONFIG_BLINK_LED_GPIO
static void configure_led(void) {
gpio_reset_pin(BLINK_GPIO);
gpio_set_direction(BLINK_GPIO, GPIO_MODE_OUTPUT);
}
static void blink_led(void) {
gpio_set_level(BLINK_GPIO, s_led_state);
}
#else
#error "unsupported LED type"
#endif
void app_main(void) {
configure_led();
while (1) {
ESP_LOGI(TAG, "Turning the LED %s!", s_led_state == true ? "ON": "OFF");
blink_led();
s_led_state = !s_led_state;
vTaskDelay(CONFIG_BLINK_PERIOD/portTICK_PERIOD_MS);
}
}
Explanation
This is a typical ESP-IDF Blink LED example, but it is more advanced than a simple GPIO blink because it supports two types of LEDs:
Addressable LED strip (WS2812 / NeoPixel type)
Normal GPIO LED (on/off LED)
The code uses compile-time configuration (sdkconfig) to decide which implementation gets compiled.
Header files
#include <stdio.h>
#include <stdbool.h>
Standard C libraries.
stdio.h
Provides standard input/output functions:
printf()
ESP-IDF often uses its own logging system instead:
ESP_LOGI()
so this is not actually used here.
stdbool.h
Allows use of:
bool
true
false
Example:
bool led_on = true;
Instead of:
int led_on = 1;
FreeRTOS headers
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
ESP-IDF runs on FreeRTOS.
FreeRTOS provides:
tasks
delays
scheduling
synchronization
This line:
vTaskDelay()
comes from:
freertos/task.h
Example:
vTaskDelay(1000 / portTICK_PERIOD_MS);
means:
Pause this task for 1000 milliseconds.
GPIO driver
#include "driver/gpio.h"
Provides ESP32 GPIO control.
Functions used:
gpio_reset_pin()
gpio_set_direction()
gpio_set_level()
Example:
gpio_set_level(GPIO_NUM_2, 1);
sets GPIO 2 HIGH.
Logging
#include "esp_log.h"
ESP-IDF logging system.
Instead of:
printf("LED ON");
ESP-IDF uses:
ESP_LOGI(TAG,"LED ON");
Output:
I (1234) blink: LED ON
The format:
LEVEL (time) TAG: message
LED strip driver
#include "led_strip.h"
This is for addressable LEDs.
Example:
WS2812B
NeoPixel
SK6812
These LEDs are not simple ON/OFF.
They receive data:
ESP32
|
| single wire
|
WS2812
|
RGB LED
You send:
red = 16
green = 16
blue = 16
and the LED generates that color.
ESP-IDF configuration
#include "sdkconfig.h"
This includes settings generated by:
idf.py menuconfig
Example:
CONFIG_BLINK_GPIO=2
CONFIG_BLINK_PERIOD=1000
CONFIG_BLINK_LED_GPIO=y
After compiling, these become C macros.
Example:
#define BLINK_GPIO CONFIG_BLINK_GPIO
becomes:
#define BLINK_GPIO 2
Global variables
Logging tag
static const char *TAG = "blink";
Used here:
ESP_LOGI(TAG,"...");
static means this variable is only visible inside this file.
LED GPIO
#define BLINK_GPIO CONFIG_BLINK_GPIO
Instead of hardcoding:
#define BLINK_GPIO 2
ESP-IDF lets you configure it.
Example:
menuconfig
|
v
CONFIG_BLINK_GPIO=2
|
v
BLINK_GPIO
LED state
static uint8_t s_led_state = 0;
Stores current LED state.
Initially:
s_led_state = 0
meaning:
OFF
Later:
s_led_state = !s_led_state;
toggles:
0 -> 1
1 -> 0
Compile-time decision
This is the important part:
#ifdef CONFIG_BLINK_LED_STRIP
means:
“If addressable LED support is enabled, compile this section.”
Otherwise:
#elif CONFIG_BLINK_LED_GPIO
compile GPIO version.
Otherwise:
#error "unsupported LED type"
stop compilation.
Case 1: Addressable LED
#ifdef CONFIG_BLINK_LED_STRIP
Example:
WS2812 LED.
LED object
static led_strip_handle_t led_strip;
This stores the LED strip driver instance.
Think:
led_strip
|
+-- GPIO
+-- protocol
+-- buffer
Blink function
static void blink_led(void)
Controls the LED.
Turn ON
if (s_led_state)
If state = 1:
led_strip_set_pixel(led_strip, 0, 16, 16, 16);
Arguments:
LED index
|
v
0
RGB:
R = 16
G = 16
B = 16
So:
white dim light
Then:
led_strip_refresh()
actually sends the data.
Turn OFF
led_strip_clear(led_strip);
sets all LEDs:
RGB = 0,0,0
Case 2: Normal GPIO LED
#elif CONFIG_BLINK_LED_GPIO
This is the simple one.
Configure GPIO
static void configure_led(void) {
gpio_reset_pin(BLINK_GPIO);
gpio_set_direction(
BLINK_GPIO,
GPIO_MODE_OUTPUT
);
}
Meaning:
Reset GPIO:
GPIO2
|
reset
Then:
GPIO2 = OUTPUT
because we want to drive the LED.
Blink GPIO
static void blink_led(void) {
gpio_set_level(
BLINK_GPIO,
s_led_state
);
}
If:
s_led_state = 1
then:
GPIO2 = HIGH
LED ON
If:
s_led_state = 0
then:
GPIO2 = LOW
LED OFF
app_main()
This is the ESP-IDF equivalent of:
main()
in normal C.
ESP-IDF starts FreeRTOS first, then calls:
app_main()
Configure LED
configure_led();
Depending on config:
Either:
GPIO setup
or:
LED strip driver setup
Infinite loop
while(1)
Embedded systems usually run forever.
Print status
ESP_LOGI(TAG, "Turning the LED %s!", s_led_state == true ? "ON":"OFF");
This uses the ternary operator:
Equivalent:
if(s_led_state) {
print("ON");
} else {
print("OFF");
}
Blink
blink_led();
Actually changes hardware.
Toggle state
s_led_state = !s_led_state;
Example:
Before:
s_led_state = 0
After:
s_led_state = 1
Next loop:
1 -> 0
Delay
vTaskDelay(
CONFIG_BLINK_PERIOD / portTICK_PERIOD_MS
);
Example:
CONFIG_BLINK_PERIOD = 1000
wait:
1000 ms = 1 second
Overall execution flow
ESP32 Boot
|
v
FreeRTOS starts
|
v
app_main()
|
v
configure_led()
|
+----------------+
| |
GPIO LED LED Strip
| |
v v
set GPIO setup driver
output RMT/SPI
|
v
while(1)
|
v
LED ON
|
v
wait
|
v
LED OFF
|
v
wait
|
v
repeat
Your firmware will likely follow the simpler pattern:
app_main()
|
+-- initialize drivers
|
+-- initialize WiFi
|
+-- initialize sensors
|
+-- create FreeRTOS tasks
|
+-- forever
|
+-- read sensor
+-- process data
+-- send MQTT
This blink example is basically showing the ESP-IDF architecture pattern: initialize hardware → run tasks forever.
Why using Preprocessor
The preprocessor runs before compilation. It modifies your source code based on conditions or substitutions.
For example:
#define BLINK_GPIO CONFIG_BLINK_GPIO
If CONFIG_BLINK_GPIO is 2, the compiler effectively sees:
#define BLINK_GPIO 2
No variable is created; it’s just text replacement.
Conditional compilation
#ifdef CONFIG_BLINK_LED_GPIO
// GPIO implementation
#elif CONFIG_BLINK_LED_STRIP
// LED strip implementation
#else
#error "unsupported LED type"
#endif
Only one branch is compiled.
Suppose sdkconfig contains:
#define CONFIG_BLINK_LED_GPIO 1
The compiler effectively sees:
// GPIO implementation
The LED strip code is completely ignored—it isn’t even compiled.
Why not use if
Imagine writing:
if (led_type == GPIO) {
gpio_set_level(...);
} else {
led_strip_refresh(...);
}
The compiler would have to compile both implementations.
On a microcontroller:
More flash memory used
Larger firmware
Longer compile time
Unnecessary dependencies
Using the preprocessor removes unused code entirely.
Why use pointers
A pointer stores the memory address of something.
Example:
int x = 10;
int *p = &x;
Memory:
Address Value
0x1000 ----> 10
^
|
p
p doesn’t store 10; it stores where 10 lives.
Why ESP-IDF uses pointers
Look at:
static led_strip_handle_t led_strip;
This is a handle (internally it’s essentially a pointer to a driver object).
Later:
ESP_ERROR_CHECK(
led_strip_new_rmt_device(
&strip_config,
&rmt_config,
&led_strip
)
);
Notice the &.
You’re passing the address of led_strip.
The function can then write into that variable:
Before
led_strip
+------+
| NULL |
+------+
|
| function
v
After
led_strip
+-----------+
| 0x3FFB100 |
+-----------+
Now led_strip refers to an initialized driver object.
Without pointers, the function couldn’t modify the caller’s variable directly.
Another example
Suppose you have:
void set(int x) {
x = 100;
}
Calling:
int value = 5;
set(value);
does not change value, because x is a copy.
With a pointer:
void set(int *x) {
*x = 100;
}
Calling:
set(&value);
changes the original variable.
Why use struct
A struct groups related information into one object.
Without a struct:
int gpio;
bool dma;
int resolution;
You now have three separate variables that belong together.
Instead:
typedef struct {
int gpio;
bool dma;
int resolution;
} config_t;
Now:
config_t cfg;
contains everything.
In ESP-IDF
Example:
led_strip_config_t strip_config = {
.strip_gpio_num = BLINK_GPIO,
.flags.with_dma = true
};
Think of it as:
strip_config
+----------------------+
| strip_gpio_num = 2 |
+----------------------+
| flags |
| with_dma = true |
+----------------------+
The driver receives one configuration object instead of many separate arguments.
Imagine if the function looked like this:
led_strip_new(
gpio,
dma,
resolution,
brightness,
pixel_count,
backend,
invert,
...
);
That quickly becomes hard to read and maintain.
Instead:
led_strip_new(&strip_config);
The configuration can grow over time without changing the function signature.
Why the . operator?
You access struct members with .:
strip_config.strip_gpio_num = 2;
Meaning:
strip_config
|
+-- strip_gpio_num
Why .member = value?
ESP-IDF often initializes structs like this:
led_strip_config_t cfg = {
.strip_gpio_num = 2,
.flags.with_dma = true
};
These are designated initializers.
Compared to:
led_strip_config_t cfg = {2, true};
the designated version is much clearer because you immediately know what each value represents.
How they work together
In the blink example, all three features complement each other:
sdkconfig
│
▼
Preprocessor (#ifdef)
│
▼
Select GPIO or LED strip implementation
│
▼
Create configuration struct
│
▼
Pass the struct's address (pointer)
│
▼
Driver initializes the hardware
This combination is one of the reasons C remains the dominant language for embedded systems: the preprocessor allows compile-time customization, structs organize related configuration cleanly, and pointers let functions efficiently work with hardware resources and large data structures without unnecessary copying.
Project Migration to main.c
The default generated project referenced:
main/blink.c
However, ESP-IDF applications require an entry function:
void app_main(void)
The source file was renamed:
blink.c
|
v
main.c
Updated:
main/CMakeLists.txt
from:
idf_component_register(SRCS "blink.c")
to:
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
)
Build System Verification
First build:
idf.py build
The project successfully generated:
bootloader
partition table
application ELF
However, linking failed:
undefined reference to `app_main'
Issue: Missing app_main
Error
undefined reference to app_main
Cause
ESP-IDF FreeRTOS startup calls:
app_main()
but the compiled source did not contain this function.
Solution
Added:
void app_main(void) {}
to:
main/main.c
After rebuilding:
idf.py build
The linker error was resolved.
Issue: Missing Source File
Error
Cannot find source file:
main/blink.c
Cause
CMake still referenced the old filename.
Solution
Updated:
main/CMakeLists.txt
with:
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
)
Then cleaned CMake cache:
idf.py fullclean
Rebuilt:
idf.py build
LED Blink Implementation
Initial example used ESP-IDF configurable LED abstraction:
CONFIG_BLINK_LED_GPIO
CONFIG_BLINK_LED_STRIP
CONFIG_BLINK_PERIOD
These values are generated from:
sdkconfig
through:
idf.py menuconfig
Issue: Unsupported LED Type
Error
#error "unsupported LED type"
and:
CONFIG_BLINK_PERIOD undeclared
Cause
The example expected menuconfig options that were not enabled.
The code contained:
#if CONFIG_BLINK_LED_GPIO
#elif CONFIG_BLINK_LED_STRIP
#else
#error "unsupported LED type"
#endif
No LED type was selected.
Solution: Simplify GPIO Control
For the first SmartDB firmware prototype, the configurable example was replaced with direct GPIO control.
Implemented:
gpio_reset_pin()
gpio_set_direction()
gpio_set_level()
This removes dependency on:
CONFIG_BLINK_*
and creates a simpler firmware base.
Final Firmware Structure
blink/
├── CMakeLists.txt
├── sdkconfig
└── main/
├── CMakeLists.txt
└── main.c
...
Build Workflow
Development cycle
After modifying code:
idf.py build
Flash:
idf.py -p /dev/ttyUSB0 flash
Monitor:
idf.py -p /dev/ttyUSB0 monitor
Combined:
idf.py build flash monitor
Hardware Connection
Find ESP32 device:
ls /dev/ttyUSB*
Example:
/dev/ttyUSB0
If permission denied:
sudo usermod -aG dialout $USER
Restart session.
Clean Build
If CMake cache becomes corrupted:
idf.py fullclean
idf.py build