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


В этом уроке научимся работать с динамической памятью микроконтроллера. Прежде всего нужно ознакомиться с распределением памяти и понять, как оно работает и что мы вообще будем делать. Перед вами схема распределения памяти в МК от AVR, которые стоят на Arduino:

Память – это по сути большой массив, каждая ячейка которого имеет свой адрес, адрес растёт слева направо (по картинке выше). Первым идёт Flash, она же программная память, память, в которой хранится сам код. Во время работы программы этот код не меняется (есть способы сделать это, но это не относится к теме урока). Сегодня нас интересует динамическая память, SRAM, которая на схеме представлена совокупностью синей, зелёной, розовой и оранжевой областей, а также белой областью со стрелочками между розовой и оранжевой. Рассмотрим подробнее:

  • Globals (синий и зелёный) – в этой области живут глобальные и статические переменные. размер этой области известен на момент запуска программы и не меняется в процессе её выполнения, т.к. глобальные и статические переменные объявлены, их размер и количество известны.
  • Heap (розовый), она же куча – из этой области мы можем выделить память под свои нужды. Размер этой области может меняться во время выполнения программы, куча “растёт” в сторону увеличения адресов, слева направо, что показано стрелкой на схеме. В этой области мы можем самостоятельно выделять память под свои нужды и освобождать её, опять же самостоятельно. Важно: процессор не даст вам выделить область, если свободной памяти под неё недостаточно, т.е. наползание кучи на стек очень маловероятно.
  • Stack, он же стек – в этой области живут локальные переменные и параметры, которые передаются функциям (формальные переменные). Размер этой области меняется во время выполнения программы, стек растёт от конца области памяти в сторону уменьшения адресов, навстречу куче (см. стрелку на схеме). Переменные, которые тут живут, называются автоматическими: программа сама выделяет память (при создании локальной переменной при входе в функцию), и сама эту память освобождает (локальная переменная удаляется при выходе из функции). Важно: процессор не контролирует размер стека, то есть во время работы стек может врезаться в кучу и перезаписать находящиеся там данные. Если вы эти данные туда помещали.
  • Available – доступная, свободная память. Как только она заканчивается, т.е. сталкиваются куча и стек – программа с большой долей вероятности зависает. Если стек самостоятельно контролирует свой размер, то с динамической памятью нужно быть аккуратнее: не забывать освобождать её, если она больше не нужна.

Выделение памяти


Для выделения и освобождения динамической памяти “из кучи” у нас есть две функции: malloc() и free() соответственно. Также есть операторы new и delete, которые делают то же самое. При выделении памяти мы получаем адрес на первый байт выделенной области, поэтому рекомендую почитать урок про адреса и указатели.

  • malloc(количество) – выделяет количество байт динамической памяти (из кучи) и возвращает адрес на первый байт выделенной области. Если свободной памяти недостаточно для выделения – возвращает “нулевой указатель” – NULL.
  • free(ptr) – освобождает память, на которую указывает ptr, назад в кучу. Адрес мы получаем в результате работы malloc() при выделении. Освободить можно только память, выделенную при помощи функций malloc(), realloc() или calloc(). В выделяемой области хранится размер этой области (+2 байта), и при освобождении функция free знает, какой размер освобождать.
  • new и delete – технически то же самое, разница в применении (см. пример ниже)
Важный момент: освобождать память нужно в обратном порядке от её выделения, чтобы избежать фрагментации памяти, т.е. когда между занятыми областями остаются пустые.

Рассмотрим пример с выделением и освобождением памяти при помощи malloc/free и new/delete. Примеры абсолютно одинаковые с точки зрения происходящего, отличаются только функциями:

malloc/free

void setup() {
  // выделяем память под 10 переменных типа byte (10 байт)
  byte *by = malloc(10);

  // выделяем память под 20 переменных типа int (40 байт)
  int *in = malloc(20 * sizeof(int));

  // выделяем память под 1 переменную типа uint32_t (4 байта)
  uint32_t *ui = malloc(4);

  // работаем с массивом
  by[0] = 50;
  by[1] = 60;
  by[2] = 90;
  uart.println(by[0]);
  uart.println(by[1]);
  uart.println(by[2]);

  // с обычной переменной
  *ui = 123456;

  free(ui); // освобождаем
  free(in); // освобождаем
  free(by); // освобождаем

  // тут *ui равна нулю! Мы её "удалили"
}

void loop() {}

new/delete

void setup() {
  // выделяем память под 10 переменных типа byte (10 байт)
  byte *by = new byte [10];

  // выделяем память под 20 переменных типа int (40 байт)
  int *in = new int [20];

  // выделяем память под 1 переменную типа uint32_t (4 байта)
  uint32_t *ui = new uint32_t;

  // работаем с массивом
  by[0] = 50;
  by[1] = 60;
  by[2] = 90;

  // с обычной переменной
  *ui = 123456;

  delete ui;    // освобождаем
  delete [] in; // освобождаем (указываем, что это массив)
  delete [] by; // освобождаем (указываем, что это массив)

  // тут *ui и остальные равны нулю! Мы их "удалили"
}

void loop() {}

Таким образом мы выделили себе память, с этой памятью можем взаимодействовать (как с обычной переменной), и потом её освободить. Напомню, что освобождать крайне желательно в обратном порядке, чтобы память освобождалась последовательно, не оставляя дыр.

Есть ещё две функции: calloc и realloc:

  • calloc(количество, размер) – выделяет память под количество элементов с размером размер каждый (в байтах). Тот же malloc, но чуть удобнее использовать: в примере выше мы умножали, чтобы получить нужное количество байт для хранения int malloc(20 * sizeof(int)), а можно было вызвать calloc(20, sizeof(int)); – заменив знак умножения на запятую =)
  • realloc(ptr, размер) – изменяет величину выделенной памяти, на которую указывает ptr, на новую величину, задаваемую параметром размер. Величина размер задается в байтах и может быть больше или меньше оригинала. Возвращается указатель на блок памяти, поскольку может возникнуть необходимость переместить блок при возрастании его размера. В таком случае содержимое старого блока копируется в новый блок и информация не теряется.

Пакетное управление памятью


Итак, выделять и освобождать память мы научились, теперь рассмотрим несколько инструментов для удобной работы с динамической памятью.

  • memset(ptr, значение, количество) – заполняет область памяти, на которую указывает ptr, байтами значение, в количестве количество штук. Часто используется для задания начальных значений выделенной области памяти. Внимание! Заполняем только байтами, 0-255.
  • memcpy(ptr1, ptr2, количество) – переписывает байты из области ptr2 в ptr1 в количестве количество. Грубо говоря переписывает один массив в другой. Внимание! Работает с байтами!
// === memset ===
// выделили 50 байт
byte *buf = malloc(50);

// забили их значением 10
memset(buf, 10, 50);

// === memcpy ===
// сделали массив
byte data1[] = {1, 2, 3, 4, 5};

// и ещё массив
//byte data2[5];  // можно из "стека" выделить
byte *data2 = malloc(5);  // можно из "кучи"

// перепишем data1 в data2
memcpy(data2, data1, 5);

// data2 теперь 1 2 3 4 5

Зачем?


Мы научились управлять динамической памятью микроконтроллера. Поздравляю! Но насколько полезен данный инструмент? Вопрос спорный. Я встречал работу с динамической памятью только в библиотеках дисплеев и адресных светодиодных лент, т.е. там в динамической памяти был создан буфер, в котором хранились байты данных. С таким же успехом можно было сделать просто массив в стеке, или в области глобальных переменных.

Также есть такой момент: среда Arduino IDE может примерно прикинуть размер занимаемой SRAM памяти на этапе компиляции, глобальные переменные она увидит, а вот создаваемые в процессе работы массивы – нет, и предсказать “а сколько там осталось” – сложно. С динамической памятью нужно работать аккуратно, не забывать её очищать и помнить о фрагментации. Динамическая память удобна в тех случаях, когда нужно поместить какой-то объём данных в буфер, который обладает большой областью видимости, в отличие от локальной переменной. С этим буфером можно взаимодействовать из разных уголков программы (передавая его адрес), а потом освободить память. А так, работа с динамической памятью вам скорее всего не пригодится, но помнить о таком инструменте полезно.

Важные страницы


  • Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
  • Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
  • Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
  • Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
  • Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете