Битовые операции
Данный урок посвящён битовым операциям (операциям с битами, битовой математике, bitmath). Из него вы узнаете, как оперировать с битами - элементарными ячейками памяти микроконтроллера.
Данная тема является одной из самых сложных для понимания в рамках данного курса уроков, так что давайте разберёмся, зачем вообще нужно уметь работать с битами:
- Гибкая и быстрая работа напрямую с регистрами микроконтроллера.
- Работа напрямую с внешними микросхемами (датчики и прочее), управление которыми состоит из записи и чтения регистров, данные в которых могут быть запакованы в байты самым причудливым образом.
- Более эффективное хранение данных: упаковка нескольких значений в одну переменную и распаковка обратно.
- Создание символов и другой информации для матричных дисплеев.
- Максимально быстрые вычисления.
- Разбор чужого кода.
Данный урок основан на оригинальном уроке по битовым операциям от Arduino, можете почитать его здесь - там всё описано чуть более подробно.
Двоичная система
В цифровом мире, к которому относится также микроконтроллер, информация хранится, преобразуется и передается в цифровом виде, то есть в виде нулей и единиц. Соответственно элементарная ячейка памяти, которая может запомнить 0
или 1
, называется бит (bit).
Минимальная ячейка памяти, которую мы можем изменить - 1 бит, а ячейка памяти, которая которая имеет адрес в памяти и мы можем к ней обратиться - байт, который состоит из 8-ми бит, каждый занимает своё место (примечание: в других архитектурах в байте может быть больше или меньше бит, в данном уроке речь идёт об AVR и 8-ми битном байте). Вспомним двоичную систему счисления из школьного курса информатики:
Двоичная | Десятичная |
0000 | 0 |
0001 | 1 |
0010 | 2 |
0011 | 3 |
0100 | 4 |
0101 | 5 |
0110 | 6 |
0111 | 7 |
1000 | 8 |
1001 | 9 |
… | … |
10000 | 16 |
Заметили последовательность?
Здесь также нужно увидеть важность степени двойки - на ней в битовых операциях завязано абсолютно всё. Давайте посмотрим на первые 8 степеней двойки в разных системах счисления:
2 в степени | DEC | BIN |
0 | 1 | 0b00000001 |
1 | 2 | 0b00000010 |
2 | 4 | 0b00000100 |
3 | 8 | 0b00001000 |
4 | 16 | 0b00010000 |
5 | 32 | 0b00100000 |
6 | 64 | 0b01000000 |
7 | 128 | 0b10000000 |
Таким образом, степень двойки явно "указывает" на номер бита в байте, считая справа налево (примечание: в других архитектурах может быть иначе). Напомню, что абсолютно неважно, в какой системе исчисления вы работаете - микроконтроллеру всё равно и он во всём видит единицы и нули. Если мы “включим” все биты в байте, то получится число 0b11111111
в двоичной системе или 255
в десятичной.
Если "сложить" полный байт в десятичном представлении каждого бита: 128+64+32+16+8+4+2+1 - получится 255. Нетрудно догадаться, что число 0b11000000
равно 128+64, то есть 192. Именно таким образом и получается весь диапазон от 0 до 255, который умещается в один байт. Если взять два байта - будет всё то же самое, просто ячеек будет 16, то же самое для 4 байт - 32 ячейки с единицами и нулями, каждая имеет свой номер согласно степени двойки.
Другие системы счисления
Данные в памяти микроконтроллера хранятся в двоичном представлении, но помимо него существуют и другие системы счисления, в которых мы можем работать. Переводить числа из одной системы счисления в другую не нужно: программе абсолютно всё равно, в каком формате вы скармливаете значение переменной, они автоматически будут интерпретированы в двоичный вид. Разные системы счисления введены в первую очередь для удобства программиста.
Теперь по сути: Arduino поддерживает четыре классических системы счисления: двоичную, восьмеричную, десятичную и шестнадцатеричную.
- Двоичная (Binary) имеет префикс 0b (ноль бэ) или B, то есть двоичное число 101 запишется как
0b101
илиB101
. - С десятичной (DEC) всё просто, пишем числа так, как они выглядят.
10
это десять,25
это двадцать пять и так далее. - Восьмеричная (Octal) может содержать числа от 0 до 7 и имеет префикс 0 (ноль), например
012
. - 16-ричная (hexademical) система имеет 16 значений на один разряд, первые 10 как у десятичной, остальные - первые буквы латинского алфавита: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f. При записи имеет префикс 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
можно сразу кучу всего настроить, к этому мы вернёмся в уроке работа с регистрами из раздела продвинутых уроков. В документации по этому поводу пишут в стиле "первый бит отвечает за это, второй за то" и так далее. Перейдём к изменению состояний битов.
Макросы для манипуляций с битами
В библиотеке Arduino.h есть несколько удобных макросов, которые позволяют включать и выключать биты в байте:
Макрос | Действие |
bitRead(value, bit) |
Читает бит под номером bit в числе value |
bitSet(value, bit) |
Включает (ставит 1) бит под номером bit в числе value |
bitClear(value, bit) |
Выключает (ставит 0) бит под номером bit в числе value |
bitWrite(value, bit, bitvalue) |
Ставит бит под номером bit в состояние bitvalue (0 или 1) в числе value |
bit(bit) |
Возвращает 2 в степени bit |
Другие встроенные макросы | |
_BV(bit) |
Возвращает 2 в степени bit |
bit_is_set(value, bit) |
Проверка на включенность (1) бита bit в числе value |
bit_is_clear(value, bit) |
Проверка на выключенность (0) бита bit в числе value |
Простой пример:
// тут myByte == 0 byte myByte = 0; // тут myByte станет 128 или 0b10000000 bitSet(myByte, 7); // тут myByte станет 192 или 0b11000000 bitWrite(myByte, 6, 1);
Этого уже достаточно для полноценной работы с регистрами. Так как это именно макросы, работают они максимально быстро и ничуть не хуже написанных вручную элементарных битовых операций. Чуть ниже мы разберём содержимое этих макросов и увидим, как они работают, а пока познакомимся с элементарными логическими операциями.
Битовые операции
Битовое И
И (AND), оно же "логическое умножение", выполняется оператором &
или and
и возвращает следующее:
0 & 0 == 0 0 & 1 == 0 1 & 0 == 0 1 & 1 == 1
Основное применение операции И - битовая маска. Позволяет "взять" из байта только указанные биты:
myByte = 0b11001100; myBits = myByte & 0b10000111; // myBits теперь равен 0b10000100
То есть при помощи &
мы взяли из байта 0b11001100 только биты 10000111, а именно - 0b11001100, и получили 0b10000100 Также можно использовать составной оператор &=
myByte = 0b11001100; myByte &= 0b10000000; // берём старший бит // myByte теперь 10000000
Битовое ИЛИ
ИЛИ (OR), оно же "логическое сложение", выполняется оператором |
или or
и возвращает следующее:
0 | 0 == 0 0 | 1 == 1 1 | 0 == 1 1 | 1 == 1
Основное применение операции ИЛИ - установка бита в байте:
myByte = 0b11001100; myBits = myByte | 0b00000001; // ставим бит №0 // myBits теперь равен 0b11001101 myBits = myBits | bit(1); // ставим бит №1 // myBits теперь равен 0b11001111
Также можно использовать составной оператор |=
myByte = 0b11001100; myByte |= 16; // 16 - 2 в 4, включаем бит №4 // myByte теперь 0b11011100
Вы уже поняли, что указывать на нужные биты можно любым удобным способом: в бинарном виде (0b00000001 - нулевой бит), в десятичном виде (16 - четвёртый бит) или при помощи макросов bit()
или _BV()
(bit(7)
даёт 128 или 0b10000000, _BV(7)
делает то же самое)
Битовое НЕ
Битовая операция НЕ (NOT) выполняется оператором ~
и просто инвертирует бит:
~0 == 1 ~1 == 0
Также она может инвертировать байт:
myByte = 0b11001100; myByte = ~myByte; // инвертируем // myByte теперь 00110011
Битовое исключающее ИЛИ
Битовая операция исключающее ИЛИ (XOR) выполняется оператором ^
или xor
и делает следующее:
0 ^ 0 == 0 0 ^ 1 == 1 1 ^ 0 == 1 1 ^ 1 == 0
Данная операция обычно используется для инвертирования состояния отдельного бита:
myByte = 0b11001100; myByte ^= 0b10000000; // инвертируем 7-ой бит // myByte теперь 01001100
То есть мы взяли бит №7 в байте 0b11001100 и перевернули его в 0, получилось 0b01001100, остальные биты не трогали.
Битовый сдвиг
Битовый сдвиг - очень мощный оператор, позволяет буквально "двигать" биты в байте вправо и влево при помощи операторов >>
и <<
, и соответственно составных >>=
и <<=
. Если биты выходят за границы блока (8 бит, 16 бит или 32 бита) - они теряются.
myByte = 0b00011100; myByte = myByte << 3; // двигаем на 3 влево // myByte теперь 0b11100000 myByte >>= 5; // myByte теперь 0b00000111 myByte >>= 2; // myByte теперь 0b00000001 // остальные биты потеряны!
Битовый сдвиг делает не что иное, как умножает или делит байт на 2 в степени. Да, это операция деления, выполняющаяся за один такт процессора! К этому мы ещё вернёмся ниже. Посмотрите на работу оператора сдвига и сравните её с макросами bit()
и _BV()
:
1 << 0 == 1 1 << 1 == 2 1 << 2 == 4 1 << 3 == 8 ... 1 << 8 == 256 1 << 9 == 512 1 << 10 == 1024
Возведение двойки в степень! Важный момент: при сдвиге дальше, чем на 15, нужно преобразовывать тип данных, например в ul: 1 << 17
даст результат 0
, потому что сдвиг выполняется в ячейке int
(как при умножении, помните?). Но если мы напишем 1ul << 17
- результат будет верный.
Применение на практике
Включаем-выключаем
Вспомним пример из пункта про битовое ИЛИ, про установку нужного бита. Вот эти варианты кода делают одно и то же:
myByte = 0b11000011; // ставим бит №3 разными способами // по сути - одно и то же myByte |= (1 << 3); myByte |= bit(3); myByte |= _BV(3); bitSet(myByte, 3); // myByte равен 0b11001011
Как насчёт установки нескольких бит сразу?
myByte = 0b11000011; // ставим бит №3 и 4 разными способами // по сути - одно и то же myByte |= (1 << 3) | (1 << 4); myByte |= bit(3) | bit(4); myByte |= _BV(3) | _BV(4); // myByte равен 0b11011011
Или прицельного выключения бит? Тут чуть по-другому, используя &=
и ~
myByte = 0b11000011; // выключаем бит №1 разными способами // по сути - одно и то же myByte &= ~(1 << 1); myByte &= ~_BV(1); bitClear(myByte, 1); // myByte равен 0b11000001
Выключить несколько бит сразу? Пожалуйста!
myByte = 0b11000011; // выключаем биты №0 и 1 разными способами myByte &= ~( (1 << 0) | (1 << 1) ); myByte &= ~( _BV(0) | _BV(1) ); // myByte равен 0b11000000
Именно такие конструкции встречаются в коде высокого уровня и библиотеках, именно так производится работа с регистрами микроконтроллера. Вернёмся к устройству Ардуиновских макросов:
#define bitRead(value, bit) (((value) >> (bit)) & 0x01) #define bitSet(value, bit) ((value) |= (1UL << (bit))) #define bitClear(value, bit) ((value) &= ~(1UL << (bit))) #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) #define bit(b) (1UL << (b))
Я думаю, комментарии излишни: макросы состоят из тех же элементарных битовых операций и сдвигов!
Склейка и разбивание данных
Самая часто встречающаяся конструкция - склеивание двух байтов в один 2-х байтный тип данных. Для этого к младшему байту через ИЛИ прибавляется старший, сдвинутый на 8 влево:
int val = byte1 | (byte2 << 8);
Как это работает "на пальцах": пусть byte1
равен 0b11111111
, a byte2
- 0b00111100
. Первым действием при помощи сдвига превращаем второй байт в 0b00111100 00000000
, а вторым - соединяем его с первым байтом. Результат 0b0011110011111111
.
Точно так же можно разбить переменную на отдельные байты. Младший байт получится автоматически (старший байт просто отсечётся при записи):
byte1 = val;
А вот старший нужно "задвинуть" на своё место:
byte2 = val >> 8;
Таким же образом можно склеить три байта, например три раздельных канала цвета (r, g, b) в один 24-бит цвет:
uint32_t color = ((uint32_t)r << 16) | (g << 8) | b;
Обратите внимание, красный канал r
принудительно преобразуем к uint32_t
, так как по умолчанию операция сдвига производится в 2-х байтной ячейке памяти, и без преобразования к 4 байтам данные будут потеряны!
Быстрые вычисления
Как я уже говорил, битовые операции - самые быстрые. Если требуется максимальная скорость вычислений - их можно оптимизировать и подогнать под "степени двойки", но иногда компилятор делает это сам, подробнее смотри в уроке про оптимизацию кода. Рассмотрим базовые операции:
- Деление на
2^n
- сдвиг вправо наn
. Например,val / 8
можно записать какval >> 3
. Компилятор не оптимизирует деление самостоятельно, что позволяет ускорить данную операцию приблизительно в 15 раз при ручной оптимизации. - Умножение на
2^n
- сдвиг влево наn
. Например,val * 8
можно записать какval << 3
. Компилятор оптимизирует умножение самостоятельно, поэтому в ручной оптимизации нет смысла. Но можно встретить в чужих исходниках. - Остаток от деления на
2^n
- битовая маска наn
младших битов. Операции остатка от деленияval % (2^n)
можно вычислить через битовую маску:val & (2^n - 1)
. Например,val % 8
можно записать какval & 7
, аval % 32
можно записать какval & 31
. Компилятор оптимизирует такие операции самостоятельно, поэтому в ручной оптимизации нет смысла. Но можно встретить в чужих исходниках.
Экономия памяти
При помощи битовых операций можно экономить немного памяти, пакуя данные в блоки. Например, переменная типа boolean
занимает в памяти 8 бит, хотя принимает только 0 и 1. В один байт можно запаковать 8 логических переменных, например вот так:
Хороший трюк, может пригодиться! Я сделал удобную библиотеку для хранения битовых флагов, документация и примеры есть здесь.
Пример сжатия 1
Таким же способом можно паковать любые другие данные других размеров для удобного хранения или сжатия. Как пример - моя библиотека microLED, в которой используется следующий алгоритм: изначально необходимо хранить в памяти три цвета для каждого светодиода, каждый цвет имеет глубину 8 бит, т.е. в общей сложности тратится 3 байта на один светодиод RRRRRRRR GGGGGGGG BBBBBBBB. Для экономии места и удобства хранения можно сжать эти три байта в два (тип данных int
), потеряв несколько оттенков результирующего цвета. Например вот так: RRRRRGGG GGGBBBBB. Сожмём и упакуем: есть три переменные каждого цвета, r
, g
, b
:
int rgb = ((r & 0b11111000) << 8) | ((g & 0b11111100) << 3) | ((b & 0b11111000) >> 3);
Таким образом мы отбросили у красного и синего младшие (правые) биты, в этом и заключается сжатие. Чем больше битов отброшено - тем менее точно получится "разжать" число. Например сжимали число 0b10101010 (170 в десятичной) на три бита, при сжатии получили 0b10101000, т.е. потеряли три младших бита, и в десятичной уже получится 168. Для упаковки используется битовый сдвиг и маска, таким образом мы берём первые пять битов красного, шесть зелёного и пять синего, и задвигаем на нужные места в результирующей 16-битной переменной. Всё, цвет сжат и его можно хранить. Для распаковки используется обратная операция: выбираем при помощи маски нужные биты и сдвигаем их обратно в байт:
byte r = (data & 0b1111100000000000) >> 8; byte g = (data & 0b0000011111100000) >> 3; byte b = (data & 0b0000000000011111) << 3;
Таким образом можно сжимать, разжимать и просто хранить маленькие данные в стандартных типах данных.
Пример сжатия 2
Давайте ещё пример: нужно максимально компактно хранить несколько чисел в диапазоне от 0 до 3, то есть в бинарном представлении это 0b00
, 0b01
, 0b10
и 0b11
. Видим, что в один байт можно запихнуть 4 таких числа (максимальное занимает два бита). Запихиваем:
// числа для примера byte val_0 = 2; // 0b10 byte val_1 = 0; // 0b00 byte val_2 = 1; // 0b01 byte val_3 = 3; // 0b11 byte val_pack = ((val_0 & 0b11) << 6) | ((val_1 & 0b11) << 4) | ((val_2 & 0b11) << 2) | (val_3 & 0b11); // получили 0b10000111
Как и в примере со светодиодами, мы просто брали нужные биты ( в этом случае младшие два, 0b11
) и сдвигали их на нужное расстояние. Для распаковки делаем в обратном порядке:
byte unpack_1 = (val_pack & 0b11000000) >> 6; byte unpack_2 = (val_pack & 0b00110000) >> 4; byte unpack_3 = (val_pack & 0b00001100) >> 2; byte unpack_4 = (val_pack & 0b00000011) >> 0;
И получим обратно наши байты. Также маску можно заменить на более удобную для работы запись, задвинув 0b11
на нужное расстояние:
byte unpack_1 = (val_pack & 0b11 << 6) >> 6; byte unpack_2 = (val_pack & 0b11 << 4) >> 4; byte unpack_3 = (val_pack & 0b11 << 2) >> 2; byte unpack_4 = (val_pack & 0b11 << 0) >> 0;
Теперь, проследив закономерность, можно сделать для себя функцию или макрос чтения пакета:
#define UNPACK(x, y) ( ((x) & 0b11 << ((y) * 2)) >> ((y) * 2) )
Где x это пакет, а y - порядковый номер запакованного значения. Выведем посмотрим:
Serial.println(UNPACK(val_pack, 3)); Serial.println(UNPACK(val_pack, 2)); Serial.println(UNPACK(val_pack, 1)); Serial.println(UNPACK(val_pack, 0));
"Трюки" с битами
На битовых операциях можно сделать очень много всего интересного, и работать оно будет очень быстро и занимать мало места. Огромный список битовых трюков и хаков можно посмотреть в этой статье, их там очень много и все с примерами. Есть ещё один небольшой сборник самых простых и полезных хаков вот здесь (английский). Его я перевёл, смотрите ниже под спойлером. Другой вариант перевода (могут быть не все трюки) можно посмотреть здесь.
Приоритет операций
Чтобы не плодить скобки, нужно знать приоритет операций. В C++ он такой:
Полезные страницы
- Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])