Привет, Хабр!

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


Стек

Обратимся к официальной документации:

FreeRTOS

The number of words (not bytes!) to allocate for use as the task’s stack. For example, if the stack is 16‑bits wide and uxStackDepth is 100, then 200 bytes will be allocated.

В FreeRTOS мы работаем в словах.

ESP-IDF

"usStackDepth – The size of the task stack specified as the number of bytes. Note that this differs from vanilla FreeRTOS."

В 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

Функция

Описание

esp_rom_gpio_pad_select_gpio(gpio_num)

Привязывает физический pad к модулю GPIO, отключая все альтернативные функции контакта.

gpio_reset_pin(gpio_num)

Сбрасывает конфигурацию пина к значению по умолчанию (вызывает esp_rom_gpio_pad_select_gpio).

gpio_set_direction(gpio_num, mode)

Устанавливает направление: GPIO_MODE_INPUT, GPIO_MODE_OUTPUTи др.

gpio_set_level(gpio_num, level)

Устанавливает логический уровень (0 или 1) на выходном пине.

gpio_get_level(gpio_num)

Читает текущее логическое состояние (0/1) на входном или выходном пине.

gpio_config(&gpio_config_t)

Упрощённая групповая конфигурация: направление, подтяжки, прерывания, маска пинов.

gpio_pullup_en(gpio_num) / gpio_pullup_dis(gpio_num)

Включает или отключает внутренний pull‑up резистор.

gpio_pulldown_en(gpio_num) / gpio_pulldown_dis(gpio_num)

Включает или отключает внутренний 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   // указатель, в который запишут дескриптор задачи
    );
}
Blink
Blink

Прерывания (ISR)

Прерывание (Interrupt) — одна из базовых концепций вычислительной техники, которая заключается в том, что при наступлении какого-либо события происходит передача управления специальной процедуре, называемой обработчиком прерываний (ISR).

Прерывания бывают двух типов:

  • Аппаратные — генерируются железом (например, периферийными модулями GPIO, таймерами, UART);

  • Программные — инициируются выполнением в коде специальной инструкции, позволяющей «искусственно» вызвать обработчик.

Кроме того, в ESP32 реализован механизм межпроцессорного вызова (IPC), который имеет два режима работы:

  1. Task Context — колбэк выполняется в контексте специальной IPC‑таски, что позволяет использовать любые функции FreeRTOS и ESP‑IDF.

  2. 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_0GPIO_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 - обработчики прерываний будут загружены в IRAM

    • ESP_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_0GPIO_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;
}

Теперь важно правильно зарегистрировать обработчик прерывания. Алгоритм действий следующий:

  1. Задаём тип прерывания:
    gpio_set_intr_type(gpio_num, intr_type);

  2. Инициализируем общий сервис обработки прерываний:
    gpio_install_isr_service(intr_alloc_flags);

  3. Регистрируем обработчик прерывания для нужного пина:
    gpio_isr_handler_add(gpio_num, isr_handler, args);

  4. Включаем прерывания для пина:
    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     // сюда запишется хэндл задачи
    );
}
ISR
ISR

На самом деле gpio_intr_enable() можно не вызывать)

Эта функция необязательна, если вы используете gpio_isr_handler_add(), так как она внутри себя уже включает прерывание.

Заключение

Если вы заметили неточности, ошибки или у вас есть предложения по улучшению статьи — обязательно отпишитесь в диалоге или в комментариях. Я с радостью подкорректирую материал, чтобы он стал полезнее для всех, кто осваивает ESP-IDF.

В следующих частях мы продолжим знакомство с ESP-IDF: разберём работу с ШИМ (PWM) и аналогово-цифровым преобразователем (ADC).

Комментарии (3)


  1. iushakov
    19.06.2025 04:32

    Если нужно читать данные, которые приходят на GPIO вход, например, со скоростью 100 килобит в секунду, лучше использовать таймер и опрашивать пин, или прерывание тоже подойдет?


    1. LAutour
      19.06.2025 04:32

      Может лучше приспособить для этого модуль RMT в ESP32? По описанию вроде подходит.


      1. iushakov
        19.06.2025 04:32

        Особенность протокола в том что ноль и единица приходят по разным пинам. То есть логическая единица на одном из пинов означает что пришла единица, а на другом что пришел ноль. А если логический ноль на обоих пинах, то это пауза, по которой определяется конец сообщения. А в RMT нет режима приема, который соответствует такой логике