Главная chevron_right Уроки chevron_right Программирование chevron_right Bit Math на Arduino: побитовые операции для начинающих и продвинутых

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. Для совместимости со старыми версиями также определены константы B0B11111111, которые можно использовать аналогично.

Побитовое 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: получим 10000000111111. Применим побитовый &: результат 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.