Мой первый обмен с I2C-датчиком выглядел так: я прочитал два байта давления и сложил их. Старший 42, младший 42 — итого 84. Датчик имел в виду 10794, я получил 84, и три вечера перепрошивал плату, грешил на провода и на партию модуля. Виноват оказался плюс: байты не складывают, их склеивают — старший сдвигают на восемь позиций влево, младший пристыковывают через побитовое ИЛИ. Одна строка, (msb << 8) | lsb, закрыла вопрос навсегда.
Битовые операции — не экзотика для программистов со стажем, а ежедневный инструмент работы с датчиками: почти каждый I2C-чип отдаёт измерения кусками по несколько бит и принимает конфигурацию полями внутри одного байта. В этом уроке соберём 16-бит значение из двух байтов, вырежем битовое поле маской, поставим и снимем флаги — и разберём граблю с приоритетами, на которой спотыкается едва ли не каждый второй.
Встречается это везде, где в документации нарисована табличка с битами: флаги статуса LCD, биты скорости у OLED-контроллера, поля конфигурации радиомодулей. Мой маячок простой: увидел в документации колонку с битами от 7 до 0 — достаю печать в BIN и иду по таблице из этого урока. Через три датчика сдвиги и маски начинают читаться быстрее, чем десятичные примеры из чужих скетчей.
Сдвиг: байт наверх, байт вниз
Байт — восемь бит, числа от 0 до 255. Тип int на Uno держит 16 бит, от −32768 до 32767. Датчик отдаёт 16-бит измерение двумя байтами: старшим и младшим. Сдвиг влево на восемь позиций переселяет старший байт на верхний этаж: 42 << 8 даёт 10752. Побитовое ИЛИ пристыковывает младший: 10752 | 42 — это 10794, он же 0x2A2A, он же 0b0010101000101010. Узор виден прямо в записи: оба исходных байта по 42, и в собранном числе они стоят этажами, не перемешиваясь.
Сдвиг влево на n равен умножению на 2 в степени n, вправо — делению нацело. Поэтому для непересекающихся полей (msb << 8) + lsb и (msb << 8) | lsb дают одно и то же — но я пишу именно ИЛИ: операция честно говорит «биты разъезжаются по разным ячейкам», а плюс намекает на арифметику, которая здесь ни при чём. На сдвигах и масках держится не только I2C: регистры таймеров, на которых работает ШИМ, настраиваются тем же языком.
Обратная задача — разобрать число на байты — решается теми же инструментами: старший байт это value >> 8, младший value & 0xFF. Так int укладывается в EEPROM по кусочкам, так же один байт раскладывают на поля. Маску 0xFF в младшем байте я пишу всегда, даже когда присваивание к byte и так обрежет лишнее: явная маска читается за секунду и не требует держать в голове типы операндов.
| Операция | Что делает | Пример на байте 0b00101010 |
|---|---|---|
| & | вырезает биты по маске | x & 0b00001000 → 0b00001000 |
| | | поднимает биты | x | 0b00010000 → 0b00111010 |
| & ~ | гасит биты | x & ~0b00001000 → 0b00100010 |
| ^ | переключает биты | x ^ 0b11111111 → 0b11010101 |
| << | сдвиг влево, умножение на 2 в степени n | x << 2 → 0b10101000 |
| >> | сдвиг вправо, деление нацело | x >> 1 → 0b00010101 |
| |= &= ^= <<= >>= | составные: делают и запоминают в переменной | cfg |= 0b00100000 — подняли бит 5 |
Маска — байт-трафарет: единицы в ней отмечают интересующие биты. Вырезать из байта конфигурации трёхбитное поле на позициях 4:2 — сдвиг вправо на два и маска на три единицы: (cfg >> 2) & 0b111. Поставить пятый бит: cfg |= (1 << 5). Снять, не тронув соседей: cfg &= ~(1 << 5) — инверсия гасит все биты, кроме нужного, и И вычищает только его. Проверить: if (cfg & (1 << 5)) — ненулевой результат значит, что бит поднят.
Формула на всю жизнь: (1 << n) - 1 — маска на n младших бит. Для трёх бит это 0b111, для пяти — 0b11111. Ею же вынимают поля из сырого байта: сдвинул до нулевой позиции, отрезал хвост — готово. Битовая склейка живёт не только на шине: EEPROM хранит int ровно теми же двумя байтами, разбор хранения int в EEPROM покажет ту же математику с обратной стороны.
Ширина поля — ещё и бесплатная проверка на промах. Трёхбитное поле вмещает только 0–7, четырёхбитное 0–15: если в поле «выбор канала» с двумя каналами внезапно оказалась девятка, сдвиг или маска не на своей позиции. Я один раз вырезал поле со сдвигом на один бит больше нужного и долго верил, что датчик врёт про свой режим. Теперь после каждой выемки поля в уме прогоняю: какое максимальное значение допускает ширина — и всё ли влезает.
#include <Wire.h> const byte ADDR = 0x76; unsigned int read16(byte reg) { // собираем 16 бит из двух байтов Wire.beginTransmission(ADDR); Wire.write(reg); Wire.endTransmission(); Wire.requestFrom(ADDR, 2); byte msb = 0; byte lsb = 0; if (Wire.available() > 0) msb = Wire.read(); if (Wire.available() > 0) lsb = Wire.read(); return (unsigned int)(msb << 8) | lsb;} void setup() { Wire.begin(); Serial.begin(9600); unsigned int value = read16(0xF7); // пример: старшая пара байтов давления Serial.print("value="); Serial.println(value); Serial.print("hex="); Serial.println(value, HEX); byte cfg = 0b01001111; // гипотетический байт конфигурации cfg |= (1 << 5); // подняли бит 5 Serial.print("set5="); Serial.println(cfg, BIN); cfg &= ~(1 << 5); // сняли обратно Serial.print("clr5="); Serial.println(cfg, BIN); Serial.print("field="); Serial.println((cfg >> 2) & 0b111); // вырезали биты 4:2} void loop() { delay(1000);}Прогон в симуляторе: заглушка отдала два байта по 42, сборка дала 10794 и hex 2A2A — узор байт-в-байт виден и в двоичной печати. Обратите внимание на вывод BIN: движок, как и настоящая плата, не печатает ведущие нули, поэтому 42 выглядит как 101010, а не 00101010. Я привык докручивать нули глазами, а при разборе протоколов включаю HEX: шестнадцатеричная пара ровно ложится на полбайта, и 2A2A читается как «два байта по 2A» без всяких усилий.
Флаги в выводе тоже честные: cfg = 0b01001111 после ИЛИ с (1 << 5) вырос до 0b1101111 в печати, после И с инверсией вернулся к 0b1001111, поле на битах 4:2 вырезалось в тройку. Движок считает эти числа по-настоящему — на живой плате они будут теми же. Заодно поглядите на механику приёма: Wire.available() и Wire.read() устроены так же, как буфер Serial — байты копятся, вы их вычитываете по одному. А обратная задача — собрать число из приходящих по UART символов — решается иначе: Serial.parseInt и сборка чисел — там про это.
Для серии байтов у меня есть приём из мануалов по протоколам: печатаю дамп одной строкой — Serial.print(x, HEX), пробел, следующий байт. Строка вроде 2A 2A 2A читается быстрее, чем столбик, и совпадает с распечатками из даташитов: с ними сравнивать удобно. Двоичную печать я включаю точечно — когда разбираюсь с конкретным полем, иначе в единицах и нулях рябит в глазах.
Скобки, знаки и прочие грабли
Грабля номер один — приоритеты. Равенство == сравнивает сильнее, чем побитовое И: условие if (st & 0x08 == 0x08) вычисляется как st & (0x08 == 0x08), то есть st & 1 — проверяется младший бит, а не третий. Правило, которое я вбил себе в пальцы: любое битовое выражение в условии берём в скобки целиком, if ((st & 0x08) == 0x08), даже когда кажется, что и так понятно.
Грабля номер два — знаковость. int на Uno знаковый: если старший байт дошёл до 0xFF, сдвиг 0xFF << 8 упрётся в знаковый разряд, и число уедет в минус. Для сырых измерений объявляйте unsigned int или word — они держат честные 0–65535. И не забывайте про ритм опроса: интервал между чтениями строится на millis вместо delay и должен быть заведомо длиннее времени измерения датчика, иначе маска честно вырежет вчерашние данные.
Грабля номер три ждёт на отрицательных температурах: DS18B20 хранит их в дополнительном коде, где старшие биты — сплошные единицы, и наивная склейка байтов с учётом знака выдаёт белиберду. Разбор дополнительного кода — это те же сдвиги: число со знаком восстанавливается одним сдвигом вправо в знаковом типе и маской на дробную часть. Когда дойдёте до отрицательных морозов на OneWire, битовая математика из этого урока уже будет под рукой — и знак перестанет быть сюрпризом.
- 1. Как собрать 16-бит значение из старшего и младшего байтов?
- 2. cfg = 0b01001111. Что вернёт (cfg >> 2) & 0b111?
- 3. Как включить бит 5, не трогая остальные?
- 4. if (st & 0x08 == 0x08) — что проверится на самом деле?
- 5. Датчик отдал байты 42 и 42. Сколько в собранном 16-бит числе?
- Склейка 16 бит: (msb << 8) | lsb — два байта по 42 дают 10794, а не 84
- Маска — трафарет из единиц: & вырезает, | поднимает, & ~ гасит, ^ переключает биты
- (1 << n) - 1 — готовая маска на n младших бит; поле вынимают сдвигом и маской
- В условиях битовые выражения — в скобках: == сильнее &, без скобок проверится не тот бит
- Сырые байты датчиков держите в unsigned int или word: знаковый int на Uno превращает 0xFF в минус
Частые вопросы по теме урока
- Как собрать 16-бит значение из старшего и младшего байтов?
- Старший байт сдвигается на 8 позиций влево, младший пристыковывается ИЛИ. Сложение случайно совпадёт только при отсутствии переносов, но намерение выражает именно ИЛИ. (msb << 8) | lsb
- cfg = 0b01001111. Что вернёт (cfg >> 2) & 0b111?
- Сдвиг подвёл биты 4:2 к младшим позициям, маска 0b111 отрезала остальное — получилось 3. Сдвиг плюс маска — стандартная выемка поля. 3
- Как включить бит 5, не трогая остальные?
- ИЛИ с единицей на нужной позиции поднимает бит и не трогает соседние. Присваивание затрёт весь байт, а ^= переключит бит в противоположное состояние. cfg |= (1 << 5)
- if (st & 0x08 == 0x08) — что проверится на самом деле?
- Равенство сильнее И: выражение читается как st & (0x08 == 0x08), то есть st & 1. Скобки обязательны: if ((st & 0x08) == 0x08). st & 1: сначала выполнится сравнение
- Датчик отдал байты 42 и 42. Сколько в собранном 16-бит числе?
- 42 << 8 = 10752, плюс младшие 42 — итого 10794, он же 0x2A2A. А 10752 — только старший байт без хвоста. 10794