Посмотр рубрик

Битовые флаги и упаковка

"Из коробки" 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() {
}

Полезные страницы #

(3 голоса)
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх