View Categories

Битовые операции

Как уже говорилось ранее, минимальная адресуемая ячейка памяти - 1 байт, то есть можно записать и прочитать из памяти данные размером как минимум 1 байт, переменную тоже можно сделать минимум 1 байт размером (исключение - битовые поля в структурах). Байт состоит из 8 бит, есть ли у нас возможность работать с ними напрямую? Есть - в C/C++ есть поддержка битовых операций.

Битовые операции позволяют взаимодействовать непосредственно с битами значения, этот процесс также называется битовой математикой (bit math). С их помощью можно не только получить доступ к конкретному биту, но и выполнять более сложные операции очень быстро - процессор обычно имеет набор аппаратных инструкций с битовыми операциями и они выполняются за один такт.

Битовые операции можно выполнять только с целочисленными типами данных, либо с объектами, классы которых написаны с поддержкой битовых операторов

При помощи битовых операций можно:

  • "Собирать" значения с любым нужным набором битов
  • Паковать и распаковывать данные
  • Настраивать биты в регистрах МК и цифровых микросхем
  • Писать очень хитрые и эффективные алгоритмы для вычислений и обработки данных
  • Понимать чужой код, который использует битовые операции 🙂

Битовые операторы #

И (AND) #

Битовое И (битовое умножение) выполняется между двумя значениями при помощи оператора & и похоже на логическое И, имеет такую же таблицу истинности:

A B A & B
0 0 0
1 0 0
0 1 0
1 1 1

Результат равен единице в том случае, если оба значения являются единицами

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

// 0b11010110 & 0b01101101

0b11010110
// &
0b01101101
// =
0b01000100

Получается результат, у которого единицы есть только там, где были единицы у обоих чисел. Битовое И обычно используется там, где нужно взять конкретные биты из числа - битовая маска (bit mask). В этом случае одно число это просто число, а другое - маска. После операции И мы получим результат, у которого единицы есть только в тех местах, где они есть и в маске. Можно делать это хоть по одному биту, например для того, чтобы вручную прочитать каждый бит из числа:

0b11010110
0b10000000  // маска
// &
0b10000000

0b11010110
0b01000000  // маска
// &
0b01000000

0b11010110
0b00100000  // маска
// &
0b00000000

Можно использовать и составной оператор &=:

uint8_t val = 0b11010110;
val &= 0b00010000;
// val == 0b00010000

Остаток от деления #

При помощи битовой маски можно очень эффективно получать остаток от деления на число, которое является 2^n (2, 4, 8, 16, 32...). Для этого мы по сути делаем маску, которая выберет нужные биты - это будут все младшие биты справа от единицы.

Чтобы получить остаток от деления на 8 (0b1000), достаточно взять маской три младших бита, то есть маска получится 0b111:

// 73 % 8
73 & 0b111; // == 1001001 & 0b111 == 1

Маску младших битов можно получить, если отнять от делителя единицу - 0b1000 - 1 == 0b0111. Таким образом получается формула быстрого остатка от деления: val % (2^n) можно вычислить как val & (2^n - 1), то есть 73 % 8 == 73 & 7.

ИЛИ (OR) #

Битовое ИЛИ (битовое сложение) выполняется между двумя значениями при помощи оператора | и похоже на логическое ИЛИ, имеет такую же таблицу истинности:

A B A или B
0 0 0
1 0 1
0 1 1
1 1 1

Результат равен единице в том случае, если хотя бы одно из значений является единицей

Логика такая же - два числа побитно "складываются": у результата будут единицы в тех местах, где у чисел есть хотя бы одна единица:

// 0b11010110 | 0b01101101

0b11010110
// |
0b01101101
// =
0b11111111

Используется там, где нужно "сложить" биты. Также имеется составной оператор |=:

uint8_t val = 0b11110000;
val |= 0b00001111;
// val == 0b11111111

Исключающее ИЛИ (XOR) #

Битовое исключающее ИЛИ, или "сложение по модулю", выполняется между двумя значениями при помощи оператора ^ или xor:

A B A ^ B
0 0 0
1 0 1
0 1 1
1 1 0

Результат равен единице в том случае, если значения отличаются

Может использоваться для инвертирования конкретных битов - они должны быть единицами в "маске":

// 0b11110000 ^ 0b10000001

0b11110000
// ^
0b10000001
// =
0b01110001

Можно использовать и составной оператор ^=:

uint8_t val = 0b11110000;
val ^= 0b11110001;
// val == 0b00000001

НЕ (NOT) #

Битовая операция НЕ выполняется с одним значением при помощи оператора ~ и просто инвертирует все биты:

// ~0b11110000

0b11110000
// ~
0b00001111

Сдвиг #

Операция битовый сдвиг (bit shift) выполняется с двумя значениями при помощи операторов >> и << и буквально сдвигает биты первого значения в указанном направлении на расстояние в битах, равное второму значению.

Если биты-единицы выходят за границу (за ноль или за максимальный размер типа) - они безвозвратно теряются

0b00111100 << 2;    // == 0b11110000
0b11110000 >> 4;    // == 0b00001111
0b00001111 >> 2;    // == 0b00000011

Можно использовать и составные операторы >>= и <<=:

uint8_t val = 0b00011000;
val >>= 3;
// val == 0b00000011;

Умножение и деление #

Если вспомнить двоичную систему счисления, то можно догадаться, что сдвиг влево - это умножение на 2^n, а сдвиг вправо - деление на 2^n, где n - величина сдвига:

0b00000001 << 3;    // == 0b00001000 == 1 * 2^3 == 1 * 8 == 8
1 << 3;             // == 8

0b00100101 >> 2;    // == 0b00001001 == 37 / 2^2 == 37 / 4 == 9
37 >> 2;            // == 9

Выбор бита #

Сдвиг влево позволяет "создать" значение, в котором установлен один конкретный бит под номером n - 1 << n, счёт начинается с нуля справа налево:

1 << 0;     // == 0b00000001
1 << 5;     // == 0b00100000
1 << 7;     // == 0b10000000

Применение #

Включить бит #

На практике очень часто бывает нужно установить (set) конкретный бит в числе - сделать его единицей. Для этого используется типовая конструкция val |= (1 << n), где n - номер бита.

Как это работает - при помощи (1 << n) получаем значение с одиноким битом под указанным номером, а затем просто "прибавляем" его при помощи битового ИЛИ:

0b00000000 | (1 << 3) == 0b00000000 | (0b1000) == 0b00001000

Можно установить несколько бит сразу, сложив одинокие биты при помощи |:

val |= (1 << 3) | (1 << 7);     // установить 3 и 7 биты

Выключить бит #

Не менее часто бывает нужно сбросить (clear) нужный бит в числе - сделать его нулём. Для этого используется типовая конструкция val &= ~(1 << n), где n - номер бита.

Как это работает - при помощи (1 << n) получаем значение с одиноким битом под указанным номером, затем инвертируем его, чтобы получить маску. И эту маску применяем к числу - маска возьмёт все биты кроме указанного!

0b11111111 & ~(1 << 3) == 0b11111111 & ~(0b00001000) == 0b11111111 & (0b11110111) == 0b11110111

Можно сбросить несколько бит сразу, сложив одинокие биты при помощи |, а затем инвертировав их:

val &= ~( (1 << 3) | (1 << 7) );    // сбросить 3 и 7 биты

Склеить байты #

Ещё одна типовая задача - склеить число из старшего и младшего байтов. Для этого к младшему (low) байту через ИЛИ прибавляется старший (high), сдвинутый на 8 влево:

val = (byteHigh << 8) | byteLow;

Точно так же можно склеить и больше байтов:

val = (byte3 << 16) | (byte2 << 8) | byte0;

Разделить на байты #

Не менее типовая обратная задача - разделить на байты. Всё то же самое в обратном порядке:

byteLow = val & 0xff;       // берём маской младший байт, 0xff == 0b11111111
byteHigh = (val >> 8);      // младшие байты отсекутся при сдвиге

Экономия памяти #

В программировании часто используются "флаги" - логические значения. Тип bool у нас занимает 1 байт, то есть 8 бит. Расточительно! Можно "запаковать" 8 флагов в один байт - читать и писать их как биты, как было показано выше. Это довольно громоздкая запись, в отдельном уроке разберём более красивые реализации.

uint8_t flags = 0;
flags |= (1 << 3);      // установить флаг #3
flags &= ~(1 << 3);     // сбросить флаг #3
flags & (1 << 3);       // прочитать флаг #3

Проверка на -1 #

В программировании очень часто встречаются инструменты, которые возвращают -1 в случае ошибки или "не найдено", т.е. валидным значением считается 0 и положительные числа. Чтобы не сравнивать значение с нулём, например if (res < 0), можно использовать инверсию: в любом типе данных -1 это все биты - единички. Если инвертировать - получится ноль: if (~res) - проверка на -1.

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

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