Оптимизация кода Arduino


С ростом навыков и созданием всё более глобальных проектов вы столкнётесь с тем, что “Ардуина” перестанет справляться с тем объёмом вычислений, который вы хотите от неё получить. Может банально не хватать быстродействия в расчётах, обновлении информации на дисплеях, отправки данных и прочих ресурсозатратных действий, а ещё может просто закончиться память! Самое страшное, когда заканчивается оперативная память: она может это сделать абсолютно незаметно, и устройство начнёт вести себя неадекватно, перезагрузится или попросту зависнет. Как этого избежать? Нужно оптимизировать свой код! Информации по этому поводу в Интернете очень мало, поэтому я опишу всё, с чем сталкивался лично.

В данной главе рассмотрено большинство существующих способов оптимизировать скорость выполнения кода, некоторые способы позволяют сократить буквально несколько микросекунд (0.000001 секунды). Также речь пойдёт об оптимизации Flash и оперативной памяти, некоторые способы позволяют сократить буквально несколько байт. Всегда оценивайте целесообразность оптимизации: если места достаточно – оптимизируйте своё время, не тратьте его на бессмысленное переписывание! Но будет правильно выработать привычку сразу писать оптимально =)

С чем компилятор справится сам (NEW!)


Мы рассмотрели много различных способов оптимизировать код, но не учли главного: компилятор и сам неплохо справляется с его оптимизацией! Некоторые перечисленные выше шаги не имеют смысла, потому что компилятор сам сделает примерно то же самое. Но мы всё равно их разобрали для общего развития и понимания процесса. Сейчас рассмотрим некоторые из действий, которые компилятор делает сам.

Модификатор volatile


Компилятор оптимизирует действия с переменными, которые не помечены как volatile, так как это прямая команда “не оптимизируй меня”. Это важный момент, потому что действия с такими переменными (если они нужны) надо оптимизировать вручную. Компилятор не будет оптимизировать вычисления, вырезать неиспользуемые переменные и конструкции с их применением!

Вырезание неиспользуемых переменных и функций


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

Оптимизация вычислений


Компилятор сам старается максимально оптимизировать вычисления:

  • Заменяет типы данных на более оптимальные там, где это возможно и не повлияет на результат. Например val /= 2.8345 выполняется в 4 раза дольше, чем val /= 2.0, потому что 2.0 была заменена на 2.
  • Заменяет операции целочисленного умножения на степени двойки (2^n) битовым сдвигом. Например, val * 16 выполняется в два раза быстрее, чем val * 12, потому что будет заменена на val << 4;
    • Примечание: для операций целочисленного деления такая оптимизация не проводится, и её можно сделать вручную: val >> 4 выполняется в 15 раз быстрее, чем val / 16. Об этом писал выше в этом же уроке;
  • Заменяет операции взятия остатка от деления % на степени двойки битовой маской (остаток от деления на 2^n можно вычислить через битовую маску: val & n). Таким образом например 100 % 10 выполняется в 17 раз дольше, чем 100 % 8, учитывайте это.
  • Предварительно вычисляет всё, что можно вычислить (константы). Например val /= 7.8125 выполняется столько же, сколько val /= (2.5*10.0/3.2+12.28*3.2), потому что компилятор заранее посчитал и подставил результат всех действий с константами;
  • Использует для умножения и деления целых чисел ячейку в два байта (знаковую). Это очень опасно, потому что результат может оказаться больше, но компилятору всё равно. Для выражений, результат которых превосходит 32’768, нужно принудительно приказать компилятору выделить больше памяти при помощи (long) или другими способами, мы разбирали это в уроке про математические операции.

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


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

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. В этом очень легко убедиться, скомпилировав код и посмотрев на объём занимаемой памяти в логе компилятора. При помощи данного трюка можно ускорить выполнение некоторых функций и уменьшить занимаемое ими место в памяти. Рассмотрим весьма полезный пример: функция быстрого чтения состояния цифрового пина для ATmega328 (остальные быстрые аналоги ищи тут):

bool fastRead(uint8_t pin) {
  if (pin < 8) {
    return bitRead(PIND, pin);
  } else if (pin < 14) {
    return bitRead(PINB, pin - 8);
  } else if (pin < 20) {
    return bitRead(PINC, pin - 14);
  }
}

Вызов fastRead(переменная) занимает 6 тактов процессора (0.37 мкс), вызов fastRead(константа) – 1 такт (0.0625 мкс)! Для сравнения, вызов стандартной digitalRead(переменная) занимает 58 тактов, а digitalRead(константа) – 52 такта. То есть при помощи оптимального кода и понимания логики работы компилятора можно сделать “digitalRead()” в 58 раз быстрее, чем это предлагает библиотека Arduino.h, при том ничуть не теряя в удобстве использования!

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

#define MY_PIN 3
const byte _pinCM = 3;

template <byte PIN>
class fast {
  public:
    fast(byte pin) : _pinC(pin) {_pin = _pinV = pin;}

    bool dreadVol() {return digitalRead(_pinV);}
    bool dreadVar() {return digitalRead(_pin);}
    bool dreadConst() {return digitalRead(_pinC);}
    bool dreadDefine() {return digitalRead(MY_PIN);}
    bool dreadExtConst() {return digitalRead(_pinCM);}
    bool dreadTempConst() {return digitalRead(PIN);}

    bool fastReadVol() {return fastRead(_pinV);}
    bool fastReadVar() {return fastRead(_pin);}
    bool fastReadConst() {return fastRead(_pinC);}
    bool fastReadDefine() {return fastRead(MY_PIN);}
    bool fastReadExtConst() {return fastRead(_pinCM);}
    bool fastReadTempConst() {return fastRead(PIN);}

    bool fastReadShortVol() {return fastReadShort(_pinV);}
    bool fastReadShortVar() {return fastReadShort(_pin);}
    bool fastReadShortConst() {return fastReadShort(_pinC);}
    bool fastReadShortDefine() {return fastReadShort(MY_PIN);}
    bool fastReadShortExtConst() {return fastReadShort(_pinCM);}
    bool fastReadShortTempConst() {return fastReadShort(PIN);}

    bool fastRead(uint8_t pin) {
      if (pin < 8) {
        return bitRead(PIND, pin);
      } else if (pin < 14) {
        return bitRead(PINB, pin - 8);
      } else if (pin < 20) {
        return bitRead(PINC, pin - 14);    // Return pin state
      }
    }
    bool fastReadShort(uint8_t pin) {
      return bitRead(PIND, pin); // <8
    }

  private:
    byte _pin;
    volatile byte _pinV;
    const byte _pinC=3;
};

Результаты бенчмарка (в тактах процессора):

  volatile variable constant define external const template const
digitalRead 58 58 58 52 52 52
pinRead 6 6 6 1 1 1
bitRead(PIND, pin); 3 1 1 1 1 1

Оптимизация скорости


Прежде чем приступать к оптимизации вычислений, нужно понять, почему оно вообще тормозит: есть такое понятие, как процессорное время, т.е. время, которое вычислительный процессор тратит на различные действия. Например, вы хотите производить какие-то вычисления, и сразу выводить их на дисплей. Если вычислений будет слишком много, выполняться они будут дольше желаемого, данные будут выводиться медленно. На самом деле такой ситуации добиться весьма непросто, всё таки процессор у нас выполняет операции на частоте 16 МГц, но один раз я таки нащупал этот “порог”. Проект назывался LED кубик, в нём рассчитывалось поведение нескольких десятков частиц на плоскости. Рассчитывалось не абы как – а по математической модели, с трением, углами наклона, отскоками от края плоскости и всё такое, результат выводился на светодиодную матрицу. При увеличении количества частиц я столкнулся с тем, что они начинают откровенно тормозить, т.е. частота обновления матрицы сильно упала. Видео про этот проект можно посмотреть на моём канале, а мы переходим к оптимизации вычислений.

Хочу поделиться инструментом для замера времени выполнения кода с точностью до одного такта процессора (0.0625 мкс для 16 МГц клока), выводит время выполнения в “тиках” процессора и микросекундах, а также частоту выполнения. Работает на таймере 1. Скачать можно с FTP сайта по прямой ссылке (нажать правой кнопкой – сохранить файл). также код находится ниже под спойлером.

// Тест скорости выполнения команд Arduino, версия PRO 2
// Просто помести свой код внутри test() и загрузи прошивку!

inline __attribute__((always_inline))
void test() {
// твой код

}

volatile uint16_t _timer1_overflows = 0;
void setup() {
  Serial.begin(9600);
  timer1_setup();
  
  TCCR1B = (1 << CS10);
  test();
  TCCR1B = 0x00;

  uint32_t clock_cycles = (((uint32_t)_timer1_overflows << 16) | TCNT1) - 2;
  float runtime = (float)clock_cycles * (1000000.0f / F_CPU);
  float loop_frequency = (float)1000000.0f / runtime;

  Serial.print("cpu clock cycles: ");
  Serial.println(clock_cycles);
  Serial.print("runtime: ");
  Serial.print(runtime, 4);
  Serial.println(" us");
  Serial.print("loop frequency: ");
  Serial.print(loop_frequency, 4);
  Serial.println(" Hz");
}

void loop() {}

void timer1_setup(void) {
  TCCR1A = 0x00;
  TCCR1B = 0x00;
  TCNT1 = 0x00;
  TIMSK1 = (1 << TOIE1);
  _timer1_overflows = 0;
  sei();
}

ISR(TIMER1_OVF_vect) {
  _timer1_overflows++;
}

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


Тип переменной/константы не только влияет на занимаемый ей объём памяти, но и на скорость вычислений! Привожу таблицу для простейших не оптимизированных компилятором вычислений. В реальном коде время может быть меньше. Примечание: время приведено для кварца 16 МГц.

Тип данных Время выполнения, мкс
Сложение и вычитание Умножение Деление, остаток
int8_t 0.44 0.625 14.25
uint8_t 0.44 0.625 5.38
       
int16_t 0.89 1.375 14.25
uint16_t 0.89 1.375 13.12
int32_t 1.75 6.06 38.3

uint32_t 1.75 6.06 37.5
       
float 8.125 10 31.5

Как вы можете заметить, время вычислений отличается в разы даже для целочисленных типов данных, так что всегда нужно прикидывать, какая максимальная величина будет храниться в переменной, и выбирать соответствующий тип данных. Стараться не использовать 32-битные числа там, где они не нужны, а также по возможности не использовать float.

В то же время, умножить long на float будет выгоднее, чем делить long на целое число. Такие моменты можно считать заранее как 1/число и умножать вместо деления в критических ко времени выполнения моментах кода. Также читай об этом чуть ниже.

Отказаться от float


Из таблицы выше вы также можете узнать, что на действия с числами с плавающей точкой микроконтроллер тратит в несколько раз больше времени по сравнению с целочисленными типами. Дело в том, что у большинства микроконтроллеров AVR (что стоят на Ардуинах) нет “хардверной” поддержки вычислений float чисел, и эти вычисления производятся не очень оптимальными программными методами. На взрослых микроконтроллерах ARM такая поддержка, к слову, имеется. Что же делать? Просто избегайте использования float там, где задачу можно решить целочисленными типами.

Если нужно перемножить-переделить кучу float‘ов, то можно перевести их в целочисленный тип, умножив на 10-100-1000, смотря какая нужна точность, вычислить, а затем результат снова перевести в float. В большинстве случаев это получается быстрее, чем вычислять float напрямую:

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

// теперь с целочисленным val можно работать без потери точности измерения и
// можно хранить его в двух байтах вместо 4-х.
// Чтобы превратить его обратно во float - просто делим на 10
float val_f = val / 10.0;

Существует также такая штука как fixed point – числа с фиксированной точкой. С точки зрения пользователя они являются обычными десятичными дробями, но по факту являются целочисленными типами и вычисляются соответственно быстрее. Нативной поддержки fixed point в Arduino нет, но можно работать с ними при помощи самописных функций, макросов или библиотек, под спойлером найдёте мой рабочий пример, который можно использовать на практике:

// макросы для работы с fixed point
#define FIX_BITS        8
#define FLOAT2FIX(a)    (int32_t)((a*(1 << FIX_BITS)))      // перевод из float в fixed
#define INT2FIX(a)      (int32_t)((a) << FIX_BITS)          // перевод из int в fixed
#define FIX2FLOAT(a)    (((float)(a)/(1 << FIX_BITS)))      // перевод из fixed в float
#define FIX2INT(a)      ((a) >> FIX_BITS)                   // перевод из fixed в int
#define FIX_MUL(a, b)   (((int32_t)(a) * (b)) >> FIX_BITS)  // перемножение двух fixed

void setup() {
  Serial.begin(9600);
  float x = 8.3;
  float y = 2.34;
  float z = 0;

  // сначала переводим в fixed
  int32_t a = FLOAT2FIX(x);
  int32_t b = FLOAT2FIX(y);
  int32_t c = 0;

  z = x + y;    // складываем float
  c = a + b;    // складываем fixed

  // переводим fixed обратно в float
  float cFloat = FIX2FLOAT(c);

  // выводим для сравнения результата
  Serial.println(z);
  Serial.println(cFloat);
}

/*
   Тесты скорости выполнения:
   x = 8.3;             // 0.75 us  - присваивание float
   a = FLOAT2FIX(8.3);  // 0.75 us  - конвертация float числа в fixed
   a = FLOAT2FIX(x);    // 14.9 us  - конвертация float переменной в fixed

   z = x + y;           // 8.25 us  - сложение float
   c = a + b;           // 2.0 us   - сложение fixed

   z = x * y;           // 10.3 us  - умножение float
   c = FIX_MUL(a, b);   // 6.68 us  - умножение fixed

   z = FIX2FLOAT(c);    // 13.37 us - конвертация fixed в float
*/

void loop() {}

Выбирать множители степенями двойки


Как рассказано в первой главе, компилятор заменяет целочисленные операции умножения на (2^n) битовыми сдвигами, которые выполняются гораздо быстрее. Как это использовать: по возможности писать свои алгоритмы так, чтобы в математических операциях получались степени двойки (2 4 8 16 32 64 128…). Например, умножение числа на 16 выполняется в два раза быстрее, чем на 15. Речь идёт о нескольких микросекундах, но иногда и это бывает важно.

Примечание: слово целочисленный здесь не просто так, для float трюк не работает!

Заменить деление битовым сдвигом


Что касается целочисленного деления на степени двойки, то компилятор не заменяет его сдвигом, и это можно и нужно сделать вручную. Например, деление long числа на 16 (val / 16) выполняется в 15 раз дольше, чем операция сдвига с таким же результатом: val >> 4 (сдвинуть на 4 бита, 16 == 2 в степени 4). Для лонгов получаем 40 мкс на деление, и 2.5 мкс на сдвиг. Экономия!

Примечание: слово целочисленный здесь не просто так, для float трюк не работает!

Заменить деление умножением на float


Опять же по таблице выше можно увидеть, что деление для всех типов данных выполняется гораздо дольше умножения, поэтому иногда бывает выгоднее заменить деление на целое число умножением на float. И да, пытаться усидеть на двух стульях, стараясь не использовать float и использовать его вместо деления…

keka / 10;	// выполняется 14.54 мкс
keka * 0.1;	// выполняется 10.58 мкс

Заменить возведение в степень умножением


Для возведения в степень у нас есть удобная функция pow(a, b), но в целочисленных расчётах лучше ей не пользоваться: она выполняется гораздо дольше ручного перемножения, потому работает с float, даже если скормить ей целое:

keka = pow(keka, 5);                             // выполняется 20.33 us
keka = (long)keka * keka * keka * keka * keka;   // выполняется 4.47 us

Оптимизировать остаток от деления


Операция остаток от деления % выполняется сравнительно долго, как и само деление (см. таблицу выше). Нужно помнить, что компилятор оптимизирует остаток от деления на 2^n, заменяя его битовой маской, взятие которой выполняется за пару тактов процессора, что в несколько десятков раз быстрее!!! Например val % 8 будет автоматически оптимизировано в val & 0b111. Нужно по возможности писать свой алгоритм так, чтобы остаток от деления искался именно от 2^n. Например, при работе с кольцевым буфером можно сделать его размер равным 16, 32, 64, 128… и ускорить операцию перехода в начало буфера, как это обычно делается buffer_pos % buffer_size.

Предварительно вычислять то, что можно вычислить


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

Ещё хороший пример: расчёт величин, которые ведут себя предсказуемо, например гармонические функции sin() и cos(). На их вычисление уходит довольно-таки много времени – 119.46 мкс!!! На практике синусы/косинусы практически никогда не вычисляют средствами микроконтроллера, их вычисляют заранее и сохраняют в виде массива. Да, опять два стула: тратить время на вычисление, или занимать память уже посчитанными данными.

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

Не использовать delay() и подобные задержки


Вполне очевидный совет: не используйте delay() там, где можно обойтись без него. А это 99.99% случаев. Используйте таймер на millis(), как мы изучали в уроке

Заменить Ардуино-функции их быстрыми аналогами


Если в проекте очень часто используется периферия микроконтроллера (АЦП, цифровые входы/выходы, генерация ШИМ…), то нужно знать одну вещь: Ардуино (на самом деле Wiring) функции написаны так, чтобы защитить пользователя от возможных ошибок, внутри этих функций находится куча различных проверок и защит “от дурака”, поэтому они выполняются гораздо дольше, чем могли бы. Также некоторая периферия микроконтроллера настроена так, что работает очень медленно. Пример: digitalWrite() и digitalRead() выполняются около 3.5 мкс, когда прямая работа с портом микроконтроллера занимает 0.5 мкс, что почти на порядок быстрее. analogRead() выполняется 112 мкс, хотя если его настроить чуть по-другому, он будет выполняться почти в 10 раз быстрее, не особо потеряв в точности.

  • О таком “разгоне” Ардуино мы поговорим в отдельном уроке, сейчас могу предложить вам описание библиотек GyverHacks и CyberLib, в них есть куча всего интересного, включая быстрые аналоги стандартным функциям. Примечание: автор открытой библиотеки Cyberlib не любит, когда ей пользуются в своих проектах, так что аккуратнее.
  • В статье полезные алгоритмы для Arduino я выложил несколько “быстрых и лёгких” аналогов Ардуино-функциям.
  • Также обратите внимание на переписанное мной стандартное ядро для плат на базе ATmega328 – GyverCore. Это ядро является аналогом стандартному, но основные функции полностью переписаны и выполняются в разы быстрее и занимают меньше места в памяти!

Использовать switch вместо else if


В ветвящихся конструкциях со множественным выбором по значению целочисленной переменной стоит отдавать предпочтение конструкции switch-case, она работает быстрее else if (изучали в уроках про условия и выбор). Но помните, что switch работает только с целочисленными! Под спойлером найдёте результаты синтетического (не оптимизированного компилятором) теста.

// тест SWITCH
// keka равна 10
// время выполнения: 0.3 мкс (5 тактов)
switch (keka) {
  case 10: break;  // выбираем это
  case 20: break;
  case 30: break;
  case 40: break;
  case 50: break;
  case 60: break;
  case 70: break;
  case 80: break;
  case 90: break;
  case 100: break;
}

// keka равна 100
// время выполнения: 0.3 мкс (5 тактов)
switch (keka) {
  case 10: break;
  case 20: break;
  case 30: break;
  case 40: break;
  case 50: break;
  case 60: break;
  case 70: break;
  case 80: break;
  case 90: break;
  case 100: break;  // выбираем это
}

// тест ELSE IF
// keka равна 10
// время выполнения: 0.50 мкс (8 тактов)
if (keka == 10) {   // выбираем это
} else if (keka == 20) {
} else if (keka == 30) {
} else if (keka == 40) {
} else if (keka == 50) {
} else if (keka == 60) {
} else if (keka == 70) {
} else if (keka == 80) {
} else if (keka == 90) {
} else if (keka == 100) {
}

// keka равна 100
// время выполнения: 2.56 мкс (41 такт)
if (keka == 10) {
} else if (keka == 20) {
} else if (keka == 30) {
} else if (keka == 40) {
} else if (keka == 50) {
} else if (keka == 60) {
} else if (keka == 70) {
} else if (keka == 80) {
} else if (keka == 90) {
} else if (keka == 100) {   // выбираем это
}

Помнить про порядок условий


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

if ( flag && getSensorState() ) {
  // какой-то код
}

Если flag имеет значение false, функция getSensorState() даже не будет вызвана! if будет сразу пропущен (или выполнен else, если он есть). Этим нужно пользоваться, расставляя условия в порядке возрастания процессорного времени, которое требуется для их вызова/выполнения, если это функции. Например, если наша getSensorState() тратит какое-то время для выполнения, то мы ставим её после флага, который является просто переменной. Это позволит сэкономить процессорное время в те моменты, когда флаг имеет значение false.

Использовать битовые операции


Используйте битовые трюки и вообще битовые операции, часто они помогают ускорить код. Читайте в отдельном уроке.

Использовать указатели и ссылки


Вместо передачи “объекта” в качестве аргумента функции, передавать его по ссылке или по указателю: процессор не будет выделять память под копию аргумента (и создавать эту копию в качестве формальной переменной) – это сэкономит время! Подробнее про указатели и ссылки читайте в отдельном уроке.

Использовать макро и встроенные функции


Каждая созданная функция имеет свой адрес в памяти, и для её вызова процессор обращается по этому адресу, что занимает время. Время очень малое, но иногда даже оно бывает критичным, поэтому такие критичные ко времени вызовы можно заменить на макро-функции или на встроенные функции, подробнее читайте в уроке про функции.

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


Константы (const или #define) “работают” гораздо быстрее переменных при передаче их в качестве аргументов в функции. Делайте константами всё, что не будет меняться в процессе работы программы! Пример:

byte pin = 3;    // частота будет 128 кГц (GyverCore)
//const byte pin = 3; // частота будет 994 кГц (GyverCore)

void setup() {
  pinMode(pin, OUTPUT);
}
void loop() {
  for (;;) {
    digitalWrite(pin, 1);
    digitalWrite(pin, 0);
  }
}

Почему это происходит? Компилятор оптимизирует код, и с константными аргументами он может выбросить из функции почти весь лишний код (если там есть, например, блоки if-else), и она будет работать быстрее.

Миновать loop


Функция loop() является вложенной во внешний цикл с некоторыми дополнительными проверками, поэтому если вам очень важно минимальное время между итерациями loop() – просто работайте в своём цикле for(;;), например вот так:

void loop() {
  for (;;) {
  // ваш код
  }
}

Кодить на ассемблере (шутка)


Arduino IDE поддерживает ассемблерные вставки, в которых на одноимённом языке можно давать прямые команды процессору, что обеспечивает максимально быстрый и чёткий код. Но у нас в семье о таком не шутят =)

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


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

Также прилагаю скетч-пример с функцией, которая выводит объём свободной SRAM. Скачать с FTP сайта (нажать правой кнопкой – сохранить файл).

/*
   Функция, возвращающая количество свободной оперативной памяти (SRAM)
   Примечание: данный способ проверки свободной оперативной памяти 
   работает некорректно в случае фрагментации памяти!
*/
void setup() {
  Serial.begin(9600);
}

void loop() {
  Serial.println(memoryFree()); // печать количества свободной SRAM
  delay(1000);
}

extern int __bss_end;
extern void *__brkval;
// Функция, возвращающая количество свободного ОЗУ
int memoryFree() {
  int freeValue;
  if ((int)__brkval == 0)
    freeValue = ((int)&freeValue) - ((int)&__bss_end);
  else
    freeValue = ((int)&freeValue) - ((int)__brkval);
  return freeValue;
}

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


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

Название Вес Диапазон
boolean 1 байт 0 или 1, true или false
char (int8_t) 1 байт -128… 127
byte (uint8_t) 1 байт 0… 255
int (int16_t) 2 байта -32 768… 32 767
unsigned int (uint16_t) 2 байта 0… 65 535
long (int32_t) 4 байта -2 147 483 648…    2 147 483 647
unsigned long (uint32_t) 4 байта 0… 4 294 967 295
float (double) 4 байта -3.4028235E+38… 3.4028235E+38

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

Использовать define


Для хранения констант в стиле номеров пинов, каких-то настроек и постоянных значений используйте не глобальные переменные, а #define. Таким образом константа будет храниться в коде, во Flash памяти, которой много.

#define MOTOR_PIN 10
#define MOTOR_SPEED 120

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


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

Использовать progmem


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

// сохраняем несколько целых чисел
const uint16_t ints[] PROGMEM = {65000, 32796, 16843, 10, 11234};

// сохраняем несколько десятичных дробей
const float floats[] PROGMEM = {0.5, 120.25, 0.9214};

// сохраняем несколько символов
const char message[] PROGMEM = {"Hello! Lolkek"};

void setup() {
  Serial.begin(9600);
  Serial.println(pgm_read_word(&(ints[2])));      // выведет 16843
  Serial.println(pgm_read_float(&(floats[1])));   // выведет 120.25

  for (byte i = 0; i < 13; i++)
    Serial.print((char)pgm_read_byte(&(message[i]))); 
    // выведет Hello! Lolkek
}

Основная функция чтения из progmem – pgm_read_ТИП. Мы можем использовать вот эти 4:

  • pgm_read_byte(data); – для 1-го байта (char, byte, int8_t, uint8_t)
  • pgm_read_word(data); – для 2-х байт (int, word, unsigned int, int16_t, int16_t)
  • pgm_read_dword(data); – для 4-х байт (long, unsigned long, int32_t, int32_t)
  • pgm_read_float(data); – для чисел с плавающей точкой

Внимание! При чтении отрицательных (signed) чисел, нужно привести тип данных. Пример:

// сохраняем несколько целых с разными знаками
const int16_t ints[] PROGMEM = {65000, 32796, -16843};

// setup
Serial.println((int)pgm_read_word(&(ints[2])));   // выведет -16843

Также есть более удобный способ записывать и читать данные, реализован в библиотеках. Смотреть тут

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


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

Так называемая “F() macro” позволяет хранить строки во Flash памяти, не занимая место в SRAM. Работает очень просто и эффективно, позволяя делать девайс с расширенным общением/отладкой через Serial порт и не думать о забитой оперативке:

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

// данный вывод ничего не занимает в оперативной памяти, благодаря F()
Serial.println(F("Type /help to help"));

Ограничить использование библиотек


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

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


Как мы обсуждали в уроке про типы данных, поддержка вычислений с float является программной (для AVR), то есть грубо говоря для вычислений “подключается библиотека”. Однократно использовав в коде все арифметические действия с float, вы подключите около 1000 байт кода во Flash память для поддержки этих вычислений.

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

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

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

Не использовать объекты классов Serial и String


Пожалуй самые “жирные” по занимаемой памяти библиотеки – это стандартные объекты Serial и String. Если в коде появляется Serial, он сразу же забирает себе минимум 998 байт Flash (3% для ATmega328) и 175 байт SRAM (8% для ATmega328). Как только начинаем использовать строки String – прощаемся с 1178 байтами Flash (4% для ATmega328).

Если Serial всё таки нужен – попробуйте использовать сильно облегчённый аналог стандартной библиотеки – GyverUART. Я написал эту библиотеку под ATmega168/328 (платы UNO, Nano, Mini). Библиотека предлагает почти все возможности стандартной Serial, но занимает в разы меньше памяти.

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


Вы должны быть в курсе, что логический тип данных boolean занимает в памяти Arduino не 1 бит, как должен занимать, а целых 8, т.е. 1 байт. Это вселенская несправедливость, ведь по сути мы можем сохранить в одном байте 8 флагов true/false, а на деле храним только один. Но выход есть: паковать биты вручную в байт, для чего нужно добавить несколько макросов. Пользоваться этим не очень удобно, но в критической ситуации, когда важен каждый байт, можно и заморочиться. Смотрите примеры:

// храним флаги как 1 бит

// макросы
#define B_TRUE(bp,bb)    bp |= bb
#define B_FALSE(bp,bb)   bp &= ~(bb)
#define B_READ(bp,bb)    bool(bp & bb)

// вот так храним наши флаги, значения обязательно как степени двойки!
#define B_FLAG_1        1
#define B_FLAG_2        2
#define B_LED_STATE     4
#define B_BUTTON_STATE  8
#define B_BUTTON_FLAG   16
#define B_SUCCESS       32
#define B_END_FLAG      64
#define B_START_FLAG    128

// этот байт будет хранить 8 бит
byte boolPack1 = 0;

void setup() {
  // суть такая: макрос функциями мы ставим/читаем бит в байте boolPack1

  // записать true во флаг B_BUTTON_STATE
  B_TRUE(boolPack1, B_BUTTON_STATE);

  // записать false во флаг B_FLAG_1
  B_FALSE(boolPack1, B_FLAG_1);

  // прочитать флаг B_SUCCESS (для примера читаем в булин переменную)
  boolean successFlag = B_READ(boolPack1, B_SUCCESS);

  // либо используем в условии
  if (B_READ(boolPack1, B_SUCCESS)) {
    // выполнить при выполнении условия
  }
}

void loop() { }

// пример упаковки битовых флагов в байт
// при помощи ардуино-функций

byte myFlags = 0; // все флаги в false

// можно задефайнить названия
// цифры по порядку 0-7
#define FLAG1 0
#define FLAG2 1
#define FLAG3 2
#define FLAG4 3
#define FLAG5 4
#define FLAG6 5
#define FLAG7 6
#define FLAG8 7

void setup() {
  // установить FLAG5 в true
  bitSet(myFlags, FLAG5);
  // установить FLAG1 в true
  bitSet(myFlags, FLAG1);

  // установить FLAG1 в false
  bitClear(myFlags, FLAG1);

  // считать FLAG5
  bitRead(myFlags, FLAG5);

  // условие с флагом 7
  if (bitRead(myFlags, FLAG7)) {
    // если FLAG7 == true
  }
}

void loop() {}

// вариант упаковки флагов в массив. ЛУЧШЕ И УДОБНЕЕ ПРЕДЫДУЩИХ ПРИМЕРОВ!

#define NUM_FLAGS 30                // количество флагов
byte flags[NUM_FLAGS / 8 + 1];      // массив сжатых флагов

// ============== МАКРОСЫ ДЛЯ РАБОТЫ С ПАЧКОЙ ФЛАГОВ ==============
// поднять флаг (пачка, номер)
#define setFlag(flag, num) bitSet(flag[(num) >> 3], (num) & 0b111)

// опустить флаг (пачка, номер)
#define clearFlag(flag, num) bitClear(flag[(num) >> 3], (num) & 0b111)

// записать флаг (пачка, номер, значение)
#define writeFlag(flag, num, state) ((state) ? setFlag(flag, num) : clearFlag(flag, num))

// прочитать флаг (пачка, номер)
#define readFlag(flag, num) bitRead(flag[(num) >> 3], (num) & 0b111)

// опустить все флаги (пачка)
#define clearAllFlags(flag) memset(flag, 0, sizeof(flag))

// поднять все флаги (пачка)
#define setAllFlags(flag) memset(flag, 255, sizeof(flag))
// ============== МАКРОСЫ ДЛЯ РАБОТЫ С ПАЧКОЙ ФЛАГОВ ==============

void setup() {
  Serial.begin(9600);

  clearAllFlags(flags);

  writeFlag(flags, 0, 1);
  writeFlag(flags, 10, 1);
  writeFlag(flags, 12, 1);
  writeFlag(flags, 15, 1);
  writeFlag(flags, 15, 0);
  writeFlag(flags, 29, 1);

  // выводим все
  for (byte i = 0; i < NUM_FLAGS; i++)
    Serial.print(readFlag(flags, i));
}
void loop() {

}

// ОДНОБИТНЫЕ ФЛАГИ ЧЕРЕЗ БИТОВЫЕ ПОЛЯ
// удобнее ручноых операций с битами,
// но кушает дополнительную Flash память и чуть
// медленнее работает

// создаём и пакуем структуру однобитных флагов
struct MyFlags {
  bool button: 1;
  bool state: 1;
  bool position: 1;
  bool flag_3: 1;
  bool flag_4: 1;
};

// объявляем структуру типа MyFlags
MyFlags flags;

void setup() {
  Serial.begin(9600);

  // по умолчанию все флаги false
  // обращаемся как к структуре
  Serial.println(flags.button);
  Serial.println(flags.state);
  flags.position = true;
  Serial.println(flags.position);
}

void loop() {
}

Использовать битовое сжатие и упаковку


В предыдущем пункте мы рассмотрели упаковку однобитных флагов в байты. Таким же способом можно паковать любые другие данные других размеров для удобного хранения или сжатия (но сначала изучите урок про битовые операции). Как пример – моя библиотека microLED, в которой используется следующий алгоритм: изначально необходимо хранить в памяти три цвета для каждого светодиода, каждый цвет имеет глубину 8 бит, т.е. в общей сложности тратится 3 байта на один светодиод RRRRRRRR GGGGGGGG BBBBBBBB. Для экономии места и удобства хранения можно сжать эти три байта в два (тип данных int), потеряв несколько оттенков результирующего цвета. Например вот так: RRRRRGGG GGGBBBBB. Сожмём и упакуем: есть три переменные каждого цвета, r, g, b:

int rgb = ((r & 0b11111000) << 8) | ((g & 0b11111100) << 3) | ((b & 0b11111000) >> 3);

Таким образом мы отбросили у красного и синего младшие (правые) биты, в этом и заключается сжатие. Чем больше битов отброшено – тем менее точно получится “разжать” число. Например сжимали число 0b10101010 (170 в десятичной) на три бита, при сжатии получили 0b10101000, т.е. потеряли три младших бита, и в десятичной уже получится 168. Для упаковки используется битовый сдвиг и маска, таким образом мы берём первые пять битов красного, шесть зелёного и пять синего, и задвигаем на нужные места в результирующей 16-битной переменной. Всё, цвет сжат и его можно хранить.

Для распаковки используется обратная операция: выбираем при помощи маски нужные биты и сдвигаем их обратно в байт:

byte r = (data & 0b1111100000000000) >> 8;
byte g = (data & 0b0000011111100000) >> 3;
byte b = (data & 0b0000000000011111) << 3;

Таким образом можно сжимать, разжимать и просто хранить маленькие данные в стандартных типах данных. Давайте ещё пример: нужно максимально компактно хранить несколько чисел в диапазоне от 0 до 3, то есть в бинарном представлении это 0b00, 0b01, 0b10 и 0b11. Видим, что в один байт можно запихнуть 4 таких числа (максимальное занимает два бита). Запихиваем:

// числа для примера
byte val_0 = 2; // 0b10
byte val_1 = 0; // 0b00
byte val_2 = 1; // 0b01
byte val_3 = 3; // 0b11

byte val_pack = ((val_0 & 0b11) << 6) | ((val_1 & 0b11) << 4) | ((val_2 & 0b11) << 2) | (val_3 & 0b11);
// получили 0b10000111

Как и в примере со светодиодами, мы просто брали нужные биты ( в этом случае младшие два, 0b11) и сдвигали их на нужное расстояние. Для распаковки делаем в обратном порядке:

byte unpack_1 = (val_pack & 0b11000000) >> 6;
byte unpack_2 = (val_pack & 0b00110000) >> 4;
byte unpack_3 = (val_pack & 0b00001100) >> 2;
byte unpack_4 = (val_pack & 0b00000011) >> 0;

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

byte unpack_1 = (val_pack & 0b11 << 6) >> 6;
byte unpack_2 = (val_pack & 0b11 << 4) >> 4;
byte unpack_3 = (val_pack & 0b11 << 2) >> 2;
byte unpack_4 = (val_pack & 0b11 << 0) >> 0;

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

#define UNPACK(x, y) ( ((x) & 0b11 << ((y) * 2)) >> ((y) * 2) )

Где x это пакет, а y – порядковый номер запакованного значения. Выведем посмотрим:

Serial.println(UNPACK(val_pack, 3));
Serial.println(UNPACK(val_pack, 2));
Serial.println(UNPACK(val_pack, 1));
Serial.println(UNPACK(val_pack, 0));

Выбор загрузчика


В одном из первых уроков я рассказывал, что во Flash памяти микроконтроллера живёт bootloader – загрузчик, который загружает прошивку по UART. Загрузчик это не три строчки кода, а гораздо больше: стандартный загрузчик занимает почти 2 кБ Flash памяти! Для Нано/Уно это целых 6%. Варианта два: прошить более современный загрузчик, который занимает в 4 раза меньше места (512 байт). Загрузчик называется optiBoot, основная информация по нему есть на его же GitHub. Процесс перепрошивки загрузчика мы разберём позже, в отдельном уроке. Пока что можете прикупить себе AVR-ISP программатор, хотя загрузчик можно прошить и просто другой Ардуиной.

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

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


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

int main(void)
{
 init();

 initVariant();

#if defined(USBCON)
 USBDevice.attach();
#endif
 
 setup();
    
 for (;;) {
  loop();
  if (serialEventRun) serialEventRun();
 }
        
 return 0;
}

Именно здесь, в инициализациях, кроется пара сотен байт занимаемой Flash памяти! А после loop() есть проверка условия (именно избегание его даёт прирост в скорости, я писал об этом в конце раздела “оптимизация скорости”). В функциях инициализации настраивается периферия микроконтроллера: АЦП, интерфейсы, таймер 0 (который даёт нам корректный millis()), и некоторые другие вещи. Если можете самостоятельно инициализировать только нужную периферию – это позволит сэкономить несколько сотен байт флэша, всё что нужно сделать – это нагло ввести в скетч свою функцию main(), и написать инициализацию только того, что нужно. Для сравнения: стандартный набор инициализации (функции setup() и loop() в скетче) дают 444 байта занимаемой Flash (Arduino IDE v. 1.8.9). Если отказаться от этого кода и перехватить управление main() – пустой скетч будет занимать 134 байта, что почти на 300 байт меньше! Это, конечно, крохоборство, но на дороге не валяется. Как это сделать:

#include <Arduino.h>

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

Функции setup() и loop() в данном скетче уже не нужны, т.к. они не используются в нашем личном main().

Попробовать GyverCore


Также обратите внимание на переписанное мной стандартное ядро для плат на базе ATmega328 – GyverCore. Это ядро является аналогом стандартному, но основные функции полностью переписаны и выполняются в разы быстрее и занимают гораздо меньше места в памяти микроконтроллера.

Купить Arduino Mega


Слегка шуточный, но дельный совет: если вам стало не хватать Уно/Нано и все возможные оптимизации уже сделаны – у вас остался только один вариант – купить Ардуино Мега, у неё в 8 раз больше Flash памяти, и в 4 раза больше SRAM.

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