Счётчик включений для солнечной будки я хранил в микросхеме памяти 24C02 на адресе 0x50: плата просыпалась раз в час, увеличивала число и засыпала обратно. Написал четыре строчки — Wire.write(0x10); Wire.write(count); — и стал ждать. Через три «пробуждения» полез проверять: память была девственно пуста, а никакого предупреждения Arduino IDE за всё это время не показала.
Причина оказалась стыдная: я скормил байты функции write(), не открыв beginTransmission() и не закрыв endTransmission(). Без конверта письмо никуда не идёт: write() честно возвращала ноль — «байтов не принято», — но возвращаемое значение я поленился читать. В этом уроке разберём почтовый механизм Wire по деталям: что делает begin(), где живёт буфер, в какой момент на линиях появляется сигнал и почему проверка кода возврата экономит часы.
Сама память между делом — хороший персонаж для знакомства: 24C02 хранит 256 байтов, и моя ячейка 0x10 — одна из них. Переживает миллион циклов записи на каждую ячейку и удерживает данные порядка сотни лет — так обещает даташит. Согласитесь, неплохой напарник для Arduino, которая живёт, пока подключена к USB: вся ценность счётчика включений именно в том, чтобы пережить отключение.
Wire.begin(): включаем шину
Wire.begin() — точка входа: команда включает аппаратный блок TWI и закрепляет за шиной пины A4 (SDA) и A5 (SCL), о которых шла речь в первом уроке раздела. Вызывают её один раз в setup(): повторный вызов перезапускает шину, а вызов из loop() — вредная привычка, от которой у меня однажды слетал уже настроенный обмен с часами. Подтяжки begin() не включает и не проверяет: на плате Uno их просто нет, линии поднимают резисторы 4,7 кОм на самих модулях. Проверить, что шина проснулась, в симуляторе можно по строке в панели I2C, а на живой плате — сканером: он же и покажет, кто слушает.
Число для полноты картины: когда устройство прижимает линию к земле, через подтяжку течёт ток около 1 мА — 5 В, поделённые на 4,7 кОм. Потому резисторы и не греются: греется нечему. Если же передать begin() аргумент — Wire.begin(0x08), — плата становится слейвом с собственным адресом; эта вторая жизнь begin() разобрана в уроке про две платы на одной шине. А ещё у begin() есть спутник — Wire.setClock(): по умолчанию шина живёт на 100 кГц, вызов Wire.setClock(400000) разгоняет её вчетверо; на коротких проводах и с OLED это заметно ускоряет перерисовку, на длинных макеточных — возвращает глюки, и я всегда откатываюсь к сотне.
beginTransmission: конверт, который ещё не отправлен
Трио beginTransmission — write — endTransmission работает как почта. beginTransmission(0x50) открывает конверт и запоминает адресата — на шине при этом тишина, панель I2C в симуляторе честно показывает пустоту. write() кладёт байты в буфер; на Uno его объём — 32 байта, и каждый вызов возвращает, сколько байтов принял: единицу для одиночного байта или ноль, когда буфер уже полон. Сама передача стартует только в endTransmission(): один кадр START — адрес — все байты с подтверждениями — STOP.
Тайминг кадра считается на пальцах: при стандартных 100 кГц каждый байт вместе с подтверждением занимает около 90 мкс, значит кадр из 32 байтов идёт примерно 3 мс. Короткие кадры шина пропускает десятками в секунду, и даже полсекундной паузы между ними хватает любому датчику с запасом. А вот растягивать один конверт на сотни байтов смысла нет: буфер отрежет лишнее молча, без единого предупреждения в консоли.
И ещё одна ловушка внутри самой пары. beginTransmission можно вызывать только один раз до endTransmission: повторный вызов молча начнёт новый конверт, а недописанные байты первого пропадут. Мой балованный скетч терял последний байт раз в десять кадров, пока я не собрал весь кадр в одну функцию — оттуда и взялось правило «открыл, записал, закрыл» без промежуточных выходов из кадра.
| Вызов | Что происходит | Что на шине в этот момент |
|---|---|---|
| Wire.begin() | Включает железо TWI, пины A4/A5 уходят шине | Тишина, линии держат подтяжки |
| Wire.beginTransmission(0x50) | Запоминает адресата, очищает буфер | Тишина |
| Wire.write(байт) | Кладёт байт в буфер, максимум 32 штуки | Тишина |
| Wire.endTransmission() | START, адрес, байты с ACK, STOP | Кадр уходит целиком за примерно 3 мс при 100 кГц |
#include <Wire.h> int writeCell(byte cell, byte data) { Wire.beginTransmission(0x50); // память 24C02 Wire.write(cell); // первый байт — номер ячейки Wire.write(data); // второй — данные return Wire.endTransmission() == 0 ? 1 : 0; // ноль — память приняла} void setup() { Wire.begin(); Serial.begin(9600); Serial.println("Пишу счётчик в EEPROM...");} int counter = 0; void loop() { int ok = writeCell(0x10, counter); Serial.print("counter="); Serial.print(counter); Serial.print(" ok="); Serial.println(ok); counter++; delay(1000);}По строкам. Функция writeCell() — вся анатомия записи в четырёх строках: конверт открыт, указатель ячейки и данные положены, кадр отправлен, а тернарный оператор превращает код возврата в понятный флаг. Память трактует первый байт после своего адреса как номер ячейки: хотим писать в 0x10 — первым байтом идёт 0x10, и только затем полезные данные.
Дальше loop() пишет новое значение раз в секунду. На живой плате период важно держать не короче цикла записи страницы — те самые 5 мс из даташита: пришлёшь раньше — память молча проигнорирует кадр и продолжит варить свою страницу. Секундный интервал даёт запас в двести раз; привычку оставлять запас я бы ставил с первого дня — на живой плате она однажды спасёт вам вечер.
У endTransmission() есть второй талант, о котором примеры молчат: аргумент. Вызов endTransmission() ставит стоп-условие и отпускает шину — сосед-мастер может влезть между вашими операциями. А endTransmission(false) стоп не ставит: шина остаётся за вами, и следующая операция начинается с повторного старта. Так защищают двухходовки «команда — ответ» на конкурентной шине; для одиночной макетки с одним мастером это избыточно, я ставлю обычный STOP — так и отлаживать проще.
Код возврата при этом я проверяю всегда — привычка, вбитая сканером из урока про поиск адресов: ноль — кадр ушёл, двойка — на адресе никого (перепутаны SDA и SCL или модуль без питания), остальные коды редки. Три строчки с проверкой и печатью в Serial занимают минуту, а в моём личном зачёте уже сэкономили не один вечер: сразу видно, виновата физика или код. Полный разбор памяти 24C02 — с чтением записанного — ждёт в уроке про EEPROM.
Коды возврата — это журнал отказов, и читать его стоит по пунктам. Двойка — на адресе никого: перепутаны SDA и SCL, нет общей земли, забыто питание или в конверте просто чужой адрес. У меня двойка чаще всего означала надломленный у обжима dupont-провод: продёргиваешь его пальцем — и код в Serial меняется на глазах.
Тройка — адресат подтвердился, но данные отверг: так память отвечает, если прийти с записью во время собственного внутреннего цикла записи. Лечится паузой, а не повторами на бис. Четвёрка — прочее; на Uno за десять лет я видел её дважды, и оба раза рядом стояли длинные провода и отсутствие подтяжки.
Отдельная беда — шина, которую кто-то прижал к земле: пропадают все ответы разом. На ESP32 у библиотеки есть Wire.reset(), на Uno помогает пара вызовов Wire.end() и Wire.begin() подряд, а в запущенных случаях — десяток тактов SCL руками. Но прежде чем лечить шину, выньте и вставьте провода: в девяти случаях из десяти виноват контакт.
Мои правила записи на шину, выстраданные той самой пустой памятью. Первое: один beginTransmission — одна операция, никаких «дописать потом»: конверт заклеивается целиком. Второе: пауза между кадрами, если слейву нужно переварить полученное, — память 24C02, например, записывает страницу за 5 мс и всё, что придёт раньше, просто проигнорирует; такие паузы строю через millis, как в уроке про замену delay. Третье: код возврата endTransmission — в Serial, всегда. Если на живой плате ok=0 при каждом кадре, отключите от шины всё лишнее и оставьте один модуль — так виновник находится за минуту.
Если сомневаетесь, какой адрес держит ваш модуль памяти или экран, — сверяйтесь с адресной книгой из прошлого урока. А в следующем уроке мы пойдём в обратную сторону: попросим у датчика данные через Wire.requestFrom() и соберём из двух байтов полноценное 16-битное значение — со знаком, без потери минуса и без сюрпризов в буфере.
- Wire.begin() включает шину на A4/A5; begin(0x08) делает плату слейвом; вызывают один раз в setup()
- beginTransmission открывает конверт, write копит байты в буфере на 32 байта, кадр уходит только в endTransmission()
- write() возвращает, сколько байтов принято; ноль значит буфер полон и кадр уедет укороченным
- endTransmission() возвращает 0 при успехе и 2, если на адресе никого; проверка — три строчки, экономящие вечера
- endTransmission(false) держит шину до повторного старта — защита двухходовки на конкурентной шине
- 1. Что окажется на шине после трёх Wire.write(), если забыть вызвать endTransmission()?
- 2. Сколько байтов вмещает буфер передачи Wire на Uno?
- 3. endTransmission() вернул 2. Что это значит?
- 4. Что делает endTransmission(false)?
- 5. Почему в кадр записи EEPROM первым уходит номер ячейки?
Частые вопросы по теме урока
- Что окажется на шине после трёх Wire.write(), если забыть вызвать endTransmission()?
- write() только кладёт байты в буфер; пока endTransmission() не вызван, на линиях A4/A5 ничего не происходит. Ничего: байты останутся в буфере
- Сколько байтов вмещает буфер передачи Wire на Uno?
- Константа BUFFER_LENGTH на Uno равна 32; лишние байты write() молча отбрасывает. 32
- endTransmission() вернул 2. Что это значит?
- Код 2 — нет подтверждения по адресу: модуля нет, нет питания или перепутаны SDA и SCL. Ноль — успех. На адрес никто не ответил
- Что делает endTransmission(false)?
- Аргумент false отменяет STOP: следующая операция начнётся с повторного старта на всё ещё занятой шине. Держит шину за собой до повторного старта
- Почему в кадр записи EEPROM первым уходит номер ячейки?
- 24C02 трактует первый байт после адреса как номер ячейки внутри себя, а все следующие — как данные для записи. Первый байт кадра — указатель внутренней ячейки, дальше идут данные