Hello! Welcome back to our module on timers and interrupts.
In the last lesson, we successfully configured an ESP32 hardware timer to generate a periodic alarm. We set up the timer's resolution, calculated an alarm value for a specific interval, and registered a placeholder callback function to be triggered on each alarm. We now have a precise, independently running clock, but the function it calls is still empty.
Today, we'll bring that function to life. This lesson directly addresses the learning outcome: Implement timer interrupt service routines (ISRs). We will explore what an ISR is, how the CPU handles it, and how to write one correctly. A key focus will be on the critical challenge of safely exchanging information between the high-priority, time-sensitive world of an ISR and the rest of your application.
1. What is an Interrupt Service Routine?
When our hardware timer's counter reaches the alarm value, it sends an electrical signal to the CPU. This signal is called an interrupt. It forces the CPU to immediately pause whatever it's doing, save its current context (like a bookmark), and jump to a special, pre-designated function to "service" the interrupt. This special function is the Interrupt Service Routine (ISR).
This mechanism is analogous to a critical safety system in a machine. Imagine a CNC machine running a complex milling program. If a limit switch is hit (the hardware event), it doesn't wait for the current G-code command to finish. It triggers an immediate, high-priority routine that stops the spindle and axes, overriding the main program to prevent damage.
To get a clear overview of how interrupts fit into an embedded system, particularly one running a Real-Time Operating System (RTOS) like FreeRTOS, please watch this short video segment.
Introduction to RTOS Part 9 - Hardware Interrupts | Digi-Key Electronics
This video from Digi-Key provides an excellent conceptual introduction to hardware interrupts and their relationship with tasks in an RTOS.
Watch from the beginning until 2:31. Pay close attention to the key idea that a hardware interrupt has a higher priority than any software task.
As the video highlights, once the ISR completes its job, the CPU restores the context it saved and resumes the main program exactly where it left off, as if nothing happened.
2. Implementing Your First ISR
Let's take the code framework from our previous lesson and implement a functional ISR. Recall that we registered a callback function named example_timer_on_alarm_cb. This function is our ISR.
The IRAM_ATTR Attribute
When you see ISR functions in ESP-IDF examples, you'll always find them decorated with IRAM_ATTR.
bool IRAM_ATTR example_timer_on_alarm_cb(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user_data)
{
// ISR code goes here
return false;
}
IRAM_ATTR is a macro that tells the compiler to place the machine code for this function into the ESP32's Internal RAM (IRAM) instead of the default location in flash memory. Why is this important?
- Speed: Accessing code from RAM is significantly faster than from flash. Since ISRs need to be executed as quickly as possible, this is a crucial optimization.
- Concurrency: The flash cache can be disabled during certain operations (like writing to flash). If your ISR code were in flash, and an interrupt occurred while the cache was disabled, the system would crash. Placing the ISR in IRAM guarantees it can always be executed.
The official ESP-IDF documentation for the timer driver emphasizes this requirement. When you register an ISR, you must ensure it's placed in IRAM.
General Purpose Timer - ESP32 - ESP-IDF Guide
Let's briefly revisit the ESP-IDF documentation. This time, focus on the notes about the ISR handler itself.
In the documentation for the timer_isr_callback_add function, find the 'Note' section. It emphasizes that the handler is called from an ISR, should be kept short, and that if you use the ESP_INTR_FLAG_IRAM flag (which the GPTimer driver does by default for you), the handler function must be declared with IRAM_ATTR.
3. Communicating from an ISR
An ISR that can't communicate with the rest of the program isn't very useful. A common pattern is for the ISR to signal that an event has occurred, and for the main application code (e.g., in the loop() function or a FreeRTOS task) to react to that signal.
The simplest way to do this is with a shared global variable. The ISR sets or increments the variable, and the main code reads it. However, this introduces two classic embedded systems challenges: compiler optimizations and race conditions.
The volatile Keyword
Let's modify our setup. The ISR will increment a counter every time it runs. The loop() function will check this counter.
// A global variable shared between the ISR and loop()
int interruptCounter = 0;
void loop() {
if (interruptCounter > 0) {
// An interrupt has occurred!
interruptCounter--;
// ... do something ...
}
}
// Our ISR
bool IRAM_ATTR example_timer_on_alarm_cb(...) {
interruptCounter++;
return false;
}
This looks straightforward, but there's a hidden problem. The compiler is designed to optimize code aggressively. When it analyzes the loop() function, it might see that nothing inside the loop's scope ever changes interruptCounter. It could incorrectly assume the if (interruptCounter > 0) check is either always true or always false and optimize the check away entirely.
To prevent this, we must declare the shared variable with the volatile keyword.
volatile int interruptCounter = 0;
The volatile keyword is a directive to the compiler that says, "This variable's value can change at any time from an external source (like an ISR or hardware register)." It forces the compiler to generate code that reads the variable's value from main memory every single time it's accessed, bypassing any optimizations.
Race Conditions and Critical Sections
We've solved the compiler problem, but a more subtle hardware-level issue remains: the race condition. What happens if the loop() function is in the middle of reading the interruptCounter variable at the exact same moment the timer interrupt occurs and the ISR tries to write to it?
For a single-byte variable like a char, the read/write operation is usually atomic (uninterruptible). But for multi-byte types like int (which is 4 bytes on the ESP32), the operation might take multiple machine instructions. An interrupt could occur between these instructions, leading to data corruption.
To prevent this, we must protect access to the shared variable using a critical section. A critical section is a block of code that cannot be interrupted. FreeRTOS provides mechanisms to create them.
- Inside an ISR, you use
portENTER_CRITICAL_ISR(&mux)andportEXIT_CRITICAL_ISR(&mux). - Inside a regular task (like
loop), you useportENTER_CRITICAL(&mux)andportEXIT_CRITICAL(&mux).
These macros disable interrupts temporarily, ensuring the code between them executes atomically. The portMUX_TYPE variable (mux in this example) is a lightweight "spin-lock" used to manage this across the ESP32's dual cores.
Let's look at a complete example that properly implements this pattern.
The article 'ESP32 Timer Interrupts' from ElectronicWings provides a clear, full code example of using a volatile counter and critical sections to communicate between an ISR and the main loop. While it uses the older, legacy timer API, the concepts of volatile and portMUX are identical and directly applicable.
First, review the full code under the 'Code' section. Note the declaration of volatile int interruptCounter, portMUX_TYPE timerMux, and the onTimer() ISR. Then, read the explanations under 'Main Function' and 'The ISR Function Code' to see how portENTER_CRITICAL and portENTER_CRITICAL_ISR are used to safely access the shared counter.
4. Putting It All Together: A Complete Example
Now, let's combine these concepts with the modern gptimer driver from our previous lesson. The following code configures a timer to fire every 500 ms. The ISR increments a shared volatile counter inside a critical section. The main loop() function checks this counter (also in a critical section) and prints a message when an interrupt has been serviced.
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gptimer.h"
#include "esp_log.h"
// For a shared variable between an ISR and a task
volatile uint32_t g_interrupt_count = 0;
// Mutex for protecting the shared variable
portMUX_TYPE g_timer_mux = portMUX_INITIALIZER_UNLOCKED;
// The ISR callback function
static bool IRAM_ATTR timer_on_alarm_cb(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user_data)
{
// Enter critical section from ISR
portENTER_CRITICAL_ISR(&g_timer_mux);
g_interrupt_count++;
// Exit critical section from ISR
portEXIT_CRITICAL_ISR(&g_timer_mux);
// This callback doesn't wake up any high-priority task, so we return false.
// We'll explore returning 'true' in a future lesson.
return false;
}
void app_main(void)
{
// --- 1. Timer Configuration ---
gptimer_handle_t gptimer = NULL;
gptimer_config_t timer_config = {
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
.direction = GPTIMER_COUNT_UP,
.resolution_hz = 1 * 1000 * 1000, // 1 MHz resolution -> 1 tick = 1 µs
};
ESP_ERROR_CHECK(gptimer_new_timer(&timer_config, &gptimer));
// --- 2. Alarm Configuration ---
gptimer_alarm_config_t alarm_config = {
.alarm_count = 500000, // Period = 500,000 ticks * 1 µs/tick = 0.5 seconds
.reload_count = 0,
.flags.auto_reload_on_alarm = true,
};
ESP_ERROR_CHECK(gptimer_set_alarm_action(gptimer, &alarm_config));
// --- 3. Register ISR Callback ---
gptimer_event_callbacks_t cbs = {
.on_alarm = timer_on_alarm_cb,
};
ESP_ERROR_CHECK(gptimer_register_event_callbacks(gptimer, &cbs, NULL));
// --- 4. Start the Timer ---
ESP_ERROR_CHECK(gptimer_enable(gptimer));
ESP_ERROR_CHECK(gptimer_start(gptimer));
ESP_LOGI("main", "Timer started");
uint32_t last_count = 0;
while (1) {
uint32_t current_count = 0;
// Enter critical section in the main task
portENTER_CRITICAL(&g_timer_mux);
current_count = g_interrupt_count;
// Exit critical section in the main task
portEXIT_CRITICAL(&g_timer_mux);
if (current_count > last_count) {
ESP_LOGI("main", "Timer interrupt occurred! Total count: %lu", current_count);
last_count = current_count;
}
// Let other tasks run
vTaskDelay(pdMS_TO_TICKS(10));
}
}
Test your understanding!
In the code above, why is the portENTER_CRITICAL(&g_timer_mux); / portEXIT_CRITICAL(&g_timer_mux); block necessary in the while(1) loop? What could go wrong if it were removed?
Show answer
The g_interrupt_count variable is a uint32_t, which is 4 bytes on the ESP32. Reading a 4-byte variable is not an atomic operation; it may take multiple CPU instructions. An interrupt could fire in the middle of this read operation, causing the current_count variable to be loaded with a corrupted value (e.g., part of the old value and part of the new, incremented value). The critical section ensures the read is atomic by preventing interrupts from occurring during the access.
Conclusion
In this lesson, you've bridged the gap between configuring a timer and making it do useful work. You now understand how to implement the core logic that executes in response to a hardware event.
Key Takeaways:
- An Interrupt Service Routine (ISR) is a high-priority function executed immediately when a hardware interrupt occurs, pausing the main application.
- ISRs on the ESP32 must be marked with
IRAM_ATTRto place them in fast, always-accessible internal RAM. - When sharing data between an ISR and other tasks, the shared variable must be declared as
volatile. - Access to the shared variable must be protected within a critical section to prevent race conditions. Use
port..._ISRversions inside the ISR and the regular versions in tasks. - The
boolreturn value from a GPTimer ISR signals to the scheduler whether a context switch might be needed, a key concept for RTOS integration.
In our next lesson, we will build directly on this foundation to apply best practices for ISR implementation. We've seen how to write an ISR, but next, we'll focus on how to write a good one—keeping them short, fast, and offloading heavy work to other tasks using more advanced RTOS synchronization tools.