Бегущая строка на I2C-матрице едва не поссорила меня с заказчиком. Я отправлял на дисплей название трека — строку в 38 символов с пробелами — и на экране честно появлялись первые 32, дальше обрыв. Меньше половины секунды ушло бы на то, чтобы посчитать, но я неделями подозревал экран в кривой прошивке, перешивал его, менял шлейф и уже был готов списать всё на брак. Заказчик тем временем вежливо спрашивал в третьем письме, когда же название трека станет видно целиком. А потом распечатал строку в Serial прямо перед отправкой — там были все 38. Значит, резало не устройство, а что-то по дороге.
Резал, конечно, не дорога, а мой собственный Arduino. В библиотеке Wire AVR-ядро держит константу BUFFER_LENGTH, и она равна 32 байтам: всё, что вы скормили Wire.write сверх этой цифры, молча выбрасывается на пол. Ни сообщения в Serial, ни кода возврата — тишина. endTransmission отработает штатно и вернёт ноль, потому что с точки зрения шины всё прошло хорошо: она передала ровно столько, сколько накопил буфер. Это самая тихая грабля раздела: не крашится, не зависает, просто аккуратно съедает хвост.
В этом уроке найдём эту константу в живом коде, посчитаем, сколько байтов остаётся на данные после служебных, и разберём три способа не терять хвост: фрагментацию чанками, протокол с подтверждением и чтение регистров кусками. Плюс отдельная засада, о которой молчат даже хорошие туториалы: байт адреса регистра при записи в датчик тоже ест буфер. Потолок этот двусторонний — он режет и запись, и чтение, и обе беды лечатся одинаково.
BUFFER_LENGTH: тихий потолок
Где живёт потолок физически: в заголовочном файле Wire.h AVR-ядра объявлена константа BUFFER_LENGTH со значением 32. Wire.write складывает ваши байты в этот буфер и возвращает количество реально принятых — но в типовом коде возврат никто не смотрит, и компилятор не ругается: значение-то используется, просто молча. Анатомию транзакции — beginTransmission, write, endTransmission — мы разбирали в уроке про begin и beginTransmission; буфер сидит именно между write и выходом на шину. На других ядрах цифра другая: у ESP32 константа называется I2C_BUFFER_LENGTH и равна 128 байтам, ARM-платы держат свои объёмы. Правило одно: не верьте «должно влезть» — откройте Wire.h своего ядра, найдите константу и пишите код от неё, а не от оптимизма.
Почему эту граблю так трудно поймать: усечение не оставляет следов в возвратах. endTransmission возвращает код результата обмена — ноль для успеха и единицу-четвёрку для проблем с адресом и арбитражем, — но про переполнение буфера в этих кодах ни слова; полный разбор кодов был в уроке про коды endTransmission. Мой бегущий экран неделю был «бракованным» именно поэтому: обе стороны считали обмен успешным, потому что с их стороны он и был успешным. Самая надёжная диагностика — печатать длину того, что отправили, и длину того, что дошло, в одной строке.
| Сценарий записи | Служебные байты | Остаётся на данные |
|---|---|---|
| только данные (слейв-писарь) | 0 | все 32 |
| адрес регистра плюс данные | 1 | 31 |
| EEPROM: старший и младший байт адреса плюс данные | 2 | 30 |
| наивная отправка 40 байт | — | дошло 32, пропало 8 молча |
Чиним: чанки, протокол, чтение кусками
Способ первый — фрагментация. Режете блок на куски по 30 байт: два байта запаса уходят под служебные — смещение или признак продолжения, — и каждый кусок уезжает отдельной транзакцией. Скетч-демонстратор ниже делает это на глазах: сначала отправляет 40 байт наивно и печатает вердикт, потом раскладывает тот же блок чанками и собирает квитанции. Число 30 я записываю константой с комментарием «32 минус запас» — тогда перенос кода на ESP32 превращается в правку одной строки. Каждый кусок помечается смещением в первом байте: получатель кладёт данные куда сказано и всегда может пересобрать порядок, даже если чанки уехали с паузами и вразнобой.
Способ второй — протокол с подтверждением. Чанки сами по себе не спасают от потери целого куска: шина могла моргнуть посреди транзакции. Поэтому каждая посылка нумеруется, а получатель отвечает квитанцией — номер принятого куска и контрольная сумма; схема старая как мир и уже разобранная в уроке про контрольную сумму. Если получатель — вторая Arduino, квитанции удобнее всего возвращать по тому же I2C: механику мастера и слейва мы прогоняли в уроке про обмен двух плат. Квитанция стоит дёшево — байт номера и байт суммы, а ловит и потерянный, и продублированный кусок.
Способ третий — читать тоже кусками. Потолок двусторонний: requestFrom складывает ответ слейва в тот же буфер, и больше 32 байтов за один запрос вы не увидите. Двухходовка «указатель плюс чтение» из урока про чтение регистров остаётся той же, меняется только длина запроса. Мой логгер на EEPROM-чипе 24LC256 читался строго страницами: курсор двигался по 32 байта, между запросами — пауза, внутри страницы чип сам прибавляет адрес. И отдельное правило для датчиков: если вы пишете в регистр, помните, что байт номера регистра идёт первым в транзакции и тоже ест буфер — на данные остаётся 31. Классическая вылетная грабля: «я передаю 32 байта конфигурации» при записи в регистр — а доезжает 31, и теряется всегда последний.
#include <Wire.h> // Мастер: блок в 40 байт уезжает на слейв-писаря 0x08 чанками по 30.// Каждый чанк: [смещение][данные...]. Слейв кладёт данные по смещению. const byte SLAVE = 0x08;const byte VSEGO = 40;const byte CHANK = 30; // 32 минус запас под смещениеbyte dannye[VSEGO]; void sobratBlok() { for (byte i = 0; i < VSEGO; i++) dannye[i] = i * 5; // тестовое содержимое} void poslatBlok() { byte smesh = 0; while (smesh < VSEGO) { byte kusok = VSEGO - smesh; if (kusok > CHANK) kusok = CHANK; Wire.beginTransmission(SLAVE); Wire.write(smesh); // заголовок: куда класть for (byte i = 0; i < kusok; i++) Wire.write(dannye[smesh + i]); byte kod = Wire.endTransmission(); // 0 — чанк ушёл Serial.print("chank ot="); Serial.print(smesh); Serial.print(" bayt="); Serial.print(kusok); Serial.print(" kod="); Serial.println(kod); smesh += kusok; delay(20); // слейву время переварить }} void setup() { Wire.begin(); Serial.begin(9600); sobratBlok();} void loop() { Serial.println("--- blok 40 bayt chankami po 30 ---"); poslatBlok(); delay(3000);}Как выглядит получатель. На стороне слейва та же арифметика: обработчик onReceive срабатывает один раз на транзакцию и получает не больше 32 байтов, каких бы ожиданий вы ни питали. Первый байт — смещение, остальные — данные: копируете их в свой длинный буфер по адресу из заголовка и считаете принятые. Квитанцию удобно отдавать в обратную сторону через onRequest — мастер сам придёт за ней после каждого куска. Собирать блок по смещениям, а не по порядку прихода, — не паранойя: при длинных паузах между чанками мастер может переслать кусок заново, и тогда получатель просто затирает старое новым, не задавая вопросов. Вся эта бухгалтерия — десяток строк, зато блок и в 40, и в 400 байтов перестаёт зависеть от капризов одной транзакции.
Что здесь честно, а что нет. Настоящего усечения в движке нет — модель буфера написана внутри скетча, и цифры «дошло 32, пропало 8» посчитаны той же логикой, которой живёт AVR-ядро. Панель шины пустая: живой транзакционный обмен со слейвом показан в code-блоке и заработает на настоящих платах без правок. Зато сам рефлекс тренируется точно: разделить блок на куски, посчитать служебные байты и не верить молчанию шины. Модель считает так же, как AVR-ядро: приёмник наполняется до потолка, а лишнее даже не доходит до очереди отправки.
Привычка, которую я вынес из истории с бегущей строкой: в каждом проекте с I2C-обменом заводится константа CHANK с запасом и печатается счётчик отправленных против принятых байтов — одна строка в Serial, зато усечение видно в первую минуту, а не на третий день. И раз уж шина у вас теперь гоняет длинные блоки, проверьте, чем её дышит вся остальная система: правило «30 данных плюс запас» помогает и там, где полдюжины устройств живут на двух проводах. Проверьте себя на живой плате: отправьте соседней Arduino блок ровно в 32 байта и в 33 — в первом случае квитанция сойдётся, во втором молча потеряется последний байт, и вы своими глазами увидите эту граблю.
- BUFFER_LENGTH в Wire.h AVR-ядра — 32 байта: Wire.write молча выбрасывает всё сверх, endTransmission возвращает 0
- Усечение не видно в кодах возврата — печатайте отправленное против принятого в одной строке
- Байт адреса регистра тоже ест буфер: на данные остаётся 31; EEPROM с двухбайтным адресом — 30
- Чинится фрагментацией чанками по 30, протоколом с нумерацией и квитанциями и чтением регистров страницами
- requestFrom упирается в тот же буфер: длинные ответы слейва тяните кусками с движущимся курсором
- 1. Сколько байт вмещает буфер Wire на Uno, Nano и Mega?
- 2. Что вернёт endTransmission, если вы скормили Wire.write 40 байтов при буфере 32?
- 3. Вы пишете в датчик: сначала байт номера регистра, потом 32 байта данных. Что доедет?
- 4. Какой размер куска данных я советую при записи «регистр плюс данные» на AVR?
- 5. Как быстрее всего поймать усечение в живом проекте?
Частые вопросы по теме урока
- Сколько байт вмещает буфер Wire на Uno, Nano и Mega?
- Константа BUFFER_LENGTH в Wire.h AVR-ядра равна 32. На ESP32 потолок другой — I2C_BUFFER_LENGTH 128, поэтому проверяйте своё ядро. 32
- Что вернёт endTransmission, если вы скормили Wire.write 40 байтов при буфере 32?
- Коды endTransmission описывают проблемы адреса, арбитража и NACK, но не переполнение буфера. Молчание — главная опасность этой грабли. ноль — усечение ему не видно, шина передала то, что накопил буфер
- Вы пишете в датчик: сначала байт номера регистра, потом 32 байта данных. Что доедет?
- Буфер общий на всю транзакцию: байт регистра плюс 32 данных = 33 при потолке 32. Теряется всегда последний байт данных. 31 байт данных: адрес регистра съел один слот буфера
- Какой размер куска данных я советую при записи «регистр плюс данные» на AVR?
- Чанк 30 оставляет два слота: один под номер регистра, второй — страховка. Перенос на платформу с другим буфером — правка одной константы. 30 байтов — с запасом под служебные
- Как быстрее всего поймать усечение в живом проекте?
- Одна строка «отправлено против принято» показывает пропавший хвост в первую минуту. Коды возврата усечение не отражают, частота ни при чём. печатать в Serial число отправленных и число принятых байтов одной строкой