Technical Note
The LED Was Easy. Understanding the Registers Wasn’t.
What a simple LED exercise taught me about memory-mapped registers, documentation, and the hardware hidden behind high-level code.
- Published
- Reading time
- 4 minutes
- STM32
- Embedded C
- Memory-Mapped Registers
Related Learning
Embedded SystemsIntroduction
I had seen LEDs used as the first exercise in microcontroller programming many times. This time, the LED itself was not the difficult part. The difficult part was understanding everything that had to happen before PD12 on the STM32F407G-DISC1 could produce a visible result.
The final program was short enough to fit on one screen. My first mental model was not. Hexadecimal addresses, peripheral base addresses, register offsets, pin numbers, bit positions, and pointer dereferencing all seemed to occupy separate layers. I could follow an instruction and make the green LED turn on, but I wanted to understand why those particular operations reached that physical pin.
The addresses did not look like hardware
The three addresses initially looked arbitrary: one for a clock-control register, one for GPIOD mode, and one for GPIOD output data. I struggled to connect a value such as 0x40020C14 with a register diagram, and then connect that diagram with a pin labeled PD12 on the board.
Repeatedly returning to the STM32 reference documentation and the board user manual was what changed this. The documentation showed that an address can be read as structure rather than memorized as a mysterious constant: a peripheral region contains a GPIO block, the block has a base address, and each register appears at a defined offset. The board manual then connects the microcontroller pin to the onboard LED.
With repetition, those layers began to align, alhamdulillah. A pointer was no longer only a C-language topic. After casting the documented address to a pointer and dereferencing it, the program was reading or changing a hardware register at that location.
Following the short but important chain
Control path
Enable the GPIOD clock
→ Configure PD12 as an output
→ Set or clear bit 12 in the output register
→ Change the physical pin state
→ Turn the LED on or offThe clock step came first because the GPIO peripheral needed to be enabled before its registers could be used as intended. In the mode register, every pin is represented by a two-bit field. PD12 therefore uses bits 25 and 24. Clearing both bits and then setting bit 24 selects the output configuration without disturbing unrelated fields.
This was when 1U << 24 stopped looking like an isolated bitwise expression. It meant placing one at a precise position inside one defined field. The same reasoning applied to 1U << 12 in the output register: bit 12 represented the state associated with PD12.
Turning the reasoning into one visible state
C
#include <stdint.h>
int main(void)
{
volatile uint32_t *pClkCtrlReg = (volatile uint32_t *)0x40023830;
volatile uint32_t *pPortDModeReg = (volatile uint32_t *)0x40020C00;
volatile uint32_t *pPortDOutReg = (volatile uint32_t *)0x40020C14;
*pClkCtrlReg |= (1U << 3);
*pPortDModeReg &= ~(3U << 24);
*pPortDModeReg |= (1U << 24);
*pPortDOutReg |= (1U << 12);
while (1)
{
}
}The original learning exercise connected addresses to physical hardware. Memory-mapped registers require volatile-qualified access because hardware activity is externally observable, not ordinary memory. STM32 CMSIS/device headers normally provide these definitions and symbolic names.
Seeing the LED light did not prove that I understood every STM32 detail, but it gave each documentation step a physical consequence. If the peripheral clock was missing, the chain was incomplete. If the mode field was wrong, the pin was not prepared as an output. If the wrong output bit changed, the onboard green LED would not respond as expected.
The exercise also showed why read-modify-write operations deserve care. The OR and AND masks target selected bits while preserving other register state. In a larger program, concurrency and peripheral-specific behavior would require more attention, but this small example made the basic purpose of a mask concrete.
Blinking made register state visible over time
C
#include <stdint.h>
int main(void)
{
volatile uint32_t *pClkCtrlReg = (volatile uint32_t *)0x40023830;
volatile uint32_t *pPortDModeReg = (volatile uint32_t *)0x40020C00;
volatile uint32_t *pPortDOutReg = (volatile uint32_t *)0x40020C14;
*pClkCtrlReg |= (1U << 3);
*pPortDModeReg &= ~(3U << 24);
*pPortDModeReg |= (1U << 24);
while (1)
{
*pPortDOutReg |= (1U << 12);
for (uint32_t i = 0; i < 300000; i++)
{
}
*pPortDOutReg &= ~(1U << 12);
for (uint32_t i = 0; i < 300000; i++)
{
}
}
}Blinking made the relationship easier to see because the output register was no longer configured once and forgotten. Setting bit 12 and clearing bit 12 produced two observable states. The loop connected repeated software execution with repeated electrical change.
The empty loops were only a simple learning delay. They were not accurate timing, and a compiler may optimize an empty delay loop when it determines that the loop has no observable effect. A hardware timer is the more reliable future method when timing must be defined and repeatable.
I would also prefer device headers, symbolic register names, and suitable drivers as a program grows. They make intent clearer and reduce dependence on raw constants. Direct register access was still valuable here because it exposed the mechanism those abstractions eventually operate on. I learned to look through the convenience layer without assuming that every larger application should be written this way.
Key takeaways
- 01A documented address carries structure: peripheral base addresses and register offsets connect code to a specific hardware block.
- 02A bit mask becomes meaningful when it is tied to a documented field, a pin, and an observable result.
- 03A software delay loop is useful for a first demonstration, not for reliable timing.
- 04The LED was easy to see. Understanding everything that had to happen before it could light up was the real exercise.
Connected evidence