Loading header...

Exercise: Write a Register-Level GPIO Driver

This exercise builds a tiny register-level GPIO driver. The goal is not to cover one specific microcontroller perfectly; the goal is to practice the patterns used by real data sheets: base addresses, volatile registers, mode fields, output bits, masks, and tests.

The exercise is intentionally host-buildable. You can compile and run the verification program on a PC before replacing the fake register block with a real MCU base address.

Learning Objectives

By the end of this exercise, you should be able to:

  • model a peripheral register block in C;
  • configure a pin direction using masks and shifts;
  • set, clear, toggle, and read GPIO pins;
  • test driver logic without physical hardware.

Prerequisites

  • Structures, headers, and source modules.
  • Bit masks and register field manipulation.
  • const and volatile.
  • Ability to compile a small C program with warnings enabled.

Concrete Task

Create a GPIO driver for a simplified 16-pin port with these registers:

Register Purpose
MODER two bits per pin: 00 input, 01 output
IDR input data register
ODR output data register

Implement:

  • gpio_configure_output;
  • gpio_configure_input;
  • gpio_write;
  • gpio_toggle;
  • gpio_read.

Constraints:

  • do not access invalid pins;
  • do not dereference a null port pointer;
  • modify only the selected pin's mode field;
  • preserve all unrelated ODR bits when writing or toggling;
  • keep the interface independent of one exact MCU vendor.
#ifndef GPIO_DRIVER_H
#define GPIO_DRIVER_H

#include <stdbool.h>
#include <stdint.h>

typedef struct {
    volatile uint32_t MODER;
    volatile uint32_t IDR;
    volatile uint32_t ODR;
} gpio_port_t;

bool gpio_valid_pin(uint32_t pin);
void gpio_configure_output(gpio_port_t *port, uint32_t pin);
void gpio_configure_input(gpio_port_t *port, uint32_t pin);
void gpio_write(gpio_port_t *port, uint32_t pin, bool level);
void gpio_toggle(gpio_port_t *port, uint32_t pin);
bool gpio_read(const gpio_port_t *port, uint32_t pin);

#endif

The struct is the register map. In the host test it is just RAM. On real hardware the pointer would refer to the peripheral base address from the datasheet.

Implementation

#include "gpio_driver.h"

#define GPIO_PIN_COUNT          16u
#define GPIO_MODE_BITS_PER_PIN  2u
#define GPIO_MODE_INPUT         0u
#define GPIO_MODE_OUTPUT        1u

static uint32_t pin_mask(uint32_t pin)
{
    return 1u << pin;
}

static uint32_t mode_shift(uint32_t pin)
{
    return pin * GPIO_MODE_BITS_PER_PIN;
}

bool gpio_valid_pin(uint32_t pin)
{
    return pin < GPIO_PIN_COUNT;
}

static void gpio_set_mode(gpio_port_t *port, uint32_t pin, uint32_t mode)
{
    if ((port == 0) || !gpio_valid_pin(pin)) {
        return;
    }

    uint32_t shift = mode_shift(pin);
    uint32_t mask = 3u << shift;
    uint32_t value = (mode << shift) & mask;

    port->MODER = (port->MODER & ~mask) | value;
}

void gpio_configure_output(gpio_port_t *port, uint32_t pin)
{
    gpio_set_mode(port, pin, GPIO_MODE_OUTPUT);
}

void gpio_configure_input(gpio_port_t *port, uint32_t pin)
{
    gpio_set_mode(port, pin, GPIO_MODE_INPUT);
}

void gpio_write(gpio_port_t *port, uint32_t pin, bool level)
{
    if ((port == 0) || !gpio_valid_pin(pin)) {
        return;
    }

    if (level) {
        port->ODR |= pin_mask(pin);
    } else {
        port->ODR &= ~pin_mask(pin);
    }
}

void gpio_toggle(gpio_port_t *port, uint32_t pin)
{
    if ((port == 0) || !gpio_valid_pin(pin)) {
        return;
    }

    port->ODR ^= pin_mask(pin);
}

bool gpio_read(const gpio_port_t *port, uint32_t pin)
{
    if ((port == 0) || !gpio_valid_pin(pin)) {
        return false;
    }

    return (port->IDR & pin_mask(pin)) != 0u;
}

The helper functions are private because they are declared static in the source file. The public API stays small and testable.

Verification Program

#include <assert.h>
#include "gpio_driver.h"

int main(void)
{
    gpio_port_t port = {0};

    gpio_configure_output(&port, 3u);
    assert((port.MODER & (3u << 6u)) == (1u << 6u));

    gpio_write(&port, 3u, true);
    assert((port.ODR & (1u << 3u)) != 0u);

    gpio_toggle(&port, 3u);
    assert((port.ODR & (1u << 3u)) == 0u);

    port.IDR = 1u << 7u;
    assert(gpio_read(&port, 7u));
    assert(!gpio_read(&port, 6u));

    gpio_configure_output(&port, 20u);
    gpio_write(0, 1u, true);

    return 0;
}

Build it as two translation units:

cc -std=c11 -Wall -Wextra -Werror gpio_driver.c test_gpio_driver.c -o test_gpio_driver
./test_gpio_driver

On Windows with a GCC-like toolchain, the same command works in PowerShell if cc is in PATH.

Expected Behavior

  • Pin 3 mode bits become 01.
  • Writing high sets bit 3 in ODR.
  • Toggling pin 3 clears bit 3.
  • Reading IDR returns the physical input bit.
  • Invalid pins and null pointers fail harmlessly.

The verification program should print nothing and exit with status 0. If an assertion fails, the program stops at the failing line.

Verification Steps

  1. Compile with -Wall -Wextra -Werror.
  2. Run the host verification program.
  3. Inspect MODER in a debugger and confirm only the selected pin's two mode bits change.
  4. Test pins 0, 15, and an invalid pin such as 16.
  5. On target hardware, replace the fake gpio_port_t object with the actual port base address from the datasheet.

Additional review checks:

  • confirm 1u << pin is never evaluated for pin >= 32;
  • confirm output writes do not assign ODR = mask by mistake;
  • confirm input configuration clears both mode bits for that pin;
  • confirm target-specific atomic set/reset registers are used if concurrent contexts can write the same output register.

Common Failure Symptoms

Symptom Likely cause
wrong pin changes mode shift should be pin * 2
writing one pin clears others assigned ODR = mask instead of using set/clear
invalid pin corrupts register missing bounds check
optimizer removes polling missing volatile on register fields
hardware does not match test register layout differs from the simplified exercise
pin 15 fails only shift or bounds check off by one
interrupt changes lost unsafe read-modify-write on shared output register

Debugging Guidance

  • Print register values in hexadecimal after every operation.
  • Test one pin at a time before looping over many pins.
  • Compare every shift and mask against the datasheet bit table.
  • Check whether the real MCU offers atomic set/reset registers; prefer them when available.
  • Add assertions for before-and-after register values, not just final logical results.
  • If real hardware fails, confirm the peripheral clock and pin mux are enabled before blaming the GPIO driver.

Extension Challenge

Add gpio_write_masked(gpio_port_t *port, uint32_t mask, uint32_t values) so multiple pins can be updated together. Then add a test that changes pins 0, 2, and 5 without disturbing pin 7.

One possible implementation:

void gpio_write_masked(gpio_port_t *port, uint32_t mask, uint32_t values)
{
    if (port == 0) {
        return;
    }

    uint32_t valid_mask = (GPIO_PIN_COUNT == 32u)
                        ? UINT32_MAX
                        : ((1u << GPIO_PIN_COUNT) - 1u);

    mask &= valid_mask;
    port->ODR = (port->ODR & ~mask) | (values & mask);
}

For this 16-pin exercise, valid_mask is 0x0000FFFF.

Concise Explained Solution

Each GPIO pin uses one bit in IDR and ODR, but two bits in MODER. The driver computes a single-bit mask for data registers and a two-bit field mask for mode configuration. It clears only the target mode field, inserts the new value, and leaves every other bit unchanged.

Summary

This exercise connects the main embedded C ideas so far: headers, structures, volatile registers, masks, shifts, and host-side verification. Real GPIO peripherals add pull-ups, alternate functions, speed, drive strength, and atomic set/reset registers, but the register-level discipline is the same.

Further Reading

Mind Map

mindmap root((GPIO Driver Exercise)) Goal Build register driver Host-test logic Prepare for real MCU Registers MODER two bits per pin IDR input bits ODR output bits Formulas Pin mask equals 1u shifted pin Mode shift equals pin times 2 Mode mask equals 3u shifted mode shift Valid mask equals 0xFFFF Implementation Check null pointer Check pin less than 16 Preserve unrelated bits Use volatile fields Verification Compile warnings as errors Assert register values Test pin 0 and 15 Test invalid pin Common mistakes ODR assignment clears pins Wrong mode shift Missing bounds check Real layout mismatch