Дневник дежурств в серверной шкафу я вёл во внешнем 24C256, подключённом в прошлом уроке: дата, время, температура — тридцать два байта на запись, аккуратный блок. Схема была простая: пишу с адреса, где остановился в прошлый раз, двигаю указатель дальше. Первые недели всё было идеально, и я даже похвастался в отчёте, что журнал «сам себя ведёт». В какой-то вечер вместо новых записей я обнаружил в начале памяти мусор, затёрший magic-байт, — хотя по адресу, куда писал, всё лежало красиво. Полчаса я искал промах в собственном коде, пока не открыл даташит на раздел с названием page write.
Ловушка называется data rollover. Если пакет записи пересекает границу 64-байтной страницы, чип не переходит на следующую страницу — адрес заворачивается в начало текущей. Я начинал блок с адреса 60: первые четыре байта легли в ячейки 60–63, остальные шестнадцать затёрли ячейки 0–15 той же страницы — вместе с magic-байтом настроек. Чип работал ровно по документации, а данные выглядели правдоподобно, потому что перезаписались прошлогодними.
В этом уроке разберём обе скорости записи: побайтовую, где каждый байт тянет собственный цикл записи, и страничную, где 64 байта укладываются в один пакет. Соберём обёртки writePage и readBytes, которые режут данные по границам страниц сами, и посчитаем выигрыш в миллисекундах — он крупнее, чем кажется.
Для симулятора — та же честная оговорка: 24C256 движок не эмулирует, поэтому сим-блок моделирует чип массивом и функциями-обёртками. Зато в модели видны обе новости: предупреждение при пересечении границы страницы и пауза после записи, отмеренная по millis, — механика один в один с настоящим чипом.
Почему адрес заворачивается: буфер на 64 байта
Внутри 24C256 стоит буфер на 64 байта — та самая страница. Во время приёма пакета мастер заполняет этот буфер, а по команде stop содержимое переписывается в ячейки. Адрес внутри страницы хранится шестью битами и крутится по кругу: 63 плюс один — снова ноль. Поэтому запись, начатая с адреса 60, после четвёртого байта продолжит в ячейки 0, 1, 2 — той же страницы, а не в 64-ю. Это не дефект: даташит честно описывает поведение разделом data rollover, просто читать даташиты мы почему-то любим после потери данных.
Размер страницы — свойство конкретного семейства, и он разный: у 24C256 — 64 байта, у 24C32 и 24C64 — 32, у 24C512 — 128. Весь объём 24C256 делится на 512 страниц по 64. Механика заворота прячется в младших шести битах адреса: они выбирают ячейку внутри страницы и они же крутятся по модулю 64. Старшие биты адреса при приёме пакета не трогаются — вот почему переход через границу не уводит на соседнюю страницу.
Чтение устроено иначе и нам на руку: внутренний указатель чипа при чтении инкрементируется по всему объёму 0…32767 без всяких страниц, поэтому весь чип читается одним потоком — хоть все 32 КБ подряд, без единого лишнего байта адреса. Единственная граница — со стороны Arduino: библиотека Wire на Uno держит буфер в 32 байта, и requestFrom больше 32 за раз не вернёт. Читайте кусками по 32 — или меньше, об этом отдельно ниже. К слову, те же 32 байта — потолок и для передачи на другие платы по I2C из урока про master и slave: это лимит контроллера, а не чипа памяти.
#include <Wire.h> const byte CHIP = 0x50; // адрес чипаconst int PAGE = 64; // размер страницы 24C256 void eeWriteByte(int addr, byte val) { Wire.beginTransmission(CHIP); Wire.write(addr / 256); Wire.write(addr % 256); Wire.write(val); Wire.endTransmission(); delay(5);} // страничная запись: режет блок по границам страниц самаvoid eeWritePage(int addr, const byte *buf, int n) { while (n > 0) { int chunk = PAGE - (addr % PAGE); // сколько до конца страницы if (chunk > n) { chunk = n; } Wire.beginTransmission(CHIP); Wire.write(addr / 256); Wire.write(addr % 256); for (int i = 0; i < chunk; i++) { Wire.write(buf[i]); } Wire.endTransmission(); delay(5); // один цикл записи на каждый пакет addr = addr + chunk; buf = buf + chunk; n = n - chunk; }} // последовательное чтение: указатель чипа идёт по объёму самvoid eeReadBytes(int addr, byte *buf, int n) { Wire.beginTransmission(CHIP); Wire.write(addr / 256); Wire.write(addr % 256); Wire.endTransmission(); while (n > 0) { int chunk = n; if (chunk > 32) { // буфер Wire на Uno - 32 байта chunk = 32; } Wire.requestFrom(CHIP, chunk); while (Wire.available() > 0) { *buf = Wire.read(); buf = buf + 1; n = n - 1; } }} void setup() { Serial.begin(9600); Wire.begin(); byte out[100]; for (int i = 0; i < 100; i++) { out[i] = i; } long t0 = millis(); eeWritePage(50, out, 100); // 50..149: три пакета вместо ста записей Serial.print("write ms="); Serial.println(millis() - t0); byte in[100]; eeReadBytes(50, in, 100); // одним потоком Serial.print("check in0="); Serial.print(in[0]); Serial.print(" in63="); Serial.print(in[63]); Serial.print(" in99="); Serial.println(in[99]);} void loop() { delay(100);}Считаем выгоду в миллисекундах, потому что на цифрах выбор очевиден. Побайтовая запись полной страницы — это 64 отдельные транзакции Wire и 64 цикла записи: примерно 320 мс занятого времени, почти треть секунды на каждые 64 байта. Страничная запись той же страницы — одна транзакция и один цикл до 5 мс. Выигрыш — в шестьдесят четыре раза, и блок из скетча это подтверждает: сотня байт с адреса 50 ложится тремя пакетами за 15 мс — 14 байт до границы страницы, потом целая страница и хвост из 22 байт. Для журнала, который пишет раз в минуту, разница между 320 и 15 мс не заметна; для записи по кнопке, когда пользователь ждёт ответа, — очень даже заметна.
Побайтовая запись при этом не плоха — она просто про другое. Пара настроек, изменившееся значение счётчика, флаг режима: по одному байту за раз, код в три строки, никаких границ и размеров страниц. Страничная окупается от десятков байт: массивы, расписания, дампы, журналы. Моё правило с границей: если данных за раз больше восьми байт или блок живёт на пересечении страниц — сразу обёртка с резкой, не изобретайте её по второму кругу в каждом скетче.
И про паузу. delay(5) после каждого пакета надёжен, но при длинной записи процессор простаивает зря. Точный вариант — ACK-опрос: пока чип занят, он не подтверждает свой адрес, и endTransmission возвращает код без нуля; шлём обращения раз в миллисекунду, пока не получим ноль. Типовой чип отвечает раньше паспортных 5 мс — внутренний цикл укладывается в 3–4. Если вы держите планировщик на millis без delay из урока про millis вместо delay, ACK-опрос встраивается в него без единой блокировки loop.
| Способ записи 64 байт | Транзакций Wire | Циклов записи | Время | Главный риск |
|---|---|---|---|---|
| Побайтово: 64 вызова write | 64 | 64 | около 320 мс | медленно; каждый байт расходует цикл |
| Одним пакетом внутри страницы | 1 | 1 | до 5 мс | заворот адреса, если пакет пересёк границу |
| Обёрткой writePage с резкой | 2–3 | по числу страниц | 5–15 мс | надо помнить размер страницы своего чипа |
| Чтение readBytes потоком | 1–2 | 0 | доли мс на байт | буфер Wire: кусками по 32 байта на Uno |
Выравнивание как дизайн-решение
Самый дешёвый способ никогда не встретить заворот — проектировать карту памяти по страницам. Я держу блок настроек с адреса 0, журнал с 64, шаблоны расписаний с 128: каждый блок начинается на границе страницы, и резка в writePage всегда даёт целые пакеты без пустых половинок. Обратная цена — дырки: блок занял 40 байт, до 64 свободно. При 32768 байтах такие дырки ничего не стоят, а предсказуемость стоит много — особенно когда карту памяти вы открываете раз в полгода и должны с одного взгляда понимать, что где лежит.
Update-семантика работает и здесь, только руками: перед страничной записью читаем старую страницу, сравниваем, и если ни один байт не изменился — не пишем вовсе. Это принцип EEPROM.update, развёрнутый на блоки: циклы записи чип расходует страницами, и лишняя страница за 5 мс не страшна, но лишняя тысяча страниц за месяц — уже износ, который мы считали в уроке про ресурс. Сравнение перед записью стоит доли миллисекунды — дешевле любой экономии.
Многобайтовые значения внутри блока раскладывайте по тем же правилам, что и во внутреннем EEPROM: int — два байта, float — четыре, порядок байтов зафиксирован в карте памяти. Приёмы из урока про побайтовую запись переносятся на внешний чип без изменений — меняется только нижний слой: вместо EEPROM.update функция eeWriteByte с двумя байтами адреса и паузой после записи. Один слой абстракции — и весь старый опыт работает на новом железе.
- Страница 24C256 — 64 байта; адрес внутри страницы крутится по модулю 64: пакет через границу заворачивает в начало той же страницы — это data rollover
- Страничная запись: 64 байта за один цикл записи до 5 мс против 64 циклов и около 320 мс побайтово — выигрыш в 64 раза
- Чтение страниц не знает: указатель идёт по всему объёму, но requestFrom не больше 32 байт за раз — буфер Wire на Uno
- Обёртка writePage режет блок по границам сама; выравнивайте блоки данных по 64 — и резка всегда даст целые пакеты
- Пауза после пакета: delay(5) или ACK-опрос через endTransmission — типовой чип отвечает за 3–4 мс
- Перед страничной записью сравнивайте со старым содержимым: неизменившуюся страницу не писать — update-семантика руками
- 1. Запись 20 байт начата с адреса 60. Куда лягут последние 16 байт?
- 2. Сколько циклов записи потратит побайтовая запись одной полной страницы?
- 3. Почему чтение можно вести одним потоком через весь чип?
- 4. Вы попросили requestFrom(0x50, 64), но данных пришло меньше. Сколько максимум вернёт Wire на Uno?
- 5. Как узнать, что чип закончил внутренний цикл записи, без слепого delay?
Страницы дают скорость, а скорость даёт соблазн: если блок пишется за 5 мс, грех не сохранять конфиг при каждом изменении. И тут наступает худший сценарий внешней памяти — питание пропало на третьей миллисекунде записи. Половина блока новая, половина старая, выглядит это как валидная страница, и прибор честно начнёт жить по гибриду. Что дают защита двумя копиями, счётчик версий и контрольная сумма — и как построить восстановление при старте, разберём в финальном уроке.
Частые вопросы по теме урока
- Запись 20 байт начата с адреса 60. Куда лягут последние 16 байт?
- Адрес внутри страницы хранится шестью битами и заворачивает в начало текущей страницы. Это data rollover — паспортное поведение чипа, а не дефект. В начало той же страницы: ячейки 0–15
- Сколько циклов записи потратит побайтовая запись одной полной страницы?
- Каждый байт — отдельный цикл записи со своей паузой до 5 мс. Страничная запись расходует один цикл на всю страницу — отсюда выигрыш в 64 раза. Шестьдесят четыре
- Почему чтение можно вести одним потоком через весь чип?
- Внутренний указатель при чтении инкрементируется по всему объёму 0…32767. Страничный заворот касается только приёма пакета записи. Страницы существуют только при записи
- Вы попросили requestFrom(0x50, 64), но данных пришло меньше. Сколько максимум вернёт Wire на Uno?
- Буфер библиотеки Wire на Uno — 32 байта, всё сверх молча отбрасывается. Читайте кусками и сверяйтесь с возвращаемым числом байт. 32
- Как узнать, что чип закончил внутренний цикл записи, без слепого delay?
- Занятый чип не отвечает на шине: endTransmission вернёт ненулевой код. Первый ноль означает, что внутренний цикл записи закончился. ACK-опрос: endTransmission возвращает 0, когда чип снова подтверждает адрес