2  Microcontroller Programming Essentials

Firmware loops, GPIO, ADC, serial debugging, non-blocking timing, and interrupt handoffs

prototyping
microcontrollers
firmware
arduino
esp32
Keywords

microcontroller programming, Arduino, ESP32, GPIO, ADC, serial debugging, millis, interrupts, IoT firmware

In 60 Seconds

Microcontroller programming is the firmware layer of an IoT prototype. The core job is to initialize hardware, read pins and sensors, keep timing responsive, report evidence through serial logs, and hand urgent events from interrupts back to the main loop without crashing the device.

Phoebe the physics guide

Phoebe’s Why

Every delay(250) before an analogRead() is secretly a sample-rate decision, whether or not the sketch author thinks of it that way. A fixed polling interval sets a Nyquist ceiling on what the firmware can faithfully see, and the chapter’s own LM35 example happens to sample a signal so slow that the ceiling is nowhere close to being tested. That is a lucky accident of the sensor, not proof that delay()-based polling is safe in general – the same 250 ms habit, reused unchanged for a fast event like a button press, is exactly the failure mode this chapter’s “missing button presses” warning is describing, and Nyquist is the reason it happens.

The Derivation

A fixed polling interval \(T_s\) sets an effective sample rate and Nyquist ceiling:

\[f_s = \frac{1}{T_s}, \qquad f_{max} \leq \frac{f_s}{2}\]

An event of duration \(\tau\) needs a sample rate obeying the same rule to guarantee at least one sample catches it:

\[f_s \geq \frac{2}{\tau}\]

Quantization step for an \(N\)-bit ADC over reference \(V_{ref}\), converted into sensor units by a linear transducer’s slope \(m\) (volts per unit):

\[q_V = \frac{V_{ref}}{2^N}, \qquad q_{unit} = \frac{q_V}{m}\]

Worked Numbers: This Chapter’s Own LM35 Loop

  • Quantization: this chapter’s own \(V_{ref}=3.3\) V and 12-bit ADC_MAX=4095 give \(q_V = 3.3/4096 = 0.806\) mV. Through the LM35’s own \(10\) mV/°C slope, that is \(q_T = 0.806/10 = 0.0806\) °C per code – fine enough that resolution was never the risk in this example.
  • The loop’s real sample rate: the chapter’s own delay(250) makes \(f_s = 1000/250 = 4.0\) Hz, so Nyquist allows signals up to \(f_{max}=2.0\) Hz. Ambient room temperature changes over minutes, not fractions of a second, so this ceiling is not remotely tested – the LM35 loop is safe by a wide margin.
  • Same code, a fast signal: reuse that identical delay(250) pattern to catch a deliberately quick \(100\) ms button press and the sample period (\(250\) ms) is already longer than the event itself, so a press can land entirely between two polls and vanish. Nyquist would require \(f_s \geq 2/0.100 = 20\) Hz, i.e. a polling interval of \(50\) ms or less – five times faster than this chapter’s LM35 loop, not the same delay(250).

The LM35 example in this chapter is not evidence that delay()-based sampling is fine; it is evidence that Nyquist happened to be satisfied by a huge margin for this signal. The chapter’s own advice to move to millis()-based scheduling and short ISRs is the general-purpose fix for every signal where that margin is not so forgiving.

2.1 Start With the Story

Picture a soil-moisture prototype that “sometimes works” on a desk. The sensor is wired, the board is powered, and the sketch uploads, but the readings freeze whenever serial output gets noisy. Before adding a radio, dashboard, or enclosure, the team needs one simple firmware story: prove the board starts, prove the loop keeps running, prove each input changes visibly, and prove timing does not hide a fault.

This chapter turns that story into a repeatable firmware habit. Every sketch should leave a record of board profile, pin choices, serial evidence, timing behavior, and the next risk it has not proved yet.

2.2 Firmware Makes Wiring Behave

A microcontroller sketch is the bridge between a wiring diagram and observable device behavior. The code must configure pins, sample inputs, update outputs, keep time, and report enough evidence to show what the prototype actually did.

Microcontroller programming route in five steps: toolchain proof, setup and loop, GPIO ADC and serial, responsive timing, and interrupt handoff.
Firmware work is easier to review when each step leaves evidence: upload proof, runtime structure, I/O behavior, timing behavior, and interrupt handoff.

The route in the figure is the minimum path from “the board powers on” to “the prototype behavior can be defended.” First prove the toolchain can upload to the exact board and port. Then prove setup() initializes the hardware once and loop() returns often enough to read inputs, update outputs, and report state. Only after that should the sketch combine GPIO, ADC, serial output, timers, and interrupt flags.

The safest beginner habit is to keep each loop pass short. Read inputs, update state, write outputs, log the important observation, then return quickly so the device can respond to the next event. A prototype that blinks an LED but blocks for five seconds may look alive while missing button presses, sensor thresholds, watchdog timing, network retries, or actuator safety checks. Good firmware turns wiring into behavior that another engineer can reproduce from board profile, code version, wiring, serial output, and timing notes.

That evidence habit matters across Arduino Uno, ESP32, RP2040, STM32, nRF52, and similar boards because the sketch structure can look familiar while the electrical limits, ADC scale, pull-up behavior, interrupt pins, timers, and library internals differ. The chapter’s goal is not to memorize every board package; it is to make each firmware boundary visible before adding more sensors, radios, storage, or cloud code. In a lab notebook, that means tying every behavior claim to a serial trace, measured pin state, wiring photograph, and board configuration rather than to memory of a successful upload.

2.3 Prove Firmware Steps Separately

Build confidence one boundary at a time before combining sensors, communication, and actuator behavior. Start with the smallest sketch that can prove upload, reset, and serial output. Record the board profile, USB or serial port, board-support package version, sketch hash or filename, serial baud rate, and the exact message observed after reset. If that record is missing, later sensor or network debugging starts on uncertain ground.

For hardware I/O, isolate each path before composing the behavior. A digital output test should prove the pin can drive the selected LED, relay input, MOSFET gate, or indicator within the board’s current and voltage limits. A digital input test should prove the default state, pull-up or pull-down choice, debounce behavior, and disconnected-wire failure mode. An ADC test should record reference voltage, raw count range, conversion formula, expected sensor range, and what happens at open-circuit or saturated input. A serial, I2C, or SPI test should record the port, address or chip-select, speed, library version, and one known-good transaction.

For timing, replace long delay() calls as soon as two jobs must share the device. Use elapsed-time checks with millis() or the board’s equivalent so LED status, sensor sampling, serial logging, button handling, communication retry, and safety checks can run on their own intervals. For interrupts, keep the ISR short: capture a timestamp, increment a counter, or set a volatile flag, then let loop() copy that state, log it, and run the heavier application work. Treat every proof as a handoff record, not just a working demo.

  • Upload proof: record board profile, port, core version, sketch version, and the smallest sketch that proves the toolchain can reach the board.
  • I/O proof: test each GPIO, ADC, serial, I2C, or SPI path with expected values and one fault case, such as disconnected input or out-of-range reading.
  • Timing proof: replace long delay() calls with elapsed-time checks so sampling, logging, communication, and safety checks can share the loop.
  • Interrupt proof: keep ISR work short, set a flag or capture a timestamp, and do the real processing in loop() where it can be logged and tested.

2.4 Under the Hood: The Loop Is a Scheduler

Arduino-style firmware looks simple because setup() and loop() hide runtime startup, timers, serial buffers, board package details, and sometimes an RTOS task. Blocking code can still starve sensor reads, watchdog resets, communication retries, or actuator safety checks.

Non-blocking firmware treats the loop as a small cooperative scheduler. Each task decides whether it is time to run, does bounded work, records evidence, and gives control back. That pattern scales better when the prototype later adds networking, storage, OTA updates, or power management. It also makes failures easier to localize because a missed sample, repeated reset, or stale network send can be tied to the task that monopolized the loop.

The hidden machinery differs by board. On a classic AVR Arduino, the sketch often runs close to the foreground loop model. On ESP32 Arduino, loop() runs inside a FreeRTOS task while Wi-Fi, Bluetooth, timers, and system services use other tasks and interrupts. On RP2040, dual-core behavior and PIO peripherals can move timing-sensitive work outside the normal loop. On nRF52 or STM32 boards, vendor SDK layers, timer peripherals, DMA, and low-power modes can change which code runs while the main loop appears idle.

On a Cortex-M style board, that hidden machinery has a concrete execution model. Normal application work runs in thread mode; an interrupt briefly runs in handler mode, then returns. Core registers such as r0 through r3 carry arguments and return values, while r13, r14, and r15 act as the stack pointer, link register, and program counter. The vector table at address 0x0, or at a relocated vector-table address on some firmware, points reset, fault, service, and chip-specific IRQ entries at their handlers. A prototype record does not need to memorize every register, but it should prove that the intended IRQ is attached, the fault path is visible, and shared values are copied safely when handler code and normal loop code touch the same state.

Most MCU peripherals are controlled through registers exposed in the processor memory map. A GPIO block, ADC, SPI, I2C controller, UART, AES accelerator, or timer usually owns an address range; the driver writes configuration fields, starts the operation, then reads status or data, polls a pending flag, or waits for an interrupt. Small transfers, such as one UART byte or one ADC sample, can be handled directly. Larger or faster transfers should consider DMA: configure a buffer, count, and peripheral trigger, let the engine move the block, then handle one completion interrupt. That avoids waking the CPU for every byte or sample, but it creates a buffer-ownership contract that must be measured and documented.

Those differences do not remove the beginner model; they make its evidence more important. A safe interrupt path records only the event that must not be missed and leaves serial printing, network sends, memory allocation, and long calculations outside the ISR. A safe timer path proves that elapsed-time comparisons survive counter wraparound and do not assume a fixed loop speed. A safe ADC or serial path treats buffers, conversion time, baud rate, and reference voltage as part of the firmware contract. When the loop is treated as a scheduler, the prototype can grow without every new feature becoming another blocking delay. These constraints also explain why examples that work alone can fail when combined without timing budgets.

2.5 Learning Objectives

By the end of this chapter, you should be able to:

  • Explain the setup() and loop() execution model used by Arduino-style firmware.
  • Configure GPIO pins for digital output, digital input, and pull-up button input.
  • Read analog sensor values and convert raw ADC counts into engineering units with board-specific limits.
  • Use serial output as an evidence stream for debugging and prototype review.
  • Replace blocking delay() patterns with millis()-based scheduling.
  • Use interrupts safely by keeping ISR work short and moving processing into the main loop.
  • Capture enough firmware evidence for another engineer to reproduce the result.

2.6 Chapter Route

This chapter is not a catalog of every Arduino function. It is the minimum firmware route needed before the hardware, software, and kit chapters become useful.

Toolchain Install the board core, select the board and port, and verify upload with a tiny sketch.

Hardware I/O Use GPIO, pull-ups, ADC inputs, serial ports, I2C, and SPI with board limits in mind.

Timing Avoid blocking long-running firmware loops; schedule periodic work with elapsed time checks.

Evidence Record code version, wiring, board, serial output, timing observations, and remaining risks.

2.7 Board And Toolchain Setup

Arduino-style programming is an API and build workflow, not one chip family. The same sketch structure can target 8-bit AVR boards, ARM Cortex-M boards, ESP8266, ESP32, and newer RISC-V based boards, but voltage limits, pin capabilities, memory, ADC behavior, and interrupt details vary by board.

Use this setup sequence:

  1. Install Arduino IDE 2.x or a compatible workflow such as PlatformIO.
  2. Install the board support package for the board you actually have.
  3. Select the exact board model and serial port.
  4. Upload a minimal sketch.
  5. Open the serial monitor and confirm baud rate, reset messages, and expected output.
  6. Save board model, core version, library versions, wiring, and test result in the prototype record.
ESP32 Board Manager URL

For ESP32 development in Arduino IDE, Espressif documents the Arduino core installation flow and board-manager package URL. Treat that URL and board package version as part of the build record, because core behavior can change between versions.

2.8 setup() And loop()

Most Arduino-style sketches have two required functions:

void setup() {
    // Runs once after reset.
    // Configure pins, start Serial, initialize sensors.
}

void loop() {
    // Runs repeatedly.
    // Read inputs, update outputs, process state, report evidence.
}

Behind that simple structure, the board support package initializes clocks, memory, pins, timers, and runtime services. Some boards run loop() directly; ESP32 Arduino runs it as a FreeRTOS task. The beginner mental model still holds: initialize once, then repeat small units of work forever.

Arduino execution model showing power or reset, setup running once for pin and serial initialization, and loop repeating sensor reads, output control, and main logic.

Arduino setup and loop execution model
Common Setup Mistakes
  • Uploading to the wrong board profile or serial port.
  • Assuming every board has LED_BUILTIN on the same pin.
  • Connecting a 5 V sensor output directly to a 3.3 V-only input.
  • Using Wi-Fi, ADC, or interrupt examples from a different board family without checking pin support.

2.10 Sketch Syntax Review

Arduino sketches use ordinary C/C++ building blocks, but the review question is always tied to firmware behavior. Record the type, range, and lifetime of values that cross a hardware boundary. A bool flag can describe a button state, an int or long counter can hold pulse counts or elapsed time, a float can carry a converted sensor value, and byte or unsigned values often appear in register, address, and buffer work. Use the smallest type that keeps the measurement honest, then note the unit and expected range in the prototype record.

Operators and control statements should make the device state readable. Arithmetic operators convert raw readings, comparison and Boolean operators test thresholds, bitwise operators mask register flags or packed status bytes, and compound assignments update counters without hiding the meaning. if/else, switch, for, while, and do...while blocks are acceptable when the stop condition is explicit. Avoid unbounded loops inside loop() unless they are waiting for a clearly timed hardware event; otherwise they can starve serial output, watchdog service, sampling, or communication retries.

Arrays and strings need the same discipline. Arrays are useful for fixed sensor windows, lookup tables, calibration points, or pin lists, but the index must stay inside the declared length. Character arrays and Arduino String objects can hold labels or serial messages, yet repeated dynamic string growth can fragment memory on small boards. For a first prototype, prefer fixed buffers or short, bounded strings when the sketch will run for a long time, and record any random-number or math-library use so reviewers can repeat the same test conditions.

2.11 GPIO: Output, Input, And Pull-Ups

GPIO pins are general-purpose digital pins. They can drive outputs, read inputs, or connect to special peripheral functions such as PWM, ADC, UART, I2C, and SPI. The same physical pin may not support every function, so always check the board pinout.

GPIO, ADC, and serial signal paths showing digital output to LED, pull-up button input, analog sensor into ADC, and UART serial output to a computer.

GPIO, ADC, and serial signal paths

2.11.1 Digital Output

const int LED_PIN = 2;  // Example GPIO; verify for your board.

void setup() {
    pinMode(LED_PIN, OUTPUT);
}

void loop() {
    digitalWrite(LED_PIN, HIGH);
    delay(250);
    digitalWrite(LED_PIN, LOW);
    delay(250);
}

2.11.2 Button Input With INPUT_PULLUP

An input pin must have a defined default voltage. INPUT_PULLUP uses the board’s internal pull-up resistor so the input reads HIGH when the button is open and LOW when the button connects the pin to ground.

const int BUTTON_PIN = 4;
const int LED_PIN = 2;

void setup() {
    pinMode(BUTTON_PIN, INPUT_PULLUP);
    pinMode(LED_PIN, OUTPUT);
}

void loop() {
    bool pressed = digitalRead(BUTTON_PIN) == LOW;
    digitalWrite(LED_PIN, pressed ? HIGH : LOW);
}
Floating Inputs Are Not Random Bugs

If a digital input is not connected to a defined voltage through a pull-up or pull-down path, it can float. A floating input may appear to work on the bench and fail when a hand, wire, relay, or radio signal changes the local electrical environment. Define the default state in hardware or with a supported internal resistor.

2.11.3 Digital Streams And Timed Pins

Some Arduino helpers treat GPIO pins as small timing streams rather than single on/off values. shiftOut() and shiftIn() clock bits through simple serial-style devices such as shift registers when a full SPI peripheral is unnecessary or unavailable. pulseIn() measures the width of a pulse from devices such as ultrasonic range sensors, and tone() generates a square-wave output for buzzers or simple audio feedback. These helpers are useful for first proofs, but they still need timing evidence: long blocking pulse measurements can stall the main loop, generated tones can interfere with timers on some boards, and bit-banged streams must stay inside the device’s voltage and timing limits.

2.12 Analog Input And ADC Evidence

Analog sensors output a voltage. The ADC converts that voltage into a number. The number range depends on board family, board core configuration, attenuation settings, and reference voltage.

Use board-specific constants in examples instead of burying assumptions in the calculation.

const int SENSOR_PIN = 34;       // Example ESP32 ADC-capable pin.
const float VREF = 3.3;          // Measure or document the reference.
const int ADC_MAX = 4095;        // Common ESP32 Arduino 12-bit default.

void setup() {
    Serial.begin(115200);
}

void loop() {
    int raw = analogRead(SENSOR_PIN);
    float voltage = raw * (VREF / ADC_MAX);

    Serial.print("raw=");
    Serial.print(raw);
    Serial.print(", voltage=");
    Serial.println(voltage, 3);

    delay(250);
}

For an LM35-style temperature sensor, the sensor output is commonly described as 10 mV per degree C. The conversion is simple, but the measurement is only as trustworthy as the board reference, ADC linearity, wiring, and calibration evidence.

float lm35CelsiusFromVoltage(float voltage) {
    return voltage * 100.0;
}
ADC Review Questions
  • What is the board’s actual ADC input voltage limit?
  • Does the pin support ADC while Wi-Fi or other peripherals are active?
  • Is calibration needed for the accuracy required by the prototype?
  • Are readings stable after averaging, grounding, and sensor warm-up?
  • Does the evidence record include raw counts, converted values, board voltage, and test conditions?

2.13 Serial Debugging As Evidence

Serial output is more than “print debugging.” In early prototypes, serial output is often the only evidence stream showing boot reason, firmware version, sensor values, timing, state transitions, and fault cases.

void setup() {
    Serial.begin(115200);
    Serial.println("firmware=mcu-essentials-demo version=0.1.0");
}

void loop() {
    int raw = analogRead(34);

    Serial.print("millis=");
    Serial.print(millis());
    Serial.print(", sensor_raw=");
    Serial.println(raw);

    delay(1000);
}

Use serial carefully:

  • Start with a common baud rate such as 115200 and record it.
  • Print key-value pairs so logs can be searched and compared.
  • Avoid printing inside interrupts.
  • Avoid very heavy logging inside fast loops.
  • Prefer explicit state names over vague messages such as “done” or “error.”

2.14 Non-Blocking Timing With millis()

delay() pauses the whole loop. While the loop is delayed, firmware cannot read a button, service a state machine, check a timeout, or publish a new sensor sample. The replacement pattern is to compare elapsed time.

Blocking vs Non-Blocking Timing: delay() vs millis() Comparison, BLOCKING: delay(), delay(1000); // CPU halts here, TIME →, Task A, delay(1000), ◼ FROZEN, Task A, resumes

Blocking vs Non-Blocking Timing
const int LED_PIN = 2;
const int SENSOR_PIN = 34;

unsigned long lastBlinkMs = 0;
unsigned long lastSensorMs = 0;
bool ledState = false;

void setup() {
    pinMode(LED_PIN, OUTPUT);
    Serial.begin(115200);
}

void loop() {
    unsigned long now = millis();

    if (now - lastBlinkMs >= 500) {
        lastBlinkMs = now;
        ledState = !ledState;
        digitalWrite(LED_PIN, ledState ? HIGH : LOW);
    }

    if (now - lastSensorMs >= 1000) {
        lastSensorMs = now;
        int raw = analogRead(SENSOR_PIN);
        Serial.print("sensor_raw=");
        Serial.println(raw);
    }

    // Other work can continue here.
}

The subtraction form now - lastTime >= interval is the standard pattern because it continues to work when millis() rolls over.

2.15 Interrupt Top and Bottom Halves

Interrupts are for urgent hardware events. An interrupt service routine pauses normal execution, runs a small handler, then returns to the main context. The safest beginner pattern is to set a flag in the ISR and do the real work in loop().

Hardware Interrupt Execution Flow: Event-Driven Program Response, EXECUTION TIMELINE →, Main Program, loop() running, ⚡ EXTERNAL EVENT!, (Button Press), IRQ FIRED, STEP 1, CPU Saves State

Hardware Interrupt Execution Flow

const int BUTTON_PIN = 4;

volatile bool buttonEvent = false;

void IRAM_ATTR buttonISR() {
    buttonEvent = true;
}

void setup() {
    Serial.begin(115200);
    pinMode(BUTTON_PIN, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}

void loop() {
    bool eventCopy = false;

    noInterrupts();
    if (buttonEvent) {
        buttonEvent = false;
        eventCopy = true;
    }
    interrupts();

    if (eventCopy) {
        Serial.println("button_event=pressed");
        // Network, display, and sensor work belongs here, not inside the ISR.
    }
}

IRAM_ATTR is ESP32-specific. On many other Arduino-compatible boards, omit it unless the board core documents an equivalent attribute.

ISR Rules
  • Keep the ISR short.
  • Set flags or copy tiny values only.
  • Mark shared ISR variables as volatile.
  • Protect multi-byte shared data when copying it.
  • Do not call delay(), Serial.print(), I2C sensor reads, network functions, dynamic allocation, or long loops in the ISR.

2.16 Firmware Evidence Record

A prototype result should be reproducible. Capture the evidence while the board is still on the bench.

Build context Board model, board core version, IDE or CLI version, library versions, sketch commit, and upload settings.

Wiring context Pin map, voltage levels, pull-up or pull-down choice, sensor power, ground path, and external modules.

Runtime context Boot log, serial baud rate, reset reason if available, sample interval, loop timing, and observed failures.

Decision context What the sketch proves, what it does not prove, and what change should trigger a retest.

2.17 Common Failure Patterns

Review These Before Blaming The Board
  • Wrong board profile or stale board support package.
  • Wrong serial port or bad USB data cable.
  • Floating button input because no pull-up or pull-down path exists.
  • ADC value interpreted without checking input range, attenuation, or calibration.
  • delay() hiding missed button presses or stale sensor reads.
  • ISR doing serial, sensor, or network work.
  • Code assumes a pin is safe without checking boot strap, ADC, PWM, or peripheral conflicts.
  • Serial logs omit firmware version, pin map, and test conditions.

2.18 Knowledge Check

Check: GPIO Default State
Check: Match The Firmware Concept

Check: Order The Interrupt Handoff

2.19 Summary

  • setup() initializes; loop() repeats normal work.
  • GPIO inputs need defined electrical states.
  • ADC readings require board-specific voltage and calibration context.
  • Serial output is evidence, not just debugging convenience.
  • millis() scheduling keeps firmware responsive.
  • Interrupts should hand off work quickly to the main loop.
  • Every prototype sketch should leave a build, wiring, runtime, and decision record.

2.20 Key Takeaway

Microcontroller code is production preparation, not just syntax: initialize hardware safely, isolate drivers, handle timing explicitly, and leave diagnostics that make field faults explainable.

2.21 What’s Next