Распределение памяти на Arduino Due никогда не возвращает NULL

Я работаю над проектом, который использует довольно много оперативной памяти для хранения и анализа данных, отправляемых с ПК. Программа в значительной степени полагается на malloc/free, который обычно работает просто отлично. Однако, если набор данных становится большим - чего я не могу сказать заранее, - Arduino просто умирает. Некоторый анализ показал, что это связано с управлением памятью. Запуск простого скетча, который просто делает

void loop() {
  int SIZE_TO_TEST = (1024 * 10);
  int* mem = (int*)malloc(SIZE_TO_TEST);
  if (mem == nullptr)
  {
    // Этого никогда не бывает
    Serial.print("Allocation failed\r\n");
    return;
  }
  Serial.print("Allocation succeeded\r\n");
  Serial.print((int)mem, HEX); // это всегда печатает 0x200712E0, независимо от размера
  Serial.println();
  for (int i = 0; i < SIZE_TO_TEST / 4; i++)
  {
    mem[i] = i;
  }

  for (int i = 0; i < SIZE_TO_TEST / 4; i++)
  {
    if (mem[i] != i)
    {
      // Вам лучше купить новую плату, если это произойдет
      Serial.print("HARDWARE ERROR: Memory broken\r\n");
    }
  }
  free(mem);
}

работает нормально и неоднократно печатает "Выделение выполнено успешно". Когда я начинаю увеличивать SIZE_TO_TEST, процессор выходит из строя, когда значение превышает эффективный размер памяти 96k. Странно то, что даже когда значение равно 1000k (далеко за пределами того, что на самом деле имеет uP), указатель, возвращаемый malloc(), по-прежнему остается тем же адресом памяти, а не NULL, как можно было бы ожидать. Сбой также не происходит, если выделенная память не используется (если два цикла for закомментированы), поэтому я подозреваю, что фактический сбой происходит потому, что программа пытается записать в память, которой на самом деле нет.

Как узнать, что память исчерпана? Почему malloc() никогда не возвращает NULL?

(Примечание: я знаю о проблемах фрагментации памяти с небольшими микроконтроллерами, но я могу обойти эту проблему)

, 👍3

Обсуждение

Только что видел: проблема также обсуждается здесь: https://forum.arduino.cc/index.php?topic=381203.0 . Однако никакого решения не было дано., @PMF

Программное обеспечение Arduino с открытым исходным кодом. Я уверен, что решение может быть найдено., @jwh20

Я могу дать вам ответ, почему он терпит неудачу. Но я не могу точно сказать вам, что вы должны с этим делать., @timemage

Основываясь на ответе @timemage, я бы предложил открыть отчет об ошибке на Arduino, а также на Newlibc., @Gabriel Staples

На [ArduinoCore-sam] (https://github.com/arduino/ArduinoCore-sam ) проект может быть. Я вообще не верю, что это действительно проблема Newlib., @timemage


2 ответа


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

7

Почему

На данный момент это частичный ответ, в основном в отношении:

Почему malloc() никогда не возвращает NULL?

Таким образом, Due, по-видимому, использует Newlib в качестве своей реализации libc; это среда выполнения systems C (стандартная и некоторые нестандартные), которая включает malloc()или большую часть malloc() в любом случае. Newlib malloc() полагается на функцию sbrk(), которая реализуется конкретной системой, должной в данном случае. Это имя и основная идея исходят из Unix; вы можете прочитать о взаимодействии между malloc и sbrk some без необходимости связывать его с Newlib или Due конкретно. Короче говоря, sbrk расширяет область, с которой должен работать malloc, и malloc делит эту область на запрошенные куски. Вот документация Newlib для sbrk и пример реализации.

Ядро Due (SAM) для Arduino реализует sbrk следующим образом:

extern caddr_t _sbrk ( int incr )
{
    static unsigned char *heap = NULL ;
    unsigned char *prev_heap ;

    if ( heap == NULL )
    {
        heap = (unsigned char *)&_end ;
    }
    prev_heap = heap;

    heap += incr ;

    return (caddr_t) prev_heap ;
}

Это своего рода дегенеративная реализация _sbrk, которая просто слепо говорит: "Вы хотите больше памяти, конечно, здесь вы идете" без ограничений. Если вы посмотрите на пример реализации sbrk для Newlib, вы увидите, что он просто вызывает abort() вместо правильного сбоя, который приведет к тому, что malloc вернет NULL, что, по общему признанию, не очень хорошо, но, по крайней мере, он не притворяется, что все в порядке.

Где-то в этом коде должно быть условие, которое проверяет, есть ли еще место, и устанавливает errno = ENOMEM и эффективно возвращает -1, если его нет.

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

Таким образом, реализация _sbrk, предоставляемая ядром Due / sam, должна быть изменена таким образом, чтобы malloc терпел неудачу, когда это необходимо. Тем не менее, я не совсем уверен, как это следует сделать. Поэтому я воздержусь от конкретных рекомендаций по этому поводу. Может быть, я посмотрю на это больше, я вернусь и обновлю это чем-то более полезным, обратившись к вашему другому вопросу:

Как узнать, что память исчерпана?

Однако в грубых терминах ответ будет заключаться в том, что вы измените эту функцию так, чтобы она возвращала -1 с ENOMEM при правильных условиях.


Как

Это ранее упомянутое обновление с некоторыми подробностями о том, как:

Как узнать, что память исчерпана?

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

Из таблицы семейства ATSAM3X8E:

SR 0 доступен по системной шине Cortex-M3 по адресу 0x2000 0000 , а SR1 - по адресу 0x2008 0000. Пользователь может видеть SRAM как непрерывный благодаря зеркальному эффекту, давая 0x2007 0000 - 0x2008 7FFF для SAM3X / A8

Указатель стека начинается в верхней части памяти 0x2008 7FFF и растет вниз. Символ _end обозначает конец изменяемых переменных длительности статического хранения или начало кучи. Где-нибудь поближе к 0x2007 0000; чем меньше этих переменных, тем ближе.

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

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

В /.arduino15/packages/arduino/hardware/sam/ 1.6.12/core/arduino/syscalls_sam3.cя включил некоторые дополнительные заголовки:

#include <errno.h>
#include <stdlib.h>

И поставьте в качестве замены _sbrk() следующее:

size_t __malloc_margin = 4 * (size_t)1024;
extern caddr_t _sbrk (int incr) {
    static const unsigned char *       heap      = (unsigned char *)&_end ;
    /****/ const unsigned char * const prev_heap = heap;

    if (incr > 0) {
        // Дополнительные проверки для роста стека


        if (incr >= 96 * (size_t)1024) {
            // Due имеет только 96K оперативной памяти,
            // поэтому любой запрос на 96K или больше
            // - это провал за воротами.
            errno = ENOMEM;
            return (caddr_t)-1;
        }

        unsigned char *stack_ptr;

        asm volatile(
            "mov %[sp_out], sp"
            : [sp_out] "=r" (stack_ptr)
            :
        );

        if (stack_ptr < heap) {
            // We're already in real trouble.
            abort();
        }

        if (stack_ptr - heap  <  incr + __malloc_margin) {
            // Недостаточно памяти, учитывая запас прочности.
            errno = ENOMEM;
            return (caddr_t)-1;
        }
    }

    heap      += incr ;
    return (caddr_t) prev_heap ;
}

__malloc_margin использовался в качестве имени переменной только потому, что это имя avr-libc использует для той же цели, а именно для настройки минимального расстояния между стеком и кучей во время выделения, которое должен увеличить разрыв. Кстати, вы можете видеть, что такая же проверка происходит в реализации avr-libc malloc (которая также принимает роль sbrk.) Почему __malloc_margin 4K ? Никаких по-настоящему научных причин. Это около 4% памяти, так что она невелика, и для меня не совсем немыслимо, что кто-то попытается поместить локальную переменную (ы), приближающуюся к 4k, в кадр стека в системе с общей памятью 96kb. Однако 4K может быть неподходящим значением по умолчанию; об этом следует подумать подробнее. Наличие его в такой переменной позволяет настраивать его во время выполнения; обычно для него существует заголовок, который предоставляет объявление extern.

Я могу добавить больше позже, чтобы объяснить, но это коротко, если разрыв увеличивается, этот sbrk смотрит, попадет ли новый разрыв в __malloc_margin указателя стека по запросу, и если он вернет значение -1 и errno = ENOMEM, чтобы заставить malloc ведите себя правильно с точки зрения пользователя.

Прежде чем кто-нибудь потрудится упомянуть: Да, я знаю, что вы можете взять локальный адрес, чтобы получить значение указателя стека. Да, проверка того, не превышает ли однократное выделение 96K, вероятно, не нужна. Да, в нем есть что-то конкретное, хотя это не совсем так; вероятно, есть идентификаторы, которые можно было бы использовать вместо этого. Вероятно, он должен прервать (), если sbrk попытается опуститься ниже кучи размером 0. Это просто несколько рабочая иллюстрация того, что должно быть в _sbrk(). Больше размышлений и по крайней мере одно изменение должно быть внесено в него перед общим использованием.

Что касается abort()

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

В комментариях вы спросили:

Что именно abort() [делает] на Должном?

Ну, в документации Newlib говорится:

Перед завершением программы abort вызывает исключение SIGABRT (используя ‘raise (SIGABRT)’).

а потом еще ниже:

Необходимые вспомогательные подпрограммы ОС: _exit и, опционально, write.

Части SIGABRT и _exit - это более или менее стандартное поведение. Таким образом, он вызовет ваш обработчик сигнала для SIGABRT, если он у вас есть.

Итак, что же делает _exit? Это длится вечно.

Я вызвал abort() в скетче и сбросил следующее через arm-версию binutils objdump с параметрами командной строки -dS.

Вот соответствующий код abort():

00081b98 <abort>:
   81b98:   b508        push    {r3, lr}
   81b9a:   2006        movs    r0, #6
   81b9c:   f000 fb5c   bl  82258 <raise>
   81ba0:   2001        movs    r0, #1
   81ba2:   f7fe fec5   bl  80930 <_exit>
   81ba6:   bf00        nop

bl <raise>, в свою очередь, вызовет ваш обработчик сигнала. а bl <_exit> вызывает exit, который отображается следующим образом:

00080930 <_exit>:

extern void _exit( int status )
{
   80930:   e7fe        b.n 80930 <_exit>

00080932 <_kill>:

    for ( ; ; ) ;
}

Похоже, оптимизатор объединил здесь некоторые вещи, но по сути _exit имеет инструкцию, которая перескакивает (разветвляется) на себя, эквивалентную for(;;);.

Я наполовину ожидал abort(); код для отключения прерываний перед вызовом _exit(). Если вы планируете abort() в своем _sbrk(), об этом стоит подумать. Вы также можете указать глобальный флаг, который можно проверить из SIGABRT, что позволяет обработчику сигнала делать что-то диагностически подходящее специально для сбоев _sbrk().
Возможно, sbrk следует установить флаг в разделе данных no-init, а затем установить сторожевой таймер и позволить ему истечь (или предоставить обработчику SIGABRT информацию для этого). Есть много вещей, которые нужно обдумать.

,

Можете ли вы сказать мне, в каком файле находится реализация _sbrk? Похоже, это хороший анализ. Может быть, моя идея ниже может помочь с отсутствующим условием?, @PMF

Это связано с ответом ["Arduino реализует sbrk"] (https://github.com/arduino/ArduinoCore-sam/blob/790ff2/cores/arduino/syscalls_sam3.c#L64 ). Это "syscalls_sam3.c", который я собираюсь обновить с некоторой дополнительной информацией., @timemage

Есть еще кое-что для тебя. Я уверен, что он будет обновлен снова, чтобы исправить ошибки, если ничего больше., @timemage

Хорошо, я попробую это в следующий раз. Довольно близко к тому, что я исследовал в качестве обходного пути. Что означает "abort()" именно в Связи с Этим?, @PMF

Я обновил ответ, чтобы рассмотреть, что "abort()" делает на должном уровне, и немного о том, как это может взаимодействовать с заменой "_sbrk()"., @timemage


2

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

const int MIN_FREE_STACK = 512;

void* mallocEx(int size)
{
  void* stackPtr = alloca(4); // Это возвращает указатель на текущее дно стека
  PrintAddress("StackPtr ", stackPtr);
  void* ptr = malloc(size);
  PrintAddress("NewInstance ", ptr);
  if ((byte*)ptr + size > ((byte*)stackPtr - MIN_FREE_STACK))
  {
    free(ptr); // Эта ячейка памяти на самом деле не является допустимым
    return nullptr;
  }
  return ptr;
}

Метод проверяет при каждой попытке выделения памяти, попадет ли вновь выделенный блок в стек вызовов (+ некоторый запас прочности). Если это так, то указатель снова освобождается и возвращается null.

Я попробовал с этим немного более требовательным тестом, и он, кажется, работает так, как ожидалось (выделяет кучу блоков, пока не выйдет из строя на отметке 91k, а затем перезапускается).


void ValidateMemoryManager()
{
  const int oneK = 1024;
  const int maxMemToTest = 100; // arduino due имеет 96k памяти
  void* ptrs[maxMemToTest];
  int idx = 0;
  int totalAllocsSucceeded = 0;
  while (idx < maxMemToTest)
  {
    void* mem = mallocEx(oneK);
    ptrs[idx] = mem;
    if (mem == nullptr)
    {
      break;
    }
    idx++;
  }

  // Происходит в симуляции
  if (idx == maxMemToTest)
  {
    idx--;
  }
  
  totalAllocsSucceeded = idx;
  while (idx >= 0)
  {
    free(ptrs[idx]);
    idx--;
  }

   Serial.print("Total usable memory: ");
   Serial.print(totalAllocsSucceeded * oneK, DEC);
   Serial.println();
}
,

Ваши ответы заслуживают большей любви. Я бы тоже подумал об использовании alloca(), потому что кажется, что он будет сопротивляться некоторым непредсказуемым результатам от оптимизатора. В конце концов я решил поместить туда крошечный кусок встроенной сборки, но я все еще не уверен, что это правильный выбор; alloca может иметь больше смысла. Я еще не прошел через все остальное, но мне кажется, я вижу, что вы делаете, и если я правильно понимаю, это работает почти так же, как неинвазивный способ обнаружения состояния отсутствия памяти., @timemage

@timemage Я еще не тестировал это, но один момент, который не работает (по крайней мере, не из коробки) с моим подходом, также правильно поддерживает new / delete. Может стать немного сложнее, потому что они не _supposed_ возвращают null при сбое, а выбрасывают исключения. И они не включены по умолчанию на arduinos., @PMF

Правильно. Теперь, когда я думаю об этом, я использовал термин "инвазивный" по отношению к ядру. Что касается вашего скетча, то он * есть *. Существуют беспроигрышные версии new и delete, но ваша точка зрения более или менее верна. Вам придется заменить их реализации, чтобы использовать свой собственный malloc. Или поочередно используйте свою пользовательскую функцию malloc, а затем используйте [placement-new] (https://en.cppreference.com/w/cpp/language/new#Placement_new ) и явный вызов деструктора. Если бы вы хотели немного сойти с рельсов, вы могли бы определить умные указатели, которые ничего не делают, кроме этого. Исправление sbrk(), вероятно, лучше., @timemage

@timemage только что проверил это: с вашим исправлением new возвращает null, если осталось недостаточно памяти. Похоже, они все-таки правильно поняли эту часть., @PMF