C/C++ - язык со строгой типизацией, т.е. существует несколько типов данных, каждый тип может хранить данные своего "формата" и размера. Стандартные типы данных могут конвертироваться между собой, об этом - в следующем уроке.
Логика #
Тип bool может хранить только 1 и 0 или специальные константы true (правда) и false (ложь), нужен для удобства работы с логическими выражениями и величинами:
bool var = true;
Несмотря на свою двоичность, bool имеет вес не 1 бит, а 1 байт, т.к. это минимальная адресуемая ячейка памяти. Хранить 1-бит значения тоже можно, см. урок про битовые флаги.
Символы #
Тип char хранит код символа из стандартной таблицы символов ASCII, подробнее об этом поговорим в уроке про строки.
char var = 'a';
Целые числа #
Целочисленные типы данных имеют лимиты по хранимым значениям, а также некоторые тонкости при вычислениях. Эти особенности можно просто выучить, но гораздо лучше будет разобраться в том, как хранятся целые числа - тогда всё покажется очень логичным и учить ничего не придётся.
Диапазон значений #
Диапазон допустимых значений целочисленного типа зависит от его размера и "знаковости" - поддержки отрицательных чисел. Чем больше места занимает тип - тем больше уникальных значений он может хранить, это количество равно 2^n, где n - размер типа в битах. По сути это - количество возможных комбинаций нулей и единиц. Например 3 бита - 2^3 = 8 возможных комбинаций: 000, 001, 010, 011, 100, 101, 110, 111. Или 8 бит: 2^8 = 256 комбинаций.
Размеры стандартных типов данных идут по степени двойки:
| Знаковый | Вес, бит | От | До |
|---|---|---|---|
| Да | 8 | -128 | 127 |
| Нет | 8 | 0 | 255 |
| Да | 16 | -32 768 | 32 767 |
| Нет | 16 | 0 | 65 535 |
| Да | 32 | -2 147 483 648 | 2 147 483 647 |
| Нет | 32 | 0 | 4 294 967 295 |
| Да | 64 | -9 223 372 036 854 775 808 | 9 223 372 036 854 775 807 |
| Нет | 64 | 0 | 18 446 744 073 709 551 615 |
Технически возможно создание "ячеек" произвольного размера для экономии памяти, см. урок про упаковку данных
Диапазоны значений стандартных типов хранятся в константах, которые можно использовать в программе. Иногда это помогает избавиться от лишних вычислений:
UINT8_MAXINT8_MAXUINT16_MAXINT16_MAXUINT32_MAXINT32_MAXUINT64_MAXINT64_MAXSIZE_MINSIZE_MAX
Для использования этих констант на некоторых платформах нужно подключить в код стандартную библиотеку #include <stdint.h> или #include <limits>
Беззнаковые числа #
Так как данные хранятся в двоичном виде, то они уже представляют собой число в диапазоне от 0 до 2^n - 1. То есть 8 бит тип может хранить числа от 0 до 255, 16 бит - от 0 до 65535 и так далее по аналогии. Типы данных, которые хранят только положительные числа, называются беззнаковыми (unsigned).
Что будет, если превысить допустимое значение? Возьмём ячейку 1 байт (8 бит) и посчитаем вверх с шагом 1 по обычным правилам сложения, начиная с числа 0b11111110 (254 DEC):
0b11111110 // 254
0b11111111 // 255
0b00000000 // 0 - 0b100000000, но старший разряд не уместится!
0b00000001 // 1
Всё верно, значение обнулится, так как старший разряд хранить уже негде - мы рассматриваем ячейку с размером 8 бит. В таком случае говорят, что ячейка переполнилась сверху.
При уменьшении числа это произойдёт в обратном порядке - из 0b00000000 получится 0b11111111, то есть ячейка переполнится снизу. Таким образом, в рамках 1 байтного типа данных 255 + 1 = 0, а 0 - 1 = 255.
Знаковые числа #
Чтобы не потерять знак числа, его нужно тоже где-то хранить. У двоичных чисел знак хранится в старшем бите: 0 - положительное число, 1 - отрицательное, такой тип хранения называется дополнительным кодом (two's complement). Таким образом, на хранение самого числа остаётся на 1 бит меньше места и диапазон значений делится пополам - от -2^(n-1) до 2^(n-1) - 1. Например для 8 бит получится диапазон десятичных чисел от -128 до 127. Типы данных с поддержкой знака числа называются знаковыми (signed).
Давайте теперь посчитаем от числа 3 DEC вниз с шагом 1 по обычным правилам вычитания в ячейке 8 бит:
0b00000011 // 3
0b00000010 // 2
0b00000001 // 1
0b00000000 // 0
0b11111111 // -1
0b11111110 // -2
Данные у нас ограничены одним байтом, поэтому при переходе через 0 пришлось "добавить" слева старший разряд 1 и сразу его убрать, т.е. 100000000 - 1 = 11111111 - ячейка переполнилась снизу.
Вот и появилась слева единица, которая отвечает за знак числа! Да, старший бит не просто отвечает за знак, он получается автоматически, то есть число хранится ровно так, как оно и представлено в двоичной системе. Именно поэтому компилятору нужно конкретно указывать, как интерпретировать данные - как беззнаковое или как знаковое число, потому что одни и те же данные будут иметь разное значение: 0b11111110 как беззнаковое - это десятичное 254, а как знаковое - десятичное -2.
Благодаря такому представлению данных все арифметические операции выполняются быстро и по обычным правилам сложения чисел. Более того, процессору не нужна дополнительная операция "вычитание", потому что вычитание - это сложение с отрицательным числом, оно происходит автоматически по обычным правилам сложения.
Типы short, int, long #
Несмотря на то, что этих типов всего 3, синтаксис позволяет комбинировать их и получить целый "зоопарк" одинаковых типов. Это нужно понять, принять и не дать себя запутать
Стандарт языка задаёт только минимумы для этих типов, т.е. максимум зависит от платформы и компилятора:
short- минимум 16 битint- минимум 16 битlong- минимум 32 битlong long- минимум 64 бит (это отдельный тип)
По умолчанию эти типы являются знаковыми, знаковость устанавливается при помощи модификаторов:
signed- знаковый, поддерживать отрицательные числаunsigned- беззнаковый, не поддерживать отрицательные числа
То есть signed int аналогичен int и поддерживает отрицательные числа, а unsigned int - не поддерживает.
Модификатор знаковости является самостоятельным типом и синонимом int, то есть signed аналогичен signed int, а unsigned - unsigned int.
Тип int является базовым целочисленным типом и остальные типы можно рассматривать как модификаторы для него: long int аналогичен long.
Вот для наглядности все возможные комбинации создания 8-ми основных типов:
int,signed,signed int- знаковый intunsigned,unsigned int- беззнаковый intshort,short int,signed short,signed short int- знаковый shortunsigned short,unsigned short int- беззнаковый shortlong,long int,signed long,signed long int- знаковый longunsigned long,unsigned long int- беззнаковый longlong long,long long int,signed long long,signed long long int- знаковый long longunsigned long long,unsigned long long int- беззнаковый long long
unsigned var = 1234;
int i = -123;
Тип size_t #
Тип size_t (size, размер) - беззнаковый целочисленный тип, размер которого ограничен максимальным весом данных, которые могут существовать на текущей платформе: размер другого типа данных, длина массива, максимальный адрес в памяти. Например:
- AVR - 16 бит
- ESP - 32 бит
- ПК - 64 бит
size_t обычно используется там, где подразумевается размер данных (количество байт) - для читаемости программы
size_t size = 1234;
Тип char #
Несмотря на то, что это символьный тип, с модификатором знака он становится отдельным типом данных для хранения целых чисел:
signed char- знаковый 8 битunsigned char- беззнаковый 8 бит
unsigned char var = 123;
Знаковость самого char зависит от платформы и компилятора, под капотом он может быть как signed, так и unsigned
Короткие названия #
К счастью, у целочисленных типов есть удобные короткие названия, которые сразу отражают знаковость и размер:
intN_t- знаковыеuintN_t- беззнаковые
где N - размер в количестве бит: 8, 16, 32 или 64. Компилятор подставляет вместо этих типов стандартные типы в зависимости от платформы, на которой компилируется код - например вместо uint8_t подставит unsigned char.
Я использую короткие названия, потому что:
- Название более короткое в большинстве случаев
- Проще ориентироваться в максимальных значениях
- Проще изменить размер или знаковость
- Вес переменной задан явно и не зависит от платформы
uint16_t var = 1234;
Для использования коротких названий нужно подключить в код стандартную библиотеку #include <stdint.h>
Вещественные числа #
Вещественные числа - числа, имеющие целую и дробную части. Во многих вычислениях удобно использовать такие числа, так как не всегда работа идёт с целыми - температура, различные коэффициенты, тригонометрия и так далее. В C/C++ есть поддержка таких чисел, они называются числами с плавающей точкой (floating point) и хранятся в экспоненциальном виде согласно стандарту IEEE 754. Этот способ позволяет хранить как очень маленькие, так и очень большие числа, но с конечной точностью:
| Вес, бит | Точность, знаков | Диапазон значений |
|---|---|---|
| 32 | ~7 | от ±1.4×10^-45 до ±3.4×10^38 |
| 64 | ~16 | от ±4.9×10^-324 до ±1.8×10^308 |
Можно заметить, насколько огромный диапазон значений даже на 32 битах: от сверхмаленьких чисел (45 нулей после запятой 0.000<...>001) до огромных - 38 нулей в целой части: 1234<...>456.00. А занимает всего 4 байта!
В C/C++ они представлены типами:
float- 32 битdouble- 64 бит (32 бит на слабых МК, например AVR)long double- зависит от платформы
float var = 3.14;
Поддержка вычислений #
Поддержка таких чисел и операций с ними может быть аппаратной на уровне устройства процессора, если у него есть блок FPU (Floating Point Unit) - тогда работа с такими числами выполняется очень быстро и не утяжеляет программу (например ESP32 и многие STM32). Если FPU отсутствует - компилятор автоматически будет использовать встроенную программную реализацию (по сути библиотеку) - она медленная и занимает много памяти, например около 1.5 КБ на МК AVR.
Формат записи #
В C/C++ поддерживается несколько форматов записи чисел с плавающей точкой:
| Тип записи | Пример | Чему равно |
|---|---|---|
| Десятичная дробь | 20.5 |
20.5 |
| Научный | 2.34E5 |
2.34*10^5 (234000) |
| Инженерный | 67e-12 |
67*10^-12 (0.000000000067) |
После десятичной точки может не быть цифры - число 12. корректно и равносильно 12.0
Точность #
Переменная с плавающей точкой хранит не то число, которое в неё записали или которое получилось в результате математической операции, а близкое к нему в пределах точности - у float точность 6-7 старших разрядов:
float v;
v = 123456.654321; // 123456.656250
v = 0.0123456789; // 0.0123456788
v = 0.0000123456789; // 0.0000123456788
v = 123456789; // 123456792.0
Обратите внимание на количество старших разрядов, которые совпадают с записанным числом. То есть "для печати" это уже совсем другое число, но для дальнейших вычислений - очень близкое к оригинальному. Именно поэтому float числа нельзя сравнивать строго (об этом в другом уроке) - их значения могут отличаться от ожидаемых.
Знаков после запятой #
У самого float нет характеристики "количество знаков после запятой" - дробная часть условно бесконечная и можно получить сколько угодно знаков после запятой, но значимыми будут только те, которые вошли в точность. Это означает, что "количество знаков после запятой" появляется только при выводе числа для чтения - в консоль или на дисплей. Например в Arduino печать в монитор порта по умолчанию выводит два знака, но можно вывести и 10, и 50.
Новички часто путаются в этом моменте. Здесь нужно просто запомнить, что "знаков после запятой" - это для человека, а для вычислений и работы с переменными в программе этой характеристики нет - вычисления сами по себе выполняются с максимально возможной точностью
NaN и inf #
Помимо численного значения данные с плавающей точкой могут иметь ещё два специальных - NaN и inf:
- NaN - дословно - "не число" (Not a Number), ошибка данных, которая могла произойти в результате вычисления или присвоена вручную при помощи константы
NAN - inf - бесконечно большое значение (infinity), которое могло получиться в результате вычислений (деление на 0) или присвоена вручную при помощи константы
INFINITY
Текстовое представление этих значений можно получить, выведя переменную в текстовый формат для отладки или при печати в консоль. Чтобы определить в программе, имеет ли переменная одно из этих значений, есть соответствующие функции isnan() и isinf():
v = NAN;
isnan(v); // true
v = INFINITY;
isinf(v); // true
Переменная в значении NAN не сравнивается с другими числами - результат всегда false. Подробнее - в уроке про сравнение
Таблица типов #
Прилагаю таблицу значений для всех типов применительно к Arduino и embedded в целом:
| Тип | Синоним | Вес, бит | От | До |
|---|---|---|---|---|
bool |
- | 8 | 0, false |
1, true |
signed char |
int8_t |
8 | -128 | 127 |
unsigned char |
uint8_t |
8 | 0 | 255 |
int |
- | 16/32* | ||
short |
int16_t |
16 | -32 768 | 32 767 |
unsigned short |
uint16_t |
16 | 0 | 65 535 |
long |
int32_t |
32 | -2 147 483 648 | 2 147 483 647 |
unsigned long |
uint32_t |
32 | 0 | 4 294 967 295 |
long long |
int64_t |
64 | -9 223 372 036 854 775 808 | 9 223 372 036 854 775 807 |
unsigned long long |
uint64_t |
64 | 0 | 18 446 744 073 709 551 615 |
float |
- | 32 | ±1.4×10^-45 | ±3.4×10^38 |
double |
- | 64** | ±4.9×10^-324 | ±1.8×10^308 |
- * - на AVR 16 бит, на ESP 32 бит
- ** - на AVR 32 бит, на ESP 64 бит
sizeof #
Существует специальный оператор sizeof - он позволяет внутри программы узнать размер любой сущности, которая имеет вес, в том числе размер типа данных. Возвращает вес в количестве байт, тип size_t:
sizeof(short); // 2 байта
sizeof(char); // 1 байт
sizeof(foo); // размер переменной foo
Оператор sizeof может применяться к переменным без скобок, например sizeof var
Тип void #
В C/C++ есть специальный тип данных void (пустота) - служебный пустой тип данных. Нельзя создать переменную такого типа, но сам тип используется в некоторых особых случаях, о которых будет рассказано в следующих уроках.
Свой тип #
В C/C++ есть возможность создать тип данных со своим названием на основе существующего типа при помощи оператора typedef (C/C++) или using (C++):
typedef int my_int; // синоним для int
using my_long = long; // синоним для long
my_int v1;
my_long v2;
Endianness* #
Данные хранятся в памяти побайтно: например, если тип данных имеет размер 4 байта - значение будет разбито на 4 байта и записано в память друг за другом. Возьмём число 0x12345678 - имеем в памяти 4 соседних байта со значениями 0x12, 0x34, 0x56 и 0x78. В каком порядке они расположены? Это зависит от платформы, на которой выполняется код. Существует два варианта:
- big-endian - старший байт числа хранится по меньшему адресу в памяти, то есть если представить, что адрес увеличивается слева направо, то наше число будет храниться так же, как записано:
12 34 56 78 - little-endian - младший байт числа хранится по меньшему адресу в памяти, то есть наоборот:
78 56 34 12
Это играет большую роль, когда устройство получает данные от другого устройства с другим порядком байтов - можно записать данные "не той стороной". На практике оно скорее всего не встретится, но о самом понятии знать нужно, оно указывается в документации на конкретную архитектуру процессора. На МК чаще встречается little-endian.
Дополнительно #
Дополнительный контент доступен владельцам набора GyverKIT и по подписке, подробнее читай здесь. Блок содержит:
- Тезисы, Вопросы, Примеры (Arduino)
- 1 блоков кода
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками