Pull to refresh

Comments 66

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

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

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

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

До сих пор удивляюсь, почему в 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 на новой архитектуре

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

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

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

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

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

Как эту проблему на RTOS решать я пока особо не думал. По идее само по себе переключение контекста выход за границы стека принести не должно; только если вы уже вылезли за его границы и такой контекст сохранился. Но это должны отловить instrument-functions.
Мы делаем следующим образом (и вроде Keil RTX так же умеет)
При аллокации стека к размеру добавляем footer в котором содержится некий magic number. При переключении контекста начало и размер стека известны, соответственно можно проверить значение в футере и таким образом предположить было ли переполнение.
Как эту проблему на RTOS решать я пока особо не думал.
Во FreeRTOS, например, есть собственные средства контроля использования стеков задач. Включается макросом
#define INCLUDE_uxTaskGetStackHighWaterMark 1
Затем можно вызывать функцию
uxTaskGetStackHighWaterMark()
передавая в качестве параметра handle задачи, для контроля использования стека задачей.
Полагаю, в других RTOS тоже должно быть нечто подобное.
Тоже самое в других операционках, это делается на уровне ядра, весь стек заполняется спец. символами, и когда задача деактивирована — идет проверка этих символов. Например в Embos, так. Если символов уже нет, значит стек переполнился, и вызывается спец. обработчик ошибки переполнения.
С другой стороны, все это делается только на этапе отладки, во время разработки. Заполнил стек спец. символами, поставил на стресс тест на 2 дня, и через два дня посомтрел до куда максмимум стек долез. В любом случае в релизе нужно сделать так, чтобы максимальный размер уже был известен, это может кстати сделать компилятор, посчитать сразу максимальную вложенность стека. На неё и стоит оритентироваться.
Первый скриншот: безуспешно пытался понять, как можно нажать кнопку «Ok» на окне, где есть только "Break", "Continue" и "Ignore".
Если по теме — проблема актуальная, но интрументацией она вроде неплохо решается на этапе отладки. Переполнение стека, на мой взгляд, это логическая ошибка (программист не расчитал нужный размер). Понятно, что в сложных системах эта величина трудно предсказуема, однако это не снимает с разработчика ответственности. Стек это такой же ресурс, как и всё остальное и в эмбедде надо чотко следить за всем.
Первый скриншот: безуспешно пытался понять, как можно нажать кнопку «Ok» на окне, где есть только «Break», «Continue» и «Ignore».

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

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

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

Если вам известен какой-то другой способ заранее узнать нужный размер стека — поделитесь, пожалуйста.
Еще RTOS помогают, у каждого таска свой стек, и всегда видно, сколько его было задействовано по максимуму…
Если RTOS умеет ставить «сторожевое» значение в стек и проверять его, то да. А если нет, то вручную вполне можно проворонить и вылезти (и при этом, скорее всего, залезть в стек другого потока).
Не слышал про такое до вашего комментария. Я правильно понимаю, что это имеет смысл только если можно стек наращивать в рантайме?

Интересная идея, но кажется Кейл так сам не умеет.
Это сделано что бы наращивать стек, но ничего не мешает использовать по другому. Задать максимальный размер сразу, а по вызову обработчика убивать задачу.
Просто надо считать:
— сколько каждая ф-ция потребляет стека
— вложенность ф-ций
— уровень прерываний

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

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

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

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

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

void foo(); // использует чуть-чуть стека

void bar(); // использует очень много стека

void main()
{
	typedef void (*Func)(); 

	Func func[] = {&foo, &bar};

	int a;
	scanf("%i", &a );

	func[a]();
}


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

а для if(x) foo() else bar(); — есть отличие ???
Но тем не менее, -fstack-usage этого не умеет. Массив указателей мог приехать из другой функции, он мог прочитаться из файла, пользователь мог вбить адреса по одной цифре — да что угодно!

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

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

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

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

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

Если же виртуальные вызовы оставить, то высчитывать максимально возможное использование стека придется ручками. Ручками опять-таки не хочется.
А если функция выделяет память на стеке размером, взятому из принятого аргумента?
сдуру можно и хрен сломать (с) народная мудрость

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

да и вообще желательно обходиться в таких системах без динамического выделения памяти
Да точно так же, как и сейчас.
void a() { /* мало стека */ }
void b() { /* много стека */ }
void c(int x)
{
  if (x) a();
  else b();
}
void d() { /* мало стека */ с(0); }
void e() { /* много стека */ c(1); }

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

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

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

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

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

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

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

Если программа и на -О0 укладывается в рамки по размеру/времени работы, то зачем ее оптимизировать?
ну в чём то это и правильно, в случае чего можно включить -О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. Обработать бОльшее количество данных за то же время, т.е. пропускная способность
  2. Быстрее уйти в сон — снижение энергопотребления
  3. Уложиться в тесные временные рамки. Для реалтайма не то, что -O3, часто приходится вручную читать выхлоп ассемблера, находя узкие места и тупые решения компилятора.

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

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

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

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

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

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

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


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

Т.е. ваш ответ никакой новой информации для меня не содержал, этот способ мне известен, спасибо.
gcc -O3 это путь к очень интересным багам, -O2 стабильный максимум

Это просто значит, что у вас достаточно UB в коде.

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

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

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


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

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

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

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


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


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


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

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

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

Имея отладочный комплект так же можно поиграться с конфигурацией внешней шиной, пытаясь вызвать Hard Fault.
Официально 1986ВЕ1Т имеет некое RISC-совместимое ядро, которое исключительно случайно очень напоминает Cortex-M0

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

Хороший, короче, микроконтроллер, прям всем советую :)
на удивление в спецификации на МК оно отсутствует

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

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

Возможно, вы правы. Если есть необходимость, могу это в статью добавить.
Видимо народу не надо, раз никто не отписался больше. Решайте сами.
Да ладно Вам: описали свои опыт и мысли, что-то обсудили, все приняли к сведению и признательны… ))))
Only those users with full accounts are able to leave comments. Log in, please.