Direct GPIO Access on ESP32: LED Patterns Using Bare-Metal C Programming

Bare-Metal ESP32 LED Patterns Guide | Memory-Mapped GPIO Programming in C
2
0
8 min read

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:

LEDESP32 GPIO
LED 1GPIO 2
LED 2GPIO 4
LED 3GPIO 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 like uint32_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:

RegisterFunction
GPIO_OUT_W1TS_REGWrite-1-To-Set. Writing a bit = sets that GPIO HIGH.
GPIO_OUT_W1TC_REGWrite-1-To-Clear. Writing a bit = sets that GPIO LOW.
GPIO_ENABLE_REGEnables 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 = 5
  • GPIO_PIN1 = 2
  • GPIO_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.

  • volatile tells 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 1 by 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);
  • W1TS sets GPIO5 HIGH.
  • Delay.
  • W1TC clears 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)

  1. Enable GPIO pins as outputs
  2. Turn a pin ON (write-1-to-set)
  3. Wait
  4. Turn it OFF (write-1-to-clear)
  5. Move to next LED
  6. 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

  1. Configure GPIO direction registers
  2. Initialize all LED pins as output
  3. Implement multiple LED patterns
  4. Toggle GPIO registers to create effects
  5. 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.

Leave a Reply