ESP32 – Rust Practical Application: Polling vs. Interrupts, Which Button Detection Method is Superior?

In your embedded projects, can the program really respond immediately when a button is pressed?

The seemingly simple “polling” may hide performance traps and response delays; while “interrupts” provide a more elegant and efficient solution. This article will guide you through a classic “button control LED” experiment using <span>Rust</span> and <span>ESP32-S3</span>, allowing you to intuitively experience the vast differences between the two from a coding perspective, helping you master the core of embedded event handling.

Polling is the action of continuously “polling” or reading the input state, while interrupts allow peripherals to “interrupt” the processor, notifying it that an event has occurred. This demonstration will show how to control the LED’s blinking frequency using both polling and interrupts.

The peripherals used are an LED and a switch button:

  • Connect the LED anode to <span>gpio4</span> on the development board, and the LED cathode to <span>GND</span> on the development board.

  • Connect one end of the button to <span>gpio9</span>, which will be configured as an input, and the other end to <span>GND</span> on the development board.

ESP32 - Rust Practical Application: Polling vs. Interrupts, Which Button Detection Method is Superior?

Wiring of the ESP32-S3 development board on a breadboard

Polling to Handle Button Press Events

Using polling to detect button press events (with real-time changing delay) to control the LED’s blinking rate, meaning that each time the button is pressed, the LED will blink at a different rate. In this application, the delay is placed in a loop, continuously polling to check if the button has been pressed.

ESP32 - Rust Practical Application: Polling vs. Interrupts, Which Button Detection Method is Superior?

Flowchart of the button controlling LED blinking algorithm

First, after configuring the GPIO pins, initialize a delay value. This value will be used to create a delay through the algorithm. Additionally, the LED output is initialized to a known state (either on or off).

Next, a counter/index is initialized to zero and enters a loop, continuously increasing the count until it reaches the delay value. Within this delay loop, it continuously polls to check if the button has been pressed. If the button is pressed at any time, the delay value will decrease, meaning the LED blinking frequency will increase. However, it is necessary to check the new delay value to ensure it does not become negative. Therefore, if the delay value falls below a certain threshold, it will be reset to the initial value. After that, the LED output state can be toggled, and the delay loop restarts.

If the check for the button press is placed after the delay loop, then when the delay is long, the button press will be missed. This is why interrupts are more convenient. With interrupts, the operation is paused to immediately respond to the button press event.

// Loop
#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{
    gpio::{Input, InputConfig, Io, Level, Output, OutputConfig, Pull},
    main, timer::timg::TimerGroup,
};
use esp_println::println;

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // Get peripherals toolbox
    let peripherals = esp_hal::init(esp_hal::Config::default());
    
    // Disable watchdog timer
    let timer_group0 = TimerGroup::new(peripherals.TIMG0);
    let mut wdt0 = timer_group0.wdt;
    wdt0.disable();
    
    let timer_group1 = TimerGroup::new(peripherals.TIMG1);
    let mut wdt1 = timer_group1.wdt;
    wdt1.disable();
    
    println!("Watchdog timers disabled!");

    // Create GPIO manager
    let _io = Io::new(peripherals.IO_MUX);

    // Configure LED: set gpio4 as output pin, initial state high
    let mut led = Output::new(peripherals.GPIO4, Level::High, OutputConfig::default());

    let config = InputConfig::default().with_pull(Pull::Up);
    // Configure button: set gpio9 as input pin and enable internal pull-up resistor
    let button = Input::new(peripherals.GPIO9, config);
    println!("button level {:?} - {:?} - {:#?}", button.is_low(), button.is_high(), button.level());

    // Create a mutable delay variable, initial value is large, representing initial slow blinking speed
    // Software Delay: Achieved by letting the CPU execute a time-consuming loop.
    // This method is simple but inefficient, as the CPU is completely "idling" during the delay, unable to do anything else.
    let mut blinkdelay = 1_000_000_u32;

    // First turn off the LED to ensure initial state consistency
    led.set_low();

    // Enter main loop
    loop {
        // This is a software delay loop. It will idle `blinkdelay` times.
        // During this delay, it continuously checks the button state.
        for _i in 1..blinkdelay {
            // println!("{} - Button: {}", i, button.is_low());
            // is_low() checks if the button is pressed (since we connected to GND and used a pull-up resistor)
            if button.is_low() {
                println!("Button pressed!");

                // If pressed, reduce the delay value to make the next blink faster
                blinkdelay = blinkdelay - 25_000_u32;
                // If the delay value becomes too small, reset it back to the initial large value to achieve cycling speed
                if blinkdelay < 25_000 {
                    blinkdelay = 1_000_000_u32;
                }
            }
        }
        // After the delay loop ends (i.e., after a period of time), toggle the LED's state
        led.toggle();
    }
}

Execute command:<span>cargo run</span> or <span>cargo build</span> and <span>espflash flash target/xtensa-esp32s3-none-elf/debug/esp32s3-demo</span> results:

[2025-08-02T09:18:55Z INFO ] Serial port: 'COM4'
[2025-08-02T09:18:55Z INFO ] Connecting...
[2025-08-02T09:18:56Z INFO ] Using flash stub
Chip type:         esp32s3 (revision v0.2)
Crystal frequency: 40 MHz
Flash size:        16MB
Features:          WiFi, BLE, Embedded Flash
MAC address:       10:51:db:85:14:b4
App/part. size:    86,736/16,384,000 bytes, 0.53%
[00:00:00] [========================================]      14/14      0x0      Skipped! (checksum matches)                                                               
[00:00:00] [========================================]       1/1       0x8000   Skipped! (checksum matches)                                                               
[00:00:00] [========================================]      22/22      0x10000  Skipped! (checksum matches)                                                               
[2025-08-02T09:18:56Z INFO ] Flashing has completed!
Commands:
    CTRL+R    Reset chip
    CTRL+C    Exit

ESP-ROM:esp32s3-20210327
Build:Mar 27 2021
rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)
Saved PC:0x40378cab
SPIWP:0xee
mode:DIO, clock div:2
load:0x3fce2810,len:0x15a0
load:0x403c8700,len:0x4
load:0x403c8704,len:0xd24
load:0x403cb700,len:0x2f04
entry 0x403c8928
I (27) boot: ESP-IDF v5.4.1-426-g3ad36321ea 2nd stage bootloader
I (27) boot: compile time Apr 24 2025 15:55:11
I (28) boot: Multicore bootloader
I (29) boot: chip revision: v0.2
I (32) boot: efuse block revision: v1.3
I (35) boot.esp32s3: Boot SPI Speed : 40MHz
I (39) boot.esp32s3: SPI Mode       : DIO
I (43) boot.esp32s3: SPI Flash Size : 16MB
I (47) boot: Enabling RNG early entropy source...
I (51) boot: Partition Table:
I (54) boot: ## Label            Usage          Type ST Offset   Length
I (60) boot:  0 nvs              WiFi data        01 02 00009000 00006000
I (67) boot:  1 phy_init         RF data          01 01 0000f000 00001000
I (73) boot:  2 factory          factory app      00 00 00010000 00fa0000
I (80) boot: End of partition table
I (83) esp_image: segment 0: paddr=00010020 vaddr=3c000020 size=04f84h ( 20356) map
I (95) esp_image: segment 1: paddr=00014fac vaddr=3fc89194 size=00658h (  1624) load
I (98) esp_image: segment 2: paddr=0001560c vaddr=40378000 size=01194h (  4500) load
I (107) esp_image: segment 3: paddr=000167a8 vaddr=00000000 size=09870h ( 39024)
I (122) esp_image: segment 4: paddr=00020020 vaddr=42010020 size=05280h ( 21120) map
I (128) boot: Loaded app from partition at offset 0x10000
I (128) boot: Disabling RNG early entropy source...
button level false - true - High

Will the button state remain low?

No, because a momentary button is used here, not a toggle switch, and the button will return to high when released.

Button state change process:

  1. Initial state (not pressed):

    GPIO9 ← Pull-up resistor ← 3.3V
    GPIO9 ← Button (open) ← GND
    
    Result: GPIO9 = High (3.3V)
    button.is_low() = false
    
  2. Button pressed (finger held down):

    GPIO9 ← Pull-up resistor ← 3.3V
    GPIO9 ← Button (closed) ← GND
    
    Result: GPIO9 = Low (0V) - GND path takes priority
    button.is_low() = true
    
  3. Button released (finger lifted):

    GPIO9 ← Pull-up resistor ← 3.3V  
    GPIO9 ← Button (open) ← GND
    
    Result: GPIO9 = High (3.3V) - controlled again by the pull-up resistor
    button.is_low() = false
    

Code representation:

for _i in 1..blinkdelay {
    if button.is_low() {  // Only true when "held down"
        println!("Button pressed!");
        blinkdelay = blinkdelay - 25_000_u32;
    }
}

Timing analysis:

Timeline 0ms 100ms 200ms 300ms 400ms
Button Not pressed Pressed Held Held Released
State High Low Low Low High
Delay Time 1M Rapidly decreasing Very small Very small Maintaining small value
LED Frequency Slow blink Accelerating Very fast Very fast Very fast
Human Eye Perception Blinking Blinking Constantly on Constantly on Fast blink

[Constantly On] explanation:

According to the delay calculation display, continuous reduction may cause the delay time value to far exceed the initial value range, due to the overflow characteristics of the u32 type affecting the actual value change process. When the delay time continues to decrease, once it reaches 0, it will wrap around to a very large positive value. This overflow mechanism will cause the delay time not to be reset but to remain in a very large value range. When the button is pressed continuously, the counter will continue to calculate in an unexpected way, avoiding the expected reset behavior. The result is that the LED switching becomes extremely slow, and it actually appears to stop blinking completely.

Why does this happen?

1. Role of the pull-up resistor:

  • When the button is open, the pull-up resistor ensures that GPIO9 always has a clear high signal

  • Prevents the pin from floating, leading to an uncertain state

2. Characteristics of momentary buttons:

  • The button only closes under mechanical pressure

  • Automatically returns to the open state when there is no pressure

  • This is the most common type of button

3. Circuit priority:

  • When the button is closed, the GND path resistance is almost 0, much smaller than the pull-up resistor

  • According to Ohm’s law, current chooses the path with the least impedance

  • So when pressed, GPIO9 is pulled to 0V

Conclusion is that the button state is low only during the physical press, and immediately returns to high after release. This is standard momentary button behavior.

Interrupt Handling for Button Press Events

Next, we will build an application that uses interrupts to detect and monitor button press events. The steps to configure interrupts are as follows in the code.

To avoid spending too much time inside the ISR, set a flag (G_FLAG) inside the ISR, and let the main thread check this flag. It is important to know that spending time in the ISR will affect the execution progress of the <span>main</span> function or other code.

// Interrupt
#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{
    gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull},
    handler, main,
};
use core::sync::atomic::{AtomicU32, Ordering};
use core::cell::{Cell, RefCell};
use critical_section::Mutex;
use esp_println::println;

esp_bootloader_esp_idf::esp_app_desc!();

// Used to share pin instance between ISR and main to clear interrupt flag in ISR
static G_PIN: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
// Used to pass the "button pressed" event flag between ISR and main
static G_FLAG: Mutex<Cell<bool>> = Mutex::new(Cell::new(false));
// Global variable: used to store blink delay, AtomicU32 is a thread-safe integer type
static BLINK_DELAY: AtomicU32 = AtomicU32::new(500); // Initial delay 500ms

#[handler] // This is an interrupt handler function
fn gpio_handler() {
    // Enter critical section, only do necessary hardware operations
    critical_section::with(|cs| {
        // Clear interrupt flag - this is a necessary hardware operation
        G_PIN.borrow_ref_mut(cs).as_mut().unwrap().clear_interrupt();
        // Set flag to notify main loop
        G_FLAG.borrow(cs).set(true);
    });
    // ISR ends - total execution time should be in microseconds
}

#[main]
fn main() -> ! {
    // Get peripherals toolbox
    let peripherals = esp_hal::init(esp_hal::Config::default());

    // Create GPIO manager
    let mut io = Io::new(peripherals.IO_MUX);
    // 1. Register interrupt handler
    io.set_interrupt_handler(gpio_handler);
    println!("GPIO interrupt handler registered");

    // Configure LED: set gpio4 as output pin, initial state high
    let mut led = Output::new(peripherals.GPIO4, Level::High, OutputConfig::default());

    let config = InputConfig::default().with_pull(Pull::Up);

    // 2. Configure pin as input
    // Configure button: set gpio9 as input pin and enable internal pull-up resistor
    let mut button = Input::new(peripherals.GPIO9, config);
    println!("button level {:?} - {:?} - {:#?}", button.is_low(), button.is_high(), button.level());
    // 3. Listen for falling edge events
    button.listen(Event::FallingEdge);

    // 4. Move configured pin into global variable
    critical_section::with(|cs| G_PIN.borrow_ref_mut(cs).replace(button));

    let mut delay = esp_hal::delay::Delay::new();

    // Enter main loop
    loop {
        // After the delay loop ends (i.e., after a period of time), toggle the LED's state
        led.toggle();

        // Get the current delay value
        let current_delay = BLINK_DELAY.load(Ordering::Relaxed);
        // Hardware delay
        delay.delay_millis(current_delay);
        
        critical_section::with(|cs| {
            if G_FLAG.borrow(cs).get() {
                // Clear global flag
                G_FLAG.borrow(cs).set(false);

                // Modify delay value
                let mut new_delay = BLINK_DELAY.load(Ordering::Relaxed);
                new_delay = if new_delay <= 100 { 500 } else { new_delay - 100 };
                BLINK_DELAY.store(new_delay, Ordering::Relaxed);
                esp_println::println!("Delay changed to: {}", new_delay);
            }
        });
    }
}

Successfully executed command:<span>cargo run</span>, results as follows:

[2025-08-02T08:36:15Z INFO ] Serial port: 'COM3'
[2025-08-02T08:36:15Z INFO ] Connecting...
[2025-08-02T08:36:16Z INFO ] Using flash stub
Chip type:         esp32s3 (revision v0.2)
Crystal frequency: 40 MHz
Flash size:        16MB
Features:          WiFi, BLE, Embedded Flash
MAC address:       10:51:db:85:14:b4
App/part. size:    87,808/16,384,000 bytes, 0.54%
[00:00:00] [========================================]      14/14      0x0      Skipped! (checksum matches)                                                               
[00:00:00] [========================================]       1/1       0x8000   Skipped! (checksum matches)                                                               
[00:00:02] [========================================]      24/24      0x10000  Verifying... OK!                                                                 
[2025-08-02T08:36:19Z INFO ] Flashing has completed!
Commands:
    CTRL+R    Reset chip
    CTRL+C    Exit

ESP-ROM:esp32s3-20210327
Build:Mar 27 2021
rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:2
load:0x3fce2810,len:0x15a0
load:0x403c8700,len:0x4
load:0x403c8704,len:0xd24
load:0x403cb700,len:0x2f04
entry 0x403c8928
I (30) boot: ESP-IDF v5.4.1-426-g3ad36321ea 2nd stage bootloader
I (30) boot: compile time Apr 24 2025 15:55:11
I (30) boot: Multicore bootloader
I (32) boot: chip revision: v0.2
I (34) boot: efuse block revision: v1.3
I (38) boot.esp32s3: Boot SPI Speed : 40MHz
I (42) boot.esp32s3: SPI Mode       : DIO
I (46) boot.esp32s3: SPI Flash Size : 16MB
I (49) boot: Enabling RNG early entropy source...
I (54) boot: Partition Table:
I (57) boot: ## Label            Usage          Type ST Offset   Length
I (63) boot:  0 nvs              WiFi data        01 02 00009000 00006000
I (69) boot:  1 phy_init         RF data          01 01 0000f000 00001000
I (76) boot:  2 factory          factory app      00 00 00010000 00fa0000
I (82) boot: End of partition table
I (86) esp_image: segment 0: paddr=00010020 vaddr=3c000020 size=053d0h ( 21456) map
I (98) esp_image: segment 1: paddr=000153f8 vaddr=3fc89194 size=0066ch (  1644) load
I (101) esp_image: segment 2: paddr=00015a6c vaddr=40378000 size=01194h (  4500) load
I (109) esp_image: segment 3: paddr=00016c08 vaddr=00000000 size=09410h ( 37904) 
I (125) esp_image: segment 4: paddr=00020020 vaddr=42010020 size=056b8h ( 22200) map
I (131) boot: Loaded app from partition at offset 0x10000
I (131) boot: Disabling RNG early entropy source...
GPIO interrupt handler registered
button level false - true - High
Delay changed to: 400
Delay changed to: 300
Delay changed to: 200
Delay changed to: 100
Delay changed to: 500
Delay changed to: 400

The “Delay changed to” in the log above is the output when the button is manually pressed, decreasing from 500 milliseconds to 100 milliseconds and then starting to decrease from 500 milliseconds again, while the LED’s blinking frequency will also increase as the delay decreases.

In the code, we placed the time-consuming delay operation and LED control in the main loop, while the quick button event detection task is handled by interrupts. The main loop no longer needs to actively poll the button, it only needs to check the flag left by the interrupt. In contrast, the interrupt operation is much more efficient than the pure polling scheme.

Project address:

https://github.com/fruzly/no_std-training/intro/esp32s3-demo

Code files for this article:04_blinky_interrupte.rs and 04_blinky_loop.rs

Leave a Comment