Thursday, August 27, 2026

Blinking an LED on the ATmega328 - A register- and bus-level walkthrough

 

 

Blinking an LED on the ATmega328

A register- and bus-level walkthrough, not an Arduino how-to

This tutorial explains what actually happens inside an ATmega328 when you turn an LED on and off — tracing the path from a CPU instruction, across the shared data bus, into a memory-mapped register, and finally through the dedicated pin-driver hardware that switches the physical pin. It assumes you already know roughly how the AVR CPU core and its shared data bus work (fetch/decode, register file, ALU) and focuses specifically on the I/O port mechanism.

Who this is for: Developers comfortable with AVR internals who want to read the ATmega328 datasheet's I/O Ports chapter with real understanding, rather than copy a library call.


 

1. What you need

1.1 Hardware

      An ATmega328 or ATmega328P (bare chip, or on an Arduino Uno board)

      One LED

      One current-limiting resistor, roughly 220–330 Ω

      Breadboard and jumper wires (if using a bare chip)

      A programmer/ISP or existing bootloader, to load the compiled program

1.2 Reference material

Keep the ATmega328/328P datasheet open to two chapters while you read this:

      “AVR CPU Core” — the register file, ALU, and shared data bus (background for section 3)

      “I/O Ports” — the chapter with the port register summary and the per-pin equivalent schematic (the core of sections 4–5)

2. The goal, stated precisely

“Blink an LED” reduces to one repeated action: change the electrical state of a single physical pin between a low voltage, typically near 0 V, and a high voltage, typically near VCC, with a pause between each change. Everything in this tutorial is about how a line of C code causes that state change — nothing more.

3. Recap — how the CPU reaches a register at all

The ATmega328's data bus is shared, not a hub. The CPU, SRAM, and every peripheral register (including the I/O port registers used in this tutorial) tap into the same 8-bit data bus. The CPU initiates each access by supplying the address and read/write control information; the addressed memory location or peripheral register responds directly. Nothing is relayed through the CPU as an intermediate stop — the CPU's role is initiating and timing the access, not forwarding data between two other devices.

This matters for GPIO because it tells you what kind of operation a register write actually is: a plain bus transaction at a fixed address, identical in mechanism to writing a byte of SRAM. There is no special “GPIO bus” separate from the data bus you already know. Note that this is the architectural model the datasheet gives you — it describes behavior, not necessarily the literal internal physical bus wiring.

4. The three registers behind every GPIO pin

Each 8-bit I/O port (B, C, D on the ATmega328) is controlled by three registers, one bit per physical pin. For Port B, which owns the Arduino Uno's on-board LED pin (PB5 / D13):

Register

Purpose

Access

DDRB

Data Direction Register. Each bit sets that pin as input (0) or output (1).

Read/write

PORTB

If the pin is an output, this bit is the output level (1 = high, 0 = low). If the pin is an input, this bit instead enables an internal pull-up resistor.

Read/write

PINB

Reads the pin's actual electrical state. Writing a logic 1 to a PINB bit toggles the corresponding PORTB bit — it is not simply read-only.

Read / special write

 

PINB's write side: Although PINB is primarily an input register, the datasheet documents that writing a 1 to a PINB bit toggles that same bit in PORTB. Worth knowing even though this tutorial only uses PINB's read behavior.

Two address spaces, not one: PORTB, DDRB, and PINB occupy locations in the AVR data address space (PINB 0x23, DDRB 0x24, PORTB 0x25) and are additionally exposed at lower addresses in the separate AVR I/O address space (PINB 0x03, DDRB 0x04, PORTB 0x05), which the dedicated IN/OUT/SBI/CBI instructions use. Both are documented in the datasheet's register summary.

5. The physical path from bit to pin

This is the part the port pin equivalent schematic in the datasheet's I/O Ports chapter is actually drawing, and it's the piece that's easy to get wrong intuitively. Setting a PORTB bit does not “route through peripherals” to reach the pin — writing that register bit is already the act of talking to the peripheral. What follows the write is a second, separate stage that never touches the shared bus again:

Stage

What happens

Bus involved?

1

CPU executes the store instruction; the value travels on the shared data bus to PORTB's register location (data-space address 0x25, or I/O-space address 0x05 if reached via IN/OUT/SBI/CBI).

Yes

2

The bus write latches into a bit that belongs to that specific pin's driver block.

Yes (this write)

3

The latch output is hardwired — not bus-connected — directly into the control circuitry of that one pin's push-pull output driver.

No

4

The output driver switches, and the physical pin voltage changes.

No

 

DDRB's bit feeds the same per-pin block, but as an enable/mode line rather than a data line: it decides whether the output driver is enabled at all, or whether it is instead disabled, leaving the pin in a high-impedance input state whose voltage is sensed by an input buffer feeding PINB (with an optional internal pull-up, also controlled by PORTB, available in that input state). This is why the datasheet draws the port schematic entirely separately from the CPU/bus diagram — everything past stage 2 is fixed, dedicated circuitry, one instance per physical pin, that the bus never touches again.

 

The write path and the read path are worth separating explicitly, since they run through different hardware in opposite directions:

WRITE (e.g. PORTB |= (1<<PB5))          READ (e.g. x = PINB)
 
CPU instruction                          Physical PB5 pin
     |                                        |
     v                                        v
Data/I-O bus access                      Input buffer
     |                                        |
     v                                        v
PORTB bit 5 latch                        PINB bit 5
     | (dedicated, non-bus link)               |
     v                                        v
PB5 output driver                        CPU reads value
     |
     v
Physical PB5 pin -> LED -> resistor -> GND

6. Step-by-step sequence

6.1 Configure the pin as output

Set bit 5 of DDRB to 1. This must happen once, typically at program start, before the pin is driven — otherwise the output driver stays disabled regardless of what PORTB holds.

6.2 Drive the pin high

Set bit 5 of PORTB to 1. Per the path in section 5, this is a bus write to PORTB that latches into PB5's driver block and switches the output driver — the LED turns on (assuming the LED and resistor are wired from the pin to ground).

6.3 Wait

Hold that state for a visible interval — hundreds of milliseconds is typical. Any timing mechanism works (a busy-wait loop, a hardware timer/counter peripheral, or an interrupt-driven delay); the choice doesn't change anything in sections 3–5, it only decides how long PORTB's bit 5 stays at its current value.

6.4 Drive the pin low

Clear bit 5 of PORTB to 0. Same mechanism as 6.2, opposite driver state — the LED turns off.

6.5 Repeat

Loop back to 6.3. The “blink” is nothing more than this four-step cycle running indefinitely.

7. What this looks like in register-level C

This is deliberately written against the registers directly — no Arduino framework — so each line maps onto a step above.

#include <avr/io.h>
#include <util/delay.h>
 
int main(void) {
    DDRB |= (1 << PB5);     // Step 6.1 — PB5 as output
 
    while (1) {
        PORTB |= (1 << PB5);   // Step 6.2 — drive high
        _delay_ms(500);        // Step 6.3 — wait
        PORTB &= ~(1 << PB5);  // Step 6.4 — drive low
        _delay_ms(500);        // Step 6.3 — wait
    }                          // Step 6.5 — repeat
}

Each of the four commented lines is a bus transaction addressing PORTB or DDRB — the same kind of access as any SRAM read/write, exactly as described in section 3. With typical AVR-GCC optimization, an operation like “|= (1<<PB5)” may compile to a single SBI or CBI (set/clear bit in I/O register) instruction, since PORTB and DDRB fall in the low I/O address range those instructions can address directly. This isn't guaranteed, though: the compiler may instead emit an explicit read-modify-write sequence (IN, an ALU op, OUT). Either way, SBI/CBI is not a single bus cycle — like any AVR instruction, it takes multiple clock cycles to execute; the point of using it is that it performs the read-modify-write as one indivisible instruction rather than three separate ones, which matters for atomicity, not raw speed.

8. Common pitfalls, explained by the model above

      LED never lights: DDRB bit was never set. The PORTB write still succeeds (it's just a bus write, section 3) but the output transistor pair is disabled (section 5, stage 3), so nothing reaches the pin electrically.

      Unpredictable behavior with no code running it: the pin is left as an input with no pull-up and nothing driving it. It's electrically floating, so its voltage is undefined; an LED connected to it may appear dim, flicker, or behave erratically depending on the surrounding circuit and noise.

      Wrong pin toggles: bit number vs. pin number confusion. PB5 refers to bit 5 within PORTB/DDRB/PINB, not “pin 5” on the physical package — check the pinout diagram, not just the bit index.

      LED polarity reversed: driving PORTB high turns the LED off instead of on. This isn't a register issue at all — it means the LED is wired pin-to-VCC-through-resistor instead of pin-to-ground, so the pin's high/low logic is inverted relative to what section 6 assumes.

9. Summary

Blinking an LED touches every layer discussed in this tutorial: a CPU instruction becomes a shared-bus transaction (section 3) at a memory-mapped register address (section 4), which latches into dedicated per-pin hardware that never touches the bus again (section 5) and switches a physical voltage. The four-step cycle in section 6 is just that mechanism, repeated. Once this path is clear, the same reasoning extends directly to PWM outputs, external interrupts, and other ATmega328 peripheral functions. The CPU still accesses peripheral registers through the same architectural data/I/O interface described in section 3, while the pin's dedicated control and multiplexing circuitry determines how that peripheral function reaches or senses the physical pin — this varies by function, unlike the plain PORTx-latch-to-driver path used for basic GPIO.

 

End of tutorial.


No comments:

Post a Comment