Если вы программируете на «большом» компьютере, то у вас такой вопрос, скорее всего, вообще не возникает. Стека много, чтобы его переполнить, нужно постараться. В худшем случае вы нажмёте ОК на окошке вроде этого и пойдете разбираться, в чем дело.

image

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

В этой статье я расскажу о собственных изысканиях на эту тему. Поскольку я программирую в основном под STM32 и под Миландр 1986 — на них я и фокусировался.

Введение


Представим самый простой случай — мы пишем простой однопоточный код без всяких операционных систем, т.е. стек у нас всего один. И если вы, как и я, программируете в uVision Keil, то память распределяется как-то так:



А если вы, как и я, считаете динамическую память на микроконтроллерах злом, то вот так:



Кстати
Если вы хотите запретить использование кучи, то можно сделать вот так:
#pragma import(__use_no_heap_region)

Подробности тут

Окей, и в чем проблема? Проблема в том, что Keil размещает стек сразу за областью статических данных. А стек в Cortex-M растет в сторону уменьшения адресов. И когда он переполняется, то он просто вылезает за пределы отведенного ему куска памяти. И перезаписывает какие-нибудь статические или глобальные переменные.

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

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

Окей, проблема понятна. Что делать?

MPU


Самое простое и очевидное — использовать MPU (сиречь, Memory Protection Unit). Позволяет назначать разным кускам памяти разные атрибуты; в частности можно окружить стек регионами «только для чтения» и ловить MemFault при записи туда.

Например, в stm32f407 MPU есть. К сожалению, во многих других «младших» stm его нет. И в Миландровском 1986ВЕ1 его тоже нет.

Т.е. решение хорошее, но не всегда доступное.

Ручной контроль


При компиляции Keil может генерировать (и делает это по-умолчанию) html-отчет с графом вызовов (опция линкера "--info=stack"). И в этом отчете приводится и информация об используемом стеке. Gcc тоже так умеет (опция -fstack-usage). Соответственно, можно иногда поглядывать в этот отчет (или написать скрипт, который делает это за вас, и вызывать его перед каждым билдом).

Причем в самом начале отчета написан путь, приводящий к максимальному использованию стека:



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

Хитрое размещение стека


Об этом способе я узнал из вот этой статьи. Статья про rust, но основная идея состоит в следующем:



При использовании gcc это возможно сделать с помощью "двойной линковки".

А в Keil’е расположение областей можно изменить с помощью своего скрипта для линкера (scatter file в терминологии Keil'a). Для этого нужно открыть опции проекта и снять галку «Use memory layout from target dialog». Тогда в поле «Scatter file» появится файл по-умолчанию. Он выглядит примерно так:


; *************************************************************
; *** Scatter-Loading Description File generated by uVision ***
; *************************************************************

LR_IROM1 0x08000000 0x00020000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00020000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
  }
  RW_IRAM1 0x20000000 0x00005000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

Что делать дальше? Возможны варианты. Официальная документация предлагает определить секции с зарезервированными именами — ARM_LIB_HEAP и ARM_LIB_STACK. Но это влечет за собой неприятные последствия, по крайней мере, для меня — размеры стека и кучи придется задавать в scatter-файле.

Во всех проектах, которые я использую, размер стека и кучи задается в ассемблерном startup-файле (который Keil генерирует при создании проекта). Менять его не очень хочется. Хочется, чтобы я просто включил в проект новый scatter-файл, и все стало хорошо. Поэтому я пошел немного другим путем:

Спойлер

#! armcc -E
; with that we can use C preprocessor 
 
#define RAM_BEGIN        0x20000000
#define RAM_SIZE_BYTES   (4*1024)
 
#define FLASH_BEGIN       0x8000000
#define FLASH_SIZE_BYTES (32*1024)
 
; This scatter file places stack before .bss region, so on stack overflow
; we get HardFault exception immediately
 
LR_IROM1 FLASH_BEGIN FLASH_SIZE_BYTES  {    ; load region size_region
  ER_IROM1 FLASH_BEGIN FLASH_SIZE_BYTES  {  ; load address = execution address
    *.o (RESET, +First)
    *(InRoot$$Sections)
    .ANY (+RO)
  }
 
  ; Stack region growing down
  REGION_STACK RAM_BEGIN {
    *(STACK)
  }
 
  ; We have to define heap region, even if we don't actually use heap
  REGION_HEAP ImageLimit(REGION_STACK) {
    *(HEAP)
  }
 
  ; this will place .bss region above the stack and heap and allocate RAM that is left for it
  RW_IRAM1 ImageLimit(REGION_HEAP) (RAM_SIZE_BYTES - ImageLength(REGION_STACK) - ImageLength(REGION_HEAP))  {  
    *(+RW +ZI)
  }
}


Тут я сказал, что все объекты по имени STACK должны размещаться в регионе REGION_STACK, а все объекты HEAP — в регионе REGION_HEAP. А все остальное — в регионе RW_IRAM1. И расположил регионы в таком порядке — начало оперативы, стек, куча, все остальное. Расчет на то, что в ассемблерном startup-файле стек и куча задаются с помощью вот такого кода (т.е. как массивы с названиями STACK и HEAP):

Спойлер
Stack_Size      EQU     0x00000400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp
                                                  
Heap_Size       EQU     0x00000200

                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

                PRESERVE8
                THUMB


Окей, возможно спросите вы, но что это нам дает? А вот что. Теперь при выходе за пределы стека процессор пытается записать (или прочитать) память, которой нет. И на STM32 при этом возникает прерывание по исключительной ситуации — HardFault.

Это не так удобно, как MemFault из-за MPU, потому что HardFault может возникать из-за множества причин, но, по крайней мере, ошибка получается громкой, а не тихой. Т.е. она возникает сразу, а не через неизвестный промежуток времени, как было раньше.

Что самое классное, мы ничем за это не заплатили, никакого оверхеда времени выполнения! Здорово. Но есть одна проблема.

Это не работает на Миландре.

Да. Конечно, на Миландрах (меня интересуют в основном 1986ВЕ1 и ВЕ91) карта памяти выглядит иначе. В STM32 до начала оперативы нет ничего, а на Миландрах до оперативы лежит область внешней шины.

Но даже если вы не используете внешнюю шину, то никакого HardFault'a вы не получите. А может и получите. А может быть, получите, но не сразу. Я не смог найти никакой информации на этот счет (что для Миландра неудивительно), а эксперименты не дали никаких внятных результатов. HardFault иногда возникал, если размер стека был кратен 256. Иногда HardFault возникал, если стек углублялся уж очень далеко в несуществующую память.

Но это даже неважно. Если HardFault не возникает каждый раз, то простое перемещение стека в начало RAM нас уже не спасает. И если уж совсем честно, STM тоже не обязан генерировать исключение при этом, спецификация ядер Cortex-M ничего конкретного на этот счет вроде бы не говорит.

Так что даже на STM это скорее хак, просто не очень грязный.

Значит, нужно искать какой-то другой способ.

Access breakpoint на запись


Если мы переместим стек в начало RAM, то предельное значение стека всегда будет одно и то же — 0x20000000. И мы можем просто поставить брейкпоинт на запись в эту ячейку. Это можно сделать командой и даже прописать в автозапуск с помощью .ini-файла:

// breakpoint on stackoverflow
BS Write 0x20000000, 1

Но это не очень надежный способ. Этот брейкпоинт будет срабатывать каждый раз при инициализации стека. Его легко случайно прибить, нажав «Kill all breakpoints». А еще он будет вас защищать только в присутствии отладчика. Не годится.

Динамическая защита от переполнений


Быстрый поиск на этот счет вывел меня к опциям Keil'a "--protect_stack" и "--protect_stack_all". Опции полезные, к сожалению, защищают они не от переполнения всего стека, а от залезания в стековый кадр другой функции. Например, если ваш код выходит за границы массива или неудачно работает с переменным числом параметров. Gcc, разумеется, тоже так умеет (-fstack-protector).

Суть этой опции в следующем: в каждый стековый кадр добавляется «guard variable», то бишь, сторожевое число. Если после выхода из функции это число изменилось, то вызывается функция-обработчик ошибки. Подробности тут.

Полезная штука, но не совсем то, что мне нужно. Мне нужна гораздо более простая проверка — чтобы при входе в каждую функцию значение регистра SP (Stack Pointer) сверялось с заранее известным минимальным значением. Но не писать же эту проверку руками на входе в каждую функцию?

Динамический контроль SP


К счастью, gcc имеет чудесную опцию "-finstrument-functions", которая позволяет вызывать пользовательскую функцию при входе в каждую функцию и при выходе из каждой функции. Обычно это используется для вывода отладочной информации, но какая разница?

К еще большему счастью, Keil вполне сознательно копирует функционал gcc, и там эта же опция доступна под названием "--gnu_instrument" (подробности).

После этого нужно всего лишь написать вот такой код:

Спойлер
// это специальный символ, который генерирует линкер
// это начало стека, регион для которого я сам так назвал в scatter-файле
extern unsigned int  Image$$REGION_STACK$$RW$$Base;
 
// чтобы получить его значение, нужно разыменование
static const uint32_t stack_lower_address = (uint32_t) &( Image$$REGION_STACK$$RW$$Base );
 
// эта функция вызывается при входе в любую функцию
extern "C" __attribute__((no_instrument_function)) void __cyg_profile_func_enter( void * current_func, void * callsite )
{
    (void)current_func;
    (void)callsite;
 
    ASSERT( __current_sp() >= stack_lower_address ); 
}
 
// а эта - при выходе
extern "C" __attribute__((no_instrument_function)) void __cyg_profile_func_exit( void * current_func, void * callsite )
{
    (void)current_func;
    (void)callsite;    
}


И вуаля! Теперь при входе в каждую функцию (в том числе в обработчики прерываний) будет выполняться проверка на переполнение стека. И если стек переполнился — будет ассерт.

Небольшие пояснения:
  • Да, разумеется, нужно проверять на переполнение с некоторым запасом, иначе есть риск «перепрыгнуть» через начало стека.
  • Image$$REGION_STACK$$RW$$Base — это особая магия получения инфы про области памяти с помощью констант, генерируемых линкером. Подробности (хотя не очень внятные местами) тут.


Идеально ли полученное решение? Конечно, нет.

Во-первых, эта проверка далеко не бесплатна, код от нее распухает процентов на 10. Ну и работать код будет медленнее (хотя я не измерял). Критично это или нет — решать вам; на мой взгляд, это разумная цена за безопасность.

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

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

Подведем итоги


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

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

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

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

Комментарии (65)


  1. ser-mk
    02.10.2018 13:12

    Спасибо за интересную статью!
    Сам когда занимался это проблемой, поступил следующим образом.
    В самом начале записывал во весь стек «guard variable», а потом в каком-нибудь таймере периодически проверял начало стека на это число.
    Так можно было кстати после продолжительной работы подключиться отладчиком и посмотреть сколько у нас «guard variable» осталось в стеке и какой запас еще есть.

    Это можно сделать командой и даже прописать в автозапуск с помощью .ini-файла

    Что за ini файл?


    1. vanxant
      02.10.2018 13:26

      Не особо поможет.
      1. До сработки проверки по таймеру можно и не дожить, т.к. система уже ушла в разнос.
      2. Ну поймали вы разрушение гарда по таймеру, дальше что? У вас нет стектрейса, чтобы понять, в какой момент это случилось.
      3. Пусть даже вы дожили до 2. У вас ровно один вариант действий — увеличивать стек (на сколько?) Так может просто сразу увеличить стек по максимуму, особенно если вы не юзаете кучу? И надеяться, что хватит…


      1. ser-mk
        02.10.2018 13:37

        Может и не дожить. Можно и в HardFault проверять стек на «guard variable» и там убедиться что кончился стек.
        Но в большинстве случаев стектрейс и не нужен ( если конечно вы в стеке случайно не выделяете большой массив). Иногда достаточно просто знать что стека мало и нужно добавить еще или просто посмотреть сколько запаса осталось.


    1. Amomum Автор
      02.10.2018 13:36

      Что за ini файл?

      В Кейле есть окно команд, в котором внезапно можно вводить команды — типа, добавить переменную в watch, поставить брейкпоинт, руками записать что-нибудь в память и тому подобное.
      Если хочется, чтобы пачка команд запускалась при каждом запуске отладки, то ее можно засунуть в текстовый файл с расширением .ini, который нужно прописать во вкладке Options->Debug->Initialization File.


    1. olekl
      02.10.2018 16:46

      Да, тоже так делал на маленьких 8-битниках, где РТОС в принципе невозможна…


  1. Amr
    02.10.2018 13:37

    До сих пор удивляюсь, почему в CPU для embedded без MMU нету например вот такого решения stack limit.
    Аппаратная проверка на каждом push/pop (STM/LDR для Cortex-M) вместо программных костылей imho намного эффективнее.
    Кстати решение с переносом стека в начало RAM работает только если стек один. Когда на борту RTOS данный подход не поможет.
    -finstrument-functions к сожалению также не совсем панацея (разве что вместе с проверкой границ стека во время context switch) так как позволяет проверять стек только на границах функции и если переполнение произошло по середине с последующим pop то содержимое памяти уже повреждено, а мы об этом не знаем :(
    А в целом статья поднимает интересную тему, спасибо.


    UPD: ARMv8-M поддерживает stack limit. Ждем STM32 на новой архитектуре


    1. Amomum Автор
      02.10.2018 13:43

      До сих пор удивляюсь, почему в CPU для embedded без MMU нету например вот такого решения stack limit.

      Да, я тоже удивляюсь. Вроде бы даже на PIC'ах stack limit есть аппаратно.

      Кстати решение с переносом стека в начало RAM работает только если стек один. Когда на борту RTOS данный подход не поможет.
      -finstrument-functions к сожалению также не совсем панацея (разве что вместе с проверкой границ стека во время context switch) так как позволяет проверять стек только на границах функции и если переполнение произошло по середине с последующим pop то содержимое памяти уже повреждено, а мы об этом не знаем :

      Собственно, проблемы-то разные. Одно дело — вылезание за границы стекового кадра, от чего можно частично защититься --stack-protect'ом, а другое — вылезание за границы стека вообще. Но их можно применять одновременно.

      Другое дело, что если в коде есть, допустим, ассемблерная вставка, которая просто лезет куда-нибудь в стек, то тут только MMU спасет. Но если программист так делает, то тут уж он сам должен думать.
      А так — хотя бы от глупых ошибок огородиться — уже хорошо.

      Как эту проблему на RTOS решать я пока особо не думал. По идее само по себе переключение контекста выход за границы стека принести не должно; только если вы уже вылезли за его границы и такой контекст сохранился. Но это должны отловить instrument-functions.


      1. Amr
        02.10.2018 13:47

        Мы делаем следующим образом (и вроде Keil RTX так же умеет)
        При аллокации стека к размеру добавляем footer в котором содержится некий magic number. При переключении контекста начало и размер стека известны, соответственно можно проверить значение в футере и таким образом предположить было ли переполнение.


      1. Serge78rus
        02.10.2018 16:02
        +1

        Как эту проблему на RTOS решать я пока особо не думал.
        Во FreeRTOS, например, есть собственные средства контроля использования стеков задач. Включается макросом
        #define INCLUDE_uxTaskGetStackHighWaterMark 1
        Затем можно вызывать функцию
        uxTaskGetStackHighWaterMark()
        передавая в качестве параметра handle задачи, для контроля использования стека задачей.
        Полагаю, в других RTOS тоже должно быть нечто подобное.


        1. lamerok
          03.10.2018 11:31

          Тоже самое в других операционках, это делается на уровне ядра, весь стек заполняется спец. символами, и когда задача деактивирована — идет проверка этих символов. Например в Embos, так. Если символов уже нет, значит стек переполнился, и вызывается спец. обработчик ошибки переполнения.
          С другой стороны, все это делается только на этапе отладки, во время разработки. Заполнил стек спец. символами, поставил на стресс тест на 2 дня, и через два дня посомтрел до куда максмимум стек долез. В любом случае в релизе нужно сделать так, чтобы максимальный размер уже был известен, это может кстати сделать компилятор, посчитать сразу максимальную вложенность стека. На неё и стоит оритентироваться.


  1. ilynxy
    02.10.2018 14:05

    Первый скриншот: безуспешно пытался понять, как можно нажать кнопку «Ok» на окне, где есть только "Break", "Continue" и "Ignore".
    Если по теме — проблема актуальная, но интрументацией она вроде неплохо решается на этапе отладки. Переполнение стека, на мой взгляд, это логическая ошибка (программист не расчитал нужный размер). Понятно, что в сложных системах эта величина трудно предсказуема, однако это не снимает с разработчика ответственности. Стек это такой же ресурс, как и всё остальное и в эмбедде надо чотко следить за всем.


    1. Amomum Автор
      02.10.2018 16:03

      Первый скриншот: безуспешно пытался понять, как можно нажать кнопку «Ok» на окне, где есть только «Break», «Continue» и «Ignore».

      Упс. Да, действительно.

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

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

      Если вам известен какой-то другой способ заранее узнать нужный размер стека — поделитесь, пожалуйста.


  1. olekl
    02.10.2018 16:44

    Еще RTOS помогают, у каждого таска свой стек, и всегда видно, сколько его было задействовано по максимуму…


    1. Amomum Автор
      02.10.2018 16:50

      Если RTOS умеет ставить «сторожевое» значение в стек и проверять его, то да. А если нет, то вручную вполне можно проворонить и вылезти (и при этом, скорее всего, залезть в стек другого потока).


  1. Kabdim
    02.10.2018 17:28

    Segmented stacks в gcc не думали попробовать?


    1. Amomum Автор
      02.10.2018 17:32

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

      Интересная идея, но кажется Кейл так сам не умеет.


      1. Kabdim
        02.10.2018 17:39

        Это сделано что бы наращивать стек, но ничего не мешает использовать по другому. Задать максимальный размер сразу, а по вызову обработчика убивать задачу.


  1. Siemargl
    02.10.2018 17:47

    Просто надо считать:
    — сколько каждая ф-ция потребляет стека
    — вложенность ф-ций
    — уровень прерываний

    и… и всё


    1. Amomum Автор
      02.10.2018 17:50

      + виртуальные вызовы + указатели на функции.
      Как вы думаете, почему -fstack-usage это не считает?


      1. Siemargl
        02.10.2018 17:56

        наверное потому, что это не привносит нагрузку на стек

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


        1. Amomum Автор
          02.10.2018 18:01

          А я думаю потому, что вызовы по указателям невозможно просчитать на этапе компиляции. Ведь строго говоря, конкретный указатель на функцию может быть выбран и в рантайме, скажем, по ГПСЧ или пользователем. Я подозреваю, что это аналогично проблеме останова.

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


          1. Siemargl
            02.10.2018 18:03
            -1

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

            стек то тут при чем???


            1. ser-mk
              02.10.2018 18:09

              Так поднимайте =) «стек то тут при чем???»

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


            1. Amomum Автор
              02.10.2018 18:10
              +1

              С квалификацией у меня, вроде бы, все в порядке, спасибо.
              Как это причем тут стек? Смотрите:

              void foo(); // использует чуть-чуть стека
              
              void bar(); // использует очень много стека
              
              void main()
              {
              	typedef void (*Func)(); 
              
              	Func func[] = {&foo, &bar};
              
              	int a;
              	scanf("%i", &a );
              
              	func[a]();
              }
              


              Как компилятор должен во время компиляции узнать, сколько стека будет использовано?


              1. Siemargl
                02.10.2018 18:23

                внезапные невиданные трудности посчитать _все_ вызываемые функции и вычислить максимум

                а для if(x) foo() else bar(); — есть отличие ???


                1. Amomum Автор
                  02.10.2018 18:30

                  Но тем не менее, -fstack-usage этого не умеет. Массив указателей мог приехать из другой функции, он мог прочитаться из файла, пользователь мог вбить адреса по одной цифре — да что угодно!

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

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


                  1. Siemargl
                    02.10.2018 18:36

                    именно так, как я и написал изначально. завышенная но верная оценка сверху — гарант надежности

                    рассчитывать во всем на компилятор… вы и есть [стек] за меня будете? (с) мультик

                    рекурсия запрещена в таких задачах


                    1. Amomum Автор
                      02.10.2018 18:44

                      Слишком завышенная оценка может быть больше, чем вся доступная память.

                      Я не спорю, что путем самоограничений можно свести задачу к решаемой статически — если уж вы в «таких задачах» запретили рекурсию, то можно и виртуальные вызовы запретить.
                      Но меня интересовало более универсальное решение.

                      Если же виртуальные вызовы оставить, то высчитывать максимально возможное использование стека придется ручками. Ручками опять-таки не хочется.


                    1. robert_ayrapetyan
                      03.10.2018 00:04

                      А если функция выделяет память на стеке размером, взятому из принятого аргумента?


                      1. Siemargl
                        03.10.2018 05:46

                        сдуру можно и хрен сломать (с) народная мудрость

                        недаром alloca не попала в стандарт и не рекомендуется к использованию.

                        да и вообще желательно обходиться в таких системах без динамического выделения памяти


              1. esaulenka
                03.10.2018 09:45

                Да точно так же, как и сейчас.

                void a() { /* мало стека */ }
                void b() { /* много стека */ }
                void c(int x)
                {
                  if (x) a();
                  else b();
                }
                void d() { /* мало стека */ с(0); }
                void e() { /* много стека */ c(1); }
                

                Сейчас компилятор посчитает за максимально «большой» вызов e() -> c() -> b(), хотя он по логике программы невозможен.


                1. Amomum Автор
                  03.10.2018 17:19

                  Окей, пример слишком простой. Представьте, что пользователь полностью вводит адрес функции, которую нужно вызвать.

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


  1. Nick_Shl
    02.10.2018 18:46
    +1

    Последнее решение не то что бы «не идеально» — это вообще не решение. Проверка указателя стека до входа в функцию и после выхода из нее даст результат только в том случае, если функция исчерпавшая стек вызовет ещё одну функцию. А если она тихо вылезет за границы стека, на выходе указатель стека вернется к тому уровню, каким был на входе и ваш "сторож" ничего не заметит.
    Как уже говорили выше — за стеком надо класть массив заполненный каким-либо паттерном и проверять целостность этого паттерна. Именно так делает FreeRTOS — каждый раз при переключении контента проверяет "А не попортила ли задача паттерн расположенный за ее стеком?" и если попортила — зовёт хэндлер для обработки переполнения стека. Впрочем, существует вероятность, хотя и очень маленькая, заполнения области паттерна при переполнении стека значениями этого паттерна — тогда и этот метод не сработает.


    1. Amomum Автор
      02.10.2018 18:50

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

      Конечно, стек все равно может переполнится ДО вызова инструментальной функции, поэтому при проверке можно сделать некоторый запас.
      С другой стороны, даже в этом случае проверка сработает корректно (а в stm32 все равно будет HardFault).


      1. Nick_Shl
        02.10.2018 19:17

        А размер этого стекового кадра не может меняться после входа в функцию? Скажем, если вы имеете объявление массива внутри блока относящегося к if'у — нет никакого смысла выделять память до того как проверено условие и стало ясно, что заходим в этот блок. Точно так же нет никакого смысла держать эту память выделенной после выхода из блока и до выхода из функции.


        1. Amomum Автор
          02.10.2018 19:58

          Насколько я понимаю, компилятор просто выделяет максимум стека однократно, а если у какого-то локального объекта заканчивается время жизни, то новый объект просто записывается поверх.
          Но это вроде как ничем не гарантируется, просто это более-менее логично — можно сэкономить на инструкциях. А выделять-освобождать память в стеке несколько раз — зачем?

          По идее, какая-нибудь alloca или VLA могут хапать еще стека уже после первоначального выделения стекового кадра.

          Но alloca я не использую (потому что Кейловская реализация использует кучу), а VLA Кейл просто в куче выделяет.


          1. Nick_Shl
            02.10.2018 20:12

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


            1. Amomum Автор
              02.10.2018 20:15

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


              1. esaulenka
                03.10.2018 09:19

                оптимизацию почти никогда не включаю

                Тут нет опечатки? А смысл? Избежать мифической ошибки компилятора?


              1. Mirn
                03.10.2018 15:31

                а оптимизацию почти никогда не включаю

                извиняюсь, а почему не включаете? это как то связано с стеком?


                1. Amomum Автор
                  03.10.2018 15:35

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

                  Если программа и на -О0 укладывается в рамки по размеру/времени работы, то зачем ее оптимизировать?


                  1. Mirn
                    03.10.2018 18:24

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

                    Но есть задачи такие что даже с максимальным уровнем оптимизации -О3 могут потребовать ещё более глубокой настройки компилятора и более пристального изучения архитектуры.
                    Например распознавание образов на лету, и в качестве примера нахождение трёх точек расположенных правильным треугольником:
                    www.youtube.com/watch?v=210UZUjZwBs
                    Благодаря хорошему использованию и компилятора и использованию особенности АРМ проца я смог добиться работы самого алгоритма за 100 микросекунд (именно микросекунд). А всё в целом включая работу с камерой и рендер 10мс. На -О0 и не использовании фишек проца это было слайдшоу менее 1фпс. Основная фишка — использование не 65кбайт RGB данных, а 4 килобайт битовой маски и спец битовые инструкции ARM по анализу и манипулированию битами в 32битном слове. (естественно не вляпавшись ни разу в написание чего либо на ассемблере)

                    Например сейчас я делаю нейросетку MobilNetv2 на мк,
                    распознаёт до 1000 объёктов с камеры.
                    image.prntscr.com/image/cXQdL9yKT9ijUf7q0PiqiQ.png
                    и хоть микроконтроллер более мощный с очень быстрой памятью
                    (вырезка из моей статьи с японского хабра)
                    prnt.sc/l1qvh8
                    вот там даже на уровне оптимизации -О3 один кадр рассчитывается за 30 секунд потому что там 300 миллионов умножений флоатов с накоплениями, более 3 миллионов весовых коэффицентов 2д КИХ фильтров.
                    после профилирования компилятора и грамотной настройки под каждую функцию отдельно и адаптации под кеши и виды памяти ужалось до 4-5 секунд, не меняя исходный код (50 мегабайт исходного кода самой сетки), обеспечивая бит — в бит сходство с прогой на ПК на каждом этапе расчётов.
                    Вот что значит уметь пользоваться компилятором и процом и архетектурой мк!
                    Но это ещё не всё, конуренты умудрились довести до 1-2фпс ту же самую сетку уже алгоритмической оптимизацией на том же железе (альфазакон — 8 битные флоаты и пурифинг). Так что есть куда рости в проф мастерстве.

                    Надеюсь заинтересовал в более углублённом изучении GCC и ядра процессора.


                    1. Amomum Автор
                      03.10.2018 18:38

                      Даа, мощно!
                      У меня как-то с задачами все сильно проще; чаще приходится по размеру оптимизировать, чтобы втиснуться на случайно поставленный в серию слишком дохлый МК. А вот чтоб код чего-то не успевал сделать — даже и не припомню.


                  1. Dima_Sharihin
                    03.10.2018 18:33

                    Это зависит от разного рода задач. Для других же быстрее — значит


                    1. Обработать бОльшее количество данных за то же время, т.е. пропускная способность
                    2. Быстрее уйти в сон — снижение энергопотребления
                    3. Уложиться в тесные временные рамки. Для реалтайма не то, что -O3, часто приходится вручную читать выхлоп ассемблера, находя узкие места и тупые решения компилятора.

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


                    1. Amomum Автор
                      03.10.2018 18:40

                      Опять-таки, все зависит от задач. Реалтайм реалтайму рознь, сами понимаете.

                      Насчет стека — тоже неоднозначно. Я видел, как на -О3 распухает стек для main'a, потому что в него вся инициализация заинлайнилась. А потом этот стек так и остается съеденным, потому что main никогда не завершается, а компилятору это невдомек.


                      1. Siemargl
                        03.10.2018 19:32
                        -1

                        Ладно, я понял свою ошибку.
                        Я начал читать лекции в детском саду.

                        А вы еще опции компиляции только для себя открываете.

                        ЗЫ. Реалтайм это не про скорость исполнения.
                        ЗЫ2. gcc -O3 это путь к очень интересным багам, -O2 стабильный максимум


                        1. Amomum Автор
                          03.10.2018 19:52

                          На мой взгляд ваша ошибка в том, что вы начали эмоционально реагировать. И вместо аргументации «чтобы не было переполнения стека, надо делать так-то и так-то» была «вы все вокруг дураки и не лечитесь». Если считаете себя взрослым в детском саду, то ведите себя соответственно.

                          Я искал более-менее универсальное решение для конкретной проблемы — я его нашел. Что вас не устраивает?

                          И таки да
                          Я знаю, что реалтайм — это не про скорость, а про гарантированный срок исполнения.


                          1. Siemargl
                            03.10.2018 20:13

                            вообще то ответ именно таким — «чтобы не было переполнения стека, надо делать так-то и так-то»


                            1. Amomum Автор
                              03.10.2018 20:21

                              Возможно вы не заметили, но я в статье привел «просто посчитать» как один из возможных способов (-fstack-usage) и сам же отмел потому что этот способ работает не всегда.

                              Т.е. ваш ответ никакой новой информации для меня не содержал, этот способ мне известен, спасибо.


  1. lorc
    02.10.2018 19:33

    Использование брейпоинтов — интересная идея, кстати. Здоровые ARMы (типа cortex A) поддерживают self-hosted debug: можно ставить и ловить BP прямо из процессора.

    Не имел дела с мелкими cortex. Может там есть такая же фича?


    1. Amomum Автор
      02.10.2018 19:54

      У мелких есть инструкция BKPT, которая при наличии отладчика отладку останавливает. Но тут это не особо помогает.


    1. Dima_Sharihin
      03.10.2018 07:55

      Судя по гуглу, что-то похожее есть и у М-кортексов


      Но лично я этим не пользовался со стороны микроконтроллера, только со стороны отладчика, когда ловил переполнения стека


      1. esaulenka
        03.10.2018 10:10

        Спасибо, занятная штука. Быстрый гуглинг показывает, что чем-то подобным балуется нордик (ещё одно описание).
        Не совсем наша задача, но для отправной точки пойдёт (вместе с библией ARM®v7-M Architectural Reference Manual, видимо...).


      1. Amomum Автор
        03.10.2018 14:29

        Да, действительно любопытно, спасибо!


      1. Amomum Автор
        03.10.2018 15:32

        И правда, интересная штука. На stm32 почему-то сходу прерывание от Debug Monitor'a не заработало, ну да ладно.

        А вот Миландр 1986ВЕ1, зараза, на другом ядре, в нем этой функциональности вообще нет.


        1. EviLOne
          04.10.2018 11:42

          Существует такая книжка от TI "Ядро Cortex-M3 компании ARM" под авторством Джозеф Ю. 15-ая и 16-ая глава посвящена режиму отладки/трассировки и модулям отладки и трассировки. Нас интересует модуль DWT и регистр DEMCR.


          Задача состоит в том, чтобы его настроить на генерацию прерывания DebugMon_Handler (на удивление в спецификации на МК оно отсутствует (как и многое чего), а в библиотеке на устройство присутствует, в любом случае отладка и трассировка как-то работает). Если же оно все таки не функционирует, в 15-ой главе описан способ получения прерывания HardFault из-за не правильно настройки.


          Информация о регистрах модуля DWT можно найти здесь


          К сожалению процессором 1986ВЕ1Т не обладаю, а отладочная под 1986ВЕ94Т на данный момент занята, так что проверить теорию не могу. Но мне кажется вектор верный, осталось только попрактиковаться, главное не забывать о безопасности, чтобы не убить МК вставляя подушку безопасности перед включением защитного кода.


          1. Amomum Автор
            04.10.2018 11:49

            Да, эти главы я уже проштудировал, но в чем я не прав — пока не понял. Прерывание DebugMon явно настроено (__BKPT без отладчика его триггерит), сам watchpoint отладчик останавливает. А вот без отладчика — ничего.

            Официально 1986ВЕ1Т имеет некое RISC-совместимое ядро, которое исключительно случайно очень напоминает Cortex-M0 :) В тех поддержке мне предложили его считать «функциональным аналогом».
            И DWT там вполне может не быть, раз уж ITM они выкинули. Хотя проверить не помешает, конечно.


            1. EviLOne
              05.10.2018 08:51

              ну да, что-то я отправила ошибся с ядром, тогда остаётся только качественное выполнение целевой программы и контроль указателя стека при входе в прерывание, можно реализовать даже вставку на асме, это 4-5 инструкций, можно и на си. это 100% вариант.

              Имея отладочный комплект так же можно поиграться с конфигурацией внешней шиной, пытаясь вызвать Hard Fault.


            1. esaulenka
              05.10.2018 09:24
              +1

              Официально 1986ВЕ1Т имеет некое RISC-совместимое ядро, которое исключительно случайно очень напоминает Cortex-M0

              Лет этак пять назад упоминалось, что там M1. Сейчас быстро нагуглить это я не смог (купили ядро для ПЛИС, сделали контроллер, и хотя бы официально это не подтверждают?..).
              И да, 12-й exception у Cortex-M1 значится как reserved (хотя, казалось бы — ядро для «самостоятельного» встраивания должно иметь все возможные способы отладки). Увы…


              1. Amomum Автор
                05.10.2018 11:52

                Я так подозреваю — но только подозреваю, разумеется, что М1 они не покупали официально (поэтому и не могут написать, что это Cortex вообще), а либо где-то эмм скопировали, либо от М3 отрезали с мясом все подряд, в том числе то, что можно было бы и оставить.
                Они даже SysTick слегка поломать умудрились, какая уж там отладка.
                Отладку, кстати, тоже сломали; во время выполнения нельзя отладчиком в память смотреть, иначе рандомные HardFault'ы сыпятся.

                Хороший, короче, микроконтроллер, прям всем советую :)


          1. esaulenka
            05.10.2018 09:29

            на удивление в спецификации на МК оно отсутствует

            В спецификации на МК есть отсылка на спецификацию конкретного ядра, а в ней — ссылка на ARMv7 reference manual.
            Там описание есть. Насколько быстро по нему получится сделать требуемое, я не проверял :-)


  1. 8street
    03.10.2018 13:47

    Мне, как хоббисту в эмбеде, кажется, что в статье не хватает вводной части с объяснением какие данные хранятся в стеке и чем это отличается от кучи. А также, какими способами можно переполнить стек и как гарантировано это сделать, для проверки.


    1. Amomum Автор
      03.10.2018 14:27

      Возможно, вы правы. Если есть необходимость, могу это в статью добавить.


      1. 8street
        04.10.2018 20:15

        Видимо народу не надо, раз никто не отписался больше. Решайте сами.


        1. Ryppka
          04.10.2018 20:52

          Да ладно Вам: описали свои опыт и мысли, что-то обсудили, все приняли к сведению и признательны… ))))