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. A1bit makes that pin an output; a0bit makes it an input. This is the first thing you set.PORTx- for an output pin, the bit you write here is the level:1drives it high,0drives it low. For an input pin, writing1here 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."
void
void
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:
void
void
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
- A register is a memory address wired to hardware. Writing it moves real voltage on a pin; reading it samples real voltage.
- Each AVR port has three registers:
DDRxsets direction (1 = output, 0 = input),PORTxsets an output's level (or enables an input's pull-up), andPINxreads the pins. - On the Uno, the on-board LED is
PB5(pin 13).DDRB |= (1 << PB5)makes it an output;PORTB |= (1 << PB5)turns it on. - To read a button:
DDRD &= ~(1 << PD2)makes PD2 an input, then testPIND & (1 << PD2). - 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. - 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?