3 Microcontroller Programming: Timing and Reliability
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;
}
- 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.
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.
-
A pin edge rings the interrupt bell.
-
The controller saves where the loop stopped.
-
The short handler sets one shared flag.
-
The saved loop state is restored at once.
-
Normal code sees the flag and does the slow work.
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.
- 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.
3.11 Common Failure Patterns
- 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
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 , where 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.
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 range | Architectural region | Practical interpretation |
|---|---|---|
0x00000000–0x1FFFFFFF | Code | Boot aliases, flash, ROM, and code-facing regions defined by the device |
0x20000000–0x3FFFFFFF | SRAM | On-chip volatile data and implemented aliases |
0x40000000–0x5FFFFFFF | Peripheral | Memory-mapped device registers; accesses may have side effects |
0x60000000–0x7FFFFFFF | External RAM, lower half | Often described with write-through/default normal-memory attributes on cache-capable systems |
0x80000000–0x9FFFFFFF | External RAM, upper half | Often described with write-back/default normal-memory attributes on cache-capable systems |
0xA0000000–0xBFFFFFFF | Shared device | External or system device space whose ordering/shareability matters |
0xC0000000–0xDFFFFFFF | Non-shared device | Device space not shared with another observer in the default model |
0xE0000000–0xFFFFFFFF | System | Private 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:
| Register | Conventional role under the Arm procedure-call standard |
|---|---|
r0 | First argument and primary return value |
r1–r3 | Remaining first four arguments; caller-saved scratch values |
r4–r10 | Callee-saved general registers |
r11 | Callee-saved; commonly used as a frame pointer when that compiler/ABI choice is enabled |
r12 | Intra-procedure-call scratch register (IP) |
r13 | Stack pointer (SP); Main and Process stack banks may exist |
r14 | Link register (LR), holding return or exception-return context |
r15 | Program 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 / field | Bit(s) | Meaning |
|---|---|---|
APSR N | 31 | Negative: the result’s most significant bit is one |
APSR Z | 30 | Zero: the result is zero |
APSR C | 29 | Carry/borrow/shift carry; interpret subtraction using Arm’s no-borrow convention |
APSR V | 28 | Signed overflow: the signed mathematical result did not fit |
APSR Q | 27 | Sticky saturation flag set by saturating/DSP operations until software clears it as permitted |
| IPSR exception number | 8:0 | Zero means Thread mode; nonzero identifies the active exception |
EPSR T | 24 | Thumb execution state; Cortex-M executes Thumb instructions and an invalid state faults |
EPSR ICI/IT | split across 26:25 and 15:10 | Interrupt-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 , 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 family | Volatile? | Write/endurance boundary | Relative access and energy | Typical IoT role |
|---|---|---|---|---|
| SRAM | Yes | Normal runtime writes; contents disappear without power | Fast random access; leakage matters while retained | Stack, heap, DMA buffers, live state |
| DRAM | Yes | Normal runtime writes; requires refresh | Dense and fast for larger working sets; controller and refresh add energy | Linux-class frame buffers, models, caches |
| Mask ROM / boot ROM | No | Fixed when manufactured | Read-only and predictable | Immutable first-stage boot or vendor routines |
| EEPROM | No | Writes are slower and endurance-limited | Byte/page updates can cost much more energy than reads | Small calibration, configuration, counters with wear control |
| NOR/NAND Flash | No | Erase-before-write, page/block granularity, finite endurance | Dense firmware or bulk storage; erase and program are expensive operations | Firmware slots, filesystem, buffered logs |
| Non-volatile RAM such as FRAM/MRAM | No | Technology-specific endurance and retention | Often offers simpler low-energy writes than Flash, at higher cost or lower density | High-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.
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.
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
- Programming Paradigms Overview for firmware structure choices beyond one sketch.
- Programming Development Tools for IDEs, build systems, debuggers, and serial tools.
- Programming Code Examples for reusable patterns.
- Programming Best Practices for maintainable prototype firmware.
- Prototyping Hardware for choosing and wiring boards, sensors, power, and enclosures.
- Simulating Hardware Programming for browser-based test workflows before flashing hardware.
