"Из коробки" C/C++ имеет фиксированные типы данных для хранения чисел - 1, 2, 4 и 8 байт, т.е. даже для 1-бит флага нам придётся использовать 1 байт переменную (8 бит). В embedded и в целом при работе с протоколами связи часто приходится использовать нестандартные типы данных ради экономии памяти. Рассмотрим несколько вариантов реализации.
Битовые поля #
В структурах и классах можно создавать переменные с произвольным размером в битах - битовые поля (bit fields). К имени переменной просто добавляется её размер в битах через двоеточие:
struct Flags {
uint8_t f0 : 1; // 1 бит
uint8_t f1 : 1; // 1 бит
uint8_t foo : 3; // 3 бита
uint8_t bar : 2; // 2 бита
uint8_t f3 : 1; // 1 бит
};
// весит 1 байт, но хранит в себе целый "зоопарк" переменных разного размера
Flags f;
// запись и чтение как обычно
f.f0 = 1;
f.f1 = 0;
f.foo = 4;
int x = f.bar;
Структура с битовыми полями позволяет разбирать сложный протокол связи, превращая некий поток данных в именованные переменные. Большой пример по этой теме есть в следующем уроке про объединения
Структуры с битовыми полями очень удобны, но не являются переносимым решением - расположение данных в памяти зависит от компилятора и архитектуры процессора. Безопасно можно использовать только для локального хранения данных или обмена между устройствами с одной архитектурой
Битовые поля имеют ряд правил и ограничений.
Особенности и ограничения #
- Только стандартные целочисленные типы данных могут быть битовыми полями:
struct Fields {
uint8_t b1 : 5; // 5 бит (значения 0.. 31)
uint8_t b2 : 3; // 3 бита (значения 0.. 7)
};
- Поля с шириной 1 бит должны быть объявлены как
unsigned. Если объявить какsigned, то вместо1в нём сохранится-1:
struct Fields {
uint8_t u1 : 1; // может хранить 0 и 1, как ожидается
int8_t i1 : 1; // будет иметь значение -1 при записи 1
int8_t i2 : 2; // может корректно хранить 0, 1 и -1
};
- Битовые поля нельзя инициализировать сразу при объявлении, это нужно делать снаружи или в конструкторе:
struct Fields {
uint8_t b1 : 5 = 20; // ошибка компиляции
uint8_t b2 : 3;
// в конструкторе
Fields() {
b1 = 20;
}
};
// снаружи
Fields f;
f.b1 = 20;
- Порядок расположения в памяти зависит от компилятора и архитектуры процессора, чаще всего - от младшего к старшему:
struct Fields {
uint8_t b1 : 5; // биты 0.. 4
uint8_t b2 : 3; // биты 5.. 7
};
- Указанный тип данных должен идти по порядку для всех полей, при изменении типа будет переход к следующей границе выравнивания:
struct Fields {
uint16_t b1 : 10; // 10 бит
uint8_t b2 : 6; // 6 бит
// оставшиеся свободные от uint16_t b1 6 бит "пропадут" - мы начали новый тип поля!
};
// нужно было так
struct Fields {
uint16_t b1 : 10; // 10 бит
uint16_t b2 : 6; // 6 бит
};
Т.е. если важна экономия, то нужно стараться "заполнить" стандартный тип, чтобы не оставалось "дырок"
- Безымянные поля могут использоваться как "заполнители", чтобы подвинуть следующее поле на несколько бит:
struct Fields {
uint16_t b1 : 10; // биты 0.. 9
uint16_t : 4; // пропуск 4 битов
uint16_t b2 : 2; // биты 14.. 15
};
- Поле нулевого размера заставляет перейти к следующей границе выравнивания:
struct Fields {
uint16_t b1 : 10; // 10 бит
uint16_t : 0; // завершить текущее выравнивание
uint16_t b2 : 10; // 10 бит
};
- Блоки битовых полей выравниваются по обычным правилам для текущей архитектуры процессора:
struct Fields {
uint8_t f0 : 4;
uint8_t f1 : 4;
uint32_t f3 : 20;
uint32_t f4 : 12;
};
sizeof(Fields); // == 8 на 32-бит платформе
Для упаковки "неудобных" данных можно паковать структуру через __attribute__((packed)), но часто можно обойтись без этого, просто более удачно выбрав типы и поля:
struct __attribute__((packed)) Fields {
uint8_t f0 : 4;
uint8_t f1 : 4;
uint32_t f3 : 20;
uint32_t f4 : 12;
};
sizeof(Fields); // == 5 на 32-бит платформе
- Чтение и запись в переменную с битовым полем осуществляется с использованием некоторого дополнительного кода, который генерируется компилятором. Эти операции могут выполняться чуть медленнее, чем с обычной переменной
- У переменной с битовом полем нельзя взять адрес или создать на неё ссылку
Упаковка флагов #
Можно вручную "запаковать" однобитные флаги в целочисленную переменную (8, 16, 32 бит):
uint8_t flags = 0;
flags |= (1 << 3); // установить флаг #3
flags &= ~(1 << 3); // сбросить флаг #3
flags & (1 << 3); // прочитать флаг #3
Для удобства можно задефайнить флаги как:
#define FLAG_0 (1 << 0)
#define FLAG_1 (1 << 1)
#define FLAG_2 (1 << 2)
flags |= FLAG_2;
flags &= ~FLAG_2;
Для этих операций тоже можно сделать макросы:
#define BIT(n) (1ul << (n))
#define SET_FLAG(x, n) ((x) |= BIT(n))
#define CLEAR_FLAG(x, n) ((x) &= ~BIT(n))
#define TOGGLE_FLAG(x, n) ((x) ^= BIT(n))
#define READ_FLAG(x, n) (((x) & BIT(n)) != 0)
uint8_t flags = 0;
SET_FLAG(flags, 3); // установить флаг #3
CLEAR_FLAG(flags, 3); // сбросить флаг #3
TOGGLE_FLAG(flags, 3); // инвертировать флаг #3
WRITE_FLAG(flags, 3, true); // установить #3
WRITE_FLAG(flags, 3, false); // сбросить #3
Serial.println(READ_FLAG(flags, 3)); // чтение
Упаковка данных #
При помощи битовых операций можно запаковать данные произвольного размера в произвольное место внутри другой переменной, т.е. организовать битовые поля вручную. В следующих примерах приводится аналог битовых полей вот такой структуры:
struct Foo {
uint16_t v1 : 10;
uint16_t v2 : 1;
uint16_t v3 : 5;
};
Foo f;
f.v1 = 123;
f.v2 = 1;
f.v3 = 12;
Serial.println(f.v1); // 123
Serial.println(f.v2); // 1
Serial.println(f.v3); // 12
Вручную #
uint16_t pack = 0;
pack |= 123; // bits 0.. 9
pack |= 1 << 10; // bit 10
pack |= 12 << 11; // bits 11.. 15
Serial.println(pack & 0b1111111111); // 123
Serial.println((pack >> 10) & 0b1); // 1
Serial.println((pack >> 11) & 0b11111); // 12
По порядку #
Можно сделать макросы для записи и чтения произвольного блока битов:
#define BIT_MASK(len) ((1ul << (len)) - 1ul)
#define GET_BITS(x, from, len) (((x) >> (from)) & BIT_MASK(len))
#define SET_BITS(x, bits, from, len) ((x) = ((x) & ~(BIT_MASK(len) << (from))) | ((((uint32_t)(bits)) & BIT_MASK(len)) << (from)))
uint16_t pack = 0;
SET_BITS(pack, 123, 0, 10); // bits 0.. 9
SET_BITS(pack, 1, 10, 1); // bit 10
SET_BITS(pack, 12, 11, 5); // bits 11.. 15
Serial.println(GET_BITS(pack, 0, 10)); // 123
Serial.println(GET_BITS(pack, 10, 1)); // 1
Serial.println(GET_BITS(pack, 11, 5)); // 12
По маске #
Но более удобно, наглядно и эффективно будет паковать данные по маске, предлагаю вот такой набор функций:
inline constexpr uint32_t bitfGet(uint32_t value, uint32_t mask) {
return (value & mask) >> __builtin_ctzl(mask);
}
inline constexpr uint32_t bitfMake(uint32_t field, uint32_t mask) {
return (field << __builtin_ctzl(mask)) & mask;
}
inline constexpr uint32_t bitfSet(uint32_t value, uint32_t field, uint32_t mask) {
return (value & ~mask) | bitfMake(field, mask);
}
uint16_t pack = 0;
pack |= bitfMake(123, 0b0000001111111111);
pack |= bitfMake(1, 0b0000010000000000);
pack |= bitfMake(12, 0b1111100000000000);
Serial.println(bitfGet(pack, 0b0000001111111111)); // 123
Serial.println(bitfGet(pack, 0b0000010000000000)); // 1
Serial.println(bitfGet(pack, 0b1111100000000000)); // 12
Библиотеки #
У меня есть библиотека BitPack, она позволяет паковать флаги в байтовый массив. Но самый быстрый и оптимальный вариант - BitFlags (ссылка на исходник) - идёт вместе с BitPack. Очень компактная реализация, позволяет читать, писать и проверять по несколько флагов за раз - код сильно оптимизируется компилятором и сворачивается до пары инструкций. Пример:
#include <BitFlags.h>
#define MY_FLAG_0 bit(0)
#define KEK_FLAG bit(1)
#define SOME_F bit(2)
void setup() {
Serial.begin(115200);
BitFlags8 flags;
flags.set(KEK_FLAG | SOME_F); // установить два флага
Serial.println(flags.read(KEK_FLAG | SOME_F)); // стоит один из флагов
Serial.println(flags.isSet(KEK_FLAG | SOME_F)); // стоят все флаги
// операция compare берёт маску по первому аргументу и сравнивает со вторым
// фактически смысл такой: определение ситуации, когда из указанных флагов подняты только определённые
// здесь - из флагов KEK_FLAG и SOME_F поднят только SOME_F (KEK_FLAG опущен)
Serial.println(flags.compare(KEK_FLAG | SOME_F, SOME_F));
}
void loop() {
}
#include <BitFlags.h>
enum class Flags {
f1 = bit(0),
f2 = bit(1),
f3 = bit(2),
};
void setup() {
Serial.begin(115200);
BitFlags8 f;
f.set(Flags::f1);
f.set(Flags::f2);
Serial.println(f.read(Flags::f1));
Serial.println(f.read(Flags::f2));
Serial.println(f.read(Flags::f3));
}
void loop() {
}
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками