2 Microcontroller Programming: Setup and GPIO
2.1 Overview
This first route takes a board from toolchain setup through a testable GPIO sketch and stable inputs.
This is part 1 of 2. Continue with Microcontroller Programming: Timing and Reliability for the second focused route.
2.2 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.3 Firmware Makes Wiring Behave
In Figure 2.1, 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.
In Figure 2.1, 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.4 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.5 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.
2.5.1 The Race Condition Behind The ISR Rules
Two ordinary lines of C — a = a + 1; in loop(), a = a - 1; in a handler — are not one CPU instruction each. The compiler expands each into a short read-modify-write sequence: load a into a register, add or subtract one, store the register back to a. If a handler-mode interrupt lands in the middle of that sequence — after the interrupted code has already loaded a into a register but before it has stored the result back — the handler’s own update can be silently overwritten once the interrupted code resumes and writes its now-stale copy. That is the concrete failure the “protect multi-byte shared data when copying it” rule above is guarding against: it is not caution for its own sake, it is what happens whenever two contexts touch the same variable and neither is paused for the whole read-modify-write sequence.
2.5.2 Three Ways To Handle The Interrupt
Firmware has three general strategies for keeping shared state safe under that risk, and each trades latency against concurrency differently:
- Spin and wait. Main-line code polls a pending flag until it is safe to proceed. No race condition and no missed update, but the CPU burns cycles doing nothing else while it waits.
- Disable interrupts around the critical section.
noInterrupts()/interrupts()pause handler-mode execution just long enough to read or write the shared value safely. Latency stays small if the protected section stays small, but a critical section that grows — a straySerial.print(), a loop, a call that takes longer than expected — delays every other interrupt on the board. - Do almost nothing in the handler, and defer the real work. The handler sets a flag or copies one small value, then returns;
loop()picks the flag up on its next pass and does the actual processing. This is the pattern this chapter’sbuttonISRexample already uses: full concurrency and a trivially short handler, at the cost of a small, bounded delay beforeloop()notices the event — the same delay the “Interrupt proof” item above asks you to account for.
Most Arduino-style firmware defaults to the third strategy because it keeps latency predictable without demanding careful critical-section bookkeeping inside every handler. The noInterrupts() / interrupts() pair around the flag copy in this chapter’s loop() is strategy two, applied briefly and locally, to protect only the moment where the flag is read and cleared — the two strategies are combined here, not competing.
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.6 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.7 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.
2.8 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.9 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.
The execution model matters because initialization and repeated work have different lifetimes. In Figure 2.2, compare the labelled setup() and loop() regions before deciding where a line of firmware belongs.
Figure 2.2 marks RUNS ONCE beside setup() and RUNS FOREVER beside loop(). The examples reinforce the split: Serial.begin(115200); initializes the serial interface, whereas temp = dht.readTemp(); belongs to the repeating sensor work. This distinction carries the programming narrative from board start-up into responsive operation: configure once, return through the loop often, and measure whether any repeated task prevents the others from running.
- 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.10 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.11 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.12 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.
Inspect Microcontroller and INPUTPULLUP in Figure 2.3 for gpio: output, input, and pull-ups. For the evidence behind gpio: output, input, and pull-ups, put Microcontroller and INPUTPULLUP into the same reading of it. The route closes at evidence log.
Read Microcontroller with INPUTPULLUP in Figure 2.3 for gpio: output, input, and pull-ups. Follow it from Microcontroller through INPUTPULLUP to evidence log. Skipping INPUTPULLUP would leave evidence log unsupported. This supplies gpio: output, input, and pull-ups with a concrete retest point.
2.12.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.12.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.13 Continue to Part 2
Continue with Microcontroller Programming: Timing and Reliability.
