View Categories

PROGMEM

PROGMEM или PGM (program memory, память программы) - собственно постоянная память, в которой хранится исполняемый код программы, он попадает туда во время прошивки и не меняется в процессе работы. В МК обычно представлен Flash памятью. В реальной программе часто бывает нужен некоторый набор данных, который не будет меняться в процессе работы - константа или массив констант: например какая-то матрица перехода координат, таблица посчитанных значений, изображения для дисплея в бинарном виде, шрифт для дисплея или просто набор строк - пунктов меню. Мы можем пометить эти данные как const, но где они будут расположены?

const int data[] = {1, 2, 3};
const char* str = "hello";

Данный урок актуален для МК AVR (Arduino UNO, Nano, Mega и прочие), а также ESP8266, которая поддерживает такую работу с памятью

Где хранятся константы? #

Проблема некоторых семейств микроконтроллеров (в частности AVR) заключается в том, что любые const данные и даже просто "строки" (тип const char[]) хранятся не только в памяти программы - при запуске они загружаются в оперативную память - RAM, т.е. начинают занимать место рядом с обычными переменными. Оперативной памяти в МК обычно сильно меньше, чем постоянной, так что программа получается очень неэффективной, а то и вовсе может не хватить памяти под задачи проекта! К счастью, выход есть.

SDK ESP32 актуальных версий автоматически размещает константы и "строки" в программной памяти - вручную ничего делать не нужно!

Гарвардская архитектура #

МК AVR имеют гарвардскую архитектуру - в ней адресное пространство постоянной памяти лежит отдельно от оперативной памяти. В программе мы по умолчанию работаем именно с оперативной памятью, т.е. любые действия с адресами и указателями происходят в оперативной памяти. Чтобы прочитать данные по адресу в программной памяти, нужно сообщить процессору о своих намерениях. Но сначала эти данные туда нужно записать.

Это - большой костыль, современные процессоры делаются на другой архитектуре с совмещённым адресным пространством, что позволяет работать с константами полностью нативно и прозрачно

Атрибут PROGMEM #

Чтобы данные остались в программной памяти и не копировались в оперативку, нужно:

  • Объявить/определить их глобально в программе или статически внутри функции
  • Сделать константой const
  • Добавить слово PROGMEM перед оператором =
  • Инициализировать значением

Для использования PROGMEM-инструментов нужно подключить библиотеку #include <avr/pgmspace.h> - она уже подключена в Arduino.h

const uint8_t pgm_u8 PROGMEM = 123;
const uint8_t pgm_arr[] PROGMEM = {1, 2, 3};

В PROGMEM можно поместить любой тип данных, в том числе составной (массив, структуру, структуру со структурами и массивами внутри)

sizeof #

Оператор sizeof работает как ожидается - возвращает размер данных в байтах. Никаких дополнительных действий не требуется - размер считается на этапе компиляции и просто подставляется числом в программу - процессору уже неважно, в какой области лежат эти данные.

Объявление и определение #

PROGMEM - это просто атрибут, он не меняет правил компиляции и линковки файлов, описанных в этом уроке. Если необходимо разделить объявление и определение на разные файлы, то нужно просто добавить слово PROGMEM и там и там:

// === lib.h - объявления
extern const int some_data_p PROGMEM;
extern const uint8_t some_arr_p[] PROGMEM;

// === lib.cpp - определения
const int some_data_p = 123;
const uint8_t some_arr_p[] PROGMEM = {1, 2, 3};

Если константа помещается в одиночный заголовочный файл, то нужно сделать её static, чтобы не было ошибки линковки. Но нужно помнить, что если такой заголовочный файл подключится в несколько исходных файлов - данные продублируются в памяти. Чтобы этого избежать - разбивайте на два файла как выше:

// === lib.h
static const int some_data_p = 123;

Одиночные константы #

Одиночные константы можно хранить в PROGMEM, но это не имеет особого смысла - проще использовать #define или просто const - компилятор скорее всего вырежет такую константу и заменит её числом:

const uint8_t pgm_u8 PROGMEM = 123;
const int16_t pgm_i16 PROGMEM = -1234;

Для чтения одиночных значений из PROGMEM существует отдельный набор функций (см. справочник), принимающих указатель (адрес) на данные в PROGMEM. Читать нужно в тот же тип, что записан, чтобы не потерялись данные или знак:

uint8_t val8 = pgm_read_byte(&pgm_u8);      // val8 == 123
int16_t val16 = pgm_read_word(&pgm_i16);    // val16 == -1234

// по указателю тоже можно
int16_t* i16_ptr = &pgm_i16;            // указатель на данные в PGM
val16 = pgm_read_word(i16_ptr);         // val16 == -1234

Хранение адреса #

Более того, можно сохранить в PROGMEM сам указатель на данные в PROGMEM, т.е. значение адреса. Это не имеет практического смысла, но важно для дальнейшего понимания урока:

const uint16_t pval PROGMEM = 1234;                 // 16-бит данные в PGM
const uint16_t* const pval_ptr PROGMEM = &pval;     // адрес pval в PGM сохранили в PGM
uint16_t* p = (uint16_t*)pgm_read_ptr(&pval_ptr);   // чтение адреса из PGM
uint16_t val16 = pgm_read_word(p);                  // чтение значения по этому адресу
// val16 == -1234

Одномерные массивы #

Здесь всё логично: можно читать данные по одному элементу по их адресу, как переменные в предыдущей главе:

const uint8_t pgm_arr[] PROGMEM = {1, 2, 3};
for (int i = 0; i < 3; i++) {
    uint8_t read1 = pgm_read_byte(&pgm_arr[i]); // адрес i-го элемента
    uint8_t read2 = pgm_read_byte(pgm_arr + i); // равносильно
    // read1 == read2 == 1, 2, 3
}

Функции для массивов #

В библиотеке для PROGMEM есть набор функций для работы с памятью - это привычные функции для работы с памятью, но с суффиксом _P на конце, они принимают в качестве исходного массива массив из PROGMEM. Например скопируем массив из PGM (из примера выше) в буфер:

// буфер размером с PGM массив
uint8_t arr[sizeof(pgm_arr)];

// копируем из PROGMEM в RAM
memcpy_P(arr, pgm_arr, sizeof(pgm_arr));

// arr == {1, 2, 3}

Многомерные массивы #

С многомерными массивами всё немного сложнее, т.к. атрибут PROGMEM помещает в PROGMEM только самую младшую размерность массива, т.е. непосредственно сами данные. Остальная конструкция массива остаётся в оперативной памяти!

const uint8_t parr[][5] PROGMEM = {{1, 1, 1, 1, 1}, {2, 2, 2, 2, 2}, {3, 3, 3, 3, 3}};
for (int i = 0; i < 3; i++) {       // 3 внутренних массива
    // указатель на i-массив в PGM, читаем из оперативки
    const uint8_t* arr = parr[i];   // #1
    for (int j = 0; j < 5; j++) {   // по 5 элементов
        pgm_read_byte(&arr[j]);     // читаем элементы
        // 1, 1, 1, 1, 1
        // 2, 2, 2, 2, 2
        // 3, 3, 3, 3, 3
    }
}

Список указателей #

Есть ещё один способ хранения многомерных массивов: сначала сохранить в PGM сами массивы с данными, а затем сделать массив указателей на эти массивы. Это позволяет хранить в PGM массивы разной длины, а не одинаковой, как в таблице. Чтобы указатели на массивы не занимали места в оперативке как глобальные переменные - их тоже можно сохранить в PROGMEM. Чтение будет отличаться в строке #1 - нам придётся прочитать адрес следующего массива из PROGMEM, а не просто из массива. В данном примере я оставил массивы одинаковой длины, чтобы не менять код. Важна сама суть, дальше это пригодится нам для строк:

// массивы с данными
const uint8_t parr1[5] PROGMEM = {1, 1, 1, 1, 1};
const uint8_t parr2[5] PROGMEM = {2, 2, 2, 2, 2};
const uint8_t parr3[5] PROGMEM = {3, 3, 3, 3, 3};

// массив указателей на них в PGM, в PGM
const uint8_t* const parr[] PROGMEM = {parr1, parr2, parr3};
for (int i = 0; i < 3; i++) {       // 3 внутренних массива
    // указатель на i-массив в PGM, читаем из PROGMEM
    const uint8_t* arr = (const uint8_t*)pgm_read_ptr(&parr[i]);    // #1
    for (int j = 0; j < 5; j++) {   // по 5 элементов
        pgm_read_byte(&arr[j]);     // читаем элементы
        // 1, 1, 1, 1, 1
        // 2, 2, 2, 2, 2
        // 3, 3, 3, 3, 3
    }
}

Строки в PROGMEM #

PGM строки могут использоваться для хранения частей текста для последующей сборки новой строки уже в оперативной памяти, для печати или вывода на дисплей (пункты меню). Строка - это такой же массив, просто с символами - типа char[]. Массив можно инициализировать строковым литералом, т.е. в массив символов запишется указанный текст вместе с завершающим нулём:

// массив в PGM с текстом "pgm string"
const char pstr[] PROGMEM = "pgm string";

Здесь "pgm string" не существует в программе - этот текст будет записан в массив pstr, т.е. просто расположен в памяти по некому адресу.

PGM_P #

PGM-строка, как и обычный массив символов, может преобразовываться и передаваться как const char*. Чтобы визуально в программе отделить указатели на RAM строки от PGM строк, используют тип PGM_P - это просто синоним const char*:

PGM_P p = pstr;     // pstr из примера выше

Суффикс _P #

Строка в PROGMEM может передаваться в фукнции как const char*, что может привести к сбою в программе: функция будет ожидать строку из оперативной памяти, а ей отправят из PROGMEM. Чтобы разрешить эту неоднозначность, договорились добавлять к именам функций, ожидающих данные из PROGMEM, суффикс _P: например strlen и strlen_P. Во многих Arduino библиотеках также есть функции и методы с суффиксом _P.

Для работы с PGM строками точно так же есть аналоги строковых функций, полный список см. в справочнике. Например измерим длину строки и запишем её в буфер в оперативной памяти:

// pstr из примера выше
char buf[strlen_P(pstr) + 1];   // буфер для строки
strcpy_P(buf, pstr);            // копировать из PGM в RAM буфер

// buf == "pgm string"

Буфер для строки всегда должен быть на 1 длиннее, чем строка (для нулевого символа)!

Локальные и PSTR() строки #

PGM строку можно создать и локально, сделав её статической:

{
    static const char pstr[] PROGMEM = "pgm string";
    char buf[strlen_P(pstr) + 1];
    strcpy_P(buf, pstr);
    // buf == "pgm string"
}

Это типовая конструкция, для которой существует макрос PSTR(s) - делает по сути то же самое и возвращает указатель на строку. Адрес строки нужно сохранить в указатель, чтобы им пользоваться:

PGM_P p = PSTR("pstr string");
strlen_P(p);                // длина 11
strcmp_P("pstr string", p); // сравнили, результат 0

Макрос сделан так, что его можно и подавать напрямую в _P функции:

char buf[20];
strcpy_P(buf, PSTR("pgm string"));
// buf == "pgm string"

Есть нюанс - PGM строки не оптимизируются компилятором, как обычные литералы, что логично - это же сразу данные в памяти. Одна и та же строка будет продублирована столько раз, сколько её вызовут с PROGMEM или из PSTR():

static const char pstr[] PROGMEM = "pgm string";    // копия 1
char buf[strlen_P(PSTR("pgm string")) + 1];         // копия 2
strcpy_P(buf, PSTR("pgm string"));                  // копия 3

Так что если такая строка нужна несколько раз - объявляем глобально через PROGMEM или локально с сохранением указателя в PGM_P, как в примере выше.

PSTR() строки могут быть созданы только локально внутри функции! Глобальный вызов приведёт к ошибке

PGM_P p1 = PSTR("str");     // ошибка компиляции

void foo() {
    PGM_P p2 = PSTR("str"); // нужно так
}

Класс __FlashStringHelper #

В Arduino есть специальный класс-обёртка для PROGMEM строк - __FlashStringHelper, позволяющий решить неоднозначность обычной перегрузкой без использования _P функций. Ардуиновский же интерфейс Print умеет печатать такие строки напрямую, без копирования их в оперативную память. Это позволяет печатать такие строки почти на все существующие дисплеи - библиотеки для них обычно поддерживают Print. Arduino-строки String также умеют создаваться из таких строк и прибавлять такие строки.

// Arduino Print.h
size_t print(const __FlashStringHelper *);  // печать строк из PGM
size_t print(const char *);                 // печать строк из RAM

Так как это просто обёртка, достаточно преобразовать свою PGM строку к (const __FlashStringHelper*):

PGM_P pstr = PSTR("pstr string");

// это сломает программу! Чтение по адресу в другой области памяти
//Serial.println(pstr);
//String s = pstr;

Serial.println((const __FlashStringHelper*)pstr);   // напечатает "pstr string"
String s = (const __FlashStringHelper*)pstr;        // создаст строку с "pstr string"

// можно и так
const __FlashStringHelper* fstr = (const __FlashStringHelper*)PSTR("pstr string");
Serial.println(fstr);   // напечатает "pstr string"

Макрос FPSTR() #

В ядре ESP8266 есть замечательный макрос FPSTR(s), который просто разворачивается в (const __FlashStringHelper*)s, что сильно сокращает код. Почему такой макрос не завезли в официальный Arduino SDK - загадка. Но вы можете добавить его самостоятельно:

#define FPSTR(s) (const __FlashStringHelper*)(s)

const char pstr_g[] PROGMEM = "global pgm str";

void setup() {
    Serial.begin(115200);
    PGM_P pstr = PSTR("local pgm str");

    Serial.println(FPSTR(pstr_g));  // напечатает "global pgm str"
    Serial.println(FPSTR(pstr));    // напечатает "local pgm str"
    Serial.println(FPSTR(PSTR("local pgm str")));
}

Макрос F() #

Также в Arduino есть удобный макрос F(s), который просто разворачивается в (const __FlashStringHelper*)PSTR(s) - сразу закидывает строку в PGM и преобразует к обёртке для вывода. По сути как раз то, что мы писали руками в последней строке предыдущего примера. Это позволяет сразу выводить такие строки из PGM памяти туда, где они поддерживаются:

Serial.println(F("F-str"));
String s = F("fstring");

Всё верно, добавление всего лишь одной буквы помещает строку в программную память и она больше не занимает места в оперативной!

Если строка нужна несколько раз в рамках одного блока кода - можно сделать на неё указатель:

const __FlashStringHelper* fstr = F("fstring");

Serial.println(fstr);

String s(fstr);
s += fstr;
// s == "fstringfstring"

Область существования #

Несмотря на то, что F() и PSTR() строки можно создавать только локально внутри функции - текстовые данные существуют в памяти по тому же адресу даже при отключении питания МК. Это означает, что можно абсолютно безопасно возвращать F- и PSTR-строки из функций и использовать в другом месте программы. Например так:

const __FlashStringHelper* getFstr() {
    return F("hello");
}

void foo() {
    Serial.println(getFstr());
}

Массивы строк #

Ещё одно частое применение PROGMEM - массив строк: для текстового меню, списка с названиями режимов и так далее. Его можно организовать несколькими способами, давайте рассмотрим большинство из них.

Локальный массив #

Самый простой вариант - локальный массив указателей на F- или PSTR-строки, т.к. запись очень лаконичная, а строки могут быть разной длины:

// PSTR()
PGM_P pgm_pstr_arr[] = {
    PSTR("pstr 0"),
    PSTR("pstr 11"),
    PSTR("pstr 222"),
};

// количество строк (по размеру указателя на платформе)
int len = sizeof(pgm_pstr_arr) / sizeof(void*);

for (int i = 0; i < len; i++) {
    Serial.println(FPSTR(pgm_pstr_arr[i]));
    Serial.println(strlen_P(pgm_pstr_arr[i]));
}
// F()
const __FlashStringHelper* fstr_arr[] = {
    F("fstr 0"),
    F("fstr 11"),
    F("fstr 222"),
};

// количество строк (по размеру указателя на платформе)
int len = sizeof(fstr_arr) / sizeof(void*);

for (int i = 0; i < len; i++) {
    Serial.println(fstr_arr[i]);
}
  • F-строки удобны для вывода напрямую в print или работы с Arduino String
  • PGM_P-строки удобны для стандартных _P-функций работы с PGM строками

Глобальный массив #

Тут есть два варианта, как с двухмерными массивами из предыдущей главы: сделать обычный двухмерный массив с фиксированным размером для строк, либо отдельные массивы строк, а затем массив указателей на них.

Двухмерный массив #

// обязательно указать размер самой большой строки +1
const char pstr_arr[][14] PROGMEM = {
    "some string 0",
    "string 1",
    "kek",
};
sizeof(pstr_arr);                       // == 42, общий размер
sizeof(pstr_arr[0]);                    // == 14, буфер строки

int len = sizeof(pstr_arr) / sizeof(pstr_arr[0]);
// len == 3, кол-во строк

for (int i = 0; i < len; i++) {
    Serial.println(FPSTR(pstr_arr[i])); // напечатаем

    // перепишем в буфер и напечатаем
    char buf[strlen_P(pstr_arr[i]) + 1];
    strcpy_P(buf, pstr_arr[i]);
    Serial.println(buf);
}

Плюсы:

  • Компактная запись
  • Удобный доступ

Минусы:

  • Строки занимают одинаковый объём памяти, указанный в [] - независимо от своей длины

Массив указателей #

// сами строки
const char pstr0[] PROGMEM = "some string 0";
const char pstr1[] PROGMEM = "string 1";
const char pstr2[] PROGMEM = "kek";

// массив указателей тоже в PROGMEM, чтобы не занимали места
const char* const pstr_arr[] PROGMEM = {pstr0, pstr1, pstr2};
int len = sizeof(pstr_arr) / sizeof(void*);
// len == 3, кол-во строк

for (int i = 0; i < len; i++) {
    // читаем адрес строки (указатель) из PROGMEM
    PGM_P pstr = (PGM_P)pgm_read_ptr(&pstr_arr[i]);
    //PGM_P pstr = (PGM_P)pgm_read_ptr(pstr_arr + i);  // или так

    Serial.println(FPSTR(pstr));    // напечатаем

    // перепишем в буфер и напечатаем
    char buf[strlen_P(pstr) + 1];
    strcpy_P(buf, pstr);
    Serial.println(buf);
}

Плюсы:

  • Строки хранятся отдельно друг от друга и занимают каждая свой размер

Минусы:

  • Довольно многословная запись, но можно обернуть в макросы
  • Каждая строка занимает на 1 размер указателя (2/4 байта) больше места

Составные данные #

Атрибутом PROGMEM можно пометить любые данные, например структуру. Рассмотрим пример с записью и чтением:

struct Data {
    int16_t val;
    uint8_t arr[3];
    char str[10];
};

const Data pdata PROGMEM = {
    -123,
    {1, 2, 3},
    "Hello",
};
Serial.println((int16_t)pgm_read_word(&pdata.val)); // -123
Serial.println(pgm_read_byte(&pdata.arr[0]));       // 1
Serial.println(pgm_read_byte(&pdata.arr[1]));       // 2
Serial.println(pgm_read_byte(&pdata.arr[2]));       // 3
Serial.println(FPSTR(pdata.str));                   // Hello

Помимо pgm_read_xxx функций и работы со строками можно использовать memcpy_P для чтения данных из PGM в RAM, например можно просто взять и создать локальную копию экземпляра структуры из PGM:

Data data;
memcpy_P(&data, &pdata, sizeof(Data));  // копируем

Serial.println(data.val);       // -123
Serial.println(data.arr[0]);    // 1
Serial.println(data.arr[1]);    // 2
Serial.println(data.arr[2]);    // 3
Serial.println(data.str);       // Hello

Точно так же можно создавать массивы структур и в целом комбинировать данные любым удобным образом.

Библиотека pgm_utils #

Использование PROGMEM и стандартной библиотеки на Си довольно многословно. Я написал свою "обёртку" для PROGMEM - библиотеку pgm_utils с использованием инструментов C++, она сильно сокращает код и делает его более читаемым. Вот некоторые примеры использования:

// одиночные данные
PGM_VAL(int, vali, 123);
PGM_VAL(float, valf, 3.14);

struct Test {
    byte i;
    char str[10];
};
PGM_STRUCT(Test, ptest, 10, "test");

void test() {
    // чтение, авто-тип
    Serial.println(pgm_read(&vali));  // 123
    Serial.println(pgm_read(&valf));  // 3.14

    // копирование структуры из PGM
    Test t = pgm_read(&ptest);
    Serial.println(t.i);    // 10
    Serial.println(t.str);  // test
}
// массивы
PGM_ARRAY_OBJ(float, parr, 1.12, 2.34, 3.45);

void test() {
    for (int i = 0; i < parr.length(); i++) {
        Serial.println(parr[1]);    // 1.12, 2.34, 3.45
    }
}
// массивы строк
PGM_STR_LIST_OBJ(strlist, "str1", "str2", "str3");

void test() {
    for (int i = 0; i < strlist.length(); i++) {
        Serial.println(strlist[i]); // str1, str2, str3
    }
}

Скорость работы #

За удобство и лёгкий вес приходится платить - согласно моим тестам (на AVR), чтение из PROGMEM через memcpy_P медленнее, чем из RAM, причём чем больше объём памяти - тем меньше разница, но она стремится к 30%:

  • 1 байт - 4 такта из RAM и 30 из PGM (750%)
  • 10 байт - 74 такта из RAM и 111 из PGM (150%)
  • 25 байт - 179 тактов из RAM и 245 из PGM (37%)
  • 50 байт - 354 такта из RAM и 471 из PGM (33%)
  • 100 байт - 704 такта из RAM и 921 из PGM (31%)
  • 200 байт - 1404 такта из RAM и 1821 из PGM (30%)

1 такт в данном случае 0.0625 мкс (ATMega328p 16MHz)

В то же время, побайтное чтение массива через pgm_read_byte оказалось на 17% быстрее из PROGMEM, чем из RAM.

5 1 голос
Рейтинг статьи
Подписаться
Уведомить о
guest

2 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх