Оптимизация кода
С ростом навыков и созданием всё более глобальных проектов вы столкнётесь с тем, что "Ардуина" перестанет справляться с тем объёмом работы, который вы хотите от неё получить. Может банально не хватать быстродействия в расчётах, обновлении информации на дисплеях, отправки данных и прочих ресурсозатратных действий, а ещё может просто закончиться память! Самое страшное, когда заканчивается оперативная память: она может это сделать абсолютно незаметно и устройство начнёт вести себя неадекватно, перезагрузится или попросту зависнет. Как этого избежать? Нужно оптимизировать свой код! Информации по этому поводу в Интернете очень мало, поэтому я опишу всё, с чем сталкивался лично.
С чем компилятор справится сам
Модификатор 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 & (2^n - 1)
, напримерval % 8
==val & 7
,val % 32
==val & 31
и так далее). Таким образом например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
. Напомню, что шаблон позволяет также создавать внутри класса массив заданного размера, об этом рассказывал в уроке про библиотеки.
Тестовый класс с дигиталРидами (для AVR) разных вариантов:
Результаты бенчмарка в тактах процессора (для AVR):
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 |
Оптимизация скорости
Начнём с вопроса как измерить время выполнения кода. Для большинства случаев достаточно стандартной конструкции с millis()/micros() - запомнить текущее время, выполнить действие, вычесть запомненное время из текущего. Точность измерения тем выше, чем меньше в "измеряемом" коде используется запрет прерываний. Минимальная единица измерения (для AVR Arduino) - 4 микросекунды, это разрешение функции micros().
Для "взрослой" оптимизации кода (вычисления, IO) этой точности будет мало. Вот вам инструмент для замера времени выполнения кода с точностью до одного такта процессора (0.0625 микросекунды для 16 МГц тактирования), выводит время выполнения в тактах процессора и микросекундах. Код работает на таймере 1. Код для ATmega328 (Arduino NANO):
Обновлённая версия, без ограничения по времени выполнения
Использовать переменные соответствующих типов
Тип переменной/константы не только влияет на занимаемый ей объём памяти, но и на скорость вычислений! Привожу таблицу для простейших не оптимизированных компилятором вычислений. В реальном коде время может быть меньше. Примечание: время приведено для AVR и кварца 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 нет, но можно работать с ними при помощи самописных функций, макросов или библиотек, под спойлером найдёте пример, который можно использовать на практике:
Также у меня есть простенькая библиотека для работы с такими числами.
Заменить умножение на 2^n битовым сдвигом
В операциях целочисленного умножения, где второй множитель является константой или числом (val / 10
), можно ускорить вычисление в том случае, когда число состоит из степени двойки (2 4 8 16 32 64 128...). Для этого нужно заменить умножение на 2^n
сдвигом влево на n
:
val * 2
==val << 1
val * 8
==val << 3
val * 32
==val << 5
- И так далее
Примечание: компилятор сам оптимизирует такие вычисления, так что нужно стараться писать свои алгоритмы так, чтобы в математических операциях были числа из степени двойки (например размеры буферов, размеры матрицы и т.д.).
Заменить деление на 2^n битовым сдвигом
В операциях целочисленного деления, где делитель является константой или числом (val / 10
), можно ускорить вычисление в том случае, когда делитель состоит из степени двойки (2 4 8 16 32 64 128...). Для этого нужно заменить деление на 2^n
сдвигом вправо на n
:
val / 2
==val >> 1
val / 8
==val >> 3
val / 32
==val >> 5
- И так далее
Вычисление выполняется в ~15 раз быстрее (AVR).
Примечание от Korugo:
Заменяя сдвигом деление на степень двойки помним, что это работает правильно только для положительных чисел.
64 / 8 // == 8 64 >> 3 // == 8 63 / 8 // == 7 63 >> 3 // == 7 -64 / 8 // == -8 -64 >> 3 // == -8 -63 / 8 // == -7 -63 >> 3 // == -8 (!)
Как быть: если число меньше нуля, и оно не делится на делитель нацело, надо добавить к результату единицу:
// делим знаковое val на 8 (2^3): if ((val < 0) && (val & ((1 << 3) - 1))) result = (val >> 3) + 1; else result = val >> 3;
Заменить остаток от деления на 2^n битовой маской
В операциях взятия целочисленного остатка от деления, где делитель является константой или числом (val % 10
), можно ускорить вычисление в том случае, когда делитель состоит из степени двойки (2 4 8 16 32 64 128...). Для этого нужно заменить взятие остатка от 2^n
на битовую маску (2^n - 1)
:
val % 2
==val & 1
val % 8
==val & 7
val % 32
==val & 31
- И так далее
Вычисление выполняется в ~17 раз быстрее (AVR).
Примечание: компилятор сам оптимизирует такие вычисления, но не всегда, поэтому в критических ко времени вычислениях рекомендуется проводить такую оптимизацию самостоятельно.
Заменить деление умножением
В операциях целочисленного деления, где делитель является константой или числом (val / 10
), можно заменить деление умножением на обратное число. Идея состоит в следующем:
a / b
== a * (1 / b)
== (a * x) >> n
, где:
x = (2^n) / b + 1
a * x
не должно превышать ячейку 32 бита (или 16 бит для более быстрого вычисления)n
- "масштаб". Чем большеn
, тем выше точность. Нужно подобрать так, чтобы было максимальным для 32 или 16 бит, при известном максимальном значенииa
Алгоритм:
- Определить
n
. Выразим из формулы дляx
:2^n < (MAX / a_max - 1) * b
, гдеMAX
- размер ячейки для операции умножения, имеет смысл 32 бит (4294967296
) или 16 бит (65356
) - Посчитать
x
по формуле выше - Заменить деление на
(a * x) >> n
для 16 бит или((uint32_t)a * x) >> n
для 32 бит - Бонус - поиск остатка от деления. Достаточно вычесть из делимого произведение результата от деления на делитель:
a % b
==a - q * b
, гдеq = a / b
, посчитанное способом выше
Вычисление выполняется в ~2 раза быстрее (AVR).
Пример расчёта:
- Хочу оптимизировать деление на
6
- Делить буду переменную, которая у меня в программе принимает значения от
0
до130
- Вычисления ограничим в ячейке 16 бит (макс. 65536) для бОльшей скорости
- Ищем
n
:(65536 / 130 - 1) * 6 = 3018
, то есть2^n
не должно превышать 3018, ближайшееn = 11
- Пересчитываем
x
:x = (2^11) / 6 + 1 = 342
- Получим выражение для вычисления
a / 6
:(a * 342) >> 11
Заменить деление умножением на float
Опять же по таблице выше можно увидеть, что деление для всех типов данных выполняется гораздо дольше умножения, поэтому иногда бывает выгоднее заменить деление на целое число умножением на float
. И да, пытаться усидеть на двух стульях, стараясь не использовать float
и использовать его вместо деления:
val / 10; // выполняется 14.54 мкс val * 0.1; // выполняется 10.58 мкс
Заменить возведение в степень умножением
Для возведения в степень у нас есть удобная функция pow(a, b)
, но в целочисленных расчётах лучше ей не пользоваться: она выполняется гораздо дольше ручного перемножения, потому работает с float
, даже если скормить ей целое:
val = pow(val, 5); // выполняется 20.33 us val = (long)val * val * val * val * val; // выполняется 4.47 us
Заменить сдвиг указателем
Очень часто при работе с данными бывает нужно "склеить" 16-32 бит переменную из отдельных байтов или наоборот разобрать её на байты. Обычно в таких случаях используются сдвиги:
// получить старший байт из 16 бит переменной a b = a >> 8; // склеить два байта в 16 бит int v = a | (b << 8); // разбить 24 бит цвет на каналы r = ((uint32_t)color >> 16) & 0xFF; g = ((uint32_t)color >> 8) & 0xFF; b = (uint32_t)color & 0xFF; // склеить 24 бит цвет из трёх каналов color = ((uint32_t)r << 16) | (g << 8) | b;
Сдвиги можно заменить на доступ к байтам переменной по указателю, и уже оттуда их читать и писать (равноценный пример):
// получить старший (второй) байт из 16 бит переменной a b = ((byte*)&a)[1]; // склеить два байта в 16 бит int ((byte*)&v)[0] = a; ((byte*)&v)[1] = b; // разбить 24 бит цвет на каналы r = ((byte*)&color)[2]; g = ((byte*)&color)[1]; b = ((byte*)&color)[0]; // склеить 24 бит цвет из трёх каналов ((byte*)&color)[2] = r; ((byte*)&color)[1] = g; ((byte*)&color)[0] = b;
Как это работает (на первом примере) - мы берём адрес переменной a
- &a
, затем преобразуем его к указателю на тип данных byte
- (byte*)&a
. Чтобы получить доступ к нужному байту в переменной, достаточно обратиться к полученной конструкции как к массиву - ((byte*)&a)[номер байта]
для записи и чтения.
Такая конструкция работает в 2 раза быстрее сдвигов, но всё же это крохоборство: в примере со сборкой 3 байт доступ по указателю даёт экономию в 1.5 микросекунды (на AVR). В подавляющем большинстве случаев это настолько незначительно, что лучше не трогать привычные сдвиги. Но если таких преобразований нужно делать много, и важен каждый такт микроконтроллера, то можно и ускорить.
Предварительно вычислять то, что можно вычислить
Некоторые сложные вычисления требуют выполнения одних и тех же действий несколько раз. Гораздо быстрее будет создать локальную переменную, в неё "посчитать" и использовать в дальнейших расчётах. Примечание: большинство расчётов компилятор оптимизирует сам, например действия с константами и конкретными цифрами.
Ещё хороший пример: расчёт величин, которые ведут себя предсказуемо, например гармонические функции sin()
и cos()
. На их вычисление уходит довольно-таки много времени - 119.46 мкс! На практике синусы/косинусы практически никогда не вычисляют средствами микроконтроллера, их вычисляют заранее и сохраняют в виде массива. Да, опять два стула: тратить время на вычисление или занимать память уже посчитанными данными. Также не забываем, что компилятор сам оптимизирует вычисления и делает это весьма неплохо.
Оптимизировать действия со String строками
Подробно разбирали в уроке про String.
Не использовать delay() и подобные задержки
Вполне очевидный совет: не используйте delay()
там, где можно обойтись без него. А это 99.99% случаев. Используйте таймер на millis()
, как мы изучали в уроке про многозадачность.
Заменить Ардуино-функции их быстрыми аналогами
Если в проекте очень часто используется периферия микроконтроллера (АЦП, цифровые входы/выходы, генерация ШИМ...), то нужно знать одну вещь: Ардуино (на самом деле Wiring) функции написаны так, чтобы защитить пользователя от возможных ошибок. Внутри этих функций находится куча различных проверок и защит "от дурака", поэтому они выполняются гораздо дольше, чем могли бы. Также некоторая периферия микроконтроллера настроена так, что работает очень медленно. Пример: digitalWrite()
и digitalRead()
выполняются около 3.5 мкс, когда прямая работа с портом микроконтроллера занимает 0.5 мкс, что почти на порядок быстрее. analogRead()
выполняется 112 мкс, хотя если его настроить чуть по-другому, он будет выполняться почти в 10 раз быстрее, не особо потеряв в точности. О таком "разгоне" Ардуино мы поговорим в отдельном уроке. В статье полезные алгоритмы для Arduino я выложил несколько "быстрых и лёгких" аналогов Ардуино-функциям.
Использовать switch вместо else if
В ветвящихся конструкциях со множественным выбором по значению целочисленной переменной стоит отдавать предпочтение конструкции switch-case
, она работает быстрее else if
(изучали в уроках про условия и выбор). Но помните, что:
switch
работает только с целочисленными даннымиcase
должны быть константами
Причём чем дальше находится верное условие (через несколько сравнений), тем больше времени выполняется код. При использовании switch
время каждый раз одинаковое!
Результаты теста для AVR:
Конструкции со сравнениями и диапазонами также можно заменить на Такой код не только выглядит более читаемо, но и выполняется сильно быстрее, особенно при попадании в дальние диапазоны. Если проверяется одновременно несколько логических выражений, то при наступлении первого результата, при котором всё условие однозначно получит известное значение, остальные выражения даже не проверяются. Например: Если Используйте битовые трюки и вообще битовые операции, часто они помогают ускорить код. Читайте в отдельном уроке. Вместо передачи "объекта" в качестве аргумента функции, передавать его по ссылке или по указателю: процессор не будет выделять память под копию аргумента и создавать эту копию в качестве формальной переменной - это сэкономит время! Подробнее про указатели и ссылки читайте в отдельном уроке. Каждая созданная функция имеет свой адрес в памяти, и для её вызова процессор обращается по этому адресу, что занимает время. Время очень малое, но иногда даже оно бывает критичным, поэтому такие критичные ко времени вызовы можно заменить на макро-функции или на встроенные функции, подробнее читайте в уроке про функции. Константы ( Почему это происходит? Компилятор оптимизирует код, и с константными аргументами он может выбросить из функции почти весь лишний код (если там есть, например, блоки Функция Arduino IDE поддерживает ассемблерные вставки, в которых на одноимённом языке можно давать прямые команды процессору, что обеспечивает максимально быстрый и чёткий код. Но у нас в семье о таком не шутят =) Чаще всего мы сталкиваемся с нехваткой памяти: постоянной Flash или оперативной SRAM. После компиляции кода мы получаем сообщение о занимаемом объёме Flash/SRAM, это ценная информация. Flash память можно забивать на 99%, её объём не изменяется в процессе работы устройства, чего не скажешь о SRAM. Допустим, на момент запуска программы у нас занято 80% оперативной памяти, но в процессе работы могут появляться и исчезать локальные переменные, которые добьют занимаемый объём до 100% и устройство скорее всего перезагрузится или зависнет. Опасность ещё в том, что "раздел" оперативной памяти начинает фрагментироваться, т.е. появляются маленькие пустые места, которые микроконтроллер не может занять новыми появляющимися данными. Да, всё как на компьютере, только кнопки "дефрагментировать" у нас нет. Поэтому нужно или учиться вручную заниматься менеджментом памяти, или стараться оставлять побольше свободной SRAM. Прилагаю скетч-пример с функцией, которая выводит объём свободной SRAM. Скачать с FTP сайта (нажать правой кнопкой - сохранить файл). Как вы помните из урока о типах данных, каждый тип имеет ограничение на максимально хранимое значение, от чего прямо зависит вес этого типа в памяти. Вот они все: Просто не используйте переменные более тяжёлых типов там, где это не нужно. Для хранения констант в стиле номеров пинов, каких-то настроек и постоянных значений используйте не глобальные переменные, а Если у вас какой-то комплексный проект, где перед прошивкой включаются/выключаются некоторые куски кода или библиотеки - используйте условную компиляцию при помощи директив Для хранения больших объемов постоянных данных (массив битмапов для вывода на дисплеи, строки с текстом, "таблицы" синуса или других корректирующих значений) используйте Читайте подробный урок по PROGMEM. Если в проекте используется вывод в COM порт фиксированных текстовых данных, то каждый символ будет занимать один байт оперативной памяти, также это относится к строковым данным и выводам на дисплей. У нас есть на вооружении встроенный инструмент, который позволяет хранить строки во Flash памяти, использовать его удобнее того же Как мы обсуждали в уроке про типы данных, поддержка вычислений с Пожалуй самые "жирные" по занимаемой памяти библиотеки - это стандартные объекты Serial и String. Если в коде появляется Serial, он сразу же забирает себе минимум 998 байт Flash (3% для ATmega328) и 175 байт SRAM (8% для ATmega328). Как только начинаем использовать строки String - прощаемся с 1178 байтами Flash (4% для ATmega328). Если Serial всё таки нужен - попробуйте использовать сильно облегчённый аналог стандартной библиотеки - microUART. Вы должны быть в курсе, что логический тип данных boolean занимает в памяти Arduino не 1 бит, как должен занимать, а целых 8, т.е. 1 байт. Это вселенская несправедливость, ведь по сути мы можем сохранить в одном байте 8 флагов В предыдущем пункте мы рассмотрели упаковку однобитных флагов в байты. Таким же способом можно паковать любые другие данные других размеров для удобного хранения или сжатия, подробнее в уроке про битовые операции. Во Flash памяти микроконтроллера живёт bootloader - загрузчик, который загружает прошивку по UART. Загрузчик это не три строчки кода, а гораздо больше: стандартный загрузчик занимает почти 2 кБ Flash памяти! Для Нано/Уно это целых 6%. Варианта два: Для обоих вариантов понадобится программатор, подробно всё разбираем в отдельном уроке. Стандартные функции Именно здесь, в инициализациях, кроется пара сотен байт занимаемой Flash памяти! А после В функциях инициализации настраивается периферия микроконтроллера: АЦП, интерфейсы, таймер 0 (который даёт нам корректный Функции switch-case
:// условия
if (a > 0 && a < 10) {код}
else if (a >= 11 && a < 20) {код}
else if (a >= 21 && a < 30) {код}
// switch с диапазонами
switch (a) {
case 0 ... 10: код; break;
case 11 ... 20: код; break;
case 21 ... 30: код; break;
}
Помнить про порядок условий
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 (;;) {
// ваш код
}
}
Кодить на ассемблере (шутка)
Оптимизация памяти
Использовать переменные соответствующих типов
Название
Вес
Диапазон
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 пишутся во время прошивки, и изменить их потом будет нельзя, можно только прочитать и использовать.Использовать F() макро
PROGMEM
. Работает очень просто и эффективно, позволяя делать девайс с расширенным общением/отладкой через 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
Использовать однобитные флаги
true
/false
, а на деле храним только один. Но выход есть: паковать биты вручную в байт, для чего нужно добавить несколько макросов. Пользоваться этим не очень удобно, но в критической ситуации, когда важен каждый байт, можно и заморочиться. Смотрите примеры:Использовать битовое сжатие и упаковку
Выбор загрузчика
Отказаться от стандартной инициализации
setup()
и loop()
являются обязательными не просто так: они входят в самую главную функцию всей программы - int main()
. Реализация данной функции лежит в ядре в файле main.cpp и выглядит вот так:loop()
есть проверка условия, которое чуть замедляет основной цикл.millis()
), и некоторые другие вещи. Если можете самостоятельно инициализировать только нужную периферию - это позволит сэкономить несколько сотен байт флэша, всё что нужно сделать - это нагло ввести в скетч свою функцию int 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()
.Полезные страницы