AVR GPIO in C: Using DDRx, PORTx and PINx for LEDs and Push Buttons

General purpose input/output (GPIO) is where every microcontroller course begins. On an AVR, each I/O port is controlled by exactly three registers, and once you understand how they interact you can drive LEDs, relays and transistors, and read switches and digital sensors without any library.

In this tutorial you will build a small circuit with four LEDs and one push button on an ATmega328P, and write a program that changes the LED pattern each time the button is pressed. Along the way you will learn about internal pull-up resistors, the toggle feature of the PINx register, and simple software debouncing.

The Three Port Registers

Each port (B, C and D on the ATmega328P; A, B, C and D on the ATmega16/32) has three 8-bit registers, where bit n controls pin n of that port.

RegisterNameWhat it does
DDRxData Direction Register1 makes the pin an output, 0 makes it an input
PORTxData RegisterFor outputs: 1 drives high, 0 drives low. For inputs: 1 enables the internal pull-up
PINxInput Pins AddressReading gives the actual logic level on each pin. On the ATmega328P, writing 1 to a bit toggles the PORTx bit

Combining DDRx and PORTx gives four possible pin states. The input-with-pull-up state is the one you will use most for switches, because it removes the need for an external resistor.

DDxnPORTxnPin state
00Input, high impedance (floating)
01Input with internal pull-up enabled
10Output, driven low
11Output, driven high

Circuit

Connect four LEDs to PB0 to PB3 (DIP pins 14 to 17), each through a 330 Ω resistor to ground, so a high output turns the LED on. Connect a push button between PD2 (pin 4) and ground. No external pull-up is needed because the internal one will be enabled. On an Arduino Uno these pins are D8 to D11 and D2.

Each AVR pin can source or sink a few tens of milliamps, and the datasheet specifies 40 mA as an absolute maximum per pin, but total port current is also limited. With 330 Ω at 5 V a red LED draws roughly 9 mA, which is comfortable. For relays or motors, always use a transistor driver.

Program: Button Steps Through LED Patterns

GPIO demo: four LEDs on PB0-PB3, button on PD2 with internal pull-up
#define F_CPU 16000000UL

#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>

#define LED_MASK   0x0F          /* PB0..PB3 */
#define BTN_PIN    PIND2

/* LED patterns shown one after another */
static const uint8_t patterns[] = {
    0x01, 0x02, 0x04, 0x08,      /* single LED walking */
    0x03, 0x0C, 0x0F, 0x00       /* pairs, all on, all off */
};

/* Returns 1 once per press, after a simple debounce */
static uint8_t button_pressed(void)
{
    if (!(PIND & (1 << BTN_PIN))) {          /* pin low = pressed */
        _delay_ms(20);                       /* let contacts settle */
        if (!(PIND & (1 << BTN_PIN))) {
            while (!(PIND & (1 << BTN_PIN))) /* wait for release */
                ;
            _delay_ms(20);
            return 1;
        }
    }
    return 0;
}

int main(void)
{
    uint8_t index = 0;

    DDRB  |= LED_MASK;                 /* PB0..PB3 outputs */
    PORTB &= ~LED_MASK;                /* all LEDs off */

    DDRD  &= ~(1 << DDD2);             /* PD2 input */
    PORTD |=  (1 << PORTD2);           /* enable pull-up on PD2 */

    while (1) {
        if (button_pressed()) {
            index++;
            if (index >= sizeof(patterns))
                index = 0;
            /* write only the low four bits, keep PB4..PB7 unchanged */
            PORTB = (PORTB & ~LED_MASK) | patterns[index];
        }
    }
}

How the Code Works

The line DDRB |= LED_MASK; sets bits 0 to 3 of DDRB while leaving bits 4 to 7 alone. This read-modify-write style matters because PB3, PB4 and PB5 are also the SPI programming pins, and other code may be using the upper bits of the port.

For the button, DDRD bit 2 is cleared to make PD2 an input and PORTD bit 2 is set to switch on the pull-up. The pin therefore reads 1 when the button is open and 0 when it is pressed, which is why the test is !(PIND & (1 << BTN_PIN)). Always read inputs from PINx, never from PORTx; PORTx only returns what you last wrote.

Mechanical switches bounce, producing several quick transitions over a few milliseconds. The function confirms the press after 20 ms, waits for release, then waits again so that release bounce is not counted as a new press. This blocking method is fine for a demo; later tutorials replace it with a timer-based approach that does not stall the program.

Fast toggling with PINx

On the ATmega328P, writing a 1 to a PINx bit toggles the matching PORTx bit in a single instruction, so PINB = (1 << PINB5); flips the LED on PB5. This feature does not exist on the ATmega16 and ATmega32; there you must use PORTB ^= (1 << PB5);, which compiles to a read, an XOR and a write.

Common Mistakes

  • Writing PORTB = 0x01; when you only meant to change one pin. This clears every other bit of the port, turning off other outputs and pull-ups.
  • Reading PORTD instead of PIND to check a switch. The result never changes when you press the button.
  • Leaving a button input without a pull-up, so it floats and triggers randomly when you touch the wires.
  • Using PC2 to PC5 on an ATmega16/32 while the JTAG interface is still enabled by fuse. Those pins will not respond to DDRC and PORTC.
  • Using PD0 and PD1 for LEDs on an Arduino Uno board. These are connected to the USB-serial chip and are also the USART pins.

Summary

AVR GPIO uses three registers per port: DDRx sets direction, PORTx sets output level or enables the pull-up, and PINx reads the real pin level. Use bitwise OR, AND and XOR with shifted masks so that only the intended pins change, read inputs from PINx, and debounce mechanical switches. The next tutorial replaces polling of the button with an external interrupt on INT0.