Сколько я могу заработать? Сколько я могу заработать? Сколько стоит! @ # QFSD @ $ RFW

19

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

asheeshr
источник
2
Можете ли вы привести пример алгоритма, в котором выбор между рекурсивным и нерекурсивным решением является столь очевидным?
Cybergibbons
1
@Cybergibbons Совсем недавно мне пришлось написать и протестировать RDP за короткий промежуток времени, поэтому я продолжил рекурсивную реализацию. Я не говорю, что итеративный вариант невозможен, просто проще перейти к рекурсивной версии.
asheeshr
Что это значит в названии вопроса: How much ca!@#QFSD@$RFW? Мне любопытно, почему никто не отредактировал это как нечто более значимое (за последние 4 года).
Ник Гэммон
Ответ 211раз (в зависимости от многих факторов) :). Смотрите мой ответ здесь: arduino.stackexchange.com/a/51098/7727 . @NickGammon, он притворяется "проклятым", я думаю. Это игра слов "рекурс". Мне понадобилась минутка, чтобы понять это тоже. Сначала было довольно странно.
Габриэль Стейплс
А ну понятно. Я думал, что у серверов SE, возможно, был сбой :)
Ник Гэммон

Ответы:

15

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

Для этого вам следует сначала узнать, как организована SRAM в Arduino на базе AVR (это не относится, например, к Arduino Galileo от Intel). Следующая диаграмма от Adafruit ясно показывает это:

SRAM организация

Затем вам нужно знать общий размер вашей SRAM (зависит от микроконтроллера Atmel, следовательно, какая у вас плата Arduino).

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

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

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

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

Если мы предположим, что:

  • Вы находитесь на Arduino UNO (SRAM = 2K)
  • ваш эскиз не использует динамическое выделение памяти (без кучи )
  • Вы знаете размер ваших статических данных (скажем, 132 байта)
  • когда ваша recurse()функция вызывается из вашего эскиза, текущий стек имеет длину 128 байт

Тогда у вас останутся 2048 - 132 - 128 = 1788доступные байты в стеке . Таким образом, количество рекурсивных вызовов вашей функции 1788 / 14 = 127, включая начальный вызов (который не является рекурсивным).

Как видите, это очень сложно, но не невозможно найти то, что вы хотите.

Более простой способ получить размер стека, доступный до recurse()вызова, - использовать следующую функцию (найдена в учебном центре Adafruit; я сам не проверял):

int freeRam () 
{
  extern int __heap_start, *__brkval; 
  int v; 
  return (int) &v - (__brkval == 0 ? (int) &__heap_start : (int) __brkval); 
}

Я настоятельно рекомендую вам прочитать эту статью в учебном центре Adafruit.

jfpoilpret
источник
Я вижу, как Питер-Р-Блумфилд опубликовал свой ответ, пока писал свой; его ответ выглядит лучше, так как он полностью описывает содержимое стека после вызова (я забыл состояние регистров).
jfpoilpret
Оба очень качественные ответы.
Cybergibbons
Статические данные = .bss + .data, а что сообщает Arduino как «ОЗУ, занятое глобальными переменными» или что-то в этом роде, верно?
Габриэль Стейплс
1
@GabrielStaples да, именно так. Более подробно .bssпредставляет глобальные переменные без начального значения в вашем коде, тогда dataкак для глобальных переменных с начальным значением. Но в конце они используют одно и то же пространство: статические данные на диаграмме.
jfpoilpret
1
@GabrielStaples забыл одну вещь, технически это не только глобальные переменные, но и переменные, объявленные staticвнутри функции.
jfpoilpret
8

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

jippie
источник
Некоторые из более высококлассных компиляторов, такие как IAR (которые поддерживают AVR) и Keil (которые не поддерживают AVR), имеют инструменты, которые помогут вам контролировать и управлять пространством стека. Это действительно не рекомендуется для чего-то столь же маленького как ATmega328 все же.
Cybergibbons
7

Это зависит от функции.

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

  • Адрес возврата (точка в коде, из которой была вызвана функция).
  • Указатель на локальный экземпляр ( this) при вызове функции-члена.
  • Параметры передаются в функцию.
  • Зарегистрируйте значения, которые необходимо восстановить после завершения функции.
  • Пространство для локальных переменных внутри вызываемой функции.

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

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

Стоит отметить, что оптимизация хвостовой рекурсии существует на некоторых компиляторах (хотя я не уверен, поддерживает ли она avr-gcc). Если рекурсивный вызов является самой последней вещью в функции, это означает, что иногда можно вообще избежать изменения кадра стека. Компилятор может просто повторно использовать существующий фрейм, так как «родительский» вызов (так сказать) закончил его использование. Это будет означать, что теоретически вы можете продолжать использовать столько раз, сколько захотите, если ваша функция не вызывает ничего другого.

Питер Блумфилд
источник
1
avr-gcc не поддерживает хвостовую рекурсию.
asheeshr
@AsheeshR - Полезно знать. Благодарю. Я подумал, что это вряд ли возможно.
Питер Блумфилд
Вы можете выполнить удаление / оптимизацию хвостовых вызовов путем рефакторинга вашего кода, не надеясь, что компилятор сделает это. Пока рекурсивный вызов находится в конце рекурсивного метода, вы можете безопасно переписать метод для использования цикла while / for.
abasterfield
1
Пост @TheDoctor противоречит "avr-gcc не поддерживает хвостовую рекурсию", как и мой тест его кода. Компилятор действительно реализовал хвостовую рекурсию, и именно так он получил до миллиона рекурсий. Питер прав - компилятор может заменить вызов / возврат (как последний вызов в функции) простым переходом . Он имеет тот же конечный результат и не использует пространство стека.
Ник Гэммон
2

У меня был точно такой же вопрос, когда я читал « Прыжок в C ++» Алекса Аллена , гл. 16: Рекурсия, стр. 230, поэтому я провел несколько тестов.

TLDR;

Мой Arduino Nano (ATmega328 mcu) может выполнить 211 рекурсивных вызовов функций (для приведенного ниже кода) до того, как произойдет переполнение стека и сбой.

Прежде всего, позвольте мне рассмотреть это требование:

Иногда рекурсия является единственным быстрым вариантом реализации определенного алгоритма.

[Обновление: ах, я снял слово «быстро». В этом случае у вас есть законность. Тем не менее, я думаю, что стоит сказать следующее.]

Нет, я не думаю, что это правда. Я почти уверен, что все алгоритмы имеют как рекурсивное, так и нерекурсивное решение без исключения. Просто иногда это значительно прощеиспользовать рекурсивный алгоритм. Сказав это, рекурсия очень не одобряется для использования на микроконтроллерах и, вероятно, никогда не будет разрешена в коде, критичном для безопасности. Тем не менее, конечно, это можно сделать на микроконтроллерах. Чтобы узнать, насколько «глубоко» вы можете войти в любую рекурсивную функцию, просто протестируйте ее! Запустите его в своем реальном приложении в реальном тестовом случае и удалите базовое условие, чтобы оно бесконечно повторялось. Распечатайте счетчик и убедитесь сами, насколько «глубоким» вы можете быть, чтобы вы знали, действительно ли ваш рекурсивный алгоритм раздвигает пределы вашей ОЗУ слишком близко для практического использования. Вот пример ниже, чтобы вызвать переполнение стека на Arduino.

Теперь несколько заметок:

Сколько рекурсивных вызовов или «стековых фреймов» вы можете получить, определяется рядом факторов, в том числе:

  • Размер вашей оперативной памяти
  • Сколько вещей уже находится в вашем стеке или занято в вашей куче (то есть: ваша свободная память имеет значение; free_RAM = total_RAM - stack_used - heap_usedили вы могли бы сказать free_RAM = stack_size_allocated - stack_size_used)
  • Размер каждого нового «стекового фрейма», который будет помещен в стек для каждого нового рекурсивного вызова функции. Это будет зависеть от вызываемой функции, ее переменных, требований к памяти и т. Д.

Мои результаты:

  • 20171106-2054hrs - Toshiba Satellite с 16 ГБ ОЗУ; четырехъядерный, Windows 8.1: окончательное значение, напечатанное до сбоя: 43166
    • потребовалось несколько секунд, чтобы вылететь - может быть 5 ~ 10?
  • 20180306-1913 гг. Высококачественный ноутбук Dell с 64 ГБ оперативной памяти; 8-ядерный, Linux Ubuntu 14.04 LTS: окончательное значение, напечатанное до сбоя: 261752
    • с последующей фразой Segmentation fault (core dumped)
    • Потребовалось всего ~ 4 ~ 5 секунд или около того, чтобы потерпеть крах
  • 20180306-1930hrs Arduino Nano: TBD --- находится на ~ 250000 и продолжает подсчитывать --- настройки оптимизации Arduino, должно быть, заставили его оптимизировать рекурсию ... ??? Да, это так.
    • Добавьте #pragma GCC optimize ("-O0")в начало файла и повторите:
  • 20180307-0910: Arduino Nano: вспышка 32 КБ, SRAM 2 КБ, процессор 16 МГц: окончательное значение, напечатанное до сбоя: 211 Here are the final print results: 209 210 211 ⸮ 9⸮ 3⸮
    • потребовалось всего доли секунды, как только он начал печатать со скоростью 115200 бит / с - возможно, 1/10 сек.
    • 2 КБ = 2048 байт / 211 кадров стека = 9,7 байт / кадр (при условии, что ВСЕ ваша память используется стеком - что на самом деле не так) - но, тем не менее, это кажется очень разумным.

Код:

Приложение для ПК:

/*
stack_overflow
 - a quick program to force a stack overflow in order to see how many stack frames in a small function can be loaded onto the stack before the overflow occurs

By Gabriel Staples
www.ElectricRCAircraftGuy.com
Written: 6 Nov 2017
Updated: 6 Nov 2017

References:
 - Jumping into C++, by Alex Allain, pg. 230 - sample code here in the chapter on recursion

To compile and run:
Compile: g++ -Wall -std=c++11 stack_overflow_1.cpp -o stack_overflow_1
Run in Linux: ./stack_overflow_1
*/

#include <iostream>

void recurse(int count)
{
  std::cout << count << "\n";
  recurse(count + 1);
}

int main()
{
  recurse(1);
}

Программа Arduino "Эскиз":

/*
recursion_until_stack_overflow
- do a quick recursion test to see how many times I can make the call before the stack overflows

Gabriel Staples
Written: 6 Mar. 2018 
Updated: 7 Mar. 2018 

References:
- Jumping Into C++, by Alex Allain, Ch. 16: Recursion, p.230
*/

// Force the compiler to NOT optimize! Otherwise this recursive function below just gets optimized into a count++ type
// incrementer instead of doing actual recursion with new frames on the stack each time. This is required since we are
// trying to force stack overflow. 
// - See here for all optimization levels: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
//   - They include: -O1, -O2, -O3, -O0, -Os (Arduino's default I believe), -Ofast, & -Og.

// I mention `#pragma GCC optimize` in my article here: http://www.electricrcaircraftguy.com/2014/01/the-power-of-arduino.html
#pragma GCC optimize ("-O0") 

void recurse(unsigned long count) // each call gets its own "count" variable in a new stack frame 
{
  // delay(1000);
  Serial.println(count);

  // It is not necessary to increment count since each function's variables are separate (so the count in each stack
  // frame will be initialized one greater than the last count)
  recurse (count + 1);

  // GS: notice that there is no base condition; ie: this recursive function, once called, will never finish and return!
}

void setup()
{
  Serial.begin(115200);
  Serial.println(F("\nbegin"));
  // First function call, so it starts at 1
  recurse (1);
}

void loop()
{
}

Ссылки:

  1. Прыжок в C ++ от Алекса Аллена , гл. 16: рекурсия, с.230
  2. http://www.electricrcaircraftguy.com/2014/01/the-power-of-arduino.html - буквально: я ссылался на свой собственный сайт во время этого «проекта», чтобы напомнить себе, как изменить уровни оптимизации компилятора Arduino для данного файла с #pragma GCC optimizeкомандой, так как я знал, что я это задокументировал.
Габриэль Стейплс
источник
1
Обратите внимание, что, согласно документам avr-lib, вы никогда не должны компилировать без оптимизации ничего, что полагается на avr-libc, поскольку некоторые вещи не гарантируют даже работу с отключенной оптимизацией. Таким образом, я советую вам против того, что #pragmaвы используете там. Вместо этого, вы можете добавить __attribute__((optimize("O0")))к одной функции , которую вы хотите unoptimize.
Эдгар Бонет
Спасибо, Эдгар. Знаете ли вы, где AVR libc это документировал?
Габриэль Стейплс
1
В документации по <util / delay.h> говорится: «Чтобы эти функции работали как задумано, оптимизация компилятора должна быть включена [...]» (выделено в оригинале). Я не совсем уверен, есть ли у других функций avr-libc это требование.
Эдгар Бонет
1

Я написал эту простую тестовую программу:

void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  recurse(1);
}

void loop() {
  // put your main code here, to run repeatedly: 

}

void recurse(long i) {
  Serial.println(i);
  recurse(i+1);
}

Я скомпилировал его для Uno, и, когда я пишу, это повторялось более миллиона раз! Я не знаю, но компилятор, возможно, оптимизировал эту программу

Доктор
источник
Попробуйте вернуться после установленного количества звонков ~ 1000. Это должно создать проблему тогда.
asheeshr
1
Компилятор хитро реализовал хвостовую рекурсию на вашем эскизе, как вы увидите, разберетесь ли вы. Это означает, что он заменяет последовательность call xxx/ retна jmp xxx. Это равносильно тому, что метод компилятора не использует стек. Таким образом, вы можете повторять миллиарды раз с вашим кодом (при прочих равных условиях).
Ник Гэммон
Вы можете заставить компилятор не оптимизировать рекурсию. Я вернусь и выложу пример позже.
Габриэль Стейплс
Выполнено! Пример здесь: arduino.stackexchange.com/a/51098/7727 . Секрет заключается в том, чтобы предотвратить оптимизацию, добавив #pragma GCC optimize ("-O0") в начало вашей программы Arduino. Я считаю, что вы должны делать это в верхней части каждого файла, к которому вы хотите, чтобы он применялся, - но я не проверял это годами, поэтому исследуйте его для себя, чтобы быть уверенным.
Габриэль Стейплс