Гаражный логгер температуры работал сорок минут, потом началось: строки в Serial печатались вперемешку с кракозябрами, значения прыгали, а ещё через десять минут плата перезагрузилась сама, без всякого watchdog. Я пересобирал скетч трижды, пока не признал: не в железе. В каждом круге loop() я собирал строку отчёта — report += хвост, глобальный String, к которому каждую секунду приклеивался свежий кусок. Каждое приклеивание просило у кучи кусок побольше, старый оставался в ней дырой, и через сорок минут на Uno с её 2048 байтами SRAM дыр стало больше, чем живой памяти.
Карта трёх памятей и сама функция freeRam() у нас уже были в уроке про PROGMEM и память — как справка. Здесь методика: как замерять остаток ОЗУ так, чтобы утечка была видна в числах, а не в подозрениях; метод дельты, которым я нашёл свой String; таблица прожорливости всего, что живёт в ОЗУ Uno. И честная сторона String: когда он безвреден — потому что ненавидеть его вслепую я не собираюсь.
freeRam(): манометр кучи
Память на Uno — сэндвич из 2048 байт. Глобальные переменные лежат снизу; над ними растёт куча — оттуда malloc выдаёт куски под String и библиотеки; стек прижат к верхнему краю адресного пространства и растёт вниз. Свободная память — зазор между вершиной кучи и вершиной стека. freeRam() измеряет ровно его: __brkval указывает на вершину кучи (пока malloc ничего не выдал — на границу __heap_start), а адрес локальной переменной — на вершину стека; разность и есть зазор в байтах. Для тех, кто хочет читать строку extern int __heap_start, *__brkval без мистики, есть урок про указатели.
Замер я вывожу раз в десять секунд, а не каждый круг: сам Serial.print живёт на стеке и примешивает свой кадр к цифре. Числа важнее ощущений: после setup у чистого скетча зазор, скажем, 1400 байт; дальше кривая либо дышит в пределах пары десятков байт — кадры функций ходят туда-сюда, — либо монотонно падает. Монотонное падение без возвратов и есть утечка.
Стартовую цифру полезно уметь расшифровывать без гаданий. Компилятор после сборки печатает строку Global variables use 424 bytes — это статика, которую видно. Но она не видит динамику: буферы, которые библиотеки просят у кучи в runtime, и литералы, скопированные в ОЗУ при старте, поэтому 2048 минус статика никогда не совпадёт с показаниями freeRam() один в один. Из привычных расходов: Serial при begin() забирает 64 байта под приёмный буфер, Servo — по несколько байт на каждый канал. Не знаете, куда делись байты, — не гадайте: снимите кривую freeRam() до и после каждого init() в setup, и библиотека, которая ест больше всех, назовётся сама.
int freeRam() { extern int __heap_start, *__brkval; int v; return (int)&v - (__brkval == 0 ? (int)&__heap_start : (int)__brkval);} const int LED = 12;String report = "garage log:"; // глобальная и только растётunsigned long tNext = 0;int shots = 0; void setup() { pinMode(LED, OUTPUT); Serial.begin(9600); Serial.print("start ram="); Serial.println(freeRam());} void loop() { if (millis() - tNext < 2000) return; tNext = millis(); shots = shots + 1; report += " t=21.4,56"; // десять байт за круг digitalWrite(LED, HIGH); if (shots % 5 == 0) { // раз в 10 секунд Serial.print("shots="); Serial.print(shots); Serial.print(" ram="); Serial.println(freeRam()); } digitalWrite(LED, LOW);}Метод дельты, которым я нашёл свой String, выглядит так. Замеряете базу сразу после setup, затем замеряете раз в минуту и считаете наклон. У гаражного логгера наклон был минус пять байт в секунду — 300 байт за минуту: на шестой минуте кривая нырнула ниже трёхсот, и начались кракозябры. Дальше — делить: закомментируйте половину loop(), снова замерьте; наклон пропал — источник в этой половине. Три-четыре таких деления находят строку точнее любой дедукции. Подозреваемые по убыванию вероятности: String-склейки, библиотеки, выделяющие буфер на каждый вызов, и const-массивы без PROGMEM, которые ядро копирует в ОЗУ при старте.
Стек в этой арифметике — отдельная песня: freeRam() показывает зазор в момент вызова, и глубокий вызов с большими локальными буферами его сжимает. Если цифры пляшут на десятки байт вверх-вниз, но не падают монотонно — это дыхание стека, а не утечка. Падение меряйте между одинаковыми точками: раз в минуту, из loop(), при одном и том же наборе активных задач. А если сам вывод в Serial ведёт себя странно, начните с первого разговора с монитором порта — половина мнимых утечек оказывается скоростью порта.
Для долгих проектов я добавляю в сам лог-файл колонку с freeRam(): через месяц жизни на даче график памяти в журнале говорит о здоровье прибора больше, чем график температуры. И держите в голове одну черту: ниже четырёхсот байт свободных — я останавливаю релиз и ищу причину, потому что запас стека на худший кадр ещё не отложен. Эта черта не из документации, а из практики: у каждого своя — но она должна быть написана и известна всей команде, а не жить в головах.
Когда String безвреден
Признаваться в обратном не собираюсь, но мера есть: String в Arduino — не приговор, а инструмент с ценником. Он безвреден, когда строка живёт мгновения: собрал, напечатал, выкинул; когда склейка случается редко — раз в минуту, а не шестьдесят раз в секунду; и когда куче есть куда расти. На ESP32 с сотнями килобайт ОЗУ String-конкатенация — повседневная норма, и я не испытываю там ни малейших угрызений. На Uno с его 2048 байтами цена другая: каждая склейка в цикле оставляет в куче лоскут, который больше никогда не станет целым куском.
Справедливости ради — часть дурной славы String заработал баг malloc в старой avr-libc 1.6.4: тот по-настоящему протекал, и скетчи той эпохи худели на глазах. Баг давно починили, но фольклорное правило «String в цикле — дурак» осталось, хотя современный String просто честно фрагментирует кучу. Если строка собирается раз в минуту и живёт секунду — она безвредна и сегодня. Если растёт, как мой логгер, — лечится тем, что лечит всё: reserve() — это шесть букв в коде и целая глава спокойствия в журнале, куча один раз просит буфер под максимум и больше не переживает; char-буфер фиксированной ширины со snprintf; или отказ от склейки в пользу сериализации — так serializeJson печатает документ в Serial без единого String со стороны вашего кода.
| Потребитель | Цена | Когда платим | Как вернуть |
|---|---|---|---|
| int numbers[10] глобально | 20 байт постоянно | сразу после заливки | uint8_t вместо int — вдвое меньше |
| String из 17 символов | 17 байт текста плюс шапка объекта и лоскуты от перевыделений | на каждую склейку | char-буфер со snprintf |
| Буфер Serial RX | 64 байта | с первого Serial.begin() | никак: цена общения с портом |
| Литерал Теплица ok без F() | по байту на символ в ОЗУ | при старте | обернуть в F() — текст уйдёт во флеш |
| Кадр вызова функции | 2 байта адреса возврата плюс локальные | на каждый уровень вложенности | короче функции, меньше локальных буферов |
Таблицы констант, которые только читаются, переезжают во флеш целиком — механика PROGMEM и цена компромиссов разобраны в уроке про таблицы во флеш. Литералы к ним присоединяются макросом F() — самая дешёвая экономия в проекте, она не ломает ни одной строки логики. Считать за собой память — навык из той же серии, что и считать миллисекунды: две копейки внимания в начале проекта против вечера охоты на призрак в конце. Мой гаражный логгер после переезда на char-буфер живёт четвёртый месяц, и его freeRam за это время сместился на ноль байт — лучший отчёт, который я видел.
Правило, которое теперь я вывешиваю над рабочим столом: у каждой строки, собираемой по кусочкам, должен быть ответ, где она умирает. Локальная и умирает в конце функции — хорошо; глобальная и растёт — держите ответ наготове. Половина утечек в моих проектах случалась не в хитрых библиотеках, а в одной-единственной безобидной строке с +=, написанной в первый вечер и дожившей до продакшена. Вопрос «где умрёт эта строка?» на code review стоит пяти минут — и дешевле любой ночной отладки умирающего прибора.
- freeRam() меряет зазор между вершиной кучи (__brkval) и вершиной стека — весь бюджет Uno 2048 байта
- Метод дельты: замер раз в минуту из loop(); монотонное падение без возвратов — утечка, дыхание на десятки байт — стек
- Гаражный логгер: десять байт каждые две секунды — 300 байт за минуту; на шестой минуте кривая нырнула ниже трёхсот
- String безвреден для строк, живущих секунды, и на ESP32; в цикле на Uno он оставляет в куче невозвратные лоскуты
- Лечение: reserve() под максимум, char-буфер со snprintf, F() для литералов, сериализация без String
- 1. Что именно считает freeRam() на Uno?
- 2. Логгер печатает freeRam() раз в минуту. Как выглядит честная утечка?
- 3. Строка report=21.4C,56% из 17 символов в Arduino String на Uno занимает…
- 4. Проекту нужен лог раз в 10 минут на ESP32 с сотнями килобайт ОЗУ. Что я советую?
- 5. Как уберечь литералы вроде Отчёт готов от SRAM на Uno?
Частые вопросы по теме урока
- Что именно считает freeRam() на Uno?
- Функция берёт адрес локальной переменной (вершина стека) и вычитает __brkval — вершину кучи. Разность и есть свободная SRAM в байтах; всего на Uno их 2048, и статику компилятор показывает строкой Global variables use. зазор между вершиной кучи и текущей вершиной стека
- Логгер печатает freeRam() раз в минуту. Как выглядит честная утечка?
- Монотонное падение без возвратов — утечка. Пляска на десятки байт — дыхание стека: кадры функций приходят и уходят, а память возвращается. падает монотонно и не возвращается
- Строка report=21.4C,56% из 17 символов в Arduino String на Uno занимает…
- Объект String несёт шапку и хранит текст в куче; каждое перевыделение может оставлять дыры. Поэтому в цикле на Uno строку собирают в char-буфере или просят запас заранее через reserve(). 17 байт текста плюс шапку объекта и лоскуты от перевыделений
- Проекту нужен лог раз в 10 минут на ESP32 с сотнями килобайт ОЗУ. Что я советую?
- String безвреден, когда строка живёт мгновения, склейка редкая, а куче есть куда расти. На ESP32 с сотнями килобайт это повседневная норма; дисциплина Uno там не нужна. оставить String: запас несопоставим с Uno, а склейка редкая
- Как уберечь литералы вроде Отчёт готов от SRAM на Uno?
- Макрос F() оставляет литерал во флеше и отдаёт его печати по одному байту — SRAM не расходуется. Самая дешёвая экономия, не ломающая ни одной строки логики. обернуть в F() — текст ляжет во флеш