← LOGBOOK LOG-355
WORKING · ELECTRONICS ·
ARDUINOAVRBARE-METALCREGISTERSATMEGA328PEMBEDDEDPORT-MANIPULATION

Bare-Metal MCU — Arduino Without the Arduino

Dropping the Arduino framework and writing direct C against AVR registers. Port manipulation, data direction registers, and what digitalWrite() is actually doing under the hood.

Why Go Bare-Metal

The Arduino framework is a teaching tool. digitalWrite(8, HIGH) is readable and it works. But it compiles to a function call that checks validity, maps the pin number through a lookup table, does a read-modify-write on the port register — around 50 cycles on the ATmega328P for something that should be 2.

More importantly, every STM32 or ESP32 tutorial assumes you know what a GPIO port is, what a data direction register does, and how to read a datasheet. Arduino hides all of that. Before moving to STM32 bare-metal, it’s worth doing one pass on the AVR — same concepts, simpler peripheral set, and the ATmega328P datasheet is only 660 pages instead of 1,700.


The ATmega328P GPIO Model

The AVR has three register types per port:

RegisterNameFunction
DDRxData Direction Register1 = output, 0 = input
PORTxPort Output RegisterSets output HIGH/LOW (or enables pull-up when input)
PINxPort Input RegisterRead-only, reflects actual pin voltage

The Uno has three ports: B (pins 8–13), C (analog pins A0–A5), D (pins 0–7). Every pin on the board maps to a specific bit in one of these registers.

Pin 13 (onboard LED) is PB5 — Port B, bit 5.


The Arduino IDE compiles C++ with the avr-libc headers available. Same with arduino-cli. The register names (DDRB, PORTB, etc.) are just macros from <avr/io.h> that expand to memory-mapped addresses.

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

int main(void) {
    // Set PB5 as output (bit 5 of DDRB)
    DDRB |= (1 << PB5);

    while (1) {
        PORTB |= (1 << PB5);   // HIGH
        _delay_ms(500);
        PORTB &= ~(1 << PB5);  // LOW
        _delay_ms(500);
    }

    return 0;
}

No setup(), no loop(), no Arduino runtime. main() is the entry point — same as any C program. The AVR C runtime sets up the stack and calls main() after reset.

_delay_ms() is a busy-wait from <util/delay.h> — it uses __builtin_avr_delay_cycles() internally and requires F_CPU to be defined to calculate the right loop count.


Compiling Without the Arduino Framework

arduino-cli can compile a plain .c file if you use the right flags. But the cleaner way for bare-metal is avr-gcc directly:

avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os -o blink.elf blink.c
avr-objcopy -O ihex blink.elf blink.hex
avrdude -c arduino -p m328p -P /dev/cu.usbserial-1120 -b 115200 -U flash:w:blink.hex

Steps:

  1. avr-gcc — cross-compiler targeting ATmega328P at 16 MHz, optimize for size
  2. avr-objcopy — convert ELF to Intel HEX format (what avrdude expects)
  3. avrdude — upload using the Arduino bootloader protocol over serial

The upload protocol is the same regardless of whether you wrote Arduino C++ or bare C — the bootloader doesn’t care.


Bit Manipulation Patterns

Three operations come up constantly:

// Set a bit (force to 1)
DDRB |= (1 << PB5);

// Clear a bit (force to 0)
PORTB &= ~(1 << PB5);

// Toggle a bit
PINB = (1 << PB5);   // Writing to PINx toggles the corresponding PORTx bit (AVR-specific)

The (1 << PB5) pattern creates a bitmask. PB5 is just 5 — a macro from <avr/io.h>. Shifting 1 left by 5 gives 0b00100000. OR-ing that into DDRB sets bit 5 without touching the others.

The toggle trick using PINx is AVR-specific — writing a 1 to a bit in the PIN register toggles the corresponding PORT bit. Saves a read-modify-write cycle.


What digitalWrite() Actually Does

Looking at the Arduino source (wiring_digital.c):

void digitalWrite(uint8_t pin, uint8_t val) {
    uint8_t timer = digitalPinToTimer(pin);
    uint8_t bit = digitalPinToBitMask(pin);
    uint8_t port = digitalPinToPort(pin);
    volatile uint8_t *out;

    if (port == NOT_A_PIN) return;

    if (timer != NOT_ON_TIMER) turnOffPWM(timer);

    out = portOutputRegister(port);

    uint8_t oldSREG = SREG;
    cli();

    if (val == LOW) {
        *out &= ~bit;
    } else {
        *out |= bit;
    }

    SREG = oldSREG;
}

It:

  1. Looks up the pin’s timer, bitmask, and port via table lookups
  2. Disables PWM if the pin was in PWM mode
  3. Disables interrupts (saves/restores SREG) to make the operation atomic
  4. Does the same |= or &= ~ that we wrote directly

The interrupt disable is legitimately useful in some contexts. The rest is overhead that doesn’t matter for most programs — but it does matter if you’re toggling a pin at high frequency or writing time-critical code.


Reading a Pin

Input works the same way — set DDR bit to 0, read from PIN register:

// Configure PD2 as input with pull-up
DDRD &= ~(1 << PD2);   // input
PORTD |= (1 << PD2);   // enable internal pull-up

// Read
if (PIND & (1 << PD2)) {
    // pin is HIGH
}

The internal pull-up (enabled by writing 1 to PORTx when in input mode) pulls the pin to Vcc through ~20–50 kΩ. Without it, a floating input reads random noise.


What This Unlocks

Going bare-metal means you can:

  • Set multiple pins simultaneouslyPORTB = 0b00101100 sets pins in one write, not four digitalWrite() calls
  • Read a whole port at once — useful for parallel data buses (LCD in 4/8-bit mode, shift registers)
  • Understand interrupts and ISRs from the register level — EICRA, EIMSK, sei(), ISR(INT0_vect)
  • Write a timer from scratchTCCR1A, TCCR1B, OCR1A, no analogWrite() wrapper

All of this transfers directly to STM32 — different register names, same mental model. Port configuration register, output data register, input data register. CMSIS header, set a bit, verify with a scope.

The next step is pulling the ATmega328P out of the Uno socket entirely and wiring the bare IC on a breadboard — crystal, decoupling caps, reset pull-up, and an external LED. That’s where the things the Uno was quietly providing become visible.