Логгер погоды в деревне писал три года в один файл и ни разу не подвёл — а потом подвёл так, что я неделю восстанавливал историю: карта отрезала сектор ровно в середине файла, и хвост лога превратился в кашу из нулей. Второго такого вечера я решил не пережидать: теперь каждый логгер пишет строку дважды — в основной файл и в резервную копию. Резерв лежит рядом на той же карте, и это осознанный компромисс: при полном отказе карты он умирает вместе с основным, но от сектора-зомби, битого кластера и кривого выключения спасает полностью.
Схема зеркала по духу близка к двойным слотам настроек в EEPROM — там механику разбирал урок про два слота, и различие я проговариваю сразу: в EEPROM копия нужна на случай обрыва питания посреди записи, здесь — на случай развала файла. От ротации логов по размеру из урока про циклическую запись схема отличается целью: ротация экономит место, зеркало — данные. Запись в два файла через режимы открытия и append-семантику подробно разобрана в уроке про дозапись, здесь добавляется дисциплина двух дескрипторов и флаг деградации.
Два дескриптора или reopen: цена памяти
Наивное решение — держать оба файла открытыми: File data и File backup, писать в оба, close при выключении. На Uno так делать я перестал быстро: каждый открытый файл держит буфер сектора на 512 байт плюс дескрипторы, и два файла съедают порядка килобайта из двух килобайт ОЗУ — вместе со строками, буферами Serial и библиотеками остаётся впритык, а один неудачный String добивает кучу. Рабочая схема на 8-битке — reopen: строка пишется в data, файл закрыт; та же строка пишется в backup, файл закрыт. Сессии по очереди, механика из урока про сессию файла, цена — пара миллисекунд на лишнее открытие, выигранное ОЗУ стоит того.
На ESP32 с его сотнями килобайт памяти два открытых дескриптора живут спокойно, и там держать оба файла открытыми с flush по таймеру даже удобнее: одна сессия на оба файла, меньше ходов по FAT. Выбор между схемами, как всегда, по ресурсам платы и по цене потери: зеркальная строка полезна, только если обе записи дошли, а дисциплину «записал — закрыл» при reopen держит сам порядок вызовов.
Флаг деградации — вторая половина схемы. Когда open(data) вернул false, а open(backup) прошёл, скетч обязан это заметить и сказать вслух: основной файл не открылся, пишу только в резерв. Без флага вы будете молча писаться в один файл и узнаете об этом при скачивании лога — а с флагом LED мигает особым кодом и Serial кричит на каждой строке.
#include <SPI.h>#include <SD.h> const int CS_PIN = 10;const int LED_OK = 12; // горит - оба файла пишутсяconst unsigned long INTERVAL = 60000UL; const char* DATA_NAME = "data.csv";const char* BACKUP_NAME = "backup.csv"; bool mainAlive = true; // основной файл открытint mirrorRow = 0;unsigned long nextAt = 0; bool appendLine(const char* name, const String& line) { File f = SD.open(name, FILE_WRITE); if (!f) return false; f.println(line); f.close(); return true;} void setup() { Serial.begin(9600); pinMode(LED_OK, OUTPUT); if (!SD.begin(CS_PIN)) { Serial.println("карта не поднялась"); while (true) delay(500); } Serial.println("зеркало: data.csv + backup.csv, reopen на каждую строку"); nextAt = millis() + 2000UL;} void loop() { if (millis() < nextAt) return; nextAt += INTERVAL; String line = String(millis() / 1000) + "," + String(analogRead(A0)); bool okMain = appendLine(DATA_NAME, line); bool okBack = appendLine(BACKUP_NAME, line); if (okMain != mainAlive) { // состояние основного изменилось mainAlive = okMain; Serial.print("[зеркало] основной файл "); Serial.println(mainAlive ? "вернулся" : "не открылся - пишу только резерв"); } if (!okMain && okBack) { digitalWrite(LED_OK, LOW); // деградация: LED молчит, резерв пишет Serial.println("[зеркало] деградация: только backup.csv"); } digitalWrite(LED_OK, okMain && okBack ? HIGH : LOW); mirrorRow += 1; if (mirrorRow % 10 == 0) { // сверка размеров раз в 10 строк File d = SD.open(DATA_NAME); File b = SD.open(BACKUP_NAME); if (d && b) { Serial.print("[сверка] data "); Serial.print(d.size()); Serial.print(" байт, backup "); Serial.print(b.size()); Serial.println(" байт"); if (b.size() > d.size() + 512) { Serial.println("[сверка] резерв обогнал основной на сектор - проверьте data.csv"); } } else { Serial.println("[сверка] один из файлов не открылся"); } d.close(); b.close(); }}В модели сценарий отказа прогоняется за десять секунд: нажали кнопку — основной файл сломан, LED погас, но строки продолжают ложиться в резерв, и сверка показывает, как резерв обгоняет основной. Повторное нажатие возвращает файл в строй, и отставание видно прямо в цифрах: столько-то строк прошло мимо основного файла. На плате та же логика живёт в флаге mainAlive и сверке размеров — ни одна строка при этом не теряется.
Восстановление после ремонта — вопрос, который всплывает через неделю и застает врасплох: основной файл починили, а он короче резерва на два дня. Мой порядок такой: скачиваю оба файла на компьютер, строю лог из backup как более длинного, и только потом принимаю решение — оставить резерв основным или склеить. Убирать файлы руками прямо на карте работающему логгеру не стоит: операции rename и remove на карте, по которой идёт запись, — отдельные грабли, о них писал урок про управление файлами. Проще перевести зеркало в спокойный режим, сделать всё на компьютере и вернуть карту на место.
| Схема | ОЗУ на Uno | Потеря при отказе | Когда брать |
|---|---|---|---|
| один файл, close на строку | 512 байт | весь файл при развале кластера | некритичные журналы |
| два открытых файла, flush по таймеру | ~1 КБ + буферы | только хвост после flush | ESP32, потоки от 2 Гц |
| reopen обеих копий на строку | 512 байт по очереди | максимум одна строка | Uno, минутные логи — мой выбор |
| файл + EEPROM-зеркало последней строки | несколько байт | одна строка + маркер | счётчики и статус, не весь лог |
Мой вывод после года с зеркалом: удвоение записи стоит двух секунд работы и пары сотен строк скетча, а возвращает уверенность, которая в одном файле держалась на честном слове. Не нужно зеркалить всё подряд: журналы, которые можно восстановить, и свалки отладки пишутся в один файл и спят спокойно. Зеркало — для тех логов, чья потеря дороже их записи: погода за сезон, счётчики, наработка, любые данные, по которым вы принимаете решения.
И последняя связка: зеркало не заменяет запись-через-flush, а дополняет её. Механику того, что доезжает до карты при провале питания, разбирал урок про sync и питание: reopen с close после каждой строки закрывает вопрос почти полностью, но если вы держите оба файла открытыми на ESP32 — flush обязателен по таймеру, иначе зеркало будет зеркалить данные, которых карта ещё не видела.
- 1. Почему на Uno я пишу в две копии через reopen, а не через два открытых файла?
- 2. open(data.csv) вернул false, backup записался. Что обязан сделать скетч?
- 3. От чего резервная копия на той же карте НЕ спасает?
- Зеркало пишется через reopen по очереди: один открытый файл за раз, ОЗУ Uno цело.
- Флаг деградации обязателен: основной файл молчит — LED и Serial говорят об этом на каждой строке.
- Сверку размеров делайте через f.size() в каталоге карты, а не по счётчику строк в ОЗУ.
- Резерв на той же карте закрывает развал файла, но не смерть носителя: ценные логи выгружайте периодически.
- Восстановление делайте на компьютере: rename/remove на работающей карте — отдельные грабли.
Частые вопросы по теме урока
- Почему на Uno я пишу в две копии через reopen, а не через два открытых файла?
- Два дескриптора с двумя секторными буферами оставляют на куче минимум, и один String добивает память. Reopen держит открытым только один файл за раз. Каждый открытый файл держит буфер 512 байт, два файла съедают ~1 КБ из 2 КБ ОЗУ
- open(data.csv) вернул false, backup записался. Что обязан сделать скетч?
- Без флага вы узнаете о деградации при скачивании лога, через неделю. Флаг с LED-кодом сообщает о режиме сразу — по нему видно, что основной файл надо чинить. Поднять флаг деградации: LED-код и сообщение в Serial на каждой строке
- От чего резервная копия на той же карте НЕ спасает?
- Обе копии живут на одном носителе: смерть контроллера или NAND убивает их вместе. От этого уровня отказов спасает только второй носитель — выгрузка на компьютер. Отказ контроллера карты целиком