prerequisite

Polling and interrupts

Two ways a program notices that something happened: asking over and over, or being interrupted when it does.

Before this

This page assumes you are comfortable with:

Why you need this

A board spends most of its life waiting: for a key, a byte, a button, a radio packet, a timer. How a program waits decides how much of the processor is left for other work, how much power the board draws, and whether the operating system's watchdog thinks the program has hung. Every echo program in this cluster waits, and they all do it the same simple way.

The idea

There are two ways to notice an event.

Polling means asking over and over. The program reads a status register, a hardware register whose bits report what the device is doing, checks one bit, and if nothing has happened, reads it again. A loop that does nothing but this is a busy-wait.

An interrupt means the hardware asks for attention. When the event happens, a device raises a signal. The processor finishes its current instruction, saves where it was, and jumps to a small function called an interrupt handler (or interrupt service routine, ISR). When the handler returns, the processor picks up the interrupted program exactly where it left off. The program does not need to check anything; it can do other work or sleep.

Polling Interrupts
Who checks The program, in a loop The hardware
CPU while waiting 100% busy Free for other work, or asleep
Delay before reacting One trip around the loop The time to save state and enter the handler
Code shape One straight loop, easy to read A handler plus a main loop that share data
Good for Tiny programs, very short waits, bare-metal code Rare or unpredictable events, battery power, programs with several jobs

Polling in the echo

The Inspector's echo payloads all poll. This wait loop, for RISC-V on the ESP32-C6, is excerpted from the rv32-usbjtag echo payload (hardware-proven):

echo_loop:
    li      t0, 0x6000F004           # USB_SERIAL_JTAG_EP1_CONF_REG
    lw      t1, 0(t0)                # read status flags
    andi    t1, t1, 4                # keep bit 2 = SERIAL_OUT_EP_DATA_AVAIL
    beqz    t1, echo_loop            # spin until a byte is waiting

Bit 2 of that register is 1 when a received byte is waiting. Until it is, the loop goes around again. The assembler turns li into two instructions here, so each trip is five machine instructions.

The payloads show polling's first cost. Their sdkconfig.defaults turns off ESP-IDF's task watchdog, with this comment from the classic ESP32 payload: "The asm echo loop holds CPU0 forever without yielding, which starves IDLE0 and trips the WDT after 5s." A watchdog is a timer that resets the chip unless the program checks in regularly. ESP-IDF's version watches whether the operating system's idle task ever gets to run, and a busy-wait never lets it.

Interrupt handlers have rules

A handler interrupts code that might be halfway through anything, so it must be short and must not wait.

  • Do little. Record the event, copy the data, and return. Long work delays every other interrupt and the main program.
  • Never block. No waiting on a lock, a slow bus, or a full buffer.
  • Do not allocate memory. The allocator may be in use by the code that was interrupted.
  • Hand off. Put the event in a queue or set a flag, and let the main loop do the slow part.

Shared data is the trap. If a handler changes a variable while the main loop is halfway through reading it, the main loop can see half of the old value and half of the new one. A queue built for the purpose, or a single flag variable that only the handler writes, avoids that.

The author's ESP_32_Keyboard project, an ESP32-S3 that receives key reports by radio and types them into a computer, uses exactly this hand-off. Its receive callback runs inside the WiFi driver, which has the same don't-block rule as an interrupt handler. This excerpt, in C with ESP-IDF, is from that project's relay firmware (hardware-proven):

static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data, int len)
{
    if (data == NULL || len < 8) {
        return;
    }

    hid_report_t report;
    memcpy(report.data, data, 8);

    /* Post to queue: don't block in WiFi callback */
    xQueueSendFromISR(hid_report_queue, &report, NULL);
}
...
    while (1) {
        if (xQueueReceive(hid_report_queue, &report, pdMS_TO_TICKS(100))) {
            ...

The callback copies 8 bytes and posts them. The main loop waits on the queue for up to 100 ms at a time, and while it waits the operating system can run other tasks.

Timers and schedulers

A hardware timer counts clock ticks and raises an interrupt when it reaches a set value, which is how a program does something every second without polling a clock. This MicroPython sample, written for this page and not hardware-tested, counts timer interrupts and does the printing in the main loop:

from machine import Timer
import time

ticks = 0

def on_tick(t):
    global ticks
    ticks += 1              # keep the handler tiny: just count

tim = Timer(0)
tim.init(period=1000, mode=Timer.PERIODIC, callback=on_tick)

seen = 0
while True:
    if ticks != seen:       # the main loop does the slow work
        seen = ticks
        print("tick", seen)
    time.sleep_ms(10)

Under ESP-IDF there is one more layer. FreeRTOS, the small operating system ESP-IDF includes, runs several tasks and uses a timer interrupt to switch between them. app_main itself runs in a task; the boot log line main_task: Calling app_main() is FreeRTOS starting it. MicroPython on the ESP32 is built on ESP-IDF, so the interpreter runs as a task too. A task that waits on a queue or calls a delay gives the CPU back. A busy-wait does not.

Worked example

How often does the C6's wait loop check the status bit while one character arrives? Use a UART at 115200 baud for the arrival and state the assumptions:

  • One character is 10 bits on the wire: a start bit, 8 data bits, a stop bit.
  • The C6 core runs at 160 MHz (the author's board notes).
  • Every instruction takes one clock cycle. Real loads from peripheral registers take longer, so this is an upper bound.
Step Calculation Result
Time for one bit 1/1152001 / 115200 s 8.68 µs
Time for one character 10×8.6810 \times 8.68 µs 86.81 µs
Cycles in that time 86.81×10−6×160×10686.81 \times 10^{-6} \times 160 \times 10^{6} about 13,889
Trips around the 5-instruction loop 13889/513889 / 5 about 2,778

So the loop asks "is it here yet?" about 2,778 times for every character that arrives, and only the last answer is yes. One full pass of the echo for a character that has arrived is 24 machine instructions, so useful work is about 24/13889≈0.17%24 / 13889 \approx 0.17\% of the cycles at a steady stream of typing, and far less when nobody is typing.

In an ESP32 project

Polling and interrupts appear in stage 4 (talk to hardware) and stage 5 (wireless). Assembly payloads and the smallest test programs poll, because they are easy to read and nothing else needs the CPU. Real projects lean on interrupts and queues: button presses, radio packets, display refresh timers. GPIO interrupt code itself is on GPIO: buttons and LEDs; serial buffering is on serial over UART and USB.

Common mistakes

  • Busy-waiting under ESP-IDF with the task watchdog on. After about 5 s the log prints a task watchdog report naming the idle task, every few seconds, while the program otherwise seems to run.
  • Printing or doing slow I/O inside a handler. Bytes go missing and other interrupts are late, or the program crashes.
  • Sharing a multi-step variable without protection. Rare, unrepeatable wrong values: a counter that occasionally jumps or loses a count.
  • Forgetting the handler can run any time. Code that sets up the queue after enabling the interrupt crashes when the first event arrives early. The keyboard firmware creates its queue before starting the radio for exactly this reason.

Cost

Polling costs 100% of a core for as long as it waits, plus the power that burns, but it costs nothing in code: the C6's wait is five instructions. Interrupts cost a little on every event, to save and restore the interrupted program's registers, plus memory for the handler and a queue, and more of the maker's time to get the sharing right. Polling starts to hurt when the board runs on a battery, when the program has a second job, or under an operating system whose watchdog expects every task to yield.

Going further

  • The ESP-IDF Programming Guide's chapters on FreeRTOS and on interrupt allocation.
  • MicroPython's documentation on writing interrupt handlers.
  • How a CPU runs instructions, for the registers a handler entry has to save.
  • Debugging resets and crashes, for the other watchdogs and what their resets look like.

Leads to

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython