Just added: Embedded C From Zero
Updated Aug 16, 2026 Edit on GitHub

Talking to Hardware: Registers and GPIO

In phase 3 you learned to flip individual bits in a number. Now the payoff: on a microcontroller, some of those numbers are not ordinary variables. They are registers, and flipping a bit in one moves real voltage on a real pin. This is where C stops being about data on a screen and starts controlling the physical world.

We will use the AVR ATmega328P, the chip on an Arduino Uno, because its registers are simple, well documented, and you can run every example in this phase for free in Wokwi, an in-browser simulator, without owning any hardware.

What a register actually is

A hardware register is a fixed memory address that the chip's designers wired directly to a piece of hardware. When your C code writes a byte to that address, you are not storing data for later - you are setting the state of the circuitry connected to it. Read from that address and you are sampling the actual voltage on the pins.

On the ATmega328P, the register named PORTB lives at data address 0x25. Write a bit there and a physical pin changes. That is the whole idea:

📝 Terminology. A GPIO pin is a General-Purpose Input/Output pin: one you control from software, as either an output (you set its voltage) or an input (you read its voltage). The ATmega328P groups its GPIO pins into ports named B, C, and D, and each port has three registers that control it.

The three registers behind every port

For any port x (B, C, or D), you get three registers, and phase 3's bit operations are exactly how you drive them:

  • DDRx - the Data Direction Register. A 1 bit makes that pin an output; a 0 bit makes it an input. This is the first thing you set.
  • PORTx - for an output pin, the bit you write here is the level: 1 drives it high, 0 drives it low. For an input pin, writing 1 here enables that pin's internal pull-up resistor (more on that below).
  • PINx - read this to sample the actual voltage on the pins right now. This is how you read a button.

You find these names, and every bit inside them, in the chip's datasheet - the ATmega328P datasheet has a register summary and a chapter per peripheral. That document is the source of truth for embedded work. Nobody memorizes it; you learn to look things up in it.

Hello, world: blink the on-board LED

The Arduino Uno has an LED soldered to pin 13, which the AVR calls PB5 (bit 5 of port B). Blinking it is the embedded equivalent of printing "hello, world."

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

void setup() {
    DDRB |= (1 << PB5);        // PB5 is an OUTPUT (Uno pin 13, the on-board LED)
}

void loop() {
    PORTB |= (1 << PB5);       // drive PB5 high: LED on
    _delay_ms(500);
    PORTB &= ~(1 << PB5);      // drive PB5 low: LED off
    _delay_ms(500);
}

setup() runs once at power-up and loop() runs forever after - they are the two functions the AVR's startup code calls for you. In a from-scratch bare-metal program you would write the same thing as int main(void) with the setup at the top and the blink inside a while (1) loop; setup and loop are that split into two named functions. The register code inside is identical either way. The names PB5, DDRB, and PORTB come from <avr/io.h>.

Try it: start a new Arduino Uno project at wokwi.com, paste this in, and Run. The on-board LED blinks once a second, no wiring needed.

Reading a button, and why inputs need help

Now flip the direction and read a pin. Wire a pushbutton from pin PD2 to ground. To read it you configure PD2 as an input, then sample PIND:

#include <avr/io.h>

void setup() {
    DDRB  |= (1 << PB5);       // LED output, as before
    DDRD  &= ~(1 << PD2);      // PD2 is an INPUT (the button)
    PORTD |=  (1 << PD2);      // enable the internal pull-up on PD2
}

void loop() {
    if (PIND & (1 << PD2)) {   // pull-up holds this HIGH while the button is open
        PORTB &= ~(1 << PB5);  // not pressed: LED off
    } else {
        PORTB |=  (1 << PB5);  // pressed (pin pulled to ground): LED on
    }
}

Notice the logic looks inverted: the LED turns on when the read is low. That is because of the pull-up, and here is why it has to be there.

An input pin is extremely high-impedance - it barely draws any current, so it is happy to float. If a pin is configured as an input and nothing is actively driving it (the button is open, connecting it to nothing), its voltage drifts with stray electrical noise from nearby wires, your hand, the power supply. Read it and you get unpredictable 1s and 0s. A floating input reads garbage.

⚠️ Gotcha. A disconnected input is not a reliable 0. It is a floating antenna. If your button "works sometimes" or triggers on its own, an unconnected or un-pulled input is the first suspect.

The fix is a pull-up (or pull-down) resistor: a resistor that gently ties the pin to a known level when nothing else is driving it. With a pull-up, the pin sits high by default; press the button to connect it to ground and it goes low. The ATmega328P has a pull-up resistor built into every pin - you enable it by writing a 1 to the pin's PORTx bit while the pin is an input, which is what PORTD |= (1 << PD2) does above. That one line saves you from soldering an external resistor.

Try it: in the same Wokwi project, add a pushbutton connected from pin 2 to GND, paste this in, and Run. Hold the button and the on-board LED lights; release it and the LED goes off.

Recap

  1. A register is a memory address wired to hardware. Writing it moves real voltage on a pin; reading it samples real voltage.
  2. Each AVR port has three registers: DDRx sets direction (1 = output, 0 = input), PORTx sets an output's level (or enables an input's pull-up), and PINx reads the pins.
  3. On the Uno, the on-board LED is PB5 (pin 13). DDRB |= (1 << PB5) makes it an output; PORTB |= (1 << PB5) turns it on.
  4. To read a button: DDRD &= ~(1 << PD2) makes PD2 an input, then test PIND & (1 << PD2).
  5. A floating input reads noise. A pull-up ties it to a known level; enable the AVR's internal one with PORTD |= (1 << PD2), which makes an idle button read HIGH and a press read LOW.
  6. The datasheet is where every register name and bit lives. Look things up; do not memorize.

You can now light a pin and read a pin. Phase 5 makes the reading efficient: instead of asking the button "are you pressed yet?" a million times a second, you let the hardware tell you the instant it happens.

Check yourself

[
  {
    "q": "What does `DDRB |= (1 << PB5);` do?",
    "choices": [
      "Sets PB5 high",
      "Configures PB5 as an output pin",
      "Reads the current state of PB5",
      "Enables the pull-up on PB5"
    ],
    "answer": 1,
    "explain": "DDRB is the data-direction register for port B. A 1 makes that pin an output; a 0 makes it an input. It sets direction, not level.",
    "why": [
      "PORTB, not DDRB, sets the output level once the pin is an output.",
      null,
      "PINB reads the pin; DDRB only sets its direction.",
      "The pull-up is enabled through PORTB while the pin is an input, not through DDRB."
    ]
  },
  {
    "q": "A pin is configured as an input with nothing connected to it. Why might reading it give random 1s and 0s?",
    "choices": [
      "The datasheet must be wrong",
      "The pin is floating - with no pull-up or pull-down it drifts with electrical noise",
      "Inputs always read zero",
      "You forgot to delay before reading"
    ],
    "answer": 1,
    "explain": "A floating input drives nothing and is driven by nothing, so its voltage wanders with stray noise and reads unpredictably. A pull-up or pull-down ties it to a known level."
  },
  {
    "q": "With the internal pull-up enabled on PD2 and a button wired from PD2 to ground, what does `PIND & (1 << PD2)` read when the button is NOT pressed?",
    "choices": [
      "Zero (LOW)",
      "Nonzero (HIGH), because the pull-up holds the pin high until the button grounds it",
      "It is undefined",
      "It depends on the value of DDRB"
    ],
    "answer": 1,
    "explain": "The pull-up holds the pin HIGH while the button is open, so the read is nonzero. Pressing connects the pin to ground, pulling it LOW. This is why button logic often looks inverted."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. What does `DDRB |= (1 << PB5);` do?

2. A pin is configured as an input with nothing connected to it. Why might reading it give random 1s and 0s?

3. With the internal pull-up enabled on PD2 and a button wired from PD2 to ground, what does `PIND & (1 << PD2)` read when the button is NOT pressed?