Посмотр рубрик

Оптимизация памяти

Чаще всего мы сталкиваемся с нехваткой памяти: постоянной Flash или оперативной RAM. После компиляции кода мы получаем сообщение о занимаемом объёме Flash/RAM, это ценная информация. Flash память можно забивать на 99%, её объём не изменяется в процессе работы устройства, чего не скажешь о RAM. Допустим, на момент запуска программы у нас занято 80% оперативной памяти глобальными переменными, но в процессе работы могут появляться и исчезать локальные и динамические переменные, которые добьют занимаемый объём до 100% и устройство скорее всего перезагрузится или зависнет. Опасность ещё в том, что при динамическом выделении памяти могут оставаться пустые места и объём занятой памяти будет увеличиваться ещё быстрее.

В следующем уроке рассмотрим оптимизацию скорости выполнения программы.

С чем компилятор справится сам #

Вырезание переменных и функций #

Компилятор вырезает из кода переменные, а также реализацию функций и методов класса, если они не используются в коде. Таким образом даже если мы подключаем огромную библиотеку, но используем из неё лишь пару методов, объём памяти не увеличится на размер всей библиотеки. Компилятор возьмёт только то, что используется.

Вырезание условий и свитчей #

Компилятор вырежет целую ветку условий или свитчей, если заранее будет уверен в результате сравнения или выбора. Как его в этом убедить? Правильно, константой! Рассмотрим элементарный пример - условие или свитч с тремя вариантами:

switch (num) {
    case 0: Serial.println("Hello 0"); break;
    case 1: Serial.println("Hello 1"); break;
    case 2: Serial.println("Hello 2"); break;
}

// или

if (num == 0) Serial.println("Hello 0");
else if (num == 1) Serial.println("Hello 1");
else if (num == 2) Serial.println("Hello 2");

Если объявить num как обычную переменную - в скомпилированный код попадёт вся конструкция целиком, три условия или весь свитч. Если num сделать константой const или дефайном #define - компилятор вырежет весь блок условий или свитч и оставит только содержимое, которое получается при заданном num. В этом очень легко убедиться, скомпилировав код и посмотрев на объём занимаемой памяти в логе компилятора. При помощи данного трюка можно ускорить выполнение некоторых функций и уменьшить занимаемое ими место в памяти, например для создания универсальной библиотеки.

Если вы пишете свою библиотеку или класс, то всё будет чуть труднее: константы внутри класса не являются для компилятора весомым поводом для вырезания условий и свитчей, даже если это const и он объявлен в списке инициализации класса. Для того, чтобы компилятор вырезал условие или свитч внутри реализации методов класса, ему нужна внешняя константа/дефайн или шаблон template.

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

if ((x + 1) < x) foo();
// для знакового x компилятор может вырезать условие,
// так как переполнение signed является неопределённым поведением

Измерение памяти #

Можно использовать мою библиотеку или код из неё. Пример:

#include <Benchmark.h>

void setup() {
    Serial.begin(115200);
    Serial.println(getFreeHeap());

    int* p = new int[100];
    Serial.println(getFreeHeap());
    delete [] p;
}
void loop() {}

Оптимизация #

Использовать переменные соответствующих типов #

Просто не используйте переменные более тяжёлых типов там, где это не нужно.

Использовать константы #

Для номеров пинов, настроек и постоянных значений лучше использовать constexpr или const. Такие константы имеют тип и область видимости, а компилятор обычно сразу подставляет их значения. #define тоже работает, но это простая текстовая подстановка без проверки типа:

#define MOTOR_PIN 10
constexpr uint8_t MOTOR_SPEED = 120;

Использовать директивы препроцессора #

Если у вас какой-то комплексный проект, где перед прошивкой включаются/выключаются некоторые куски кода или библиотеки - используйте условную компиляцию при помощи директив #if, #elif, #ifdef и прочих, подробнее - в уроке.

Использовать PROGMEM #

Для хранения больших объёмов постоянных данных (массив битмапов для вывода на дисплеи, строки с текстом, "таблицы" синуса или других корректирующих значений) на AVR используйте PROGMEM - возможность хранить и читать данные во Flash памяти микроконтроллера, которой гораздо больше, чем оперативной. Особенность состоит в том, что данные во Flash пишутся во время прошивки, и изменить их потом будет нельзя, можно только прочитать и использовать. На других архитектурах постоянные данные и так могут не копироваться в RAM, а PROGMEM работать иначе. Читайте подробный урок по PROGMEM.

Использовать F() макро #

Если в проекте используется вывод в COM порт фиксированных текстовых данных, то каждый символ будет занимать один байт оперативной памяти, также это относится к строковым данным и выводам на дисплей. У нас есть на вооружении встроенный инструмент, который позволяет хранить строки во Flash памяти, использовать его удобнее того же PROGMEM. Работает очень просто и эффективно, позволяя делать девайс с расширенным общением/отладкой через Serial порт и не думать о забитой оперативке:

// данный вывод (строка, текст) занимает в оперативной памяти 18 байт
Serial.println("Hello <username>!");

// текст не копируется целиком в RAM, благодаря F()
Serial.println(F("Type /help to help"));

Не использовать float #

Как мы обсуждали в уроке про типы данных, поддержка вычислений с float является программной для классических AVR. Использование арифметики и особенно форматирования float может добавить заметный объём кода во Flash. Точная разница зависит от операций, ядра, компилятора и уже подключённого кода. Если нужно хранить много дробных значений в оперативной или EEPROM памяти, то иногда есть смысл заменить их целочисленными. Как это сделать без потери нужной точности:

// допустим, нам нужно хранить массив таких значений, не тратя лишнюю память.
// пусть sensorRead() возвращает float температуру с точностью до 1 знака.
// Превратим её в целочисленное, умножив на 10 (или 100, смотря какая нужна точность):
vals[30] = sensorRead() * 10;

// на AVR целочисленные int vals занимают в два раза меньше памяти!
// Чтобы превратить их обратно во float - просто делим на 10
float val_f = vals[30] / 10.0;

Также можно использовать числа с фиксированной точкой, например мою библиотеку fixed.

Не использовать динамическую память #

Динамическая память (new, malloc...) требует служебных данных, может фрагментировать небольшую RAM и усложняет контроль максимального расхода памяти. В embedded-коде её обычно избегают не столько из-за размера самого аллокатора, сколько ради предсказуемости.

Аккуратнее с Serial и String #

Serial добавляет код драйвера и статические буферы приёма и передачи, а конкретный расход зависит от ядра и его настроек. Класс String использует динамическую память: частые изменения длины строки могут приводить к лишним выделениям и фрагментации. Если размер известен заранее, помогут reserve() или обычный символьный буфер. Если в коде появляется Serial, он сразу же забирает себе минимум 998 байт Flash (3% для ATmega328) и 175 байт SRAM (8% для ATmega328). Как только начинаем использовать строки String - прощаемся с 1178 байтами Flash (4% для ATmega328).

Использовать однобитные флаги #

Логический тип bool занимает 1 байт, а не 1 бит. Но флаги можно паковать в байты и другие типы данных, читайте в отдельном уроке.

Отказаться от стандартной инициализации #

Стандартные функции setup() и loop() являются обязательными не просто так: они входят в самую главную функцию всей программы - int main(). Реализация данной функции лежит в ядре в файле main.cpp и выглядит примерно вот так:

// main.cpp

int main() {
    // системный код
    setup();

    for (;;) {
        // системный код
        loop();
    }
}

Именно здесь, в инициализациях, кроется пара сотен байт занимаемой Flash памяти! А после loop() есть проверка условия, которое чуть замедляет основной цикл.

В функциях инициализации настраивается периферия микроконтроллера: АЦП, интерфейсы, таймер 0 (который даёт нам корректный millis()), и некоторые другие вещи. Если можете самостоятельно инициализировать только нужную периферию - это позволит сэкономить немного Flash, всё что нужно сделать - это нагло ввести в скетч свою функцию int main() и написать инициализацию только того, что нужно. В старой тестовой сборке для AVR стандартный пустой скетч занимал 444 байта Flash, а вариант со своим main() - 134 байта. В современных версиях ядра и компилятора цифры могут отличаться. Это, конечно, крохоборство, но на дороге не валяется. Как это сделать:

#include <Arduino.h>

int main() {
    // наш личный "setup"
    for (;;) {
        // наш личный "loop"
    }
    return 0;
}

Это платформозависимый трюк для опытных разработчиков. Без штатного init() могут не работать millis(), ШИМ, АЦП и другая периферия Arduino

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


(6 голосов)
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Прокрутить вверх