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.
constandvolatile.- 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
ODRbits when writing or toggling; - keep the interface independent of one exact MCU vendor.
Header
#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
IDRreturns 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
- Compile with
-Wall -Wextra -Werror. - Run the host verification program.
- Inspect
MODERin a debugger and confirm only the selected pin's two mode bits change. - Test pins
0,15, and an invalid pin such as16. - On target hardware, replace the fake
gpio_port_tobject with the actual port base address from the datasheet.
Additional review checks:
- confirm
1u << pinis never evaluated forpin >= 32; - confirm output writes do not assign
ODR = maskby 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
- ARM CMSIS: Peripheral Access Layer
- STMicroelectronics: STM32 GPIO Application Notes
- SEI CERT C: Integer Rules