Почему считается плохой практикой использовать ключевое слово "new" в Arduino?

Я уже задавал этот вопрос:

Требуется ли удалять переменные перед сном?

На этот вопрос @Delta_G опубликовал этот комментарий:

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

Этот комментарий получил три лайка, и когда я гуглю о динамическом выделении с помощью Arduino, все также стараются держаться от этого подальше. Подводя итог всем исследованиям, которые я сделал, мой вывод теперь Не выделять память, если вы действительно действительно должны.

Я использую IDE Visual Studio для создания своих библиотек C++, которые я намерен использовать с Arduino. На Arduino IDE я просто ссылаюсь на эти библиотеки, и код отлично компилируется. Visual Studio очень мощная и позволяет мне создавать действительно хороший код, потому что я могу протестировать его на своем компьютере перед запуском на Arduino. Например, я создал эту библиотеку:

// MyQueue.h

typedef struct QueueItem
{
    void* item;

    QueueItem* next;

    QueueItem()
    {
        item = nullptr;
        next = nullptr;
    }

} QueueItem;


class Queue
{
public:
    unsigned char count;        /* Количество элементов в очереди */
    QueueItem* first;           /* Указывает на первый элемент в очереди */

    Queue()                     /* Конструктор */
    {
        count = 0;
        first = nullptr;
    }

    void enqueue(void* item)    /* Помещает объект в очередь */
    {
        count++;

        if (first == nullptr)
        {
            first = new QueueItem();
            first->item = item;

            // Лог сообщение, потому что мы используем "New" ключевое слово. Нам нужно убедиться, что мы избавимся от QueueItem позже

            #ifdef windows

            std::cout << "Creating " << first << endl;

            #endif // windows
        }
        else {

            // Найти последний элемент
            QueueItem* current = first;
            while (current->next != NULL)
            {
                current = current->next;
            }
            QueueItem* newItem = new QueueItem();
            newItem->item = item;

            // Log сообщение, потому что мы используем ключевое слово "new". Нам нужно убедиться, что мы избавимся от QueueItem позже
            #ifdef windows
            std::cout << "Creating " << newItem << endl;
            #endif // windows

            current->next = newItem;
        }
    }

    void* dequeue()
    {
        if (count == 0)
            return nullptr;

        QueueItem* newFirst = first->next;

        void* pointerToItem = first->item;

        // Log message мы удаляем объект, потому что создали его с помощью ключевого слова 'new' 
        #ifdef windows
        std::cout << "Deleting " << first << endl;
        #endif // windows

        delete first;
        first = newFirst;
        count--;

        return pointerToItem;
    }

    void clear()                /* Очистка очереди */
    {
        while (count > 0)
        {
            dequeue();
        }
    }

    ~Queue()                    /* Деструктор. Освободить все */
    {
        clear();
    }
};

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

typedef struct Foo
{
    int id;
} Foo;

void someMethod()
{

    Queue q;

    // Создание элементов
    Foo a;
    a.id = 1;

    Foo b;
    b.id = 2;

    // Постановка в очередь a,b и c
    q.enqueue(&a);
    q.enqueue(&b);

    // Deque
    Foo * pointerTo_a = (Foo*)q.dequeue();
    int x = pointerTo_a->id; // =1

    Foo * pointerTo_b = (Foo*)q.dequeue();
    int y = pointerTo_b->id; // =2

    // Ошибка
    Foo * test = (Foo*)q.dequeue();
    // test == null pointer
}

Большинство людей говорят, что не используйте пустые указатели. Почему?! Поскольку я использую указатели void, теперь я могу использовать этот класс queue с любым объектом, который мне нужен!

Поэтому я думаю, что мой вопрос таков: почему все стараются держаться подальше и избегать такого кода?

Я использую радиомодуль NRF24L01 для отправки сообщений нескольким ардуино. Удобно иметь очередь сообщений для отправки. Я мог бы закодировать ту же программу, не выделяя памяти и избегая нового ключевого слова. Но этот код, на мой взгляд, будет выглядеть некрасиво.

В этом карантине я решил изучить C++, и это изменило способ, которым я кодирую Arduino. В тот момент, когда я узнал C++, я перестал использовать Arduino IDE. Я был поддержанным разработчиком в течение 12 лет, и именно поэтому я изучил C++ за пару месяцев. Arduino для меня просто хобби. Я все еще новичок в микроконтроллерах, и мне хотелось бы понять, почему люди держатся подальше от полной мощности C++, когда речь заходит о микроконтроллерах. Я знаю, что у меня всего 2 килобайта оперативной памяти. Я не буду выделять столько памяти. Я все еще хочу воспользоваться языком программирования C++, используя новые , удаляющие, указатели и деструкторы. Я хочу продолжать использовать Visual Studio для написания мощных библиотек C++.

В C++ я пишу такие интерфейсы

// Примечание Я использую uint32_t вместо "unsigned long", потому что unsigned long отличается размером в Windows от Arduino. Также я использую unsigned short вместо int, потому что unsigned short имеет одинаковый размер на Windows и Arduino.

class IArduinoMethods
{
public:

    // Unsigned long в Arduino
    virtual void delay(uint32_t delayInMilliseconds) = 0;

    virtual void print(const char* text) = 0;


    virtual uint32_t millis() = 0; // Получить прошедшее время в миллисекундах
};

И тогда я реализую эти классы следующим образом. Например, это класс, который я буду использовать при тестировании моего кода на компьютере с Windows:

// Класс, который будет запущен в Windows.
class ArduinoMockWindows : public IArduinoMethods
{
public:

    // Унаследовано через переопределение IArduinoMethods
    virtual void delay(uint32_t delayInMilliseconds) override
    {
        // Этот код будет отличаться на Arduino, и именно поэтому мне нужна эта зависимость
        Sleep(delayInMilliseconds); // Windows
    }


    virtual uint32_t millis()
    {
        //clock_begin = std::chrono::steady_clock::now();

        std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now();
        auto duration = now.time_since_epoch();
        // и т.д...
        return someDuration;

    }


};

Поскольку компьютер Windows не может отправлять радиосообщения NRF24, я могу реализовать интерфейс (зависимость), который будет писать в файл, например, вместо отправки реального радиопакета только для тестирования.

Предостережение состоит в том, что мои библиотеки потребуют этих зависимостей. Чтобы моя библиотека работала, я должен буду передать ей объект типа IArduinoMethods и INrfRadio. Если я выполняю свой код на Windows, я передам ему класс, который будет реализовывать те методы, которые могут работать на Windows. В любом случае, суть не в том, чтобы показать, как работает C++. Я просто показываю, как я использую указатели и выделяю память для многих вещей.

Поскольку я выделил память, я смог протестировать свою библиотеку на Windows и, например, на Arduino. Я также могу создавать модульные тесты. Я вижу так много преимуществ, выделяя память. Если я буду организованным и не забуду освободить предметы, которыми я больше не пользуюсь, я смогу получить все эти преимущества. Почему люди не кодируют так, когда дело доходит до Arduino?


Изменить 1


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

Я ненавижу, когда люди делают то, что им говорят, не понимая, как все работает. Например, ответ https://arduino.stackexchange.com/a/77078/51226 во-первых, Почему библиотека очередей находится в этом вопросе?. Будут времена, когда кольцевой буфер работает лучше, и другие времена, когда новое ключевое слово работает лучше. Вероятно, кольцевой буфер будет работать лучше всего для большинства случаев.

Возьмите следующий сценарий, где у вас осталось только 1 КБ памяти.

  1. Существует иерархия узлов, где у узла есть ребенок и брат. Например, у узла A может быть ребенок B и брат C. Затем у ребенка B может быть другой ребенок и т. Д.

(Я буду хранить это в памяти)

  1. У меня есть очередь работы, которую нужно сделать.

(Мне придется где - то хранить эту работу)

  1. У меня будет очередь событий

(Мне придется хранить это где-то)

Если я использую то, что большинство людей говорят, что я должен сделать, то я должен буду:

  1. Зарезервируйте 500 кБ, чтобы иметь возможность хранить узлы (я буду ограничен количеством узлов n)

  2. Зарезервируйте 250 кБ для очереди работы, которую необходимо выполнить.

  3. Зарезервируйте 250 кБ для очереди событий.

Это то, что сделает большинство людей, и это будет отлично работать без проблем фрагментации кучи.

А теперь вот что я сделаю

  1. Убедитесь, что все, что я выделяю, имеет размер 12 байт. Узел имеет только свой идентификатор (unsigned int), дочерний элемент (указатель), тип (unsigned char) и т. Д. В общей сложности 12 байт.

  2. Убедитесь, что вся работа, которая будет поставлена в очередь, также имеет размер 12 байт.

  3. Убедитесь, что все события, которые будут поставлены в очередь, также имеют размер 12 байт.

Теперь, если у меня будет больше работы, чем событий, это сработает. Я просто должен запрограммировать в своем коде, что я никогда не выделяю больше 70 элементов. У меня будет глобальная переменная, которая имеет такое количество распределений. Мой код будет более гибким. Мне не придется зацикливаться строго на 20 событиях, 20 работах и 30 узлах. Если у меня будет меньше узлов, то я смогу иметь больше событий. **В любом случае я хочу сказать, что одно решение не лучше другого. Будут сценарии, когда одно решение будет лучше.

В заключение просто поймите, как работает фрагментация кучи, и вы получите большую мощность, используя новое ключевое слово. Не будь овцой и делай то, что тебе говорят, не понимая, как все устроено.


Правка 2


Благодаря @EdgarBonet я закончил тем, что хранил узлы в стеке. Вот почему:

У меня есть иерархия узлов, которая может быть представлена следующим образом:

typedef struct Node
{
   unsigned short id;
   Node * sibling;
   Node * child;
} Node;

Как вы можете видеть, каждый узел имеет всего 6 байт. Это еще одна причина, по которой я не очень заботился о распределении узлов в самом начале. Если я выделю этот узел в куче, то потеряю еще 2 байта (33%) для каждого распределения, потому что при каждом распределении должен быть сохранен размер узла. В результате я создал эти два метода и буфер:

// Чтобы это сработало, узел никогда не должен иметь идентификатор 0 !!!

Node nodeBuffer[50];                     /* Буфер для хранения узлов в стеке */

Node* allocateNode(Node nodeToAllocate)  /* Метод хранения узла */
{
    // Найти первое доступное место, где можно сохранить узел
    for (char i = 0; i < 50; i++)
    {
        if (nodeBuffer[i].id == 0)
        {
            nodeBuffer[i] = nodeToAllocate;
            return & nodeBuffer[i];
        }
    }
    return nullptr;
}

void freeNode(Node* nodeToFree)          /* Метод удаления узла */
{
    nodeToFree->id = 0; // Если идентификатор узла равен 0, то это мое соглашение о том, что он удален.
}

А в моем коде раньше были такие вещи, как:

Node * a = new Node();
a->id = 234423;
// ....
// .. etc
// ..
delete a;

Теперь мне просто нужно заменить этот код на:

Node * a = allocateNode({});
a->id = 234423;
// ....
// .. etc
// ..
freeNode(a);

И мой код работает точно так же, без необходимости использовать новое ключевое слово. Я думал, что будет сложно реорганизовать код и создать буфер.

Я сделал эту модификацию, потому что хотел иметь возможность хранить больше узлов в своем коде. Потеряв эти 33%, я не смог бы создать столько. Если я выделяю только объекты одного размера и не выделяю их так много, то совершенно нормально использовать новое ключевое слово. > Кроме того, в случае очереди я буду выделять и удалять объекты очень быстро. Потому что объекты не будут сохраняться в памяти слишком долго, и шансы иметь фрагментацию кучи очень низки.

, 👍12

Обсуждение

короткий ответ: нет никакого управления кучей, чтобы дефрагментировать ее. таким образом, вы можете выделить при настройке или создать пул объектов, но не удалять/освобождать память., @Juraj

Потому что у вас очень ограниченное пространство памяти и нет операционной системы, чтобы очистить за вами. Это означает, что вы должны сделать много вещей вручную, которые вы обычно не должны делать, как наблюдать за фрагментацией кучи. Добавьте к этому тот факт, что Arduino-это одна программа на одном устройстве, и это устраняет большую часть преимуществ использования кучи памяти. Тебе больше не с кем поделиться, так что можно быть скупым. Но это в основном о том, что у вас нет операционной системы, чтобы держать вещи в чистоте для вас., @Delta_G

1. Этот вопрос выглядит как дубликат [Is using malloc() and free() a really bad idea on Arduino?]"(https://arduinoprosto.ru/q/682). 2. Перед разыменованием указателя, возвращаемого "new", вы должны проверить, что это не " nullptr`. В противном случае ваш код неизбежно рухнет при исчерпании памяти. 3. Для данного конкретного случая использования используется [кольцевой буфер]. (https://en.wikipedia.org/wiki/Circular_buffer), по-видимому, самый простой, безопасный для памяти вариант. Это [то, что "Серийный номер" использует для постановки в очередь bytes](https://github.com/arduino/ArduinoCore-avr/blob/1.8.3/cores/arduino/HardwareSerial.h#L113-L114)., @Edgar Bonet

-1 ибо "Не будь овцой и делай то, что тебе говорят.", @null

Тогда вы можете быть овцой @null и делать то, что вам говорят люди ;) быть очень нулевым ., @Tono Nam

Я не могу назвать это ответом, потому что он не нацелен на Arduino, но на некоторых встроенных платформах с очень небольшим объемом памяти для таких вещей, как управление кучей, "maloc" реализован как "uint8_t* rval = __frontier; __frontier += sz; return rval", а "free" -это no-op. Излишне говорить, что на такой платформе использование кучной памяти таким образом идет плохо!, @Cort Ammon

Конечно, вы правы, но, как показывает ваше редактирование, использование динамического распределения безопасно снимает некоторые неудобные ограничения. Я бы не рекомендовал его новичку (т. Е. большинству из тех, кто задает здесь вопросы). Он также имеет свою собственную стоимость памяти: два байта на выделенный фрагмент плюс некоторое заполнение, потому что более чем маловероятно, что все ваши объекты естественным образом имеют одинаковый размер., @Edgar Bonet

@CortAmmon: У вас есть пример? Avr-libc malloc() выполняет управление кучей, и он работает на устройствах, таких как ATtiny13A (1 KiB flash, 64 байта оперативной памяти). Не то, чтобы было бы очень разумно использовать его на таком устройстве..., @Edgar Bonet

@EdgarBonet: да, я трачу впустую 2 байта на каждое распределение, которое выполняю :/ . Мне бы хотелось, чтобы куча хранила размеры в "символе", а не использовала 2 байта, так как я никогда не храню ничего больше 255. Поскольку я храню очень мало объектов, я буду использовать этот метод, продолжая использовать этот подход. Есть ли способ проинструктировать компилятор хранить размеры объектов кучи, используя один байт вместо двух?, @Tono Nam

@TonoNam Вы могли бы сжать это, используя простое сегрегированное хранилище, как это делает boost.pool. Это позволяет вам использовать куски произвольного размера (за счет фрагментации, если вы, конечно, не освободите их равномерно). Крайняя версия этого варианта, с 1 большим куском, конечно же, является типичным подходом резервирования хранилища, который вы описали выше., @Cort Ammon

@CortAmmon спасибо, что было бы здорово узнать, но я думаю, что я очень сложный редеет. Я просто закончил тем, что сохранил эти элементы в буфере. Я думал, что это будет трудно реорганизовать, когда это не так. Спасибо за помощь, @Tono Nam

@TonoNam Ты получил мой даунвот за этот последний абзац твоей первой правки. Вы агрессивно защищаете свое первоначальное решение, утверждая, что существуют сценарии, в которых оно может быть лучшим решением, - как будто кто-то утверждает, что это не так. Я нахожу такое отношение типа "Я мог бы выйти сухим из воды с тем, что у меня было в первую очередь" несколько детским. Поскольку вы сделали то, что вам сказали (и это справедливо, потому что вы получили хороший совет), ваша рекомендация явно ложна: делать то, что вам говорят, на самом деле является ключом к пониманию вещей., @null

Re: "Если я выделяю только объекты одного размера и не выделяю так много, то совершенно нормально использовать новое ключевое слово". Но какое преимущество это дает вам в этом пункте? Если объекты имеют заданный размер и у вас есть место для их заданного количества, то почему бы вам просто статически не выделить столько? Речь идет не только о том, что вы могли бы подумать о последствиях того, что все пойдет не так. Я думаю, что вы действительно не узнаете этого, пока он не укусит вас. Но в один прекрасный день ты будешь говорить людям то же самое, что мы говорим тебе, потому что тебя это укусит., @Delta_G

Ре: "В заключение просто поймите, как работает фрагментация кучи, и вы получите большую силу, используя новое ключевое слово. Не будь овцой и делай то, что тебе говорят, не понимая, как все устроено. Ты говоришь, что ненавидишь, когда люди слепо следуют правилам. Терпеть не могу, когда кто-то, кому приходится задавать подобные вопросы, думает, что я просто овца, идущая за ним. Я знаю, что делаю. Я знаю, где опасность. Ты думаешь, что перехитрил его. До тебя я видел тысячи людей, которые думали так же. В конце концов все они получают один и тот же тяжелый урок. И ты тоже., @Delta_G


5 ответов


Лучший ответ:

17

Большинство ардуино (например, Uno или Nano) имеют очень мало оперативной памяти, поэтому вам сначала нужно убедиться, что вы никогда не выделяете слишком много памяти. Динамическое выделение памяти также может привести к фрагментации кучи (кучи являются частью памяти, где происходит динамическое выделение).

В большинстве случаев вы хотели бы выделить память разных размеров (например, массивы разных размеров) или просто разные объекты (каждый из которых имеет свой собственный размер) (!!! Это ключевой момент здесь). Затем вы удалите некоторые из этих объектов, которые создадут дыры в памяти. Они могут быть снова заполнены объектами того же или меньшего размера. По мере того, как проходит время и происходит больше выделения и удаления, эти отверстия, как правило, становятся меньше, вплоть до точки, где ни один из ваших новых выделяемых объектов не может поместиться там. Эта память тогда непригодна. Это явление называется фрагментацией кучи.

Эти дыры естественным образом появляются и на ПК, но есть 2 ключевых отличия:

  1. У Arduino так мало оперативной памяти, что дыры могут очень быстро заполнить вашу память.

  2. В то время как ПК имеет операционную систему, которая управляет оперативной памятью (дефрагментирует ее или помещает неиспользуемые данные в файл подкачки/подкачки), Arduino не имеет операционной системы. Поэтому никто не следит за реальной доступной оперативной памятью и не убирает ее время от времени.

Это не значит, что вы не можете использовать динамическое распределение на Arduino, но это очень рискованно в зависимости от того, что именно вы делаете и как долго программа должна работать без сбоев.

Учитывая это большое предостережение, вы очень ограничены в том, как использовать динамическое распределение. Если вы будете делать это слишком часто, то получите очень нестабильный код. Остальные возможности, где это может быть безопасно использовать, также могут быть легко выполнены с помощью статического распределения. Например, возьмем вашу очередь, которая в основном представляет собой связанный список. Где проблема с выделением массива QueueItems в самом начале? Каждый элемент получает способ определить, является ли он действительным. При создании нового элемента вы просто выбираете первый элемент в массиве, который имеет недопустимый элемент, и устанавливаете ему нужное значение. Вы все еще можете использовать данные с помощью указателей, как и раньше, но теперь они у вас есть со статическим распределением.

Вы можете обнаружить, что код выглядит более уродливым, но вам нужно адаптироваться к платформе, которую вы используете.

Обратите внимание, что это неприменимо, если вы собираетесь создавать только объекты одинакового размера. Любой удаленный объект оставит отверстие, в которое может поместиться любой новый объект. Компилятор использует этот факт. Так что в таком случае вы в безопасности. Просто каждый объект, который вы динамически создаете в своей программе, должен быть точно такого же размера. Это, конечно, также включает в себя объекты, созданные внутри различных библиотек или классов. По этой причине это все еще может быть плохим выбором дизайна, так как вы или другие люди (если вы хотите опубликовать свой код) можете захотеть связать свою библиотеку с другим кодом.

Еще один способ обезопасить себя-создавать и удалять объекты только в замкнутых циклах, то есть созданный объект должен быть удален до создания следующего. Хотя это не подходит для вашего приложения.


"Большие" микроконтроллеры, например платы без Arduino с ESP32, имеют гораздо больше памяти. Таким образом, использование динамического выделения не так уж плохо на них. Хотя у вас все еще нет операционной системы для управления оперативной памятью.

,

Большое спасибо за объяснение. Я уже закодировал множество библиотек, используя указатели. Итак, я предполагаю, что теперь я должен быть осторожен с количеством объектов, которые я размещаю правильно? Теперь код работает, потому что я выделяю очень мало объектов, и в куче всегда есть место для новых объектов. До тех пор пока мои выделения невелики по размеру и я не распределяю их на множество объектов я должен быть хорошим правильно?, @Tono Nam

есть ли какой-нибудь способ де-фрагментировать память вручную?, @dandavis

@TonoNam Да, это правильно. Вы должны учитывать размер, скорость выделения и то, как долго должен работать код. Предполагая постоянную скорость распределения, код может работать хорошо в течение дня. Но если вы хотите, чтобы он работал как месяц, это может быть совсем по-другому. Если вы хотите использовать динамическое распределение, вам нужно будет просто проверить это., @chrisl

@dandavis Я не знаю другого способа, кроме как просто удалить все динамические объекты и воссоздать их заново. Если вы сделаете это, я бы предположил, что тогда у вас снова будет линейная куча памяти без дыр в ней (эффективно дефрагментированная). Хотя я недостаточно разбираюсь в компиляторе, но могу это гарантировать. Кроме того, я не думаю, что это действительно вариант в большинстве случаев., @chrisl

"Большинство ардуино (таких как Uno или Nano) имеют очень мало оперативной памяти" Плюс программы Arduino обычно являются контроллерами, которые, как ожидается, будут работать без присмотра; они не запускаются до завершения и не перезапускаются в другой раз, как большинство настольных программ. Таким образом, даже компьютер с большой памятью, использующий распределение " new " / "delete", в конечном итоге тоже фрагментирует свою память. Это просто займет (намного) больше времени., @JRobert

@dandavis: Единственный способ дефрагментировать выделенную память " new " /delete - это не делать этого. Вместо этого я объявляю массив буферов фиксированного размера и раздаю один из них, независимо от того, сколько запрашивает вызывающий (до размера буфера, конечно). Этот механизм не вызовет фрагментации, потому что вы никогда не разделяете блоки, поэтому каждое отверстие можно использовать повторно. Вы должны знать - или хорошо догадываться! - максимальное количество буферов, которое когда-либо понадобится вашему приложению, и объявите еще несколько для безопасности., @JRobert

@dandavis Да, вы можете де-фрагментировать память. Это называется "управление памятью", и хотя в операционной системе есть сложная программа для этого, вы можете написать простую. Самое большое предостережение заключается в том, что вы должны обновлять все указатели на объект после его перемещения, поэтому вы должны быть в состоянии найти каждый указатель на него из вашей процедуры управления памятью. Я не знаю, уместна ли такая программа на Arduino. Не пробовал., @jpaugh

Я бы предложил использовать [ETL](https://www.etlcpp.com/), который похож на STL, но для встроенного программирования. Он предоставляет все векторы, очереди, связанные списки, карты, итераторы, алгоритмы и другие полезные вещи из стандартной библиотеки C++, вообще не используя динамическое распределение. (Вы должны указать максимальный размер вашей структуры, и все будет обрабатываться автоматически внутри нее). Примечание: Я не имею никакого отношения к ETL, я просто довольный его пользователь., @vsz

Для реализации очереди, вероятно, лучше иметь указатели или индексы первого и последнего элемента, чем давать каждому элементу действительный флаг. Таким образом, доступ становится O(1), гораздо проще обернуть массив для повторного использования памяти, и нет никаких странных ситуаций, таких как "дыры", которые вы не пытались выразить., @Jasper

Я бы добавил к этому, что динамическое распределение часто происходит медленно и иногда непредсказуемо с точки зрения таймингов (из-за фрагментации и других эффектов), поэтому, если вы пишете чувствительный к производительности или времени код, часто лучше избегать его, даже если использование памяти не является проблемой., @Austin Hemmelgarn


7

Динамическое распределение, как правило, не рекомендуется во встроенных приложениях, поскольку вы не можете гарантировать, что не превысите (не попытаетесь выделить больше) доступной памяти. Статическое распределение, как правило, имеет эту гарантию, хотя ошибки нехватки памяти все еще могут быть возможны.

Кроме того, доступно гораздо меньше сервисов или инструментов для автоматического управления памятью и управления ею за вас. Любая служба, которая делает это, будет потреблять вычислительные ресурсы.

Это означает, что вы по своей сути создаете механизм в своем устройстве, который может вызвать переполнение памяти (кучи) и возможное неопределенное поведение (UB). Это верно, даже если ваш код не содержит ошибок и не имеет утечек памяти.

В некритичных, исследовательских, обучающих и прототипных приложениях это может быть не важно.

Учтите, что без тщательного рассмотрения неопределенное поведение может привести к сбоям оборудования и небезопасной производительности, например, если устройство перенастроит GPIO из-за ошибочной записи в правильные регистры во время сбоя.

,

Да *Динамическое распределение, как правило, не рекомендуется*. +1 за использование слова в целом. В некоторых случаях это может сработать. Мне просто нужно будет отслеживать, сколько объектов я выделяю, и убедиться, что все они имеют одинаковый размер., @Tono Nam


5

Для начала исправьте свою библиотеку

Как отмечает @crasic, динамическое выделение памяти, как правило, не рекомендуется для встроенных систем. Это может быть приемлемо для встроенных устройств с большим объемом свободной памяти - например, обычно используется встроенный Linux, и все приложения/службы Linux будут использовать динамическое распределение памяти, - но на небольших устройствах, таких как Arduino, просто нет гарантии, что это сработает.

Ваша библиотека иллюстрирует одну общую причину, по которой это является проблемой. Ваша функция enqueue() создает новый элемент очереди (), но не проверяет, что распределение прошло успешно. Результатом неудачного распределения может быть либо исключение C++ bad_alloc, либо возврат нулевого указателя, который при обращении к нему выдаст исключение доступа к системной памяти (сигнал SIGSEGV в Linux, например). В программировании Linux и Windows почти повсеместно игнорируется сбой выделения памяти (как рекомендуется большинством учебников), поскольку огромный объем свободной оперативной памяти и наличие виртуальной памяти делают это очень маловероятным, но это неприемлемо во встроенном программировании.

В более общем плане, хотя, как говорит @crasic, фрагментация памяти может привести к тому, что даже код без ошибок не сможет выделить память. Результатом будет сбой в выделении памяти, но код, по крайней мере, будет знать, что это произошло, и, вероятно, сможет продолжить.

Но лучше, вместо этого используйте очередь FIFO фиксированного размера

Ваш код использует динамическое выделение для добавления и удаления элементов в очереди. Вполне возможно (и столь же просто с точки зрения кодирования) создать массив фиксированного размера для очереди, поэтому различные режимы динамического распределения отказов просто неприменимы. Элемент, подлежащий постановке в очередь, просто копируется в следующий свободный слот очереди, и слот очереди помечается как свободный, когда он был использован. (Не забывайте использовать мьютекс при добавлении и удалении элементов из очереди, потому что добавление и удаление часто вызываются из разных мест.)

Очередь может быть любого размера, который вы считаете подходящим (с учетом того, сколько у вас оперативной памяти). С фиксированным размером вы вынуждены принимать проектное решение о том, что произойдет, если очередь переполнится - вы удаляете самые старые данные, чтобы освободить место для нового значения, или игнорируете новое значение? Это может показаться нежелательной новой функцией, но это хорошо, потому что третий вариант, который у вас есть в настоящее время, заключается в том, что ваш код говорит "Аааааааааааааааааааааааааа, я не знаю, что делать!" и вылетает со смертельным исходом, а мы этого действительно не хотим.

,

Очередь FIFO фиксированного размера обычно реализуется в виде [кольцевого буфера](https://en.wikipedia.org/wiki/Circular_buffer)., @Edgar Bonet

@EdgarBonet Да, это так. Однако OP должен быть в состоянии найти множество существующего кода для FIFOs в Интернете, так что я думаю, что меня больше интересует помощь им в разработке концепции, чем то, как именно они реализуют ее., @Graham

Исправление FIFO размером с кольцевой буфер-отличное решение. Будут времена, когда кольцевой буфер будет лучше (вероятно, в большинстве случаев), а есть и другие, когда это не так. Взгляните на редактирование вопроса. Если вы следите за распределениями и никогда не превышаете количество распределений одинакового размера, у вас не должно возникнуть проблем с выделением нового элемента очереди., @Tono Nam

Именно Грэм ваш ответ, вероятно, является лучшим решением для большинства случаев, а также самым безопасным. Но в тех случаях, когда я строю иерархию узлов, будет сложнее создать ее с помощью буфера. Если я уже распределяю узлы, я думаю, что могу продолжать распределять их, используя очередь. Я хочу сказать, что если вы знаете, как все работает, то нормально не соглашаться с конвенцией. Кроме того, вероятность того, что распределение не сработает, очень мала, так как я не использую прерывания и не запускаю свой код в разных местах. Для остальных моих проектов я, вероятно, буду использовать ваше решение., @Tono Nam

@TonoNam Вы предполагаете, что степень детализации вашего распределения снизится как минимум до 4 байт. Если степень детализации в лучшем случае составляет 8 байт, то каждое 12-байтовое выделение фактически будет занимать 16 байт оперативной памяти. Таким образом, ваш код на основе кучи сможет хранить только 3/4 того, что может быть сохранено в статически выделенной очереди. Плюс любые накладные расходы, связанные с запуском кучи, которые могут быть значительными, когда вы выделяете небольшие объемы данных (а 12 байт обычно "маленькие"). Вы не можете просто разделить длину ОЗУ на длину структуры и предположить, сколько их у вас получится., @Graham

Именно по этой причине я внес правку № 2 в вопрос Грэма. Спасибо за всю помощь., @Tono Nam


2

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

Допустим, мы разрабатываем код на Arduino UNO. У нас есть 2K оперативной памяти для работы. У нас есть класс, который загружает список имен, может быть, это устройство доступа к зданию или что-то в этом роде. В любом случае, у этого гипотетического класса есть поле имени для хранения чьего-либо имени. И мы решаем использовать класс String для хранения имени в виде строки.

Допустим, после того, как вся наша программа будет готова и сделает то, что она делает, для этого списка объектов останется 500 байт, каждый с полем имени, которое может быть различной длины. Таким образом, мы прекрасно справляемся в течение многих лет с командой из 14 или 15 человек со средней длиной имени 30 символов или около того.

Но однажды записывается новый парень. И имя у него действительно длинное. Допустим, он занимает 100 символов. Даже если мы умные программисты и у нас есть только одна копия этой строки в памяти, она у нас есть, и вдруг она не подходит. Теперь программа, которая работала годами, внезапно дает сбой. И никто не знает, почему.

Так что решение простое, верно? Установите максимальное ограничение на длину имени. Какой-нибудь простой код, который проверяет длину имени, и мы можем написать отличный фрагмент, который по-прежнему допускает длину имени переменной и не позволит вам создать нового пользователя, если его осталось меньше. Кажется достаточно простым, но потом Бекки из бухгалтерии выходит замуж, и ее фамилия меняется с Смит на Вольфешлегельштейнхаузенбергердорф, и вдруг наша программа снова без всякой причины прерывается.

Так что решение простое, верно? Мы установим максимальную длину и обязательно зарезервируем достаточно памяти для каждого объекта, чтобы он мог позволить себе имя максимальной длины. И это то, что мы делаем наиболее эффективно без динамического распределения, поскольку мы уже знаем размер объекта.

Я слышу, что вы говорите: "Но Дельта-Джи, если у нас там есть все эти короткие имена, зачем мы будем тратить на них всю эту память, когда в этом нет необходимости? Давайте сэкономим место только для длинного, если у нас будет длинный". И мне нравится ваше мышление, это хорошая мысль. Но это ничему не помогает, чтобы сохранить эту память. Если вы все-таки сэкономите немного, что вы собираетесь с этим делать? Если ваша программа использует его, то для более длинного случая больше нет места, и внезапно вам приходится устанавливать еще более короткую максимальную длину для такого использования.

Скажем, например, у нас есть 500 байт места, и мы устанавливаем максимальную длину 50 байт для 10 пользователей. И давайте скажем, что, когда имена короткие, мы хотим, чтобы программа использовала часть этого сэкономленного пространства. Если программа может занять 100 байт в этом пространстве, когда имена короткие, то почему бы не повторить ту же ситуацию с длинными именами? Так что на самом деле, поскольку программа может использовать все, кроме 400 байт, то на самом деле места всего 400 байт, и мы должны установить максимум 40 байт для 10 пользователей или 50 байт для 8.

Поскольку вам все равно придется пойти на эту жертву, тогда имеет смысл только убрать работу по динамическому распределению и просто сделать вещи фиксированного размера или использовать буферы фиксированного размера.

Если бы у нас был компьютер с гигабайтами памяти, мы бы даже не думали об этом. Но на Arduino UNO с 2 Тыс. байт памяти это может очень быстро стать большой проблемой.

Другая проблема заключается в том, что эти ошибки настолько коварны. Если бы ошибки с нехваткой памяти просто вызвали простой сбой и эта штука больше не работала, это было бы не так страшно. Но это не то, как ошибки с нехваткой памяти работают на микроконтроллере. Все зависит от того, как все устроено в памяти компилятором.

Эти ошибки часто проявляются как нечто, что, кажется, работает большую часть времени, но имеет некоторые забавные ошибки, которые никто не может объяснить. Возможно, проблема возникает только в том случае, если у кого-то есть имя длиной ровно 26 символов, и он пытается открыть дверь в среду. Или, может быть, проблема возникнет только в том случае, если Бекки войдет в систему сразу после Боба. Может быть, это просто искажает три буквы на экране, но в остальном все работает. Может быть, с другим измененным именем, которое внезапно превращается в наш замок, открывающийся для любого. Нет никаких догадок или объяснений ошибок в памяти. Поэтому мы должны быть очень осторожны, чтобы избежать даже отдаленной возможности столкнуться с одним из них.

И именно поэтому мы избегаем использования динамического распределения на небольших микроконтроллерах. В конце концов, вы ничего не можете с этим поделать, и даже если бы могли, последствия того, что что-то немного не так, ужасно расстраивают. С такими типами программ вам почти всегда приходится в конечном итоге вводить какие-то ограничения на все, и как только вы устанавливаете ограничения, динамическое распределение больше не используется.

,

Снова в своем примере из реальной жизни вы выделяете объекты разных размеров (имена). Это приведет к фрагментации кучи. Если вам нужно выделить имена одинакового размера и их очень мало, то вполне можно использовать распределение кучи., @Tono Nam

Но, как я уже сказал в примере, если вы выделяете фиксированное количество вещей фиксированного размера, то почему вы хотите использовать для этого динамическое распределение? В чем было бы преимущество в этот момент?, @Delta_G

Что если у меня несколько очередей, мне не нужно создавать по одному буферу для каждой. Могут быть случаи, когда распределение полезно. Я хочу сказать, что если вы понимаете, как это работает, то можете его использовать. Вероятно, в большинстве случаев нет смысла его использовать, но не никогда., @Tono Nam

Да, не никогда, но и не за то, о чем ты думаешь. Вы сталкиваетесь с той же проблемой. Если у вас несколько очередей и вы хотите, чтобы они могли совместно использовать буферное пространство, то, безусловно, было бы проще выделить один буфер и один класс, который управляет добавлением и удалением. Я думаю, что, вероятно, все, что может быть добавлено в очередь, должно наследовать какой-то базовый класс, который позволяет вам выполнять код в одном месте. Это именно тот вариант использования, о котором я говорю, когда вы думаете, что знаете, что делаете, и однажды какой-то угловой случай, о котором вы никогда не подозревали, укусит вас., @Delta_G

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

Единственное хорошее исключение, о котором я могу думать, - это когда у вас есть класс, который вам нужно создать позже в коде. Возможно, вам понадобится некоторый пользовательский ввод, прежде чем вы сможете создать экземпляр. Таким образом, у вас просто есть указатель, а затем в коде вы можете создать новый объект с новым и установить этот указатель. Но в этом случае план состоит в том, чтобы создать объект на ранней стадии программы и оставить его там на все время. В этом случае вы никогда не планируете освобождать или удалять объект и создавать другой., @Delta_G


-1

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

Куча на простых устройствах, таких как Arduino, - это то, что ближе всего к тому, что обычно называют системой "база и границы" (технически это выделенный стек, который также можно посмотреть, если вам интересно). Память где-то выделяется, а затем помечается информацией о том, где она находится в памяти и сколько ее в ней. Когда он освобождается, эта область снова становится свободной. Проблемы возникают из-за того, что это пространство должно быть смежным. Думайте о выделенной памяти как о массиве (которым она в основном и является); нет никакой логики, чтобы сказать: "ой, здесь есть еще один объект, пропустите 37 байт и продолжайте.

При повторном выделении фрагменты памяти распределяются и перемещаются дальше по куче, так как два фрагмента памяти, очевидно, не могут занимать одно и то же пространство. Память может снова стать доступной, если часть ее освободится, но при этом остальная часть будет "плавать" выше в куче, в середине пространства памяти (если вы когда-либо играли в определенную 3D-воксельную строительную игру, подумайте о добыче части ствола дерева). Дальнейшие выделения затем заполнят части очищенных областей в освобожденных частях (или в верхней части используемой кучи, если они не поместятся в очищенные области), но, если только недавно выделенная память не будет точно такого же размера, как то, что было удалено, образуются отверстия, в которых свободная память слишком мала, чтобы вместить что-либо еще. В конце концов, может быть много свободной памяти, но ее нельзя использовать, так как нет смежных разделов, даже если здесь и там есть куча байтов.

Как уже упоминалось, один обходной путь состоит в том, чтобы гарантировать, что вся выделенная память имеет одинаковый размер, но это может не помочь, когда так много систем Arduino использует динамическую память самостоятельно. Это в основном связано со строками (особенно с их возвратом, конкатенацией или установкой одной из них в другую), но вполне могут быть и другие утилиты, которые ее используют, что делает саму отладку опасной. Все, что позволяет вам "регистрировать" обратный вызов, прослушиватель или пользовательскую функцию, также, скорее всего, будет использовать динамическую память, и я полагаю, что библиотека WiFi с функцией "сканирования" или библиотека файловой системы (например, SD-карта), вероятно, также будут использоваться.

Многие встроенные строковые библиотеки на самом деле делают полные копии кучи памяти на возврат или определение, так что даже если исходную строку, затем автоматически освобождаются по строке библиотеке, когда переменная выходит из области видимости, были коротко два экземпляра этого элемента в куче, вызывая фрагментацию (поскольку второй был сдвинут вперед первым, оставляя отверстие при первом удаляется, а второй остается).

Вероятно, было бы безопасно, если бы вы использовали динамическое выделение памяти в очень небольших объемах и нечасто, до тех пор, пока нет способов отобразить максимальный объем выделенной памяти таким образом, чтобы предотвратить выделение нового фрагмента, но это, по общему признанию, раздражает при проверке.

Не совсем идеальное решение существует, когда вы используете кучу как что-то вроде стека, и вы выделяете все очень большое свободное пространство во время выполнения задачи, а затем освобождаете его, прежде чем выполнять следующий элемент или иным образом снова выделяете память. Это позволяет вам решать проблемы, которые заняли бы больше памяти, чем если бы вы использовали глобальную память для каждой, но, опять же, это также можно сделать, если вы просто заключите этот раздел кода в фигурные скобки и используете локальный массив (который помещает его в фактический стек). Обратите внимание, что некоторые системы (например, ESP8266, которые могут быть запрограммированы с помощью платформы Arduino, но, безусловно, не являются Arduino) могут иметь ограничения по размеру стека, поэтому в некоторых случаях может потребоваться динамическая память.

Интересно, что, несмотря на выделение кучи стеком, фактический стек невосприимчив к этой проблеме. Это связано с тем, как работает программирование, ориентированное на возврат. Любой вызов функции или выделение памяти в стеке (что, как вы знаете, делается путем определения переменной обычным способом) всегда помещается сверху, и когда он возвращается или выходит за пределы области, удаляется. Поскольку нет способа удалить объекты из середины стека, фрагментации никогда не происходит, и, следовательно, ваша свободная память всегда доступна, если вы не превышаете физические ресурсы. Таким образом, распределение стека завершается неудачно только тогда, когда выделенная память перестает вести себя как стек (что, к сожалению, часто случается).

В заключение отметим, что современные компьютерные операционные системы используют гораздо более продвинутую технику, называемую подкачкой, к которой подходит ваше решение (ядро может иметь и использовать исключения из этого правила, поскольку оно критично по времени, а подкачка немного медленнее, но есть причина, по которой кодирование ядра настолько кропотливое). При подкачке вся память специально распределяется блоками одинакового размера, и блоки могут быть распределены так, чтобы наилучшим образом удовлетворять запросам на память. Однако блоки могут быть расположены в любом порядке и даже не должны быть физически смежными в памяти. Это означает, что невозможно выделить часть памяти, так как блоки всегда имеют одинаковый размер (и, следовательно, всегда помещаются в отверстия). Это блок управления памятью, который выполняет остальную часть магии, превращая все эти крошечные блоки в то, что кажется большим объемом памяти (а также управляя правами собственности на память, но это здесь не имеет значения).

К сожалению, для подкачки требуется аппаратная поддержка, гораздо больше памяти для хранения таблицы страниц и поддержка операционной системы (или, по крайней мере, какой-то менеджер, который периодически запускает и реорганизует вещи для вас). Ничего из этого нет в Arduino, и на самом деле это могло бы вызвать проблемы для кода реального времени, который изначально предназначался для работы на процессоре Arduino. Помните, что Arduino был построен вокруг небольшого дешевого чипа, который имел программный интерфейс и компилятор с открытым исходным кодом (и несколько других проектов, у которых "заимствованы" код и IDE), и никогда не был предназначен для тех применений, для которых мы его обычно используем (вот почему компьютерное программирование-это не совсем то же самое, что встроенная разработка; кодер должен мыслить гораздо более низкоуровневым способом, чем обычно, и поэтому обычно используется C/C++-это несколько ближе к сборке, чем большинство других языков, хотя C++ уходит от этого). Даже архитектура процессора AVR довольно красноречиво говорит о его реальном варианте использования: высокая скорость обработки в реальном времени. Шины памяти и данных независимы, что позволяет выполнять код даже во время передачи данных из оперативной памяти, EEPROM или pin ввода-вывода.

Существует полезное бесплатное руководство по разработке операционных систем под названием "Операционные системы: три простых элемента", которое является источником некоторых из того, что я узнал, а затем написал здесь. Поскольку авторы не хотят, чтобы его главы были связаны напрямую, а на целевой странице есть несколько ложных ссылок на случайные книги, я не могу связать его напрямую, но вы всегда можете посмотреть его, если вам интересно. Вам, конечно, понадобятся темы по управлению памятью, и сами главы связаны в разделе "Цветная таблица".

,