Ночь в теплице прошла тревожно, а я узнал об этом только утром. Датчик исправно печатал показания в Serial каждые две секунды — но монитор порта хранит ровно одну последнюю строку. Когда я спросил у «станции», что было в три часа ночи, устройство честно показало утренние 21 градус, и вся ночная история провалилась в пустоту. Данные шли, память о них — нет.
Монитор порта вообще устроен как сток без памяти: он показывает поток, который приходит сейчас, плюс немного собственного буфера прокрутки. Закрыли окно — история исчезла вместе с буфером. Поэтому хранение истории — обязанность самого скетча, и оперативная память для последних минут подходит идеально: пишется мгновенно, читается мгновенно, а при перезагрузке честно обнуляется вместе с остальной программой.
Первая попытка починить это была предсказуемо неудачной: я завёл массив и на каждом опросе сдвигал его содержимое, освобождая ячейку для свежего значения. tempLog[i] = tempLog[i + 1] в цикле — и так на каждое показание. Работало, но каждая запись трогала все двенадцать ячеек, а однажды я перепутал направление сдвига и журнал стал показывать будущее: самое свежее значение оказывалось в самой старой ячейке. Час отладки ради операции, которая вообще не нужна.
Правильный инструмент называется кольцевым массивом, и в этом уроке мы напишем его для показаний DHT: с записью по кругу, счётчиком заполненных ячеек и статистикой поверх — минимумом, максимумом и средним. Заодно посчитаем, сколько оперативной памяти съест журнал любого размера, и решим, где хранить историю длиннее пары минут.
Как устроено кольцо: голова вместо сдвига
Идея кольцевого массива — перестать двигать данные и начать двигать указатель. Заводим массив фиксированного размера и переменную head — номер ячейки, куда ляжет следующее показание. Запись выглядит так: tempLog[head] = t, затем head увеличивается на единицу, а когда доходит до конца массива — перескакивает в ноль. Свежие данные перезаписывают самые старые, и массив всегда хранит последние N показаний без единого лишнего копирования.
Одной головы мало: сразу после включения массив полон нулей, и если пройтись по всем двенадцати ячейкам в поисках минимума, вы увидите 0.0 — хотя датчик ещё ни разу не отвечал. Поэтому в паре с head живёт счётчик count: он растёт с каждой записью, пока не упрётся в размер массива. Пока count меньше размера — обрабатываем только заполненную часть, после — уже всё кольцо. Приятный бонус такой геометрии: самое старое значение лежит ровно в ячейке, которую следующая запись перезапишет, — заглянуть в прошлое кольца можно без единого вычисления.
Теперь про цену. В Arduino Uno 2048 байта оперативной памяти, и половина из них занята стеком, Serial-буферами и библиотеками ещё до вашего setup(). Одно показание — это два значения float по 4 байта, то есть 8 байт на точку. Журнал из 12 точек занимает 96 байт — около 5 % всей RAM. Это настолько дёшево, что скучно, но масштабируется всё линейно, и в таблице ниже видно, как быстро кольцо дорастает до сотен байт.
| Точек в журнале | Занимает в RAM | История при опросе раз в 2 с | Доля RAM Uno |
|---|---|---|---|
| 12 | 96 байт | 24 секунды | около 5 % |
| 30 | 240 байт | 1 минута | около 12 % |
| 60 | 480 байт | 2 минуты | около 23 % |
| 120 | 960 байт | 4 минуты | около 47 % |
Статистика поверх кольца
Сам по себе журнал — просто коробка с числами. Польза появляется, когда поверх него считается статистика: минимум подсказывает, не подмораживало ли ночью, максимум — не жарило ли у стекла днём, среднее сглаживает дрожь отдельных отсчётов. Всё это — один проход по заполненной части кольца в цикле for: сравнили с текущим минимумом, сравнили с максимумом, прибавили к сумме. По окончании цикла сумма делится на count, и среднее готово. В те же ячейки можно класть что угодно: время первого выхода за порог, статус тревоги, показания второго датчика — кольцо хранит ровно то, что вы в него положите.
Заметьте пропорцию затрат: чтение DHT занимает у процессора около 4–5 миллисекунд, потому что библиотека ждёт 40 импульсов с линии данных. Запись в массив и вся статистика по двенадцати ячейкам укладываются в несколько микросекунд. Кольцевой журнал — одна из самых дешёвых функций, которые вы можете добавить в проект: ни байта лишней RAM сверх массива, ни миллисекунды лишнего времени сверх одного прохода.
Мелочь, на которой спотыкаются позже: сумму держите во float. На двенадцати точках переполнение int не грозит, но привычка считать суммы и среднее в float избавляет от тихих сюрпризов, когда кольцо вырастет до сотен ячеек — int на Arduino вмещает лишь 32 767, и сорок градусов, записанные три тысячи раз, это уже 120 000.
Моё правило с оговоркой: журнал в RAM держу для последних минут, не более сотни точек. Если история нужна за часы и сутки — RAM закончится на подходе к 250 точкам, а EEPROM с его записью по тем же 2-секундным интервалам выработает ресурс из 100 000 циклов за двое суток непрерывной работы. Длинная история — это SD-карта, и мы касались этого в уроке про логирование на SD.
const int RAZM = 12;float tempLog[12];float humLog[12];int head = 0; // куда писать следующее показаниеint count = 0; // сколько ячеек уже заполнено // в loop(), после успешного чтения t и h:tempLog[head] = t;humLog[head] = h;head = head + 1;if (head >= RAZM) head = 0; // замкнуть кольцоif (count < RAZM) count = count + 1; // статистика — только по заполненной части:for (int i = 0; i < count; i++) { if (tempLog[i] < tmin) tmin = tempLog[i]; if (tempLog[i] > tmax) tmax = tempLog[i]; tsum = tsum + tempLog[i];}float tsred = tsum / count;Два практических приёма, которые окупаются сразу. Первый: печатайте журнал по команде, а не только текущую строку. Раз в десять циклов пройдитесь по кольцу в столбик — номер отсчёта, температура, влажность — и утром перед вами картина ночи, а не одинокое утреннее число. Я печатаю пары имя=значение, t=23.5 и h=45, по одной на строку: такие пары Serial Plotter превращает в отдельные ряды, и журнальную статистику можно смотреть графиком, а не глазами по столбцу. Приём разобран в уроке про Serial Plotter.
Второй приём — скользящее среднее. Среднее по всему кольцу отстаёт от быстрых изменений: при скачке с 22 на 28 градусов среднее за двенадцать точек ползёт к новому значению почти минуту при опросе раз в две секунды. Для реагирования берите среднее по последним пяти ячейкам — окно в 10 секунд догоняет изменение в два с половиной раза быстрее, а дрожь одиночных отсчётов всё ещё сглаживает.
И третья мелочь, о которой вспоминают на практике: пропускайте запись при сбое чтения. Если датчик вернул nan, писать его в журнал не стоит — не-число проползёт через все расчёты, и среднее за вечер превратится в тыкву. Один if с isnan перед записью в кольцо решает дело: сбойный отсчёт не попадает в историю, ячейка не тратится на мусор, а статистика продолжает считаться по честным точкам.
Пара слов о том, чего в скетче нет и почему. Между опросами стоит delay(2000) — для DHT11 это не перестраховка, а требование самого сенсора, и подробности я разбирал в уроке про интервал опроса. В боевом проекте я бы заменил паузу на millis-планировщик, чтобы журнал не блокировал остальную логику — приём описан в уроке про millis вместо delay. И если журнал показывает странные выбросы, проверьте сам сенсор по диагностике nan и срывов чтения — кольцо честно запомнит и мусор, если мусор пришёл с линии.
Куда двигаться дальше. Журнал в RAM отлично прикручивается к звуковой сигнализации: можно запоминать момент, когда сработала тревога, — об этом следующий урок. А если захочется выводить статистику на экран, минимальные и максимальные значения красиво живут на второй строке LCD в термогигрометре с индикацией.
- 1. Куда попадает очередное показание в кольцевом массиве?
- 2. Сколько RAM займёт журнал из 60 точек, если в каждой хранить температуру и влажность как float?
- 3. Зачем в кольце счётчик count рядом с head?
- 4. Как из кольца прочитать самое свежее показание, если head уже указывает на следующую свободную ячейку?
- 5. Историю климата за сутки лучше хранить…
- Кольцевой массив — это массив, голова head и счётчик count: данные стоят на месте, двигается только указатель записи.
- Точка с температурой и влажностью весит 8 байт: 96 байт на 12 точек, 480 — на 60. Считайте бюджет RAM до того, как выбирать размер.
- Статистика (минимум, максимум, среднее) — один проход for по заполненной части кольца, микросекунды на всю операцию.
- Цикл статистики должен идти до count, а не до размера массива — иначе до наполнения кольца увидите нули.
- RAM — для последних минут, EEPROM — для порогов и настроек, SD-карта — для истории за часы и сутки.
Частые вопросы по теме урока
- Куда попадает очередное показание в кольцевом массиве?
- Указатель head сам ходит по кругу и перезаписывает старые данные. Сдвигать массив не нужно ни на одном опросе. В ячейку head, после чего head сдвигается на следующую по кругу
- Сколько RAM займёт журнал из 60 точек, если в каждой хранить температуру и влажность как float?
- Точка — это два float по 4 байта, то есть 8 байт. Умножаем на 60 — получаем 480 байт, около 23 % RAM Uno. 480 байт
- Зачем в кольце счётчик count рядом с head?
- Сразу после включения массив полон нулей. Count подсказывает, какая часть кольца настоящая, — по ней и считается статистика. Чтобы знать, сколько ячеек заполнено, и не читать нули до наполнения кольца
- Как из кольца прочитать самое свежее показание, если head уже указывает на следующую свободную ячейку?
- Свежая запись лежит на шаг назад от головы. Выражение с вычитанием единицы и остатком от деления не выйдет за границы массива даже на переходе через ноль. tempLog[(head + RAZM - 1) % RAZM]
- Историю климата за сутки лучше хранить…
- RAM не резиновая, а EEPROM при записи каждые 2 секунды выработает ресурс за пару суток. SD-карта пишется миллиарды раз и вмещает месяцы данных. В строке Serial-монитора, он всё сохраняет