2 Microcontroller Programming Essentials
Firmware loops, GPIO, ADC, serial debugging, non-blocking timing, and interrupt handoffs
microcontroller programming, Arduino, ESP32, GPIO, ADC, serial debugging, millis, interrupts, IoT firmware
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.
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()andloop()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 withmillis()-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:
- Install Arduino IDE 2.x or a compatible workflow such as PlatformIO.
- Install the board support package for the board you actually have.
- Select the exact board model and serial port.
- Upload a minimal sketch.
- Open the serial monitor and confirm baud rate, reset messages, and expected output.
- Save board model, core version, library versions, wiring, and test result in the prototype record.
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.
- Uploading to the wrong board profile or serial port.
- Assuming every board has
LED_BUILTINon 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.9 First Sketch: Blink With Evidence
The first sketch should prove that the toolchain, USB cable, board selection, upload path, reset behavior, and serial monitor work.
const int LED_PIN = LED_BUILTIN;
void setup() {
pinMode(LED_PIN, OUTPUT);
Serial.begin(115200);
Serial.println("boot: blink evidence sketch");
}
void loop() {
digitalWrite(LED_PIN, HIGH);
Serial.println("led=on");
delay(500);
digitalWrite(LED_PIN, LOW);
Serial.println("led=off");
delay(500);
}This version still uses delay() because it is a first hardware proof. Later in the chapter, you will replace that blocking pattern with millis().
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.
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.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;
}- 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.
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().
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.
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
- 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
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
- 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.
