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:
| Register | Name | Function |
|---|---|---|
DDRx | Data Direction Register | 1 = output, 0 = input |
PORTx | Port Output Register | Sets output HIGH/LOW (or enables pull-up when input) |
PINx | Port Input Register | Read-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.
Blink in Direct C
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:
avr-gcc— cross-compiler targeting ATmega328P at 16 MHz, optimize for sizeavr-objcopy— convert ELF to Intel HEX format (what avrdude expects)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:
- Looks up the pin’s timer, bitmask, and port via table lookups
- Disables PWM if the pin was in PWM mode
- Disables interrupts (saves/restores SREG) to make the operation atomic
- 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 simultaneously —
PORTB = 0b00101100sets pins in one write, not fourdigitalWrite()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 scratch —
TCCR1A,TCCR1B,OCR1A, noanalogWrite()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.