Bit Math на Arduino: побитовые операции для начинающих и продвинутых
Разбираем побитовые операции в Arduino: AND, OR, XOR, NOT, сдвиги — с практическими примерами, работой с портами и экономией памяти.
Статья обновлена 28.09.2022 автором Hannes Siebeneicher.
Когда программируешь для Arduino (да и для любого другого компьютера), рано или поздно приходится работать с отдельными битами. Это кажется страшным только поначалу — на деле это мощный инструмент, который открывает возможности, недоступные через стандартные функции.
Вот несколько ситуаций, где побитовые операции реально помогают:
- Экономия памяти: упаковать до 8 значений
true/falseв один байт. - Управление отдельными битами в регистрах управления или портах микроконтроллера.
- Быстрое умножение и деление на степени двойки через битовые сдвиги.
В этой статье мы разберём базовые побитовые операторы языка C++, а затем покажем, как их комбинировать для решения реальных задач. Материал основан на туториале по bit math от CosineKitty.
Двоичная система счисления
Чтобы объяснять побитовые операторы наглядно, в этом уроке большинство чисел будем записывать в двоичном виде — _base two_. В этой системе каждый разряд числа — это либо 0, либо 1. Именно так хранятся данные внутри любого современного компьютера. Каждый такой разряд называется _bit_ (сокращение от _binary digit_).
В привычной десятичной системе (_base ten_) число 572 означает 5·10² + 7·10¹ + 2·10⁰. Аналогично в двоичной системе число 11010 означает 1·2⁴ + 1·2³ + 0·2² + 1·2¹ + 0·2⁰ = 16 + 8 + 2 = 26.
Понимание двоичной системы — обязательное условие для освоения этого урока. Если нужно освежить знания, хорошая отправная точка — Wikipedia article on the binary system.
Arduino позволяет записывать двоичные числа прямо в коде, добавляя перед ними префикс 0b, например 0b11 == 3. Для совместимости со старыми версиями также определены константы B0–B11111111, которые можно использовать аналогично.
Побитовое AND
Побитовый оператор AND в C++ обозначается одиночным амперсандом & и ставится между двумя целочисленными выражениями. Он работает с каждой парой битов независимо: результат равен 1 только если оба входных бита равны 1, в остальных случаях — 0.
0 & 0 == 0
0 & 1 == 0
1 & 0 == 0
1 & 1 == 1
В Arduino тип int занимает 16 бит, поэтому операция & между двумя выражениями типа int выполняет 16 одновременных AND-операций. Например:
int a = 92; // в двоичном виде: 0000000001011100
int b = 101; // в двоичном виде: 0000000001100101
int c = a & b; // результат: 0000000001000100, или 68 в десятичном
Здесь каждый из 16 битов переменных a и b обрабатывается побитово, результат записывается в c — получается значение 01000100 в двоичном виде, то есть 68 в десятичном.
Одно из самых частых применений побитового AND — выделение конкретного бита из числа, так называемое _masking_. Например, чтобы извлечь младший бит переменной x и сохранить его в y:
int x = 5; // двоичный: 101
int y = x & 1; // теперь y == 1
x = 4; // двоичный: 100
y = x & 1; // теперь y == 0
Практическое замечание
Маскирование особенно удобно при чтении состояния отдельных флагов в регистрах микроконтроллера. Вместо того чтобы читать весь регистр, вы выделяете нужный бит и работаете только с ним.
Побитовое OR
Побитовый оператор OR обозначается вертикальной чертой |. Как и &, оператор | работает с каждой парой битов независимо, но по другому правилу: результат равен 1, если хотя бы один из входных битов равен 1.
0 | 0 == 0
0 | 1 == 1
1 | 0 == 1
1 | 1 == 1
Пример использования в коде:
int a = 92; // в двоичном виде: 0000000001011100
int b = 101; // в двоичном виде: 0000000001100101
int c = a | b; // результат: 0000000001111101, или 125 в десятичном
Побитовое OR часто применяют, чтобы гарантированно установить определённый бит в 1. Например, чтобы скопировать биты из a в b, при этом принудительно установив младший бит в 1:
b = a | 1;
Побитовое XOR
В C++ есть ещё один оператор — _bitwise exclusive OR_, также известный как побитовый XOR (произносится «экс-ор»). Обозначается символом «крышечка» ^. Он похож на побитовый OR |, но с одним отличием: результат равен 1 только если входные биты различны. Если оба равны 0 или оба равны 1 — результат 0.
0 ^ 0 == 0
0 ^ 1 == 1
1 ^ 0 == 1
1 ^ 1 == 0
Другими словами: XOR возвращает 1 там, где биты разные, и 0 там, где одинаковые.
Пример:
int x = 12; // двоичный: 1100
int y = 10; // двоичный: 1010
int z = x ^ y; // двоичный: 0110, или 6 в десятичном
Оператор ^ часто используют для переключения (toggle) отдельных битов — то есть для смены 0 на 1 и 1 на 0, не трогая остальные биты:
y = x ^ 1; // инвертировать младший бит x и сохранить результат в y
Где пригодится XOR
Toggle через XOR — классический приём при работе со светодиодами или флагами состояния: не нужно знать текущее значение бита, просто применяете XOR с нужной маской — и бит переключается.
Побитовое NOT
Побитовый NOT обозначается тильдой ~. В отличие от & и |, он применяется к одному операнду справа и инвертирует все его биты: 0 становится 1, 1 становится 0.
int a = 103; // двоичный: 0000000001100111
int b = ~a; // двоичный: 1111111110011000 = -104
Возможно, вас удивит отрицательный результат -104. Это связано с тем, что старший бит переменной типа int является _sign bit_: если он равен 1, число считается отрицательным. Такое представление называется _two's complement_ (дополнительный код). Подробнее об этом — в статье Wikipedia article on two's complement.
Интересный факт: для любого целого x выражение ~x эквивалентно -x-1.
Знак числа может приводить к неожиданным результатам — об этом поговорим чуть ниже.
Операторы битового сдвига
В C++ есть два _bit shift_ оператора: _left shift_ — оператор << и _right shift_ — оператор >>. Они сдвигают биты левого операнда влево или вправо на количество позиций, указанное правым операндом.
int a = 5; // двоичный: 0000000000000101
int b = a << 3; // двоичный: 0000000000101000, или 40 в десятичном
int c = b >> 3; // двоичный: 0000000000000101, то есть снова 5, как и было
При сдвиге x влево на y позиций (x << y) крайние левые y битов просто теряются:
int a = 5; // двоичный: 0000000000000101
int b = a << 14; // двоичный: 0100000000000000 — первая 1 из 101 была отброшена
Если ни один значимый бит не «выпадает», левый сдвиг эквивалентен умножению на 2 в степени правого операнда. Это удобно для генерации степеней двойки:
1 << 0 == 1
1 << 1 == 2
1 << 2 == 4
1 << 3 == 8
...
1 << 8 == 256
1 << 9 == 512
1 << 10 == 1024
...
При сдвиге x вправо на y позиций (x >> y) поведение зависит от типа данных. Если x имеет тип int (знаковый), старший бит (знаковый) копируется в освобождающиеся позиции — это называется _sign extension_:
int x = -16; // двоичный: 1111111111110000
int y = x >> 3; // двоичный: 1111111111111110
Чаще всего такое поведение нежелательно. Чтобы слева всегда вставлялись нули, используйте приведение к типу unsigned int:
int x = -16; // двоичный: 1111111111110000
int y = unsigned(x) >> 3; // двоичный: 0001111111111110
Если аккуратно избегать знакового расширения, правый сдвиг >> можно использовать как деление на степени двойки:
int x = 1000;
int y = x >> 3; // целочисленное деление 1000 на 8, результат y = 125
Операторы присваивания
В программировании часто нужно изменить значение переменной x и сохранить результат обратно в неё же. Например, увеличить x на 7:
x = x + 7; // увеличить x на 7
Поскольку такая конструкция встречается постоянно, C++ предлагает краткую форму записи — специальные _assignment operators_:
x += 7; // увеличить x на 7
Аналогичные операторы существуют для побитового AND, OR, левого и правого сдвигов:
int x = 1; // двоичный: 0000000000000001
x <<= 3; // двоичный: 0000000000001000
x |= 3; // двоичный: 0000000000001011 — потому что 3 в двоичном это 11
x &= 1; // двоичный: 0000000000000001
x ^= 4; // двоичный: 0000000000000101 — инверсия по маске 100
x ^= 4; // двоичный: 0000000000000001 — снова инверсия по маске 100
Для побитового NOT ~ краткого оператора нет. Чтобы инвертировать все биты переменной x, нужно писать явно:
x = ~x; // инвертировать все биты x и сохранить обратно в x
Важно: побитовые операторы vs. логические операторы
Очень легко перепутать побитовые и логические операторы C++. Например, побитовый AND & — это не то же самое, что логический AND &&. Вот почему:
- Разная логика вычисления. Побитовый
&обрабатывает каждую пару битов независимо. Логический&&сначала приводит оба операнда к булевому значению (true==1 илиfalse==0), а затем возвращает одно значениеtrueилиfalse. Например,4 & 2 == 0: 4 — это 100 в двоичном, 2 — это 010, общих единичных битов нет. Но4 && 2 == true, аtrueчислово равно1: оба числа ненулевые, значит оба считаются булевымtrue. - Разное поведение при вычислении операндов. Побитовые операторы всегда вычисляют оба операнда. Логические операторы используют так называемое _short-cut_ вычисление: если результат уже определён по левому операнду, правый не вычисляется вовсе. Это важно, если операнды имеют побочные эффекты — например, выводят что-то или изменяют память.
Вот наглядный пример:
int fred (int x)
{
Serial.print ("fred ");
Serial.println (x, DEC);
return x;
}
void setup()
{
Serial.begin (9600);
}
void loop()
{
delay(1000); // пауза 1 секунда, чтобы не переполнять вывод по Serial!
int x = fred(0) & fred(1);
}
Если загрузить эту программу и открыть Serial Monitor, каждую секунду будут появляться строки:
fred 0
fred 1
Это потому, что вызываются и fred(0), и fred(1), оба возвращают значения, которые затем побитово ANDed — результат 0 записывается в x.
Теперь замените строку
int x = fred(0) & fred(1);
на версию с логическим && вместо побитового &:
int x = fred(0) && fred(1);
После загрузки вы увидите лишь одну строку в секунду:
fred 0
Почему? Логический && использует короткое замыкание: если левый операнд равен нулю (false), результат уже false, и правый операнд не вычисляется. То есть строка int x = fred(0) && fred(1); эквивалентна:
int x;
if (fred(0) == 0) {
x = false; // записывает 0 в x
} else {
if (fred(1) == 0) {
x = false; // записывает 0 в x
} else {
x = true; // записывает 1 в x
}
}
Логический && позволяет выразить эту сложную логику очень компактно.
Аналогичная разница есть между побитовым OR | и логическим OR ||: побитовый всегда вычисляет оба операнда, а логический пропускает правый, если левый уже ненулевой (false). Кроме того, побитовый | работает с каждым битом независимо, а логический || возвращает true или false для всего выражения целиком.
Типичные ошибки
Начинающие часто пишут & вместо && в условиях if — и получают неожиданное поведение. Запомните: для логических проверок используйте && и ||, а &, |, ^ — для работы с битами.
Применение на практике: решаем реальные задачи
Теперь разберём, как комбинировать побитовые операторы для решения конкретных задач в среде Arduino.
Порты микроконтроллера Atmega8
Обычно для работы с цифровыми пинами в Atmega8 используют функции digitalRead() и digitalWrite(). Предположим, в функции setup() нужно настроить пины 2–13 как выходы, установить пины 11, 12, 13 в HIGH, а остальные — в LOW. Стандартный способ:
void setup()
{
int pin;
for (pin=2; pin <= 13; ++pin) {
pinMode (pin, OUTPUT);
}
for (pin=2; pin <= 10; ++pin) {
digitalWrite (pin, LOW);
}
for (pin=11; pin <= 13; ++pin) {
digitalWrite (pin, HIGH);
}
}
То же самое можно сделать через прямой доступ к Atmega8 портам и побитовые операции:
void setup()
{
// установить пины 1 (Serial transmit) и 2..7 как выходы,
// но оставить пин 0 (Serial receive) как вход
// (иначе Serial перестанет работать!) ...
DDRD = B11111110; // цифровые пины 7,6,5,4,3,2,1,0
// установить пины 8..13 как выходы...
DDRB = B00111111; // цифровые пины -,-,13,12,11,10,9,8
// выключить цифровые выходные пины 2..7 ...
PORTD &= B00000011; // выключает 2..7, не трогая пины 0 и 1
// одновременная запись на пины 8..13...
PORTB = B00111000; // включает 13,12,11; выключает 10,9,8
}
Здесь используется то, что регистры DDRD и DDRB содержат по 8 бит, каждый из которых определяет направление пина: 1 — выход, 0 — вход. Два старших бита DDRB не задействованы — пинов 14 и 15 на Atmega8 нет. Регистры PORTB и PORTD хранят последнее записанное значение для каждого пина: HIGH (1) или LOW (0).
Почему это обычно не стоит делать:
- Код значительно сложнее читать, отлаживать и сопровождать. Процессор сэкономит микросекунды, но вы потратите часы на поиск ошибки.
- Код привязан к конкретному микроконтроллеру. Функции digitalRead() и digitalWrite() работают на всём семействе Atmel, а регистры отличаются от чипа к чипу.
- Легко случайно сломать что-то важное. Например, строка
DDRD = B11111110;специально оговаривает, что пин 0 должен остаться входом — потому что это RX последовательного порта. Одна ошибка — и Serial перестаёт работать.
Когда прямой доступ к портам всё же оправдан:
- Программа почти не влезает во flash-память: запись в порт одной операцией занимает намного меньше байт, чем цикл
forс вызовамиdigitalWrite. - Нужно изменить несколько пинов одновременно:
digitalWrite(10,HIGH);иdigitalWrite(11,HIGH);выполняются с задержкой в несколько микросекунд, тогда какPORTB |= B1100;устанавливает оба пина в один момент. - Нужна максимальная скорость переключения. Если заглянуть в исходник
lib/targets/arduino/wiring.c, видно, что digitalRead() и digitalWrite() компилируются в десятки инструкций. Прямой доступ к порту — это буквально несколько тактов.
Продвинутый пример: отключение прерывания
Теперь разберём, что делает вот такой фрагмент кода из реального кода Arduino:
// отключить прерывание
GICR &= ~(1 << INT0);
Это реальный код из библиотеки Arduino 0007, файл lib\targets\arduino\winterrupts.c. GICR — регистр управления прерываниями: каждый его бит включает (1) или выключает (0) соответствующее прерывание. Значение INT0 определяется в зависимости от типа микроконтроллера:
#define INT0 6
или
#define INT0 0
Для одних процессоров строка компилируется в:
GICR &= ~(1 << 0);
Для других:
GICR &= ~(1 << 6);
Разберём второй случай подробнее. Выражение (1 << 6) сдвигает 1 влево на 6 позиций — это 2⁶ = 64, или в двоичном виде: 01000000. Затем оператор ~ инвертирует все биты: получается 10111111. Далее применяется оператор побитового AND с присваиванием, то есть код эквивалентен:
GICR = GICR & B10111111;
Эффект: все биты GICR остаются без изменений, кроме второго сверху — он сбрасывается в 0.
Если же INT0 определён как 0, строка интерпретируется иначе:
GICR = GICR & B11111110;
В этом случае сбрасывается младший бит GICR. Вот так одна строка кода поддерживает разные микроконтроллеры.
Экономия памяти: несколько значений в одном байте
Представьте, что вы создаёте светодиодную матрицу и хотите отображать символы, включая и выключая отдельные LEDs. Например, битовая карта буквы X для матрицы 5×7 может выглядеть так:

Простой способ хранить такое изображение — массив целых чисел:
const prog_uint8_t BitMap[5][7] = { // хранить в памяти программ, чтобы сэкономить RAM
{1,1,0,0,0,1,1},
{0,0,1,0,1,0,0},
{0,0,0,1,0,0,0},
{0,0,1,0,1,0,0},
{1,1,0,0,0,1,1}
};
void DisplayBitMap()
{
for (byte x=0; x<5; ++x) {
for (byte y=0; y<7; ++y) {
byte data = pgm_read_byte (&BitMap[x][y]); // читать данные из памяти программ
if (data) {
// включить светодиод в позиции (x,y)
} else {
// выключить светодиод в позиции (x,y)
}
}
}
}
Для одного символа это вполне приемлемо: 35 байт на 35 пикселей. Но если вам нужны все 96 печатных символов ASCII — это уже 96 × 35 = 3360 байт, что съедает значительную часть flash-памяти Atmega8.
Есть куда более эффективный способ. Заменим двумерный массив одномерным массивом байтов. Каждый байт содержит 8 бит, из которых 7 младших описывают один столбец матрицы 5×7:
const prog_uint8_t BitMap[5] = { // хранить в памяти программ, чтобы сэкономить RAM
B1100011,
B0010100,
B0001000,
B0010100,
B1100011
};
(Здесь используются предопределённые двоичные константы, доступные начиная с Arduino 0007.) Теперь вместо 35 байт на символ — всего 5. Но как работать с такими данными? Переписываем функцию DisplayBitMap(), которая обращается к отдельным битам каждого байта массива BitMap:
void DisplayBitMap()
{
for (byte x=0; x<5; ++x) {
byte data = pgm_read_byte (&BitMap[x]); // читать данные из памяти программ
for (byte y=0; y<7; ++y) {
if (data & (1<<y)) {
// включить светодиод в позиции (x,y)
} else {
// выключить светодиод в позиции (x,y)
}
}
}
}
Ключевая строка:
if (data & (1<<y)) {
Выражение (1<<y) формирует маску для нужного бита в data. Побитовый AND data & (1<<y) проверяет, установлен ли этот бит. Если да — результат ненулевой, if воспринимает это как true. Если бит равен 0 — выполняется else.
Как проверить результат
Чтобы убедиться, что маскирование работает правильно, можно вывести значение выражения data & (1 << y) в Serial Monitor для разных значений y и сравнить с ожидаемыми битами вашего байта.
Краткий справочник
В этом разделе биты 16-битного целого нумеруются от 0 (младший) до 15 (старший, знаковый для знакового типа):

Везде, где встречается переменная n, её значение — от 0 до 15.
y = (x >> n) & 1; // n=0..15. записывает n-й бит x в y. y принимает значение 0 или 1
x &= ~(1 << n); // принудительно устанавливает n-й бит x в 0. остальные биты не меняются
x &= (1<<(n+1))-1; // оставляет нетронутыми младшие n бит x; все старшие биты обнуляются
x |= (1 << n); // принудительно устанавливает n-й бит x в 1. остальные биты не меняются
x ^= (1 << n); // инвертирует n-й бит x. остальные биты не меняются
x = ~x; // инвертирует ВСЕ биты x
Вот интересная функция, использующая одновременно побитовый & и логический &&. Она возвращает true, если переданное 32-битное целое x является точной степенью двойки (1, 2, 4, 8, 16, 32, 64 и т.д.). Например, IsPowerOfTwo(64) вернёт true, а IsPowerOfTwo(65) вернёт false.
Разберём на примере числа 64. В двоичном виде 64 — это 1000000. Вычтем 1: получим 1000000 → 0111111. Применим побитовый &: результат 0000000 — ноль, значит это степень двойки. Для 65 (двоичное 1000001) тот же алгоритм даёт 1000001 & 1000000 == 1000000 — ненулевое значение, не степень двойки.
bool IsPowerOfTwo (long x)
{
return (x > 0) && (x & (x-1) == 0);
}
А вот функция, подсчитывающая количество единичных битов в 16-битном целом x:
int CountSetBits (int x)
{
int count = 0;
for (int n=0; n<16; ++n) {
if (x & (1<<n)) {
++count;
}
}
return count;
}
Альтернативный вариант:
int CountSetBits (int x)
{
unsigned int count;
for (count = 0; x; count++)
x &= x - 1;
return count;
}
Ещё больше приёмов для работы с битами можно найти here.