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

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

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

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

Легенда:

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

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

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

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

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

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

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

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

Зачем?

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

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

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

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

Зачем?

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

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

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


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

Двоичный семафор — это семафор, максимальный счетчик которого равен 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.

Пример

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

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

#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. Буду рад вашим вопросам и пожеланиям в комментариях — пишите, о чём вам было бы интересно узнать в следующих материалах!

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