Типы памяти микроконтроллера


Начнём с типов памяти микроконтроллера, их целых три:

  • Flash или ПЗУ – постоянное запоминающее устройство, энергонезависимая память микроконтроллера, в которой хранится программный код, прошивка. Функции, процедуры, операции, всё это сидит там и не меняется в процессе работы прошивки (можно конечно туда залезть, но это мы делать не будем, да и не надо оно). Во время загрузки прошивки загрузчик (bootloader) записывает прошивку именно сюда.
  • SRAM или ОЗУ – оперативное запоминающее устройство, оперативная память. В ней хранятся переменные, значения которых могут изменяться во время работы, к этой памяти мы имеем более чем свободный доступ и будем активно этим пользоваться. После перезагрузки микроконтроллера память полностью очищается.
  • EEPROM – англ. Electrically Erasable Programmable Read-Only Memory — электрически стираемое перепрограммируемое ПЗУ, энергонезависимая память, отведённая пользователю для хранения значений, которые можно менять, но они не будут сбрасываться после перезагрузки. Штука удобная, позже мы с ней ещё поработаем.

В ближайшее время нас будет интересовать только SRAM память, в которой хранятся переменные, именно о них дальше и пойдёт речь.

Двоичная система


В цифровом мире, к которому относится также микроконтроллер, информация хранится, преобразуется и передается в цифровом виде, то есть в виде нулей и единиц. Соответственно элементарная ячейка памяти, которая может запомнить 0 или 1, называется бит (bit). Таким образом мы плавно переходим к двоичной системе исчисления. Ну же, вспоминайте школьную информатику! Не вдаваясь в подробности “как это работает”, просто попробуем рассмотреть закономерность

Двоичная Десятичная
0000 0
0001 1
0010 2
0011 3
0100 4
0101 5
0110 6
0111 7
1000 8
1001 9
10000 16

И так далее. Помимо закономерности увеличения разрядов и чисел есть ещё одна: приглядитесь к числам в двоичной системе со всеми нулями справа от единицы:

10 2
100 4
1000 8
10000 16

Именно, степень двойки! Именно на степенях двойки в цифровом мире завязано очень много. Чтобы получить количество десятичных чисел, которые могут быть закодированы заданным количеством бит, нужно возвести 2 в степень количества бит. Смотрим на таблицу выше и продолжаем:

  • 5 бит – 32
  • 6 бит – 64
  • 7 бит – 128
  • 8 бит – 256
  • 9 бит – 512
  • 10 бит – 1024

И так далее. Сразу нужно запомнить, что в программировании счёт начинается с нуля, то есть 5ю битами мы можем закодировать десятичное число от 0 до 31, 8-ю битами – от 0 до 255, 10-ю битами – от 0 до 1023. Очень важно понять и запомнить это, дальше очень пригодится.

Следующая по величине единица измерения в цифровом мире – байт (byte), состоит из 8 бит. Почему 8? Исторически сложилось, что шины первых микропроцессоров имели разрядность 8 бит, возможно поэтому это количество приняли за более старшую единицу памяти. Также 8 это 2 в степени 3, что очень символично и удобно. А ещё, для кодирования всех латинских букв, знаков препинания, математических знаков и просто символов (всех что на клавиатуре) раньше хватало 7-ми бит (128 символов), но потом их стало мало, и ввели дополнительный бит, восьмой. То есть 8 бит это также размер таблицы символов, которая называется ASCII. К ней мы вернёмся уже в этой главе. Так что вопрос почему в одном байте 8 бит четкого ответа не имеет, ведь бывает и 6-ти битный байт, и 9-ти битный… Но это исключения древних процессоров, в современных цифровых устройствах в одном байте содержится ровно 8 бит, что позволяет закодировать 256 десятичных чисел от 0 до 255 соответственно. Дальше вы уже точно знаете:

  • 1 кБ = 1024 Б
  • 1 МБ = 1024 кБ
  • 1 ГБ = 1024 МБ
  • И так далее

Другие системы исчисления


Честно вам скажу, я не знаю, в какой из них в переменной хранятся значения, но суть в том, что это в целом неважно. Значения можно записывать в переменную в любой системе исчисления и в любой же их получать. Ведь микроконтроллер это мощный калькулятор, вот он и считает-переводит! Постарайтесь сразу запомнить и понять, что переводить числа из одной системы исчисления в другую не нужно, Ардуино абсолютно всё равно, в каком формате вы скармливаете значение переменной. Разные системы исчисления введены в первую очередь для удобства программиста.

Теперь по сути: ардуино поддерживает (да в целом другого и не нужно) четыре классических системы исчисления: двоичную, восьмеричную, десятичную и шестнадцатеричную. Да, и до неё добрались. Краткая напоминалка: 16-ричная система имеет 16 значений на один разряд, первые 10 как у десятичной, остальные – первые буквы латинского алфавита. 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f.

С десятичной системой всё просто, пишем числа так, как они выглядят. 10 это десять, 25 это двадцать пять, и так далее. Двоичная (Binary) имеет префикс 0b (ноль бэ) или B, то есть двоичное число 101 запишется как 0b101 ИЛИ B101. Восьмеричная (Octal) имеет префикс 0 (ноль), например 012. Шестнадцатеричная (hexadecimal) имеет префикс 0x (ноль икс), FF19 запишется как 0xFF19.

Базис Префикс Пример Особенности
2 (двоичная) B или 0b (ноль бэ) B1101001 цифры 0 и 1
8 (восьмеричная) 0 (ноль) 0175 цифры 0 – 7
10 (десятичная) нет 100500 цифры 0 – 9
16 (шестнадцатеричная) 0x (ноль икс) 0xFF21A цифры 0-9, буквы A-F

Основная фишка 16-ричной системы в том, что она позволяет записывать длинные десятиричные числа короче, например один байт (255) запишется как 0xFF, два байта (65 535) как 0xFFFF, а жуткие три байта (16 777 215) как 0xFFFFFF. Вы не представляете (или уже имеете представление), насколько удобно и понятно это позволяет работать с цветами и оттенками.

Двоичная же система обычно используется для наглядного представления данных и низкоуровневых конфигураций различного железа. Например конфиг кодируется одним байтом, каждый бит в нём отвечает за отдельную настройку (вкл/выкл), и передав один байт вида 0b10110100 можно сразу кучу всего настроить. В документации по этому поводу пишут в стиле “первый бит отвечает за это, второй за то” и так далее.

Переменные


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

Степень двойки преследует нас и дальше, ведь объём одной ячейки памяти в микроконтроллере тоже ей кратен:

  • 1 байт = 8 бит = 256
  • 2 байта = 16 бит = 65 536
  • 4 байта = 32 бита = 4 294 967 296

Да, больше четырёх байт в ардуино (точнее в МК от AVR) уже не влезет, при использовании обычных типов данных. Для работы с разными диапазонами значений используются разные типы данных (переменных). По сути можно использовать 3 байта для хранения чего угодно, но это не оптимально. Это как знать, что вам нужно будет унести максимум 200 мл воды (меньше 1 байта), но вы всё равно берёте 19 литровую бутыль (2 байта). Или железнодорожную цистерну на 120 тонн (4 байта). Если хотите писать красивый и оптимальный код, используйте соответствующие типы данных. Кстати, вот они:

Название Альт. название Вес Диапазон Особенность
boolean bool 1 байт 0 или 1, true или false Логическая переменная. bool на Arduino тоже занимает 1 байт, а не бит!
char 1 байт -128… 127 Хранит номер символа из таблицы символов ASCII
int8_t 1 байт -128… 127
byte uint8_t 1 байт 0… 255
int int16_t, short 2 байта -32 768… 32 767
unsigned int uint16_t, word 2 байта 0… 65 535
long int32_t 4 байта -2 147 483 648…    2 147 483 647 – 2 миллиарда… 2 миллиарда
unsigned long uint32_t 4 байта 0… 4 294 967 295 0… 4 миллиарда…
float 4 байта -3.4028235E+38… 3.4028235E+38 Хранит числа с плавающей точкой (десятичные дроби). Точность: 6-7 знаков
double 4 байта   Для AVR то же самое, что float. А так он 8 байт на более взрослом железе
int64_t 8 байт -(2^64)/2… (2^64)/2-1 *Очень большие числа. Serial не умеет такие выводить
uint64_t 8 байт 2^64-1 *Очень большие числа. Serial не умеет такие выводить

*Не встречал упоминания об этом в официальных источниках, но Ардуино также поддерживает 64 битные числа, соответственно тип данных int64_t и uint64_t

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

  • UINT8_MAX – вернёт 255
  • INT8_MAX – вернёт 127
  • UINT16_MAX – вернёт 65 535
  • INT16_MAX – вернёт 32 767
  • UINT32_MAX – вернёт 4 294 967 295
  • INT32_MAX – вернёт 2 147 483 647
  • UINT64_MAX – вернёт 18 446 744 073 709 551 615
  • INT32_MAX – вернёт ‭9 223 372 036 854 775 807

Помимо целочисленных типов (byte, int, long) есть более интересные:

  • boolean – логический тип данных, принимает значения 0 и 1, или true и false (правда и ложь). По сути ведёт себя как бит, но занимает 8 бит. Экая несправедливость! Есть несколько путей хранить логические переменные так, чтобы они занимали 1 байт, но об этом поговорим позже.
  • char – символьный тип данных, в численном эквиваленте принимает значения от -128 до 127. В случае с char эти значения являются кодами символов в стандартной таблице символов ASCII.
  • float – тип данных с плавающей точкой (англ. float – плавающий), т.е. десятичная дробь.

Объявление переменных


Переменная объявляется очень просто:

  • <тип данных> <имя> <точка с запятой>
  • или <тип данных><имя> = <значение> <точка с запятой>
byte myVal;
int sensorRead = 10;

Самое важное, что следует помнить: переменная должна бывать объявлена до обращения к себе, буквально находиться выше по коду. Иначе вы получите ошибку “переменная не объявлена” – Not declared in this scope

Константы


Что такое константа понятно из её названия – что-то, значение чего мы можем только прочитать и не можем изменить. Задать (объявить) константу можно двумя способами:

  • Точно так же как переменную, указав перед типом данных слово const. Кажется, что константа будет сидеть в оперативной памяти, но это не так: при автоматической оптимизации кода (во время компиляции) константа заменяется дефайном! По дефайнам читайте ниже
    const byte myConst = 10;  // объявить константу
  • При помощи директивы препроцессору #define, которая делает следующее: на этапе компиляции кода препроцессор заменяет указанные все последовательности символов в текущем документе (напомню, что вкладки Arduino IDE являются одним документом) на соответствующие им значения. Константа, определённая при помощи #define не занимает места в оперативной памяти, а хранится как код программы во Flash памяти, это самый большой плюс данного способа. Синтаксис: #define <имя> <значение>. Точка запятой не ставится. Таким способом обычно указывают пины подключения, настройки, различные величины и всё такое. Пример:
    #define BTN_PIN 10
    #define DEFAULT_VALUE 3423

Структуры


Структура (struct) – очень интересный тип данных: это совокупность разнотипных переменных, объединённых одним именем. В некоторых случаях структуры позволяют очень сильно упростить написание кода, сделать его более логичным и легко модифицируемым. Тип данных структура объявляется вот по такой схеме:

struct <ярлык> {
  <тип> <имя переменной 1>;
  <тип> <имя переменной 2>;
  <тип> <имя переменной 3>;
};

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

<ярлык> <имя структуры>;  // объявить одну структуру
<ярлык> <имя структуры1>, <имя структуры2>;  // объявить две структуры типа <ярлык>
<ярлык> <имя структуры>[5];  // объявить массив структур

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

struct {
  <тип> <имя переменной 1>;
  <тип> <имя переменной 2>;
  <тип> <имя переменной 3>;
} <имя структуры>;
  • Обращение к члену структуры производится вот по такой схеме: <имя структуры>.<имя переменной> и позволяет менять или читать значение.
  • Если две структуры имеют одинаковую структуру (объявлены одним ярлыком) то можно одну структуру просто приравнять к другой, все переменные запишутся соответственно на свои места.
  • Ещё одним удобным вариантом является присваивание значения вот таким образом: <имя структуры> = ( <ярлык> ) { <значение переменной 1>, <значение переменной 2>, <значение переменной 3> };

Рассмотрим большой пример, где показано всё вышенаписанное

struct myStruct { // создаём ярлык myStruct
  boolean a;
  byte b;
  int c;
  long d;
  byte e[5];
} kek;            // и сразу создаём структуру kek

// создаём массив структур cheburek типа myStruct
myStruct cheburek[3];

void setup() {  
  // присвоим членам структуры значения вручную
  kek.a = true;
  kek.b = 10;
  kek.c = 1200;
  kek.d = 789456;
  kek.e[0] = 10;    // e у нас массив!
  kek.e[1] = 20;
  kek.e[2] = 30;

  // присвоим структуру kek структуре cheburek номер 0
  cheburek[0] = kek;

  // присвоим элемент массива из структуры kek 
  // структуре cheburek номер 1
  cheburek[0].e[1] = kek.e[1];

  // забьём данными структуру cheburek номер 2
  cheburek[2] = (myStruct) {
    false, 30, 3200, 321654, {1, 2, 3, 4, 5}
  };
}

Для чего нужны структуры? В большинстве примеров в интернете приводится использование структур для хранения адресных данных, т.е. создания базы данных адресов: имя, фамилия, номер телефона, итд. На моей практике структуры оказались очень удобными для создания меню с большим количеством режимов и настроек (скажем несколько каналов, у каждого одинаковый набор настроек). Структуры очень удобно передавать и получать например при помощи RF24 модулей, т.е. вместо массивов удобнее использовать структуры и передавать одной строчкой сразу кучу типов данных. Также структуру можно целиком записать в eeprom одной строчкой (командой put) и одной строчкой оттуда же её считать, не заморачиваясь с номерами ячеек, как это происходит при записи данных вручную.

Перечисления


Перечисления (enum – enumeration) – тип данных, представляющий собой набор именованных констант, нужен в первую очередь для удобства программиста. Сразу пример из опыта: допустим у нас есть переменная mode, отвечающая за номер режима работы устройства. Мы для себя запоминаем, какому значению переменной какой режим будет соответствовать, и где-нибудь себе записываем, например 0 – обычный режим, 1 – режим ожидания, 2 – режим настройки_1, 3 – режим настройки_2, 4 – калибровка, 5 – аварийный режим, ошибка. При написании или чтении программы часто придётся обращаться к этому списку, чтобы не запутаться. Можно сделать первый шаг по оптимизации: обозвать каждый режим при помощи дефайна:

#define NORMAL 0
#define WAITING 1
#define SETTINGS_1 2
#define SETTINGS_2 3
#define CALIBRATION 4
#define ERROR_MODE 5

Таким образом вместо цифры можно будет использовать понятные слова и ориентироваться в коде будет гораздо проще. Использование enum ещё немного упрощает эту конструкцию: перечисление позволяет создать переменную (по умолчанию типа int), которая может принимать только те “названия”, которые для неё указаны. Это удобно тем, что в одной программе могут находиться разные хранители режимов с одинаковыми названиями, и в отличие от define это не будет приводить к ошибкам.

Объявление перечисления чем-то похоже на объявление структуры:

enum <ярлык> {<имя1>, <имя2>, <имя3>, <имя4>, <имя5>};

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

<ярлык> <имя перечисления>;

Также как и у структур, можно объявить перечисление без создания ярлыка (зачем нам лишняя строчка?):

enum {<имя1>, <имя2>, <имя3>, <имя4>, <имя5>} <имя перечисления>;

Созданное таким образом перечисление является переменной, которая может принимать указанные для неё <имена>, также с этими именами её можно сравнивать. Теперь самое главное: имена для программы являются числами, начиная с 0 и далее по порядку увеличиваясь на 1. В абстрактном примере выше <имя1> равно 0, <имя2> равно 1, <имя3> равно 2, и так далее. Помимо указанных имён, перечислению можно приравнять и число напрямую, но как бы зачем. Рассмотрим пример!

// создаём перечисление modes
// не создавая ярлык
enum {
  NORMAL,
  WAITING,
  SETTINGS_1,
  SETTINGS_2,
  CALIBRATION,
  ERROR_MODE,
} modes;

void setup() {
  Serial.begin(9600);   // для отладки
  modes = CALIBRATION;  // присваивание значения

  // можем сравнивать
  if (modes == CALIBRATION) {
    Serial.println("calibr");
  } else if (modes == ERROR_MODE) {
    Serial.println("error");
  }

  // присваиваем числом
  modes = 3;  // по нашему порядку это будет SETTINGS_2
}

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

enum {SET1 = 1, SET2, SET3, SET4, SET5} settings;

Таким образом SET1 имеет значение 1, SET2 будет 2 и так далее по порядку.

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


В языке есть инструмент typedef, позволяющий создать свой тип данных, основанный на другом стандартном. Зачем? С одной стороны для удобства программиста, с другой – чтобы его запутать =) Я никогда им не пользовался, но для разбора чужих кодов из инторнета это знать нужно! Итак, typedef работает следующим образом: typedef <тип> <имя>; – создать новый тип данных <имя> на основе типа <тип>. Пример:

typedef byte color;

Создаёт тип данных под названием color, который будет абсолютно идентичен типу byte (то есть принимать 0-255). Теперь с этим типом можно создавать переменные:

color R, G, B;

Создали три переменные типа color, который тот же byte, только в профиль. Это всё!

Есть ещё один важный момент, касающийся структур struct и перечислений enum – в кодах из интернета вы часто встретите использование typedef перед struct и enum. В чистом языке Си это имеет большой смысл, но в C++ это наоборот является проблемой. typedef в ардуино-прошивках используют программисты, которые пришли с Си и по привычке используют typedef, на плюсах делать этого не нужно.

В языке Си (без ++) при создании структуры/перечисления по ярлыку нужно писать слово struct/enum перед ярлыком, иначе будет ошибка. Либо нужно саму структуру нужно объявить как typedef

// НА СИ, НЕ ++
// делаем так
struct myStruct {
  byte x;
  byte y;
};
struct myStruct kek;  // создать структуру kek

// или так
typedef struct myStruct {
  byte x;
  byte y;
};
myStruct kek;  // создать структуру kek

В С++ и на Ардуино этого делать не нужно! Наоборот, typedef в этом применении может приводить к ошибкам. Например:

// используя typedef вы не можете
// объявить структуру сразу.
// Вот это приведёт к ошибке при
// попытке обратиться к члену структуры
typedef struct {
  byte x;
  byte y;
} kek;

// вот это тоже приведёт к ошибке
typedef struct myStruct {
  byte x;
  byte y;
} kek;

// Единственным вариантом останется
// работа по ярлыку
typedef struct myStruct {
  byte x;
  byte y;
};

myStruct kek;
// вот серьёзно, нужны ли вам такие проблемы?

Область видимости


Переменные, константы и другие типы данных (структуры и перечисления) имеют такое важное понятие, как область видимости. Она бывает

  • Глобальной
  • Локальной
  • Формальной (параметр)

Глобальная


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

byte var;
void setup() {
  // спокойно меняем глобальную переменную
  var = 50;
}
void loop() {
  // спокойно меняем глобальную переменную
  var = 70;
}

Локальная


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

void setup() {
  byte var;
  // спокойно меняем локальную переменную
  var = 50;
}
void loop() {
  // приведёт к ошибке
  var = 70;
}

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

byte var;   // глобальная var
void setup() {
  byte var;
  // меняем локальную var
  var = 50;
}
void loop() {
  // а тут меняем уже глобальную var
  var = 70;
}

Формальная (параметр)


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

void setup() {
  // передаём 10 как аргумент
  myFunc(10);
}

void loop() {
}

void myFunc(byte var) {
  // тут var является локальной
  // прибавим к ней 20
  var += 20;
}

Спецификаторы


Помимо возможности сделать переменную константой при помощи спецификатора const у нас есть ещё несколько интересных инструментов по работе с переменной.

static


static – делает переменную (или константу) статичной. Что это значит? Для начала вспомним, как работает обычная локальная переменная: при вызове функции локальная переменная создаётся заново и получает нулевое значение, если не указано иначе. Если локальная переменная объявлена как static – она будет хранить своё значение от вызова к вызову функции, то есть станет грубо говоря глобально-локальной. Пример:

Обычная локальная:

void setup() {
  myFunc(); // вернёт 20
  myFunc(); // вернёт 20
  myFunc(); // вернёт 20
  myFunc(); // вернёт 20
}

void loop() {
}

byte myFunc() {
  byte var = 10;
  var += 10;
  return var;
}

Статичная локальная:

void setup() {
  myFunc(); // вернёт 20
  myFunc(); // вернёт 30
  myFunc(); // вернёт 40
  myFunc(); // вернёт 50
}

void loop() {
}

byte myFunc() {
  static byte var = 10;
  var += 10;
  return var;
}

Статичная глобальная:

Статичная глобальная переменная становится доступной только в данном файле, спецификатор static позволяет спрятать её от воздействий из других файлов программы.

extern


extern – указывает компилятору, что переменная объявлена где-то в другом файле программы, и при компиляции он её найдёт и будет использовать. А если не найдёт – ошибки не будет. Например при помощи данного кода можно сбросить счётчик millis()

// указываем, что хотим использовать
// переменную timer0_millis,
// которая объявлена где-то далеко
// в файлах Arduino
extern volatile unsigned long timer0_millis;

void setup() {
  timer0_millis = 0;  // сброс mills()
}
void loop() {
  
}

volatile


volatile – данный спецификатор указывает компилятору, что данную переменную не нужно оптимизировать и её значение может быть изменено откуда-то извне. Обычно переменные с таким спецификатором используются в обработчиках прерываний.

Видео


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


  • Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
  • Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
  • Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
  • Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
  • Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
Последнее обновление Сентябрь 06, 2019
2019-09-06T13:43:03+03:00