This STM32 Bare-Metal LED Blink Guide demonstrates how to blink an LED using direct register access, without relying on HAL or LL libraries, to validate CPU execution, clock configuration, and GPIO control.
If you are interested in a higher-level approach, I have already published a separate article on blinking an LED using STM32 HAL functions, which you are welcome to read. HAL (an abstracted, portable approach) is convenient and productive, especially for application-level development. However, if you are serious about embedded systems and firmware engineering, in my opinion, your journey should start at a lower level. Understanding the silicon directly is the foundation for effective debugging, optimization, and reliable firmware bring-up.
Hardware Setup:
| Component Name | Quantity | Purchase Link |
|---|---|---|
| STM32 NUCLEO Development Board | 1 | Amazon |
| LED | 1 | Amazon |
| Resistor 220 ohm | 1 | Amazon |
| Breadboard | 1 | Amazon |
| Jumper wire pack | 1 | Amazon |
| USB Mini-B cable | 1 | Amazon |
For troubleshooting, some extremely useful test equipment
| Equipment Name | Purchase Link |
|---|---|
| Best Oscilloscope for Professionals | Amazon |
| Best Oscilloscope for Beginners and Students | Amazon |
| Logic Analyzer | Amazon |
| Best Budget Multimeter | Amazon |
| Adjustable Bench Power Supply | Amazon |
Why bare metal?
Blinking an LED without HAL helps you understand what is really happening under the hood: the clock configuration, register access, and GPIO control that are hidden behind library functions. This addresses a critical question every firmware engineer must ask when bringing up a new board:
By avoiding library APIs, you gain direct visibility into the microcontroller’s low-level operations—clock enabling, GPIO configuration, register writes, and timing control. These are the exact details that HAL abstracts away, and understanding them is essential for debugging, performance tuning, and low-level bring-up.
If you are new to STM32, start with bare-metal programming before moving to HAL or RTOS-based development. This step is not optional if you want to become a strong firmware engineer.
At this point, you may be wondering:
Is STM32 HAL bad?
The answer is No.
Professionally, I use HAL and LL APIs extensively. They are well-designed, productive, and absolutely suitable for real-world projects. However, HAL intentionally hides three fundamental hardware realities that you must understand to debug real embedded systems:
- Clock gating: peripherals are disabled by default
- Hardware configuration: GPIO pins never configure themselves
- Register-level control: software intent does not always match hardware behavior
When firmware fails to start, peripherals do not respond, or pins remain silent, these are the first areas that require investigation.
If you can power up a blank or dead board and make an LED blink using direct register access, you can debug:
- startup and reset failures.
- broken clock trees and PLL misconfiguration.
- bootloader issues.
- execution from external flash
- early RTOS bring-up problems
If you cannot, these projects will be frustrating and slow.
The Bare-metal LED blinking is not an academic exercise. But it is the foundation of STM32 bring-up, debugging, and firmware reliability.
Bare Metal STM32 Programming for LED Blinking:
The purpose of this article is not to teach you how to blink an LED using an STM32. But it is about understanding what is really happening inside your STM32 chip.
Here, the LED is simply a tool. The real goal is to prove one thing: that the chip is alive and behaving exactly as the datasheet says it should.
When a board powers up for the first time, you don’t care about clean architecture, reusable drivers, or software layers. Those come later. Right now, you care about certainty. You need clear answers to very basic questions:
- Is the CPU executing instructions?
- Is the clock tree running?
- Are the registers readable and writable?
- Do the pins change state when commanded?
Bare-metal programming exists for this exact phase. It removes every layer that could hide a mistake. There is no middleware to blame, and no framework to “fix” things behind your back. If something doesn’t work, the reason is always concrete: a clock is disabled, a bit is wrong, or a hardware assumption is false.
This is why experienced firmware engineers always start with bare metal. Before trusting any library, you must first trust the silicon, and the only way to do that is to talk to it directly.
Once you understand that, the next step becomes obvious. Before writing even a single line of code, you must read the documentation. This is not optional. Because bare-metal programming has zero tolerance for assumptions.
The microcontroller will do exactly what the reference manual says, nothing more, nothing less. If your code fails, it is almost never because the chip is broken. It is because something was misunderstood or skipped.
Before Writing Code: Read the Documents
As mentioned earlier, before writing even a small piece of code for a microcontroller, you should first become familiar with the MCU documentation. These documents are provided by the microcontroller manufacturer and explain how the hardware actually works.
For every STM32 device, there are two documents that are especially important during the bring-up stage.
1. The Board User Manual (or Schematic):
The Board User Manual explains how the STM32 chip is connected on your specific development board.
It tells you:
- Physical Connection: Which GPIO port and pin is the LED connected to?
- Circuit Logic: Is the LED active-high or active-low?
- Components: Are there external resistors or transistors involved?
2. The STM32 Reference Manual:
The STM32 Reference Manual explains how the microcontroller works internally. This is the most important document when you are writing bare-metal code.
It tells you:
- Where each peripheral is located in memory
- Which registers control GPIO, RCC, timers, UART, and other peripherals
- What each register does and what every bit means
- Which clock must be enabled before a peripheral can be used
In simple words, this document explains how the silicon works inside the chip.
From the reference manual, you must find three critical things:
- Bus: Which bus (AHB, APB1, APB2) the GPIO peripheral is connected to
- Clock: Which RCC clock-enable bit turns that bus and peripheral ON
- Registers: The exact register layout used to configure pin mode, output type, speed, and pull-up/down
None of this can be guessed. And none of it should be blindly copied from another project.
Why These Documents Matter
Think of it this way: the reference manual explains how the hardware works, and the board manual shows how everything is connected. You need both.
If an LED does not blink, the cause is never mysterious. It is almost always one of these:
- The GPIO clock is not enabled.
- The pin mode is configured incorrectly.
- The wrong GPIO pin is used.
- The LED is wired differently than expected.
- The CPU never reached your code
High-level libraries can hide these mistakes. Bare-metal code cannot.
STM32 Bare-Metal LED Blink (Register-Level C):
After reading the reference manual and datasheet, you are ready to begin coding. To keep things simple and structured, we will break the process into three steps.
The 3-Step Hardware Handshake (STM32 Bare Metal):
In STM32 bare-metal programming, every peripheral follows the same 3-step hardware handshake: clock, configuration, and action. It does not matter if you are using GPIO, UART, SPI, I2C, or Timers. If even one step is missing, the peripheral will not work.
The rule is simple:
- Clock — Wake the peripheral up.
- Configuration — Tell it how to behave.
- Action — Tell it what to do.
Step 1: Enable the Peripheral Clock (“Wake-Up Peripheral”):
After a reset, almost all STM32 peripherals are clock-gated to save power. A peripheral without a clock is effectively inactive.
If you try to access a peripheral register before enabling its clock:
- The write may be silently ignored (common for many APB/AHB peripherals).
- The CPU may trigger a HardFault, immediately crashing the system (typically due to a bus fault on clock-gated or non-existent registers).
Step 2: Configure the Peripheral (The Setup):
Once the clock is enabled, the peripheral is accessible—but it is unconfigured. For GPIO, pins typically reset to Input Mode (floating) or Analog Mode (on low-power devices). You must explicitly configure the pin as an output before it can drive an LED.
For a simple LED blink, the default GPIO settings are usually sufficient. But in real-world projects, always verify the following:
- OTYPER → Push-Pull vs Open-Drain.
- OSPEEDR → Output speed (Low / Medium / High / Very High)
- PUPDR → Pull-up / Pull-down configuration
These settings directly impact signal integrity, power consumption, and EMI, even for basic GPIO usage.
Step 3: Control the Pin:
Once the pin is configured as an output, you are ready to control the connected hardware (in this case, an LED).
Whether writing a HIGH signal to the pin turns the LED ON or OFF depends on how the LED is connected. If the LED is configured as active HIGH, setting the pin HIGH turns it ON. If it is active LOW, setting the pin HIGH turns it OFF.
| Circuit Configuration | Connection | Logic State | Result |
|---|---|---|---|
| Active High (Sourcing) | Pin → LED → GND | Write 1 (High) | LED ON |
| Write 0 (Low) | LED OFF | ||
| Active Low (Sinking) | VCC → LED → Pin | Write 0 (Low) | LED ON |
| Write 1 (High) | LED OFF |
Now it is time to write the code. The following example demonstrates the most fundamental hardware sanity check on an STM32H5 device: blinking an LED using direct register access. Here, we do not use HAL, LL, or any other abstractions, just the CPU, the registers, and the silicon.
If this works, three critical facts are immediately proven:
- The CPU is running.
- The clock tree is functional.
- The AHB peripheral bus is alive.
#include "stm32h563xx.h"
/* LD1 on NUCLEO-H563ZI is connected to PB0 */
#define LED_PIN_POS (0U)
#define LED_PIN (1U << LED_PIN_POS)
/* BSRR values for atomic GPIO access */
#define LED_SET_BIT (LED_PIN)
#define LED_RESET_BIT (LED_PIN << 16U)
/**
* @brief Crude blocking delay
*
* Not accurate. Not power-efficient.
* More than sufficient to prove the CPU is executing instructions.
*/
static void delay(volatile uint32_t count)
{
while (count--)
{
__NOP();
}
}
int main()
{
/* ----------------------------------------------------
* 1. Enable GPIOB peripheral clock (mandatory)
* ---------------------------------------------------- */
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN;
/* Read-back ensures the clock domain is active */
(void)RCC->AHB2ENR;
/* ----------------------------------------------------
* 2. Configure PB0 as a push-pull output
* ---------------------------------------------------- */
/* MODER = 01 ? General-purpose output */
GPIOB->MODER &= ~(3U << (LED_PIN_POS * 2U));
GPIOB->MODER |= (1U << (LED_PIN_POS * 2U));
/* Push-pull output */
GPIOB->OTYPER &= ~LED_PIN;
/* Medium speed is sufficient for an LED */
GPIOB->OSPEEDR &= ~(3U << (LED_PIN_POS * 2U));
GPIOB->OSPEEDR |= (1U << (LED_PIN_POS * 2U));
/* No pull-up, no pull-down */
GPIOB->PUPDR &= ~(3U << (LED_PIN_POS * 2U));
/* Start from a known state: LED OFF */
GPIOB->ODR &= ~LED_PIN;
/* ----------------------------------------------------
* 3. Blink forever using atomic GPIO access
* ---------------------------------------------------- */
while (1)
{
GPIOB->BSRR = LED_SET_BIT; /* LED ON */
delay(1000000);
GPIOB->BSRR = LED_RESET_BIT; /* LED OFF */
delay(1000000);
}
}
The above C program blinks the onboard LED (PB0) on the STM32H563 Nucleo board using direct register access, validating core execution, clock configuration, and GPIO access, without HAL or LL.
Let’s now walk through the code step by step and understand how it works.
1. Device Header:
#include "stm32h563xx.h"
This header file provides:
- Base addresses of peripherals
- Register structures (RCC, GPIO, etc.)
- Bit definitions and masks
Without this file, the compiler has no knowledge of the STM32H563 register map.
2. LED and Bit Definitions
#define LED_PIN_POS (0U) #define LED_PIN (1U << LED_PIN_POS)
It defines PB0 as the LED pin using bit masks for clean and readable code.
3. Atomic GPIO Control (BSRR):
#define LED_SET_BIT (LED_PIN) #define LED_RESET_BIT (LED_PIN << 16U)
Here, we use the BSRR (Bit Set/Reset Register) to change the GPIO pin state atomically. This prevents read-modify-write problems that can occur when multiple bits share the same register.
4. Simple Blocking Delay:
static void delay(volatile uint32_t count)
A simple blocking delay implemented using __NOP(). This delay is not cycle-accurate or time-precise, but it is sufficient to demonstrate that the CPU is running and executing instructions.
5. Enable GPIO Clock:
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN; (void)RCC->AHB2ENR;
Enabling a clock is not instantaneous. It takes a few CPU cycles for the clock signal to propagate through the silicon and reach the peripheral. If you enable the clock and immediately access the GPIO registers in the very next instruction, the write may fail because the peripheral is not ready yet.
To avoid this, we perform a dummy read:
(void)RCC->AHB2ENR;
This forces the CPU to complete the bus transaction, ensuring the peripheral clock is active before we continue.
6. Configure PB0 as Output:
To make PB0 work correctly as an LED output, we must configure five key parameters. Each one controls a specific aspect of the pin’s behavior.
1. GPIO Mode:
GPIOB->MODER &= ~(3U << (LED_PIN_POS * 2U)); GPIOB->MODER |= (1U << (LED_PIN_POS * 2U));
Each GPIO pin uses two bits in MODER:
| Value | Mode | Note |
|---|---|---|
| 00 | Input | Default (High-Z) |
| 01 | Output | Required for LEDs |
| 10 | Alternate Function | UART / SPI / I2C |
| 11 | Analog | ADC / DAC |
2. Set Output Type (OTYPER)
Select push-pull mode so the pin can actively drive both HIGH and LOW.
GPIOB->OTYPER &= ~LED_PIN; // 0 = Push-Pull
Why?
Open drain requires an external pull-up resistor. Push-pull directly drives the LED and is the correct choice here.
3.Set Output Speed (OSPEEDR)
GPIO output speed controls the signal edge rate (slew rate), not the toggle frequency.
/* Set PB0 to Medium speed (01) */ GPIOB->OSPEEDR &= ~(3U << (LED_PIN_POS * 2U)); // Clear both bits GPIOB->OSPEEDR |= (1U << (LED_PIN_POS * 2U)); // Set to Medium speed
An LED does not require fast signal edges, so medium speed is sufficient.
This helps:
- Reduce electromagnetic interference (EMI)
- Lower dynamic power consumption
- Improve signal integrity
Using high or very high speed is unnecessary for LEDs and may increase noise without any benefit.
4. Disable Pull-Up / Pull-Down Resistors (PUPDR):
Since the pin is actively driven as an output, internal pull-up or pull-down resistors are not required.
GPIOB->PUPDR &= ~(3U << (LED_PIN_POS * 2U)); // 00: No pull-up, no pull-down
5. Set a Known Initial State (ODR)
Force the LED OFF before entering the main loop. It ensures the LED starts in a known OFF state before the application enters the main execution loop.
GPIOB->ODR &= ~LED_PIN;
7 Blink the LED Forever:
while (1)
{
GPIOB->BSRR = LED_SET_BIT; /* LED ON */
delay(1000000);
GPIOB->BSRR = LED_RESET_BIT; /* LED OFF */
delay(1000000);
}
This infinite loop performs a simple and reliable sequence:
- Turns the LED ON using an atomic set (single-cycle GPIO updates) operation.
- Waits for a visible delay.
- Turns the LED OFF using an atomic reset operation.
- Waits again.
I hope you now have a clear understanding of how to blink an LED using bare-metal programming on an STM32. If you have any questions or doubts, feel free to ask in the comments, I will be happy to help.
Frequently Asked Questions (FAQ):
Q1: What is bare-metal programming in STM32?
Bare-metal programming in STM32 means writing firmware that directly accesses hardware registers without using abstraction layers such as HAL, LL, or an RTOS. It provides full control over clocks, peripherals, and GPIO behavior.
Q2: Why should I blink an LED without HAL on STM32?
Blinking an LED without HAL proves that the CPU is executing code, the clock tree is running, and GPIO registers are accessible. It is the first and most reliable hardware sanity check during STM32 board bring-up.
Q3: Is STM32 HAL bad for professional projects?
No. STM32 HAL is well-designed and widely used in professional products. However, it hides low-level details such as clock gating and register configuration, which every firmware engineer must understand for effective debugging and bring-up.
Q4: Which STM32 registers are involved in LED blinking?
The key registers are:
- RCC AHB/APB enable registers – to enable the GPIO clock
- GPIOx_MODER – to configure the pin mode
- GPIOx_OTYPER – to select push-pull or open-drain
- GPIOx_OSPEEDR – to control output speed
- GPIOx_PUPDR – to configure pull-up/down resistors
- GPIOx_BSRR – for atomic pin set/reset operations
Q5: Why is the BSRR register recommended for GPIO control?
BSRR allows atomic set and reset of GPIO pins without read-modify-write operations. This prevents race conditions and is safer than writing directly to the ODR register, especially in interrupt-driven systems.
Q6: Do I need to enable the clock for GPIO on STM32?
A: Yes. STM32 peripherals are clock-gated (disabled) by default to save power. You must set the specific enable bit in the RCC register before the GPIO will function.
Related posts:
- Blinking an LED on STM32: Step-by-Step Guide.
- STM32 GPIO Button and LED Control – Understanding STM32 Button Input.
- STM32 GPIO tutorial: Modes, Functions, and Configuration
- Map-Based Method for GPIO Alternate Function Configuration.