Работа с динамической памятью

Динамическая память


Чаще всего во время работы мы используем локальные и глобальные переменные: промежуточные вычисления делаем в локальных, объекты классов библиотек создаём глобально и пользуемся ими в программе и так далее. Не углубляясь в программу можно понять, в каком месте доступна конкретная переменная, где её область определения, т.е. где она существует. Если мы видим имя такой переменной в коде – мы с уверенностью можем сказать, что в данном месте программы эта переменная существует. Её значение находится в памяти и мы можем с ним работать напрямую, прочитать или изменить его, а также “измерить” вес этой переменной. Дело в том, что жизненный цикл таких переменных известен заранее, они не могут менять свой размер в процессе работы программы, не могут появиться или исчезнуть в другое время или в другом месте, работа с ними проста и понятна.

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

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


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

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

Сегодня нас интересует динамическая память, SRAM, которая на схеме представлена совокупностью синей, зелёной, розовой и оранжевой областей, а также белой областью со стрелочками между розовой и оранжевой. Рассмотрим подробнее:

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

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


Для выделения и освобождения динамической памяти “из кучи” у нас есть готовые инструменты. При выделении памяти мы получаем адрес на первый байт выделенной области и его нужно будет хранить в указателе (читайте предыдущий урок про адреса и указатели).

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

Рассмотрим пример с выделением и освобождением памяти при помощи 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() {}

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

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

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() {}

Динамические объекты


Иногда бывает нужно использовать условную “библиотеку” на большой области определения, но делать это не на всём протяжении работы программы, т.е. иногда выделять объект из памяти, работать с ним, а затем удалять. Под библиотекой я в данном случае подразумеваю класс или структуру, т.к. 99.99% библиотек являются классами (читай урок про классы и объекты). С точки зрения программы, объект – это тоже переменная, просто “комплексная”. Мы всегда можем создать указатель на объект и использовать его динамически (не забываем, что оператор “точка” . превращается в “стрелочку” ->, см. урок про указатели). Абстрактный пример со стандартной библиотекой Servo, в которой объект создаётся динамически, а затем выгружается из памяти при повороте на заданный угол:

#include <Servo.h>
Servo* srv;   // указатель на тип Servo

void setup() {
  srv = new Servo;  // создали
  srv->attach(10);  // подключили
}

int count = 0;
void loop() {
  if (count < 100) {
    srv->write(count);
    count += 10;
    if (count >= 100) delete srv;  // удалили
    delay(100);
  }
}

Фрагментация


Мы можем зарезервировать себе область памяти, можем её освободить. Что может пойти не так?

  1. Если последовательно несколько раз выделить память, участки будут располагаться друг за другом.
  2. Если освободить участок, выделенный раньше предыдущих – в памяти останется “дырка”: занятые области не сдвигаются на освободившееся место!
  3. Если продолжить выделять память, программа будет примерять новую область сначала на “дырки”, а затем уже на свободную область в куче.
  4. Если неаккуратно обращаться с динамической памятью – эта самая память может закончиться гораздо быстрее, чем ожидалось!
Освобождать память нужно в обратном порядке от её выделения, чтобы избежать образования пустых мест.

Потерял ключи


Как вы могли видеть из примеров выше – освобождение блока памяти производится по указателю, то есть мы указываем, с какого адреса нужно освобождать память. Это означает, что нам ни в коем случае нельзя терять указатель, иначе мы не сможем освободить память! Это как потерять ключи от дома =) Абстрактный пример:

void foo() {
  {
    int* v = new int;
    *v = 12345;
  }
  // здесь и дальше мы уже не сможем удалить v!
}
Не забывайте освобождать память!

Существует ли переменная?


Как определить, существует ли на данный момент выделенная переменная и можно ли с ней работать? Очень просто: у нас ведь есть указатель на неё. Если передать указатель в условие, то он покажет true, если адрес ненулевой (переменная существует в памяти), и false – если нулевой (переменная не существует в памяти, обращаться по указателю нельзя). Возвращаясь к примеру с Серво, можно поступить так:

if (srv) srv->write(..);

То есть если объект существует (по указателю), то работаем с ним.

Но есть пара моментов, о которых нужно помнить, если в программе проводится такая проверка на существование:

  • Локально созданный указатель может быть ненулевым. Если при создании указателя локально вы не выделяете память сразу, то лучше задать его нулём, приравняв к NULL или nullptr: int* val = nullptr;
  • При освобождении памяти указатель не сбрасывается в ноль! Делаем это вручную:
    delete v;
    v = nullptr;

Вес динамической переменной


Если зачем-то понадобилось узнать вес динамической переменной, то нужно не перепутать, что измерять: мы работаем с указателем, а у указателя есть значение (тип), на которое он указывает. Например выделим байт памяти: byte* b = new byte;

  • Если измерить как sizeof(b) – результат будет 2 байта (AVR) и 4 байта (esp8266/esp32), потому что это – вес указателя, который зависит от разрядности адресного пространства микроконтроллера, т.е. результат не зависит от типа и веса данных!
  • Если измерить как sizeof(*b) – получим 1 байт, именно столько и весит байт, а мы измеряли размер данных по указателю.

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


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

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

Зачем?


Новичку работа с динамической памятью не понадобится, но знания будут полезны при изучении чужого кода. Также с динамической памятью мы сталкиваемся каждый раз, когда используем String-строки, которые являются динамическими массивами. Удобно, не правда ли?

Полезные страницы


5/5 - (7 голосов)
Подписаться
Уведомить о
guest

12 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии