Привет, Хабр!
Это вторая статья из цикла по программированию ESP32 на ESP‑IDF. В первой части мы познакомились с базовой терминологией RTOS и реализовали несколько простых задач (tasks). Сегодня же мы перейдём к работе с GPIO и прерываниями (ISR), а заодно обсудим особенности настройки стека задач в ESP‑IDF (спасибо за совет @0x6b73ca).

Стек
Обратимся к официальной документации:
FreeRTOS
В FreeRTOS мы работаем в словах.
ESP-IDF
В ESP-IDF мы работаем в байтах.
В классическом FreeRTOS параметр usStackDepth
задаётся в машинных словах (StackType_t), тогда как в ESP‑IDF тот же параметр измеряется в байтах. Это важно учитывать при переносе проектов между платформами, чтобы не выделять слишком много или слишком мало памяти для стека задачи.
GPIO
По умолчанию каждый физический контакт (pad) ESP32 может выполнять различные аппаратные функции — от АЦП и touch‑сенсора до UART, SPI и LED‑контроллера. Чтобы использовать pad как обычный цифровой ввод‑вывод, его нужно переключить в режим GPIO.
Для этого служит функция
esp_rom_gpio_pad_select_gpio(pin);
Она отключает все альтернативные функции (ADC, touch, UART, SPI, LEDC и прочие) и привязывает выбранный pad к модулю GPIO.
Далее можно задать направление и уровни с помощью стандартных API. Ниже — основные функции для работы с GPIO:
Основные функций при работе с GPIO |
Функция |
Описание |
---|---|
|
Привязывает физический pad к модулю GPIO, отключая все альтернативные функции контакта. |
|
Сбрасывает конфигурацию пина к значению по умолчанию (вызывает |
|
Устанавливает направление: |
|
Устанавливает логический уровень (0 или 1) на выходном пине. |
|
Читает текущее логическое состояние (0/1) на входном или выходном пине. |
|
Упрощённая групповая конфигурация: направление, подтяжки, прерывания, маска пинов. |
|
Включает или отключает внутренний pull‑up резистор. |
|
Включает или отключает внутренний pull‑down резистор. |
Помигаем)
А чем собственно мигаем?
На плате NodeMCU‑32S (и на многих других «devkit»-модулях для ESP32) встроенный светодиод физически подключён к контакту GPIO2. Также сразу объявим дескриптор задачи мигания:
#define LED_GPIO GPIO_NUM_2 // Порт светодиода
TaskHandle_t Blink_Handle = NULL; // Дескриптор задачи Blink
Blink_Task
Думаю, в прошлой статье мы достаточно подробно разобрали механизм задач, поэтому без лишних пояснений приведём итоговую структуру задачи:
void Blink_Task(void *arg){
esp_rom_gpio_pad_select_gpio(LED_GPIO); // "переключение" выбранного физического контакта в режим GPIO
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT); // Устанавливаем направление как выход
while(1){
gpio_set_level(LED_GPIO, 1); // Устанавливаем логический уровень 1
vTaskDelay(pdMS_TO_TICKS(1000)); // Ждем
gpio_set_level(LED_GPIO, 0); // Устанавливаем логический уровень 0
vTaskDelay(pdMS_TO_TICKS(1000)); // Ждем
}
}
Результат
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#define LED_GPIO GPIO_NUM_2 // Порт светодиода
TaskHandle_t Blink_Handle = NULL; // Дескриптор задачи Blink
void Blink_Task(void *arg){
esp_rom_gpio_pad_select_gpio(LED_GPIO); // "переключение" выбранного физического контакта в режим GPIO
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT); // Устанавливаем направление как выход
while(1){
gpio_set_level(LED_GPIO, 1); // Устанавливаем логический уровень 1
vTaskDelay(pdMS_TO_TICKS(1000)); // Ждем
gpio_set_level(LED_GPIO, 0); // Устанавливаем логический уровень 0
vTaskDelay(pdMS_TO_TICKS(1000)); // Ждем
}
}
void app_main() {
xTaskCreate(
Blink_Task, // указатель на функцию‑задачу
"BLINK", // имя задачи (для отладки)
4096, // размер стека
NULL, // аргумент, передаваемый в функцию (здесь не нужен)
10, // приоритет задачи
&Blink_Handle // указатель, в который запишут дескриптор задачи
);
}

Прерывания (ISR)
Прерывание (Interrupt) — одна из базовых концепций вычислительной техники, которая заключается в том, что при наступлении какого-либо события происходит передача управления специальной процедуре, называемой обработчиком прерываний (ISR).
Прерывания бывают двух типов:
Аппаратные — генерируются железом (например, периферийными модулями GPIO, таймерами, UART);
Программные — инициируются выполнением в коде специальной инструкции, позволяющей «искусственно» вызвать обработчик.
Кроме того, в ESP32 реализован механизм межпроцессорного вызова (IPC), который имеет два режима работы:
Task Context — колбэк выполняется в контексте специальной IPC‑таски, что позволяет использовать любые функции FreeRTOS и ESP‑IDF.
ISR Context — вызов происходит сразу в контексте высокоприоритетного прерывания (Inter‑Processor Interrupt). В этом режиме колбэк должен находиться в IRAM и реализовываться на ассемблере, поскольку нельзя полагаться на доступность флеш‑кеша и поддерживаются только низкоуровневые инструкции.
Подробности и ограничения каждого режима можно найти в официальной документации по IPC:
https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/ipc.html
Регистрация обработчика прерывания в ESP-IDF
Ниже приведены основные функции и их атрибуты при работе с ISR:
gpio_set_intr_type(gpio_num, intr_type);
Назначение: задаёт тип аппаратного прерывания (по какому фронту) для конкретного GPIO‑пина.
-
Параметры:
gpio_num
– номер пина (GPIO_NUM_0
…GPIO_NUM_39
).-
intr_type
- тип прерывания:GPIO_INTR_DISABLE
- отключить прерыванияGPIO_INTR_POSEDGE
- по положительному фронтуGPIO_INTR_NEGEDGE
- по отрицательному фронтуGPIO_INTR_ANYEDGE
- по любому фронтуGPIO_INTR_LOW_LEVEL
- по удержанию низкого уровняGPIO_INTR_HIGH_LEVEL
- по удержанию высокого уровня
В контексте GPIO‑прерываний термин «фронт» означает момент изменения уровня сигнала, а «уровень» (level) — сам факт удержания сигнала в том или ином состоянии. Например,
GPIO_INTR_POSEDGE
(«по положительному фронту») — срабатывает один раз в момент, когда входной сигнал переходит из 0 в 1 (низкий → высокий).GPIO_INTR_ANYEDGE
(«по любому фронту») — срабатывает как при переходе 0→1, так и при 1→0.
gpio_install_isr_service(intr_alloc_flags);
Назначение: инициализирует общий сервис прерываний GPIO, позволяя затем регистрировать обработчики (
gpio_isr_handler_add
) для отдельных пинов.-
Параметр
intr_alloc_flags
:0
– без специальных флагов (приоритет и режим определяются системой)ESP_INTR_FLAG_IRAM
- обработчики прерываний будут загружены в IRAMESP_INTR_FLAG_LOWMED
- средний / низкий приоритет прерывания
Когда Вы используете флаг
ESP_INTR_FLAG_IRAM
вgpio_install_isr_service()
, вы гарантируете, что:
Обработчик прерывания (ISR), который вы зарегистрируете позже, будет вызываться из IRAM.
Все внутренние структуры и маршрутизация вызова ISR также будут размещены в IRAM.
Это необходимо, потому что во время некоторых прерываний (например, связанных с SPI Flash или DMA) кеш может быть отключён, и код, находящийся во флеш‑памяти, станет недоступным. Если в такой момент произойдёт переход по адресу, лежащему во флеш, произойдёт краш, watchdog reset или undefined behavior.
Для тех кто позабыл или не знал:
IRAM (Internal RAM) — это встроенная быстрая оперативная память внутри ESP32. Она работает быстрее и не зависит от внешней SPI Flash, что критично важно для надёжной и быстрой работы обработчиков прерываний.
gpio_isr_handler_add(gpio_num, isr_handler, args);
Назначение: регистрирует функцию-обработчик прерывания (
isr_handler
) на конкретный GPIO‑пин.-
Параметры:
gpio_num
— номер пина (GPIO_NUM_0
…GPIO_NUM_39
)isr_handler
— указатель на функцию-обработчик прерыванияargs
— произвольный аргумент, который будет передан в функцию-обработчик
gpio_intr_enable(gpio_num);
Назначение: включает прерывания для указанного GPIO-пина.
Параметр
gpio_num
— номер GPIO-пина.
Прерывание не сработает, если вы не вызвали эту функцию (или отключили его ранее).
gpio_intr_disable()
- отключение прерывания.
// функция-обработчик прерывания
void IRAM_ATTR gpio_isr_handler(void* arg) {
}
IRAM_ATTR — атрибут (фактически декоратор), гарантирующий, что этот код попадёт в быстрый IRAM, а не во флеш.
Если у вас возник вопрос (ну вдруг ¯\_(ツ)_/¯): «Зачем нужен IRAM_ATTR
, если уже есть ESP_INTR_FLAG_IRAM
?», то я постараюсь ответить.
Флаг ESP_INTR_FLAG_IRAM
обязывает систему использовать обработчик, лежащий в IRAM — но сам он не перемещает вашу функцию туда! Чтобы компилятор реально положил обработчик прерывания в IRAM, вы должны явно это указать — с помощью IRAM_ATTR
.
Практика
Наша цель — реализовать прерывание от кнопки, которое будет изменять логический уровень светодиода. Для этого немного переработаем задачу Blink_Task
, которую мы писали в самом начале:
uint8_t state = 0; // Переменная, хранящая текущее состояние LED (0 – выключен, 1 – включён)
TaskHandle_t Blink_Handle = NULL; // Дескриптор задачи Blink
// Задача, которая постоянно устанавливает уровень на LED_GPIO согласно state
void Blink_Task(void *arg) {
while (1) {
gpio_set_level(LED_GPIO, state);
}
}
Осталось реализовать сам обработчик прерывания, который будет менять состояние state
. Функция получится довольно компактной:
/*
Обработчик прерывания от кнопки.
Просто инвертирует переменную state.
IRAM_ATTR — атрибут, гарантирующий, что этот код попадёт в быстрый IRAM,
а не во флеш (важно для надёжности ISR).
*/
static void IRAM_ATTR gpio_isr_handler(void *arg) {
state = !state;
}
Теперь важно правильно зарегистрировать обработчик прерывания. Алгоритм действий следующий:
Задаём тип прерывания:
gpio_set_intr_type(gpio_num, intr_type);
Инициализируем общий сервис обработки прерываний:
gpio_install_isr_service(intr_alloc_flags);
Регистрируем обработчик прерывания для нужного пина:
gpio_isr_handler_add(gpio_num, isr_handler, args);
Включаем прерывания для пина:
gpio_intr_enable(gpio_num);
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#define LED_GPIO GPIO_NUM_2 // Пин, к которому подключён светодиод
#define BUTTON_GPIO GPIO_NUM_23 // Пин, к которому подключена кнопка
uint8_t state = 0; // Переменная, хранящая текущее состояние LED (0 – выключен, 1 – включён)
TaskHandle_t Blink_Handle = NULL; // Дескриптор задачи Blink
// Задача, которая постоянно устанавливает уровень на LED_GPIO согласно state
void Blink_Task(void *arg) {
while (1) {
gpio_set_level(LED_GPIO, state);
}
}
/*
Обработчик прерывания от кнопки.
Просто инвертирует переменную state.
IRAM_ATTR — атрибут, гарантирующий, что этот код попадёт в быстрый IRAM,
а не во флеш (важно для надёжности ISR).
*/
static void IRAM_ATTR gpio_isr_handler(void *arg) {
state = !state;
}
void app_main() {
esp_rom_gpio_pad_select_gpio(LED_GPIO); // "Переключение" выбранного физического контакта в режим GPIO
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT); // Настройка LED_GPIO как цифровой вывод
gpio_set_level(LED_GPIO, 0); // сразу гасим
esp_rom_gpio_pad_select_gpio(BUTTON_GPIO); // "Переключение" выбранного физического контакта в режим GPIO
gpio_set_direction(BUTTON_GPIO, GPIO_MODE_INPUT); // Настройка BUTTON_GPIO как входа
gpio_pullup_en(BUTTON_GPIO); // Подтяжка к VCC
gpio_set_intr_type(BUTTON_GPIO, GPIO_INTR_POSEDGE); // Прерывание при нажатии (положительный фронт)
gpio_install_isr_service(ESP_INTR_FLAG_IRAM); // Устанавливаем сервис ISR
gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler, NULL); // Регистрируем функцию-обработчик прерывания
gpio_intr_enable(BUTTON_GPIO); // Включаем прерывания для кнопки
// Создаём задачу, которая будет “мигать” LED в соответствии с state
xTaskCreate(
Blink_Task, // функция‑задача
"BLINK", // имя (для отладки)
2048, // размер стека (в байтах)
NULL, // параметр задачи
5, // приоритет
&Blink_Handle // сюда запишется хэндл задачи
);
}

На самом деле
gpio_intr_enable()
можно не вызывать)Эта функция необязательна, если вы используете
gpio_isr_handler_add()
, так как она внутри себя уже включает прерывание.
Заключение
Если вы заметили неточности, ошибки или у вас есть предложения по улучшению статьи — обязательно отпишитесь в диалоге или в комментариях. Я с радостью подкорректирую материал, чтобы он стал полезнее для всех, кто осваивает ESP-IDF.
В следующих частях мы продолжим знакомство с ESP-IDF: разберём работу с ШИМ (PWM) и аналогово-цифровым преобразователем (ADC).
iushakov
Если нужно читать данные, которые приходят на GPIO вход, например, со скоростью 100 килобит в секунду, лучше использовать таймер и опрашивать пин, или прерывание тоже подойдет?
LAutour
Может лучше приспособить для этого модуль RMT в ESP32? По описанию вроде подходит.
iushakov
Особенность протокола в том что ноль и единица приходят по разным пинам. То есть логическая единица на одном из пинов означает что пришла единица, а на другом что пришел ноль. А если логический ноль на обоих пинах, то это пауза, по которой определяется конец сообщения. А в RMT нет режима приема, который соответствует такой логике