Chapters

3 Microcontroller Programming: Timing and Reliability

prototyping
microcontrollers
firmware
arduino
esp32

3.1 Start With the Situation

Digital pins now behave as expected, but sensor readings and timed work add new failure paths. The team must measure ADC evidence, debug without blocking the loop, and protect interrupt and watchdog boundaries.

3.2 Overview

This route moves from analog evidence into serial debugging, cooperative timing, interrupts, and reliability records.

This is part 2 of 2. Review Microcontroller Programming: Setup and GPIO when you need the first route.

3.3 Learning Objectives

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

  • interpret ADC evidence
  • debug firmware through serial records
  • implement non-blocking timing, interrupts, and watchdog recovery

3.4 Chapter Roadmap

Follow the original sections below in order. They begin at the reviewed split boundary and keep every worked example, figure, check, and supporting banner with the section that owns it.

3.5 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?

3.6 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.”

3.7 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.

Responsiveness is easier to judge when the two timing models share one time axis. Use Figure 3.1 to compare BLOCKING: delay() with NON-BLOCKING: millis() before choosing how the loop will schedule concurrent duties.

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
Figure 3.1: Blocking vs Non-Blocking Timing

On the blocking half of Figure 3.1, CPU FROZEN coincides with Task B missed and Task C missed. The non-blocking half instead shows the millis() test interleaving A, B, and C, so elapsed time becomes a condition to check rather than a command that halts execution. That contrast connects directly to the running firmware narrative: a prototype that must sample, communicate, and react should demonstrate that every duty still gets a turn within its deadline.

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.

3.8 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().

Inspect 1 Main program and 5 Restore context in Figure 3.2 for interrupt top and bottom halves. To challenge the claim in interrupt top and bottom halves, use it to distinguish 1 Main program from 5 Restore context. Use No slow serial work as the boundary.

Hardware interrupt flow showing the main program sleeping until an external event raises an IRQ, CPU context being saved, a short interrupt service routine setting a flag or reading one register, context being restored, and the main program resuming where it paused. The rules keep the ISR short, avoid blocking delay and slow serial work, and use disciplined shared state.
Figure 3.2: Hardware Interrupt Execution Flow
  1. Voltage Vera: A sensor switch rings a small bell beside Vera and pauses the moving program belt.

    A pin edge rings the interrupt bell.

  2. Voltage Vera: Vera clips a place marker to the paused belt.

    The controller saves where the loop stopped.

  3. Voltage Vera: At a tiny side station, Vera raises one bright flag and does no heavy work.

    The short handler sets one shared flag.

  4. Voltage Vera: Vera returns to the place marker and restarts the belt.

    The saved loop state is restored at once.

  5. Voltage Vera: Farther along the belt, Vera lowers the flag and runs the sensor and network work at a full bench.

    Normal code sees the flag and does the slow work.

CW-0021 walkthrough: An interrupt briefly pauses the loop, saves its place, sets a small shared flag, restores the loop, and leaves slow work for normal code.

Read 1 Main program with 5 Restore context in Figure 3.2 for interrupt top and bottom halves. Trace it starting at 1 Main program, crossing 5 Restore context, and closing on No slow serial work. A failure at 5 Restore context changes the route from 1 Main program. Use 5 Restore context to assign evidence ownership in interrupt top and bottom halves.

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.

3.9 Watchdog Timer: A Reliability Backstop

Interrupts and millis() scheduling keep the loop responsive to events its author anticipated. A watchdog timer covers the events nobody anticipated: a sensor library call that blocks forever, a network function that never returns, a rare branch that spins. Many microcontroller cores expose a watchdog as a hardware counter that runs independently of loop() and resets the board if it is not serviced, or “fed,” inside a configured timeout — a deadman’s switch for a firmware stall.

#include <avr/wdt.h>

void setup() {
    Serial.begin(115200);
    wdt_enable(WDTO_2S);  // Reset the board if the loop stalls for 2 s.
}

void loop() {
    // Sample, act, and log inside the timeout window.
    wdt_reset();  // Feed the watchdog once this pass completes cleanly.
}

wdt_enable() and wdt_reset() are the classic AVR watchdog API from <avr/wdt.h>. ESP32, RP2040, and other cores expose their own watchdog mechanisms, such as the ESP32 Arduino core’s task watchdog, with different setup calls but the same deadman’s-switch idea; check the board core’s documentation before moving this pattern to a different board family.

A watchdog only protects a loop that is already structured to return often. Feeding it right before a long delay() or an unbounded wait defeats the purpose, because the feed call proves nothing about whether the rest of the loop would actually have completed. Treat an unexpected watchdog reset the same way this chapter treats any other fault: as a reset reason worth recording in the runtime evidence, not a silent recovery.

3.10 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.

3.11 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.

3.12 Knowledge Check

Check: GPIO Default State
Check: Match The Firmware Concept
Check: Order The Interrupt Handoff

3.13 Compiler And Fixed-Point Lab

A compiler can schedule independent instructions, use vector instructions when the target has them, and replace a short branch with predicated work. None of those transformations removes the need for evidence. Inspect the generated code, time the actual target, and keep the input distribution with the result. A faster loop on a desktop compiler does not prove the same saving on an MCU with a different instruction set, memory system, or floating-point unit.

Fixed-point arithmetic makes that evidence boundary visible. In signed Q-format, a stored integer is interpreted with a scale of 2m2^m, where mm is the number of fractional bits. More fractional bits improve resolution but reduce range. Fewer fractional bits widen the range but increase quantization error. Overflow is not a small accuracy loss: wraparound can reverse a sign or turn a large control demand into a small one, so the lab saturates deliberately and reports the event.

Runnable Conversion And Overflow Lab

The cycle inputs are a transparent comparison model, not a processor claim. Replace them with measured cycle counts or energy per operation from the target toolchain before choosing an implementation.

Run three evidence cases: a value near zero to expose quantization, the largest expected field value, and a value just beyond the representable range. Then compile the same multiply-accumulate loop with optimization disabled and enabled. Record compiler version and flags, inspect whether SIMD or predication was emitted, and compare measured cycles, code size, buffer memory, numerical error, and overflow behavior. Keep floating point if it is accurate enough and the measured target cost is acceptable; fixed point earns its complexity only when the evidence shows a useful resource gain.

3.14 Cortex-M core and address maps

Cortex-M code sees a 32-bit, 4 GiB address space even when the selected MCU implements only a small fraction of it. The architectural regions give toolchains and debuggers a common starting point; the device reference manual still decides which flash banks, SRAM blocks, peripheral registers, and aliases actually exist.

Address rangeArchitectural regionPractical interpretation
0x000000000x1FFFFFFFCodeBoot aliases, flash, ROM, and code-facing regions defined by the device
0x200000000x3FFFFFFFSRAMOn-chip volatile data and implemented aliases
0x400000000x5FFFFFFFPeripheralMemory-mapped device registers; accesses may have side effects
0x600000000x7FFFFFFFExternal RAM, lower halfOften described with write-through/default normal-memory attributes on cache-capable systems
0x800000000x9FFFFFFFExternal RAM, upper halfOften described with write-back/default normal-memory attributes on cache-capable systems
0xA00000000xBFFFFFFFShared deviceExternal or system device space whose ordering/shareability matters
0xC00000000xDFFFFFFFNon-shared deviceDevice space not shared with another observer in the default model
0xE00000000xFFFFFFFFSystemPrivate peripheral bus, debug, interrupt controller, and vendor/system areas

Do not infer a cache from the write-through/write-back labels: Cortex-M0/M0+ implementations do not suddenly acquire a data cache because an address falls in an external-RAM region. Actual cacheability, MPU attributes, bus fabric, and external-memory support are core- and SoC-specific. In everyday MCU firmware the critical ranges are normally code, on-chip SRAM, peripherals, and the System Control Space.

Memory-mapped I/O means a peripheral owns addresses just as RAM owns addresses, but reads and writes are not ordinary storage operations. A status-register read may clear a flag, a write-one-to-clear bit behaves unlike assignment, and access width or ordering can matter. Use the vendor header’s volatile register definitions, then check the exact register description before applying a read-modify-write sequence.

The core-register map is smaller but equally contractual:

RegisterConventional role under the Arm procedure-call standard
r0First argument and primary return value
r1r3Remaining first four arguments; caller-saved scratch values
r4r10Callee-saved general registers
r11Callee-saved; commonly used as a frame pointer when that compiler/ABI choice is enabled
r12Intra-procedure-call scratch register (IP)
r13Stack pointer (SP); Main and Process stack banks may exist
r14Link register (LR), holding return or exception-return context
r15Program counter (PC)

The frame pointer is a convention, not a new architectural register. Some Thumb toolchains use r7, some use r11, and optimized builds may omit a frame pointer entirely. Preserve the callee-saved rule and inspect the generated unwind/debug information instead of assuming every stack frame has an r11 chain.

3.15 Cortex-M status registers

On Cortex-M4, xPSR is the combined view of three overlapping status-register roles: the Application Program Status Register (APSR), Interrupt Program Status Register (IPSR), and Execution Program Status Register (EPSR).

View / fieldBit(s)Meaning
APSR N31Negative: the result’s most significant bit is one
APSR Z30Zero: the result is zero
APSR C29Carry/borrow/shift carry; interpret subtraction using Arm’s no-borrow convention
APSR V28Signed overflow: the signed mathematical result did not fit
APSR Q27Sticky saturation flag set by saturating/DSP operations until software clears it as permitted
IPSR exception number8:0Zero means Thread mode; nonzero identifies the active exception
EPSR T24Thumb execution state; Cortex-M executes Thumb instructions and an invalid state faults
EPSR ICI/ITsplit across 26:25 and 15:10Interrupt-continuable instruction state or Thumb IT conditional-execution state

The arithmetic flags are easiest to separate with two examples. 0xFFFFFFFF + 1 produces zero, so Z=1 and C=1; interpreted as signed 1+1-1+1, it does not overflow, so V=0. By contrast, 0x7FFFFFFF + 1 produces 0x80000000: N=1, V=1, and C=0. Carry describes the unsigned result, while overflow describes the signed result.

IPSR gives exception context, not a generic interrupt-enabled flag. Handler mode has a nonzero exception number; Thread mode reads zero. EPSR’s ICI/IT fields are execution-resume state maintained by the processor around interrupted multi-cycle instructions or IT blocks. Application code should not treat them as spare flags. When a debugger shows xPSR, decode each view by its own meaning: arithmetic result in APSR, active exception in IPSR, and execution state in EPSR.

3.16 Memory Hierarchy For IoT Firmware

Memory names describe different contracts, not interchangeable storage. A design review should keep volatile working memory, immutable boot code, updateable firmware, calibration values, event logs, and high-write counters separate before selecting a device.

Memory familyVolatile?Write/endurance boundaryRelative access and energyTypical IoT role
SRAMYesNormal runtime writes; contents disappear without powerFast random access; leakage matters while retainedStack, heap, DMA buffers, live state
DRAMYesNormal runtime writes; requires refreshDense and fast for larger working sets; controller and refresh add energyLinux-class frame buffers, models, caches
Mask ROM / boot ROMNoFixed when manufacturedRead-only and predictableImmutable first-stage boot or vendor routines
EEPROMNoWrites are slower and endurance-limitedByte/page updates can cost much more energy than readsSmall calibration, configuration, counters with wear control
NOR/NAND FlashNoErase-before-write, page/block granularity, finite enduranceDense firmware or bulk storage; erase and program are expensive operationsFirmware slots, filesystem, buffered logs
Non-volatile RAM such as FRAM/MRAMNoTechnology-specific endurance and retentionOften offers simpler low-energy writes than Flash, at higher cost or lower densityHigh-write counters, checkpoints, event journals

Do not copy a generic endurance, retention, speed, or energy number into a product decision. Record the exact part, voltage, temperature range, access mode, erase geometry, error-correction assumptions, and datasheet revision. Endurance is a workload calculation: a one-million-cycle cell can still fail early if firmware rewrites it many times per second without wear distribution.

For a review exercise, classify every persistent field in the firmware. Give each one a maximum write rate, required retention after power loss, acceptable recovery loss, and integrity mechanism. Size volatile buffers from the worst credible burst rather than the average. Finally, simulate an interrupted update and an interrupted log write: the hierarchy is credible only if boot code can select a known-good image and the application can distinguish a complete record from a torn one.

Pair address evidence with live execution state using Figure 3.3.

Cortex-M address regions paired with core-register groups, calling-convention roles, frame pointer, stack pointer, link register, and program counter.
Figure 3.3: Cortex-M address regions paired with core-register groups, calling-convention roles, frame pointer, stack pointer, link register, and program counter.

In the diagram Figure 3.3, the field strip runs from Code 0x0 through SRAM, Periph, Device, and System regions. Below it, Arguments and return assigns r0-r3, while Control the call identifies r13 SP, r14 LR, and r15 PC as the registers that reconstruct execution flow.

Decode processor status by question, not as one opaque hexadecimal word, in the diagram Figure 3.4.

Cortex-M xPSR anatomy combining APSR condition flags, IPSR exception number, and EPSR Thumb and ICI or IT execution state.
Figure 3.4: Cortex-M xPSR anatomy combining APSR condition flags, IPSR exception number, and EPSR Thumb and ICI or IT execution state.

In the diagram Figure 3.4, aPSR: arithmetic outcome exposes N/Z/C/V/Q, IPSR: current exception supplies the exception number, and EPSR: execution state carries Thumb plus ICI/IT state. Their xPSR combination is a convenient snapshot, not a single undifferentiated meaning.

3.17 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.

3.18 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.

3.19 What’s Next