Power Management and Sleep Modes: Interrupts as the Wake-Up Key

Understanding Interrupts in Microcontrollers and Microprocessors — Part 6


            

Throughout this series we have seen interrupts make firmware more responsive, more accurate, and more efficient. In this final part we bring all of those ideas together in the context of power management. This is where interrupt-driven design pays its biggest dividend. A microcontroller running a polling loop at full speed consumes its maximum current continuously. A microcontroller designed around interrupts and sleep modes can spend 99% of its time in a deep sleep consuming microamps — or even nanoamps — waking only when an interrupt demands attention. For any battery-powered product, this difference means the gap between a device that lasts days and one that lasts years on the same battery.

The Core Idea: Sleep Until Something Happens

Every modern microcontroller has one or more low-power sleep modes. In these modes the CPU clock is stopped, halting program execution. Some peripherals — timers, UART receivers, GPIO interrupt detectors — can remain active and draw only a tiny fraction of the normal operating current. When a configured interrupt source fires, the hardware automatically wakes the CPU, executes the ISR, and resumes normal execution from exactly where it left off.

The pattern is almost always the same regardless of platform:

  1. Do whatever work needs to be done.
  2. Configure the interrupt source that should wake you up.
  3. Enter a sleep mode.
  4. CPU halts — power drops dramatically.
  5. Interrupt fires — CPU wakes up automatically.
  6. ISR executes.
  7. Main program resumes after the sleep instruction.
  8. Go back to step 1.

The art of low-power embedded design is choosing the deepest sleep mode you can enter while still keeping the interrupt sources you need active — because deeper sleep means fewer active peripherals and lower power, but also fewer possible wake-up sources.

Understanding Sleep Mode Depth

Most microcontrollers offer several sleep modes at different depths. The trade-off is always the same: deeper sleep means lower power but fewer active peripherals and longer wake-up time. Here is a generalised view of typical sleep depth levels:

  • Idle / Light Sleep: CPU clock stopped, most peripherals still running. Fastest wake-up, modest power saving. Current typically reduced by 50–80% from active mode.
  • Sleep / Normal Sleep: CPU and most high-speed clocks stopped. Some peripherals (timers, UART, GPIO interrupts) still active on a slow clock. Good balance of power saving and wake-up capability.
  • Deep Sleep / Stop Mode: Almost everything stopped. Only a handful of interrupt sources can wake the device — typically GPIO pins, an RTC/low-power timer, or a watchdog. Current in the microamp range.
  • Standby / Hibernate / Power-Off: Nearly all circuits off. Only an external reset pin or RTC alarm can wake the device. RAM content may or may not be preserved. Current in the nanoamp range.

Choosing the right sleep depth for your application is a design decision. A device that needs to wake up every 10 milliseconds to sample a sensor might use Idle mode. A remote weather station that wakes once per hour to take a reading and transmit it might use the deepest available sleep.

MSP430: The King of Low-Power Design

The MSP430 family from Texas Instruments was designed from the ground up with ultra-low power as its primary goal. Its architecture and sleep modes are the gold standard for battery-powered microcontroller design, and its interrupt system is central to that philosophy.

MSP430 offers five low-power modes (LPM0 through LPM4) plus an extended LPM4.5:

  • LPM0: CPU stopped, MCLK off, SMCLK and ACLK running. Timers and UART still active. ~100µA typical.
  • LPM1: Like LPM0 but DCO oscillator also disabled.
  • LPM2/LPM3: Most clocks off, only ACLK (32kHz watch crystal) running. Timer_A on ACLK can still wake the device. ~1–2µA typical for LPM3.
  • LPM4: All clocks stopped. Only GPIO interrupts can wake the device. ~100nA typical.
  • LPM4.5: Complete power-down of the core. Only specific I/O pins can wake the device. RAM not retained. ~10–45nA typical.

LPM3 is the most commonly used mode in real MSP430 designs. With a 32kHz watch crystal running Timer_A and the device in LPM3, you get a periodic wake-up every N seconds at roughly 1–2 microamps average current. This is how MSP430-based devices achieve multi-year battery life from a coin cell.

#include <msp430.h>

// Example: MSP430G2553
// Wake every 1 second via Timer_A on ACLK (32768 Hz watch crystal)
// Read a sensor, transmit a value, go back to sleep
// Average current in LPM3: ~2µA (excludes sensor and radio current)

volatile unsigned char wakeFlag = 0;

int main(void) {
    WDTCTL = WDTPW | WDTHOLD;          // Stop watchdog timer

    // Configure P1.0 as output for LED
    P1DIR |= BIT0;
    P1OUT &= ~BIT0;

    // Configure Timer_A0 on ACLK, up mode, interrupt every 1 second
    TA0CCR0  = 32767;                   // 32768 ticks = 1 second on ACLK
    TA0CCTL0 = CCIE;                    // Enable compare interrupt
    TA0CTL   = TASSEL_1 | MC_1 | TACLR; // ACLK, up mode, clear

    // Enable global interrupts and enter LPM3
    // The CPU will only run when an interrupt wakes it
    __bis_SR_register(LPM3_bits + GIE);

    // After each wakeup the CPU returns here momentarily
    // then loops back and re-enters LPM3
    while (1) {
        if (wakeFlag) {
            wakeFlag = 0;

            P1OUT |= BIT0;             // LED on — doing work
            // readSensor();
            // transmitData();
            P1OUT &= ~BIT0;           // LED off — done

            // Re-enter LPM3 until next timer interrupt
            __bis_SR_register(LPM3_bits);
        }
    }
}

#pragma vector=TIMER0_A0_VECTOR
__interrupt void Timer_A0_ISR(void) {
    wakeFlag = 1;
    // Wake CPU from LPM3 by clearing the LPM bits in the saved SR
    __bic_SR_register_on_exit(LPM3_bits);
}

The key instruction is __bic_SR_register_on_exit(LPM3_bits). This modifies the saved copy of the status register on the stack so that when the ISR returns, the CPU does not go back to sleep immediately but instead resumes the main program. Without this, the CPU would return to LPM3 the instant the ISR finished.

For GPIO-triggered wake from LPM4 (deepest sleep with clocks running):

#include <msp430.h>

int main(void) {
    WDTCTL = WDTPW | WDTHOLD;

    P1DIR  &= ~BIT3;                   // P1.3 as input (button)
    P1REN  |=  BIT3;                   // Enable pull resistor
    P1OUT  |=  BIT3;                   // Pull-up
    P1IES  |=  BIT3;                   // Interrupt on falling edge
    P1IFG  &= ~BIT3;                   // Clear flag
    P1IE   |=  BIT3;                   // Enable interrupt

    // Enter LPM4 — all clocks stopped, only GPIO interrupt can wake
    __bis_SR_register(LPM4_bits + GIE);

    while (1) {
        // Work happens here after button press wakes the device
        // Then re-enter LPM4
        __bis_SR_register(LPM4_bits);
    }
}

#pragma vector=PORT1_VECTOR
__interrupt void Port1_ISR(void) {
    if (P1IFG & BIT3) {
        P1IFG &= ~BIT3;               // Clear flag
        __bic_SR_register_on_exit(LPM4_bits); // Wake CPU
    }
}

STM32: Stop and Standby Modes

STM32 microcontrollers offer a rich set of low-power modes. The most useful for interrupt-driven wake-up are Stop mode and Standby mode.

  • Sleep mode: CPU clock off, all peripherals running. Entered with WFI (Wait For Interrupt) or WFE (Wait For Event). Any interrupt wakes the device. Current reduced by roughly 50%.
  • Stop mode: All clocks stopped except LSI and LSE (low-speed oscillators). SRAM and register contents preserved. GPIO interrupt (EXTI), UART in low-power mode, RTC alarm, or I2C address match can wake the device. Current typically 1–100µA depending on configuration.
  • Standby mode: Almost all power removed. Only RTC, backup registers, and certain wake-up pins remain active. RAM contents lost (except backup SRAM on some devices). Current typically 1–5µA.
// STM32 Sleep mode — wake on any interrupt (simplest case)
// In your main loop after completing work:

HAL_PWR_EnableSleepOnExit();          // Optional: sleep on ISR exit
HAL_PWR_EnterSLEEPMode(PWR_MAINREGULATOR_ON, PWR_SLEEPENTRY_WFI);
// CPU halts here until any interrupt fires
// Execution resumes here after ISR completes
// STM32 Stop Mode — wake on EXTI (GPIO interrupt) or RTC alarm
// Much lower power — all high-speed clocks stopped

// Configure EXTI pin interrupt before entering stop mode
// (done in MX_GPIO_Init via CubeMX)

// Enter Stop mode
HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);

// ---- CPU is halted here, drawing ~5µA ----

// Execution resumes here after EXTI or RTC interrupt wakes the device
// IMPORTANT: After Stop mode on STM32F1/F4, clocks must be reconfigured
// HAL handles this if you use HAL_PWR_EnterSTOPMode()
// On STM32L4 and later, SystemClock_Config() may need to be called again

SystemClock_Config();                 // Restore system clock after Stop
// Continue with normal operation

A very common and practical STM32 pattern is using the RTC (Real Time Clock) with a wake-up timer to produce a periodic wake from Stop mode — similar to the MSP430 Timer_A on ACLK pattern:

// Configure RTC wake-up timer for 10 second interval
// CubeMX sets up RTC — here we start the wake-up timer

HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, 9, RTC_WAKEUPCLOCK_CK_SPRE_16BITS);
// CK_SPRE = 1Hz clock, value 9 = wake every 10 seconds

// Main application loop
while (1) {
    // Do work: read sensors, transmit data, update display
    doPeriodicWork();

    // Enter Stop mode — RTC wake-up timer will fire in 10 seconds
    HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);

    // Woke up — restore clock
    SystemClock_Config();
}

// RTC wake-up ISR callback (HAL handles the IRQ, calls this)
void HAL_RTCEx_WakeUpTimerEventCallback(RTC_HandleTypeDef *hrtc) {
    // Minimal work here — main loop does the real work
    // Just let execution fall through to resume after EnterSTOPMode
}

On STM32L series (L0, L1, L4, L5, U5) — which are specifically optimised for low power — Stop mode current can be as low as 300nA with the right configuration, making them competitive with MSP430 for battery-powered applications.

ESP32: Light Sleep and Deep Sleep

The ESP32 is not primarily a low-power device — its Wi-Fi and Bluetooth radios consume significant current when active. However, it does offer effective sleep modes for duty-cycled applications where the radio is only active briefly and the device sleeps the rest of the time.

  • Light Sleep: CPU and most peripherals paused. RAM and CPU state preserved. GPIO, timer, UART, touchpad, and ULP (Ultra Low Power) coprocessor can wake the device. Current ~0.8mA. Wake-up is fast (~1ms) and execution resumes from where it stopped.
  • Deep Sleep: CPU, RAM, and most peripherals powered off. Only RTC memory, RTC peripherals, and ULP coprocessor remain active. RAM contents lost (except RTC RAM — 8KB retained). Wake-up sources: timer, GPIO (ext0/ext1), touchpad, ULP. Current ~10–150µA. Wake-up causes a full reboot — setup() runs again.
// ESP32 Deep Sleep with timer wake-up
// Device wakes every 30 seconds, does work, goes back to sleep
// setup() runs fresh each wake-up — no persistent RAM except RTC memory

#include <esp_sleep.h>

// RTC_DATA_ATTR variables survive deep sleep (stored in RTC RAM)
RTC_DATA_ATTR int bootCount = 0;

void setup() {
    Serial.begin(115200);
    bootCount++;
    Serial.printf("Boot number: %d\n", bootCount);

    // Print wake-up reason
    esp_sleep_wakeup_cause_t wakeReason = esp_sleep_get_wakeup_cause();
    if (wakeReason == ESP_SLEEP_WAKEUP_TIMER) {
        Serial.println("Woke up from timer");
    }

    // Do your work here
    readSensors();
    connectWiFiAndTransmit();

    // Configure wake-up: timer after 30 seconds
    esp_sleep_enable_timer_wakeup(30 * 1000000ULL); // microseconds

    Serial.println("Entering deep sleep...");
    Serial.flush();

    // Enter deep sleep — this function does not return
    esp_deep_sleep_start();
}

void loop() {
    // Never reached in deep sleep pattern
}
// ESP32 Deep Sleep with GPIO wake-up (ext0 — single pin)
// Wake when GPIO 33 goes HIGH

void setup() {
    Serial.begin(115200);

    esp_sleep_wakeup_cause_t wakeReason = esp_sleep_get_wakeup_cause();
    if (wakeReason == ESP_SLEEP_WAKEUP_EXT0) {
        Serial.println("Woke up from GPIO 33");
        handleButtonPress();
    }

    // Configure ext0 wake-up on GPIO 33, HIGH level
    esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1);

    // Also enable timer as backup wake-up after 1 hour
    esp_sleep_enable_timer_wakeup(3600 * 1000000ULL);

    esp_deep_sleep_start();
}

For applications needing background processing during sleep — sensor polling, touch detection — the ESP32 has a dedicated ULP (Ultra Low Power) coprocessor that runs a small program while the main CPU sleeps and can wake it when a condition is met. This enables sensor-triggered wake-up with the main CPU drawing near-zero current between events.

// ESP32 Light Sleep — RAM preserved, execution resumes after sleep call
// Useful when wake-up time and RAM preservation matter

void setup() {
    Serial.begin(115200);

    // Configure wake-up sources
    esp_sleep_enable_timer_wakeup(5 * 1000000ULL);  // 5 seconds
    gpio_wakeup_enable(GPIO_NUM_4, GPIO_INTR_LOW_LEVEL);
    esp_sleep_enable_gpio_wakeup();
}

void loop() {
    Serial.println("Doing work...");
    doWork();
    Serial.flush();

    // Enter light sleep — execution resumes here on wake-up
    // Unlike deep sleep, this DOES return
    esp_light_sleep_start();

    // Back here after wake-up
    Serial.println("Woke from light sleep");

    esp_sleep_wakeup_cause_t reason = esp_sleep_get_wakeup_cause();
    if (reason == ESP_SLEEP_WAKEUP_GPIO) {
        handleGPIOWakeup();
    }
}

Arduino: Sleeping the AVR

Classic Arduino boards use AVR microcontrollers which support six sleep modes. Arduino does not expose these through its standard API, but the avr/sleep.h header gives access to them directly. The most useful modes are SLEEP_MODE_PWR_DOWN (deepest, ~100nA, only pin interrupt or watchdog can wake) and SLEEP_MODE_IDLE (lightest, timers still run).

#include <avr/sleep.h>
#include <avr/interrupt.h>

volatile bool woken = false;

// INT0 ISR — wakes the device from power-down sleep
ISR(INT0_vect) {
    woken = true;
    // No need for special wake instruction on AVR —
    // returning from ISR automatically resumes after sleep_cpu()
}

void goToSleep() {
    set_sleep_mode(SLEEP_MODE_PWR_DOWN);  // Deepest sleep
    sleep_enable();                        // Allow sleep

    // Ensure INT0 is configured as wake source before sleeping
    attachInterrupt(digitalPinToInterrupt(2), [](){}, FALLING);

    cli();                                 // Disable interrupts atomically
    sleep_enable();
    sei();                                 // Re-enable — sleep starts here
    sleep_cpu();                           // CPU halts

    // ---- sleeping here, ~100nA ----

    // Execution resumes here after interrupt wakes the device
    sleep_disable();                       // Disable sleep mode
}

void setup() {
    pinMode(2, INPUT_PULLUP);              // Wake-up pin
    Serial.begin(9600);
}

void loop() {
    Serial.println("Doing work...");
    delay(100);
    Serial.flush();

    Serial.println("Going to sleep...");
    Serial.flush();                        // Flush before sleeping!

    goToSleep();

    Serial.println("Woke up!");
}

A very important practical note: always flush your serial output with Serial.flush() before entering sleep on Arduino. If you sleep while the UART transmit buffer is not empty, the transmission will be cut off mid-stream.

The popular Rocket Scream Low-Power library wraps AVR sleep modes into a much cleaner API and handles the edge cases for you:

#include <LowPower.h>

void loop() {
    doWork();

    // Sleep for 8 seconds (watchdog timer wake-up, no external interrupt needed)
    LowPower.powerDown(SLEEP_8S, ADC_OFF, BOD_OFF);

    // Woke up — loop repeats
}

Microchip PIC: Sleep Mode and Wake on Interrupt

PIC microcontrollers enter sleep mode with the SLEEP() instruction. In sleep, the CPU and main oscillator stop. The device can be woken by an external interrupt on INT pins, a port change interrupt, a WDT timeout, MSSP activity, a comparator change, or an RTC alarm depending on the specific PIC variant.

#include <xc.h>

volatile unsigned char wakePending = 0;

void __interrupt(high_priority) HighISR(void) {
    if (INT0IF) {
        INT0IF = 0;
        wakePending = 1;
        // Returning from ISR after SLEEP() resumes at the
        // instruction following SLEEP() automatically
    }
}

void main(void) {
    TRISBbits.TRISB0 = 1;             // RB0/INT0 as input
    INTCON2bits.INTEDG0 = 0;          // Falling edge trigger
    INTCONbits.INT0IF   = 0;          // Clear flag
    INTCONbits.INT0IE   = 1;          // Enable INT0
    INTCONbits.GIEH     = 1;          // Global high priority enable

    while (1) {
        doWork();

        // Enter sleep — CPU and oscillator stop
        // Current drops to ~100nA on PIC16LF/PIC18LF low-voltage devices
        SLEEP();
        NOP();                         // Recommended NOP after SLEEP()

        // Execution resumes here after interrupt wakes the device
        if (wakePending) {
            wakePending = 0;
            handleWakeEvent();
        }
    }
}

Microchip’s newer PIC16LF and PIC18LF (Low-power/Low-voltage) variants are specifically optimised for battery operation, with sleep currents as low as 20–30nA and fast wake-up times. The PIC16LF15376 family, for example, includes multiple sleep depths and a Peripheral Module Disable system that lets you power off individual peripherals that are not needed.

Comparing Sleep Currents Across Platforms

  • MSP430 (LPM3): ~1–2µA with 32kHz timer running. LPM4: ~100nA. LPM4.5: ~10–45nA. Best-in-class for ultra-low power.
  • STM32L4 (Stop 2 mode): ~300nA with RTC running. Among the best ARM Cortex-M options for low power.
  • STM32F4 (Stop mode): ~1–2µA. Good but not as optimised as the L-series.
  • ESP32 (Deep Sleep): ~10–150µA depending on RTC peripheral configuration. Not as low as MSP430 or STM32L, but acceptable for many battery applications when duty-cycled with Wi-Fi bursts.
  • Arduino AVR (Power-Down): ~100nA with BOD disabled. Excellent for a mature 8-bit architecture.
  • PIC18LF (Sleep): ~20–100nA. Competitive with AVR for 8-bit low-power applications.

Practical Design Rules for Low-Power Interrupt-Driven Systems

  • Keep active time as short as possible. Wake up, do the minimum necessary work, and go back to sleep. Every millisecond awake costs orders of magnitude more energy than a millisecond asleep.
  • Choose peripherals that can operate during sleep. An RTC, a low-power timer, or a GPIO interrupt detector consumes microamps. A running ADC or SPI bus consumes milliamps. Power them down before sleeping if you do not need them as wake sources.
  • Disable the ADC before deep sleep. On AVR, MSP430, and many other platforms the ADC draws significant current if left enabled during sleep. Always disable it explicitly.
  • Flush serial output before sleeping. Especially on Arduino and STM32 — sleeping mid-transmission corrupts output and may hang the UART.
  • Use RTC RAM or non-volatile memory to preserve state across deep sleep. On ESP32 use RTC_DATA_ATTR. On STM32 use backup registers. On PIC and AVR consider EEPROM for infrequently changed state.
  • Account for wake-up latency in your timing. Deep sleep modes can take hundreds of microseconds to fully wake and restore clocks. If your application requires a response within microseconds, use a lighter sleep mode.
  • Measure actual current — do not rely on datasheet numbers alone. Peripheral leakage, pull-up resistors, voltage regulator quiescent current, and external components all add to real-world consumption. Use a Nordic PPK2, an Otii Arc, or even a simple µA-range multimeter to measure actual system current in sleep.

Closing the Series: What You Have Learned

Across these six parts you have built a complete understanding of interrupts in microcontrollers and microprocessors:

  • Part 1 established the fundamental concept — what an interrupt is, why polling is insufficient, and what types of interrupts exist.
  • Part 2 taught the golden rules of ISR writing — keeping ISRs short, using volatile correctly, protecting multi-byte variables, and platform-specific patterns on Arduino, STM32, ESP32, MSP430, and PIC.
  • Part 3 showed how timer interrupts enable precise, non-blocking periodic tasks — the backbone of real-time embedded firmware.
  • Part 4 explained interrupt priorities and nesting — how the NVIC on ARM Cortex-M manages multiple concurrent interrupts, and how simpler platforms handle the same challenge.
  • Part 5 applied interrupt-driven design to communication peripherals — UART ring buffers, SPI transfers, I2C sensor reads, and ADC conversions, all without stalling the CPU.
  • Part 6 showed how interrupts are the key to low-power design — enabling processors to sleep at nanoamp current levels and wake instantly when something needs attention.

Interrupts are not just a feature of microcontrollers — they are the foundation of how embedded systems interact with the real world efficiently, accurately, and economically. Every professional embedded firmware project uses them. Now you understand not just how to use them, but why they work the way they do, and how that reasoning applies across the full range of platforms you are likely to encounter.

Good luck with your projects — and may your ISRs always be short, your flags always volatile, and your sleep currents always low.

Peripheral Interrupts: UART, SPI, I2C, and ADC

In the previous parts we focused on external pin interrupts and timer interrupts. These are triggered by signals on GPIO pins or by hardware counters. But microcontrollers contain many other built-in peripherals — serial communication interfaces, analog-to-digital converters, and more — and all of them can generate interrupts too. In fact, peripheral interrupts are where […]

Comments are closed.