При создании простых встраиваемых систем мы обычно пишем код внутри одного бесконечного цикла, часто называемого «суперциклом» или «программированием на уровне аппаратного обеспечения». В этом подходе микроконтроллер выполняет одну инструкцию за другой. Если выполнение определенной функции занимает много времени или используется блокирующая функция, весь процессор останавливается, и никакой другой код не может выполняться, пока эта функция не завершится.
По мере усложнения встраиваемых систем за счет объединения Wi-Fi, считывания данных с датчиков, обновления дисплея и ввода данных пользователем, подход с использованием суперцикла становится неэффективным. Система становится неотзывчивой и сложной в управлении.
Для преодоления этого ограничения используется операционная система реального времени (Real-Time Operating System, RTOS). RTOS выступает в качестве высокоэффективного менеджера для микроконтроллера. Вместо одного большого цикла мы разбиваем нашу программу на более мелкие, независимые программы, называемые задачами. RTOS быстро переключает внимание процессора между этими задачами в зависимости от времени и приоритета. Это быстрое переключение создает иллюзию одновременного выполнения, делая систему очень отзывчивой и детерминированной.
В основе фреймворка ESP-IDF лежит операционная система FreeRTOS. FreeRTOS является отраслевым стандартом для встраиваемых систем. Однако традиционные развертывания FreeRTOS обычно используют одноядерные процессоры. Поскольку ESP32-S3 имеет двухъядерный процессор, компания Espressif значительно модифицировала FreeRTOS для поддержки SMP (симметричной многопроцессорной обработки).
Это означает, что версия FreeRTOS ESP-IDF позволяет нам не только разбивать программу на задачи, но и явно указывать, будет ли задача выполняться на ядре 0, которое обычно используется для системных и коммуникационных задач, таких как Wi-Fi/Bluetooth, или на ядре 1, зарезервированном для кода приложения.
Если вы хотите больше узнать о том как работает FreeRTOS на микроконтроллерах и чтобы лучше понять изложенный в данном разделе материал, рекомендуем прочитать наш цикл статей про использование FreeRTOS в плате Arduino:
- как использовать FreeRTOS в Arduino – руководство для начинающих;
- использование очередей в Arduino FreeRTOS;
- использование семафоров и мьютексов в FreeRTOS на Arduino Uno.
В основе FreeRTOS лежит планировщик — компонент, отвечающий за определение того, какая задача получит доступ к ЦП в любой момент времени. Планировщик работает с использованием периодического сигнала, известного как прерывание по такту. Аппаратный таймер внутри микроконтроллера генерирует это прерывание с фиксированной частотой, обычно каждые 1 миллисекунду (1 кГц) в системах ESP-IDF. Каждый раз, когда происходит это прерывание, FreeRTOS на короткое время приостанавливает нормальное выполнение и переоценивает состояние всех задач в системе.
Именно эта периодическая переоценка обеспечивает FreeRTOS его «реальное время» работы. Вместо того чтобы позволять одному и тому же потоку программ выполняться бесконечно, операционная система постоянно проверяет, следует ли другой задаче взять на себя управление процессором. Задачи могут находиться в разных состояниях, таких как «Выполняется» , «Готов» , «Заблокировано» или «Приостановлено» . Задача планировщика — анализировать эти состояния и выбирать задачу с наивысшим приоритетом, готовую к выполнению.
FreeRTOS использует модель вытесняющего планирования, что означает, что приоритет задач напрямую влияет на доступ к ЦП. Если задача с более высоким приоритетом становится готовой к выполнению, пока выполняется задача с более низким приоритетом, планировщик немедленно прерывает выполнение задачи с более низким приоритетом и переключает его на задачу с более высоким приоритетом. Этот процесс называется вытеснением.
Планировщик также управляет ситуациями, когда несколько задач имеют одинаковый уровень приоритета. В этом случае FreeRTOS обычно использует метод, называемый временным разделением. Вместо того чтобы позволять одной задаче монополизировать ЦП, планировщик делит процессорное время на небольшие отрезки в зависимости от системного такта. Одна задача может выполняться в течение одного такта, после чего очередь переходит к другой задаче с равным приоритетом. Этот чередующийся режим продолжается до тех пор, пока обе задачи остаются готовыми к выполнению.
Важная деталь заключается в том, что переключение контекста происходит очень быстро. FreeRTOS сохраняет контекст выполнения текущей задачи, включая регистры ЦП и информацию о стеке, прежде чем восстановить контекст следующей задачи. Поскольку этот процесс является легковесным, операционная система может переключаться между задачами достаточно быстро, так что многозадачность кажется одновременной, даже на одноядерном процессоре.
Задача — это самый фундаментальный строительный блок FreeRTOS. Мы можем рассматривать задачу как независимую программу со своим собственным бесконечным циклом, своим собственным приоритетом и своей собственной выделенной памятью (называемой стеком).
Задачи в FreeRTOS выполняются в различных состояниях:
- Выполняется (Running): Задача в данный момент выполняется на ядре ЦП.
- Готово (Ready): Задача готова к выполнению, но ожидает завершения, поскольку в данный момент процессор занят задачей с более высоким приоритетом.
- Заблокировано (Blocked): Задача ожидает какого-либо события (например, истечения таймера или получения данных от датчика). В заблокированном состоянии она не потребляет процессорное время.
- Приостановлено (Suspended): Задача явно приостановлена программистом и не будет выполняться снова, пока не будет явно возобновлена.
Каждой задаче присваивается приоритет от 0 до configMAX_PRIORITIES - 1. В ESP-IDF максимальное значение обычно равно 24. Большие числа означают более высокий приоритет. Планировщик FreeRTOS строго соблюдает приоритеты: он всегда приостанавливает задачу с более низким приоритетом, если задача с более высоким приоритетом готова к выполнению.
Давайте создадим простой проект для демонстрации задач. Мы хотим заставить мигать два светодиода с совершенно разными частотами. Мы можем реализовать это с помощью суперцикла с задержками, и это работает, но такой подход приводит к блокирующему коду. Если частоты не связаны, могут возникнуть проблемы. С FreeRTOS мы можем назначить каждому светодиоду свою собственную задачу.
Сначала определим функции задач. Каждая задача в FreeRTOS должна возвращать значение void, принимать параметр void * и содержать бесконечный цикл. Крайне важно использовать vTaskDelay() вместо стандартной задержки. vTaskDelay() переводит задачу в состояние «Заблокировано», освобождая ядро ЦП для выполнения других задач.
|
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 |
#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "driver/gpio.h" #define LED_1 4 #define LED_2 5 // Task 1: Blinks LED 1 every 500ms void blink_task_1(void *pvParameters) { gpio_set_direction(LED_1, GPIO_MODE_OUTPUT); while (1) { gpio_set_level(LED_1, 1); vTaskDelay(pdMS_TO_TICKS(500)); // Block for 500ms gpio_set_level(LED_1, 0); vTaskDelay(pdMS_TO_TICKS(500)); } } // Task 2: Blinks LED 2 every 1000ms void blink_task_2(void *pvParameters) { gpio_set_direction(LED_2, GPIO_MODE_OUTPUT); while (1) { gpio_set_level(LED_2, 1); vTaskDelay(pdMS_TO_TICKS(1000)); // Block for 1000ms gpio_set_level(LED_2, 0); vTaskDelay(pdMS_TO_TICKS(1000)); } } |
Теперь, внутри функции app_main(), мы создаём эти задачи с помощью xTaskCreatePinnedToCore. Для работы этой функции требуется:
- Название функции задачи.
- Строковое имя, удобочитаемое для отладки.
- Размер стека в байтах, определяющий объем памяти, необходимый для выполнения задачи.
- Параметры, передаваемые в задачу; здесь мы передаем
NULL. - Уровень приоритета; мы используем его
1для обеих задач. - Это идентификатор задачи; мы передаем его дальше,
NULLпотому что нам не нужно будет ссылаться на него позже. - Основной идентификатор
0или1, илиtskNO_AFFINITYдля того, чтобы позволить ОС реального времени выбрать.
|
1 2 3 4 5 6 7 |
void app_main(void) { // Create Task 1 on Core 1 xTaskCreatePinnedToCore(blink_task_1, "Task 1", 2048, NULL, 1, NULL, 1); // Create Task 2 on Core 1 xTaskCreatePinnedToCore(blink_task_2, "Task 2", 2048, NULL, 1, NULL, 1); } |
Мы можем не только создавать и запускать задачи. Мы также можем контролировать их состояние и жизненный цикл. Для этого нам необходимо получить дескриптор задачи при её создании.
vTaskSuspend(TaskHandle_t xTaskToSuspend): приостанавливает задачу. Она переходит в состояние "Приостановлено" и игнорирует все события RTOS до возобновления выполнения.vTaskResume(TaskHandle_t xTaskToResume): выводит задачу из приостановленного состояния и возвращает её в состояние готовности.vTaskDelete(TaskHandle_t xTaskToDelete): полностью уничтожает задачу и освобождает выделенную ей память стека. (Для «перезапуска» задачи необходимо удалить ее и вызвать функциюxTaskCreateснова).
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
TaskHandle_t myTaskHandle = NULL; void app_main(void) { // 1. Create the task and save its handle xTaskCreatePinnedToCore(blink_task_1, "Task 1", 2048, NULL, 1, &myTaskHandle, 1); // 2. Suspend the task (Stop it) vTaskSuspend(myTaskHandle); // 3. Resume the task vTaskResume(myTaskHandle); // 4. Delete the task (Passing NULL deletes the task that calls it) vTaskDelete(myTaskHandle); } |
Когда выполняется несколько задач, им неизбежно необходимо обмениваться данными. Например, задача «Датчик» считывает данные о температуре, а задача «Дисплей» выводит их на OLED-экран.
Мы можем передавать эти данные с помощью глобальных переменных. В RTOS глобальные переменные опасны. Если задача датчика находится на полпути к обновлению глобальной переменной, и RTOS внезапно переключается на задачу отображения, задача отображения может считать поврежденные или неполные данные.
Для решения этой проблемы FreeRTOS предоставляет очереди. Очередь — это безопасный канал связи между задачами по принципу «первым пришел — первым вышел» (FIFO).
- Задача отправки перемещает данные в конец очереди.
- Принимающая задача извлекает данные из начала очереди.
- Если очередь пуста, принимающая задача автоматически переходит в состояние «Заблокировано» до получения данных, что исключает трату процессорного времени.
Для работы с очередями сначала необходимо создать глобальную переменную типа QueueHandle_t. После этого мы можем управлять очередью, используя три основные функции:
xQueueCreate()- создает объект очереди. Принимает два аргумента: длину очереди (максимальное количество элементов, которое она может вместить) и размер каждого элемента, хранящегося в очереди.xQueueSend()- эта функция отправляет новые данные в очередь. Она принимает три аргумента: глобальную переменную очереди, добавляемые данные и значение таймаута, определяющее, как долго функция должна ждать, если очередь заполнена.xQueueReceive()- эта функция считывает и извлекает данные из очереди. Как и предыдущая функция, она принимает три аргумента: дескриптор очереди, указатель на место хранения полученных данных и значение таймаута, определяющее, как долго функция должна ожидать данных.
Давайте воссоздадим автоматизированную систему освещения, которую мы создали в уроке 2, используя очереди и задачи. Сначала соберем нашу схему.
Теперь мы можем создать нашу программу. Начнём с импорта необходимых библиотек и создания глобальной переменной очереди.
|
1 2 3 4 5 6 7 |
#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" #include "esp_adc/adc_oneshot.h" #include "driver/gpio.h" QueueHandle_t data_queue; |
После этого мы создаём две функции, которые описывают наши задачи:
- Задача производителя (producer task) считывает данные с датчика LDR и отправляет их в очередь.
- Задача потребителя (consumer task) получает данные из очереди и управляет светодиодом на основе полученного значения.
|
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 |
void producer_task(void *pvParameters) { adc_oneshot_unit_handle_t adc1_handle; adc_oneshot_unit_init_cfg_t init_config1 = { .unit_id = ADC_UNIT_1 }; adc_oneshot_new_unit(&init_config1, &adc1_handle); adc_oneshot_chan_cfg_t config = { .bitwidth = ADC_BITWIDTH_DEFAULT, .atten = ADC_ATTEN_DB_12 }; adc_oneshot_config_channel(adc1_handle, ADC_CHANNEL_4, &config); int ldrValue = 0; while (1) { adc_oneshot_read(adc1_handle, ADC_CHANNEL_4, &ldrValue); xQueueSend(data_queue, &ldrValue , pdMS_TO_TICKS(100)); vTaskDelay(pdMS_TO_TICKS(500)); } } void consumer_task(void *pvParameters) { int received_data = 0; while (1) { // Wait FOREVER (portMAX_DELAY) for data to arrive in the queue xQueueReceive(data_queue, &received_data, portMAX_DELAY); if (received_data > 1600) { gpio_set_level(7, 1); } else { gpio_set_level(7, 0); } } } |
Наконец, внутри программы app_main() мы инициализируем GPIO 7 как выходной контакт, создаём очередь, а затем запускаем обе задачи.
|
1 2 3 4 5 6 7 8 9 10 11 |
void app_main(void) { gpio_reset_pin(7); gpio_set_direction(7, GPIO_MODE_OUTPUT); data_queue = xQueueCreate(5, sizeof(int)); if (data_queue != NULL) { xTaskCreatePinnedToCore(producer_task, "Producer", 2048, NULL, 1, NULL, 1); xTaskCreatePinnedToCore(consumer_task, "Consumer", 2048, NULL, 1, NULL, 1); } } |
Очереди отлично подходят для передачи данных, но что, если двум задачам необходимо получить доступ к одному и тому же физическому аппаратному ресурсу?
Представьте, что две задачи пытаются использовать шину I²C или терминал UART в одну и ту же миллисекунду. Данные, отправляемые на терминал, будут искажены, и выходные данные обеих задач будут смешаны. Это называется состоянием гонки.
Чтобы предотвратить это, FreeRTOS предоставляет мьютекс (взаимное исключение). Мьютекс действует как физический ключ от запертой комнаты.
- Прежде чем задача сможет использовать общий ресурс, она должна занять позицию мьютекса.
- Если другая задача попытается использовать этот ресурс, она увидит, что мьютекс исчез, и немедленно перейдет в состояние «Заблокировано», ожидая завершения.
- Когда первая задача завершается, она возвращает мьютекс.
- Затем ожидающая задача захватывает мьютекс, блокирует дверь и продолжает выполнение.
Для работы с мьютексами сначала необходимо создать глобальную переменную типа SemaphoreHandle_t. После этого мы можем управлять мьютексом, используя две основные функции:
xSemaphoreCreateMutex()- создаёт объект мьютекса и возвращает дескриптор этого объекта.xSemaphoreTake()- эта функция пытается заблокировать мьютекс перед доступом к общему ресурсу. Она принимает два аргумента: дескриптор мьютекса и значение таймаута, указывающее, как долго задача должна ждать, если мьютекс уже заблокирован.xSemaphoreGive()- освобождает мьютекс после того, как общий ресурс перестаёт использоваться, позволяя другим задачам получить к нему доступ.
Давайте создадим пример, в котором две задачи пытаются одновременно записывать сообщения на терминал. Поскольку терминал является общим ресурсом, мы будем использовать мьютекс, чтобы гарантировать, что только одна задача может записывать данные одновременно.
Начнём с импорта необходимых библиотек и создания глобальной переменной-мьютекса.
|
1 2 3 4 5 6 |
#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include <stdio.h> SemaphoreHandle_t terminal_mutex; |
После этого мы создаём две задачи:
- Первая задача выводит длинное сообщение на терминал.
- Второе задание также выводит другое сообщение.
- Обе задачи должны заблокировать мьютекс перед использованием
printf().
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void print_task_1(void *pvParameters) { while (1) { xSemaphoreTake(terminal_mutex, portMAX_DELAY); printf("Task 1 is writing a long message...\n"); vTaskDelay(pdMS_TO_TICKS(100)); printf("Task 1 finished writing.\n"); xSemaphoreGive(terminal_mutex); vTaskDelay(pdMS_TO_TICKS(500)); } } void print_task_2(void *pvParameters) { while (1) { xSemaphoreTake(terminal_mutex, portMAX_DELAY); printf("Task 2 is writing something different...\n"); vTaskDelay(pdMS_TO_TICKS(100)); printf("Task 2 finished writing.\n"); xSemaphoreGive(terminal_mutex); vTaskDelay(pdMS_TO_TICKS(300)); } } |
Наконец, внутри app_main() мы создаём мьютекс, а затем запускаем обе задачи.
|
1 2 3 4 5 6 7 |
void app_main(void) { // Create the mutex terminal_mutex = xSemaphoreCreateMutex(); if (terminal_mutex != NULL) { xTaskCreatePinnedToCore(print_task_1, "Printer1", 2048, NULL,1, NULL, 1); xTaskCreatePinnedToCore(print_task_2, "Printer2", 2048, NULL, 1, NULL, 1); } } |
Без мьютекса обе задачи могут пытаться использовать терминал printf() одновременно, что приводит к искаженному или смешанному выводу. Защита терминала с помощью мьютекса позволяет обращаться к нему только одной задаче одновременно. Это особенно эффективно при работе с переменными и данными.
Распространенной проблемой RTOS при использовании мьютексов является инверсия приоритетов. Она возникает при взаимодействии трех задач с разными приоритетами:
- Задача с низким приоритетом получает мьютекс.
- Позже высокоприоритетной задаче потребуется тот же мьютекс, поэтому она блокируется в ожидании его получения.
- Тем временем задача со средним приоритетом становится готовой к выполнению.
Поскольку задача со средним приоритетом имеет более высокий приоритет, чем задача с низким приоритетом, она вытесняет задачу с низким приоритетом и занимает ресурсы процессора. В результате задача с высоким приоритетом остается заблокированной, ожидая мьютекса, в то время как задача с низким приоритетом не может работать достаточно долго, чтобы освободить его.
Иными словами, задача с высоким приоритетом косвенно задерживается из-за задачи со средним приоритетом, даже несмотря на то, что задача со средним приоритетом вообще не использует мьютекс.
Подобные ошибки крайне сложно отлаживать, и они могут вызывать серьезные проблемы со временем выполнения в системах реального времени.
Для решения этой проблемы в FreeRTOS мьютексы реализуют наследование приоритетов. Когда задача с высоким приоритетом блокируется в ожидании мьютекса, принадлежащего задаче с низким приоритетом, RTOS временно повышает приоритет задачи с низким приоритетом до уровня приоритета задачи с высоким приоритетом. Это позволяет задаче с низким приоритетом немедленно начать выполнение, завершить критическую секцию, освободить мьютекс и затем вернуться к своему первоначальному приоритету.
В то время как мьютексы предназначены для защиты ресурсов (блокировки двери), бинарный семафор в основном используется для сигнализации и синхронизации между задачами или между прерыванием и задачей. В отличие от мьютекса, он не связан с владением ресурсом, а с уведомлением о произошедшем событии. Он ведет себя как простой флаг, который может быть либо «доступен» (1), либо «недоступен» (0). Одна часть системы «дает» семафор для отправки сигнала, а другая часть «берет» его, чтобы дождаться этого сигнала.
Бинарные семафоры чаще всего используются для обработки обработчиков прерываний (ISR). Во встроенных системах нам нужно, чтобы наши обработчики прерываний работали как можно быстрее. Если нажата кнопка или получен пакет, не следует выполнять ресурсоемкую обработку внутри самого прерывания, поскольку это блокирует весь процессор.
Вместо этого мы используем метод, называемый отложенной обработкой прерываний:
- Аппаратное обеспечение инициирует прерывание.
- Система ISR выполняет лишь минимальные действия и "выдает" бинарный семафор.
- Задача, которая "перехватывала" этот сигнал, просыпается и выполняет тяжелую работу.
Для использования бинарного семафора мы применяем следующие функции:
-
xSemaphoreCreateBinary()- эта функция инициализирует объект семафора, но важно понимать его начальное состояние. В отличие от простого флага, который может начинаться как «доступен», вновь созданный бинарный семафор начинается в пустом (недоступном) состоянии. Это означает, что первая операцияTakeбудет блокироваться до тех пор, пока другая часть системы явно не подаст сигнал. -
xSemaphoreGiveFromISR()- эта функция отправляет сигнал из обработчика прерываний (ISR). Ее задача — просто уведомить систему о произошедшем событии, не выполняя при этом никакой сложной обработки. -
xSemaphoreTake()- эта функция используется задачей для ожидания сигнала. Задача, вызывающая эту функцию, перейдет в заблокированное состояние до тех пор, пока не будет получен сигнал семафора. Как только обработчик прерываний (или другая задача) выдаст сигнал, ожидающая задача немедленно разблокируется и возобновит выполнение для обработки события.
Давайте посмотрим, как работают эти функции, создав простой проект, который включает и выключает светодиод при нажатии кнопки BOOT.
Начнём с подключения необходимых библиотек и определения констант. Затем объявим переменную xGuiSemaphore, которая будет использоваться в качестве бинарного семафора для синхронизации прерывания от кнопки с задачей.
|
1 2 3 4 5 6 7 8 9 |
#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "driver/gpio.h" #define BUTTON_GPIO 0 #define LED_GPIO 7 SemaphoreHandle_t xGuiSemaphore; |
После этого мы определяем подпрограмму обработки прерываний (ISR), которая автоматически выполняется при возникновении прерывания от кнопки. Функция размещается в IRAM - IRAM_ATTR, чтобы она могла быстро и надежно выполняться во время прерываний.
Внутри обработчика прерываний мы объявляем переменную с именем xHigherPriorityTaskWoken и инициализируем её значением pdFALSE. Эта переменная используется FreeRTOS для указания того, привело ли предоставление семафора к тому, что задача с более высоким приоритетом вышла из состояния «Заблокировано» и стала готова к выполнению.
Функция xSemaphoreGiveFromISR() выдает семафор безопасным для обработчиков прерываний способом. Если задача с более высоким приоритетом ожидала этот семафор, функция xHigherPriorityTaskWoken обновляется до pdTRUE.
Наконец, в portYIELD_FROM_ISR() проверяется этот флаг и запрашивается немедленное переключение контекста перед выходом из прерывания. Это позволяет пробужденной задаче с более высоким приоритетом выполниться немедленно, вместо того чтобы ждать следующего такта планировщика.
|
1 2 3 4 5 |
static void IRAM_ATTR gpio_isr_handler(void* arg) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(xGuiSemaphore, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } |
Далее мы создаём нашу задачу. Эта задача отвечает за выполнение фактической работы, которая должна произойти после нажатия кнопки. Сначала она настраивает вывод GPIO светодиода как выход, затем входит в бесконечный цикл, где ждёт сигнала от семафора. Когда обработчик прерываний выдаёт сигнал от семафора, эта задача просыпается и переключает состояние светодиода.
|
1 2 3 4 5 6 7 8 9 10 11 |
void led_toggle_task(void *pvParameters) { gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT); int state = 0; while (1) { if (xSemaphoreTake(xGuiSemaphore, portMAX_DELAY) == pdPASS) { state = !state; gpio_set_level(LED_GPIO, state); } } } |
Наконец, в функции app_main() мы сначала создаём бинарный семафор с помощью метода xSemaphoreCreateBinary(). Этот семафор будет использоваться для синхронизации между обработчиком прерываний (ISR) и задачей. После создания мы проверяем, был ли семафор успешно выделен, убеждаясь, что возвращаемый дескриптор не равен NULL. Если он равен NULL, это означает, что система не смогла выделить необходимые ресурсы.
Далее мы настраиваем GPIO с помощью структуры gpio_config_t. Эта структура определяет все параметры для выбранного контакта:
intr_type = GPIO_INTR_NEGEDGE- настраивает прерывание на спадающий фронт (т.е. когда сигнал меняется с высокого на низкий), что обычно используется для обнаружения нажатия кнопки, когда вывод подтянут к высокому уровню и замыкается на землю.mode = GPIO_MODE_INPUT- устанавливает контакт в качестве входного, позволяя считывать внешние сигналы, такие как состояние кнопки.pin_bit_mask = (1ULL << BUTTON_GPIO)- выбирает, какой вывод GPIO будет конфигурироваться, устанавливая соответствующий бит в 64-битной маске.pull_up_en = 1- включает внутренний подтягивающий резистор, обеспечивая стабильный высокий уровень сигнала на выводе, когда кнопка не нажата.
После заполнения структуры конфигурации мы применяем её с помощью команды gpio_config(&io_conf), которая записывает эти настройки в аппаратные регистры и активирует конфигурацию GPIO.
Затем мы инициализируем систему прерываний GPIO, вызывая функцию gpio_install_isr_service(0). Эта функция настраивает структуру службы прерываний, чтобы можно было зарегистрировать обработчики прерываний GPIO. Аргумент 0 указывает, что используются флаги конфигурации по умолчанию.
Наконец, мы подключаем обработчик прерываний к выбранному выводу GPIO с помощью функции gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler, NULL). Это связывает указанный вывод GPIO с функцией обработчика прерываний gpio_isr_handler, которая будет автоматически выполняться при возникновении настроенного события прерывания. Последний параметр ( NULL) — это определяемый пользователем аргумент, передаваемый в обработчик прерываний, который в данном случае не используется.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
void app_main(void) { xGuiSemaphore = xSemaphoreCreateBinary(); if (xGuiSemaphore != NULL) { gpio_config_t io_conf = { .intr_type = GPIO_INTR_NEGEDGE, // Trigger on press (High to Low) .mode = GPIO_MODE_INPUT, .pin_bit_mask = (1ULL << BUTTON_GPIO), .pull_up_en = 1, }; gpio_config(&io_conf); gpio_install_isr_service(0); gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler, NULL); xTaskCreatePinnedToCore(led_toggle_task, "LED_Task", 2048, NULL, 10, NULL, 1); } } |
При работе с ESP32-S3 понимание принципов работы памяти имеет фундаментальное значение. Память — это место, где хранится код программы, переменные и данные во время работы микросхемы. Для наглядности мы можем разделить нашу память на два основных рабочих пространства: флэш-память, которая служит нашим постоянным хранилищем данных, и оперативная память (RAM), которая служит нашей быстрой, временной рабочей средой.
ESP32-S3 управляет этими рабочими пространствами, используя гибкое отображение памяти и два различных пути: шину инструкций, используемую исключительно для выполнения кода, и шину данных, используемую для чтения и записи информации побайтно.
Флэш-память — это наше долговременное хранилище. Она надежно хранит наш программный код, сохраненные настройки и постоянные данные, так что все остается в безопасности даже при отключении питания платы. Поскольку объем внутренней оперативной памяти ограничен, мы в значительной степени полагаемся на флэш-память для хранения большей части нашего приложения.
- IROM (код, выполняемый из Flash): здесь находится большая часть нашего исполняемого двоичного кода. Поскольку мы не можем разместить все во внутренней оперативной памяти, ESP32-S3 использует специальную систему кэширования для прямого отображения этого кода в адресное пространство инструкций. Это позволяет нам запускать наше приложение непосредственно из Flash почти так же быстро, как если бы оно находилось во внутренней памяти.
- DROM (Data Stored in Flash): это наш раздел данных только для чтения. Мы используем DROM для хранения константных переменных и фиксированных данных, которые наша программа должна считывать, но которые она никогда не будет изменять или выполнять.
Оперативная память (RAM) исключительно быстрая и используется для активных вычислений и временных переменных. Однако стандартная оперативная память теряет всю свою информацию при выключении питания. ESP32-S3 разделяет нашу рабочую среду для работы с оперативной памятью на несколько специализированных областей в зависимости от того, чего нам необходимо достичь:
- DRAM (оперативная память данных): это наше основное рабочее пространство для неконстантных статических данных, данных, инициализированных нулями, и нашей кучи времени выполнения. DRAM строго подключена к шине данных, а это значит, что мы не можем выполнять код отсюда. Если нам необходимо, чтобы определенные данные сохранились после перезагрузки программного обеспечения (теплой загрузки), мы можем выделить секцию «noinit» в DRAM, чтобы предотвратить их стирание при запуске. Кроме того, когда нашим аппаратным периферийным устройствам требуется прямой доступ к памяти (DMA) для быстрой отправки или приема данных, мы должны тщательно размещать наши буферы памяти здесь, в DRAM, чтобы обеспечить их правильное выравнивание и форматирование.
- IRAM (оперативная память инструкций): в отличие от DRAM, IRAM является исполняемой памятью. Мы резервируем это ценное пространство для наиболее важных задач, таких как обработчики прерываний или код, чувствительный ко времени выполнения. Размещая эти операции в IRAM, мы избегаем небольших задержек, связанных с выборкой инструкций из Flash-памяти. Однако это требует баланса: любое пространство, занимаемое IRAM, уменьшает объем DRAM, доступный для статических данных и кучи.
- Память RTC (часы реального времени): это особый, энергоэффективный сегмент оперативной памяти, который остается активным даже тогда, когда остальная часть чипа переведена в режим глубокого сна.
- Быстрая память RTC: мы используем эту область для кода и данных, которые должны выполняться немедленно после выхода чипа из глубокого сна. Если она не нужна для процедур пробуждения, мы можем добавить оставшееся пространство к нашей общей куче данных, хотя это работает немного медленнее, чем стандартная DRAM.
- Медленная память RTC: мы используем этот более глубокий раздел памяти для глобальных и статических переменных, которые должны сохранять свои значения на протяжении всего цикла глубокого сна, или для данных, к которым должен обращаться сопроцессор сверхнизкого энергопотребления (ULP), пока основной процессор находится в режиме ожидания.
Теперь, когда мы понимаем общую структуру памяти в ESP32-S3, давайте рассмотрим, как работает оперативная память и как мы можем ею управлять. ESP32-S3 делит оперативную память на две основные области: кучу и стек.
На протяжении всех лекций мы работали со стеком, даже не замечая его. Каждый раз, когда мы создаём переменную внутри функции, она обычно сохраняется в стеке, который используется для локального и автоматического выделения памяти. При каждом вызове функции микроконтроллер автоматически резервирует часть памяти стека для локальных переменных этой функции, аргументов функции и адресов возврата. После завершения выполнения функции эта память автоматически освобождается.
Стек работает по принципу «последний вошел — первый вышел» (LIFO), то есть сначала удаляются данные, добавленные последними. Благодаря своей простой структуре, память стека чрезвычайно быстрая и эффективная. Однако размер стека ограничен по сравнению с размером кучи. Объявление очень больших локальных переменных или глубоко вложенные вызовы функций могут исчерпать доступное пространство стека, вызывая переполнение стека. На ESP32-S3 переполнение стека обычно приводит к сбою системы, зависанию или автоматической перезагрузке.
Давайте создадим простой пример, чтобы понять, как работает стек. В этом примере одна функция вызывает другую, что позволяет нам наблюдать за тем, как управляется стек во время выполнения функции.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <stdio.h> void secondFunction() { int b = 20; printf("b = %d\n a = %d\n", b,a); // we can access a } void firstFunction() { int a = 10; printf("a = %d\n", a); //b = 5 + a we cant access be secondFunction(); } void app_main(void) { firstFunction(); } |
Когда в app_main() вызывается функция firstFunction() микроконтроллер создает новый кадр стека, содержащий локальную переменную a, параметры функции и адрес возврата. Пока функция firstFunction() еще выполняется, она вызывает функцию secondFunction(), в результате чего поверх предыдущего кадра стека помещается еще один кадр. Этот новый кадр хранит локальную переменную b и информацию, необходимую для выполнения функции secondFunction().
После завершения secondFunction() ее кадр стека автоматически удаляется, и программа возвращается к исходному состоянию firstFunction(). После завершения firstFunction() ее кадр стека также освобождается, и выполнение возвращается к исходному состоянию app_main().
Этот процесс демонстрирует, почему локальные переменные существуют только внутри своей функции или вложенных функций, и почему мы не можем получить доступ к переменной после завершения выполнения её функции. После возврата из функции память в стеке освобождается и может быть повторно использована другими функциями.
В отличие от стека, который автоматически выделяет и освобождает память во время вызовов функций, куча используется для динамического выделения памяти. Память в куче управляется вручную программистом, то есть мы явно запрашиваем память, когда она необходима, и освобождаем ее, когда она больше не требуется.
В отличие от структуры стека LIFO (последний вошел — первый вышел), куча представляет собой большой пул памяти, где блоки могут выделяться и освобождаться в любом порядке. Однако эта гибкость имеет свою цену. Выделение памяти в куче происходит медленнее, чем в стеке.
Куча особенно полезна, когда необходимый объем памяти неизвестен на этапе компиляции. Вместо создания локальных переменных фиксированного размера мы можем динамически выделять память во время выполнения программы. Это очень распространено во встроенных системах при обработке буферов, коммуникационных пакетов, данных с датчиков, обработке изображений или массивов с динамическим размером.
На ESP32-S3 управление памятью в куче осуществляется распределителем памяти ESP-IDF. Для динамического управления памятью обычно используются такие функции, как malloc(), calloc(), realloc(), и free(). ESP-IDF также предоставляет расширенные возможности управления памятью в куче, позволяя разработчикам выделять память из различных областей памяти, таких как внутренняя ОЗУ, память с поддержкой DMA или внешняя PSRAM.
В отличие от стека, память кучи не исчезает автоматически после завершения функции. Выделенная память остается зарезервированной до тех пор, пока она не будет явно освобождена с помощью метода free(). Неосвобождение неиспользуемой памяти кучи приводит к утечке памяти, что постепенно уменьшает доступную память и в конечном итоге может привести к сбою системы.
Давайте создадим простой пример, чтобы понять, как работает куча памяти на ESP32-S3 с использованием ESP-IDF.
|
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 |
#include <stdio.h> #include <stdlib.h> void app_main(void) { // Allocate memory for 5 integers int *numbers = (int *)malloc(5 * sizeof(int)); // Check if allocation was successful if (numbers == NULL){ printf("Memory allocation failed!\n"); return; } // Store values inside the allocated memory for (int i = 0; i < 5; i++){ numbers[i] = (i + 1) * 10; } // Print the stored values for (int i = 0; i < 5; i++){ printf("numbers[%d] = %d\n", i, numbers[i]); } // Release the allocated memory free(numbers); printf("Heap memory released\n"); } |
В этом примере функция malloc() динамически резервирует из кучи память достаточного размера для хранения пяти целых чисел. Возвращаемый указатель numbers содержит адрес выделенного блока памяти.
|
1 |
int *numbers = (int *)malloc(5 * sizeof(int)); |
В отличие от переменных стека, эта память остается действительной, даже если она была выделена внутри другой функции, до тех пор, пока она не будет освобождена с помощью метода free().
После выделения памяти мы сохраняем значения в массиве обычным способом, используя индексацию указателями.
|
1 |
numbers[i] = (i + 1) * 10; |
Когда память больше не нужна, мы освобождаем её с помощью метода free().
|
1 |
free(numbers); |
После вызова функции free() менеджер кучи снова помечает этот участок памяти как доступный, чтобы его можно было впоследствии повторно использовать другими частями программы.
ESP-IDF предоставляет встроенные функции для мониторинга использования кучи во время выполнения. Это чрезвычайно полезно при отладке утечек памяти или проверке объема доступной свободной памяти.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <stdio.h> #include "esp_heap_caps.h" void app_main(void){ printf("Free heap before allocation: %d bytes\n", heap_caps_get_free_size(MALLOC_CAP_8BIT)); int *data = malloc(100 * sizeof(int)); printf("Free heap after allocation: %d bytes\n", heap_caps_get_free_size(MALLOC_CAP_8BIT)); free(data); printf("Free heap after free(): %d bytes\n", heap_caps_get_free_size(MALLOC_CAP_8BIT)); } |
Эта функция heap_caps_get_free_size() позволяет проверять доступную память кучи в режиме реального времени. Это особенно важно для встроенных систем, где ресурсы памяти ограничены.
Хотя стандартные функции C, такие как malloc(), просты в использовании, они имеют существенное ограничение в современных встроенных системах: они не позволяют указать, где именно в аппаратном обеспечении микроконтроллера должна быть выделена память. ESP32-S3 имеет сложную карту памяти, которая включает в себя быструю внутреннюю SRAM (разделенную на IRAM и DRAM) и часто более медленную, но гораздо большую внешнюю PSRAM (SPI RAM).
Для решения этой проблемы ESP-IDF предоставляет мощную функцию, называемую heap_caps_malloc(). Эта функция позволяет нам явно указывать, в какой области памяти должны быть размещены наши данные, применяя определенные флаги возможностей.
|
1 2 3 |
#include "esp_heap_caps.h" void *heap_caps_malloc(size_t size, uint32_t caps); |
size- количество байтов, которые вы хотите выделить.caps- битовая маска флагов возможностей памяти определяет, где и как выделяется память.
Рассмотрим наиболее распространенные сценарии использования управления выделением памяти в куче на ESP32-S3.
1. Выделение памяти только из внутренней оперативной памяти. Иногда нам нужны критически важные для производительности данные, доступ к которым должен осуществляться максимально быстро. По умолчанию, если включена внешняя PSRAM, malloc() данные могут размещаться там. Мы можем заставить распределитель памяти использовать только быструю внутреннюю DRAM:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <stdio.h> #include "esp_heap_caps.h" void app_main(void) { // Force allocation strictly in fast internal DRAM int *data = heap_caps_malloc(100 * sizeof(int), MALLOC_CAP_INTERNAL); if (data == NULL) { printf("Internal RAM allocation failed!\n"); return; } // ... use the data ... free(data); } |
2. Выделение памяти из внешней PSRAM. Внутренняя память ESP32-S3 очень ценна и обычно используется для задач FreeRTOS и быстрых операций. Если нам нужен большой объем памяти, мы должны переместить его во внешнюю PSRAM. Это невероятно полезно для:
- больших буферов данных;
- обработки изображений или буфера кадров камеры;
- обработки аудиоданных;
- больших сетевых пакетов.
|
1 2 3 4 5 6 7 8 9 |
// Allocate a massive buffer in external PSRAM int *buffer = heap_caps_malloc(10000 * sizeof(int), MALLOC_CAP_SPIRAM); if (buffer == NULL) { printf("PSRAM allocation failed!\n"); } else { printf("Successfully allocated large buffer in PSRAM.\n"); free(buffer); } |
3. Безопасное выделение памяти с помощью DMA. При работе с аппаратными периферийными устройствами, такими как интерфейсы SPI, I2S (аудио) или ЖК-дисплеи, оборудование часто использует прямой доступ к памяти (DMA). DMA позволяет периферийным устройствам читать и записывать данные в память независимо от ЦП, что значительно ускоряет передачу данных. Однако аппаратное обеспечение DMA может получать доступ только к определенным физическим областям памяти.
Если мы передадим стандартную память контроллеру DMA, это может привести к сбою. Необходимо явно запросить память, поддерживающую DMA:
|
1 2 |
// Allocate memory that is physically accessible by DMA hardware uint8_t *dma_buffer = heap_caps_malloc(2048, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); |
Обратите внимание, что для объединения флагов мы использовали побитовый оператор ИЛИ ( |). Это гарантирует, что память будет доступна через DMA и будет находиться во внутренней оперативной памяти.
4. Объединение нескольких ограничений. Побитовое ИЛИ ( |) позволяет нам объединять столько ограничений, сколько требуется нашему приложению. Например, если нам нужна память, гарантированно доступная побайтно (8-битная) и строго внутренняя:
|
1 2 |
// Must be accessible as normal 8-bit memory AND come from internal RAM void *p = heap_caps_malloc(512, MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL); |
Одной из важных проблем динамического выделения памяти является фрагментация кучи. Фрагментация возникает, когда множество выделений и освобождений памяти разных размеров оставляют небольшие неиспользуемые промежутки в памяти. Даже если общий объем свободной памяти кажется достаточным, может не оказаться достаточно большого непрерывного блока для нового выделения.
В приложениях на базе ESP32-S3, работающих длительное время, чрезмерная фрагментация может в конечном итоге привести к ошибкам выделения памяти. Для уменьшения фрагментации:
- Избегайте многократного выделения и освобождения памяти в быстрых циклах.
- По возможности используйте буферы повторно.
- Для предсказуемых систем предпочтительнее использовать пулы памяти фиксированного размера.
- Освободите неиспользуемую память как можно скорее.
- Отслеживайте использование кучи во время выполнения.
Эффективное управление памятью имеет большое значение во встроенных системах, поскольку ресурсы памяти ограничены по сравнению с настольными компьютерами. Некачественное управление памятью может привести к сбоям, нестабильности, неожиданным перезагрузкам или снижению производительности системы.
Теперь нам необходимо связать наше понимание памяти с концепциями FreeRTOS, которые мы изучили ранее. В системе без операционной системы (Bare-Metal) существует только один основной стек для всей программы. Но в RTOS каждая задача получает свой собственный выделенный стек.
Когда мы вызываем функцию xTaskCreatePinnedToCore(), откуда берется память для стека новой задачи? Она выделяется из кучи. Если мы указываем задаче, что ей требуется 4096 байт памяти стека, RTOS использует malloc функцию резервирования 4096 байт памяти кучи специально для локальных переменных этой задачи.
Поскольку переполнение стека является наиболее распространенной ошибкой в разработке ESP-IDF, FreeRTOS предоставляет функцию, называемую uxTaskGetStackHighWaterMark(). Эта функция показывает минимальный объем оставшегося пространства стека, который когда-либо был доступен задаче с момента ее запуска. Если это число приближается к нулю, необходимо увеличить размер стека задачи.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include <stdio.h> void memory_monitor_task(void *pvParameters) { int local_array[100]; // Consumes 400 bytes on this task's stack while (1) { // Pass NULL to check the stack of the currently running task UBaseType_t high_water_mark = uxTaskGetStackHighWaterMark(NULL); // This prints the number of *bytes* (or words depending on architecture) remaining printf("Task Stack High Water Mark: %u\n", high_water_mark); vTaskDelay(pdMS_TO_TICKS(2000)); } } void app_main(void) { // We allocate 2048 bytes from the Heap to serve as this task's Stack xTaskCreatePinnedToCore(memory_monitor_task, "MemTask", 2048, NULL, 1, NULL, 1); } |
Теперь, когда мы понимаем, как работает оперативная память и как она временно хранит наши данные во время работы программы, давайте рассмотрим, как ESP32-S3 обрабатывает данные, которые должны сохраниться после выключения и включения питания или перезагрузки. В отличие от оперативной памяти, которая является энергозависимой и очищается при отключении питания устройства, флэш-память является энергонезависимой. Это означает, что она сохраняет свои данные неограниченно долго без питания.
ESP32-S3 использует внешнюю SPI-флэш-память, а иногда и внутреннюю встроенную флэш-память для хранения скомпилированного кода приложения, загрузчика и постоянных пользовательских данных. Однако мы не можем просто так записывать данные в флэш-память где попало; они должны быть строго организованы.
Поскольку флэш-память хранит множество важных данных, она разделена на отдельные разделы или «сектора». Таблицу разделов можно рассматривать как карту или индекс, который точно указывает ESP32-S3, где что находится.
При компиляции проекта ESP-IDF формат .csv определяется в файле. Типичная таблица разделов делит флэш-память на следующие части:
- Загрузчик: код, который выполняется сразу после запуска для загрузки основного приложения.
- Factory App: основной исполняемый скомпилированный код нашей программы.
- Разделы OTA: зарезервированные места для обновлений по беспроводной сети, позволяющие устройству загружать новое приложение, пока старое еще работает.
- NVS (энергонезависимое хранилище): небольшой раздел, используемый для сохранения параметров конфигурации.
- Разделы данных: большие пространства, отформатированные для файловых систем, таких как SPIFFS или LittleFS.
Благодаря такой структуре флэш-памяти микроконтроллер гарантирует, что запись текстового файла в раздел данных случайно не перезапишет код приложения.
Вот пример того, как выглядит файл partitions.csv:
|
1 2 3 4 5 |
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 1M, storage, data, spiffs, , 1M, |
Библиотека Non-Volatile Storage (NVS) — это инструмент ESP-IDF для сохранения небольших фрагментов данных. Она работает как словарь, используя пары «ключ-значение». Мы сохраняем фрагмент данных (значение) и присваиваем ему уникальное строковое имя (ключ). Позже мы можем запросить у NVS значение, связанное именно с этим ключом.
NVS оптимизирована для флэш-памяти. Поскольку флэш-память может быть стерта лишь определенное количество раз до того, как начнет деградировать, NVS использует метод выравнивания износа для равномерного распределения операций чтения/записи по секторам флэш-памяти, что продлевает срок службы оборудования. NVS идеально подходит для сохранения учетных данных Wi-Fi, смещений калибровки или переменных состояния (например, счетчика перезагрузок).
Рассмотрим простой пример использования NVS для создания небольшого проекта, который подсчитывает нажатия кнопок пользователем и сохраняет это значение даже после перезагрузки устройства.
В этом проекте мы создадим простую схему с двумя кнопками:
- Кнопка «Вверх» подключена к GPIO 4.
- Кнопка сброса подключена к GPIO 5.
При каждом нажатии кнопки «Увеличение счета» значение счетчика возрастает и сохраняется в NVS. Кнопка «Сброс» обнуляет сохраненное значение и возвращает счетчик к нулю.

Теперь давайте создадим программу для нашего приложения.
|
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
#include <stdio.h> #include "nvs_flash.h" #include "nvs.h" #include "driver/gpio.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" void set_counter(int32_t val){ nvs_handle_t my_handle; esp_err_t err = nvs_open("storage", NVS_READWRITE, &my_handle); if (err == ESP_OK) { nvs_set_i32(my_handle, "counter", val); nvs_commit(my_handle); nvs_close(my_handle); } } void app_main(void) { gpio_reset_pin(5); gpio_reset_pin(4); gpio_set_direction(4, GPIO_MODE_INPUT); gpio_set_direction(5, GPIO_MODE_INPUT); gpio_set_pull_mode(5,GPIO_PULLDOWN_ONLY); gpio_set_pull_mode(4,GPIO_PULLDOWN_ONLY); esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); err = nvs_flash_init(); } nvs_handle_t my_handle; int32_t counter = 0; err = nvs_open("storage", NVS_READWRITE, &my_handle); if (err == ESP_OK) { nvs_get_i32(my_handle, "counter", &counter); printf("counter = %ld \n", counter); nvs_close(my_handle); } while (1){ if(gpio_get_level(4)){ counter++; set_counter(counter); printf("counter is now %ld.\n", counter); vTaskDelay(500 / portTICK_PERIOD_MS); } if(gpio_get_level(5)){ counter = 0; set_counter(counter); printf("counter reset to 0 \n count = %ld.\n", counter); vTaskDelay(500 / portTICK_PERIOD_MS); } } } |
Для работы с NVS (энергонезависимой памятью) сначала необходимо подключить требуемые библиотеки. В этом примере мы добавили два новых заголовочных файла: nvs_flash.h и nvs.h. Эти библиотеки предоставляют функции, необходимые для инициализации, чтения, записи и управления данными, хранящимися в памяти NVS.
Внутри app_main() мы начинаем с инициализации флэш-памяти NVS с помощью команды nvs_flash_init(). Это подготавливает флэш-память к использованию для хранения данных. Мы также проверяем наличие возможных ошибок инициализации, таких как нехватка свободных страниц или обнаружение старой версии NVS. Если возникает какая-либо из этих проблем, мы стираем раздел NVS и инициализируем его заново.
Далее мы создаём дескриптор NVS (my_handle) и переменную с именем counter и значением по умолчанию 0. Дескриптор действует как соединение с хранилищем NVS. Затем мы открываем пространство имён NVS с именем "storage"используя флаг NVS_READWRITE, который позволяет нам как читать, так и записывать данные. После открытия пространства имён мы получаем сохранённое значение счётчика с помощью nvs_get_i32() и сохраняем его в переменной counter. Значение выводится в терминал, после чего дескриптор NVS закрывается.
Затем программа переходит в бесконечный цикл, который непрерывно проверяет состояние двух кнопок. При нажатии кнопки, подключенной к GPIO 4, значение счетчика увеличивается на единицу. Обновленное значение сохраняется в NVS с помощью соответствующей функции set_counter(), и новое значение счетчика выводится на терминал. При нажатии кнопки, подключенной к GPIO 5, счетчик сбрасывается до нуля 0, снова сохраняется в NVS, и отображается обновленное значение.
Функция set_counter() отвечает за сохранение значения счетчика в NVS. Она принимает один аргумент val который представляет собой значение, которое мы хотим сохранить. Внутри функции мы открываем пространство имен "storage", используем nvs_set_i32() для сохранения целочисленного значения под ключом "counter", а затем вызываем nvs_commit()для окончательной записи изменений во флэш-память. Наконец, мы закрываем дескриптор NVS, используя nvs_close() для освобождения ресурсов.
NVS также поддерживает строки, мы можем хранить строки, используя nvs_set_str()
|
1 2 |
char ssid[] = "MyWiFi"; nvs_set_str(my_handle, "wifi_ssid", ssid);nvs_commit(my_handle); |
Для чтения строки мы используем функцию nvs_get_str поскольку не уверены в размере хранимой строки. Сначала мы используем функцию для получения размера строки, затем создаем файл-паразит того же размера и считываем строку.
|
1 2 3 4 5 6 |
size_t required_size; nvs_get_str(my_handle, "wifi_ssid", NULL, &required_size); char *buffer = malloc(required_size); nvs_get_str(my_handle, "wifi_ssid", buffer, &required_size); printf("SSID: %s\n", buffer); free(buffer); |
Хотя NVS отлично подходит для работы с очень мелкими переменными, он совершенно непригоден для больших наборов данных. Если мы создаем веб-сервер и нам нужно хранить HTML, CSS и файлы изображений, или если мы записываем тысячи показаний температуры в текстовый файл, нам необходима настоящая файловая система.
ESP-IDF в основном поддерживает две файловые системы для внутренней флэш-памяти:
- SPIFFS (SPI Flash File System): более старая, широко используемая файловая система. Она обеспечивает выравнивание износа, но не поддерживает настоящие каталоги. Если er создает файл по адресу
/data/logs/temp.txt, SPIFFS просто рассматривает всю строку"/data/logs/temp.txt"как имя файла. - LittleFS: новая, высоконадежная файловая система, разработанная специально для микроконтроллеров. В отличие от SPIFFS, LittleFS поддерживает реальную структуру каталогов, значительно быстрее и обладает высокой устойчивостью к перебоям питания во время операций записи. Благодаря превосходной производительности и безопасности, LittleFS обычно рекомендуется вместо SPIFFS для современных проектов на базе ESP32-S3.
По умолчанию ESP-IDF не резервирует флэш-память для пользовательской файловой системы. Для использования LittleFS необходимо создать пользовательскую таблицу разделов, которая выделяет отдельную область флэш-памяти для хранения данных.
Создайте файл с именем partitions.csv в корневом каталоге проекта рядом с файлом CMakeLists.txt со следующим содержимым:
|
1 2 3 4 5 |
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 1M, storage, data, spiffs, , 1M, |
В этой таблице раздел storage определяет область флэш-памяти размером 1 МБ, которая будет использоваться LittleFS. Даже если подтип указан как spiffs, раздел все равно можно смонтировать и использовать с LittleFS.
Далее нам нужно указать ESP-IDF использовать нашу пользовательскую таблицу разделов и включить компонент esp_littlefs в проект. Это можно сделать с помощью менеджера компонентов ESP-IDF, создав файл с указанным именем idf_component.yml внутри каталога main/.
Менеджер компонентов ESP-IDF автоматически загрузит и интегрирует компонент LittleFS в процессе сборки.
|
1 2 |
dependencies: espressif/esp_littlefs: "^1.1.0" |
После этого включите настройку пользовательской таблицы разделов, выполнив следующую команду в терминале:
|
1 |
idf.py menuconfig |
В меню настроек перейдите по следующему пути:
|
1 |
Partition Table -> Partition Table |
Затем измените параметр следующим образом:
|
1 |
Single factory app, no OTA |
В:
|
1 |
Custom partition table CSV |
Наконец, сохраните конфигурацию и выйдите из меню.
Теперь, когда хранилище LittleFS настроено, мы можем начать использовать его в нашем проекте. ESP-IDF интегрирует LittleFS с уровнем виртуальной файловой системы (VFS), что означает, что мы можем использовать стандартные файловые функции C, такие как fopen(), fprintf(), fgets(), и , fclose() точно так же, как и в настольной системе.
Следующий пример демонстрирует полный рабочий процесс:
- Монтирование раздела LittleFS;
- Создание файла и запись в него;
- Считывание файла обратно;
- Отключение файловой системы.
|
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 46 47 48 49 50 51 52 53 54 55 56 57 58 |
#include <stdio.h> #include <string.h> #include "esp_littlefs.h" void app_main(void) { // 1. Configure the LittleFS mount esp_vfs_littlefs_conf_t conf = { .base_path = "/littlefs", // The root path we will use in code .partition_label = "storage", // Must match the name in partitions.csv .format_if_mount_failed = true, // Format the partition if it's empty/corrupted .dont_mount = false, }; // 2. Initialize and mount the file system esp_err_t ret = esp_vfs_littlefs_register(&conf); if (ret != ESP_OK) { printf("Failed to mount or format filesystem\n"); return; } printf("LittleFS mounted successfully.\n"); // 3. Write a file (standard C library functions) printf("Opening file for writing...\n"); FILE *f = fopen("/littlefs/hello.txt", "w"); if (f == NULL) { printf("Failed to open file for writing\n"); return; } fprintf(f, "Hello World from LittleFS on ESP32-S3!\n"); fclose(f); printf("File written.\n"); // 4. Read the file back printf("Opening file for reading...\n"); f = fopen("/littlefs/hello.txt", "r"); if (f == NULL) { printf("Failed to open file for reading\n"); return; } char line[64]; fgets(line, sizeof(line), f); fclose(f); // Remove newline character if present char *pos = strchr(line, '\n'); if (pos) { *pos = '\0'; } printf("Read from file: '%s'\n", line); // 5. Unmount (optional, usually done before a system reboot) esp_vfs_littlefs_unregister(conf.partition_label); } |
Первый шаг — создание структуры конфигурации esp_vfs_littlefs_conf_t. Она указывает ESP-IDF, как должна быть смонтирована файловая система. Поле base_path определяет виртуальный каталог, где файловая система будет отображаться в нашем приложении, а partition_label должно совпадать с именем раздела, определенным в partitions.csv.
Эта опция format_if_mount_failed особенно полезна на этапе разработки. Если раздел пуст или поврежден, ESP-IDF автоматически отформатирует его, вместо того чтобы завершить процесс монтирования с ошибкой.
Далее мы монтируем файловую систему с помощью команды make mount esp_vfs_littlefs_register(). Если монтирование проходит успешно, раздел становится доступным через слой VFS, что позволяет нам использовать стандартные операции с файлами C.
Для создания файла мы используем fopen()команду write в режиме записи ( "w"). Путь к файлу начинается с <путь>, /littlefs/поскольку это точка монтирования, которую мы настроили ранее. Затем мы записываем текст в файл с помощью команды write fprintf()и закрываем его с помощью команды write, fclose()чтобы гарантировать корректное сохранение данных.
После записи пример повторно открывает тот же файл в режиме чтения ( "r"). Функция fgets() считывает содержимое файла в символьный буфер, который затем выводится в последовательный монитор.
Наконец, мы отключаем файловую систему с помощью команды make esp_vfs_littlefs_unregister(). Этот шаг необязателен во многих встроенных приложениях, но его рекомендуется выполнять перед перезагрузкой устройства или выключением системы.
Хотя файловые системы, такие как LittleFS и SPIFFS, отлично подходят для управления файлами и каталогами, они создают накладные расходы. Если нам нужно регистрировать огромные массивы необработанных метрик датчиков, передавать двоичные данные на высоких скоростях или управлять пользовательскими криптографическими блоками, полный отказ от виртуальной файловой системы (VFS) обеспечивает нам абсолютный контроль и максимальную производительность.
Доступ к необработанной флэш-памяти позволяет нам напрямую считывать и записывать данные в определенные физические сектора микросхемы SPI-флэш-памяти с помощью API разделов ESP-IDF.
Работа с необработанной флэш-памятью очень эффективна, но требует соблюдения физических ограничений NOR-флэш-памяти:
- Стирание перед записью: ячейки флэш-памяти могут быть переведены из логического состояния
1в логическое только0во время операции записи. Чтобы снова преобразовать логическое состояние0в логическое1, необходимо стереть весь сектор. Мы не можем перезаписать существующий байт, не стерев предварительно его сектор. - Выравнивание по секторам: операции стирания не могут быть нацелены на отдельные байты; они работают строго с секторами размером 4 КБ (4096 байт). Любое смещение и размер при стирании должны быть идеально выровнены по границе 4 КБ.
- Управление износом: в отличие от LittleFS, при прямом доступе к разделу выравнивание износа не происходит автоматически. Непрерывная запись в один и тот же сектор приведет к преждевременному износу этой области флэш-памяти.
Чтобы предотвратить случайное перезаписывание системного кода или данных NVS приложением, необходимо выделить отдельный раздел песочницы в пользовательской конфигурации.
В файл partitions.csv мы добавляем пользовательскую запись данных:
|
1 2 3 4 5 |
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 1M, raw_data, data, 0x99, , 256K, |
В этой конфигурации мы определяем раздел с меткой raw_data. Мы используем универсальный тип data и присваиваем ему пользовательский шестнадцатеричный подтип ( 0x99), чтобы четко определить его как нефайловое, необработанное пространство хранения. Его размер ограничен 256 КБ.
Для взаимодействия с этим разделом необходимо включить заголовок esp_partition.h. Рабочий процесс включает в себя определение раздела по его метке, удаление целевого сектора, запись двоичных данных и их последующее считывание.
Следующий пример демонстрирует безопасную транзакцию с использованием необработанной флэш-памяти:
|
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
#include <stdio.h> #include <string.h> #include "esp_partition.h" void app_main(void) { // 1. Find the custom partition const esp_partition_t *partition = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, 0x40, "storage" ); if (partition == NULL) { printf("Partition not found\n"); return; } printf("Partition found: %s\n", partition->label); // 2. Data to write const char *message = "Hello from raw flash memory!"; // 3. Erase flash sector before writing esp_err_t ret = esp_partition_erase_range( partition, 0, 4096 ); if (ret != ESP_OK) { printf("Failed to erase partition\n"); return; } // 4. Write data to flash ret = esp_partition_write( partition, 0, message, strlen(message) + 1 ); if (ret != ESP_OK) { printf("Failed to write data\n"); return; } printf("Data written successfully.\n"); // 5. Read data back char buffer[64]; ret = esp_partition_read( partition, 0, buffer, sizeof(buffer) ); if (ret != ESP_OK) { printf("Failed to read data\n"); return; } printf("Read from flash: '%s'\n", buffer); } |
Первый шаг — определение местоположения раздела с помощью команды esp_partition_find_first(). Мы ищем раздел с типом ESP_PARTITION_TYPE_DATA, подтипом 0x40 и меткой "storage".
Если раздел существует, ESP-IDF возвращает указатель на структуру esp_partition_t, содержащую информацию о разделе, включая его адрес, размер и метку.
Перед записью во флэш-память необходимо стереть целевую область с помощью команды sudo apt-remove esp_partition_erase_range(). Флэш-память не может напрямую перезаписывать существующие данные. Вместо этого сначала необходимо стереть сектора флэш-памяти, обычно блоками по 4 КБ.
После стирания сектора мы записываем необработанные байты во флэш-память, используя метод esp_partition_write(). В этом примере мы храним простую текстовую строку, но тот же метод можно использовать для бинарных структур, массивов или сериализованных объектов.
Для проверки работы мы считываем данные обратно с помощью функции esp_partition_read(). Данные копируются в буфер ОЗУ и выводятся в последовательный монитор.
В отличие от LittleFS или SPIFFS, прямой доступ к флэш-памяти не предоставляет файлов, папок или автоматического управления памятью. Все обрабатывается вручную, что делает этот метод чрезвычайно эффективным, но также и более подверженным ошибкам, если смещения и операции стирания не управляются должным образом.
Подобно тому, как фрагментация кучи представляет опасность для оперативной памяти, износ флэш-памяти является основной опасностью для энергонезависимых хранилищ.
Флэш-память со временем физически деградирует. Каждый раз, когда мы стираем и записываем сектор, кремний получает микроскопические повреждения. Типичный чип флэш-памяти рассчитан примерно на 100 000 циклов стирания/записи на сектор.
Если бы мы каждую секунду записывали показания температуры в один и тот же необработанный адрес флэш-памяти, мы бы уничтожили этот конкретный сектор чипа всего за сутки. Чтобы предотвратить это, NVS, SPIFFS и LittleFS используют алгоритмы выравнивания износа. Алгоритмы выравнивания износа незаметно сопоставляют наши данные с различными физическими точками, обеспечивая равномерное распределение записи по всему чипу, что значительно продлевает срок службы оборудования.
1 просмотров






