Microcontrollers
A microcontroller is an entire computer on one chip: a CPU core, flash for code, RAM for data, and a set of hardware peripherals — timers, serial buses, analog converters, PWM generators — all wired to physical pins. It costs anywhere from thirty cents to a few dollars, runs on microwatts to milliwatts, and boots into your code in microseconds because there's no operating system to load.
Programming one is different from programming a computer in a specific way: you talk to hardware by writing to memory addresses. There is no driver layer, no syscall, no kernel. Setting a pin high means writing a bit to a register at a fixed address that the silicon has wired to an output driver. Everything else — reading a sensor, sending a byte, sleeping for 10 ms — is the same pattern with a different address. Once that clicks, the entire field is a matter of reading datasheets carefully.
TL;DR
- An MCU integrates CPU, flash, RAM, and peripherals on one chip; an MPU needs external memory and usually runs Linux.
- Peripherals are memory-mapped registers — you configure hardware by writing bits to specific addresses.
- GPIO pins are configurable as input, output, or a peripheral function; input pins need a defined level (pull-up/pull-down).
- Interrupts let hardware call your code asynchronously. Keep ISRs tiny; set a flag and handle it in the main loop.
volatileis mandatory for anything hardware or an ISR can change, or the compiler will optimize away your reads.- Timers are the most versatile peripheral: periodic ticks, PWM, input capture, and precise delays.
- Learn the buses: UART (point to point), I²C (two wires, many devices), SPI (fast, four wires).
- The datasheet and reference manual are the documentation — there is no Stack Overflow answer for your specific register.
Quick Example
Blinking an LED on an STM32 by writing registers directly — the "hello world" that shows what a HAL is hiding:
In production you'd use the vendor HAL (HAL_GPIO_WritePin) rather than raw registers — but the HAL does exactly this, and when it misbehaves, register-level understanding is the only way to find out why.
Core Concepts
MCU vs. MPU
Choose an MCU for battery power, hard timing requirements, instant boot, and low unit cost. Choose an MPU when you need a filesystem, a network stack with TLS, a display framework, or general-purpose software. Many products use both: an MPU for the application and an MCU for real-time control and power management.
Memory-mapped I/O
A register write is not a memory write. GPIOA_BSRR = 1 changes a voltage on a physical pin. Some registers clear on read, some are write-only, and some require a specific write sequence to unlock. The reference manual tells you which; guessing does not work.
volatile
Use volatile for every hardware register, every variable shared with an ISR, and every variable a DMA controller writes. Omitting it produces bugs that appear only at higher optimization levels — which is to say, only in the release build.
GPIO configuration
Every pin needs a defined configuration:
⚠️ Warning: A floating input reads random values and consumes extra current as the input buffer oscillates. Every input pin needs a pull-up, a pull-down, or a guaranteed external driver. Unused pins should be configured (analog or output-low), not left at reset defaults.
Interrupts
An interrupt lets hardware call your function asynchronously — a pin changed, a timer expired, a byte arrived.
Rules that matter:
- Keep ISRs short. Microseconds. Set a flag, copy a byte, and return.
- No blocking calls — no
printf, nomalloc, no delays, no waiting on a bus. - Clear the interrupt flag, or the handler re-enters immediately and the system hangs.
- Share data through
volatilevariables, and guard multi-byte shared state against being read mid-update.
Interrupt priorities let a time-critical handler preempt a less critical one. On Cortex-M, lower numbers mean higher priority — a detail that reverses intuition and causes real bugs.
Timers
The most useful peripheral, and the one worth learning properly:
Timers give you periodic interrupts (a system tick), PWM for motor speed and LED brightness, input capture for measuring pulse widths, and output compare for precise waveform generation. Almost any timing problem is better solved with a timer than with a delay loop.
Communication buses
I²C needs pull-up resistors on both lines (typically 4.7 kΩ) — its most common failure cause, because without them the bus simply doesn't work and there's no error to see. SPI is faster and simpler electrically but needs a chip-select line per device.
Reading a Datasheet
Vendor documentation splits into two documents, and knowing which to open saves hours:
- Datasheet — the part's electrical and physical characteristics: pinout, voltage and current limits, timing, package, temperature range, and which peripherals this specific variant has.
- Reference manual — how to program the peripherals: every register, every bit field, every configuration sequence. This is usually a thousand-plus pages, and it's the one you'll live in.
A practical reading order: the block diagram to see what's on the chip, the pinout and alternate-function table to plan your board, absolute maximum ratings so you don't destroy anything, then the specific peripheral chapter you need. Nobody reads these front to back — you search them.
The register tables are the payoff. When a HAL function doesn't do what you expect, comparing what it wrote against what the manual says the register needs is how you find the answer.
Best Practices
Enable the peripheral clock first
Every peripheral has a clock gate, off by default to save power. Writes to a clockless peripheral silently do nothing and reads return zero. This is the single most common "my code does nothing" cause on ARM MCUs.
Use volatile for anything hardware or an ISR touches
The bug it prevents appears only under optimization, which means only in production. Getting this right by habit is much cheaper than debugging it.
Prefer static allocation to malloc
Heap fragmentation in long-running firmware produces allocation failures weeks into uptime, on a device nobody can reach. Most embedded coding standards ban dynamic allocation after initialization for exactly this reason.
Do real work in the main loop, not in ISRs
An ISR sets a flag or pushes to a ring buffer; the main loop does the processing. Long ISRs delay other interrupts, cause missed events, and make timing unanalyzable.
Use fixed-width integer types
int is 16 bits on some MCUs and 32 on others. uint8_t, int16_t, and uint32_t from <stdint.h> mean the same thing everywhere and are what you want when a register field is exactly 8 bits.
Enable the watchdog in production
A watchdog timer reboots the device if your code stops petting it. It's the difference between a transient fault and a device that's bricked until someone drives out to power-cycle it. Feed it from the main loop only — feeding it from a timer ISR defeats the purpose, since the ISR keeps running while the main loop is stuck.
Configure every pin, including unused ones
Unused pins left in their reset state can float, consume current, and pick up noise. Set them to analog mode or output-low. This matters most for battery devices, where a few floating inputs measurably shorten runtime.
Debounce mechanical inputs
A button press produces several milliseconds of electrical chatter and will register as multiple events. Debounce in software (ignore changes within ~20 ms) or hardware (an RC filter). An interrupt on a raw button pin will fire five times per press.
Common Mistakes
Forgetting the peripheral clock
Omitting volatile on a shared flag
Doing work inside an ISR
Non-atomic access to multi-byte shared data
Busy-wait delay loops
Ignoring the electrical limits
FAQ
Which microcontroller should I start with?
An ESP32 or an STM32 Nucleo board. The ESP32 includes Wi-Fi and Bluetooth, has excellent documentation and a huge community, and costs a few dollars. STM32 is the industrial workhorse with the deepest professional ecosystem and the best debugging story. Arduino Uno (AVR) is still a fine first hour but is architecturally dated — you'll outgrow it quickly. Raspberry Pi Pico (RP2040/RP2350) is another strong choice with unusually good documentation.
Arduino or the vendor SDK?
Arduino is an excellent way to get something working in an afternoon and hides the peripheral configuration you eventually need to understand. Vendor SDKs (STM32Cube, ESP-IDF, Zephyr) expose the full hardware, support proper debugging, and are what production firmware uses. Start with Arduino if you want a fast first result; move to the vendor SDK as soon as you need power management, precise timing, or a peripheral Arduino doesn't wrap.
How much C do I need?
Solid fundamentals: pointers, bit manipulation, structs, volatile, and the preprocessor. You do not need dynamic memory, since you'll largely avoid it. Bitwise operations (|=, &= ~, <<) are constant in embedded work and worth being fluent in. See Embedded C.
How do I debug with no console?
A debug probe (ST-Link, J-Link, or CMSIS-DAP) over SWD gives breakpoints, single-stepping, and live memory inspection — this is the primary tool and worth the twenty dollars immediately. Add a UART log for tracing, a spare GPIO toggled around a code section so you can measure timing on a scope, and a logic analyzer for bus problems. A twenty-dollar logic analyzer answers I²C and SPI questions that hours of code reading will not.
What is a HAL and should I use one?
A Hardware Abstraction Layer wraps register access in functions (HAL_GPIO_WritePin) so code is portable across a vendor's family and readable to newcomers. Use one — hand-writing registers for everything is slow and error-prone. Understand what it's doing underneath, because HALs are sometimes buggy, sometimes slow, and always eventually insufficient for something you need.
How do I reduce power consumption?
Sleep aggressively — a device that's awake 1% of the time uses roughly 1% of the power. Use __WFI() instead of busy-waiting, pick the deepest sleep mode compatible with your wake sources, gate clocks to unused peripherals, configure unused pins, and lower the CPU clock when full speed isn't needed. Then measure with a current meter rather than reasoning about it: real consumption is usually dominated by something surprising, like a sensor left powered or a floating pin.
Related Topics
- Embedded C — The language and its embedded-specific idioms
- Embedded Rust — Memory safety on bare metal
- Real-Time Operating Systems — When a superloop isn't enough
- ESP32 — A connected MCU with a strong ecosystem
- Raspberry Pi & Embedded Linux — The MPU side of the ladder
- IoT Security — Securing a device an attacker can hold
- OTA Firmware Updates — Shipping fixes to deployed hardware
- C — The language fundamentals
- Memory Management — Why static allocation wins here