Статическое распределение памяти в микроконтроллерах
Холмс: Любезнейший, не подскажите где мы находимся?
Пастух: Вы находитесь на воздушном шаре!!!
Холмс: Вы должно быть программист.
Пастух: Да, но как вы догадались?
Холмс: Только программист мог дать столь точный и
при этом столь бесполезный ответ.
… отрывок из известного анекдота
Если Вы когда нибудь программировали под микроконтроллер, неважно, с помощью Arduino IDE или напрямую работали с компилятором для AVR, ARM, или ESP, Вы наверняка видели отчеты о завершении сборки вроде
Sketch uses 1,090 bytes (3%) of program storage space. Maximum is 30,720 bytes.
Global variables use 21 bytes (1%) of dynamic memory, leaving 2,027 bytes for local variables. Maximum is 2,048 bytes.
Или
text data bss dec hex filename
52136 1148 12076 65360 ff50 MyProject
Такие отчеты действительно являются абсолютно точными… Вот только неполными, а потому не такими уж полезными. Проблема в том, что тут учитываются только те данные, которые были распределены статически. А вот все что выделяется через new или malloc в статистику не попадает. Как результат гораздо сложнее отследить моменты когда вдруг перестает хватать памяти и прошивка начинает работать неверно. А ведь памяти в микроконтроллерах обычно не очень много, и за этим параметром стОит тщательно следить.
На вскидку я не вспомнил ни одного примера для младших и средних микроконтроллеров, где бы применение динамического выделения памяти было бы действительно оправданно. Как правило это выделение некоторого буфера или создание каких нибудь объектов в самом начале работы прошивки, после чего эти объекты так и висят в памяти до следующего ресета. А это повод аллоцировать такую память статически — сегодня этим и займемся.
Статья рассчитана на новичков (хотя совсем уж базовые вещи рассказывать не буду — ожидаю, что читатель проштудировал хоть какую нибудь книгу по C++). Поехали.
Статическое распределение переменных
Если не вдаваться глубоко в детали и описания различных сегментов данных, то оперативную память в микроконтроллере можно поделить на 3 вида:
- статически распределенная память — сюда пападает все о чем знает компилятор на этапе сборки проекта: глобальные переменные и объекты, статические переменные в функциях и объектах
- куча (heap) — большая область памяти, из которой система (аллокатор памяти) нарезает кусочки кому сколько нужно. Функции вроде malloc и оператор new берут память как раз отсюда
- стек — место где процессор сохраняет регистры и адреса возвратов из функций, но также на стеке размещаются локальные переменные в функциях.
Давайте исследуем это на практике. Чтобы не парится с компилятором и системой сборки я воспользуюсь Arduino IDE под целевую платформу Arduino Nano.
Взглянем еще разок на отчет свыше (из шапки). Как я уже сказал в статистику попадают только те объекты, которые были распределены на этапе компиляции.
Global variables use 21 bytes (1%) of dynamic memory, leaving 2,027 bytes for local variables. Maximum is 2,048 bytes.
Судя по сообщению свободной памяти у нас просто завались. Но давайте посмотрим на код
int * buf;
void setup()
{
buf = new int[3000];
}
void loop() {}
Прошивка пытается выделить памяти больше чем есть в самом микроконтроллере. Разумеется, такой код работать не будет. Но, на мой взгляд. самая большая проблема тут в том, что код компилируется успешно, а компилятор вовремя не надавал по рукам и не предупредил, что память может закончится. И хорошо если есть внятный обработчик ошибки. А если нет?
Конечно, Вы сейчас скажете, мол сам себе злобный Буратино! Тебе отчет говорит, что есть всего 2 кб, а ты пытаешься выделить 3000 элементов по 2 байта каждый. Но давайте подумаем как бы это выглядело в реальном проекте. Скорее всего часть была бы уже занята какими нибудь переменными, некоторым образом бы использовался стек, сколько-то памяти было бы уже распределено динамически. И тут вдруг наступает какое нибудь редкое событие, которое потребует еще кусочек памяти и… ой все, приехали.
Что я имею в виду под статическим распределением и чего вообще хочу добиться? Я ничего не имею против динамического выделения памяти. Но мне не очень понятно зачем использовать динамическое выделение для объектов, буферов, и массивов заранее известного размера. Почему бы просто не объявить это как глобальную переменную?
Я пока временно уменьшил размер массива до более вменяемого размера.
int buf[300];
void setup()
{
buf[0] = 1; //Avoid optimizing this array
}
void loop()
{
}
Теперь компилятор и линковщик заранее знают про некий массив размером в 300×2=600 байт, который должен быть размещен в оперативной памяти. Более того, линковщик может выделить этому массиву фиксированный адрес, который при желании можно посмотреть утилитой objdump (если, конечно, найдете куда Arduino IDE кладет бинарь)
cd C:\Users\GrafAlex\AppData\Local\Temp\arduino_build_55567
"C:\Program Files (x86)\Arduino\hardware\tools\avr\bin\avr-objdump.exe" -x -D -S -s StaticMem.ino.elf
…
00800100 l O .bss 00000258 buf
…
Тут 0×00800100 это адрес, который линковщик присвоил нашему буферу, 0×258 это его размер (600 байт)
Попробуем теперь вернуть неадекватный размер в 3000 элементов и посмотреть что получится. Мы закономерно получаем «фе» от системы сборки
Sketch uses 456 bytes (1%) of program storage space. Maximum is 30,720 bytes.
Global variables use 6,009 bytes (293%) of dynamic memory, leaving -3,961 bytes for local variables. Maximum is 2,048 bytes.
...
Not enough memory; see http://www.arduino.cc/en/Guide/Troubleshooting#size for tips on reducing your footprint.
Собственно, это и была цель упражнения — компилятор и линкер еще на этапе сборки надавали по рукам и сказали, что выделять такой большой буфер это плохая идея.
Сейчас придут знатоки хороших практик кодирования и скажут, что глобальные переменные это зло. Предлагаю полемику на этот счет оставить за кадром, а кого глобальные переменные смущают, то я могу предложить несколько альтернатив. В сущности, все нижеописанные подходы служат одной и той же цели — подсказать компилятору, что определенный объект можно распределить в памяти еще на этапе компиляции/линковки, дать ему заранее известный адрес и место в оперативной памяти. Как бонус получаем еще отсутствие фрагментации памяти, а также у нас нет оверхеда на динамическую аллокацию (во всяком случае для таких объектов).
Статическая глобальная переменная
Можно объявить наш массив/объект/переменную используя слово static. Это ограничит область видимости переменной — к ней можно будет доступиться только из этого cpp файла.
static int buf[300];
void setup()
{
buf[0] = 1; //Avoid optimizing this array
}
Теперь даже если создать другой cpp файл и попробовать обратиться к нашему массиву, например через extern, то ничего не получится.
extern int buf[300];
void foo()
{
buf[0]++;
}
Линковщик выругается, что не может найти переменную buf
C:\Program Files (x86)\Arduino\hardware\arduino\avr\cores\arduino/main.cpp:47: undefined reference to `buf'
Если же в обоих файлах переменную buf объявить как static, то у каждого cpp файла эта переменная будет своя!
static int buf[300];
void foo()
{
buf[0]++;
}
Вывод objdump в этом случае будет выглядеть так
0080036a l O .bss 00000258 _ZL3buf.lto_priv.12
00800112 l O .bss 00000258 _ZL3buf.lto_priv.13
Безымянный namespace
Можно положить нашу глобальную переменную в безымянный namespace и получить те же плюшки, что и в предыдущем пункте. По сути это глобальная переменная с ограниченной областью видимости.
namespace
{
int buf[300];
}
Статические члены классов
Статические члены классов также распределяются на этапе компиляции.
class A
{
static int buf[300];
public:
int * getBuf()
{
return buf;
}
};
int A::buf[300];
void setup() {}
void loop()
{
A a;
a.getBuf()[0] += 1;
}
Несмотря на то, что экземпляров класса A может быть много (для примера я создаю по экземпляру каждый раз, когда попадаю в функцию loop ()), буфер объявлен как статический член класса. А это значит, что он будет существовать в единственном экземпляре и распределен в памяти еще на этапе линковки.
00800100 l O .bss 00000258 A::buf
Статические переменные в функциях
Если нужно статически распределить некий объект, но знать про него должна всего одна функция, то очень удобно использовать локальные статические переменные.
int getCounter()
{
static int counter = 0;
return ++counter;
}
void setup()
{
Serial.begin(9600);
}
void loop()
{
Serial.println(getCounter());
}
Переменная counter распределена статически. Она не будет создаваться каждый раз при вызове функции getCounter (), а будет иметь фиксированный адрес в памяти
00800116 l O .bss 00000002 getCounter()::counter
Побочным эффектом (а заодно и полезной особенностью) такого распределения является то, что значение переменных «выживают» между вызовами функции — при каждом следующем вызове getCounter () значение переменной будет увеличено на единицу.
Тем не менее с этим способом связано несколько нюансов. Например статическое распределение массивов будет работать как нужно
int * getBuf()
{
static int buf[300];
return buf;
}
Стандарт языка C++ гласит, что переменная buf будет инициализирована при первом вызове getBuf (), ну, а поскольку для инициализации тут ничего делать не нужно, то этот массив просто будет выделен где нибудь в секции .bss (область памяти, которая заполняется нулями на старте прошивки)
int * getBuf()
{
static int buf[300] = {1, 2, 3};
return buf;
}
В таком варианте компилятор также распределит эту переменную статически в оперативной памяти, но в памяти программ добавится еще 600 байт, которые будут хранить начальные значения для этого массива.
class Buf
{
public:
int buf[300];
Buf(int v)
{
buf[1] = v;
}
};
int * getBuf()
{
static Buf buf(1234);
return buf.buf;
}
В таком варианте у нас появляется нетривиальный конструктор, который что-то делает при первом вызове функции getBuf (). Для платформы AVR (ATMega) компилятор сгенерирует специальный флажок, который будет регулировать нужно ли запускать конструктор или он уже был запущен до этого. Это нужно иметь в виду, т.к. под этот флажок расходуется немножко оперативной памяти, а также будет неявная проверка флажка при каждом вызове getBuf (), что может сказаться на производительности.
А вот на платформе ARM (например, STM32) получается весьма неожиданная штуковина. Как только появляется нетривиальный конструктор прошивка сразу вырастает примерно на 60 кб и уже может не поместиться в микроконтроллер. Это связано с тем, что компилятор под платформу ARM более строго следует стандарту С++ и реализует потокобезопасную инициализацию статических переменных (на случай если несколько потоков вдруг одновременно зайдут в функцию getBuf ()).
Более того, этот код также проверяет, а не вызывается ли эта функция рекурсивно во время инициализации нашей переменной? И хотя по стандарту это undefined behavior, реализация имени g++ кидает исключение recursive_init_error. А раз есть исключение, то есть и код, который эти исключения обслуживает. По меркам больших процессоров там не очень много (те самые 60 кб), но вот для микроконтроллера это очень дофига.
Решение — добавить ключик компилятора -fno-threadsafe-statics, он как раз и предназначен для того, чтобы отключить всю эту лабуду если у нас нет многопоточности и мы уверенны, что этот код будет вызываться строго из одного потока.
Синглтон
Наконец, можно использовать синглтон — объект, который существует в памяти в единственном экземпляре. В общем случае объект может создаваться динамически, но в случае микроконтроллеров его есть смысл распределять на этапе компиляции.
class Singleton
{
int buf[300];
public:
static Singleton & getInstance()
{
static Singleton instance;
return instance;
}
int * getBuf()
{
return buf;
}
};
void setup()
{
Serial.begin(9600);
Singleton::getInstance().getBuf()[42] = 10;
}
void loop()
{
Serial.println(Singleton::getInstance().getBuf()[42]);
}
Технически это вариация и комбинация пунктов выше — тут используются статические члены класса, а также статические локальные переменные. Тем не менее этот вариант хорош тем, что в нагрузку к данным могут еще прилагаться и методы/функции доступа/обработки/изменения инкапсулированные в одной сущности. При этом сами данные распределяются статически.
00800116 l O .bss 00000258 Singleton::getInstance()::instance
Обратите внимание, что сам массив buf объявлен как обычный член данных класса. Статическим является экземпляр instance, который внутри содержит наш массив. К сожалению objdump описывает данные внутри объекта не очень детально. Если бы внутри класса Singleton помимо buf были бы и другие поля, objdump все равно бы их слепил вместе и показывал одной строкой.
Кому хочется хардкорности и шаблонной магии, предлагаю ознакомиться этой статьей. Как раз вышла вчера и более полно раскрывает тему синглтонов применительно к микроконтроллерам.
Заключение
Оперативной памяти в небольших микроконтроллерах, как правило, не очень много. Если вам повезло и вы работаете с ARM (STM32, nRF, NXP) или ESP8266 то у Вас в распоряжении до нескольких десятков килобайт (в зависимости от «толстости» микроконтроллера). Если у Вас AVR (в составе Arduino или сам по себе), то это всего 1–4 кб. Если же вам не повезло и у вас архитектура MCS51, то у вам доступно всего пара сотен байт.
И хотя стандартная библиотека позволяет использовать функции динамического выделения памяти (new, malloc), и эти функции используются во множестве библиотек (например которые входят в состав STM Cube, или доступны в мире Arduino), как мне кажется, динамическое выделение памяти в микроконтроллерах создает больше проблем чем приносит пользы.
В этой статье я предлагаю долгоживущие объекты распределять в памяти статически, на этапе компиляции. Это позволит более детально отслеживать распределение памяти и минимизировать риск ситуации, когда память внезапно закончилась.
Я ни в коем случае не призываю бросаться и переделывать все объекты в программе на глобальные переменные. Описанный способ годиться в первую очередь для объектов и буферов которые создаются вначале работы прошивки и живут все время работы программы. Или же для организации некоторого пула объектов, откуда специальный менеджер будет раздавать объекты кому нужно.
Не стОит этот способ использовать для короткоживущих объектов, возможно размещение на стеке подойдет куда лучше. Также этот способ не подойдет, если для создания объектов нужна некоторая информация не доступная на этапе компиляции.
Всем спасибо кто дочитал эту статью до конца. Я буду рад конструктивной критике. Мне также будет интересно обсудить нюансы в комментариях.