Метео-будка на вышке закрыта четырьмя винтами, герметизирована по периметру и стоит в сорока минутах езды. Логгер в ней писал строку раз в секунду — около 12 байт с переводом строки, примерно мегабайт в сутки. Карта на 4 ГБ по байтам должна была жить годами, но хвосты недозаполненных кластеров на множестве мелких файлов съели её за девять месяцев, и скетч начал молча терять записи. В итоге приехала команда из двух человек с картридером и терпением: полдня на дорогу, двадцать минут на вскрытие — ради удаления файлов, которые мог бы снести сам скетч одной строкой SD.remove().
После той поездки в каждый мой логгер входит маленький блок самообслуживания: посмотреть оглавление — этому был посвящён предыдущий урок, снести лишнее, переименовать ценное. Здесь разберём весь набор операций: remove, exists, rmdir, rename — и главное, как не удалить не то. Удаление с карты необратимо: корзины в FAT не существует, поэтому добрая половина урока про защиту от собственных пальцев.
Полный набор: remove, exists, rename
Набор короткий. SD.remove(path) удаляет файл навсегда и возвращает bool: false означает, что файла с таким именем нет, — молча, без сообщений в Serial. SD.exists(path) отвечает true или false, и та же функция работает для папок. SD.rmdir(path) сносит папку, но только пустую — с содержимым она откажется. myFile.rename(новое-имя) переименовывает файл через открытый дескриптор и тоже возвращает bool: false, если целевое имя уже занято.
Две оговорки. Rename в библиотеке SD появился не сразу: на старой версии компилятор скажет, что у класса File нет такого метода, — обновите библиотеку через менеджер и перезалейте скетч. И remove папки не трогает: для папок есть rmdir, а «удалить со всем содержимым» из скетча нельзя ни одной командой — и слава богу, ниже объясню, почему это удачное ограничение.
По цене операций есть приятный факт: rename и remove трогают только запись каталога — 32 байта служебной структуры, а данные в кластерах не копируются и не стираются. Поэтому переименование двухсотмегабайтного лога занимает миллисекунды, а не минуты чтения-записи. Отсюда же и окно восстановления после remove: содержимое физически ещё на карте, просто каталог больше не знает, что оно чьё-то, — и любой новый лог может лечь поверх этих секторов.
Отдельная история — пути. remove с именем без слэшей ищет файл в корне: если ваш лог лежит в папке, путь обязателен целиком, как в open. Я потерял десять минут на том, что удалял logs/run.log, указав в строке только имя файла, — лог спокойно жил в подпапке, remove возвращал false, а я уже подозревал карту. Правило то же, что и у open: путь либо полный, либо ничего, и проверка SD.exists перед удалением — не паранойя, а способ увидеть в Serial, что вы вообще по тому адресу ищете.
| Вызов | Что делает | Когда вернёт false |
|---|---|---|
| SD.remove(path) | удаляет файл навсегда | файла нет или путь ведёт в папку |
| SD.rmdir(path) | удаляет пустую папку | папка не пуста или не существует |
| SD.exists(path) | спрашивает наличие файла или папки | нет такого имени |
| f.rename(path) | переименовывает через дескриптор File | целевое имя уже занято |
| SD.open(name, FILE_WRITE) + close | создаёт пустой файл | нет места, карта не смонтирована |
#include <SPI.h>#include <SD.h> const int chipSelect = 10; void setup() { Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("SD error"); while (true) { } } // 1. Снести прошлый бэкап, если он есть if (SD.exists("run.old")) { SD.remove("run.old"); Serial.println("stary run.old snesen"); } // 2. Текущий лог переименовать в бэкап if (SD.exists("run.log")) { File f = SD.open("run.log", FILE_READ); if (f) { bool ok = f.rename("run.old"); Serial.print("rename ok="); Serial.println(ok); f.close(); } } // 3. Создать пустой файл нового запуска File fresh = SD.open("run.log", FILE_WRITE); if (fresh) { fresh.println("start"); fresh.close(); } else { Serial.println("run.log ne sozdalsya"); }} void loop() { }Скетч делает три обращения к карте строго по смыслу: проверить, переименовать, создать. Порядок важен. Сначала SD.exists — чтобы не гонять remove в пустоту. Потом rename текущего лога в run.old — и до создания нового, чтобы новый не успел написать ни строки. И только после этого SD.open("run.log", FILE_WRITE) создаёт файл с нуля: FILE_WRITE при отсутствии имени создаёт, а close сразу после печати фиксирует запись в каталоге — механику разбирал в уроке про запись и close. Закроете дескриптор сразу — получите честный пустой файл нового запуска, с датой в каталоге и без данных.
Создание с нуля вместо перезаписи — тот самый единственный честный способ затереть файл, к которому мы пришли в уроке про режимы File: через remove. Здесь он же, но с запасной стропой: rename убирает старый лог в run.old вместо немедленного удаления. Если новый запуск начался с браком — старый лог ещё на карте, и откат стоит одного rename обратно.
Пустые файлы — не только побочный эффект. Ими я пользуюсь как флагами: скетч создаёт boot.ok после успешной инициализации датчиков, а следующая программа по SD.exists с этим именем решает, доверять ли предыдущему запуску. Тот же приём работает как семафор между двумя платами на одной карте: первая дописала данные и создала ready.flg, вторая видит флаг — читает. Дешевле любой файловой блокировки, и на FAT с её одним дескриптором на имя это единственная честная координация.
Теперь тупик, в который я влез с самоочисткой. Логика была простая: суммирую размеры файлов, перевалило за лимит — удаляю самый старый. Счётчик объявил int: на Uno это 16 бит, потолок 32767. Сумма моих байтов переполнила его к третьему дню, счётчик уехал в минус, и скетч, решив, что лимит превышен, снёс самый свежий лог — единственный, который был ещё нужен. Итог: размеры суммируем только в unsigned long, а жертву выбираем по дате в имени файла, а не по позиции в списке.
Кнопка-защита: удаление в две ступени
#include <SPI.h>#include <SD.h> const int chipSelect = 10;const int btnPin = 2;const int guardPin = 3;const int ledPin = 12;int prevBtn = HIGH; void setup() { pinMode(btnPin, INPUT_PULLUP); pinMode(guardPin, INPUT); pinMode(ledPin, OUTPUT); Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("SD error"); while (true) { } } Serial.println("Готов: кнопка = чистка по префиксу d24");} void loop() { int btn = digitalRead(btnPin); if (btn == LOW && prevBtn == HIGH) { if (digitalRead(guardPin) == LOW) { Serial.println("ПРЕДОХРАНИТЕЛЬ: чистка не подтверждена, ничего не делаю"); } else { int removed = 0; File dir = SD.open("/"); File f = dir.openNextFile(); while (f) { if (!f.isDirectory() && String(f.name()).startsWith("d24")) { Serial.print("remove "); Serial.println(f.name()); f.close(); SD.remove("/" + String(f.name())); // имя напечатано ДО удаления removed++; } else { f.close(); } f = dir.openNextFile(); } dir.close(); Serial.print("removed="); Serial.println(removed); digitalWrite(ledPin, HIGH); delay(150); digitalWrite(ledPin, LOW); } } prevBtn = btn;}Когда операции с файлами доступны с кнопки на корпусе, я собираю их в две ступени. Тумблер D3 — предохранитель: пока он выключен, кнопка D2 только печатает в Serial, что она сделала бы. Тумблер включён — та же кнопка выполняет операцию по-настоящему: светодиод вспыхивает на удачную операцию, зуммер при отказе коротко пищит. Две ступени стоят один digitalRead, а берегут от соседа, который «просто нажал посмотреть», и от себя через месяц, когда забыл, что кнопка вообще что-то удаляет. Про разницу кнопки и тумблера — урок про кнопку и toggle: у кнопки состояние живёт пока палец, у тумблера — пока его не переключили.
В скетче выше обратите внимание на порядок внутри цикла: имя печатается в Serial до вызова remove, и только потом файл сносится. Это моя страховка на случай, когда префикс задан шире, чем я думал: d24 ловит и d2401.log, и d2401 — файл конфигурации, если я забыл про него. Альтернатива тумблеру — удержание кнопки три секунды с обратным отсчётом в Serial, но оно съедает delay или таймер, и на устройствах, где кнопкой пользуется кто-то ещё, я всё равно ставлю физический тумблер: его видно с метра, а удержание — нет.
Чистить я учу скетч по списку, а не по счётчику: сначала печатается оглавление из предыдущего урока, потом отбираются кандидаты по префиксу и возрасту. Возраст без часов на плате — это дни аптайма, каркас для них даёт схема с millis; с настоящими часами дата в имени берётся из логгера со штампами времени. А половину потребности в удалении снимает структура: когда логи лежат по папкам-дням, чистка — это снос старых папок по списку, а не сортировка плоского корня: за один прогон каталога — десяток старых папок, каждая — пачка remove и один rmdir. Как завести папки и не упереться в лимиты FAT — следующий урок про mkdir. В симуляторе ниже — модель: каталог живёт массивом строк, но логика предохранителя и порядок «проверить — создать — переименовать — удалить» те же, что в скетчах выше.
- 1. SD.remove("log.csv") вернул false. О чём это говорит?
- 2. Что вернёт f.rename("run.old"), если файл run.old уже существует?
- 3. Как из скетча создать пустой файл?
- 4. Мой самоочиститель снёс свежий лог вместо старого. Что сломалось?
- 5. Зачем тумблер-предохранитель перед кнопкой удаления?
- SD.remove удаляет файл навсегда, корзины нет; false означает, что файла нет или путь ведёт в папку — проверяйте SD.exists до вызова
- SD.rmdir берёт только пустую папку; снести папку с содержимым из скетча нельзя ни одной командой — и это хорошая новость
- rename — метод дескриптора File, false на занятом имени: сначала снести прошлый .bak, потом переименовать, потом создать новый
- Пустой файл — SD.open(name, FILE_WRITE) и сразу close: запись в каталоге фиксируется только close
- Размеры суммируем в unsigned long (int переполняется после 32767), имя жертвы печатаем в Serial до remove, удаление с кнопки — только через тумблер-предохранитель
Частые вопросы по теме урока
- SD.remove("log.csv") вернул false. О чём это говорит?
- remove молчит: bool false означает отсутствие файла или папку по пути. Проверяйте намерение через SD.exists до вызова. Файла с таким именем нет — или путь ведёт в папку
- Что вернёт f.rename("run.old"), если файл run.old уже существует?
- rename возвращает false на занятом имени. Отсюда порядок ротации: сначала снести прошлый run.old через remove, потом переименовать. false: целевое имя занято
- Как из скетча создать пустой файл?
- FILE_WRITE при отсутствии имени создаёт файл, close сразу после — фиксирует пустой файл в каталоге. Именно так выглядит честная перезапись. SD.open(name, FILE_WRITE) и сразу close()
- Мой самоочиститель снёс свежий лог вместо старого. Что сломалось?
- int на Uno — 16 бит, потолок 32767. Сумма байтов уехала в минус, и логика выбрала жертву неверно. Считайте в unsigned long, жертву — по дате в имени. Сумма размеров считалась в int и переполнилась после 32767
- Зачем тумблер-предохранитель перед кнопкой удаления?
- Две ступени стоят один digitalRead: пока тумблер выключен, кнопка лишь печатает, что сделала бы. Экономит и от чужих пальцев, и от своей забывчивости. Подтверждение в две ступени: без предохранителя кнопка только показывает намерение