Controlling LEDs may look simple on the surface—but when you step away from Arduino functions and high-level SDKs, you unlock a deeper layer of embedded engineering. Bare-metal programming lets you talk directly to the hardware, giving you uncompromised control, predictable timing, and industry-grade understanding of how microcontrollers truly work.
In today’s tutorial, we will build LED blinking patterns without using ESP-IDF libraries, Arduino functions, or abstractions. Instead, we will work directly with register-level C code on the ESP32.
If you’re looking to strengthen your fundamentals, prepare for core embedded interviews, or gain confidence in low-level programming, this project offers a high-impact learning opportunity.
You can also watch the step-by-step demonstration on our official YouTube channel:
And download the complete project from GitHub:
👉 Source Code: https://github.com/ashus3868/LED-Patterns-Bare-Metal.git
Why Bare-Metal Programming?
Most microcontroller tutorials abstract away the hardware through convenient libraries. While great for prototyping, they hide the inner workings of:
- GPIO direction configuration
- Output enable registers
- Peripheral clocks
- Memory-mapped IO
- Timing control through busy loops
Bare-metal programming gives you hands-on access to all of these, enabling a deeper understanding of how your MCU works under the hood.
This approach mirrors how firmware is developed in industry for production-grade systems—precise, lightweight, and fully deterministic.
Project Overview
We will implement multiple LED patterns using:
Hardware Used
Key Learning Outcomes
- Configuring GPIO pins at the register level
- Writing raw values to memory-mapped registers
- Implementing custom delay loops
- Generating dynamic patterns purely in C
- Understanding how hardware responds to bit-level operations
Hardware Connection
For this demonstration, we will connect three LEDs to the following GPIO pins:
| LED | ESP32 GPIO |
|---|---|
| LED 1 | GPIO 2 |
| LED 2 | GPIO 4 |
| LED 3 | GPIO 5 |
Each LED should be connected in series with a current-limiting resistor (220Ω recommended).
System Architecture: What Happens Internally?
To create LED patterns without libraries, your code must manage the ESP32’s GPIO registers directly:
✔ Enable GPIO output direction
Configure the GPIO’s direction registers so the pins function as outputs.
✔ Control high/low levels
Write to SET and CLEAR registers to turn LEDs ON or OFF.
✔ Manage timing on your own
Implement a delay loop using a simple CPU busy-wait, since no OS or SDK services are available.
This methodology gives you real-time insight into how bits control hardware.
Core Concepts Behind the Code
Here is the full code to generate LED patterns:
#include <stdio.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
// for output GPIOs
#define GPIO_OUT_W1TS_REG 0x3FF44008
#define GPIO_OUT_W1TC_REG 0x3FF4400C
#define GPIO_ENABLE_REG 0x3FF44020
#define GPIO_PIN 5
#define GPIO_PIN1 2
#define GPIO_PIN2 4
// Disabled the watchdog to make this work
void delay(volatile uint32_t cycles) {
while (cycles--);
}
void app_main(void)
{
volatile uint32_t* gpio_out_w1ts_reg = (volatile uint32_t*)GPIO_OUT_W1TS_REG;
volatile uint32_t* gpio_out_w1tc_reg = (volatile uint32_t*)GPIO_OUT_W1TC_REG;
volatile uint32_t* gpio_enable_reg = (volatile uint32_t*)GPIO_ENABLE_REG;
*gpio_enable_reg |= (1 << GPIO_PIN);
*gpio_enable_reg |= (1 << GPIO_PIN1);
*gpio_enable_reg |= (1 << GPIO_PIN2);
while (1) {
*gpio_out_w1ts_reg = (1 << GPIO_PIN); // Set GPIO2 high
delay(5000000); // 500 ms
*gpio_out_w1tc_reg = (1 << GPIO_PIN); // Set GPIO2 low
delay(5000000);
*gpio_out_w1ts_reg = (1 << GPIO_PIN1); // Set GPIO2 high
delay(5000000); // 500 ms
*gpio_out_w1tc_reg = (1 << GPIO_PIN1); // Set GPIO2 low
delay(5000000);
*gpio_out_w1ts_reg = (1 << GPIO_PIN2); // Set GPIO2 high
delay(5000000); // 500 ms
*gpio_out_w1tc_reg = (1 << GPIO_PIN2); // Set GPIO2 low
delay(5000000);
}
}
Let’s break the code down into simple, hardware-centric concepts.
1. Header Files
#include <stdio.h>
#include <stdint.h>
<stdint.h>gives you fixed-width integer types likeuint32_t.
2. Register Definitions
#define GPIO_OUT_W1TS_REG 0x3FF44008
#define GPIO_OUT_W1TC_REG 0x3FF4400C
#define GPIO_ENABLE_REG 0x3FF44020
#define GPIO_PIN 5
#define GPIO_PIN1 2
#define GPIO_PIN2 4
These constants represent memory-mapped register addresses inside the ESP32.
What these registers do:
| Register | Function |
|---|---|
GPIO_OUT_W1TS_REG | Write-1-To-Set. Writing a bit = sets that GPIO HIGH. |
GPIO_OUT_W1TC_REG | Write-1-To-Clear. Writing a bit = sets that GPIO LOW. |
GPIO_ENABLE_REG | Enables GPIO pins as outputs. |
Instead of using gpio_set_direction() or gpio_set_level(), you directly write to these hardware addresses.
Pins Used
GPIO_PIN = 5GPIO_PIN1 = 2GPIO_PIN2 = 4
These correspond to your LED pins.
3. Custom Delay Function
void delay(volatile uint32_t cycles) {
while (cycles--);
}
A very simple busy-wait loop:
- Keeps the CPU spinning until the counter reaches zero.
- No timers, no libraries, no RTOS delays.
- Not accurate for real-time applications, but sufficient for LED blinking.
4. Main Application
void app_main(void)
{
ESP-IDF calls app_main() after initialization.
5. Creating Register Pointers
volatile uint32_t* gpio_out_w1ts_reg = (volatile uint32_t*)GPIO_OUT_W1TS_REG;
volatile uint32_t* gpio_out_w1tc_reg = (volatile uint32_t*)GPIO_OUT_W1TC_REG;
volatile uint32_t* gpio_enable_reg = (volatile uint32_t*)GPIO_ENABLE_REG;
Here you convert the register addresses into pointers so that you can write to them like normal variables.
volatiletells the compiler: Do not optimize away these writes, since they affect hardware.
Each pointer now directly maps to a hardware register.
6. Configure GPIO Pins as Output
*gpio_enable_reg |= (1 << GPIO_PIN);
*gpio_enable_reg |= (1 << GPIO_PIN1);
*gpio_enable_reg |= (1 << GPIO_PIN2);
This line sets the bit corresponding to each pin in the GPIO enable register.
What it does:
- Shifts
1by the pin number. - ORs it into the
GPIO_ENABLE_REG. - That enables output mode for GPIO 5, GPIO 2, and GPIO 4.
Equivalent to:
gpio_set_direction(GPIO_X, GPIO_MODE_OUTPUT);
But done directly at the register level.
7. Infinite Loop for Blinking LEDs
while (1) {
This continuously cycles through the three LEDs one after another.
Pattern 1: Toggle GPIO5
*gpio_out_w1ts_reg = (1 << GPIO_PIN);
delay(5000000);
*gpio_out_w1tc_reg = (1 << GPIO_PIN);
delay(5000000);
W1TSsets GPIO5 HIGH.- Delay.
W1TCclears GPIO5 LOW.- Delay.
This produces a visible ON/OFF LED blink.
Pattern 2: Toggle GPIO2
*gpio_out_w1ts_reg = (1 << GPIO_PIN1);
delay(5000000);
*gpio_out_w1tc_reg = (1 << GPIO_PIN1);
delay(5000000);
Same logic, different pin.
Pattern 3: Toggle GPIO4
*gpio_out_w1ts_reg = (1 << GPIO_PIN2);
delay(5000000);
*gpio_out_w1tc_reg = (1 << GPIO_PIN2);
delay(5000000);
Completes the sequential LED blinking.
How the Pattern Works (Summary)
- Enable GPIO pins as outputs
- Turn a pin ON (write-1-to-set)
- Wait
- Turn it OFF (write-1-to-clear)
- Move to next LED
- Repeat forever
This creates a running LED pattern across three pins, implemented with pure register-level C.
Why This Code Is Valuable
This program teaches several industrial embedded firmware concepts:
- Direct register manipulation
- Memory-mapped IO
- Bit-level hardware control
- Manual timing without RTOS
- Understanding GPIO architecture
- Executing deterministic bare-metal logic
It strips away all abstraction and exposes you to how microcontrollers truly interface with the physical world.
LED Patterns Implemented
The GitHub project includes multiple patterns such as:
1. Sequential Running Lights
LED1 → LED2 → LED3 → repeat
Ideal for understanding how to toggle multiple pins efficiently.
2. Reverse Running Pattern
LED3 → LED2 → LED1 → repeat
Teaches bidirectional logic loops.
3. Blink-All Pattern
All LEDs ON → delay → all LEDs OFF
A simple but useful test pattern.
4. Alternate Pattern
LED1 & LED3 ON, LED2 OFF → reverse
Demonstrates simultaneous bit manipulations.
5. Custom Patterns
Students can easily extend this project by modifying the pattern logic inside the loop.
Every pattern is driven purely through register writes—no HAL, no drivers, no abstraction.
Project Flowchart
- Configure GPIO direction registers
- Initialize all LED pins as output
- Implement multiple LED patterns
- Toggle GPIO registers to create effects
- Loop continuously
This simple control framework mirrors how industrial firmware is structured.
Code Availability
The complete project, including all LED patterns and bare-metal register-level implementations, is available here:
👉 GitHub Repository:
https://github.com/ashus3868/LED-Patterns-Bare-Metal.git
You can clone, modify, and expand the project as per your learning goals.
Video Tutorial
For a practical, real-time understanding, follow the YouTube demonstration:
👉 https://youtu.be/Zt7vrzsHHMc
The video includes board setup, wiring explanation, and pattern execution using the same codebase.
Hands-on Exercises to Explore Further
Once you’re comfortable generating patterns, try extending the project:
⭐ Add speed control
Use input GPIO to change pattern speed dynamically.
⭐ Create brightness effects
Simulate PWM-like behavior using fast toggling.
⭐ Add more LEDs
Scale patterns to 5, 8, or even 16 LEDs.
⭐ Create pattern selection mode
Switch patterns with a push button.
⭐ Port the code to other MCUs
Try the same bare-metal approach on STM32, PIC, or AVR.
These exercises push you toward a more advanced firmware engineering mindset.
Conclusion
Bare-metal programming unlocks a powerful, foundational understanding of embedded systems. By generating LED patterns directly through register-level C code, you get complete visibility into how microcontrollers truly work—bit by bit and clock by clock.
This project is a great starting point for anyone aiming to master low-level firmware development, improve problem-solving intuition, and build production-ready embedded workflows.
Explore the demo, download the code, experiment with your own patterns, and continue your journey toward becoming a high-value embedded engineer.





