Install and configure Arduino IDE and program ESP32/Arduino boards
Distinguish the roles of setup() and loop() in the microcontroller execution model
Configure GPIO pins for digital and analog input/output
Implement serial communication for debugging and data transfer
Design interrupt-driven routines for responsive sensor reading
Apply non-blocking timing techniques using millis() instead of delay()
Interface with common sensors using reusable code patterns
Key Concepts
GPIO: General Purpose I/O pin configurable as digital input, digital output, or special function (PWM, ADC, UART, I2C, SPI).
Interrupt: Hardware signal causing the CPU to suspend the current program and execute a dedicated interrupt service routine.
Watchdog Timer: Hardware counter resetting the microcontroller if firmware hangs for longer than a configured timeout.
Deep Sleep: Processor state shutting down most peripherals, reducing current to 10-100 µA, extending battery life by 10-100×.
I2C: Two-wire serial bus (SDA + SCL) supporting multiple addressed peripherals at 100 kHz to 3.4 MHz.
SPI: Four-wire full-duplex serial protocol for high-speed communication with sensors, displays, and flash memory.
UART: Universal Asynchronous Receiver-Transmitter, the simplest serial interface used for debugging and AT command modems.
For Beginners: What is Microcontroller Programming?
A microcontroller is a tiny computer on a chip. Unlike your laptop or phone, it’s designed to do one thing well: interact with the physical world through sensors and actuators.
Why is this different from regular programming?
Direct hardware access - You control individual pins on the chip
Real-time constraints - Events happen in microseconds, not milliseconds
Limited resources - Often only 4-32 KB of RAM (your phone has ~8 GB!)
No operating system - Your code runs directly on the hardware
What you’ll learn:
How to make an LED blink (the “Hello World” of hardware)
How to read a button or sensor
How to send data to your computer
How to respond to events quickly (interrupts)
This chapter prepares you for hands-on work with Arduino and ESP32 boards in the prototyping chapters.
For Kids: Meet the Sensor Squad! 🌟
Programming a microcontroller is like teaching a tiny robot brain to follow your instructions - you get to be the boss!
2.1.1 The Sensor Squad Adventure: Max Learns His First Trick
It was Max the Microcontroller’s very first day on the job, and he was SO excited! But there was one problem - Max didn’t know how to do ANYTHING yet. He was just a blank chip, waiting to learn.
“Don’t worry, Max!” said Lila the LED cheerfully. “The humans will teach you using something called PROGRAMMING. They’ll write special instructions that tell you exactly what to do!” Sammy the Sensor nodded wisely. “First, they’ll write a setup() part - that’s like your morning routine where you get ready for the day. You learn which pins do what and say hello to all your sensor friends!”
“Then comes the fun part,” added Bella the Battery. “The loop() is like your daily job that you do over and over forever! Maybe you read Sammy’s temperature, then tell Lila to blink, then wait a second, and do it all again!” Max’s circuits tingled with excitement. “So setup() runs once when I wake up, and loop() runs forever until someone unplugs me? That sounds simple!” The team cheered as the humans uploaded Max’s first program. BLINK! BLINK! BLINK! went Lila, following Max’s brand new instructions. “I did it!” cheered Max. “I made Lila blink! This is amazing - I can learn to do ANYTHING!”
2.1.2 Key Words for Kids
Word
What It Means
Program
A list of instructions that tells the microcontroller exactly what to do, step by step - like a recipe for a robot!
setup()
The part of the program that runs ONE time when the microcontroller wakes up - like brushing your teeth in the morning
loop()
The part of the program that runs FOREVER, over and over - like breathing (you do it automatically all day!)
Upload
Sending your program from the computer into the microcontroller’s brain so it can learn what to do
2.1.3 Try This at Home!
Be a Human Microcontroller: Write a simple “program” on paper with setup() and loop() sections. For setup(), write things you do once in the morning (brush teeth, get dressed). For loop(), write things you repeat all day (breathe, blink eyes, check if you’re hungry). Now have a friend or family member “run” your program - they do the setup steps once, then repeat the loop steps over and over until you say “stop!” This is exactly how a microcontroller works - it follows your instructions perfectly, exactly as you wrote them. If you wrote “jump 100 times” it would do ALL 100 jumps. That’s why programmers have to be very careful with their instructions!
2.2 The Arduino/ESP32 Ecosystem
2.2.1 Why Arduino and ESP32?
Platform
Pros
Cons
Best For
Arduino Uno
Simple, huge community, 5V logic
Slow (16 MHz), no Wi-Fi
Learning, simple projects
Arduino Nano
Small, breadboard-friendly
Same limitations as Uno
Compact projects
ESP32
Wi-Fi+Bluetooth, fast (240 MHz), cheap
3.3V logic, more complex
IoT projects
ESP8266
Very cheap, Wi-Fi
Fewer pins, older
Simple Wi-Fi projects
Raspberry Pi Pico
Dual-core, flexible
Less community support
Advanced projects
Figure 2.1: Arduino/ESP32 Development Ecosystem: IDE, Libraries, Hardware, and Community
Figure 2.2: Alternative View: Learning Journey Timeline - Instead of showing ecosystem components, this view presents Arduino/ESP32 learning as a progressive journey. Beginners start with LED blinking (Week 1-2), advance through sensors and serial debugging (Week 3-4), master non-blocking timing and interrupts (Week 5-6), and finally integrate Wi-Fi and cloud connectivity (Week 7-8). This timeline helps students see the logical progression and estimate their learning path from first program to complete IoT device.
2.2.2 Setting Up Your Development Environment
1. Install Arduino IDE
Download from arduino.cc (version 2.x recommended)
Search “esp32” and install “ESP32 by Espressif Systems”
3. Select Your Board
Tools → Board → Select your board model
Tools → Port → Select the USB port (COM3, /dev/ttyUSB0, etc.)
2.3 Program Structure: Setup and Loop
Every Arduino/ESP32 program has two essential functions:
void setup(){// Runs ONCE when the board powers on or resets// Use for: Pin configuration, serial begin, sensor initialization}void loop(){// Runs FOREVER in a continuous loop// Use for: Reading sensors, controlling outputs, main logic}
2.3.1 Understanding the Execution Model
Behind the scenes, the Arduino core (board support package) provides a small runtime that:
Initializes hardware (clocks, pins, peripherals)
Calls setup() once
Repeatedly runs loop() (or runs it as a background task on RTOS-based boards)
The exact CPU architecture depends on the board (AVR, ARM Cortex-M, Xtensa, RISC-V), but the mental model stays the same: your application code runs, and hardware events (GPIO, timers, UART) can interrupt it.
Reality Check: Arduino is an API, not a single chip family
Arduino Uno/Nano: 8-bit AVR (ATmega328P)
Many modern Arduino boards: ARM Cortex-M (SAMD, STM32, nRF52, etc.)
ESP32 family: Xtensa or RISC-V cores (Arduino runs on top of ESP-IDF/FreeRTOS)
Figure 2.3: Arduino Program Execution Model: Setup and Infinite Loop
Most microcontrollers share the same building blocks:
Program memory (Flash/ROM) stores your code and constants
RAM (SRAM) stores the stack, heap, and variables
Peripherals (GPIO, timers, ADC, UART, I2C, SPI) expose hardware features through registers
Figure 2.4: Conceptual microcontroller building blocks: CPU core connected to program memory, RAM, and on-chip peripherals (addresses and sizes vary by chip).
Two execution contexts matter for IoT code:
Main context: your normal code (setup(), loop(), background work)
Interrupt context: short handlers triggered by hardware events
Microcontrollers implement interrupts via an interrupt vector table (or an equivalent dispatch table) that maps interrupt sources to handler function addresses. When you call attachInterrupt() in Arduino code, you’re asking the board support package to wire a pin event to your ISR.
Optional: ARM Cortex-M terms you may see in datasheets
On ARM Cortex-M MCUs, the main program runs in Thread mode and ISRs run in Handler mode. On interrupt entry, the CPU pushes a small stack frame (registers + return address) and restores it on exit. Other architectures use different names, but the same idea applies: interrupt code runs in a separate, time-critical context.
Why This Matters for IoT Programming
Understanding these architectural details helps you:
Debug interrupt issues - Know why ISRs need special attributes (IRAM_ATTR, volatile)
Optimize performance - Understand stack usage and function call overhead
Write safer code - Recognize race conditions between main code and ISRs
Manage memory - Know where variables live (stack vs heap vs registers)
2.3.3 Your First Program: Blink
// The "Hello World" of hardware programming// Blinks the built-in LED on and offvoid setup(){// Configure the built-in LED pin as an output pinMode(LED_BUILTIN, OUTPUT);}void loop(){ digitalWrite(LED_BUILTIN, HIGH);// Turn LED on delay(1000);// Wait 1 second (1000 ms) digitalWrite(LED_BUILTIN, LOW);// Turn LED off delay(1000);// Wait 1 second}
Understanding the Code
pinMode(pin, mode) - Configures a pin as INPUT, OUTPUT, or INPUT_PULLUP
digitalWrite(pin, value) - Sets a pin HIGH (3.3V/5V) or LOW (0V)
delay(ms) - Pauses execution for milliseconds
LED_BUILTIN is a predefined constant for the onboard LED (pin varies by board; often pin 13 on Arduino Uno, and commonly GPIO2 on many ESP32 dev boards).
2.4 GPIO: Digital Input and Output
2.4.1 What is GPIO?
GPIO (General Purpose Input/Output) pins can be configured as either inputs (reading sensors/buttons) or outputs (controlling LEDs/relays).
2.4.2 Digital Output Example: Controlling an LED
constint LED_PIN =2;// GPIO2 on ESP32void setup(){ pinMode(LED_PIN, OUTPUT);}void loop(){ digitalWrite(LED_PIN, HIGH);// LED on delay(500); digitalWrite(LED_PIN, LOW);// LED off delay(500);}
Figure 2.5: Button Input with Internal Pull-Up Resistor Configuration
INPUT_PULLUP Explained
Without a pull-up (or pull-down) resistor, an unconnected input pin “floats” and reads random values. INPUT_PULLUP enables the internal pull-up resistor, keeping the pin HIGH when the button is not pressed.
With a pull-up: - Button not pressed → Pin reads HIGH (1) - Button pressed → Pin reads LOW (0) - “Active Low” logic
Try It: GPIO Pin State Visualizer
Explore how pull-up and pull-down resistors affect GPIO pin readings. Select a resistor configuration and button state to see the voltage at the pin and the digital value the microcontroller reads.
Key Insight: Higher bit resolution (12-bit vs 10-bit) provides finer voltage steps. ESP8266’s 1.0V reference limits maximum measurable voltage but provides better precision for low-voltage sensors.
2.5.2 Reading a Temperature Sensor (LM35)
constint TEMP_PIN =35;void setup(){ Serial.begin(115200);}void loop(){int rawValue = analogRead(TEMP_PIN);// LM35 outputs 10mV per degree Celsius// With 3.3V reference and 12-bit ADC:float voltage = rawValue *(3.3/4095.0);float temperatureC = voltage *100.0;// 10mV/°C Serial.print("Temperature: "); Serial.print(temperatureC); Serial.println(" °C"); delay(1000);}
Putting Numbers to It
Let’s work through the LM35 temperature sensor ADC calculation in detail:
Temperature resolution: With 12-bit ADC and 3.3V reference: \[\Delta T = 0.806 \text{ mV/bit} \times \frac{1°C}{10 \text{ mV}} = 0.0806°C \text{ per bit} \approx 0.08°C\]
This means the ESP32 can resolve temperature changes as small as 0.08°C when using the LM35 sensor — excellent precision for most IoT applications!
Practical consideration: ESP32 ADC has ~2-3% non-linearity, so actual precision is closer to ±0.3°C without calibration.
Interactive: LM35 Temperature Calculator
Simulate the LM35 sensor and see how ADC values translate to temperature readings:
html`<div style="background: var(--bs-light, #f8f9fa); padding: 1rem; border-radius: 8px; border-left: 4px solid ${lm35_calc.clamped?'#E74C3C':'#16A085'}; margin-top: 0.5rem;"><h4 style="margin-top:0;">LM35 Sensor Reading</h4><table style="width:100%; border-collapse:collapse;"><tr><td style="padding:8px; border:1px solid #ddd;"><strong>Target Temperature</strong></td> <td style="padding:8px; border:1px solid #ddd;">${target_temp.toFixed(1)} °C</td></tr><tr><td style="padding:8px; border:1px solid #ddd;"><strong>LM35 Output Voltage</strong></td> <td style="padding:8px; border:1px solid #ddd;">${(lm35_calc.sensor_voltage*1000).toFixed(1)} mV (${lm35_calc.sensor_voltage.toFixed(3)} V)</td></tr><tr><td style="padding:8px; border:1px solid #ddd;"><strong>ADC Raw Value</strong></td> <td style="padding:8px; border:1px solid #ddd;">${lm35_calc.raw_value}${lm35_calc.clamped?'(CLAMPED!)':''}</td></tr><tr><td style="padding:8px; border:1px solid #ddd;"><strong>Measured Voltage</strong></td> <td style="padding:8px; border:1px solid #ddd;">${lm35_calc.measured_voltage.toFixed(3)} V</td></tr><tr><td style="padding:8px; border:1px solid #ddd;"><strong>Calculated Temperature</strong></td> <td style="padding:8px; border:1px solid #ddd;">${lm35_calc.measured_temp.toFixed(2)} °C</td></tr><tr><td style="padding:8px; border:1px solid #ddd;"><strong>Error</strong></td> <td style="padding:8px; border:1px solid #ddd;">${(lm35_calc.measured_temp- target_temp).toFixed(2)} °C</td></tr><tr><td style="padding:8px; border:1px solid #ddd;"><strong>Temperature Resolution</strong></td> <td style="padding:8px; border:1px solid #ddd;">${lm35_calc.resolution.toFixed(3)} °C per ADC bit</td></tr></table>${lm35_calc.clamped?'<p style="color:#E74C3C; font-weight:bold; margin-top:0.5rem;">⚠️ Warning: Sensor voltage exceeds ADC reference! Reading will be clamped. Use a voltage divider or switch to Arduino Uno (5V reference).</p>':''}</div>`
Key Insights:
ESP32 can only measure up to ~33°C with 3.3V reference (LM35 outputs 330 mV at 33°C)
Arduino Uno with 5V reference can measure up to 50°C directly
For higher temperatures on ESP32, use a voltage divider or level shifter
12-bit ADC provides ~0.08°C resolution vs 10-bit’s ~0.49°C resolution
2.6 Serial Communication
2.6.1 What is Serial Communication?
Serial communication sends data one bit at a time over a wire. It’s essential for: - Debugging - Print values to see what your code is doing - Data logging - Send sensor data to a computer - Configuration - Receive commands from a computer
Figure 2.6: Serial Communication Path: MCU to USB-to-Serial to Computer Monitor
2.6.2 Serial Functions
void setup(){ Serial.begin(115200);// Start serial at 115200 baud Serial.println("Hello, World!");// Print with newline}void loop(){// Print different data types Serial.print("Integer: "); Serial.println(42); Serial.print("Float: "); Serial.println(3.14159,2);// 2 decimal places// Read incoming dataif(Serial.available()>0){char received = Serial.read(); Serial.print("Received: "); Serial.println(received);} delay(1000);}
2.6.3 Common Serial Functions
Function
Description
Example
Serial.begin(baud)
Initialize serial
Serial.begin(115200)
Serial.print(data)
Print without newline
Serial.print("Hi")
Serial.println(data)
Print with newline
Serial.println(42)
Serial.available()
Check if data waiting
if(Serial.available())
Serial.read()
Read one byte
char c = Serial.read()
Serial.readString()
Read until timeout
String s = Serial.readString()
Serial.parseInt()
Parse integer
int n = Serial.parseInt()
Try It: Serial Baud Rate and Data Transfer Calculator
Explore how baud rate affects data transfer speed. Enter a message and see how long it takes to send at different baud rates, and understand why 115200 is the most common choice for debugging.
html`<div style="background: var(--bs-light, #f8f9fa); padding: 1rem; border-radius: 8px; border-left: 4px solid #3498DB; margin-top: 0.5rem;"><h4 style="margin-top:0;">Serial Transfer Analysis</h4><table style="width:100%; border-collapse:collapse;"><tr style="background:#e8f4f8;"><td colspan="2" style="padding:6px; border:1px solid #ddd;"><strong>Frame Format</strong></td></tr><tr><td style="padding:6px; border:1px solid #ddd;">Bits per character</td> <td style="padding:6px; border:1px solid #ddd;">1 start + ${serial_data_bits} data + ${serial_calc.parityBit} parity + ${serial_stop_bits} stop = <strong>${serial_calc.bitsPerChar} bits</strong></td></tr><tr><td style="padding:6px; border:1px solid #ddd;">Protocol overhead</td> <td style="padding:6px; border:1px solid #ddd;">${serial_calc.overhead.toFixed(1)}% (${serial_calc.bitsPerChar- serial_data_bits} non-data bits per character)</td></tr><tr style="background:#e8f4f8;"><td colspan="2" style="padding:6px; border:1px solid #ddd;"><strong>Transfer Calculation</strong></td></tr><tr><td style="padding:6px; border:1px solid #ddd;">Message</td> <td style="padding:6px; border:1px solid #ddd;">"${serial_message}" (${serial_calc.msgLen} characters)</td></tr><tr><td style="padding:6px; border:1px solid #ddd;">Total bits to send</td> <td style="padding:6px; border:1px solid #ddd;">${serial_calc.totalBits} bits</td></tr><tr><td style="padding:6px; border:1px solid #ddd;"><strong>Transfer time</strong></td> <td style="padding:6px; border:1px solid #ddd; font-weight:bold; color:#16A085;">${serial_calc.transferTime_ms<1? serial_calc.transferTime_us.toFixed(1) +' us': serial_calc.transferTime_ms<1000? serial_calc.transferTime_ms.toFixed(2) +' ms': serial_calc.transferTime_s.toFixed(3) +' s'}</td></tr><tr style="background:#e8f4f8;"><td colspan="2" style="padding:6px; border:1px solid #ddd;"><strong>Throughput</strong></td></tr><tr><td style="padding:6px; border:1px solid #ddd;">Max characters/second</td> <td style="padding:6px; border:1px solid #ddd;">${Math.floor(serial_calc.charsPerSecond).toLocaleString()} chars/s</td></tr><tr><td style="padding:6px; border:1px solid #ddd;">Effective data rate</td> <td style="padding:6px; border:1px solid #ddd;">${(serial_calc.effectiveDataRate/1000).toFixed(1)} kbps (${(serial_calc.effectiveDataRate/8/1024).toFixed(2)} KB/s)</td></tr></table><p style="margin:0.5rem 0 0 0; padding:8px; background:#d4edda; border-radius:4px; border:1px solid #c3e6cb; color:#155724;"><strong>Insight:</strong> ${serial_baud >=115200?'At '+ serial_baud.toLocaleString() +' baud, Serial.println() is fast enough for real-time debugging. A typical sensor reading prints in under 1 ms.': serial_baud >=9600?'At '+ serial_baud.toLocaleString() +' baud, each character takes ~'+ (1000/serial_calc.charsPerSecond).toFixed(1) +' ms. Fine for occasional prints, but heavy logging will slow your loop.':'At '+ serial_baud.toLocaleString() +' baud, serial output is very slow. This rate is only used for legacy devices or long-distance RS-232 connections.'}</p></div>`
2.7 Timing Without Blocking
2.7.1 The Problem with delay()
delay()blocks the entire program. Nothing else can happen during the delay.
// BAD: Blocking code - can't do anything else during delaysvoid loop(){ digitalWrite(LED1, HIGH); delay(1000);// Stuck here for 1 second! digitalWrite(LED1, LOW); delay(1000);// Stuck here again!// Can't read sensors, respond to buttons, etc.}
2.7.2 The Solution: millis() and Non-Blocking Code
// GOOD: Non-blocking code using millis()unsignedlong previousMillis =0;constlong interval =1000;// 1 secondbool ledState =false;void loop(){unsignedlong currentMillis = millis();// Check if it's time to toggle the LEDif(currentMillis - previousMillis >= interval){ previousMillis = currentMillis;// Toggle LED ledState =!ledState; digitalWrite(LED_PIN, ledState);}// Other code can run here without being blocked!int sensorValue = analogRead(SENSOR_PIN);// Process sensor data...}
Figure 2.7: Blocking delay() vs Non-Blocking millis() Timing Comparison
2.7.3 Multiple Timed Tasks
// Blink LED every 500ms AND read sensor every 100msunsignedlong ledPreviousMillis =0;unsignedlong sensorPreviousMillis =0;constlong ledInterval =500;constlong sensorInterval =100;void loop(){unsignedlong currentMillis = millis();// LED taskif(currentMillis - ledPreviousMillis >= ledInterval){ ledPreviousMillis = currentMillis; digitalWrite(LED_PIN,!digitalRead(LED_PIN));}// Sensor taskif(currentMillis - sensorPreviousMillis >= sensorInterval){ sensorPreviousMillis = currentMillis;int value = analogRead(SENSOR_PIN); Serial.println(value);}}
Try It: Blocking vs Non-Blocking Task Scheduler
Simulate running multiple timed tasks and see why blocking delay() fails when you need responsive multitasking. Adjust the task intervals and observe how tasks overlap or get delayed.
An interrupt immediately pauses the main program when an event occurs (like a button press), runs a special function (ISR - Interrupt Service Routine), then resumes the main program.
Figure 2.8: Hardware Interrupt Service Routine: Event-Driven Program Execution
2.8.2 Interrupt Example: Button Counter
constint BUTTON_PIN =4;volatileint buttonCount =0;// 'volatile' for variables used in ISRvoid IRAM_ATTR buttonISR(){// IRAM_ATTR required on ESP32 buttonCount++;}void setup(){ Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLUP);// Attach interrupt: pin, function, trigger mode attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);}void loop(){ Serial.print("Button pressed "); Serial.print(buttonCount); Serial.println(" times"); delay(1000);}
2.8.3 Interrupt Trigger Modes
Mode
Triggers When
LOW
Pin is LOW
CHANGE
Pin changes state (HIGH→LOW or LOW→HIGH)
RISING
Pin goes LOW→HIGH
FALLING
Pin goes HIGH→LOW
ISR Rules
Keep your ISR fast and simple:
No delay() - Never use delay() in an ISR
No Serial - Serial.print() is slow, avoid in ISR
Use volatile - Variables shared with ISR must be volatile
Keep it short - Do minimal work, set a flag for main loop
IRAM_ATTR - Required on ESP32 to place ISR in fast RAM
2.8.4 Interrupt Timing and Bottom Halves
Understanding interrupt timing is critical for building responsive IoT systems. When an interrupt occurs, the processor must save context, execute the ISR, and restore context—all of which takes time.
The bottom half pattern splits interrupt handling into two parts:
Top Half (ISR) - Runs immediately, does minimal work:
Read critical hardware registers
Set a flag or increment a counter
Clear interrupt status bits
Takes microseconds
Bottom Half (Main Loop) - Processes the event later:
Heavy computation
Serial printing
Network communication
Can take milliseconds
// Example: Bottom half pattern for sensor readingvolatilebool sensorReady =false;volatileuint16_t rawValue =0;// TOP HALF: Fast ISRvoid IRAM_ATTR sensorISR(){ rawValue = readSensorRegister();// Quick hardware read sensorReady =true;// Set flag}// BOTTOM HALF: Main loop processingvoid loop(){if(sensorReady){ sensorReady =false;// Heavy processing here (safe in main loop)float voltage = rawValue *3.3/4095.0;float temperature = calculateTemp(voltage); sendToCloud(temperature); Serial.println(temperature);}}
2.8.5 Race Conditions and Shared Data
When both your main program and ISRs access the same variables, race conditions can occur. This is one of the most common bugs in embedded systems.
Common Race Condition Scenarios:
Multi-byte variables - Reading a 32-bit counter while an ISR updates it:
// DANGEROUS CODE - Race condition!volatileuint32_t eventCount =0;void IRAM_ATTR counterISR(){ eventCount++;// Might interrupt main program}void loop(){// Reading eventCount might get corrupted value if ISR fires mid-read!uint32_t current = eventCount;}
Unprotected shared state - ISR and main loop both modify the same data structure:
// DANGEROUS CODE - Race condition!volatileint sensorIndex =0;int sensorData[10];void IRAM_ATTR sensorISR(){ sensorData[sensorIndex++]= readSensor();// Can corrupt index}void loop(){if(sensorIndex >=10){// Race condition! processData(); sensorIndex =0;// Can conflict with ISR}}
Solutions to Race Conditions:
Use atomic operations for simple variables:
volatilebool flag =false;// Single-byte, atomic on most platforms
Disable interrupts for critical sections:
void loop(){ noInterrupts();// Disable all interruptsuint32_t safeCopy = eventCount;// Safe read interrupts();// Re-enable interrupts// Use safeCopy here}
Use flags instead of shared data:
// SAFE: ISR only sets flag, main loop processes datavolatilebool newData =false;int latestValue =0;// NOT volatile - only main loop accessesvoid IRAM_ATTR sensorISR(){ tempBuffer = readSensor();// Temporary variable newData =true;// Atomic flag set}void loop(){if(newData){ noInterrupts(); latestValue = tempBuffer; newData =false; interrupts(); processValue(latestValue);// Safe processing}}
Debugging Race Conditions
Race conditions are hard to debug because:
They’re intermittent - may not happen every time
They’re timing-dependent - adding debug code changes timing and hides the bug
They cause corrupted data - symptoms appear far from the root cause
Prevention is better than debugging:
Keep ISRs simple and fast
Use the bottom-half pattern
Minimize shared variables
Document all shared state with volatile
Use critical sections for multi-step operations
2.9 Common Sensor Patterns
2.9.1 Pattern 1: Polling (Simple but Blocking)
// Read sensor in main loopvoid loop(){int value = analogRead(SENSOR_PIN);if(value > THRESHOLD){// Take action} delay(100);// Read every 100ms}
2.9.2 Pattern 2: Non-Blocking with millis()
// Read sensor without blockingunsignedlong lastRead =0;void loop(){if(millis()- lastRead >=100){ lastRead = millis();int value = analogRead(SENSOR_PIN); processValue(value);}// Other tasks can run here}
2.9.3 Pattern 3: Interrupt-Driven
// React immediately to digital eventsvolatilebool motionDetected =false;void IRAM_ATTR motionISR(){ motionDetected =true;}void loop(){if(motionDetected){ motionDetected =false;// Handle motion detection sendAlert();}// Other tasks}
Compare the four sensor reading patterns side-by-side. Adjust parameters to see how each pattern handles response time, CPU usage, and complexity trade-offs for your IoT application.
pattern_analysis = {const interval = pattern_sensor_rate;const freq = pattern_event_freq;const powerSensitive = pattern_power_critical.includes("Power-Sensitive Device");const responseReq = pattern_response_req;const patterns = [ {name:"Polling with delay()",cpuUsage:100,responseTime: interval,complexity:1,blocking:true,color:"#E74C3C",pros:"Simplest to write and understand",cons:"Blocks CPU, misses events during delay, wastes power" }, {name:"Non-Blocking millis()",cpuUsage:Math.min(95,5+ (1000/ interval) *2),responseTime:Math.min(interval,5),complexity:2,blocking:false,color:"#16A085",pros:"Responsive, supports multiple tasks, no blocking",cons:"Slightly more complex, still polls continuously" }, {name:"Interrupt-Driven",cpuUsage: powerSensitive ?1:5,responseTime:0.01,complexity:3,blocking:false,color:"#3498DB",pros:"Instant response, lowest power (can sleep between events)",cons:"Complex ISR rules, volatile variables, race conditions" }, {name:"State Machine",cpuUsage:Math.min(90,10+ (1000/ interval) *3),responseTime:Math.min(interval,10),complexity:4,blocking:false,color:"#9B59B6",pros:"Best for complex sequences, clear state transitions",cons:"Most complex to write, overkill for simple tasks" } ];// Score each pattern for the scenario patterns.forEach(p => {let score =50;if (responseReq ==="Critical (<10ms)") { score += p.responseTime<1?30: p.responseTime<10?15:-20; } elseif (responseReq ==="Moderate (10-100ms)") { score += p.responseTime<100?15:-10; }if (powerSensitive) { score += p.cpuUsage<10?25: p.cpuUsage<50?5:-15; }if (freq ==="Rare (<1/min)") { score += p.name.includes("Interrupt") ?20:0; }if (freq ==="Frequent (>10/min)") { score += p.name.includes("millis") ?15: p.name.includes("State") ?10:0; } score -= p.complexity*3; p.score=Math.max(0,Math.min(100, score)); }); patterns.sort((a, b) => b.score- a.score);return patterns;}
Show code
html`<div style="background: var(--bs-light, #f8f9fa); padding: 1rem; border-radius: 8px; border-left: 4px solid #2C3E50; margin-top: 0.5rem;"><h4 style="margin-top:0;">Pattern Comparison for Your Scenario</h4><table style="width:100%; border-collapse:collapse;"><tr> <th style="padding:6px; border:1px solid #ddd;">Pattern</th> <th style="padding:6px; border:1px solid #ddd;">Response</th> <th style="padding:6px; border:1px solid #ddd;">CPU Use</th> <th style="padding:6px; border:1px solid #ddd;">Complexity</th> <th style="padding:6px; border:1px solid #ddd;">Fit Score</th></tr>${pattern_analysis.map((p, i) =>`<tr style="background:${i ===0?'#d4edda':'transparent'};"> <td style="padding:6px; border:1px solid #ddd;"><span style="color:${p.color}; font-weight:bold;">${p.name}</span></td> <td style="padding:6px; border:1px solid #ddd;">${p.responseTime<1?'<1 ms': p.responseTime<1000?Math.round(p.responseTime) +' ms': (p.responseTime/1000).toFixed(1) +' s'}</td> <td style="padding:6px; border:1px solid #ddd;">${Math.round(p.cpuUsage)}%</td> <td style="padding:6px; border:1px solid #ddd;">${'*'.repeat(p.complexity)}${' '.repeat(4- p.complexity)}</td> <td style="padding:6px; border:1px solid #ddd;"> <div style="background:#e9ecef; border-radius:4px; height:18px; width:100px; display:inline-block; vertical-align:middle;"> <div style="background:${p.color}; border-radius:4px; height:18px; width:${p.score}px;"></div> </div> ${p.score}/100 </td></tr>`).join('')}</table><div style="margin-top:0.75rem; padding:8px; background:#d4edda; border-radius:4px; border:1px solid #c3e6cb; color:#155724;"><strong>Recommended: ${pattern_analysis[0].name}</strong><br/>${pattern_analysis[0].pros}. ${pattern_analysis[0].responseTime<1?'Response time under 1 ms makes it ideal for time-critical applications.':'Good balance of simplicity and performance for this use case.'}</div><div style="margin-top:0.5rem; font-size:0.9em; color:#7F8C8D;"><strong>Tips:</strong> Enable "Power-Sensitive" to see how interrupt-driven patterns dominate for battery-powered devices. Set response time to "Critical" to see why polling with delay() fails for real-time applications.</div></div>`
2.10 Knowledge Check
Test your understanding of core microcontroller programming concepts.
Quiz: Timing and Interrupts
Scenario: You’re building a greenhouse monitoring system with: - Temperature sensor (analog, read every 5 seconds) - Soil moisture sensor (analog, read every 30 seconds) - Alert button (digital, needs instant response) - Status LED (blink every 1 second)
Pitfall: Stack Overflow in Interrupt Service Routines
The Mistake: Declaring large local arrays or buffers inside ISRs, such as char buffer[256] for formatting debug messages or storing sensor data within the interrupt handler.
Why It Happens: Developers treat ISRs like regular functions, forgetting that ISRs use a separate, much smaller stack. On ESP32, the ISR stack is typically 2-4 KB (configurable via CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE), while Arduino AVR boards have only 256-512 bytes for the entire stack. A single char[256] array consumes half the available ISR stack on AVR.
The Fix: Keep ISR local variables under 32 bytes total. Use global or static buffers declared with volatile outside the ISR, and have the ISR only set flags or copy small values (4-8 bytes). For ESP32, use IRAM_ATTR and limit stack usage to under 512 bytes:
// WRONG: Large buffer in ISRvoid IRAM_ATTR badISR(){char msg[256];// Stack overflow risk! sprintf(msg,"Value: %d", sensorValue);}// CORRECT: Minimal ISR, process in main loopvolatileuint16_t isrValue =0;volatilebool dataReady =false;void IRAM_ATTR goodISR(){ isrValue = readRegister();// 2 bytes only dataReady =true;// Flag for main loop}
Pitfall: Heap Fragmentation from Repeated malloc/free
The Mistake: Using dynamic memory allocation (malloc, new, String concatenation) in the main loop of embedded systems, leading to gradual memory fragmentation until the system crashes after hours or days of operation.
Why It Happens: On desktop systems, modern allocators and virtual memory hide fragmentation issues. On microcontrollers with 4-256 KB RAM and no virtual memory, fragmentation is permanent until reset. Each String concatenation like String s = "Temp: " + String(temp) + "C" allocates 3+ heap blocks that fragment differently on each iteration.
The Fix: Use static buffers with fixed sizes allocated at compile time. For string formatting, use snprintf() with stack-allocated char[] arrays. Reserve String objects once at startup if absolutely needed:
For ESP32, monitor heap with ESP.getFreeHeap() and ESP.getMaxAllocHeap() - if max allocatable block shrinks over time while free heap stays constant, you have fragmentation.
Common Pitfalls
1. Blocking the Loop with delay()
Mistake: Using delay(1000) to wait between sensor readings, which freezes the entire program and ignores all inputs during that time
Why it happens: delay() is the first timing function beginners learn, and it works for simple blink sketches. The consequences only become apparent when adding buttons, displays, or multiple sensors
Solution: Use non-blocking timing with millis(). Store the last action time and check if enough time has passed: if (millis() - lastRead >= interval) { lastRead = millis(); readSensor(); }. This allows the loop to remain responsive to other inputs
2. Not Using volatile for ISR-Shared Variables
Mistake: Declaring a variable like int buttonPressed = 0; that’s modified in an interrupt but read in loop(), then wondering why changes aren’t detected
Why it happens: The compiler optimizes by caching variable values in registers. Without volatile, it may never re-read the actual memory location where the ISR updated the value
Solution: Always declare ISR-shared variables as volatile: volatile int buttonPressed = 0;. For multi-byte variables on 8-bit MCUs, also disable interrupts briefly when reading to prevent torn reads
3. Ignoring Pull-Up Resistors on Inputs
Mistake: Connecting a button between a GPIO pin and ground without enabling internal pull-ups, causing the pin to “float” when the button isn’t pressed and read random high/low values
Why it happens: Beginners assume unconnected pins read LOW (0V), but floating inputs pick up electrical noise and oscillate unpredictably between HIGH and LOW
Solution: Enable internal pull-ups with pinMode(pin, INPUT_PULLUP) for active-low buttons (pressed = LOW), or add external 10k resistors. Always ensure inputs have a defined voltage when not actively driven
Worked Example: Smart Doorbell with Motion Detection
Scenario: Build a battery-powered doorbell that: (1) sends alert when button pressed, (2) logs motion events, (3) sleeps between events to save power.
Minimize sleep current - The biggest win (10 µA vs 10 mA = 1000× difference)
Reduce active time - Shorter wake periods save energy
Fewer wake events - Less frequent measurements extend battery life
Optimize active current - Disable unused peripherals (WiFi, LEDs, sensors)
Decision Framework: When to Use Interrupts vs Polling
Scenario
Use Interrupts
Use Polling
Rationale
Button press detection
✓ Yes
Maybe
Interrupts guarantee instant response; polling might miss fast presses
Sensor read every 10 seconds
No
✓ Yes
Polling with millis() is simpler; no need for interrupt overhead
Emergency stop signal
✓ Yes
No
Safety-critical: must respond within microseconds
UART data arrival
✓ Yes
Maybe
Interrupt prevents data loss; polling works if checking frequently
I2C sensor read
No
✓ Yes
I2C libraries handle communication; no interrupt needed
Multiple fast events (>100 Hz)
✓ Yes (with queue)
No
Polling can’t keep up; interrupts with FIFO queue required
Low-power sleep/wake
✓ Yes
No
Only interrupts can wake from deep sleep
Simple blink LED
No
✓ Yes
Polling with millis() is cleaner for non-critical timing
Decision Tree:
Q1: Is the event unpredictable and rare (<1% of time)?
├─ YES: Is response time critical (<10 ms)?
│ ├─ YES: Use interrupts
│ └─ NO: Consider polling (simpler)
└─ NO: Event is frequent or periodic
└─ Use polling with millis()
Q2: Does the device need to sleep for power savings?
└─ YES: Use interrupt for wake-up event
Interrupt Use Cases:
Use Case
Why Interrupt is Better
Doorbell button
Instant response, no polling waste
Emergency stop button
Safety-critical, <1 ms response
Rotary encoder
Fast pulses (100-1000 Hz) would be missed by polling
UART RX
Data arrives asynchronously, must capture immediately
External RTC alarm
Wakes MCU from deep sleep
Zero-crossing detector (AC)
Precise timing for TRIAC control
Polling Use Cases:
Use Case
Why Polling is Better
Temperature read every 60s
Scheduled task, no urgency
Display update
Main loop handles refresh timing
State machine transitions
Logic-driven, not event-driven
Analog sensor averaging
Need multiple samples over time
Network communication
Libraries handle protocol, polling checks status
Hybrid Approach:
Best practice: Use interrupts for urgent events, polling for periodic tasks:
volatilebool urgentFlag =false;void IRAM_ATTR urgentISR(){ urgentFlag =true;// Set flag only}void loop(){// Handle urgent interruptif(urgentFlag){ urgentFlag =false; handleUrgentEvent();// Heavy processing in main loop}// Poll periodic tasksstaticunsignedlong lastRead =0;if(millis()- lastRead >=1000){ lastRead = millis(); readSensor();}}
Key Insight: Interrupts are for reacting, polling is for scheduling.
Common Mistake: Doing Too Much Work in ISRs
The Mistake: A developer writes an ISR that reads a sensor, processes data, formats a string, and prints to serial. The device becomes unresponsive and crashes randomly.
Bad Code:
void IRAM_ATTR buttonISR(){// WAY TOO MUCH WORK IN ISR!float temp = dht.readTemperature();// 2 seconds blocking!char message[100]; sprintf(message,"Button pressed! Temp: %.1f C", temp); Serial.println(message);// Slow serial I/O// Even worse: network call sendMQTTMessage("button/pressed", message);// 100+ ms!}
Why It’s Broken:
ISRs block ALL other interrupts (including watchdog, WiFi stack)
Serial printing is slow (~10 ms for 100 characters at 115200 baud)
MQTT send blocks for 100-500 ms (network latency)
Total ISR time: ~2.6 seconds while system is unresponsive!
Consequences:
Watchdog timer resets device (ISR too long)
Wi-Fi stack crashes (missed interrupts)
Other buttons don’t work (interrupts blocked)
Random crashes (stack corruption)
The Fix: Top-Half/Bottom-Half Pattern
Good Code:
// TOP HALF: ISR only sets flag (microseconds)volatilebool buttonPressed =false;void IRAM_ATTR buttonISR(){ buttonPressed =true;// 1 microsecond}// BOTTOM HALF: Main loop does heavy workvoid loop(){if(buttonPressed){ buttonPressed =false;// Safe to do slow operations in main loopfloat temp = dht.readTemperature();char message[100]; sprintf(message,"Button pressed! Temp: %.1f C", temp); Serial.println(message); sendMQTTMessage("button/pressed", message);} delay(10);}
ISR Golden Rules:
✅ ALLOWED in ISR: - Set/clear flags (volatile bool) - Increment counters (volatile uint32_t count++) - Read hardware registers (GPIO.in) - Store timestamp (volatile unsigned long time = millis())
❌ FORBIDDEN in ISR: - delay() or any blocking call - Serial.print() (too slow) - Sensor reads (DHT, I2C, SPI—all slow) - Network operations (WiFi, MQTT, HTTP) - malloc() / new (heap allocation) - Floating-point math (slow on some MCUs) - Long loops (for(int i=0; i<1000; i++))
Quantified Impact:
Operation
Time in ISR
Consequence
Set flag
1 μs
✓ Safe
Read GPIO
1 μs
✓ Safe
Serial.print() 10 chars
870 μs
⚠ Risky (short messages OK)
DHT22 read
2,000,000 μs (2 s!)
✗ Crash
MQTT publish
100,000 μs (100 ms)
✗ Crash
ESP32 Specific:
ESP32 has dual-core architecture. On core 0, WiFi stack runs constantly. Long ISRs on core 1 can starve WiFi, causing disconnections:
// Mark ISR for fast RAM executionvoid IRAM_ATTR fastISR(){// Only code in IRAM, no flash access GPIO.out_w1ts =(1<< LED_PIN);// Set LED}
Real-World Example:
Smart doorbell with 2-second ISR (reading temperature) caused: - 15% of button presses ignored (watchdog reset during ISR) - WiFi disconnection every 30 minutes (stack timeout) - 3-star Amazon reviews: “Doorbell misses button presses”
After fix (flag-only ISR): 100% button press detection, zero WiFi issues, 5-star reviews.
Key Takeaway: ISRs should take <100 μs. Anything longer moves to main loop!
Match the Microcontroller Concept
Order the Microcontroller Program Lifecycle
Label the Diagram
2.11 Summary
Concept
Key Points
Program Structure
setup() runs once, loop() runs forever
GPIO
Configure with pinMode(), read/write with digitalRead()/digitalWrite()
Analog Input
Use analogRead(), values depend on ADC resolution
Serial
Essential for debugging; use Serial.begin(), print(), println()
Timing
Avoid delay(); use millis() for non-blocking code
Interrupts
Use for time-critical events; keep ISR short
2.12 Visual Reference Gallery
The following AI-generated visualizations provide alternative perspectives on microcontroller programming concepts.
Arduino Pin Configuration
Arduino Pins
Understanding Arduino pin capabilities ensures correct hardware connections and prevents conflicts between peripherals.
Interrupt Vector Table
Interrupt Vector Table
The interrupt vector table maps interrupt sources to their handlers, enabling rapid response to hardware events without polling.
Control Registers Architecture
Control Registers
Direct register access provides maximum control over microcontroller peripherals, enabling optimization beyond what library functions allow.
2.13 Concept Relationships
Microcontroller Programming
├── Builds on: [Electronics Basics](../electronics/electricity-introduction.html) - Voltage, current, GPIO fundamentals
├── Uses: [Analog-Digital Electronics](../electronics/analog-digital-electronics.html) - ADC for analog sensor reading
├── Applies: [Sensor Fundamentals](../sensors/sensor-fundamentals-and-types.html) - Physical sensors being programmed
├── Enables: [Prototyping Hardware](prototyping-hardware.html) - Physical implementation of concepts
└── Optimizes with: [Energy-Aware Considerations](../energy-power/energy-aware-considerations.html) - Power management patterns
Key Programming Patterns:
setup()/loop() structure is the foundation for all embedded patterns
ARM Cortex-M Programming Guide - https://developer.arm.com/documentation/
In 60 Seconds
Microcontroller programming for IoT balances limited memory and CPU with reliable sensor polling, interrupt handling, and wireless communication—skills best learned through iterative hardware prototyping with real failure scenarios.