Диммер для полки с рассадой я собрал за вечер, а доводил неделю. Схема простая: потенциометр на A0 задаёт яркость, но отдавать её в analogWrite напрямую нельзя — на первых четвертях оборота ручки глаз почти ничего не различает, а на верхних всё кажется одинаково ярким. Кривую восприятия выправляет гамма-таблица: 256 готовых значений яркости, компенсирующих нелинейность глаза и светодиода из урока про ШИМ. К ней за месяц приросли таблица частот для щелчков интерфейса и меню на семь строк. Дописал очередную графику, нажал «Проверить» — и прочитал в логе: Global variables use 1 486 bytes (72%) of dynamic memory. Я ещё ничего не запускал, а из 2 КБ ОЗУ свободными остались 560 байт, и в них предстояло поселиться стеку, Serial-буферу и всем переменным.
Таблицы — самая коварная статья расхода ОЗУ. Переменную я ещё как-то помню, а таблица из шестнадцати констант выглядит невинно и стоит ровно шестнадцать байт. Гамма-таблица на 256 значений — это восьмая часть всей памяти Uno ещё до первой переменной. При этом таблица по смыслу константа: её никто не перезаписывает в loop(), ей незачем жить в оперативной памяти. Почему она туда попадает — вопрос архитектуры, и тот же ответ объясняет сразу и PROGMEM, и необходимость функции pgm_read_byte.
План такой: сначала разберём, зачем компилятор копирует константные массивы в ОЗУ и что меняет PROGMEM; потом — чем читать данные обратно и сколько байт это возвращает; в конце — симулятор с табличным lookup на потенциометре и зуммере. Оговорку про движок скажу заранее: PROGMEM он не эмулирует, поэтому в макетке таблица объявлена обычным массивом, а на плату вы возьмёте версию из code-блока. Три памяти платы и функцию freeRam мы уже разбирали в уроке про память — там же о том, как строки душат ОЗУ.
Что делает PROGMEM и почему одного слова мало
У ATmega328P флеш и ОЗУ висят на разных шинах: это гарвардская архитектура, команды и данные разделены. Обычный код адресует только пространство данных. Поэтому компилятор делает ход конём: константные массивы он кладёт во флеш вместе с прошивкой, но при старте копирует в ОЗУ — чтобы ваш tab[i] работал как обычное чтение. Отсюда цена: массив объявлен const, а место в оперативке занимает как переменная, и freeRam из урока про память это честно покажет.
PROGMEM отменяет копирование: строка const byte GAMMA[256] PROGMEM = {...} означает «держи во флеше, в ОЗУ не выноси». Но после этого GAMMA[i] читает уже не то, что вы думаете: по адресу массива в пространстве данных лежит мусор, потому что данные — на другой шине. Читать надо функцией pgm_read_byte(&GAMMA[i]): она умеет во второе пространство и вернёт честный байт. Мой тупик на этой паре: добавил PROGMEM, вызовы не тронул, скетч собрался без единого предупреждения — и диммер начал показывать мусор, «сдвинутую» таблицу, где каждое значение не своё. Проблема не ошибка компиляции, а молчаливое чтение не оттуда, и искать её почти не за что зацепиться взглядом.
Второе правило — const рядом с PROGMEM обязателен. В свежих версиях avr-gcc объявление без const — ошибка компиляции, а в старых оно молча съедало всю экономию: люди на форумах месяцами не понимали, почему память не вернулась. Дисциплина одна: const byte имя[размер] PROGMEM — и чтение только через pgm-функции.
#include <avr/pgmspace.h> // 16 точек гаммы: во флеше, ОЗУ не тратитсяconst byte GAMMA[16] PROGMEM = {0, 1, 2, 4, 7, 11, 16, 23, 31, 41, 53, 67, 83, 101, 122, 145}; void setup() { Serial.begin(9600); for (int i = 0; i < 16; i++) { Serial.print("i="); Serial.print(i); Serial.print(" y="); Serial.println(pgm_read_byte(&GAMMA[i])); }} void loop() { int syr = analogRead(A0); // ползунок яркости int zona = map(syr, 0, 1023, 0, 15); // индекс в таблице analogWrite(9, pgm_read_byte(&GAMMA[zona])); delay(20);}Прежде чем класть таблицу во флеш, честно спросите себя: а не хватит ли формулы. Гамма-кривую можно считать и без таблицы — взять квадрат отношения: y = x * x / 255. Формула стоит почти ноль байт, но на каждый оборот loop() плате приходится умножать и делить, а float-операции на 16 МГц — это десятки тактов каждая. Таблица — наоборот: ноль вычислений, один lookup, но байты на месте. Моё правило с границей: до пары десятков значений беру формулу, дальше — таблицу; для 256 точек формула уже спорит с бюджетом ОЗУ, а таблица во флеше этот вопрос закрывает навсегда.
Есть случай, где таблица вне конкуренции, — калибровка. Зависимость сопротивления NTC-термистора от температуры в даташите стоит списком из двадцати точек; пересчёт формулой Стейнхарта-Харта требует float, логарифма и веры в производителя. Двадцать значений int во флеше — сорок байт и мгновенный ответ, а заодно точность ровно та, что обещана в таблице. Табличный lookup мы уже применяли для журнала событий в уроке про массивы и записи — здесь та же механика, только данные живут не в ОЗУ, а во флеше.
pgm_read_byte, pgm_read_word и цена в байтах
Функций чтения из флеша три, и они покрывают всё, что я кладу в PROGMEM. pgm_read_byte — для byte и char, по одному байту. pgm_read_word — для int и uint16_t: забирает два байта за раз, младший первым, как и положено AVR. pgm_read_dword — для long, четыре байта. Таблица нот — как раз случай для word: частоты от 262 до 494 Гц в один байт не влезают, поэтому пишем pgm_read_word(&NUOTY[i]). Схема всегда одна: амперсанд от элемента, функция возвращает значение. Строки для печати — отдельная песня: их закрывает F() из урока про память, частный случай того же PROGMEM.
Экономия предсказуема заранее — сколько байт занимает таблица, столько и вернёте. Гамма-таблица на 256 значений byte — минус 256 байт ОЗУ. Таблица калибровки из двадцати int — минус 40. Шрифт 5 на 8 для 96 печатных символов ASCII — пять байт на букву, 480 байт, почти четверь всей памяти Uno: поэтому библиотеки экранов держат шрифты во флеше всегда, и это не их прихоть, а арифметика. Меню из семи строк на русском в UTF-8 — ещё около 140 байт, и каждое из этих чисел для платы с 2048 байтами — не косметика.
Граница применимости: всё сказанное — про AVR. На ESP32 и ESP8266 флеш спроецирован в адресное пространство, PROGMEM там остался для совместимости, и обычное tab[i] читает данные честно. Если один и тот же скетч переносите туда и обратно, держите чтение таблицы в одном месте — одной функцией-обёрткой, чтобы платформенное отличие не расползалось по коду.
| Где лежит | Как объявить | Как читать | Когда беру |
|---|---|---|---|
| ОЗУ (по умолчанию) | const byte tab[16] = {...}; | tab[i] напрямую | пара десятков значений и данные, которые скетч меняет |
| Флеш, PROGMEM | const byte tab[16] PROGMEM = {...}; | pgm_read_byte(&tab[i]) | таблицы от пары десятков значений, калибровки, шрифты |
| Флеш, word-таблицы | const int tab[12] PROGMEM = {...}; | pgm_read_word(&tab[i]) | частоты, пороги, любые числа больше 255 |
| Флеш, строки для печати | Serial.println(F("Текст")); | сама F() | все длинные тексты в Serial и на LCD |
Движок симулятора PROGMEM не исполняет: у виртуальной платы нет деления памяти на пространства, и pgm-функций в нём нет. Поэтому в макетке ниже таблица объявлена обычным const-массивом — это модель данных, а не модель памяти: в симуляторе проверяется механика lookup, индекс из потенциометра — значение из таблицы — вывод на периферию. На настоящей плате вы берёте тот же массив, дописываете PROGMEM и меняете чтение на pgm_read_word — остальной скетч не меняется ни на символ.
Сюжет макетки — хроматическая октава на зуммере: двенадцать частот в таблице, tone() из урока про зуммер играет выбранную, потенциометр A0 ходит по индексам 0-11. Кривую таблицы заодно постройте в плоттере — пилу из двенадцати ступеней видно сразу.
- PROGMEM держит данные только во флеше и отменяет копирование в ОЗУ при старте: сколько байт в таблице, столько и вернули
- На AVR флеш и ОЗУ — разные шины: PROGMEM-массив читается только через pgm_read_byte, pgm_read_word, pgm_read_dword
- Обычный tab[i] поверх PROGMEM компилируется и молча читает мусор — самая тихая ловушка PROGMEM
- Экономия предсказуема: 256 байт гаммы, 40 байт калибровки, 480 байт шрифта 5 на 8 — четверть всей ОЗУ Uno
- const рядом с PROGMEM обязателен; на ESP32/ESP8266 флеш проецируется в память и читается напрямую
- 1. Массив объявлен с PROGMEM, а читается как tab[i]. Что увидит скетч на Uno?
- 2. Сколько байт ОЗУ вернёт таблице из 48 значений int объявление с PROGMEM?
- 3. Чем читать таблицу частот int из флеша?
- 4. Почему шрифты для экранов всегда живут во флеше?
- 5. Тот же скетч с PROGMEM перенесли на ESP32 и читают tab[i] напрямую. Что произойдёт?
Частые вопросы по теме урока
- Массив объявлен с PROGMEM, а читается как tab[i]. Что увидит скетч на Uno?
- Компиляция пройдёт молча, но чтение уйдёт в пространство данных, где флеш-данных нет. Вернётся мусор — так и рождаются «съехавшие» таблицы. мусор: данные во флеше, а чтение пошло в пространство данных
- Сколько байт ОЗУ вернёт таблице из 48 значений int объявление с PROGMEM?
- int на AVR весит 2 байта: 48 × 2 = 96 байт, которые больше не копируются в ОЗУ при старте. Чтение — pgm_read_word. 96 байт
- Чем читать таблицу частот int из флеша?
- int занимает два байта, поэтому нужен word: pgm_read_word забирает оба байта за раз, младшим вперёд. byte-функция вернула бы половину значения. pgm_read_word(&tab[i])
- Почему шрифты для экранов всегда живут во флеше?
- Шрифт 5 на 8 — пять столбцов-байтов на каждый из 96 символов ASCII. В ОЗУ он съел бы четверть всей памяти платы ещё до переменных. 5 байт на символ при 96 символах — 480 байт, четверь всей ОЗУ Uno
- Тот же скетч с PROGMEM перенесли на ESP32 и читают tab[i] напрямую. Что произойдёт?
- На ESP8266/ESP32 PROGMEM остался для совместимости: флеш проецируется в память, и обычное чтение возвращает данные. Гарвардская ловушка — только про AVR. заработает: флеш на ESP32 спроецирован в адресное пространство