Добрый день, уважаемый читатель! В этой статье поговорим о использовании оперативной памяти ( RAM ) в FreeRTOS / ESP-IDF. С оперативной памятью на ESP32 не всё так просто и очевидно, как в однопоточных контроллерах.
Предупреждение! Данная статья уже несколько устарела. Дело в том, что я более не рассматриваю Дзен в качестве серьезной платформы для своих статей (причина - политика самого Дзена). А следовательно, я не корректирую и не дополняю их здесь, на этой платформе. Почти все статьи, которые опубликованы на канале, есть на моем основном сайте - kotyara12.ru. Там я стараюсь исправлять поддерживать статьи в актуальном состоянии: исправляю ошибки, добавляю что-то новое, и т.д. Если вы хотите прочитать актуальную версию статьи - рекомендую вам перейти по ссылке под данным предупреждением. Либо можете ознакомиться с двумя версиями и сравнить их.
Актуальная версия статьи: https://kotyara12.ru/iot/esp32_memory/
Да, это ещё одна "теоретическая" статья, но она, на мой взгляд, совершенно необходима перед тем, как мы научимся запускать задачи.
Предполагается, что вы знакомы с общими принципами работы с динамической памятью (динамическую память еще иногда называют heap или куча, я буду использовать все эти термины). Но я ещё раз "пройдусь" по общим моментам. Возможно, я где-то ошибаюсь, поправьте меня в комментариях.
Если вы посещали форумы для начинающих arduin-щиков, то наверняка сталкивались с рекомендациями не использовать для хранения изменяемых строк динамическую память. Аргументировано это, как правило, тем, что на микроконтроллерах "сборка мусора" отсутствует как класс, а частое выделение и освобождение небольших блоков памяти приводит к её быстрой фрагментации (когда небольшие свободные блоки памяти густо перемешаны с занятыми блоками памяти, что делает невозможным выделение больших по размеру блоков памяти). В итоге очень часто в примерах для работы со строками используются заранее выделенные (на этапе компиляции) статические буферы заведомо большего размера. На самом деле это отчасти правильно - это и значительно быстрее (не тратится процессорное время на выделение
и освобождение памяти) и не приводит к фрагментации кучи.
Но с ростом сложности программы это становится проблемой - строк и массивов ( с заранее неизвестной длиной ) становится столь много, что выделять под каждый значительного размера буфер становится нереальным. Поясню на примере. Допустим, нам требуется отправить в mqtt сообщение. Большая часть сообщений будет занимать 10-20 символов. Но иногда требуется отправить сообщение и значительно большего размера, когда генерируется большое и сложный json-пакет, например длиной 3-4 килобайта. И если под буфер отправки выделить статический буфер не проблема, то "собрать из кусочков" такой JSON без использования кучи становится нереально - понадобится несколько десятков таких буферов с заранее неизвестным большим размером. Которые, к тому же, будут реально использоваться очень редко, а статически выделенная память выделена все время. Если мы попытаемся так сделать, то свободная оперативная память очень-очень быстро закончится под такие вот буферы. А с динамическим выделением память расходуется гораздо более экономно. Нужно только очень внимательно следить за освобождением выделенной памяти, иначе будут возникать так называемые утечки памяти. При этом желательно освобождать память в обратном порядке выделению, чтобы избежать ее фрагментации (не всегда это возможно, но нужно стараться).
На самом деле ESP-IDF почти везде использует динамическое выделение памяти под различные данные. И мои прошивки в большинстве своем используют также динамическое выделение памяти. И при этом устройства работают месяцами без сбоев и перезагрузок. Это говорит о том, что динамическое выделение памяти в общем-то не так и страшно.
Но в некоторых случаях я всё-таки предпочитаю использовать статическое выделение памяти, когда это возможно и оправдано. Например: почти все задачи, очереди и другие объекты FreeRTOS я предпочитаю выделять статически. А смысл выделять их динамически? Они запускаются один раз при старте системы и работают постоянно. Буферы, которые используются постоянно, тоже лучше выделить статически. В общем, нужно подходить к методу выделения памяти вдумчиво, в зависимости от поставленной задачи.
Особенности RAM на ESP32
Если обратиться к спецификациям на ESP32, то можно увидеть, что данный микроконтроллер имеет несколько разных типов оперативной памяти ( RAM ). Например для модуля ESP32-WROOM-32D/U ( ESP32-D0WD-V3 ) это выглядит примерно так:
- 520 КБ встроенной SRAM для данных и инструкций
- 8 КБ SRAM в RTC, которая называется RTC FAST Memory и может использоваться основным процессором во время загрузки RTC из режима глубокого сна
- 8 КБ SRAM в RTC, которая называется RTC SLOW Memory и может быть доступна только сопроцессору ULP в режиме глубокого сна
- 1 Кбит eFuse: 256 бит используются для системы (MAC-адрес и конфигурация чипа), а остальные 768 бит зарезервированы для клиентских приложений, включая флэш-шифрование и идентификатор чипа
Некоторые модули, например ESP32-WROVER-IE (ESP32-D0WD-V3), имеют также некоторое количество "внешней" оперативной памяти (и FLASH), подключенной через интерфейс QSPI, которая поэтому иногда так и называется - SPIRAM. Но она отображается в адресное пространство CPU не вся сразу, а постранично, через буфер; поэтому доступ к ней происходит заметно медленнее, чем к основной памяти. Об тонкостях своих "танцев с бубном и ESP32-WROVER" я планирую рассказать позже, если не забуду.
Для большинства приложений, мы можем использовать только первый тип RAM - 520 КБ встроенной SRAM. Казалось бы: 520 КБ это достаточно много, хватит на всё ( где-то это мы уже слышали, ага ). Но, если вы захотите проверить, сколько памяти вам доступно из вашей программы, то обнаружите, что общий размер доступной памяти значительно меньше, порядка 270~300 КБ:
"heap_kb": {"total": 271.6, "errors": 0, "free": 151.7, "free_percents": 55.9, "free_min": 112.5, "free_min_percents": 41.4}
Это именно общий размер памяти CAP_DEFAULT (тип памяти при вызове malloc(), calloc()), в том числе выделяемый и для нужд FreeRTOS. Куда делись около 200 КБ памяти!!!??? Дело в том, что общий блок памяти 520 КБ делится на два (источники: тыц и тыц):
- IRAM 192 КБ - Instruction RAM (сегмент кода, также называемый текстовым сегментом, в котором скомпилированная программа находится в памяти)
- DRAM 328 КБ - Data RAM (используется для BSS, данных, кучи)
То есть на самом деле для размещения данных доступно только 328 КБ. DRAM распределяется следующим образом:
- Первые 8 КБ используются в качестве памяти данных для некоторых функций ПЗУ.
- Затем компоновщик помещает инициализированный сегмент данных после этой первой памяти объемом 8 КБ.
- Далее следуют сегмент BSS («block started by symbol», также называемый сегментом неинициализированных данных, где хранятся глобальные и статические переменные с нулевой инициализацией).
- Сегмент данных (также называемый сегментом инициализированных данных, где хранятся инициализированные глобальные и статические переменные).
- Память, оставшаяся после выделения данных и сегментов BSS, настроена на использование в виде кучи. Вот где обычно происходит динамическое распределение памяти.
- Стек вызовов, в котором хранятся параметры функций, локальные переменные и другая информация, относящаяся к функциям.
Обратите внимание, что размер сегментов данных и BSS зависит от приложения. Но, насколько я понимаю, описанная структура памяти не является какой-то особенностью ESP32, это свойственно вообще любым программам.
Из-за технических ограничений ESP32 максимальное использование статически выделенной памяти BSS составляет 160 КБ. Остальное отдается под общую кучу. Во время выполнения доступная динамическая память кучи может быть меньше, чем рассчитано во время компиляции, поскольку при запуске часть памяти выделяется из кучи до запуска планировщика FreeRTOS (включая память для стеков начальных задач FreeRTOS).
Таким образом, каждое приложение, в зависимости от используемых им компонентов и вызываемых им API, изначально имеет разный доступный размер кучи. В моей прошивке (с учетом примерно 8~10 статически выделяемых задач) это обычно что-то около 270 КБ.
Куда же попадет ваша переменная после компиляции?
Куда же попадет ваша переменная после компиляции?
- Локальная переменная, например самый простой int внутри какой-нибудь функции или задачи, попадет в стек - общий (если это однопоточный Arduino) или стек задачи (если эта переменная внутри задачи FreeRTOS). Но её преимущество - она будет автоматически уничтожена сразу после того, как покинет область видимости, то есть станет не нужна. И на её место может спокойно лечь другая такая же переменная, что есть хорошо.
- Глобальная переменная или помеченная как static - попадёт либо в сегмент BSS, либо в сегмент инициализированных данных (что, в общем-то без особой разницы, они находятся рядышком) и будет там находится постоянно. Основное преимущество - они находятся "ниже" общей кучи и не вносят вклад в её фрагментацию. Но если бы будете использовать глобальные или статические переменные в программе всего пару раз - то это и есть абсолютное зло, так как место под них всё остальное время будет занято впустую.
- Динамические переменные, то есть размещаемые в памяти с помощью malloc() или calloc(), расположены в общей куче (heap). Для минимизации фрагментации памяти желательно, чтобы такие переменные "жили" как можно меньше по времени. Чем дольше по времени используется динамическая переменная, тем больший вклад в фрагментацию памяти она может вносить (но может и не вносить, если она расположена в самом начале кучи, например).
В общем думайте, прежде чем создать переменную, как вы будете её использовать в своей программе. Бездумное назначение переменных в некоторых случаях может приводить к проблемам в работе программы.
Выделение стека для задач FreeRTOS
Переполнение стека - одна из проблем, с которыми сталкиваются пользователи, которые только начали осваивать FreeRTOS. Дело в том, что FreeRTOS требует выделение отдельного стека вызовов для каждой задачи, которую вы создаете.
Стек - это динамическая память , выделяемая потоком управления программой. Стек вызовов отслеживает все активные функции (те, которые были вызваны, но еще не завершились) от начала программы (задачи) до текущей точки выполнения и обрабатывает размещение всех параметров функций и локальных переменных.
Каждая задача FreeRTOS содержит собственный стек, общий размер которого указывается при создании задачи. Стек задачи выдается из общего пула памяти кучи. Создали десять задач - будьте добреньки выделить десять блоков памяти из общей кучи под стек.
Этот момент является основным поводом для критики со стороны программистов Arduino, не желающих разбираться с FreeRTOS. Я очень часто встречал подобные комментарии на различных форумах.
Как подобрать размер стека для задачи? Если выделить слишком большой стек для задачи - останется меньше памяти под кучу и для других задач. Если выделить слишком маленький стек - в какой-то момент времени (не обязательно сразу) мы получим перезагрузку контроллера из-за переполнения стека. Переполнение стека (stack overflow) наиболее частая причина нестабильности приложения.
На самом деле, на этот вопрос нет однозначного ответа. Подобрать оптимальный размер стека можно опытным путем. Обычно, при создании очередной задачи я выделяю заведомо больший стек, чем это нужно, а уже потом, с помощью специальных функций отладки (тыц, тыц и тыц), постепенно уменьшаю его размер, добиваясь стабильной работы при минимально возможном стеке.
Espressif рекомендует не делать размер стека меньше чем 2048 байт, но у меня некоторые из задач (например "пищалка" и "мигалка светодиодами" стабильно работают при размере стека всего в 1024 байт (1КБ).
Как уменьшить размер используемого стека?
При использовании стандартных библиотечных функций Cи стек может использоваться очень интенсивно, особенно при вводе/выводе и поддержке строковых функций, таких как sprintf(). Но при размещении данных в областях памяти, выделяемых динамически с помощью функций malloc() и calloc(), стек задачи не используется - в этом случае данные размещаются в общей куче (и это может использоваться при передаче данных между разными задачами).
Кроме того, стек достаточно интенсивно используется при вызове функций внутри задачи, когда вызываемой функции передается большое количество "тяжёлых" параметров. Все эти параметры, включая адрес самой функции, размещаются в стеке. Исходя из этого при необходимости передачи большого количества переменных в какую-либо функцию гораздо лучше объявить структуру данных при помощи typedef struct, объявить переменную-указатель, выделить под нее память в куче, заполнить ее данными и только после этого передать в функцию указатель на переменную. В этом случае в стек вызовов попадет только указатель 32 бит / 4 байта. Если размер параметров функции не превышает 4 байт, то никакого смысла это не имеет.
Возьмем пример одной из библиотечных функции ESP-IDF:
esp_err_t esp_mqtt_set_config(esp_mqtt_client_handle_t client, const esp_mqtt_client_config_t *config)
где *config - указатель на довольно громоздкую структуру esp_mqtt_client_config_t.
В этом случае вызов данной функции происходит гораздо экономнее в плане стека, чем если бы мы "затолкали" все данные из структуры esp_mqtt_client_config_t непосредственно в стек.
Избегайте глубоких рекурсивных вызовов функций. Отдельные рекурсивные вызовы функций не всегда увеличивают использование стека каждый раз, когда они вызываются, но если каждая функция включает большие переменные на основе стека, накладные расходы могут стать довольно высокими.
Избегайте размещения больших переменных в стеке. В си любая большая структура или массив, выделенные как «автоматическая» переменная (т. е. область действия объявления C по умолчанию), будут использовать пространство в стеке. Минимизируйте их размеры, выделяйте их статически и/или посмотрите, сможете ли вы сэкономить память, выделяя их из кучи только тогда, когда они необходимы. На примере той же структуры esp_mqtt_client_config_t рассмотрим, куда попадает переменная в зависимости от того, как мы ее объявили.
1. Если объявить esp_mqtt_client_config_t как локальную переменную:
esp_mqtt_client_config_t mqtt_config;
mqtt_config.host = "m1.wqtt.ru"
mqtt_config.port = 8888;
....
esp_mqtt_set_config (&client, &mqtt_config);
В этом случае переменная mqtt_config попадет в стек задачи, из которой мы выполнили вызов этого кода. Но после того, как она уйдет из области видимости, она будет автоматически удалена компилятором из стека. Вполне допустимый вариант, хотя и не оптимальный.
2. Если объявить esp_mqtt_client_config_t как статическую переменную:
static esp_mqtt_client_config_t mqtt_config;
mqtt_config.host = "m1.wqtt.ru"
mqtt_config.port = 8888;
....
esp_mqtt_set_config (&client, &mqtt_config);
В этом случае переменная mqtt_config попадает в сегмент BSS и хранится там всегда. На мой взгляд в данном конкретном случае это очень неразумное расходование памяти (так как настройки эти нужны только один раз при запуске). Хотя стек задачи не пострадает.
3. Если объявить esp_mqtt_client_config_t как статическую переменную с инициализацией:
static esp_mqtt_client_config_t mqtt_config = {
.host = "m1.wqtt.ru",
.port = 8888,
};
esp_mqtt_set_config (&client, &mqtt_config);
В этом случае переменная mqtt_config попадает в сегмент данных (после BSS) и хранится там всегда. Тоже не лучше.
4. Самый сложный (с точки зрения программиста) вариант:
esp_mqtt_client_config_t* mqtt_config;
mqtt_config = (esp_mqtt_client_config_t*)calloc(1, sizeof(esp_mqtt_client_config_t));
mqtt_config->host = "m1.wqtt.ru"
mqtt_config->port = 8888;
....
esp_mqtt_set_config (&client, mqtt_config);
free(mqtt_config);
В этом случае стек задачи для хранения данных конфигурации не используется, а переменную нужно выделить в куче "вручную" и не забыть её удалить, когда она не нужна. На мой взгляд, это самый правильный вариант.
Да, конечно, если с момента вызова calloc() до free() будут ещё запросы на выделение памяти (а они будут, хотя бы внутри esp_mqtt_set_config()), то после освобождения памяти куча уже будет немного фрагментированной. Но, с одной стороны, размер блока под esp_mqtt_client_config_t достаточно велик, чтобы потом этот участок памяти мог быть повторно использован. А с другой стороны, если Вы сильно переживаете по поводу фрагментации - используйте первый способ (локальная переменная).
Если вы будете использовать TLS-соединения, то следует учесть, что ESP32 требует достаточно много памяти при установке соединения. ESP32, в отличие от ESP8266 "умеет" устанавливать полноценные (а не embedded) защищенные соединения, но для этого требуется много памяти. По умолчанию ESP-IDF настроена так, что память под буферы TLS выделяется статически. Если перенастроить ESP_IDF на использование динамических буферов TLS, то можно существенно сэкономить на свободной куче: что-то около 16-22 КБ. Но при этом мы немного потеряем в скорости соединения и, возможно, немного увеличим фрагментацию кучи. Более подробно я об этом расскажу в соответствующей статье.
Фрагментация кучи и борьба с ней
Очень часто можно услышать мнение, что использование динамического выделения памяти из кучи на микроконтроллерах - абсолютное зло, ибо фрагментация кучи все испортит и мы все умрем. На самом деле, есть такая проблема, но "не так страшен чёрт как его малюют". Можно спорить много и бесконечно, но мой опыт говорит о том, что причина фрагментации кроется чаще в неграмотном использовании этой самой памяти.
Допустим, мы сделали следующие шаги: разместили в куче несколько переменных - A, B, C, D. Затем запустили какую-либо задачу с выделением стека для неё из общей кучи. И в конце разместили в куче ещё парочку переменных. У нас получится (весьма условно, конечно) примерно такая картина:
Теперь, если нам понадобится удалить переменную B, мы получим маленькую "дырку" в области памяти:
Если после этого ваша программа попытается заново выделить память размером в 4 "условных экселевских блока", то менеджер памяти легко может отдать эту свободную область. А если нужно больше памяти? Тогда это место вроде бы и свободно, но использовать его не получится.
Другой вариант: для новой переменной нам требуется уже не 4 блока, а только 3. В этом случае снова получим свободный "хвостик", который никуда не получится использовать:
То же самое может произойти и с переменной E, если она будет освобождена / удалена, а переменная F - останется.
В итоге может случиться такая ситуация, что свободных блоков много, но они сильно раздроблены, и выделить один большой непрерывный блок не представляется возможным.
На самом деле не все так страшно. Даже взять ту же ESP-IDF - внутри стандартный библиотек динамическая память используется очень активно. Покопайтесь в исходниках, благо они открытые. Если бы проблема с фрагментацией действительно была бы столь катастрофической, я думаю, в Espressif давно озаботились бы этим. Но ничего...
Как бороться с фрагментацией?
Всего парочка достаточно очевидных советов:
Всегда старайтесь размещать постоянно используемые переменные и объекты как статические. То есть задачи, очереди, буферы, да вообще любые переменные, которые создаются при запуске программы и используется бесконечно долго. Тем более, что во FreeRTOS для очень многих объектов предусмотрено статическое выделение памяти.
Ещё раз - если вы при написании программы понимаете, что та или иная переменная (даже строка), будет нужна с момента старта и постоянно - её стоит пометить как static. Это заставит компилятор поместить её не в кучу или стек, а в секцию BSS, и она не будет вносить вклад в общую фрагментацию.
И наоборот, если переменная "живет" относительно недолго и используется нечасто - ей самый прямой путь в кучу (как это было описано выше). Ну или стандартный способ - в стек (обычные локальные переменные).
Никогда не забывайте удалять короткоживущие динамические объекты после использования - строки, параметры конфигурации и т.д. Иначе вы получите не только утечку памяти, но и увеличите её фрагментацию.
Практические выводы
На основании своего личного опыта, что выделение отдельного стека для каждой задачи - не проблема. Да, имеется небольшой "геморрой" на старте, пока вы подбираете его размер. Но в дальнейшем никаких проблем обычно не наблюдается (если вы в коде не накосячите, что вероятнее). В качестве примера могу привести список задач на одном из моих устройств (охранно-пожарная сигнализация, телеметрия температуры в доме и управление котлом), stack_minimum показывает "минимальный нижний уровень", который был достигнут задачей, то есть другими словами "остаточный запас стека":
- mqtt_task (ядро 1), stack size ???, stack_minimum 1412
- led_system (ядро 1), stack size 1024, stack_minimum 452
- led_alarm (ядро 1), stack size 1024, stack_minimum 528
- flasher (ядро 1), stack size 1024, stack_minimum 560
- buzzer (ядро 1), stack size 1024, stack_minimum 488
- siren (ядро 1), stack size 1024, stack_minimum 568
- sensors (ядро 1), stack size 4096, stack_minimum 1736
- alarm (ядро 1), stack size 4096, stack_minimum 2404
- http_send (ядро 1), stack size 3584, stack_minimum 716
- pinger (ядро 1), stack size 3072, stack_minimum 1024
- tg_send (ядро 1), stack size 3584, stack_minimum 771
- wifi (ядро 0), stack size ???, stack_minimum 4588
Я опустил из списка некоторые системные задачи, которые запускаются автоматически, вроде IDLE и IPC, и на которые я не могу повлиять.
Свободная память в этом случае - чуть менее 50%. Устройство стабильно работает долгое время без перезапусков.
А что с PROGMEM?
Если вы программировали на Arduino, то наверняка сталкивались с макросами препроцессора PROGMEM, PSTR и F, которые заставляют компоновщик изменять место хранения переменной в программе. Например PROGMEM помещает переменную во FLASH память. А что с ними на ESP32?
А на ESP32 макросы PROGMEM, PSTR и F отсутствуют!
То есть просто отсутствуют. Они нигде не встречаются в исходном коде ESP-IDF и в документации.
Комментарий от Дмитрия:
В avr-gcc все данные (в том числе и static const) по умолчанию размещаются в SRAM. А так как размер RAM в avr очень маленький, то использовался атрибут PROGMEM, который позволял разместить static const данные во flash (но при этом при непосредственном использовании все равно происходило копирование в SRAM – pgm_read….).
В ESP32 весь код по умолчанию размещается уже в external flash, кроме того в external flash размещаются все static const данные (при этом обращение к этим данным происходит непосредственно без всяких pgm_read), и таким образом атрибут PROGMEM становится ненужным.
Однако возникает противоположная проблема – в определенных режимах (например в обработчиках прерываний и т.д.) external flash недоступен, поэтому код, который работает в таких режимах должен быть размещен в SRAM (конкретно в IRAM). Для этого используется атрибут IRAM_ATTR. Код функций помеченный этим аттрибутом, будет размещен в IRAM. Однако размещение кода в IRAM не гарантирует также и размещение данных в RAM, т.е. например если внутри функции помеченной атрибутом IRAM_ATTR у нас будет static const массив, то этот массив компилятор все равно сможет разместить в external flash. Для того чтобы этого избежать и явно указать компилятору что static const данные нужно тоже размещать в RAM (конкретно в DRAM) используется атрибут DRAM_ATTR.
Все доступные для ESP32 атрибуты размещены в файле esp_attr.h – там вы сможете найти много интересных и полезных макросов.
Полезные ссылки
Как всегда, в заключении привожу список полезных ссылок и источников информации для статьи:
- Мой сайт: https://kotyara12.ru, здесь вы можете найти архив моих прежних статей (в том числе и на тему ESP32), а так же обзоры датчиков и заметки по электронике.
- Мои репозитории: https://github.com/kotyara12?tab=repositories
_______________
На этом пока всё, до встречи на сайте и на dzen-канале!
👍 Понравилась статья? Поддержите канал лайком или комментарием! Каналы на Дзене "живут" только за счет ваших лайков.
📌Подпишитесь на канал и вы всегда будете в курсе новых статей.
🔶 Полный архив статей вы найдете здесь
Благодарю за вашу поддержку! 🙏