Логгер в теплице отработал неделю, и заказчик позвонил с вопросом, которого я не ждал: сколько раз за ночь включался насос и когда был последний запуск? Запись в log.csv раз в минуту у меня работала со времён первого урока про логирование, а вот читать назад скетч не умел совсем. Вынуть карту и сунуть в кардридер я мог только дома, а стоял я на объекте с ноутбуком без кардридера, зато с USB-кабелем и Serial-монитором. Ситуация смешная: данные лежат в тридцати сантиметрах от процессора, байты по проводам уже бегают — но дорога обратно в скетче не нарисована. За неделю в файле набежало десять с лишним тысяч строк — двенадцать килобайт текста, которые я обязан был уметь читать обратно.
Дорога обратно оказалась короче дороги туда. Файловый объект File в библиотеке SD разговаривает тем же языком, что и Serial: available(), read(), peek(). Если вы проходили урок про available и read на Serial, тело цикла чтения узнаете с первого взгляда — меняется только источник байтов. В этом уроке прочитаем лог целиком побайтово, посчитаем байты и строки, разберёмся, почему read() возвращает int, а не char, и во что упирается скорость.
Заодно проговорим правило, которое экономит вечера: чтение — это такая же сессия с картой, как запись. Всё, что мы говорили про дисциплину close() в уроке про запись и закрытие файла, работает в обратную сторону без изменений. Карта при чтении почти не тратится: ресурс записи ограничен тысячами циклов, а чтение карта переживает миллионы раз подряд — к чтению своих же логов можно не относиться бережно.
available и read: знакомая пара с другой стороны
SD.open() в режиме FILE_READ отдаёт дескриптор File, у которого курсор стоит на первом байте — механику курсора мы разбирали в уроке про режимы открытия файла. myFile.available() отвечает на вопрос «есть ли байты между курсором и концом файла», а сразу после открытия это число равно размеру файла. Каждый read() сдвигает курсор на байт вперёд, поэтому available() тает: так получается естественный счётчик остатка, и цикл while (myFile.available()) останавливается ровно на конце файла, без ручных границ. На прогресс это ложится само: раз в сотню байт печатайте position() — на логе в десятки килобайт такая строка спасает от ощущения, что скетч завис.
Тип возврата read() — int, и это не прихоть разработчиков. Функция отдаёт либо байт от 0 до 255, либо -1, когда читать нечего; char не умеет держать -1 как отдельное значение, поэтому для метки конца выбрали широкий тип. На практике это значит: пока available() истинно, read() вернёт число 0-255, и кастом (char) вы превращаете его в символ — код 65 станет буквой A. Перепутаете печать числа и символа — получите в мониторе вместо PUMP ON последовательность 80 85 77 80, на которой я потерял минут десять, проверяя кодировку. Отсюда привычка: байт хранить в int, а приводить к char только в момент печати.
Рядом живут три служебных метода. peek() подглядывает байт без сдвига курсора, position() говорит, где курсор стоит, size() — сколько байт в файле всего. Для чтения лога хватает пары available-read, но size() полезен сразу после открытия: заранее знаете объём работы и можете прикинуть время.
| Метод | Что отдаёт | На пустоте или конце | Зачем мне |
|---|---|---|---|
| myFile.available() | число оставшихся байт от курсора | 0 | условие цикла чтения |
| myFile.read() | int: байт 0-255 или -1 | -1 | само чтение; (char)b для печати |
| myFile.peek() | тот же байт, курсор на месте | -1 | подглядеть разделитель заранее |
| myFile.size() | размер файла в байтах | 0 у пустого файла | прикинуть объём до цикла |
| myFile.position() | номер байта под курсором | равен size() в конце | понять, докуда дочитали |
Теперь скорость, чтобы вы не ждали чуда. Карта по SPI отдаёт сотни килобайт в секунду, и двенадцатикилобайтный лог читается в переменные за десятки миллисекунд. Но я в этом уроке сразу печатаю каждый байт в Serial, а это другая арифметика: на 9600 бод символ стоит около миллисекунды, и 12 000 байт ползут секунд двенадцать с хвостом на переводы строк. Для разовой проверки хватает, а если хотите листать лог живьём — поднимайте скорость порта до 115200 в обоих концах, узкое место исчезнет. Хитрость для длинных логов: печатайте в монитор не каждый байт, а каждую сотую строку — картина та же, времени в сто раз меньше, а содержимое остаётся на карте: чтение и печать — независимые шаги. Ограничение не в карте, а в мониторной трубе: про размер буфера и почему данные из порта нельзя вычитывать вразнобой, у меня отдельный урок про буфер UART.
Второе ограничение — оперативка. На Uno её 2 КБ, и «прочитать файл целиком в строку» на логе в несколько килобайт заканчивается одинаково: скетч вешается или молча теряет хвост. Поэтому мой паттерн всегда один: вычитал байт — сразу напечатал или посчитал, память не копится. Строки можно собирать на ходу, но не длиннее одного перевода строки: следующая строка начинает новую переменную, а не дописывает старую.
Счётчики: лог, который отчитывается сам
Счётчик байт — одна переменная и один плюс в теле цикла. Счётчику строк нужен маркер конца: файл, который писал println(), заканчивает каждую запись парой , поэтому я считаю байты с кодом 10. Лог раз в минуту — это 1440 строк в сутки; за трое суток ожидаю 4320, и если скетч насчитал ровно столько, ни одна запись не потерялась по дороге. Дешёвая проверка целостности, которая работает без кардридера: записано — прочитано — совпало.
На той же паре счётчиков строятся ответы на вопросы заказчика. Сколько раз включался насос — это количество строк со статусом 1 в колонке статуса; когда был последний запуск — номер последней строки. Побайтовый цикл не требует знаний о структуре: он честно тащит всё подряд, а фильтровать можно хоть на ходу, хоть потом. С колонками и статусами разберёмся в уроке про CSV-колонки, а построчный разбор с готовыми полями — тема следующего урока про readStringUntil. На последнем объекте я сводил лог за месяц к трём числам прямо в цикле чтения: пришёл статус 1 — плюс один к счётчику включений, и за минуту у меня был ответ заказчику без единого скрипта.
#include <SPI.h>#include <SD.h> const int chipSelect = 10; void setup() { Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("Нет карты - читать нечего"); while (true) { } } File f = SD.open("log.csv", FILE_READ); if (!f) { Serial.println("Файл не открылся: нет файла или карта не смонтирована"); while (true) { } } unsigned long bytes = 0; // счётчик байт unsigned long lines = 0; // счётчик строк unsigned long t0 = millis(); // замер времени чтения while (f.available()) { int b = f.read(); // байт 0..255 или -1, если пусто if (b < 0) { break; // страховка от чтения на пустой } bytes = bytes + 1; if (b == '\n') { // код 10 - конец записи, которую писал println lines = lines + 1; } Serial.write(b); // байт как символ, без каста } f.close(); Serial.println(); Serial.print("Прочитано байт: "); Serial.println(bytes); Serial.print("Строк: "); Serial.println(lines); Serial.print("Время чтения, мс: "); Serial.println(millis() - t0);} void loop() { }Порядок в скетче отражает дисциплину: открыли с проверкой if (f), дочитали до нуля в available(), закрыли. Close после чтения — не формальность: правило одного открытого файла не различает чтение и запись, и зависший дескриптор помешает следующим операциям с картой, вплоть до отказа открыть файл на запись. Время чтения я печатаю в конце не для красоты: если millis отдал, скажем, 12 400 мс на 12 200 байт, всё время съел Serial на 9600 бод, и карта ни при чём — цифра сразу расставляет подозрения по местам.
Если файл большой, а нужен только хвост — позиционируйтесь перед циклом: seek() из того же урока про режимы отставит курсор ближе к концу, и вы прочтёте свежие строки, не листая неделю истории. Это удобный компромисс между «читать всё» и «городить индекс», которого хватает почти всегда. И учтите: чтение не обязано кончаться в один присест, но после close и повторного open курсор вернётся в начало — позицию придётся восстанавливать seek-ом, поэтому доезжайте до конца за одну сессию.
Остался последний шаг к комфорту: читать не байты, а готовые строки с ключами и значениями — это метод readStringUntil и разбор конфига, которым занимается следующий урок.
- 1. Что вернёт myFile.read(), когда все байты уже прочитаны?
- 2. Почему read() отдаёт int, а не char?
- 3. Сколько примерно времени займёт печать лога в 12 000 байт через Serial на 9600 бод?
- 4. Как посчитать строки в логе, который писали через println()?
- 5. Файл читается, но в мониторе 80 85 77 80 вместо PUMP ON. В чём причина?
- Чтение файла — та же пара available/read, что и на Serial: курсор после open стоит в начале, available() тает с каждым read()
- read() возвращает int: 0-255 или -1 на конце; храните в int, кастуйте (char) только при печати — или печатайте Serial.write(b)
- Цикл while (myFile.available()) — естественная граница файла; страховка if (b < 0) спасает от гонки на пустом потоке
- Скорость упирается в Serial: 12 000 байт на 9600 бод — около 12 секунд; карта отдаёт сотни КБ/с, поэтому для листания лога поднимайте бод
- Считайте байты и строки прямо при чтении: записано-прочитано совпало — целостность проверена без кардридера
- close() обязателен и после чтения: правило одного дескриптора не различает чтение и запись
Частые вопросы по теме урока
- Что вернёт myFile.read(), когда все байты уже прочитаны?
- read() возвращает int: байт 0-255 или -1, когда читать нечего. Ширина int нужна именно ради метки -1, которую char не сохранит как отдельное значение. -1 — метка конца данных
- Почему read() отдаёт int, а не char?
- Диапазон char не вмещает -1 как отдельный маркер конца. Поэтому тип возврата — int, а каст (char) делается только в момент печати символа. Чтобы было куда положить метку конца -1
- Сколько примерно времени займёт печать лога в 12 000 байт через Serial на 9600 бод?
- На 9600 бод символ стоит около миллисекунды, поэтому 12 000 байт — это примерно 12 секунд. Карта при этом отдаёт данные в сотни раз быстрее: горлышко — Serial. Около 12 секунд — по миллисекунде на байт
- Как посчитать строки в логе, который писали через println()?
- println() заканчивает каждую запись парой , поэтому счётчик строк — это количество байт с кодом 10 в потоке. Отдельного метода lines() у File нет. Считать байты с кодом 10 — конец каждой записи
- Файл читается, но в мониторе 80 85 77 80 вместо PUMP ON. В чём причина?
- read() отдал правильные байты, но Serial.print(int) печатает их числами. Нужен Serial.write(b) или Serial.print((char)b) — тогда 80 85 77 80 станут PUMP. Байт напечатан как число вместо символа