- PVSM.RU - https://www.pvsm.ru -

Программирование ESP32 с ESP-IDF в среде platformio #3

Привет!

Это четвертая статья из цикла по ESP-IDF. Как и обещал, сегодня мы рассмотрим мьютексы и семафоры на простых (и не очень) примерах.

Программирование ESP32 с ESP-IDF в среде platformio #3 - 1

А зачем мне они вообще нужны?...

Попробую ответить на вопрос, который может возникнуть у многих - а зачем мне вообще изучать семафоры и мьютексы мне вполне для синхронизации хватит очередей?

Легенда:

Смоделируем ситуацию: вы — стажёр в офисе, у вас есть коллега-стажёр Виталя и начальник Колян. В вашем уютном офисе есть почтовый ящик (очередь): кто-то (producer) бросает туда письмо (данные или команду), а кто‑то другой (consumer) его забирает и обрабатывает. Итог:

  • Почтовый ящик (единственный) на офис, куда стажёры кладут письма. [очередь]

  • Два стажёра (Вы и Виталя), которые готовят письма и носят их в ящик. [producer]

  • Начальник (Колян), который иногда проверяет ящик и забирает письма, чтобы их отправить. [consumer]

1. Мьютекс: «Ключ от почтового ящика»

Почтовый ящик — это механический ящик с замком. У него есть один ключ, и пока кто‑то держит ключ, другие не умеют открыть крышку.

  • Когда стажёр (Вы) подходит к ящику, он берёт ключ (вызывает xSemaphoreTake(mutex)), открывает крышку, кладёт письмо, закрывает и возвращает ключ (xSemaphoreGive(mutex)).

  • Если в этот момент второй стажёр (Виталя) пытается открыть ящик, ему придётся ждать, пока Вы вернёте ключ.

Зачем?

Без мьютекса оба стажёра яростно пытались бы залезть в этот ящик и как можно быстрее сбросить туда свои письма. Ящик мог бы зависнуть в полузакрытом состоянии. Мьютекс гарантирует, что Виталя дождётся, когда вы отдадите ему ключ, и не будет пытаться опередить вас.

Кроме того, если у Коляна (начальника) очень важная задача по проверке ящика (высокий приоритет), а тут два стажёра «тупят», весь бизнес накрылся бы медным тазом. Здесь вступает ещё одна фича — priority inheritance. Она повысит приоритет стажёра, держащего ключ (мьютекс), чтобы он быстрее освободил ресурс, плюс его не перебил второй стажёр, и начальник (высокоприоритетная задача) не стоял в ожидании.

2. Бинарный семафор: «Сигнал о письме»

Ситуация. После того как стажёр кладёт письмо в ящик, он звонит начальнику (Коляну), чтобы он проверил ящик.

Зачем?

Семафор здесь — простой способ передать событие “готово письмо” из стажёра начальнику. Без семафора Вика бы бесконечно опрашивала ящик (polling), тратя энергию и время.

3. Счётный семафор: «Ограничение по ячейкам»

Ситуация. У вас есть только небольшой ящик, в который может поместиться не более трёх писем. Вы решили, что проверять ящик будет начальник только тогда, когда в нём накопится два и более писем. По факту это работа с обычным счетчиком.


Двоичные семафоры FreeRTOS

Двоичный семафор [1] — это семафор, максимальный счетчик которого равен 1, отсюда и название «двоичный». Задача может «взять» семафор, только если он доступен, а семафор доступен, только если его счетчик равен 1.

в FreeRTOS двоичный семафор реализован как очередь длиной в 1 элемент, размером в 0 байт, где важен только факт — «есть разрешение» или «нет»

  • Он может быть только полон (после give) или пуст (после take), как очередь ёмкостью один.

  • Все, что вы делаете — либо give, либо take.

  • Данные не передаются (размер элемента = 0), передаётся лишь сам факт события: дали — значит событие произошло, взяли — значит событие обработано.

Функции API семафоров позволяют задать время блокировки. Время блокировки определяет максимальное количество тактов, на которые задача должна перейти в состояние «Блокировано» при попытке «занять» семафор, если семафор недоступен немедленно. Если на одном семафоре заблокировано несколько задач, то при следующем освобождении семафора разблокируется задача с наивысшим приоритетом.

Основные API двоичного семафора

Создание двоичного семафора

SemaphoreHandle_t sem = xSemaphoreCreateBinary();

— выделяет под семафор очередь ёмкостью в один «слот» (элемент размером 0).

Отдача (Give)

Из задачи:

xSemaphoreGive(sem);

Из ISR:

BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(sem, &woken);
portYIELD_FROM_ISR(woken);
Пояснение

Когда вы даёте семафор из прерывания, нужно учесть, что может быть разблокирована задача с более высоким приоритетом, и тогда стоит сразу же переключиться на неё. Вот что делают эти три строки:

  1. BaseType_t woken = pdFALSE;
    — заводим переменную-флаг woken, изначально равную pdFALSE. Она будет сигнализировать, разблокировал ли семафор задачу, у которой приоритет выше, чем у текущей.

  2. xSemaphoreGiveFromISR(sem, &woken);
    — даём семафор из ISR. Если в этот момент семафор «был взят» и теперь задача, ожидавшая его в xSemaphoreTake(), разблокируется, то в woken запишут pdTRUE. То есть ISR сообщает, что есть задача, которой надо «отдать» управление.

  3. portYIELD_FROM_ISR(woken);
    — смотрим флаг woken. Если он pdTRUE, макрос инициирует немедленный переключатель контекста после выхода из ISR, чтобы сразу запустить только что разблокированную, более приоритетную задачу. Если же woken == pdFALSE, переключения не происходит — текущая задача продолжит выполняться дальше.

Взятие (Take)

if (xSemaphoreTake(sem, portMAX_DELAY) == pdTRUE) {
    // семафор успешно взят
}

— блокирует текущую задачу до тех пор, пока семафор не станет «полным» (Give), после чего возвращает pdTRUE; при этом семафор снова переходит в пустое состояние и требует нового Give перед следующим Take.

Пример

Рассмотрим пример, когда задача используется для обслуживания периферийного устройства. Опрос периферийного устройства будет пустой тратой ресурсов и помешает выполнению других задач. Поэтому предпочтительно, чтобы задача проводила большую часть времени в заблокированном состоянии (позволяя другим задачам выполняться) и выполнялась только тогда, когда ей действительно нужно что-то сделать. Это достигается с помощью двоичного семафора: задача блокируется при попытке «захватить» семафор. Затем для периферийного устройства пишется процедура прерывания, которая просто «выдаёт» семафор, когда периферийному устройству требуется обслуживание.

Немного перепишем наше прерывание [2], которое мы написали пару уроков назад:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/gpio.h"
#include "esp_attr.h"
#include "esp_log.h"

#define LED_GPIO    GPIO_NUM_2     // Пин, к которому подключён светодиод
#define BUTTON_GPIO GPIO_NUM_23    // Пин, к которому подключена кнопка

TaskHandle_t Blink_Handle = NULL;  // Дескриптор задачи Blink

static const char *TAG = "binary_sem";

// Бинарный семафор для синхронизации кнопка→задача
static SemaphoreHandle_t button_sem = NULL;

// Задача, которая постоянно устанавливает уровень на LED_GPIO согласно state
void Blink_Task(void *arg) {
    while (1) {
        if(xSemaphoreTake(button_sem, portMAX_DELAY)){
            ESP_LOGI(TAG, "Button pressed!");
            // Включаем LED на 500 мс
            gpio_set_level(LED_GPIO, 1);
            vTaskDelay(pdMS_TO_TICKS(500));
            gpio_set_level(LED_GPIO, 0);
        }
    }
}

/*
    Обработчик прерывания от кнопки.
    Просто инвертирует переменную state.
    IRAM_ATTR — атрибут, гарантирующий, что этот код попадёт в быстрый IRAM,
    а не во флеш (важно для надёжности ISR).
*/
static void IRAM_ATTR gpio_isr_handler(void *arg) {
    BaseType_t woken = pdFALSE;
    xSemaphoreGiveFromISR(button_sem, &woken);
    portYIELD_FROM_ISR(woken);
}

void app_main() {
    // Создаём бинарный семафор (он будет пустым)
    button_sem = xSemaphoreCreateBinary();
    if (button_sem == NULL) {
        ESP_LOGE(TAG, "Failed to create semaphore");
        return;
    }
    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);  // Включаем прерывания для кнопки

    // Создаём задачу “мигания” по семафору
    if (xTaskCreate(Blink_Task, "BLINK", 2048, NULL, 5, &Blink_Handle) != pdPASS) {
        ESP_LOGE(TAG, "Failed to create Blink_Task");
    }
}

Создаем

// Бинарный семафор для синхронизации кнопка→задача
static SemaphoreHandle_t button_sem = NULL;

button_sem = xSemaphoreCreateBinary();
if (button_sem == NULL) {
    ESP_LOGE(TAG, "Failed to create semaphore");
    return;
}

Даем

// ISR: при спаде кнопки «даём» семафор
static void IRAM_ATTR button_isr(void *arg) {
    BaseType_t woken = pdFALSE;
    xSemaphoreGiveFromISR(button_sem, &woken);
    portYIELD_FROM_ISR(woken);
}

Получаем

// Блокируемся, пока семафор не «дадут»
if (xSemaphoreTake(button_sem, portMAX_DELAY) == pdTRUE) {
    ESP_LOGI(TAG, "Button pressed!");
    // Включаем LED на 500 мс
    gpio_set_level(LED_GPIO, 1);
    vTaskDelay(pdMS_TO_TICKS(500));
    gpio_set_level(LED_GPIO, 0);
}

FreeRTOS Подсчет семафоров

Подобно тому, как двоичные семафоры можно рассматривать как очереди длиной один элемент, счётные семафоры можно рассматривать как очереди длиной больше одного элемента.

Счётный семафор расширяет идею бинарного: вместо одного флага он хранит счетчик от 0 до заданного максимума. Это полезно, когда нужно накопить несколько событий или разрешений и обработать их по одному.

Основные API счетного семафора

Взятие (xSemaphoreTake) и отдача (xSemaphoreGive / xSemaphoreGiveFromISR) у двоичного и счётного семафоров абсолютно одинаковы по API — единственное отличие в том, как вы их создаёте.

Создание счетного семафора

// uxMaxCount = 5, uxInitialCount = 0
SemaphoreHandle_t sem = xSemaphoreCreateCounting(5, 0);

— при создании вы указываете максимальное (uxMaxCount) и начальное (uxInitialCount) значение счётчика:

Пример

Сделаем мини‑игру: если мы успеем нажать на кнопку более трёх раз за 5 секунд, то увидим три быстрых мигания, а если нет — одно.

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/gpio.h"
#include "esp_attr.h"
#include "esp_log.h"

#define LED_GPIO    GPIO_NUM_2     // Пин, к которому подключён светодиод
#define BUTTON_GPIO GPIO_NUM_23    // Пин, к которому подключена кнопка

TaskHandle_t Blink_Handle = NULL;  // Дескриптор задачи Blink

static const char *TAG = "counting_sem";

// Семафор
static SemaphoreHandle_t button_sem = NULL;

// Задача
void Blink_Task(void *arg) {
    int count;
    while (1) {
        // Ждём 5 секунд
        vTaskDelay(pdMS_TO_TICKS(5000));

        // Считаем накопленные нажатия
        count = 0;
        while(xSemaphoreTake(button_sem, 0) == pdTRUE){
            count++;
        }
        ESP_LOGI(TAG, "Button pressed %d times in last 5s", count);

            if (count >= 3) {
                // 3 быстрых моргания
                for (int i = 0; i < 3; i++) {
                    gpio_set_level(LED_GPIO, 1);
                    vTaskDelay(pdMS_TO_TICKS(200));
                    gpio_set_level(LED_GPIO, 0);
                    vTaskDelay(pdMS_TO_TICKS(200));
                }
            } else {
                // Одно долгое
                gpio_set_level(LED_GPIO, 1);
                vTaskDelay(pdMS_TO_TICKS(1000));
                gpio_set_level(LED_GPIO, 0);
            }
    }
}

// ISR: при каждом нажатии ++счётчик
static void IRAM_ATTR gpio_isr_handler(void *arg) {
    BaseType_t woken = pdFALSE;
    xSemaphoreGiveFromISR(button_sem, &woken);
    portYIELD_FROM_ISR(woken);
}

void app_main() {
    // Создаём счётный семафор (максимум 10, изначально 0)
    button_sem = xSemaphoreCreateCounting(10, 0);
    if (button_sem == NULL) {
        ESP_LOGE(TAG, "Failed to create semaphore");
        return;
    }
    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);  // Включаем прерывания для кнопки

    // Создаём задачу “мигания” по семафору
    if (xTaskCreate(Blink_Task, "BLINK", 2048, NULL, 5, &Blink_Handle) != pdPASS) {
        ESP_LOGE(TAG, "Failed to create Blink_Task");
    }
}
Если успели нажать > 3 раз за 5 секунд

Если успели нажать > 3 раз за 5 секунд
Если не успели нажать > 3 раз за 5 секунд

Если не успели нажать > 3 раз за 5 секунд

Мьютексы FreeRTOS

Двоичные семафоры и мьютексы очень похожи, но имеют некоторые тонкие различия: мьютексы включают механизм наследования приоритетов (priority inheritance), а двоичные семафоры — нет. Мьютексы отлично подходят для взаимного исключения.

Немного подробно про priority inheritance

Когда несколько задач разного приоритета борются за один и тот же ресурс (мьютекс), может возникнуть классическая проблема priority inversion:

  • Задача L (Low‑prio) берёт мьютекс и входит в критическую секцию.

  • Задача H (High‑prio) просыпается и пытается взять тот же мьютекс → блокируется, потому что L ещё не вернула мьютекс.

  • Задача M (Med‑prio) с приоритетом между L и H может встать на выполнение и «перекрыть» L, потому что H в очереди ждёт мьютекс и не может выполнить свою работу и вернуть управление.

В результате H простаивает, а L не получает CPU, чтобы освободить мьютекс — и весь алгоритм может «зависнуть» на задаче среднего приоритета, даже если H гораздо важнее.

Как работает priority inheritance

FreeRTOS при захвате мьютекса автоматически сравнивает приоритеты владельца и ожидающей задачи:

  • Если владелец мьютекса (L) имеет приоритет ниже, чем ожидающая задача (H), то у L временно повышается приоритет до уровня H.

  • Это даёт L право продолжить выполняться даже в присутствии средних задач M, чтобы как можно скорее выйти из критической секции и вернуть мьютекс.

  • Как только L делает xSemaphoreGive(mutex), его приоритет возвращается к исходному значению, и планировщик снова сможет запустить H (или M, если H уже выполнена).


Спасибо, что дочитали до конца! Прошу прощения за долгое затишье — в ближайшее время выйдет новая статья, в которой мы продолжим изучать esp-idf. Буду рад вашим вопросам и пожеланиям в комментариях — пишите, о чём вам было бы интересно узнать в следующих материалах!

Автор: nikitatm333

Источник [3]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/esp/425687

Ссылки в тексте:

[1] Двоичный семафор: https://www.freertos.org/Documentation/02-Kernel/02-Kernel-features/02-Queues-mutexes-and-semaphores/02-Binary-semaphores#:~:text=Updated%20Jul%202025-,FreeRTOS%20binary%20semaphores,-%5BSee%20also%20Blocking

[2] прерывание: https://habr.com/ru/articles/919362/#:~:text=%D0%BF%D0%BE%D0%BC%D0%BE%D1%89%D1%8C%D1%8E%20IRAM_ATTR.-,%D0%9F%D1%80%D0%B0%D0%BA%D1%82%D0%B8%D0%BA%D0%B0,-%D0%9D%D0%B0%D1%88%D0%B0%20%D1%86%D0%B5%D0%BB%D1%8C%20%E2%80%94%20%D1%80%D0%B5%D0%B0%D0%BB%D0%B8%D0%B7%D0%BE%D0%B2%D0%B0%D1%82%D1%8C

[3] Источник: https://habr.com/ru/articles/923128/?utm_campaign=923128&utm_source=habrahabr&utm_medium=rss