Skip to main content

WS2812 RGB LED

In Hello World, your main.c looked like this:

src/main.c (Hello World)
#include <stdio.h>

int main(void)
{
printf("Hello World! %s\n", CONFIG_BOARD);
return 0;
}

Six lines. No hardware. No drivers. No configuration.

Now you are going to light up a real LED — and the code will look more complex. That is okay. The complexity is not in the logic, it is in the setup: telling Zephyr which hardware exists, which driver to use, and which pin to talk to. Once that is wired up in the overlay and prj.conf, the application code itself stays clean.

This page walks through that transition step by step.


The ESP32-S3-DevKitC has one addressable RGB LED wired to GPIO48, driven via I2S. This page runs the official Zephyr LED strip sample on it — your first real peripheral application.

Prerequisites

  • Overlay file from Writing Overlays already in place at boards/esp32s3_devkitc_esp32s3_procpu.overlay

prj.conf

prj.conf
CONFIG_LOG=y
CONFIG_LED_STRIP=y
CONFIG_LED_STRIP_LOG_LEVEL_DBG=y

The full sample code

This is the official Zephyr LED strip sample, unmodified:

src/main.c
/*
* Copyright (c) 2017 Linaro Limited
* Copyright (c) 2018 Intel Corporation
* Copyright (c) 2024 TOKITA Hiroshi
*
* SPDX-License-Identifier: Apache-2.0
*/

#include <errno.h>
#include <string.h>

#define LOG_LEVEL 4
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(main);

#include <zephyr/kernel.h>
#include <zephyr/drivers/led_strip.h>
#include <zephyr/device.h>
#include <zephyr/drivers/spi.h>
#include <zephyr/sys/util.h>

#define STRIP_NODE DT_ALIAS(led_strip)

#if DT_NODE_HAS_PROP(DT_ALIAS(led_strip), chain_length)
#define STRIP_NUM_PIXELS DT_PROP(DT_ALIAS(led_strip), chain_length)
#else
#error Unable to determine length of LED strip
#endif

#define DELAY_TIME K_MSEC(CONFIG_SAMPLE_LED_UPDATE_DELAY)

#define RGB(_r, _g, _b) { .r = (_r), .g = (_g), .b = (_b) }

static const struct led_rgb colors[] = {
RGB(CONFIG_SAMPLE_LED_BRIGHTNESS, 0x00, 0x00), /* red */
RGB(0x00, CONFIG_SAMPLE_LED_BRIGHTNESS, 0x00), /* green */
RGB(0x00, 0x00, CONFIG_SAMPLE_LED_BRIGHTNESS), /* blue */
};

static struct led_rgb pixels[STRIP_NUM_PIXELS];

static const struct device *const strip = DEVICE_DT_GET(STRIP_NODE);

int main(void)
{
size_t color = 0;
int rc;

if (device_is_ready(strip)) {
LOG_INF("Found LED strip device %s", strip->name);
} else {
LOG_ERR("LED strip device %s is not ready", strip->name);
return 0;
}

LOG_INF("Displaying pattern on strip");
while (1) {
for (size_t cursor = 0; cursor < ARRAY_SIZE(pixels); cursor++) {
memset(&pixels, 0x00, sizeof(pixels));
memcpy(&pixels[cursor], &colors[color], sizeof(struct led_rgb));

rc = led_strip_update_rgb(strip, pixels, STRIP_NUM_PIXELS);
if (rc) {
LOG_ERR("couldn't update strip: %d", rc);
}

k_sleep(DELAY_TIME);
}

color = (color + 1) % ARRAY_SIZE(colors);
}

return 0;
}

What each part does?

Macro / FunctionWhat it does
DT_ALIAS(led_strip)Resolves the led-strip alias from the overlay to the actual device node. No hardcoded device names.
DT_PROP(... chain_length)Reads chain-length = <1> from the overlay at compile time. The array size is set by the DTS, not by a #define in your code.
DEVICE_DT_GET(STRIP_NODE)Gets the driver instance bound to ws2812@0. Fails to compile if the node doesn't exist or the driver isn't enabled.
device_is_ready(strip)Confirms the driver initialized successfully at runtime. Always check this before using any device.
led_strip_update_rgb(strip, pixels, count)Sends the pixel buffer to the LED. For I2S-based WS2812, this triggers a DMA transfer.
The Zephyr pattern

Notice that DT_ALIAS, DT_PROP, and DEVICE_DT_GET are all compile-time macros — they read from the devicetree and generate code before your firmware runs. device_is_ready and led_strip_update_rgb are the only runtime calls. This split is intentional: Zephyr catches misconfiguration at build time, not on the board.

Kconfig — one more piece of the puzzle

You may have noticed the sample uses CONFIG_SAMPLE_LED_UPDATE_DELAY and CONFIG_SAMPLE_LED_BRIGHTNESS in main.c. These are not built-in Zephyr symbols — they are application-defined Kconfig values.

This is a pattern you will see throughout Zephyr: CONFIG_* is not just for enabling drivers. Your own application can define symbols too, giving you a clean way to expose tuneable parameters without hardcoding them. The Kconfig file in your project root is where you declare them.

Create Kconfig in your project root:

Kconfig
# SPDX-License-Identifier: Apache-2.0

menu "WS2812 Sample Configuration"

config SAMPLE_LED_UPDATE_DELAY
int "Delay between LED updates in ms"
default 50

config SAMPLE_LED_BRIGHTNESS
int "LED brightness"
default 16
range 1 255

endmenu

source "Kconfig.zephyr"
warning

source "Kconfig.zephyr" at the end is required — it pulls in the full Zephyr Kconfig tree. Without it the build fails.


Final project structure

Before you build, your project should look like this:

devzone/hello_world/
├── boards/
│ └── esp32s3_devkitc_esp32s3_procpu.overlay ← hardware description
├── CMakeLists.txt
├── Kconfig ← application config symbols
├── prj.conf ← enabled features
└── src/
└── main.c ← application code

Every file has one job. Together they give Zephyr everything it needs to build your firmware.

Build and flash

west build -b esp32s3_devkitc/esp32s3/procpu .
west flash

Expected output

*** Booting Zephyr OS build v4.4.0 ***
[00:00:00.312] <inf> main: Found LED strip device ws2812@0
[00:00:00.312] <inf> main: Displaying pattern on strip

The LED cycles through red → green → blue on repeat.



🎉 You are already further than you think

Take a moment to look at what you have built.

You started with a Hello World — a single printf that printed a string over serial and did nothing else. Now your project drives a real peripheral: a DMA-backed I2S bus, an addressable RGB LED 🌈, a devicetree overlay that routes the signal to the right pin, and a Zephyr driver that abstracts all of it behind a clean API.

devzone/hello_world/
├── boards/
│ └── esp32s3_devkitc_esp32s3_procpu.overlay ← you wrote this ✅
├── CMakeLists.txt
├── Kconfig ← you configured this ✅
├── prj.conf ← you configured this ✅
└── src/
└── main.c ← you understand this ✅

That is not a toy project anymore. That is the skeleton of a real embedded product — hardware described, drivers selected, peripherals routed. Every production Zephyr project on the planet starts from exactly this structure and grows outward from it.

This is how firmware grows in the real world 🚀 — one peripheral at a time, one overlay at a time. Small steps that compound into something powerful.

🏆 Congratulations. This is how real firmware is built.