GPS-модуль выдавал в Serial строку раз в секунду, и логгер должен был писать её на карту дословно. Проверял я по-простому: Serial Monitor показывает строку — значит, в файле она есть. Через час наблюдений я открыл log.csv и нашёл дырку в полторы минуты: между строкой 342 и строкой 435 — пустота. Ни перезагрузок, ни просадки питания в это время не было, Serial жил своей жизнью, а в файле всё это просто исчезло.
Два вечера я лечил провода и питание, пока не поставил в скетч замер времени: миллис до записи, запись, миллис после. Выяснилось, что каждая запись сектора уводит loop в ступор на десятки миллисекунд, а раз в несколько минут — на сотню с лишним. В этот ступор входящий Serial никто не слушает, буфер приёма переполняется, и строки теряются пачкой. Не плохая карта и не шумные провода — нормальное поведение файловой записи, о котором мой скетч не знал.
Здесь разберём, куда уходит время между println и картой, почему типичные 7-8 мс за сектор иногда превращаются в 100, как посчитать запас своего Serial-буфера и что делать, когда его не хватает. Механику буфера 512 байт и обязательный close() мы уже разбирали в уроке про запись и close — сейчас важнее вторая половина вопроса: что происходит с остальным скетчем в те миллисекунды, пока карта занята.
Пауза на сектор: что происходит между println и картой
Карта физически пишет порциями по 512 байт — секторами. Библиотека SD держит в оперативной памяти буфер ровно такого размера: пока вы печатаете строки, они копируются туда, и карта вообще не трогается. Заполнился буфер — начинается физическая запись сектора, и вот здесь loop встаёт целиком. Типичная запись заполненного сектора через SD.h на Uno занимает 7-8 мс — цифру несложно получить самому замером миллис-ом, и скетч ниже её печатает. Но контроллер карты периодически уходит в собственную работу — выравнивание износа ячеек, перестройка таблиц FAT — и тогда та же запись растягивается до 20-100 мс. Пики редкие: у меня раз в несколько сотен записей, зато каждая стоит пачку строк из входящего потока.
Теперь арифметика потерь. Входящий буфер Serial на Uno — 64 байта. При 9600 бод один байт занимает около миллисекунды, значит запас — примерно 65 мс тишины: если запись сектора уложилась в этот запас, ничего не пропадёт. При 115200 бод тот же буфер заполняется за 5-6 мс — любая запись на карту длиннее пары миллисекунд роняет данные, и спасает только батч. GPS из моей сцены шёл на 9600, но пики по 100 мс перекрывали запас вдвое — отсюда дырка в полторы минуты, собранная из трёх подряд неудачных записей.
Отдельная ловушка — не буфер, а сам факт занятости. Пока идёт запись, скетч не выполняет Serial.read(): строка, пришедшая в середине паузы, будет прочитана только после неё — если вообще влезла в 64 байта. Не влезший хвост теряется целиком. Поэтому писать чаще и мелкими кусочками не помогает: каждая запись — отдельная пауза, и суммарно пауз становится больше, чем при одной крупной. Помогает обратное: писать реже, но крупнее, и подгадывать момент, когда поток молчит. Ровно так же блокирует loop и delay — ниже живая лаборатория с двумя одинаковыми мигалками: левая стоит внутри delay и теряет нажатия, правая крутится на millis и ловит каждое; пощёлкайте кнопками и посчитайте потери.
Блокирующая пауза против неблокирующего таймера: нажмите кнопку и сравните.
Подгружаем интерактив…
Батч и замер: скетч, который сам показывает свои паузы
#include <SPI.h>#include <SD.h> const int chipSelect = 10;const int LINES_PER_BATCH = 6; // строк в пачке unsigned long lastLine = 0;unsigned long startedAt = 0;String batch = ""; // пачка строкint lines = 0; // сколько строк в пачкеunsigned long worst = 0; // худшая запись за сессию void setup() { Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("SD error"); while (true) { } } startedAt = millis(); lastLine = millis();} void loop() { // строка лога раз в секунду — копим в пачку if (millis() - lastLine >= 1000) { lastLine = millis(); unsigned long sec = (millis() - startedAt) / 1000; batch += String(sec) + "," + String(analogRead(A0)) + "\r\n"; lines = lines + 1; } // пачка собрана — одна блокирующая запись вместо шести if (lines >= LINES_PER_BATCH) { unsigned long t0 = millis(); // старт замера File f = SD.open("log.csv", FILE_WRITE); if (f) { f.print(batch); f.close(); } unsigned long dt = millis() - t0; // длительность записи if (dt > worst) { worst = dt; } Serial.print("batch lines="); Serial.print(lines); Serial.print(" dt="); Serial.print(dt); Serial.print(" worst="); Serial.println(worst); batch = ""; lines = 0; }}Смысл скетча — в двух числах, которые он печатает. Первое — dt: длительность последней записи пачки, замеренная миллис-ом до и после open-print-close. На вашей конкретной карте будет своё среднее: у меня на 8 ГБ Class 4 пачка из шести строк укладывается в 12-18 мс. Второе — worst: худшая запись за сессию. Именно worst показывает то, чего не видно в среднем: раз в несколько сотен пачек прилетает 80-110 мс, и теперь я печатаю этот рекорд в Serial каждые десять минут — если он пополз вверх, карта стареет или питание просело.
Пачку из шести строк я выбрал не из теории. Строка лога занимает около 11 байт с переводом строки, шесть строк — 66 байт, а один сектор 512 вмещает примерно 46 таких строк. Собирать батч на весь сектор выгоднее по паузам, но при сбое питания терялась бы уже почти минута данных, а не шесть секунд. Моё правило: размер батча подбирается от цены ошибки. Если потеря минуты ничего не стоит — собирайте сектор целиком, для треков и журналов, где каждая строка на вес золота, оставляю шесть.
| Причина паузы или потери | Как выглядит в логе | Что делать |
|---|---|---|
| Запись сектора блокирует loop: 7-8 мс типично, пики до 100 | Интервалы меток прыгают, входящие строки пропадают | Батч: копить строки и писать одной записью |
| Выравнивание износа у контроллера карты | Раз в сотни записей одна длинная дыра | Замер dt и рекорд worst в Serial — контроль старения |
| RX-буфер Serial 64 байта переполнился за паузу | Теряется хвост пачкой, начало строки — обрыв | Считать запас по своей скорости порта, снижать поток |
| delay() внутри цикла логгера | Ровные одинаковые пропуски меток | Убрать delay, строить цикл на millis |
Честный вывод: писать в паузах
Стратегий на деле три, и все они про одно: не стоять в записи, когда идут данные. Первая — батч, как в скетче выше. Вторая — писать в паузах потока: GPS молчит между строками, датчик опрашивается раз в секунду — в этом окне и надо писать, а не когда удобно программисту. Третья — признать, что читать поток и писать на одной Uno одновременно нельзя, если поток плотнее записи: тогда нужна другая плата. На ESP32 с тем же кодом картина мягче: когда буфер записи растёт с 512 байт до 64 КБ, устойчивая скорость поднимается примерно с 230 до 420 кбит/с — большой запас RAM превращает записи в редкие крупные броски.
И последнее, что я вынес из этой истории: дырки надо ловить не глазами по файлу, а в момент записи. Замер dt и рекорд worst — пять строк кода, зато сразу видно, попадаете вы в запас своего буфера или нет. Если не попадаете — честнее снизить частоту строк и поднять их вес, чем надеяться, что карта обязана успевать. Она успевает ровно настолько, насколько успевает её контроллер, и пик в 100 мс — не её каприз, а её нормальное поведение.
На живой плате повторите замер на своей карте: скетч выше печатает dt и worst, дальше остаётся сопоставить worst с запасом буфера при вашей скорости порта. Заодно вспомните, что пауза на сектор тормозит весь loop: обновления дисплея, опрос кнопок и мигание индикатором в этот момент замирают вместе с приёмом Serial. Формат строк и шапку CSV заведите сразу — как в уроке про CSV-лог и чтение конфига, — тогда сравнение двух режимов записи делается по одному файлу. Каркас цикла на millis, без единого delay, берите из урока про millis вместо delay, а входящий поток ловите тем же кодом, что и в уроке про available и read. Если карта в принципе не стартует, сначала диагностика SD.begin — скорость имеет смысл мерить, только когда запись вообще работает.
- 1. Сколько занимает типичная запись заполненного сектора 512 байт на Uno?
- 2. Плата пишет сектор 80 мс, данные идут по Serial на 9600 бод. Что станет с пришедшими за паузу строками?
- 3. Во сколько раз запас входящего буфера на 115200 бод меньше, чем на 9600?
- 4. Зачем копить строки в String и писать пачкой раз в шесть строк?
- 5. Поток плотный, пауз в нём нет. Как ещё можно сберечь строки?
- Печать в файл копит строки в буфере 512 байт; раз в сектор loop встаёт на запись: типично 7-8 мс, пики до 100
- RX-буфер Serial на Uno — 64 байта: на 9600 бод запас около 65 мс, на 115200 — 5-6 мс
- Во время записи Serial не читается: всё сверх буфера теряется пачкой — файл проверяйте чтением, не монитором
- Батч из 4-8 строк режет число пауз в разы; длительность записи меряйте millis-ом и храните рекорд worst
- Честный вывод: писать в паузах потока и не ждать, что карта обязана успевать, — пик 100 мс у неё в норме поведения
Частые вопросы по теме урока
- Сколько занимает типичная запись заполненного сектора 512 байт на Uno?
- Полный сектор через SD.h пишется за 7-8 мс; контроллер карты иногда уходит в выравнивание износа, и тогда запись растягивается до 20-100 мс. 7-8 мс, с редкими пиками до 100 мс
- Плата пишет сектор 80 мс, данные идут по Serial на 9600 бод. Что станет с пришедшими за паузу строками?
- Буфер приёма на Uno — 64 байта, это около 65 мс на 9600 бод. Всё, что пришло за более длинную паузу сверх этого, теряется. Влезут первые 64 байта, остальное потеряется
- Во сколько раз запас входящего буфера на 115200 бод меньше, чем на 9600?
- Скорость порта выросла в 12 раз, буфер тот же: 64 байта при 115200 заполняются за 5-6 мс. Любая запись на карту длиннее — потеря. В 12 раз — примерно 5-6 мс вместо 65
- Зачем копить строки в String и писать пачкой раз в шесть строк?
- Шесть коротких записей — это шесть пауз по 7-100 мс; одна крупная — одна пауза. Чем меньше пауз, тем меньше окон, где буфер приёма переполняется. Батч снижает число блокирующих пауз в loop
- Поток плотный, пауз в нём нет. Как ещё можно сберечь строки?
- Чем крупнее батч, тем реже паузы и тем больше строк успевает прийти между ними. Частоту строк снижать не обязательно — но растёт цена сбоя питания. Увеличить батч и писать одной крупной записью