どのくらい再帰できますか?どのくらい再帰できますか?どのくらいca!@#QFSD @ $ RFW


19

Arduino UnoボードのRAMは限られているため、使用可能なコールスタックは限られています。特定のアルゴリズムを実装するための唯一の迅速なオプションが再帰である場合があります。だから、コールスタックが厳しく制限されていることを考えると、ボード上で実行されている特定のプログラムを考えると、スタックオーバーフローが発生する前に(そして悪いことが起こる)正確にいくつの再帰呼び出しを許容できるかを知る方法は何でしょうか?


2
再帰的ソリューションと非再帰的ソリューションの選択が非常に明確なアルゴリズムの例を挙げていただけますか?
サイバーギボン14

1
@Cyber​​gibbons最近では、RDPを短時間で記述およびテストする必要があったため、再帰的な実装を進めました。反復バージョンは不可能だと言っているのではなく、単に再帰バージョンを使用する方が簡単だと言っているだけではありません。
asheeshr

質問のタイトルでこれはどういう意味How much ca!@#QFSD@$RFWですか?なぜ誰もそれをより有意義なものに編集していないのか(過去4年間)興味があります。
ニックギャモン

答えは211時間です(多くの要因に依存します):)。ここに私の答えを参照してください:arduino.stackexchange.com/a/51098/7727を。@NickGammon、彼は私が思う「呪い」のふりをしています。これは、「再帰」を表す言葉です。それを理解するために少し時間がかかりました。最初はかなり混乱していた。
ガブリエルステープルズ

ああなるほど。SEサーバーに不具合があるかもしれないと思っていました:)
ニックギャモン

回答:


15

本当に再帰したい場合(そして@jippieが悪い考えだと言ったように、サブリミナルメッセージ: しないでください)、どれだけ再帰できるか知りたい場合は、計算と実験を行う必要があります。また、再帰関数が呼び出されるときのメモリ状態に大きく依存するため、一般的には近似値しかありません。

このためには、AVRベースのArduino内でSRAMがどのように構成されているかを最初に知る必要があります(IntelのArduino Galileoなどには適用されません)。Adafruitの次の図は、それを明確に示しています。

SRAMの構成

次に、SRAMの合計サイズを知る必要があります(Atmel MCUに依存するため、使用しているArduinoボードの種類)。

この図では、コンパイル時に既知であり、後で変更されないため、静的データブロックのサイズを簡単に見つけることができます。

ヒープサイズは、動的なメモリ割り当て(に応じて、実行時に変えることができて知って、より困難になることができますmallocか、newあなたのスケッチやそれが使用するライブラリによって実行されます)。Arduinoでは動的メモリを使用することは非常にまれですが、いくつかの標準関数がそれを行います(タイプStringはそれを使用すると思います)。

スタックサイズについては、関数呼び出しの現在の深さ(各関数呼び出しは呼び出し側のアドレスを格納するためにStackで2バイトかかります)および渡された引数を含むローカル変数の数とサイズ(これらは、これまでに呼び出されたすべての関数に対してStackに保存されます)。

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ラーニングセンターでこの記事を読むことを強くお勧めします。


私が執筆中にpeter-r-bloomfieldが彼の答えを投稿したのを見ます。彼の答えは、呼び出し後のスタックの内容を完全に説明しているため、見栄えがよくなります(レジスタの状態を忘れていました)。
jfpoilpret 14

どちらも非常に質の高い回答です。
サイバーギボン14

静的データ= .bss + .data、およびArduinoによって「グローバル変数に占有されるRAM」などと報告されているものは何ですか?
ガブリエルステープルズ

1
@GabrielStaplesはい、正確に。.bssコードで初期値のないグローバル変数をより詳細に表しdataますが、初期値のあるグローバル変数用です。ただし、最終的には同じスペース、つまり図の静的データを使用します。
jfpoilpret

1
@GabrielStaplesは一つのことを忘れました。技術的には、これらはそこに行くグローバル変数であるだけでなくstatic、関数内で宣言された変数もあります。
jfpoilpret

8

すでに自分で述べたように、再帰はマイクロコントローラー上では悪い習慣であり、おそらく可能な限り回避したいでしょう。上のArduinoのサイトの無料RAMサイズを確認するために利用可能ないくつかの例とライブラリがあります。たとえば、これを使用して、再帰を破るタイミングを把握したり、スケッチをプロファイリングしたり、その制限をハードコーディングしたりするのが少し難しくなります。このプロファイルは、プログラムのすべての変更およびArduinoツールチェーンのすべての変更に必要です。


IAR(AVRをサポートする)やKeil(AVRをサポートしない)など、より高性能なコンパイラーには、スタックスペースの監視と管理に役立つツールがあります。ただし、ATmega328ほど小さいものにはお勧めできません。
サイバーギボン14

7

機能に依存します。

関数が呼び出されるたびに、新しいフレームがスタックにプッシュされます。通常、潜在的に以下を含むさまざまな重要なアイテムが含まれます。

  • リターンアドレス(関数の呼び出し元のコード内のポイント)。
  • thisメンバー関数を呼び出す場合のローカルインスタンスポインター()。
  • 関数に渡されるパラメーター。
  • 関数の終了時に復元する必要がある値を登録します。
  • 呼び出された関数内のローカル変数用のスペース。

ご覧のとおり、特定の呼び出しに必要なスタック領域は関数によって異なります。たとえば、intパラメータのみを使用し、ローカル変数を使用しない再帰関数を記述する場合、スタック上で数バイトを超える必要はありません。つまり、いくつかのパラメーターを取り、多くのローカル変数を使用する関数よりもはるかに多くの関数を再帰的に呼び出すことができます(スタックをより速く食い尽くします)。

明らかに、スタックの状態は、コード内で他に何が行われているかに依存します。標準loop()関数内で直接再帰を開始する場合、おそらくスタックにはまだ多くはありません。ただし、他の関数のいくつかのレベルの深さでネストを開始した場合、それほど多くのスペースはありません。これは、スタックを使い果たすことなく再帰できる回数に影響します。

一部のコンパイラには末尾再帰の最適化が存在することに注意してください(avr-gccがサポートするかどうかはわかりませんが)。再帰呼び出しが関数の最後の場合、スタックフレームの変更をまったく回避できない場合があることを意味します。「親」呼び出し(いわば)の使用が終了したため、コンパイラは既存のフレームを再利用できます。つまり、関数が他に何も呼び出さない限り、理論的には好きなだけ再帰を続けることができます。


1
avr-gccは末尾再帰をサポートしていません。
asheeshr

@AsheeshR-知っておくと良い。ありがとう。おそらくありそうもないと思った。
ピーターブルームフィールド14

コンパイラが実行することを期待する代わりに、コードをリファクタリングすることにより、テールコールの除去/最適化を行うことができます。再帰呼び出しが再帰メソッドの最後にある限り、while / forループを使用するようにメソッドを安全に書き換えることができます。
abasterfield 14

1
@TheDoctorの投稿は、「avr-gccは末尾再帰をサポートしていません」、彼のコードのテストと矛盾しています。コンパイラーは実際に末尾再帰を実装しました。これが、100万回の再帰に到達した方法です。Peterは正しいです-コンパイラーがcall / return(関数の最後の呼び出しとして)を単にjumpに置き換えることは可能です。最終結果は同じであり、スタックスペースを消費しません。
ニックギャモン

2

Alex AllainよるJumping into C ++、16章:Recursion、p.230 を読んでいたのとまったく同じ質問があったので、いくつかのテストを実行しました。

TLDR;

私のArduino Nano(ATmega328 mcu)は、スタックオーバーフローが発生してクラッシュする前に、211の再帰関数呼び出し(以下に示すコードの場合)を実行できます。

最初に、この主張に対処しましょう。

特定のアルゴリズムを実装するための唯一の迅速なオプションが再帰である場合があります。

[更新:ああ、「クイック」という単語を読みました。その場合、ある程度の妥当性があります。それでも、私は次のことを言う価値があると思います。]

いいえ、それは本当のことだとは思いません。すべてのアルゴリズムには、例外なく再帰的ソリューションと非再帰的ソリューションの両方があると確信ています。それは時々それがかなり簡単だということです再帰アルゴリズムを使用します。そうは言っても、再帰はマイクロコントローラーで使用するために非常に嫌われており、おそらく安全上重要なコードでは許可されないでしょう。それにもかかわらず、マイクロコントローラでそれを行うことはもちろん可能です。特定の再帰関数にどのように「深く」入ることができるかを知るには、テストするだけです!実際のテストケースの実際のアプリケーションで実行し、無限に再帰するように基本条件を削除します。カウンターを印刷して、どのように「深く」進むことができるかを確認して、再帰アルゴリズムが実際に使用するにはRAMの限界に近づきすぎていないかどうかを確認してください。以下は、Arduinoでスタックオーバーフローを強制するための例です。

さて、いくつかのメモ:

再帰呼び出し、つまり「スタックフレーム」をいくつ取得できるかは、次のような多くの要因によって決まります。

  • RAMのサイズ
  • スタック上に既にあるもの、またはヒープに占有されているもの(つまり、空きRAMが重要です; free_RAM = total_RAM - stack_used - heap_usedまたは、言うかもしれませんfree_RAM = stack_size_allocated - stack_size_used
  • 新しい再帰関数呼び出しごとにスタックに配置される新しい「スタックフレーム」のサイズ。これは、呼び出される関数、その変数、メモリ要件などに依存します。

私の結果:

  • 20171106-2054hrs-Toshiba Satellite w / 16 GB RAM; クアッドコア、Windows 8.1:クラッシュ前に出力される最終値:43166
    • クラッシュするのに数秒かかりました。5〜10ですか?
  • 20180306-1913hrs 64 GB RAMを搭載したDellハイエンドラップトップ; 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-0910hrs Arduino Nano:32 kBフラッシュ、2 kB SRAM、16 MHzプロセッサー:クラッシュ前に印刷された最終値:211 Here are the final print results: 209 210 211 ⸮ 9⸮ 3⸮
    • 115200のシリアルボーレートで印刷を開始すると、ほんの一瞬でした(たぶん1/10秒)
    • 2 kiB = 2048バイト/ 211スタックフレーム= 9.7バイト/フレーム(すべてのRAMがスタックで使用されていることを前提としていますが、実際にはそうではありません)-しかし、これは非常に合理的です。

コード:

PCアプリケーション:

/*
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. Alex AllainよるC ++へのジャンプ、16章:再帰、p.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")))する1つの関数に追加することができます。
エドガーボネット

ありがとう、エドガー。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用にコンパイルしましたが、私が書いているように、それは100万回以上繰り返されています!わかりませんが、コンパイラはこのプログラムを最適化した可能性があります


コールの設定数〜1000回後に戻るようにしてください。問題が発生するはずです。
asheeshr

1
コンパイラは、逆アセンブルした場合にわかるように、スケッチにテール再帰を巧妙に実装しています。これは、シーケンスをcall xxx/ で置き換えることを意味retjmp xxxます。これは、コンパイラのメソッドがスタックを消費しないことを除いて、同じことです。したがって、コードで何十億回も再帰する可能性があります(他の条件は同じです)。
ニックギャモン

再帰を最適化しないようにコンパイラーに強制することができます。後で戻って例を投稿します。
ガブリエルステープルズ

できた!ここでは例:arduino.stackexchange.com/a/51098/7727。その秘密は#pragma GCC optimize ("-O0") 、Arduinoプログラムの先頭に追加することで最適化を防ぐことです。これを適用したいファイルの先頭で実行する必要があると思いますが、何年も調べていないので、自分で確認してください。
ガブリエルステープルズ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.