2  Microcontroller Programming

2.1 Learning Objectives

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

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

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?

  1. Direct hardware access - You control individual pins on the chip
  2. Real-time constraints - Events happen in microseconds, not milliseconds
  3. Limited resources - Often only 4-32 KB of RAM (your phone has ~8 GB!)
  4. 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.

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
Arduino ESP32 development ecosystem diagram showing four connected components: Arduino IDE with syntax highlighting and serial monitor, Library ecosystem with sensor drivers networking and utilities, Hardware platforms including Arduino Uno Nano ESP32 and ESP8266, and Community support with forums documentation and examples
Figure 2.1: Arduino/ESP32 Development Ecosystem: IDE, Libraries, Hardware, and Community
Eight-week Arduino ESP32 learning journey timeline showing Week 1-2 Foundations with LED blink and button input, Week 3-4 Sensors with analog read and serial debug, Week 5-6 Timing with non-blocking code and interrupts, Week 7-8 IoT Ready with Wi-Fi connection and cloud data integration
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)

2. Add ESP32 Board Support (for ESP32 boards)

  1. Go to: File → Preferences

  2. Add to “Additional Boards Manager URLs”:

    https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  3. Go to: Tools → Board → Boards Manager

  4. 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:

  1. Initializes hardware (clocks, pins, peripherals)
  2. Calls setup() once
  3. 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.

  • 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)
Arduino program execution flow diagram showing power on reset leading to setup function running once for pin configuration serial begin and sensor initialization, then entering infinite loop function repeatedly reading sensors controlling outputs and executing main logic
Figure 2.3: Arduino Program Execution Model: Setup and Infinite Loop

2.3.2 Microcontroller Architecture Essentials (Conceptual)

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
Microcontroller architecture block diagram showing CPU core connected via system bus to Flash program memory, SRAM data memory, and peripheral blocks including GPIO timers ADC UART I2C SPI with memory addresses and typical sizes
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.

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:

  1. Debug interrupt issues - Know why ISRs need special attributes (IRAM_ATTR, volatile)
  2. Optimize performance - Understand stack usage and function call overhead
  3. Write safer code - Recognize race conditions between main code and ISRs
  4. Manage memory - Know where variables live (stack vs heap vs registers)

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

const int LED_PIN = 2;  // GPIO2 on ESP32

void setup() {
    pinMode(LED_PIN, OUTPUT);
}

void loop() {
    digitalWrite(LED_PIN, HIGH);  // LED on
    delay(500);
    digitalWrite(LED_PIN, LOW);   // LED off
    delay(500);
}

2.4.3 Digital Input Example: Reading a Button

const int BUTTON_PIN = 4;  // GPIO4
const int LED_PIN = 2;     // GPIO2

void setup() {
    pinMode(BUTTON_PIN, INPUT_PULLUP);  // Internal pull-up resistor
    pinMode(LED_PIN, OUTPUT);
}

void loop() {
    int buttonState = digitalRead(BUTTON_PIN);

    if (buttonState == LOW) {  // Button pressed (active LOW with pull-up)
        digitalWrite(LED_PIN, HIGH);
    } else {
        digitalWrite(LED_PIN, LOW);
    }
}
Button input circuit diagram with internal pull-up resistor showing GPIO pin connected to VCC through 10k ohm internal pull-up resistor, button switch between GPIO and ground, when button not pressed pin reads HIGH 3.3V or 5V, when button pressed pin reads LOW 0V active low logic
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.


2.5 Analog Input: Reading Sensors

Many sensors output analog voltages. The ADC (Analog-to-Digital Converter) converts these to digital values.

Board ADC Resolution Voltage Range Value Range
Arduino Uno 10-bit 0-5V 0-1023
ESP32 12-bit 0-3.3V 0-4095

2.5.1 Reading a Potentiometer

const int POT_PIN = 34;  // GPIO34 (ADC1 on ESP32)

void setup() {
    Serial.begin(115200);
    // No pinMode needed for analog input
}

void loop() {
    int rawValue = analogRead(POT_PIN);  // 0-4095 on ESP32

    // Convert to voltage (ESP32: 12-bit ADC, 0-4095 maps to 0-3.3V)
    float voltage = rawValue * (3.3 / 4095.0);

    // Convert to percentage
    int percentage = map(rawValue, 0, 4095, 0, 100);

    Serial.print("Raw: ");
    Serial.print(rawValue);
    Serial.print(" | Voltage: ");
    Serial.print(voltage);
    Serial.print("V | Percent: ");
    Serial.print(percentage);
    Serial.println("%");

    delay(100);
}

Try different ADC platforms and see how raw values convert to voltage and percentage:

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)

const int 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);
}

Let’s work through the LM35 temperature sensor ADC calculation in detail:

LM35 sensor characteristics:

  • Output: \(10 \text{ mV/°C}\) (linear scale factor)
  • At 25°C: sensor outputs \(25°C \times 10 \text{ mV/°C} = 250 \text{ mV} = 0.25 \text{ V}\)

ESP32 ADC specifications:

  • Resolution: 12 bits → \(2^{12} = 4096\) discrete values (0 to 4095)
  • Reference voltage: \(V_{\text{ref}} = 3.3 \text{ V}\)
  • Step size (voltage per bit): \(\frac{V_{\text{ref}}}{2^{12}} = \frac{3.3}{4096} = 0.000806 \text{ V/bit} = 0.806 \text{ mV/bit}\)

Converting ADC value to voltage: \[V_{\text{measured}} = \text{rawValue} \times \frac{V_{\text{ref}}}{4095}\]

For 25°C (sensor outputs 250 mV): \[\text{rawValue} = \frac{0.25 \text{ V}}{0.000806 \text{ V/bit}} = 310 \text{ (ADC count)}\]

Converting voltage to temperature: \[T = \frac{V_{\text{measured}}}{10 \text{ mV/°C}} \times 1000 = V_{\text{measured}} \times 100\]

Example with rawValue = 310:

  • Voltage: \(310 \times \frac{3.3}{4095} = 0.2497 \text{ V}\)
  • Temperature: \(0.2497 \times 100 = 24.97°C \approx 25°C\)

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.

Simulate the LM35 sensor and see how ADC values translate to temperature readings:

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

Serial communication data path diagram showing microcontroller UART TX RX pins connected to USB to serial converter chip translating 3.3V or 5V TTL serial to USB protocol, then USB cable to computer running serial monitor software displaying transmitted data at configured baud rate typically 115200
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 data
    if (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.


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 delays
void 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()
unsigned long previousMillis = 0;
const long interval = 1000;  // 1 second
bool ledState = false;

void loop() {
    unsigned long currentMillis = millis();

    // Check if it's time to toggle the LED
    if (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...
}
Timing comparison diagram showing blocking delay approach with CPU frozen during delay periods unable to process other tasks versus non-blocking millis approach with CPU free to run other code while waiting, timestamps checked each loop iteration to determine when interval elapsed for task execution
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 100ms
unsigned long ledPreviousMillis = 0;
unsigned long sensorPreviousMillis = 0;
const long ledInterval = 500;
const long sensorInterval = 100;

void loop() {
    unsigned long currentMillis = millis();

    // LED task
    if (currentMillis - ledPreviousMillis >= ledInterval) {
        ledPreviousMillis = currentMillis;
        digitalWrite(LED_PIN, !digitalRead(LED_PIN));
    }

    // Sensor task
    if (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.


2.8 Interrupts: Responding Instantly

2.8.1 What are Interrupts?

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.

Hardware interrupt execution flow diagram showing main program running in loop, external event like button press triggering interrupt, CPU saving current state and jumping to interrupt service routine ISR, ISR executing minimal fast code setting flags or reading registers, then CPU restoring saved state and resuming main program from interruption point
Figure 2.8: Hardware Interrupt Service Routine: Event-Driven Program Execution

2.8.2 Interrupt Example: Button Counter

const int BUTTON_PIN = 4;
volatile int buttonCount = 0;  // 'volatile' for variables used in ISR

void 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:

  1. No delay() - Never use delay() in an ISR
  2. No Serial - Serial.print() is slow, avoid in ISR
  3. Use volatile - Variables shared with ISR must be volatile
  4. Keep it short - Do minimal work, set a flag for main loop
  5. 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:

  1. 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
  2. Bottom Half (Main Loop) - Processes the event later:
    • Heavy computation
    • Serial printing
    • Network communication
    • Can take milliseconds
// Example: Bottom half pattern for sensor reading
volatile bool sensorReady = false;
volatile uint16_t rawValue = 0;

// TOP HALF: Fast ISR
void IRAM_ATTR sensorISR() {
    rawValue = readSensorRegister();  // Quick hardware read
    sensorReady = true;                // Set flag
}

// BOTTOM HALF: Main loop processing
void 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:

  1. Multi-byte variables - Reading a 32-bit counter while an ISR updates it:

    // DANGEROUS CODE - Race condition!
    volatile uint32_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;
    }
  2. Unprotected shared state - ISR and main loop both modify the same data structure:

    // DANGEROUS CODE - Race condition!
    volatile int 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:

  1. Use atomic operations for simple variables:

    volatile bool flag = false;  // Single-byte, atomic on most platforms
  2. Disable interrupts for critical sections:

    void loop() {
        noInterrupts();  // Disable all interrupts
        uint32_t safeCopy = eventCount;  // Safe read
        interrupts();    // Re-enable interrupts
    
        // Use safeCopy here
    }
  3. Use flags instead of shared data:

    // SAFE: ISR only sets flag, main loop processes data
    volatile bool newData = false;
    int latestValue = 0;  // NOT volatile - only main loop accesses
    
    void 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 loop
void 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 blocking
unsigned long 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 events
volatile bool motionDetected = false;

void IRAM_ATTR motionISR() {
    motionDetected = true;
}

void loop() {
    if (motionDetected) {
        motionDetected = false;
        // Handle motion detection
        sendAlert();
    }
    // Other tasks
}

2.9.4 Pattern 4: State Machine

enum State { IDLE, READING, PROCESSING, SENDING };
State currentState = IDLE;
unsigned long stateTimer = 0;

void loop() {
    switch (currentState) {
        case IDLE:
            if (millis() - stateTimer >= 5000) {
                currentState = READING;
            }
            break;

        case READING:
            sensorValue = readSensor();
            currentState = PROCESSING;
            break;

        case PROCESSING:
            processedValue = processSensor(sensorValue);
            currentState = SENDING;
            break;

        case SENDING:
            sendData(processedValue);
            stateTimer = millis();
            currentState = IDLE;
            break;
    }
}
Try It: Sensor Reading Pattern Comparison

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.


2.10 Knowledge Check

Test your understanding of core microcontroller programming concepts.

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 ISR
void IRAM_ATTR badISR() {
    char msg[256];  // Stack overflow risk!
    sprintf(msg, "Value: %d", sensorValue);
}

// CORRECT: Minimal ISR, process in main loop
volatile uint16_t isrValue = 0;
volatile bool 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:

// WRONG: Heap fragmentation in loop
void loop() {
    String msg = "Sensor: " + String(analogRead(A0));  // Fragments heap!
    Serial.println(msg);
}

// CORRECT: Static buffer, no heap allocation
void loop() {
    static char msg[32];  // Allocated once, reused
    snprintf(msg, sizeof(msg), "Sensor: %d", analogRead(A0));
    Serial.println(msg);
}

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

Scenario: Build a battery-powered doorbell that: (1) sends alert when button pressed, (2) logs motion events, (3) sleeps between events to save power.

Hardware:

  • ESP32 DevKit
  • PIR motion sensor (GPIO4)
  • Button (GPIO5)
  • LED indicator (GPIO2)

Code:

#include <WiFi.h>
#define BUTTON_PIN 5
#define PIR_PIN    4
#define LED_PIN    2

volatile bool buttonPressed = false;
volatile bool motionDetected = false;
unsigned long lastMotion = 0;

// ISRs -- keep SHORT, just set flags
void IRAM_ATTR buttonISR() { buttonPressed = true; }
void IRAM_ATTR motionISR() { motionDetected = true; lastMotion = millis(); }

void setup() {
    Serial.begin(115200);
    pinMode(BUTTON_PIN, INPUT_PULLUP);
    pinMode(PIR_PIN, INPUT);
    pinMode(LED_PIN, OUTPUT);
    attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
    attachInterrupt(digitalPinToInterrupt(PIR_PIN), motionISR, RISING);
    WiFi.begin("YourSSID", "YourPassword");
}

void loop() {
    if (buttonPressed) {
        buttonPressed = false;
        digitalWrite(LED_PIN, HIGH);
        Serial.println("ALERT: Doorbell pressed!");
        digitalWrite(LED_PIN, LOW);
    }
    if (motionDetected) {
        motionDetected = false;
        Serial.printf("Motion at: %lu\n", millis());
        delay(5000);  // Debounce
    }
    // Deep sleep after 30s idle (200mA -> 10mA)
    if (millis() - lastMotion > 30000) {
        esp_sleep_enable_ext0_wakeup(GPIO_NUM_5, 0);
        esp_deep_sleep_start();
    }
    delay(100);
}

Key Techniques Demonstrated:

  1. Interrupts for Responsiveness:
    • Button ISR triggers immediately (no polling delay)
    • Motion ISR captures exact detection time
  2. Non-Blocking Main Loop:
    • loop() checks flags, doesn’t wait for events
    • Other tasks (WiFi, logging) can run concurrently
  3. Power Management:
    • Deep sleep after 30 seconds of inactivity
    • Wake on button press (EXT0 interrupt)
    • Saves battery life: 200 mA active → 10 μA sleep (20,000× reduction!)
  4. Debouncing:
    • 5-second delay after motion prevents spam

Testing Results:

Event Response Time Current Draw
Button press <10 ms (instant) 200 mA (active)
Motion detect <50 ms 200 mA (active)
Idle (awake) - 80 mA (WiFi on)
Deep sleep - 10 μA

Battery Life Calculation:

Assuming 2000 mAh battery, 10 events per day (20 seconds active each):

Active time: 10 events × 20 s = 200 s/day = 0.056 hours
Active energy: 200 mA × 0.056 h = 11.2 mAh/day

Sleep time: 24 h - 0.056 h = 23.944 hours
Sleep energy: 0.01 mA × 23.944 h = 0.24 mAh/day

Total: 11.2 + 0.24 = 11.44 mAh/day
Battery life: 2000 mAh / 11.44 mAh = 175 days (~6 months)

Lessons:

  • Interrupts enable instant response
  • Deep sleep extends battery life 20,000×
  • Non-blocking code allows multiple tasks

Calculate how long your battery-powered IoT device will last based on usage patterns:

Key Factors for Long Battery Life:

  1. Minimize sleep current - The biggest win (10 µA vs 10 mA = 1000× difference)
  2. Reduce active time - Shorter wake periods save energy
  3. Fewer wake events - Less frequent measurements extend battery life
  4. Optimize active current - Disable unused peripherals (WiFi, LEDs, sensors)
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:

volatile bool urgentFlag = false;

void IRAM_ATTR urgentISR() {
    urgentFlag = true;  // Set flag only
}

void loop() {
    // Handle urgent interrupt
    if (urgentFlag) {
        urgentFlag = false;
        handleUrgentEvent();  // Heavy processing in main loop
    }

    // Poll periodic tasks
    static unsigned long 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:

  1. ISRs block ALL other interrupts (including watchdog, WiFi stack)
  2. DHT sensor read takes 2 seconds (device frozen for 2 s!)
  3. Serial printing is slow (~10 ms for 100 characters at 115200 baud)
  4. MQTT send blocks for 100-500 ms (network latency)
  5. 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)
volatile bool buttonPressed = false;

void IRAM_ATTR buttonISR() {
    buttonPressed = true;  // 1 microsecond
}

// BOTTOM HALF: Main loop does heavy work
void loop() {
    if (buttonPressed) {
        buttonPressed = false;

        // Safe to do slow operations in main loop
        float 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 execution
void 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!

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

  1. setup()/loop() structure is the foundation for all embedded patterns
  2. Non-blocking millis() timing enables responsive multi-tasking
  3. Interrupt handlers bridge hardware events to software responses
  4. State machines organize complex sequential behaviors
  5. Bottom-half processing keeps ISRs fast while handling complex work in loop()

2.14 See Also

2.14.1 Prerequisites

2.14.2 Hands-On Practice

2.14.3 Advanced Topics

2.14.4 Tools and Debugging

2.14.5 Reference Documentation

  • Arduino Language Reference - https://www.arduino.cc/reference/en/
  • ESP32 Arduino Core - https://docs.espressif.com/projects/arduino-esp32/
  • 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.

2.15 What’s Next

If you want to… Read this
Learn wireless communication protocol integration Programming Paradigms and Tools
Understand power management for battery prototypes Power Management
Explore debugging and testing techniques Programming Best Practices