Работа с PROGMEM памятью
Часто бывает нужно сохранить в памяти микроконтроллера большой объём данных, которые не будут меняться в процессе работы, например:
- Калибровочный массив
- Текст названий пунктов меню
- Просто какой-то текст
- Посчитанная тригонометрия (синус, косинус)
- Изображения для дисплея (bitmap)
- И многое другое
Хранить такие данные в оперативной памяти (в виде обычной переменной) - не самая лучшая идея, ведь они не будут меняться, а место займут! Оперативной памяти всегда гораздо меньше, чем программной (Flash) памяти: в той же ATmega328 (Arduino UNO/Nano/Pro mini) - 32 кб Flash и 2 кб SRAM, в 16 раз меньше! Так что гораздо эффективнее хранить такие данные во Flash, он же программная память, он же program memory, он же PROGMEM. Но как?
Мы привыкли к тому, что переменные мы можем менять во время выполнения программы, на то они и переменные, на то и память называется динамической. А вот с Flash памятью всё не так просто - писать в неё может только программатор, при помощи которого загружается код программы, либо загрузчик (bootloader), который практически выполняет функцию программатора. Есть кстати модифицированный загрузчик, который позволяет иметь доступ к Flash памяти прямо из программы, но в этих уроках мы рассматриваем стандартные средства, в данном случае - утилиту PROGMEM. Для работы с PROGMEM используется встроенная библиотека avr/pgmspace.h, подключать её не нужно, она подключится сама (в версиях Arduino IDE выше 1.0).
Запись
Ключевое слово (модификатор переменной) PROGMEM позволяет записать данные во Flash память. Синтаксис такой:
const тип_данных data[] PROGMEM = {}; // так const PROGMEM тип_данных data[] = {}; // или так
Всё! Данные, в показанном случае массивы тип_данных
будут помещены во Flash память. PROGMEM может работать со всеми целочисленными типами (8, 16, 32, 64 бита), float и char.
Важный момент! Модификатор PROGMEM можно применять только к глобальным (определённым вне функций) или статическим (глобальным или локальным, но со словом static
) переменным! Читай урок про типы данных. Полный список возможностей pgmspace можно посмотреть в документации.
Чтение
Если с записью всё очень просто (добавляется ОДНО ключевое слово), то с чтением всё гораздо интереснее: оно осуществляется при помощи специальных функций. Основная функция чтения из progmem – pgm_read_тип(адрес)
. Мы можем использовать вот эти 4:
pgm_read_byte(data);
– для 1-го байта (char, byte, int8_t, uint8_t)pgm_read_word(data);
– для 2-х байт (int, word, unsigned int, int16_t, uint16_t)pgm_read_dword(data);
– для 4-х байт (long, unsigned long, int32_t, uint32_t)pgm_read_float(data);
– для чисел с плавающей точкой
Где data
- адрес (или указатель) сохранённого блока данных! Вспомните урок про указатели, чтобы понимать, о чём речь. Полный список возможностей pgmspace можно посмотреть в документации.
Одиночные числа
Рассмотрим запись и чтение одиночных чисел:
const uint16_t data PROGMEM = 125; const int16_t signed_data PROGMEM = -654; const float float_data PROGMEM = 3.14; void setup() { Serial.begin(9600); Serial.println(pgm_read_word(&data)); // выведет 125 uint16_t *dataPtr = &data; // попробуем через указатель Serial.println(pgm_read_word(dataPtr)); // выведет 125 Serial.println(pgm_read_word(&signed_data)); // выведет 64882 Serial.println((int16_t)pgm_read_word(&signed_data)); // выведет -654 Serial.println(pgm_read_float(&float_data)); // выведет 3.14 }
Что здесь важно помнить: читая отрицательные числа (например типы int
и long
) нужно обязательно приводить тип, потому что PROGMEM хранит числа в беззнаковом представлении. Обратите внимание на чтение signed_data
из примера выше, без приведения к int
число выводится некорректно!
Одномерные массивы
С массивами чисел всё весьма ожидаемо:
const uint8_t data[] PROGMEM = {10, 20, 30, 40}; void setup() { Serial.begin(9600); for (byte i = 0; i < 4; i++) { Serial.println(pgm_read_byte(&data[i])); } // выведет 10 20 30 40 }
Читай урок по массивам в блоке базовых уроков программирования.
Двумерные массивы
При создании двумерного массива нужно обязательно указывать размер хотя бы одной из размерностей:
const uint16_t data[][5] PROGMEM = { {10, 20, 30, 40, 50}, {60, 70, 80, 90, 100}, {110, 120, 130, 140, 150}, }; void setup() { Serial.begin(9600); // выведет 70, вторая строка второй столбец Serial.println(pgm_read_word(&data[1][1])); // выведет 150, третья строка пятый столбец Serial.println(pgm_read_word(&data[2][4])); }
Массив массивов
Можно хранить несколько массивов в одном, объявив так называемую таблицу ссылок, то есть ещё один массив, который содержит указатели на массивы данных. Этот вариант отличается от двумерного массива тем, что количество "столбцов" в каждой строке может быть любое, необязательно одинаковое:
// массивы const uint16_t data0[] PROGMEM = {10, 20, 30, 40, 50}; const uint16_t data1[] PROGMEM = {60, 70, 80, 90, 100}; const uint16_t data2[] PROGMEM = {110, 120, 130, 140, 150}; const uint16_t data3[] PROGMEM = {160, 170, 180, 190, 200}; // таблица ссылок const uint16_t* const data_array[] PROGMEM = {data0, data1, data2, data3}; void setup() { Serial.begin(9600); // выведет 170, второй элемент четвёртого массива Serial.println(pgm_read_word(&data_array[3][1])); }
Другая ситуация - есть у нас функция, которая принимает 1-мерный массив байтов (например), который хранится в PROGMEM. Например эта функция выводит на дисплей изображение, которое как раз и закодировано в массиве, и функция ожидает, что массив будет PROGMEMный и сама знает, как с ним работать (например так сделано в библиотеках GyverOLED и GyverGFX). Условный пример вывода:
const uint8_t frame0[] PROGMEM = {...}; const uint8_t frame1[] PROGMEM = {...}; const uint8_t frame2[] PROGMEM = {...}; const uint8_t frame3[] PROGMEM = {...}; ... display(frame0); display(frame1); display(frame2); display(frame3);
Если изображений нужно вывести много и подряд, или иметь возможность вызывать "по номеру" - такой подход будет очень громоздким. Можно заменить его на список массивов и выводить по индексу. Но для этого придётся спуститься на уровень ниже - получить адрес массива из списка, и уже его передать в функцию:
const uint8_t* const frames[] PROGMEM = {frame0, frame1, frame2, frame3}; ... for (int i = 0; i < 4; i++) { display((const uint8_t*)pgm_read_word(&frames[i])); }
Как это работает - мы читаем адрес указанного массива (т.к. массив - указатель сам на себя) в памяти и преобразуем его к (const uint8_t*)
т.к. наша функция ожидает именно его. Что тут важно - мы читаем адрес массива в памяти, в AVR Arduino (Nano, UNO...) адресация 16 битная, поэтому адрес из PROGMEM списка мы читаем как pgm_read_word
. Если работать на 32-битных МК (ESP8266, ESP32...), то нужно будет использовать pgm_read_dword
!
Строки в PROGMEM
Строка (как массив символов) хранится в оперативной памяти программы. Речь идёт о строках вида:
char str[] = "Hello!";
PROGMEM позволяет хранить символьные строки в программной памяти. Это очень удобно, ведь большинство текста не меняется в процессе работы программы: названия пунктов меню, имена параметров запросов к сайту, статичные части веб-страниц и так далее. Далее по тексту для краткости будем называть такие строки PGM-строки, или строки в программной памяти.
Для манипуляций с PGM-строкой (вывод, сложение с другими строками, передача в функции) понадобятся дополнительные преобразования. Почему? Программа не знает, что строка хранится не в оперативной памяти: для неё это обычная const char*
строка. Но данные ведь находятся в другой области памяти! Если начать читать их как обычную строку - можно прочитать "мусор", либо программа и вовсе зависнет из за ошибок чтения по указанному адресу.
- Для удобства программиста существует "тип данных"
PGM_P
, который является макросом наconst char*
, то есть это просто указатель на строку. Так сделано для того, чтобы визуально разделить в программе обычные строки от PGM-строк:const char*
- строка в оперативной памятиPGM_P
- строка в программной памяти
- Для работы с PGM-строками существует целый набор функций, это "прогмемные" аналоги функций из урока про строки: это те же самые функции, но с постфиксом
_P
. Полный набор можно посмотреть в документации.
Рассмотрим сначала запись, а потом чтение, при помощи разных инструментов.
Глобальная строка
Строка объявляется глобально, то есть вне функций в программе. Это удобно и выгодно в том случае, когда строка будет использоваться в программе несколько раз. Объявив её один раз, мы будем избегать дубликатов. Синтаксис следующий, строка объявляется как массив в примерах выше:
const char message[] PROGMEM = "Global pgm string";
Обращаться к этой строке в программе можно по её имени, message
.
Массив строк
Иногда бывает удобно хранить несколько строк под одним именем, например для пунктов меню. В таком случае можно использовать массив строк, про него мы говорили в уроке про строки. Механизм абсолютно такой же, как у массива массивов, который мы рассмотрели чуть выше: создаём сами строки, а затем - массив с указателями на них:
// объявляем "строки" const char str1[] PROGMEM = "Period"; const char str2[] PROGMEM = "Work"; const char str3[] PROGMEM = "Stop"; // объявляем таблицу ссылок const char* const names[] = { str1, str2, str3, }; const char* const names_p[] PROGMEM = { str1, str2, str3, };
Тут есть важный момент - указатели на строки можно хранить как в оперативной памяти (массив names
), так и в PROGMEM (массив names_p
). Доступ к ним будет отличаться, т.к. во втором случае нужно сначала прочитать из прогмема адрес строки, а потом уже читать саму строку! Например:
Serial.println(FPSTR((PGM_P)pgm_read_ptr(&names_p[0]))); Serial.println(FPSTR(names[0]));
В первом случае сначала читается адрес строки, затем передаётся в макрос. Во втором - достаточно просто прочитать массив.
Локальная строка
Иногда бывает удобно объявлять и использовать PGM-строки локально внутри функции, например если строка используется только в этой функции и больше нигде. Для этого нужно обернуть текст в макрос PSTR()
, который поместит текст в PROGMEM и вернёт на него указатель типа const char*
(используем PGM_P
, чтобы не перепутать с обычной строкой):
PGM_P strp = PSTR("Local pgm string");
Чтение строк из PROGMEM
Переписать в буфер
Можно скопировать строку из PROGMEM в оперативную память для решения следующих задач:
- Изменение строки
- Работа как с обычной си-строкой в оперативной памяти
- Отправка в функцию, которая принимает тип
char*
(строка в оперативной памяти)
Для этого нужно:
- Определить размер PGM-строки при помощи функции
strlen_P()
(вернёт длину строки без учёта завершающего символа) - Создать буфер - массив
char
такого же размера +1 символ (для завершающего нулевого символа строки) - Скопировать в буфер PGM-строку при помощи функции
strcpy_P()
(копирует строку вместе с завершающим символом)
// глобальная строка const char pstr_g[] PROGMEM = "Global pgm string"; // глобальный массив строк const char str1[] PROGMEM = "Period"; const char str2[] PROGMEM = "Work"; const char str3[] PROGMEM = "Stop"; const char* const str_list[] PROGMEM = { str1, str2, str3 }; void setup() { Serial.begin(9600); char buf_g[strlen_P(pstr_g) + 1]; strcpy_P(buf_g, pstr_g); Serial.println(buf_g); // Global pgm string char buf_list[strlen_P(str_list[1]) + 1]; strcpy_P(buf_list, str_list[1]); Serial.println(buf_list); // Work // локальная строка PGM_P pstr_l = PSTR("Local pgm string"); char buf_l[strlen_P(pstr_l) + 1]; strcpy_P(buf_l, pstr_l); Serial.println(buf_l); // Local pgm string } void loop(){}
Преобразовать к __FlashStringHelper*
Способ подходит для следующих задач:
- Отправка PGM-строки в функцию, которая принимает тип
__FlashStringHelper*
- Прибавить PGM-строку к
String
-строке (String
поддерживает работу с__FlashStringHelper
) - "Напечатать" PGM-строку при помощи
print()
/println()
в монитор порта/на дисплей/веб/любой объект стандартного класса Print
В Arduino-фреймворке есть очень удобный инструмент, позволяющий работать с PGM-строками, называется он __FlashStringHelper
. Не углубляясь в подробности, будем считать что это просто ещё один тип строковых данных наряду с char*
и String
. Некоторые функции в библиотеках принимают этот тип данных (можно посмотреть в документации или заголовочном файле библиотеки), что позволяет передавать в них PGM-строки без лишних действий, нужно просто преобразовать переменную к (const __FlashStringHelper*)
. Например:
Serial.println((const __FlashStringHelper*)str_pgm); // str_pgm - PGM-строка
В "ядре" esp8266/esp32 для такого преобразования есть удобный макрос FPSTR(строка)
, непонятно почему его не сделали для AVR Arduino. Можно объявить макрос самостоятельно и поместить его в начале программы:
#define FPSTR(pstr) (const __FlashStringHelper*)(pstr)
И предыдущий код будет выглядеть более компактно:
Serial.println(FPSTR(str_pgm)); // str_pgm - PGM-строка
Полный пример:
// глобальная строка const char pstr_g[] PROGMEM = "Global pgm string"; // глобальный массив строк const char str1[] PROGMEM = "Period"; const char str2[] PROGMEM = "Work"; const char str3[] PROGMEM = "Stop"; const char* const str_list[] PROGMEM = { str1, str2, str3 }; void setup() { Serial.begin(9600); // локальная строка PGM_P pstr_l = PSTR("Local pgm string"); // выведем через print() Serial.println(FPSTR(pstr_g)); // Global pgm string Serial.println(FPSTR(str_list[2])); // Stop Serial.println(FPSTR(pstr_l)); // Local pgm string // закинем в String String s; s += FPSTR(pstr_g); s += FPSTR(str_list[2]); s += FPSTR(pstr_l); Serial.println(s); // Global pgm stringStopLocal pgm string } void loop(){}
Передать в _P функцию
Способ подходит для следующих задач:
- Передача PGM-строки в функцию, которая принимает тип
PGM_P
(в основном это функции с постфиксом_P
) - Удобное помещение текста в PROGMEM и сразу передача в функцию, которая принимает тип
PGM_P
Некоторые функции в библиотеках поддерживают работу напрямую с PGM строками: они принимают тип данных const char*
или PGM_P
, а в имени обязательно имеют постфикс _P
. Например write_P(PGM_P buf)
из библиотеки к esp8266 (можно посмотреть в документации или заголовочном файле библиотеки). Это означает, что в такую функцию можно передать PGM-строку без дополнительных преобразований:
// глобальная строка const char pstr_g[] PROGMEM = "Global pgm string"; // глобальный массив строк const char str1[] PROGMEM = "Period"; const char str2[] PROGMEM = "Work"; const char str3[] PROGMEM = "Stop"; const char* const str_list[] PROGMEM = { str1, str2, str3 }; void setup() { Serial.begin(9600); // локальная строка PGM_P pstr_l = PSTR("Local pgm string"); client.write_P(pstr_g); client.write_P(str_list[2]); client.write_P(pstr_l); client.write_P(PSTR("Inline pgm string")); } void loop(){}
F() макро
Способ подходит для следующих задач:
- Удобное помещение текста в PROGMEM и сразу передача в функцию, которая принимает тип
__FlashStringHelper*
- В том числе для сборки
String
-строк
Вы могли подумать, а зачем создавать PSTR()
строку и PGM_P
переменную ради одного вывода? Действительно, может просто можно сделать print((const __FlashStringHelper*)PSTR("Hello, World!"))
? Да, можно! Причём всё уже придумано за нас и называется "F() macro", этот удобный макрос позволяет ещё проще хранить строки в программной памяти для отправки в функции, которые поддерживают __FlashStringHelper
(можно посмотреть в документации или заголовочном файле библиотеки):
void setup() { Serial.begin(9600); Serial.println(F("Inline pgm string")); String s; s += F("Hello, "); s += F("World!"); Serial.println(s); } void loop(){}
F() макро + __FlashStringHelper
Также F-макро позволяет создавать и хранить строки типа __FlashStringHelper*
. F-строки не оптимизируются компилятором, то есть например здесь
Serial.print(F("Hello!")); lcd.print(F("Hello!"));
Строки займут место в памяти программы как две строки, то есть компилятор не объединит две одинаковые строки, как это происходит при обычной работе со строками. Поэтому F-строку можно создать отдельно и передать в нужные функции или сложить со стрингой, но сделать это можно только локально:
void setup() { const __FlashStringHelper* str = F("Hello!"); Serial.println(str); lcd.println(str); String s = str; s += str; }
Полезные страницы
- Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])