В данной статье мы научимся использовать FreeRTOS с ESP32, используя фреймворк ESP-IDF. Преимущество ESP-IDF в том, что для него уже существует порт FreeRTOS для ESP32. Поэтому нам не нужно устанавливать дополнительные библиотеки и драйверы. ESP-IDF FreeRTOS основан на Vanilla FreeRTOS v10.4.3
Мы рассмотрим создание и удаление задач FreeRTOS, установку для них приоритета, прерывание задач и т.д. При использовании ESP-IDF большая часть кода использует функциональные возможности стандартной FreeRTOS версии 10.4.3, поэтому это важный аспект при программировании. Давайте начнем с того, как создавать и удалять задачи, а затем перейдем к задачам прерываний FreeRTOS.
Также на нашем сайте вы можете прочитать статью про работу с FreeRTOS и памятью в ESP32-S3 с помощью ESP-IDF - возможно, некоторые аспекты в ней рассмотрены более понятным для вас языком.
Прежде чем продолжить, убедитесь, что у вас установлена последняя версия VS Code и настроено расширение ESP-IDF:
- Установка ESP32 ESP-IDF на Windows и его интеграция с Visual Studio Code
- Установка ESP32 ESP-IDF на Linux Ubuntu
Также на нашем сайте вы можете прочитать статью про начало работы с ESP32 с использованием ESP-IDF.
Введение в задачи FreeRTOS
В приложениях реального времени (RTOS) приложение обычно состоит из набора независимых задач или подпрограмм. На одноядерном микроконтроллере одновременно может выполняться только одна задача. С другой стороны, на двухъядерном процессоре, таком как ESP32, две задачи могут выполняться одновременно, если они не зависят друг от друга. Планировщик FreeRTOS распределяет эти задачи на основе их приоритета, временного интервала и времени выполнения.
Планировщик FreeRTOS часто запускает и останавливает каждую задачу по мере выполнения приложения. Задача не имеет представления об активности планировщика RTOS, поэтому именно планировщик FreeRTOS отвечает за подтверждение того, что контекст процессора (значения регистров, содержимое стека и т. д.) при переключении задачи точно такой же, как и при её выгрузке. Для этой цели каждой задаче предоставляется собственный стек. При выгрузке задачи контекст выполнения сохраняется в стеке этой задачи, чтобы его можно было точно восстановить при последующей выгрузке той же задачи. Дополнительную информацию о задачах FreeRTOS можно найти здесь.
API FreeRTOS предоставляют возможности для планирования, создания, удаления, приостановки, возобновления задач, а также установки приоритета задач. Примеры использования каждой из этих функций мы рассмотрим на протяжении данной статьи.
Пример создания задачи FreeRTOS для ESP32 с помощью ESP-IDF
Откройте VS Code и создайте новый проект ESP-IDF. Теперь перейдите к файлу main.c. Здесь мы определим функции и программный код.
Для начала подключите заголовочные файлы FreeRTOS. Библиотека freertos/task.h позволит нам использовать задачи FreeRTOS.
|
1 2 |
#include "freertos/FreeRTOS.h" #include "freertos/task.h" |
xTaskCreate() для первой задачи
Внутри функции main() мы будем создавать задачи. Для создания задачи используйте функцию xTaskCreate(). Эта функция принимает несколько аргументов.
- Первый аргумент — это имя функции. В нашем случае мы установили его значение равным Demo_Task.
- Второй аргумент — это имя задачи, используемое в описательных целях. Обычно это необходимо для отладки и получения дескриптора задачи. Оно указывается в двойных кавычках, поэтому в нашем случае мы задаем второй аргумент как «Demo_Task».
- Третий аргумент задаёт размер стека задачи. Он указывает объём памяти, который мы хотим зарезервировать для конкретной задачи. Для повышения эффективности пользователь может установить размер стека после создания задачи и получения её размера. Мы установили его равным '4096'.
- Четвертый аргумент — это параметр. Это значение, которое передается в качестве параметра создаваемой задаче. Его можно использовать для указания переменной, которая используется в основной функции и значение которой необходимо добавить в задачу. Другой способ реализации — создать глобальную переменную. В нашем случае мы установим этот аргумент равным NULL, что указывает на то, что мы не используем это свойство.
- Пятый аргумент — это приоритет задачи. Мы установили его равным «10».
- Последний аргумент — это дескриптор, используемый для изменения функции задачи, например, приостановки, удаления, возобновления, получения или установки конфигурации задачи. Он работает как указатель, поэтому с ним используется символ амперсанда. Он необязателен, поэтому мы также можем установить его в NULL. В нашем случае мы установили его в &myTaskHandle.
|
1 |
xTaskCreate(Demo_Task, "Demo_Task", 4096, NULL, 10, &myTaskHandle); |
Дескриптор задачи
Дескриптор задачи будет определен как глобальная переменная вне основной функции. Мы установили для нее значение NULL.
|
1 |
TaskHandle_t myTaskHandle = NULL; |
Функция первой задачи
Теперь создадим определение функции Demo_Task. Внутри бесконечного цикла while мы будем выводить сообщение на последовательный терминал каждую секунду. Необходимо использовать бесконечный цикл, иначе микроконтроллер будет постоянно перезагружаться.
|
1 2 3 4 5 6 7 |
void Demo_Task(void *arg) { while(1){ printf("Demo_Task printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } |
xTaskCreatePinnedToCore() для второй задачи
Аналогично, давайте создадим еще одну задачу Demo_Task2 и определим ее функцию, чтобы мы могли видеть, как обе задачи выполняются одновременно. Для этого мы будем использовать функцию xTaskCreatePinnedToCore(). Она позволит пользователю выбрать, какое ядро ESP32 (ядро 0 или ядро 1) будет выполнять конкретную задачу. Давайте выберем ядро 1 для второй задачи. Функция xTaskCreatePinnedToCore() принимает один дополнительный аргумент по сравнению с функцией xTaskCreate(). Это седьмой аргумент, указывающий ядро, которое будет использоваться для создаваемой задачи. Пользователь может указать в качестве аргумента «0» или «1».
Для второй задачи выберем core1, это будет Demo_Task2.
|
1 |
xTaskCreatePinnedToCore(Demo_Task2, "Demo_Task2", 4096, NULL,10, &myTaskHandle2, 1); |
Аналогичным образом, мы определяем идентификатор задачи для второй задачи, как показано ниже:
|
1 |
TaskHandle_t myTaskHandle2 = NULL; |
Это определение функции для данной задачи.
|
1 2 3 4 5 6 7 |
void Demo_Task2(void *arg) { while(1){ printf("Demo_Task2 printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } |
Полный код и демонстрация
Это полный код, который мы используем. Здесь мы создали две задачи, которые будут выполняться одновременно. Demo_Task1 выводит в последовательный терминал сообщение «Demo_Task printing..», а Demo_Task2 — сообщение «Demo_Task printing..».
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> TaskHandle_t myTaskHandle = NULL; TaskHandle_t myTaskHandle2 = NULL; void Demo_Task(void *arg) { while(1){ printf("Demo_Task printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } void Demo_Task2(void *arg) { while(1){ printf("Demo_Task2 printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } void app_main(void) { xTaskCreate(Demo_Task, "Demo_Task", 4096, NULL, 10, &myTaskHandle); xTaskCreatePinnedToCore(Demo_Task2, "Demo_Task2", 4096, NULL,10, &myTaskHandle2, 1); } |
Для прошивки микросхемы введите следующую команду в последовательном терминале. Не забудьте заменить COM-порт на тот, через который подключена ваша плата.
|
1 |
idf.py -p COMX flash monitor |
Последовательный терминал будет отображать сообщения по мере того, как наши задачи будут выполняться бесконечно.
Пример удаления задачи FreeRTOS в ESP32 с помощью ESP-IDF
Теперь давайте изменим приведенный выше код, чтобы показать вам функциональность удаления задачи.
Посмотрите на модифицированную функцию Demo_Task2() ниже. Вместо бесконечного цикла while мы использовали цикл for, который будет выводить сообщение каждую секунду. Этот цикл завершится через 5 секунд, после чего ESP32 перезагрузится.
|
1 2 3 4 5 6 7 |
void Demo_Task2(void *arg) { for(int i=0;i<5;i++){ printf("Demo_Task2 printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } |
Удалим эту задачу через 5 секунд, чтобы ESP32 не перезагрузился. Для удаления задачи используем функцию vTaskDelete() и указываем в качестве параметра дескриптор удаляемой задачи. Если передать NULL, то будет удалена вызывающая задача.
|
1 |
vTaskDelete(myTaskHandle2) |
Внутри функции Demo_Task() сначала создадим целое число 'count', которое будет хранить количество секунд. Когда count достигнет 3, мы вызовем функцию vTaskDelete() и укажем в качестве параметра дескриптор Demo_Task2. Эта функция будет вызвана внутри функции Demo_Task(), поэтому первая задача удалит вторую задачу через 5 секунд. Таким образом, ESP32 не перезагрузится, и первая задача продолжит выполняться.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
void Demo_Task(void *arg) { int count = 0; while(1){ count++; printf("Demo_Task printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); if (count == 3) { vTaskDelete(myTaskHandle2); printf("Demo_Task2 is deleted!\n"); } } } |
Полный код и демонстрация
Ниже вы можете ознакомиться с полным кодом, в котором мы демонстрируем функцию vDeleteTask().
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> TaskHandle_t myTaskHandle = NULL; TaskHandle_t myTaskHandle2 = NULL; void Demo_Task(void *arg) { int count = 0; while(1){ count++; printf("Demo_Task printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); if (count == 5) { vTaskDelete(myTaskHandle2); printf("Demo_Task2 is deleted!\n"); } } } void Demo_Task2(void *arg) { for(int i=0;i<5;i++){ printf("Demo_Task2 printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } void app_main(void) { xTaskCreate(Demo_Task, "Demo_Task", 4096, NULL, 10, &myTaskHandle); xTaskCreatePinnedToCore(Demo_Task2, "Demo_Task2", 4096, NULL,10, &myTaskHandle2, 1); } |
Для прошивки микросхемы введите следующую команду в последовательном терминале. Не забудьте заменить COM-порт на тот, через который подключена ваша плата.
|
1 |
idf.py -p COMX flash monitor |
Ниже показан вывод последовательного терминала. Обратите внимание, что задача Demo_Task2 выполнялась в течение 5 секунд, а затем была удалена. Задача Demo_Task продолжает выполняться бесконечно без перезагрузки ESP32.
Пример приостановки и возобновления задачи FreeRTOS в ESP32 с помощью ESP-IDF
Для приостановки задачи используется функция vTaskSuspend(), в качестве параметра которой указывается дескриптор приостанавливаемой задачи. Если передать NULL, то это приведет к приостановке вызывающей задачи.
|
1 |
vTaskSuspend(myTaskHandle2); |
Для возобновления задачи используется функция vTaskResume(), в качестве параметра которой указывается дескриптор задачи, которую необходимо возобновить. Если передать NULL, то будет возобновлена вызывающая задача.
|
1 |
vTaskResume(myTaskHandle2); |
Изменения в коде
Во-первых, мы изменим приведенный выше код, чтобы увеличить лимит выполнения задачи Demo_Task2 до 10 секунд.
|
1 2 3 4 5 6 7 |
void Demo_Task2(void *arg) { for(int i=0;i<10;i++){ printf("Demo_Task2 printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } |
Затем, внутри функции Demo_Task(), мы приостановим выполнение Demo_Task2 через 5 секунд, а затем возобновим его через 3 секунды. Через 10 секунд он будет удален, чтобы предотвратить перезагрузку ESP32.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
void Demo_Task(void *arg) { int count = 0; while(1){ count++; printf("Demo_Task printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); if (count == 5) { vTaskSuspend(myTaskHandle2); printf("Demo_Task2 is suspended!\n"); } if (count == 8) { vTaskDelete(myTaskHandle2); printf("Demo_Task2 is resumed!\n"); } if (count == 10) { vTaskDelete(myTaskHandle2); printf("Demo_Task2 is deleted!\n"); } } } |
Полный код и демонстрация
Ниже вы можете ознакомиться с полным кодом, в котором мы демонстрируем функции vSuspendTask(), vResumeTask() и vDeleteTask().
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> TaskHandle_t myTaskHandle = NULL; TaskHandle_t myTaskHandle2 = NULL; void Demo_Task(void *arg) { int count = 0; while(1){ count++; printf("Demo_Task printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); if (count == 5) { vTaskSuspend(myTaskHandle2); printf("Demo_Task2 is suspended!\n"); } if (count == 8) { vTaskResume(myTaskHandle2); printf("Demo_Task2 is resumed!\n"); } if (count == 10) { vTaskDelete(myTaskHandle2); printf("Demo_Task2 is deleted!\n"); } } } void Demo_Task2(void *arg) { for(int i=0;i<10;i++){ printf("Demo_Task2 printing..\n"); vTaskDelay(1000/ portTICK_RATE_MS); } } void app_main(void) { xTaskCreate(Demo_Task, "Demo_Task", 4096, NULL, 10, &myTaskHandle); xTaskCreatePinnedToCore(Demo_Task2, "Demo_Task2", 4096, NULL,10, &myTaskHandle2, 1); } |
Для прошивки микросхемы введите следующую команду в последовательном терминале. Не забудьте заменить COM-порт на тот, через который подключена ваша плата.
|
1 |
idf.py -p COMX flash monitor |
Ниже показан вывод последовательного терминала. Обратите внимание, что задача Demo_Task2 выполнялась в течение 5 секунд, а затем была приостановлена на три секунды. После истечения этих трех секунд она возобновилась. Через 10 секунд задача Demo_Task2 была удалена, но Demo_Task продолжает работать бесконечно без перезагрузки ESP32.
Расчет количества тактов
Для расчета времени работы приложения мы используем функцию xTaskGetTickCount(). Эта функция возвращает количество тактов с момента запуска приложения.
Использование прерываний задач FreeRTOS в ESP32 с помощью ESP-IDF
Теперь давайте рассмотрим, как использовать прерывания FreeRTOS. Для демонстрации мы будем использовать прерывание для переключения светодиода, подключенного к плате ESP32, с помощью кнопки.
ESP-IDF предоставляет библиотеку gpio.h, которая содержит полезные функции для управления цифровыми входными и выходными контактами ESP32 и для генерации прерываний. В этом проекте нам нужно успешно настроить прерывание для переключения состояния светодиода.
Первым шагом является подключение заголовочного файла:
|
1 |
#include "driver/gpio.h" |
Для установки типа срабатывания прерывания GPIO мы используем функцию gpio_set_intr_type(), которая принимает два аргумента. Первый аргумент — это номер GPIO, тип срабатывания которого мы хотим установить. Второй аргумент — это тип прерывания.
|
1 |
gpio_set_intr_type(gpio_num_t gpio_num, gpio_int_type_t intr_type) |
Далее, для установки службы обработчика прерываний GPIO драйвера мы используем функцию gpio_install_isr_service(). Эта служба обработчика позволяет создавать обработчики прерываний GPIO для каждого контакта. Функция принимает один аргумент — флаг, отвечающий за выделение прерывания.
|
1 |
gpio_install_isr_service(int intr_alloc_flags) |
Для добавления обработчика прерывания для конкретного вывода GPIO используется функция gpio_isr_handler_add(). Она принимает три аргумента. Первый аргумент — номер вывода GPIO, второй — обработчик прерывания для этого конкретного вывода, а последний — параметр для обработчика прерывания.
|
1 |
gpio_isr_handler_add(gpio_num_t gpio_num, gpio_isr_t isr_handler, void *args) |
Схема проекта
Теперь давайте продемонстрируем, как генерировать прерывания на плате ESP32, используя кнопку для переключения светодиода. Кнопка будет подключена к выводу прерывания ESP32 и настроена как вход. Светодиод будет настроен как цифровой выход. Светодиод будет переключаться по каждому положительному фронту (от 0 до 1).
Для работы требуются следующие компоненты:
- Плата разработки ESP32.
- Кнопка.
- Один светодиод 5 мм.
- Один резистор на 220 Ом.
- Один резистор 10 кОм.
- Макетная плата.
- Соединительные провода.
Соберите схему, как показано ниже:

Когда кнопка не нажата, на выводе GPIO33 будет низкий логический уровень, или же состояние кнопки будет низким, а когда кнопка нажата, на выводе GPIO33 будет высокий логический уровень. Это означает, что при нажатии кнопки возникает восходящий фронт сигнала. Мы можем обнаружить этот восходящий фронт с помощью выводов прерывания ESP32.
Примечание: контакты GPIO6, GPIO7, GPIO8, GPIO9, GPIO10 и GPIO11 нельзя использовать в качестве выводов прерываний ESP32.
Следуйте приведенной ниже схеме, чтобы выбрать контакт GPIO ESP32, который можно использовать для подключения кнопки.
Переключение светодиода на ESP32 с помощью кнопки и прерываний FreeRTOS
Откройте VS Code и создайте новый проект ESP-IDF. Теперь перейдите к файлу main.c. Здесь мы определим функции и программный код.
Код прерывания ESP32
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include "driver/gpio.h" #define ESP_INR_FLAG_DEFAULT 0 #define LED_PIN 27 #define PUSH_BUTTON_PIN 33 TaskHandle_t ISR = NULL; void IRAM_ATTR button_isr_handler(void *arg){ xTaskResumeFromISR(ISR); } void interrupt_task(void *arg){ bool led_status = false; while(1){ vTaskSuspend(NULL); led_status = !led_status; gpio_set_level(LED_PIN, led_status); printf("Button pressed!\n"); } } void app_main(void) { gpio_pad_select_gpio(PUSH_BUTTON_PIN); gpio_pad_select_gpio(LED_PIN); gpio_set_direction(PUSH_BUTTON_PIN, GPIO_MODE_INPUT); gpio_set_direction(LED_PIN ,GPIO_MODE_OUTPUT); gpio_set_intr_type(PUSH_BUTTON_PIN, GPIO_INTR_POSEDGE); gpio_install_isr_service(ESP_INR_FLAG_DEFAULT); gpio_isr_handler_add(PUSH_BUTTON_PIN, button_isr_handler, NULL); xTaskCreate(interrupt_task, "interrupt_task", 4096, NULL, 10, &ISR); } |
Как работает код
Для начала мы подключим необходимые библиотеки для этого проекта. Это включает библиотеку driver/gpio.h, поскольку нам нужно работать с контактами GPIO, и библиотеки FreeRTOS, так как мы хотим добавлять задержки и создавать задачи.
|
1 2 3 4 |
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include "driver/gpio.h" |
Далее мы определим параметр ESP_INR_FAG_DEFAULT и установим его значение равным нулю. Это будет использоваться при установке прерывания.
|
1 |
#define ESP_INR_FLAG_DEFAULT 0 |
Мы подключили светодиод к выводу GPIO27. Поэтому мы определим переменную с именем 'LED_PIN', которая будет хранить значение вывода GPIO 27. Она будет использоваться позже в коде для управления цифровым выходом.
Мы подключили кнопку к выводу GPIO33. Поэтому мы определим переменную с именем 'PUSH_BUTTON_PIN', которая будет хранить значение вывода GPIO 33. Она будет использоваться позже в коде для настройки прерывания.
|
1 2 |
#define LED_PIN 27 #define PUSH_BUTTON_PIN 33 |
Дескриптор задачи будет определен как глобальная переменная вне основной функции. Мы установили для нее значение NULL.
|
1 |
TaskHandle_t ISR = NULL; |
Внутри функции interrupt_task() мы сначала создаём логическую переменную с именем 'led_status' и устанавливаем её значение в false. Затем, используя непрерывный цикл while, мы сначала приостанавливаем текущую задачу, чтобы она выполнилась один раз, переключаем состояние led_status, устанавливаем значение LED_PIN в состояние, хранящееся в переменной 'led_status', и выводим 'Кнопка нажата!' в последовательный терминал.
|
1 2 3 4 5 6 7 8 9 |
void interrupt_task(void *arg){ bool led_status = false; while(1){ vTaskSuspend(NULL); led_status = !led_status; gpio_set_level(LED_PIN, led_status); printf("Button pressed!\n"); } } |
Функция button_isr_handler() возобновит выполнение задачи interrupt_task с этого момента.
|
1 2 3 |
void IRAM_ATTR button_isr_handler(void *arg){ xTaskResumeFromISR(ISR); } |
app_main()
Внутри основной функции мы сначала настроим контакты PUSH_BUTTON_PIN и LED_PIN как контакты GPIO, используя функцию gpio_pad_select_gpio(), и укажем контакт в качестве параметра внутри неё.
|
1 2 |
gpio_pad_select_gpio(PUSH_BUTTON_PIN); gpio_pad_select_gpio(LED_PIN); |
Затем следующим шагом будет настройка вывода LED_PIN как выхода и вывода PUSH_BUTTON_PIN как входа. Мы зададим направление вывода как входа или выхода с помощью функции gpio_set_direction(). Эта функция принимает два аргумента. Первый аргумент — это вывод GPIO, а второй — режим (вход или выход), в котором мы хотим установить вывод. В нашем случае мы хотим установить вывод LED_PIN как выходной вывод, а вывод PUSH_BUTTON_PIN — как выходной вывод.
|
1 2 |
gpio_set_direction(PUSH_BUTTON_PIN, GPIO_MODE_INPUT); gpio_set_direction(LED_PIN ,GPIO_MODE_OUTPUT); |
Следующий шаг — установка прерывания на выводе кнопки по положительному фронту. Поскольку мы используем подтягивающий резистор с кнопкой, при нажатии она будет находиться в высоком состоянии, при отпускании — в низком. Прерывание произойдет, когда состояние изменится с низкого на высокое. Следующая функция устанавливает прерывание для вывода PUSH_BUTTON_PIN по положительному (восходящему) фронту:
|
1 |
gpio_set_intr_type(PUSH_BUTTON_PIN, GPIO_INTR_POSEDGE); |
Затем мы установим службу ISR с конфигурацией по умолчанию.
|
1 |
gpio_install_isr_service(ESP_INR_FLAG_DEFAULT); |
Далее мы добавляем подпрограмму обработки прерываний (ISR). Здесь мы добавляем обработчик прерываний.
|
1 |
gpio_isr_handler_add(PUSH_BUTTON_PIN, button_isr_handler, NULL); |
Наконец, мы создаём задачу прерывания, используя функцию xTaskCreate().
|
1 |
xTaskCreate(interrupt_task, "interrupt_task", 4096, NULL, 10, &ISR); |
Компиляция скетча
Для прошивки микросхемы введите следующую команду в последовательном терминале. Не забудьте заменить COM-порт на тот, через который подключена ваша плата.
|
1 |
idf.py -p COMX flash monitor |
После успешной прошивки кода нажмите кнопку, чтобы включить светодиод. Затем нажмите кнопку еще раз, чтобы выключить его. При каждом изменении состояния кнопки с высокого на низкое светодиод переключается.
Демонстрация работы проекта
Показана на следующих рисунках.












