Перейти к содержимому

Распределение памяти в ESP32

Добрый день, уважаемый читатель! В этой статье поговорим о использовании оперативной памяти ( RAM ) в FreeRTOS / ESP-IDF. С оперативной памятью на ESP32 не всё так просто и очевидно, как в однопоточных контроллерах.

Да, это ещё одна “теоретическая” статья, но она, на мой взгляд, совершенно необходима перед тем, как мы научимся запускать задачи.

Картинка из свободного доступа, в качестве иллюстрации

Предполагается, что вы знакомы с общими принципами работы с динамической памятью (динамическую память еще иногда называют 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 зависит от приложения. Из-за технических ограничений максимальное использование статически выделенной памяти BSS составляет 160 КБ. Остальное отдается под общую кучу. Во время выполнения доступная динамическая память кучи может быть меньше, чем рассчитано во время компиляции, поскольку при запуске часть памяти выделяется из кучи до запуска планировщика FreeRTOS (включая память для стеков начальных задач FreeRTOS).

Таким образом, каждое приложение, в зависимости от используемых им компонентов и вызываемых им API, изначально имеет разный доступный размер кучи. В моей прошивке (с учетом примерно 8~10 статически выделяемых задач) это обычно что-то около 270 КБ.

Выделение стека для задач 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);

В этом случае стек задачи для хранения данных конфигурации не используется, а переменную нужно выделить в куче “вручную” и не забыть её удалить, когда она не нужна. На мой взгляд, это самый правильный вариант.

Если вы будете использовать TLS-соединения, то следует учесть, что ESP32 требует достаточно много памяти при установке соединения. По умолчанию ESP-IDF настроена так, что память под буферы TLS выделяется статически. Если перенастроить ESP_IDF на использование динамических буферов TLS, то можно существенно сэкономить на свободной куче: что-то около 16-22 КБ. Но при этом мы немного потеряем в скорости соединения. Более подробно я об этом расскажу в соответствующей статье.

Практические выводы

На основании своего личного опыта, что выделение отдельного стека для каждой задачи – не проблема. Да, имеется небольшой “геморрой” на старте, пока вы подбираете его размер. Но в дальнейшем никаких проблем обычно не наблюдается (если вы в коде не накосячите, что вероятнее). В качестве примера могу привести список задач на одном из моих устройств (охранно-пожарная сигнализация, телеметрия температуры в доме и управление котлом):

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%. Устройство стабильно работает долгое время без перезапусков.

График свободной памяти на устройстве

Полезные ссылки

Как всегда, в заключении привожу список полезных ссылок и источников информации для статьи:

  1. ESP-IDF :: Heap Memory Allocation
  2. ESP-IDF :: Heap Memory Debugging
  3. ESP-IDF :: Minimizing RAM Usage
  4. ESP-IDF :: Minimizing Binary Size
  5. Mahavir Jain :: ESP32 Memory Analysis — Case Study
  6. Amey Inamdar :: ESP32 Programmers’ Memory Model
  7. Канал на Дзене
  8. Мои репозитории

Добавить комментарий

Ваш адрес email не будет опубликован.