Конфиг моего инкубатора жил в config.txt и был скучным: threshold=38, hysteresis=2. Скучным он оставался ровно до дня, когда на одно устройство понадобилось повесить три реле с разными пинами. Пины зашивать в код я не хотел — вся прелесть конфига в том, что железо переносится правкой текстового файла, — и родилась строка pins=3,5,7: три числа в одном значении, через запятую, как CSV внутри конфига. Парсер из ранних уроков такое значение не брал: toInt читал до первой запятой и молча выбрасывал хвост. В списке оставался один пин из трёх, и второе реле молчало бы до выяснения обстоятельств.
Заодно назрел второй вопрос: как вообще читать файл построчно. В арсенале два способа, и они не равнозначны. Первый — readStringUntil: функция сама собирает строку до перевода строки и отдаёт готовый String. Второй — посимвольный цикл: available, read, и байты складываются в свой буфер руками, с решением на лету, что пропустить, а что обрезать. Гайды почти всегда показывают первый способ — он короче. Я за два года логгеров переехал на второй везде, где файл больше пары десятков строк, и дальше объясню, почему.
План урока такой: разберём обе техники по косточкам — где у них таймауты, куда девается от Windows-блокнота и сколько RAM съедает каждая; потом соберём парсер конфига со списочным значением, который переживает пробелы, комментарии и пустые строки. Формат конфига как контекста устройства я описывал в уроке про конфиг с карты, а здесь сфокусируемся на самом чтении и на значениях, которые шире одного числа.
Посимвольное чтение: контроль над каждым байтом
Каркас посимвольного чтения вы знаете по Serial, потому что File — тот же поток: while (f.available()) тянет байты через f.read(), и встреченный код 10 означает конец строки. Разница в том, что здесь я волен делать с каждым байтом что угодно ещё до того, как он попал в строку. Код 13 — невидимый хвост от Windows-блокнота — пропускается continue-ом и никогда не доезжает до парсера. Байты сверх длины буфера обрезаются молча и предсказуемо: буфер у меня 32 байта, и строка физически не может его переполнить.
Главная причина, по которой на Uno я выбираю посимвольный цикл, — память. Каждая строка на String — кусок кучи: прочитать файл из двухсот строк по тридцать байт — значит двести раз попросить у кучи кусочек и вернуть его. Куча на Uno — примерно два килобайта, и фрагментация после такого чтения — обычное дело: память вроде свободна, а большой буфер уже не выделяется. С фиксированным char-буфером этой истории нет: буфер заводится один раз, кучи касается ноль раз, фрагментировать нечего.
Цена у контроля одна — кода больше: восемь-десять строк на функцию чтения против одного вызова. Плюс тонкость, на которой я горел: если последняя строка файла не закрыта переводом строки, цикл available() кончается посреди строки, и без флага any её легко потерять. Поэтому моя readLine возвращает факт, что байты были: пустой возврат — честный конец файла, а не обрубленная строка. Полная механика available и read с побайтовым разбором — в уроке про цикл чтения файла.
readStringUntil: один вызов и его стоимость
readStringUntil(10) делает ту же работу одним вызовом: тянет байты, пока не встретит терминатор, и возвращает готовый String. Для конфига на шесть строк это самый разумный выбор — коротко, читаемо, и парсер сразу получает строку, с которой умеют работать indexOf и substring. Я сам стартовал с этого способа и на маленьких конфигах остаюсь на нём до сих пор: переписывать шесть коротких строк на посимвольный цикл ради двухсот байт кучи — та ещё перестраховка.
Подводная часть всплывает, когда файл растёт. Первый сюрприз — таймаут: терминатора нет — функция ждёт его секунду по умолчанию; лечится f.setTimeout(50) перед циклом. Второй — хвост : блокнот Windows заканчивает строки парой байтов, и без trim() сравнение ключей расходится с образцом. Третий и главный — String: каждая строка файла становится объектом в куче, и на сотнях строк куча фрагментируется до отказа. Все три грабли я разбирал по косточкам в уроке про парсинг строк конфига — вместе с таблицей кривых строк и ловушкой молчаливого нуля; пересказывать их здесь не буду.
Отдельно не путайте Serial и файл: у Serial тот же readStringUntil, и там таймаут выручает, потому что собеседник может задуматься. У файла хвост жёсткий, и ожидать его секундой — трата миллисекунд без пользы. Механику той же функции на потоке порта я гонял в уроке про команды из Serial — если она у вас в пальцах, файловая версия переучивания не потребует.
| Техника | Код | Память | Где ломается |
|---|---|---|---|
| Посимвольный цикл | 8-10 строк на readLine | фиксированный char-буфер, куча не трогается | забытый флаг конца файла — теряется последняя строка |
| readStringUntil | один вызов | String на каждую строку, куча фрагментируется | секунда таймаута на строке без терминатора, хвост без trim |
| readString (всё сразу) | один вызов | String на весь файл | файл больше свободной RAM не читается вообще |
Значение-список: CSV внутри конфига
Теперь сам парсер. Формат остаётся имя=значение, но значение может быть списком: pins=3,5,7. Алгоритм разбора по запятым — тот же, каким CSV-строка режется на колонки: indexOf ищет запятую, substring отдаёт кусок до неё, остаток идёт на следующий виток, каждый кусок тримится и переводится в число. Запятых нет — значение целиком уходит как единственный элемент списка. Отсюда приятное следствие: одиночное число и список обрабатываются одним кодом, отдельная ветка не нужна.
Два правила прикрутились после первого же кривого конфига. Первое — лимит длины списка: массив pins у меня на шесть элементов, и цикл просто не принимает седьмой, так что опечатка не перепишет память. Второе — фильтр диапазона: пин обязан лежать в 2-12, значение 99 отсеется на входе, а не выстрелит digitalWrite-ом в никуда. Валидировать нужно каждое поле, а не строку целиком. Для одиночных значений всё осталось как было: threshold парсится toInt с проверкой диапазона; механику проверки с strtol и ловушкой нуля на мусоре я подробно описывал в уроке про числа из Serial — внутри String.toInt() прячется тот же механизм. Если конфиг однажды вырастет до полноценной таблицы, дисциплину колонок подскажет урок про CSV-формат.
Почему запятая, а не точка с запятой или пробел? Запятая — соглашение CSV, и файл остаётся совместим с приёмами, которыми мы режем логи. Пробел хуже: его же срезает trim, и человеку легко набрать двойной. Но это моё мнение, а не закон: если под задачу удобнее другой разделитель, парсер меняется в одном месте — символе в indexOf.
#include <SPI.h>#include <SD.h> const int chipSelect = 10;const int MAX_LEN = 32; // самая длинная строка конфига + запас int threshold = 300; // дефолты: конфиг их только уточняетint pins[6]; // список пинов из строки pins=3,5,7int pinsCount = 0; // Посимвольное чтение одной строки в свой буфер:// 13 срезаем на лету, переполнение исключено длиной буфераbool readLine(File f, char *buf) { int i = 0; bool any = false; while (f.available()) { int c = f.read(); if (c == 10) { break; } // 10 - перевод строки if (c == 13) { continue; } // 13 - возврат каретки от Windows any = true; if (i < MAX_LEN - 1) { buf[i] = (char)c; i = i + 1; } } buf[i] = 0; // терминальный ноль return any; // false - строк не осталось} void setup() { Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("Net karty - rabotaem na defoltah"); return; } File f = SD.open("config.txt", FILE_READ); if (!f) { Serial.println("Net config.txt - defolty"); return; } char buf[MAX_LEN]; while (readLine(f, buf)) { String line(buf); line.trim(); if (line.length() == 0) { continue; } // пустая строка int eq = line.indexOf("="); if (eq < 0) { continue; } // комментарий или мусор String key = line.substring(0, eq); String val = line.substring(eq + 1); key.trim(); val.trim(); if (key == "threshold") { int n = val.toInt(); if (n > 0 && n <= 1023) { threshold = n; } } else if (key == "pins") { pinsCount = 0; while (val.length() > 0 && pinsCount < 6) { // лимит массива int k = val.indexOf(","); String item = ""; if (k < 0) { item = val; val = ""; } else { item = val.substring(0, k); val = val.substring(k + 1); } item.trim(); int p = item.toInt(); if (p >= 2 && p <= 12) { // диапазон на каждое поле pins[pinsCount] = p; pinsCount = pinsCount + 1; } } } } f.close(); Serial.print("threshold="); Serial.println(threshold); Serial.print("pins: "); for (int i = 0; i < pinsCount; i = i + 1) { Serial.print(pins[i]); Serial.print(" "); } Serial.println();} void loop() { }Что смотреть в симуляторе. Разница между двумя техниками чтения здесь не видна — она в памяти и таймаутах, а модель подаёт строки уже готовыми. Зато парсер виден как на ладони: pins=3,5,7 рассыпается на три пина, pins = 3, 5, 7 проходит тем же кодом благодаря trim внутри цикла по запятым, а pins=3,5,7,99,1000,1,2 покажет работу двух фильтров — лимит массива съест только шесть значений, диапазон выбросит 99 и 1000, и в списке останутся 3, 5, 7, 1, 2. Одиночный threshold=26 проходит той же веткой — запятых нет, значение уходит целиком.
Перенос на плату сводится к замене источника строк: вместо Serial-ветки — цикл readLine из скетча выше, парсер между этими случаями не отличается ни строкой. И если захочется убедиться, сколько RAM экономит посимвольный вариант на файле подлиннее, — приёмы замера свободной памяти разложены в уроке про экономию RAM.
- 1. Файл на двести строк читается построчно на Uno. Какая техника щадит кучу?
- 2. Что останется от значения 3,5 после val.toInt()?
- 3. Строки конфига сохранены в Windows-Блокноте. Что срезает посимвольный цикл кодом 13?
- 4. Строка конфига длиннее буфера в 32 байта. Что сделает readLine из урока?
- 5. Чем разбор значения 5 отличается от разбора 5,7,9?
- Две техники: readStringUntil — коротко, но String в куче; посимвольный цикл — буфер фиксированной длины и контроль каждого байта.
- Код 10 — конец строки, код 13 срезается на лету: хвост от Windows-блокнота не доезжает до парсера.
- f.setTimeout(50) перед циклом: файл не должен ждать секунду на строке без терминатора.
- Значение-список pins=3,5,7 режется тем же циклом indexOf-substring, что и CSV-строка; без запятой работает как одиночное число.
- Лимит массива и диапазон пинов 2-12 фильтруют каждое поле: кривая строка не переписывает память.
Частые вопросы по теме урока
- Файл на двести строк читается построчно на Uno. Какая техника щадит кучу?
- String на каждую строку роится в куче, и на двухстах строках фрагментация обеспечена. Фиксированный буфер заводится один раз и кучи не касается. посимвольный цикл с фиксированным char-буфером — куча не расходуется вовсе
- Что останется от значения 3,5 после val.toInt()?
- toInt собирает цифры до первого постороннего символа. Именно поэтому каждый кусок между запятыми переводится отдельно, а не вся строка разом. 3 — разбор остановится на запятой, хвост отброшен
- Строки конфига сохранены в Windows-Блокноте. Что срезает посимвольный цикл кодом 13?
- Windows заканчивает строку парой байтов 13 и 10. Цикл режет по 10, а 13 пропускает continue-ом — парсер его никогда не видит. возврат каретки , который иначе остаётся в хвосте строки
- Строка конфига длиннее буфера в 32 байта. Что сделает readLine из урока?
- Лимит i < MAX_LEN - 1 не даёт выйти за границы буфера: лишние байты читаются и выбрасываются. Память под строку не растёт никогда. обрежет строку до 31 символа — переполнения нет по построению
- Чем разбор значения 5 отличается от разбора 5,7,9?
- indexOf не найдёт запятую, вернёт -1, и всё значение уходит как единственный элемент списка. Одна ветка кода закрывает оба случая. ничем: цикл по запятым без запятой съедает значение целиком