Programming Microcontrollers
Learning Objectives
- Explain the build pipeline that turns C source code into firmware running on an MCU (compile, link, flash)
- Write and explain a
setup()/loop()style Arduino program and its bare-metal C equivalent - Use
pinMode,digitalWrite,digitalRead, andanalogReadcorrectly, and explain what each does at the register level - Configure and use a hardware interrupt (ISR) instead of polling
- Explain the difference between blocking code (
delay()) and non-blocking timing (millis()) - Identify common beginner bugs: missing
volatile, blocking delays inside interrupts, stack overflow from recursion
Quick Answer
Programming a microcontroller means writing C (or occasionally C++/assembly) that gets compiled into machine code and flashed into the MCU's flash memory, where it runs forever in a loop, directly controlling hardware pins — there's no operating system managing multiple programs like on a PC. The two most common styles are the Arduino-style setup()/loop() abstraction (great for beginners) and direct register-level C (essential for professional embedded work, debugging, and squeezing out performance). Learning to program microcontrollers matters because it's the skill that turns a schematic into a working product — every smart device, robot, and sensor node needs firmware that reads inputs, makes decisions, and drives outputs reliably, often within strict timing deadlines.
From Source Code to Running Firmware
Unlike a desktop program, you don't "run" embedded code on your computer — you cross-compile it for a different CPU architecture, then transfer the finished binary onto the MCU's flash memory. The pipeline looks like this:
When you click "Upload" in the Arduino IDE, all of this happens behind the scenes: avr-gcc compiles your sketch, avr-objcopy produces a .hex file, and avrdude sends it over the USB-serial bootloader into the ATmega328P's flash.
The setup()/loop() Model
Arduino's programming model hides the fact that your "program" never actually ends — it's structured as one-time initialization followed by an infinite loop:
void setup() {
// Runs exactly once after reset/power-up
pinMode(2, INPUT_PULLUP); // button on pin 2, using internal pull-up resistor
pinMode(13, OUTPUT); // LED on pin 13
Serial.begin(9600); // start UART at 9600 baud for debugging
}
void loop() {
// Runs forever, as fast as the CPU can execute it
int buttonState = digitalRead(2);
if (buttonState == LOW) { // pressed = pulled to ground
digitalWrite(13, HIGH);
Serial.println("Button pressed!");
} else {
digitalWrite(13, LOW);
}
}
This is exactly equivalent to a bare-metal main():
int main(void) {
setup();
for (;;) {
loop();
}
return 0; // never reached
}
Knowing this matters because when you move to non-Arduino platforms (STM32 with HAL, ESP-IDF, bare AVR), you write main() with an explicit while(1) yourself — same underlying structure, different syntax.
Blocking vs. Non-Blocking Timing: The Classic Beginner Trap
delay(500) works fine for a single blinking LED, but it halts the entire CPU for 500 ms — no button reads, no sensor polling, nothing else happens. This is the single most common design mistake in beginner embedded code.
// BAD: blocking - can't do anything else while waiting
void loop() {
digitalWrite(13, HIGH);
delay(1000); // CPU is frozen here
digitalWrite(13, LOW);
delay(1000);
// a button press during either delay() is completely missed
}
// GOOD: non-blocking, using millis() - the CPU stays responsive
unsigned long previousMillis = 0;
const long interval = 1000;
bool ledState = false;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
ledState = !ledState;
digitalWrite(13, ledState ? HIGH : LOW);
}
// other code (reading buttons, sensors, etc.) still runs every loop iteration
checkButton();
}
millis() returns the number of milliseconds since boot, driven by a hardware timer interrupt counting in the background — so checking "has enough time passed?" costs almost no CPU time, unlike delay(), which wastes it entirely.
Interrupts: Reacting Instantly Instead of Polling
Polling means constantly asking "did the event happen yet?" in your main loop — wasteful and can miss fast events. An interrupt lets the hardware notify the CPU immediately when something happens (a pin changes state, a timer overflows, a UART byte arrives), pausing loop() to run a dedicated handler function.
volatile bool buttonPressed = false; // 'volatile' is mandatory: tells the
// compiler this variable can change
// outside normal program flow (in the ISR)
void setup() {
pinMode(2, INPUT_PULLUP);
Serial.begin(9600);
// attach interrupt: when pin 2 goes from HIGH to LOW, call handleButton()
attachInterrupt(digitalPinToInterrupt(2), handleButton, FALLING);
}
void handleButton() {
buttonPressed = true; // ISRs should be tiny: just set a flag and return
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
Serial.println("Button pressed (via interrupt)!");
}
// main loop keeps running freely; the interrupt fires the instant the
// button is pressed, regardless of what loop() is doing
}
Why It Matters
Missing volatile on a variable shared between an ISR and the main loop is one of the most notorious embedded bugs: the compiler may optimize away the "repeated" read of buttonPressed inside loop(), assuming it never changes there — even though the interrupt changes it asynchronously. The symptom is code that works in debug builds (optimizations off) and mysteriously fails in release builds (optimizations on).
Register-Level Equivalent (What's Really Happening)
Arduino's attachInterrupt wraps the AVR's External Interrupt registers. The bare-metal version:
#include <avr/io.h>
#include <avr/interrupt.h>
volatile uint8_t buttonPressed = 0;
int main(void) {
DDRD &= ~(1 << PD2); // PD2 (pin 2) as input
PORTD |= (1 << PD2); // enable internal pull-up on PD2
EICRA |= (1 << ISC01); // configure INT0 to trigger on falling edge
EIMSK |= (1 << INT0); // enable external interrupt 0
sei(); // globally enable interrupts
while (1) {
if (buttonPressed) {
buttonPressed = 0;
// handle button press
}
}
}
ISR(INT0_vect) {
buttonPressed = 1;
}
This is exactly why understanding Chapter 1 and Chapter 2 (registers, buses, control unit) pays off directly here: EIMSK, EICRA, and PORTD are memory-mapped registers, and setting a bit in them directly configures hardware behavior — no library needed.
Reading Analog Signals
analogRead() triggers the MCU's onboard ADC (analog-to-digital converter), which converts a voltage (0-5V on most Arduinos) into a 10-bit digital number (0-1023):
void setup() {
Serial.begin(9600);
}
void loop() {
int sensorValue = analogRead(A0); // 0-1023
float voltage = sensorValue * (5.0 / 1023.0); // convert to volts
Serial.print("Voltage: ");
Serial.println(voltage);
delay(200);
}
Real-World Example
A soil moisture sensor outputs an analog voltage proportional to moisture. Reading it via analogRead(), converting to a percentage, and comparing it against a threshold to trigger a water pump relay is a genuine, common embedded systems project — and uses exactly this pattern (ADC read → convert → decide → act).
Key Terms
| Term | Definition |
|---|---|
| Firmware | Compiled program permanently stored in an MCU's flash memory |
| Cross-compilation | Compiling code on one machine (your PC) to run on a different CPU architecture (the MCU) |
| Bootloader | Small program on the MCU that receives new firmware over UART/USB and writes it to flash |
| ISR (Interrupt Service Routine) | A function that runs automatically when a hardware interrupt fires |
| volatile | A C keyword telling the compiler a variable may change outside normal program flow, preventing unsafe optimization |
| Polling | Repeatedly checking a condition in software instead of waiting for a hardware interrupt |
| Blocking code | Code that halts further execution until an operation (like delay()) finishes |
| ADC (Analog-to-Digital Converter) | Peripheral that converts an analog voltage into a digital number the CPU can process |
| Baud rate | The speed (bits per second) of serial (UART) communication |
Common Mistakes
-
Misconception: "
delay()is fine to use anywhere in my program." Why it's wrong: Beginners don't realizedelay()freezes the entire CPU, including interrupt-driven logic waiting to be handled in the main loop. Correct explanation: Usedelay()only for simple, single-task sketches. For anything that needs to remain responsive (reading buttons, multiple timed events), usemillis()-based non-blocking timing. -
Misconception: "A variable shared with an interrupt doesn't need any special declaration as long as it's global." Why it's wrong: Being global makes it accessible from the ISR, but the compiler can still cache its value in a register and never re-check memory, since it doesn't know the ISR changes it. Correct explanation: Any variable modified inside an ISR and read in the main loop (or vice versa) must be declared
volatileso the compiler always re-reads it from memory. -
Misconception: "Interrupt service routines should do all the work needed when the event happens, like printing to serial or writing to an SD card." Why it's wrong: Long-running or blocking operations inside an ISR delay all other interrupts and can break precise timing elsewhere in the system. Correct explanation: ISRs should be as short as possible — typically just setting a flag or copying a small piece of data — with the actual processing done in the main loop afterward.
Comparison and Connections
| Aspect | Polling | Interrupt-driven |
|---|---|---|
| CPU usage while waiting | Wastes cycles checking repeatedly | CPU free to do other work |
| Response latency | Depends on loop speed; can miss fast events | Near-instant, hardware-triggered |
| Code complexity | Simple | Slightly more complex (ISR + shared state) |
| Best for | Slow-changing signals, simple sketches | Time-critical events (buttons, encoders, UART RX) |
| Aspect | delay() | millis() |
|---|---|---|
| CPU behavior | Blocks (busy-waits) | Non-blocking |
| Can do other work meanwhile? | No | Yes |
| Typical use | Quick prototypes, single-task demos | Any program needing responsiveness |
Practice Questions
Recall
- What are the two functions every Arduino sketch must define, and what does each do?
Answer guidance:
setup()runs once at startup for initialization;loop()runs repeatedly forever afterward. - What does the
volatilekeyword do, and when must it be used? Answer guidance: It tells the compiler a variable can change outside normal program flow (e.g., inside an ISR), forcing the compiler to always read its current value from memory instead of a cached copy.
Understanding
3. Explain why delay() is considered "blocking" and why that's a problem in a program that also needs to read a button.
Answer guidance: delay() halts CPU execution entirely for the specified time, so any button press or sensor event occurring during that window is not processed until the delay ends, potentially missing fast or brief inputs.
4. Explain the sequence of steps that happens between clicking "Upload" in the Arduino IDE and the LED actually blinking on the board.
Answer guidance: Source compiled by avr-gcc into object code, linked into a .hex firmware image, sent by avrdude through the bootloader over USB-serial into flash memory, then the MCU resets and begins executing from flash.
Application
5. You need to read a rotary encoder that changes state very quickly and can't afford to miss a transition. Would you use polling in loop() or an interrupt? Justify your answer.
Answer guidance: An interrupt attached to the encoder's signal pins, because polling in loop() risks missing rapid transitions if other code in the loop takes too long between checks; an interrupt guarantees the transition is captured the instant it happens.
6. Write (in words or code) how you would modify the blocking blink example to also print "Hello" over serial every 2 seconds without using two separate delay() calls.
Answer guidance: Use two independent millis()-based timers with their own previousMillis variables and intervals (1000 ms for the LED, 2000 ms for the serial print), checked independently each loop iteration.
Analysis
7. A student's interrupt-based button counter works correctly when compiler optimizations are disabled but fails (counter appears stuck) when optimizations are enabled. Diagnose the likely bug.
Answer guidance: The shared counter variable is missing the volatile qualifier, so the optimizer assumes it never changes inside the main loop and caches its value in a register instead of re-reading it after the ISR updates it in memory.
8. Compare the tradeoffs of implementing a stopwatch feature using delay()-based timing versus millis()-based timing, considering both code simplicity and functionality.
Answer guidance: delay() is simpler to write for a single, isolated timed action but makes the device unresponsive during the wait; millis() requires tracking state variables (more code) but allows the stopwatch to run alongside other tasks like button checks or display updates.
FAQ
Q1: Do I have to use the Arduino IDE to program microcontrollers? No — Arduino sketches are just C/C++ with a convenience framework. Professional embedded work commonly uses PlatformIO, vendor IDEs (STM32CubeIDE, MPLAB X), or plain Makefiles with GCC toolchains.
Q2: Why does my program behave differently after I add Serial.print() statements for debugging?
This is often a sign of a race condition or timing bug (e.g., a missing volatile) — adding print statements changes execution timing enough to mask or reveal the issue. It's a strong hint to check shared variables between ISRs and the main loop.
Q3: Can I use recursion in embedded C on a microcontroller? Technically yes, but with caution — MCUs have very limited RAM (sometimes just a few KB) for the call stack, and deep or unbounded recursion can silently overflow the stack and corrupt memory, causing unpredictable crashes.
Q4: What's the difference between .hex and .bin firmware files?
Both are compiled firmware images ready to flash. Intel HEX (.hex) is a text-based format with addresses and checksums (common for AVR/Arduino); .bin is a raw binary image (common for ARM Cortex-M tools).
Q5: Why do embedded C programs avoid malloc() and dynamic memory allocation?
Dynamic allocation on a system with only a few KB of RAM and no memory protection risks heap fragmentation and running out of memory unpredictably, which is unacceptable in systems that must run reliably for years. Most embedded code uses only static/global arrays and the stack.
Quick Revision
- Firmware = compiled C/C++ flashed permanently into MCU flash; there's no OS multitasking underneath
setup()runs once;loop()runs forever — equivalent to a baremain()with awhile(1)loopdelay()blocks the entire CPU;millis()-based timing keeps the program responsive- Interrupts (ISRs) react instantly to hardware events instead of wasting cycles polling
- Any variable shared between an ISR and the main loop must be declared
volatile - ISRs must be short — set a flag or copy data, then handle the real work in
loop() analogRead()uses the onboard ADC to convert a voltage into a 10-bit number (0-1023 on most AVR boards)- Bare-metal register access (e.g.,
DDRD,PORTD,EIMSK) is what Arduino library calls wrap under the hood - Avoid dynamic memory allocation (
malloc) and unbounded recursion — RAM is extremely limited on MCUs - Build pipeline: source → compiler → linker → firmware image (.hex/.bin) → flashed via bootloader/programmer
Related Topics
Prerequisites: Introduction to Microcontrollers, Microprocessor Architecture, basic C programming
Related Topics: Microcontroller Peripherals, Embedded Systems Programming
Next Topics: Interfacing Microcontrollers, Microcontroller Projects