If you’re a software developer who wants to get into embedded systems, GPIO is probably the first thing you’ll encounter. It sounds fancy, but it’s actually one of the simplest concepts in hardware.

GPIO stands for General Purpose Input/Output. These are the pins on a microcontroller that you can configure to either read a signal or send a signal. That’s it. You tell the pin “be an output” and it can turn things on and off. You tell it “be an input” and it can read whether something is on or off.

In this post, I’ll explain how this actually works under the hood - how a line of C code becomes an electrical signal.


What is a Microcontroller?

Before we talk about GPIO, let’s understand what a microcontroller actually is.

A microcontroller is a tiny computer on a single chip. It has:

Unlike your laptop, a microcontroller doesn’t have an operating system. Your code runs directly on the hardware. There’s no Linux, no Windows, no scheduler. Just your code and the chip.

This is why embedded programming feels different. You’re not calling System.out.println(). You’re writing directly to memory addresses that control physical pins.


What is GPIO?

GPIO pins are the bridge between your code and the outside world. Each pin can be in one of two modes:

Output mode: The pin sends a signal. You set it HIGH (3.3V or 5V) or LOW (0V). This can turn on an LED, trigger a relay, or send data to another device.

Input mode: The pin reads a signal. It checks if the voltage is HIGH or LOW. This can detect a button press, read a sensor, or detect a signal from another device.

Most microcontrollers have multiple GPIO pins. For example, an STM32 has anywhere from 20 to over 100 GPIO pins, depending on the model.


The Magic: Memory-Mapped Registers

Here’s the key concept that connects software to hardware: memory-mapped registers.

In a microcontroller, every peripheral (GPIO, timer, UART) is controlled by writing to specific memory addresses. These addresses are mapped to hardware registers - small memory locations that control the behavior of the peripheral.

When you write 0x1 to a specific address, you’re not writing to RAM. You’re telling the GPIO peripheral to set a pin HIGH. The hardware interprets that value and changes the electrical output.

Here’s a simplified example for STM32:

// This address controls GPIO Port A
#define GPIOA_ODR  ((volatile uint32_t *)0x40020014)

// Set pin 5 HIGH (turn on LED)
*GPIOA_ODR |= (1 << 5);

// Set pin 5 LOW (turn off LED)
*GPIOA_ODR &= ~(1 << 5);

That’s it. You’re writing to a memory address, and the hardware does the rest. No libraries, no abstractions. Just raw memory writes.


Step by Step: What Happens When You Set a GPIO Pin

Let’s trace what happens when you write this line of code:

GPIOA->ODR |= (1 << 5);  // Set pin PA5 HIGH

Step 1: The CPU Executes the Instruction

The CPU reads the instruction from flash memory. It sees a write operation to the address of GPIOA->ODR (Output Data Register).

Step 2: The Bus Routes the Write

The CPU’s write signal goes through the AHB bus (Advanced High-performance Bus). This is the internal highway that connects the CPU to all peripherals.

Step 3: The GPIO Peripheral Receives the Write

The GPIO peripheral sees a write to its ODR register. It reads the value you wrote.

Step 4: The Output Driver Changes the Pin

The GPIO peripheral’s output driver circuit changes the voltage on pin PA5. If you wrote a 1, it connects the pin to VCC (3.3V). If you wrote a 0, it connects the pin to GND (0V).

Step 5: The Physical Pin Changes State

The actual metal pin on the chip changes voltage. If you have an LED connected to that pin, it turns on.

The entire process happens in nanoseconds. Your code runs at 72 MHz (or faster), so the pin changes state almost instantly.


Input: Reading a Button Press

Reading input works the opposite way. You configure a pin as input, then read its state.

Here’s how you might read a button on pin PA5:

// Read the Input Data Register for GPIOA
#define GPIOA_IDR  ((volatile uint32_t *)0x40020010)

// Check if pin 5 is HIGH
if (*GPIOA_IDR & (1 << 5)) {
    // Button is pressed
} else {
    // Button is not pressed
}

When you read from GPIOA_IDR, the GPIO peripheral looks at the voltage on pin PA5. If it’s above a certain threshold (usually around 2V for 3.3V logic), it returns 1. If it’s below, it returns 0.

This is how your code knows whether a button is pressed, a sensor is triggered, or a signal is present.


Configuring GPIO Pins

Before you can use a GPIO pin, you need to configure it. This is done through special registers called mode registers.

For STM32, each GPIO port has a MODER (Mode Register) that sets the mode for each pin:

// Configure PA5 as output (mode = 01)
GPIOA->MODER |= (1 << 10);   // Set bit 10
GPIOA->MODER &= ~(1 << 11);  // Clear bit 11

// Configure PA5 as input (mode = 00)
GPIOA->MODER &= ~(1 << 10);  // Clear bit 10
GPIOA->MODER &= ~(1 << 11);  // Clear bit 11

Each pin uses 2 bits in the mode register:

So for 16 pins per port, you need 32 bits (4 bytes) for the mode register.


A Complete Example: Blinking an LED

Let’s put it all together. Here’s the minimal code to blink an LED on STM32 without any libraries:

#include <stdint.h>

// Register addresses for GPIOA
#define GPIOA_MODER  ((volatile uint32_t *)0x40020000)
#define GPIOA_ODR    ((volatile uint32_t *)0x40020014)

// Simple delay function
void delay(int count) {
    for (int i = 0; i < count; i++) {
        __asm__("nop");
    }
}

int main(void) {
    // Configure PA5 as output
    *GPIOA_MODER |= (1 << 10);   // Set bit 10
    *GPIOA_MODER &= ~(1 << 11);  // Clear bit 11

    while (1) {
        // Set PA5 HIGH (LED on)
        *GPIOA_ODR |= (1 << 5);
        delay(1000000);

        // Set PA5 LOW (LED off)
        *GPIOA_ODR &= ~(1 << 5);
        delay(1000000);
    }
}

This code:

  1. Configures pin PA5 as output
  2. Enters an infinite loop
  3. Sets the pin HIGH (turns LED on)
  4. Waits a bit
  5. Sets the pin LOW (turns LED off)
  6. Waits a bit
  7. Repeats

No Arduino framework, no digitalWrite(), no abstractions. Just direct register manipulation. This is how embedded programming actually works.


Whyvolatile Matters

You’ll notice every pointer is declared as volatile. This is crucial.

Without volatile, the compiler might optimize away your reads or writes. It might think “I just wrote to this address, no need to write again” and skip the second toggle. But the hardware state can change independently of your code, so the compiler must always read/write to the actual address.

// WITHOUT volatile - compiler might optimize this away
uint32_t *reg = (uint32_t *)0x40020014;
*reg |= (1 << 5);
*reg |= (1 << 5);  // Compiler might skip this!

// WITH volatile - always reads/writes from hardware
volatile uint32_t *reg = (volatile uint32_t *)0x40020014;
*reg |= (1 << 5);
*reg |= (1 << 5);  // Always executes

Pull-Up and Pull-Down Resistors

When a GPIO pin is configured as input and nothing is connected, the voltage is floating. It can be HIGH, LOW, or somewhere in between. This is bad because your code might read random values.

To fix this, most microcontrollers have internal pull-up and pull-down resistors:

// Enable pull-up resistor on PA5 (STM32)
GPIOA->PUPDR |= (1 << 10);   // Set bit 10
GPIOA->PUPDR &= ~(1 << 11);  // Clear bit 11

This is why most button circuits use pull-up resistors. The pin is HIGH by default, and pressing the button pulls it LOW.


From Software to Electrical Signal

Here’s the full picture of what happens when you run *GPIOA_ODR |= (1 << 5):

[Your Code]  ->  [CPU]  ->  [Bus]  ->  [GPIO Peripheral]  ->  [Output Driver]  ->  [Physical Pin]
     |                                                                     |
     |                                                                     |
 Write to address 0x40020014                                    Pin voltage changes to 3.3V

The CPU executes your instruction, the bus routes the write to the GPIO peripheral, the peripheral updates its internal state, and the output driver changes the physical pin voltage.

This all happens in a single clock cycle. At 72 MHz, that’s about 14 nanoseconds.


Key Takeaways

  1. GPIO is just memory-mapped registers. Writing to specific memory addresses controls hardware pins.
  2. Output mode lets you send signals (turn things on/off). Input mode lets you read signals (detect button presses).
  3. Volatile is mandatory. Always use volatile when accessing hardware registers to prevent compiler optimizations.
  4. Configure before use. Set the pin mode (input/output) before reading or writing.
  5. Pull-up/pull-down resistors prevent floating inputs from giving random readings.

What’s Next

Now that you understand GPIO, you can explore:

GPIO is the foundation of embedded systems. Once you understand how code controls hardware through registers, everything else builds on top of that.


References