Морозильный ларь в гараже я оснастил сигнализацией: датчик DS18B20, реле на компрессор, целевая температура — минус восемнадцать градусов. Настройку цели, разумеется, надо помнить через выключение — опыт с ночником из первого урока про EEPROM меня уже научил. Записал, проверил, закрыл проект. Через неделю заехал — а ларь держит плюс сорок, потому что сигнализация видит цель «плюс два градуса». Читаю ячейку: 238. Пишу минус восемнадцать — читаю 238. Круг замкнутый.
Тупик разрешился за минуту, но минуту я искал не в том направлении: перепроверял датчик, потом реле, потом самого себя. А виновата арифметика: EEPROM.write принимает один байт, числа от 0 до 255, и минус восемнадцать в этот диапазон не помещается физически. Плата молча обрезала значение до младшего байта — и 238 оказалось честным остатком от деления.
В этом уроке научимся раскладывать любое число на байты и собирать обратно: целые со знаком — через деление, остаток и поправку на знак, дробные — через четырёхбайтовый IEEE-754. Приём скучный, но фундаментальный: на нём держатся и счётчики из урока про счётчик включений, и все структуры, которые мы сохраним в следующем.
В симуляторе раскладку можно пощупать руками: скетч берёт показания A0 — это как раз целое шириной больше байта — режет его на старший и младший байты, складывает в две ячейки и собирает обратно. Совпало — зелёный светодиод молчит, ошиблись — загорается.
Отрицательные числа: допкод и два байта
Разберём, что же случилось с минус восемнадцатью. Тип int на платах AVR занимает два байта и умеет от −32768 до 32767. Отрицательные числа в нём живут в дополнительном коде: −18 — это 65518, если смотреть на те же шестнадцать бит как на беззнаковое число. Побайтно это 255 и 238. Когда я записал «минус восемнадцать» прямо в ячейку, функция отрезала младший байт — 238 — и выбросила старший. Отсюда и магическое число на чтении.
Правильная раскладка — школьная арифметика из урока про счётчик, с одной добавкой на знак. Старший байт: value / 256, младший: value % 256. Для отрицательных значений это работает без изменений, если считать по правилам беззнаковой арифметики, а вот при сборке надо вернуть знак: собираем raw = high × 256 + low в переменной вместимостью побольше и, если старший байт получился 128 или больше, вычитаем 65536. Собрали 65518, старший байт 255 — знак минус — вычли 65536, получили −18. Всё. Диапазоны всех типов одним взглядом — в уроке про переменные и типы.
Порядок байтов — договорённость, которую вы обязаны зафиксировать. У меня старший байт всегда лежит по меньшему адресу, и скетчи соседних проектов следуют тому же. Есть и готовые помощники: highByte() и lowByte() делают ровно этот разрез, а в терминах сдвигов это value, сдвинутое на 8 бит вправо, и value, наложенное на маску 255. Деление с остатком и сдвиги с маской — две записи одной операции, выбирайте, что читается для вас прозрачнее.
Пока раскладка байтов и частота записей под контролем, память живёт долго — ресурс ячеек мы считали в прошлом уроке. Осталось научиться не терять байты по дороге.
Где такие числа встречаются на практике, кроме температур: пробег в метрах до 32 километров, минуты наработки мотора почти до 546 часов, давление в гектопаскалях, порог влажности почвы в сырых единицах analogRead. Всё это целые со знаком или без — и всё это ровно две ячейки. Как только нужен запас сверху — счётчик на годы, координата в сантиметрах — берите long и четыре ячейки: механика та же, множитель 65536 вместо 256. Вся эта механика — чистая работа с битами и байтами: ниже — интерактивная песочница, покрутите ползунок байта, щёлкайте отдельные разряды и гоняйте сдвиги с масками — каждая операция покажет число до и после.
Биты, байты и переполнение — видно глазами, а не в голове.
Подгружаем интерактив…
#include <EEPROM.h> // Карта памяти: 2 - magic-байт, 3-4 - целевая температура (int)const int addrMagic = 2;const int addrHigh = 3;const int addrLow = 4; int loadTarget() { int high = EEPROM.read(addrHigh); int low = EEPROM.read(addrLow); if (EEPROM.read(addrMagic) != 170 || (high == 255 && low == 255)) { return -18; // первый запуск - заводской дефолт } long raw = high; // собираем в long: 255*256 в int не влезает raw = raw * 256 + low; if (high >= 128) { raw = raw - 65536; // старший байт >= 128 значит минус } return raw;} void saveTarget(int t) { EEPROM.update(addrHigh, t / 256); // старший байт (для минусов это 255) EEPROM.update(addrLow, t % 256); // младший EEPROM.update(addrMagic, 170); // память инициализирована} void setup() { Serial.begin(9600); int target = loadTarget(); Serial.print("target="); Serial.println(target);} void loop() { // в реальном проекте здесь кнопки и гистерезис термостата delay(100);}Три места, где этот код обычно ломают. Первое — сборка не в long: high × 256 при high = 255 даёт 65280, что за пределами знакового int, и в чистом C++ это путь к непредсказуемым значениям. Промежуточное присваивание в long, как в счётчике включений, снимает вопрос. Второе — забыть magic-байт: без него свежая плата прочтёт пару 255/255, и без проверки на неё вы получите −1 вместо дефолта. Третье — не совпадающие при записи и чтении адреса: запись в 3-4, а чтение из 4-5 — классика, когда карту памяти ведут в голове.
Для целевых температур и порогов, кстати, честная альтернатива — не хранить дроби вовсе: работать с «температурой, умноженной на десять», как с целым. −18,5 превращается в −185 и прекрасно живёт в двух байтах. Но это уже дизайнерское решение конкретного проекта, а приём с допкодом работает всегда. А чтобы увидеть байты глазами — в двух системах сразу — пригодится урок про двоичную и шестнадцатеричную системы.
Ещё один неприятный сценарий — разорванная запись: плата записала старший байт, обновила младший и обесточилась ровно посередине. В ячейках остаётся значение из старшего старого и младшего нового байта — гибрид, который выглядит как число, только не имеет смысла. Лечится контролкой уровня блока: magic и контрольная сумма пишутся последними, и пока они не сошлись, прочитанные поля считаются подозрительными. Для температур и порогов достаточно того, что мы уже сделали — magic-байт ставится в saveTarget после байтов значения.
float: четыре байта IEEE-754
С дробными числами трюк «поделил на 256» не работает, и вот почему. Тип float занимает четыре байта, но это не «четыре цифры разряда» — это IEEE-754: один бит знака, восемь бит порядка и двадцать три бита мантиссы. Деление на 256 режет число по байтовым границам, а байтовые границы в IEEE-754 проходят сквозь мантиссу — на выходе вы получите не усечённое значение, а бессмыслицу. Проверьте на досуге: раскладывайте 1.0 по байтам делением — соберёте обратно совсем другое число.
Поэтому для float байтовый дамп делают только целиком. Вариантов три, и все честные: union, накладывающий float и массив из четырёх байтов на одну и ту же память; memcpy четырёх байтов из переменной в буфер; и шаблонная функция EEPROM.put из следующего урока, которая делает то же самое за вас. Точность у float — около семи значащих цифр, для калибровочных коэффициентов и температур этого хватает с запасом.
В симуляторе union и указатели не поддержаны, так что приём с наложением проверяется только на реальной плате — а в скетче ниже мы отрабатываем родственную механику на целом числе, где всё видно глазами.
И маленькая ловушка переносимости: на платах AVR тип double — это тот же float, четыре байта, и слово «двойная точность» там ничего не добавляет. На ESP32 или в коде для компьютера double уже восемь байт. Скетч, который честно считает sizeof и пишет байты через union, перенесётся без сюрпризов, а тот, что захардкодил «дробное — это всегда четыре», на новой плате прочитает мусор. Привычка спрашивать sizeof у типа, а не у памяти, окупается каждый раз.
#include <EEPROM.h> union FloatBytes { // float и байты - одна и та же память float f; byte b[4];}; const int addrFloat = 10; void setup() { Serial.begin(9600); FloatBytes out; // пишем out.f = 0.0417; // калибровочный коэффициент датчика for (int i = 0; i < 4; i++) { EEPROM.update(addrFloat + i, out.b[i]); } FloatBytes in; // читаем for (int i = 0; i < 4; i++) { in.b[i] = EEPROM.read(addrFloat + i); } Serial.print("koeff="); Serial.println(in.f, 6); // печатаем с шестью знаками после точки} void loop() { delay(100);}| Тип | Байт | Диапазон значений | Ячеек EEPROM на Uno |
|---|---|---|---|
| byte | 1 | 0…255 | 1 |
| int | 2 | −32768…32767 | 2 |
| unsigned int | 2 | 0…65535 | 2 |
| long | 4 | около ±2,1 млрд | 4 |
| float | 4 | ±3,4·10^38, 7 значащих цифр | 4 |
- Ячейка EEPROM — один байт 0…255; int занимает две ячейки, long и float — по четыре
- Отрицательные int хранятся в дополнительном коде: −18 — это байты 255 и 238; раскладка value / 256 и value % 256, при сборке raw >= 32768 означает минус
- Собирайте многобайтовое значение в long: 255 × 256 не влезает в знаковый int
- highByte() и lowByte() — то же, что деление с остатком или сдвиг на 8 бит и маска 255
- float режут только целиком: union, memcpy или EEPROM.put; деление на 256 разваливает IEEE-754 в бессмыслицу
- Порядок байтов и magic-байт — ваша договорённость с будущим собой; зафиксируйте её в карте памяти
- 1. Сколько ячеек EEPROM займёт один int на Arduino Uno?
- 2. Что даст раскладка числа −5 на байты по правилам беззнаковой арифметики?
- 3. Как правильно собрать знаковый int из двух прочитанных байтов?
- 4. Почему нельзя раскладывать float через деление на 256, как int?
- 5. Зачем в скетче с коэффициентом 0.0417 нужен union?
Раскладывать байты руками — полезное упражнение и рабочий инструмент, но признайте: на пятой-шестой настройке скетч превращается в бухгалтерию адресов и сдвигов. Компилятор умеет делать то же самое сам — один вызов кладёт в EEPROM целую структуру настроек со всеми её int, float и magic-байтами. Как устроены EEPROM.put и EEPROM.get, сколько байт занимает структура и зачем внутри неё версия раскладки — финальный урок раздела.
Частые вопросы по теме урока
- Сколько ячеек EEPROM займёт один int на Arduino Uno?
- int на AVR — два байта, значит две ячейки: старший и младший байт. Четыре ячейки — это long или float. Две
- Что даст раскладка числа −5 на байты по правилам беззнаковой арифметики?
- −5 в дополнительном коде — это 65531. Делим: 65531 / 256 = 255, остаток 251. При сборке: 255 × 256 + 251 = 65531, старший байт ≥ 128 — значит минус. 255 и 251
- Как правильно собрать знаковый int из двух прочитанных байтов?
- Сборка всегда в ёмкую переменную с проверкой старшего байта: 128 и больше — число отрицательное, вычитаем 65536. Вариант «high + low» — классическая ошибка новичка. Собрать в long, затем если raw >= 32768 — вычесть 65536
- Почему нельзя раскладывать float через деление на 256, как int?
- В IEEE-754 бит знака, порядок и мантисса упакованы так, что байтовые границы проходят сквозь мантиссу. Дробная часть не хранится отдельно — раскладывают весь дамп целиком. Байты IEEE-754 не совпадают с десятичными разрядами: деление рвёт мантиссу
- Зачем в скетче с коэффициентом 0.0417 нужен union?
- Union накладывает float и byte[4] на одну память: записали в поле f — прочитали из массива b. Никакой арифметики над битами руками. Он позволяет смотреть на память float как на массив из четырёх байтов без сдвигов и копирований