Quanto posso contare? Quanto posso contare? Quanto ca! @ # QFSD @ $ RFW


19

La scheda Arduino Uno ha una RAM limitata, il che significa che ha uno stack di chiamate limitato disponibile. A volte, la ricorsione è l'unica opzione rapida per implementare un certo algoritmo. Quindi, dato che lo stack di chiamate è fortemente limitato, quale sarebbe un modo per scoprire che, dato un determinato programma in esecuzione sulla scheda, esattamente quante chiamate ricorsive puoi permetterti prima che si verifichi un overflow dello stack (e accadono cose brutte)?


2
Puoi dare un esempio di un algoritmo in cui la scelta tra una soluzione ricorsiva e non ricorsiva è così chiara?
Cybergibbons,

1
@Cybergibbons Più di recente, ho dovuto scrivere e testare RDP in breve tempo, quindi sono andato avanti con l'implementazione ricorsiva. Non sto dicendo che l'iterazione non sia possibile, solo che potrebbe essere più semplice andare avanti con la versione ricorsiva.
asheeshr,

Che cosa significa questo nel titolo domanda: How much ca!@#QFSD@$RFW? Sono curioso di sapere perché nessuno lo ha modificato per renderlo qualcosa di più significativo (negli ultimi 4 anni).
Nick Gammon

La risposta è 211volte (a seconda di molti fattori) :). Vedi la mia risposta qui: arduino.stackexchange.com/a/51098/7727 . @NickGammon, fa finta di "imprecare" penso. È un gioco di parole per "recurse". Mi ci è voluto un minuto per capirlo. All'inizio era piuttosto confuso.
Gabriel Staples,

Oh, capisco. Ho pensato che i server SE avrebbero potuto avere un problema tecnico :)
Nick Gammon

Risposte:


15

Se vuoi davvero fare affidamento (e come ha detto @jippie è una cattiva idea; messaggio subliminale: non farlo ) e vuoi sapere quanto puoi ricorrere, allora dovrai eseguire alcuni calcoli ed esperimenti; inoltre generalmente ne avrai solo un'approssimazione poiché dipende molto dallo stato della memoria nel momento in cui verrà chiamata la tua funzione ricorsiva.

Per questo, dovresti prima sapere come è organizzata SRAM all'interno di Arduino basato su AVR (non si applicherà ad esempio all'Arduino Galileo di Intel). Il seguente diagramma di Adafruit lo mostra chiaramente:

Organizzazione SRAM

Quindi devi conoscere la dimensione totale della tua SRAM (dipende dalla MCU Atmel, quindi che tipo di scheda Arduino hai).

In questo diagramma, è facile scoprire la dimensione del blocco di dati statici come è noto in fase di compilazione e non cambierà in seguito.

La dimensione dell'heap può essere più difficile da conoscere in quanto può variare in fase di esecuzione, a seconda delle allocazioni di memoria dinamica ( malloco new) eseguite dallo schizzo o dalle librerie che utilizza. L'uso della memoria dinamica è piuttosto raro su Arduino, ma alcune funzioni standard lo fanno (il tipo lo Stringusa, penso).

Per le dimensioni dello Stack , varierà anche durante il runtime, in base alla profondità corrente delle chiamate di funzione (ogni chiamata di funzione richiede 2 byte sullo Stack per memorizzare l'indirizzo del chiamante) e il numero e la dimensione delle variabili locali inclusi gli argomenti passati ( che sono anche memorizzati nello Stack ) per tutte le funzioni chiamate fino ad ora.

Supponiamo quindi che la tua recurse()funzione utilizzi 12 byte per le sue variabili e gli argomenti locali, quindi ogni chiamata a questa funzione (la prima da un chiamante esterno e quelli ricorsivi) utilizzerà i 12+2byte.

Se supponiamo che:

  • sei su Arduino UNO (SRAM = 2K)
  • lo schizzo non utilizza l'allocazione dinamica della memoria (no Heap )
  • conosci la dimensione dei tuoi dati statici (diciamo 132 byte)
  • quando la recurse()funzione viene chiamata dallo schizzo, lo Stack corrente è lungo 128 byte

Quindi ti restano 2048 - 132 - 128 = 1788i byte disponibili nello Stack . Il numero di chiamate ricorsive alla tua funzione è quindi 1788 / 14 = 127, inclusa la chiamata iniziale (che non è ricorsiva).

Come puoi vedere, questo è molto difficile, ma non impossibile trovare quello che vuoi.

Un modo più semplice per ottenere la dimensione dello stack disponibile prima che recurse()venga chiamato sarebbe quello di utilizzare la seguente funzione (trovata nel centro di apprendimento di Adafruit; non l'ho testata da sola):

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

Ti incoraggio vivamente a leggere questo articolo nel centro di apprendimento di Adafruit.


Vedo peter-bloomfield pubblicare la sua risposta mentre stavo scrivendo la mia; la sua risposta sembra migliore in quanto descrive completamente il contenuto dello stack dopo una chiamata (avevo dimenticato lo stato dei registri).
jfpoilpret,

Entrambe le risposte di ottima qualità.
Cybergibbons,

Dati statici = .bss + .data, e cosa è corretto da Arduino come "RAM occupata da variabili globali" o quant'altro, corretto?
Gabriel Staples,

1
@GabrielStaples sì, esattamente. Più in dettaglio .bssrappresenta le variabili globali senza valore iniziale nel codice, mentre dataè per le variabili globali con un valore iniziale. Ma alla fine usano lo stesso spazio: Dati statici nel diagramma.
jfpoilpret

1
@GabrielStaples ha dimenticato una cosa, tecnicamente queste non sono solo variabili globali che vanno lì, ma hai anche variabili dichiarate staticall'interno di una funzione.
jfpoilpret

8

La ricorsione è una cattiva pratica su un microcontrollore come ti sei già dichiarato e probabilmente vorrai evitarlo quando possibile. Sul sito Arduino ci sono alcuni esempi e librerie disponibili per controllare la dimensione della RAM libera . Ad esempio, puoi usarlo per capire quando interrompere la ricorsione o un po 'più complicato / rischioso per profilare il tuo schizzo e codificare il limite in esso. Questo profilo sarebbe richiesto per ogni modifica nel programma e per ogni modifica nella catena di strumenti di Arduino.


Alcuni dei compilatori più sofisticati, come IAR (che supportano AVR) e Keil (che non supportano AVR) dispongono di strumenti che consentono di monitorare e gestire lo spazio dello stack. In realtà non è consigliabile su qualcosa di piccolo come un ATmega328.
Cybergibbons,

7

Dipende dalla funzione.

Ogni volta che viene chiamata una funzione, un nuovo frame viene inserito nello stack. Di solito conterrà vari elementi critici, tra cui potenzialmente:

  • Indirizzo di ritorno (il punto nel codice da cui è stata chiamata la funzione).
  • Puntatore all'istanza locale ( this) se si chiama una funzione membro.
  • I parametri sono passati alla funzione.
  • Registrare i valori che devono essere ripristinati al termine della funzione.
  • Spazio per variabili locali all'interno della funzione chiamata.

Come puoi vedere, lo spazio di stack richiesto per una determinata chiamata dipende dalla funzione. Ad esempio, se si scrive una funzione ricorsiva che accetta solo un intparametro e non utilizza variabili locali, non avrà bisogno di più di qualche byte nello stack. Ciò significa che puoi chiamarlo ricorsivamente molto più di una funzione che accetta diversi parametri e utilizza molte variabili locali (che consumeranno lo stack molto più rapidamente).

Ovviamente lo stato dello stack dipende da cos'altro sta succedendo nel codice. Se si avvia una ricorsione direttamente all'interno della loop()funzione standard , probabilmente non ci sarà già molto nello stack. Tuttavia, se lo avvii annidato di diversi livelli in profondità in altre funzioni, non ci sarà più spazio. Ciò influirà sul numero di volte in cui puoi ricorrere senza esaurire la pila.

Vale la pena notare che l'ottimizzazione della ricorsione della coda esiste su alcuni compilatori (anche se non sono sicuro che avr-gcc lo supporti). Se la chiamata ricorsiva è l'ultima cosa in una funzione, significa che a volte è possibile evitare di alterare del tutto il frame dello stack. Il compilatore può semplicemente riutilizzare il frame esistente, poiché la chiamata 'parent' (per così dire) ha finito di usarlo. Ciò significa che teoricamente puoi continuare a ricorrere quanto vuoi, purché la tua funzione non chiami nient'altro.


1
avr-gcc non supporta la ricorsione della coda.
asheeshr,

@AsheeshR - Buono a sapersi. Grazie. Ho pensato che probabilmente era improbabile.
Peter Bloomfield,

È possibile eseguire un'eliminazione / ottimizzazione delle chiamate di coda rifattorizzando il codice anziché sperare che il compilatore lo esegua. Finché la chiamata ricorsiva si trova alla fine del metodo ricorsivo, è possibile riscrivere in modo sicuro il metodo per utilizzare un ciclo while / for.
Abasterfield,

1
Il post di @TheDoctor contraddice "avr-gcc non supporta la ricorsione della coda", così come il mio test del suo codice. Il compilatore ha effettivamente implementato la ricorsione della coda, ed è così che ha ottenuto fino a un milione di ricorsioni. Peter ha ragione: è possibile che il compilatore sostituisca call / return (come l'ultima chiamata in una funzione) semplicemente saltando . Ha lo stesso risultato finale e non consuma spazio nello stack.
Nick Gammon

2

Ho avuto la stessa identica domanda mentre leggevo Jumping in C ++ di Alex Allain , Ch 16: Recursion, p.230, quindi ho eseguito alcuni test.

TLDR;

Il mio Arduino Nano (ATmega328 mcu) può eseguire 211 chiamate di funzione ricorsive (per il codice indicato di seguito) prima che abbia uno stack overflow e si arresti in modo anomalo.

Prima di tutto, lasciami rispondere a questo reclamo:

A volte, la ricorsione è l'unica opzione rapida per implementare un certo algoritmo.

[Aggiornamento: ah, ho scremato la parola "veloce". In tal caso hai una certa validità. Tuttavia, penso che valga la pena dire quanto segue.]

No, non credo sia una vera affermazione. Sono abbastanza sicuro che tutti gli algoritmi hanno una soluzione sia ricorsiva che non ricorsiva, senza eccezioni. È solo che a volte è significativamente più sempliceusare un algoritmo ricorsivo. Detto questo, la ricorsione è molto disapprovata per l'uso su microcontrollori e probabilmente non sarebbe mai consentita in un codice critico per la sicurezza. Tuttavia, è ovviamente possibile farlo su microcontrollori. Per sapere quanto "in profondità" puoi andare in una data funzione ricorsiva, basta provarla! Eseguilo nella tua applicazione di vita reale in un caso di test di vita reale e rimuovi le tue condizioni di base in modo che possa ricorrere all'infinito. Stampa un contatore e vedi di persona quanto "in profondità" puoi andare in modo da sapere se il tuo algoritmo ricorsivo sta spingendo i limiti della tua RAM troppo vicino per essere utilizzato praticamente. Ecco un esempio di seguito per forzare l'overflow dello stack su un Arduino.

Ora, alcune note:

Quante chiamate ricorsive o "stack frame" che puoi ottenere sono determinate da una serie di fattori, tra cui:

  • La dimensione della tua RAM
  • Quanta roba è già sul tuo stack o occupata nel tuo heap (es: la tua RAM libera conta; free_RAM = total_RAM - stack_used - heap_usedo potresti dire free_RAM = stack_size_allocated - stack_size_used)
  • La dimensione di ogni nuovo "frame stack" che verrà inserito nello stack per ogni nuova chiamata di funzione ricorsiva. Questo dipenderà dalla funzione chiamata e dalle sue variabili e requisiti di memoria, ecc.

I miei risultati:

  • 20171106-2054hrs - Toshiba Satellite w / 16 GB RAM; quad-core, Windows 8.1: valore finale stampato prima dell'arresto anomalo: 43166
    • ci sono voluti diversi secondi per andare in crash - forse 5 ~ 10?
  • 20180306-1913 ore laptop Dell di fascia alta con 64 GB di RAM; Ubuntu 14.04 LTS a 8 core: valore finale stampato prima dell'arresto anomalo: 261752
    • seguita dalla frase Segmentation fault (core dumped)
    • ci sono voluti solo ~ 4 ~ 5 secondi circa per arrestarsi
  • 20180306-1930hrs Arduino Nano: TBD --- è a ~ 250000 e continua a contare --- le impostazioni di ottimizzazione di Arduino devono averlo causato per ottimizzare la ricorsione ... ??? Sì, è così.
    • Aggiungi #pragma GCC optimize ("-O0")all'inizio del file e ripeti:
  • 20180307-0910 ore Arduino Nano: flash da 32 kB, SRAM da 2 kB, processore da 16 MHz: valore finale stampato prima dell'arresto anomalo: 211 Here are the final print results: 209 210 211 ⸮ 9⸮ 3⸮
    • ha impiegato solo una frazione di secondo dopo aver iniziato a stampare con una velocità di trasmissione seriale di 115200, forse 1/10 di secondo
    • 2 kiB = 2048 byte / 211 stack frame = 9,7 byte / frame (supponendo che TUTTA la RAM sia utilizzata dallo stack - che in realtà non è il caso) - ma ciò sembra comunque molto ragionevole.

Il codice:

L'applicazione per 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);
}

Il programma "Sketch" di 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()
{
}

Riferimenti:

  1. Jumping in C ++ di Alex Allain , Ch 16: Recursion, p.230
  2. http://www.electricrcaircraftguy.com/2014/01/the-power-of-arduino.html - letteralmente: ho fatto riferimento al mio sito Web durante questo "progetto" per ricordarmi come modificare i livelli di ottimizzazione del compilatore Arduino per un determinato file con il #pragma GCC optimizecomando poiché sapevo di averlo documentato lì.

1
Nota che, secondo i documenti di avr-lib, non dovresti mai compilare senza ottimizzazione tutto ciò che si basa su avr-libc, poiché alcune cose non sono garantite per funzionare anche con l'ottimizzazione disattivata. Ti consiglio quindi di #pragmanon usarlo lì. Invece, è possibile aggiungere __attribute__((optimize("O0")))alla singola funzione che si desidera non ottimizzare.
Edgar Bonet,

Grazie Edgar. Sai dove AVR libc ha documentato questo?
Gabriel Staples,

1
La documentazione su <util / delay.h> afferma: "Affinché queste funzioni funzionino come previsto, le ottimizzazioni del compilatore devono essere abilitate [...]" (enfasi nell'originale). Non sono del tutto sicuro che qualsiasi altra funzione avr-libc abbia questo requisito.
Edgar Bonet,

1

Ho scritto questo semplice programma di test:

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);
}

L'ho compilato per Uno e, mentre scrivo, è ripetuto oltre 1 milione di volte! Non lo so, ma il compilatore potrebbe aver ottimizzato questo programma


Prova a tornare dopo un determinato numero di chiamate ~ 1000. Dovrebbe creare un problema allora.
asheeshr,

1
Il compilatore ha abilmente implementato la ricorsione della coda sul tuo schizzo, come vedrai se lo smonterai. Ciò significa che sostituisce la sequenza call xxx/ retcon jmp xxx. Ciò equivale alla stessa cosa, tranne per il fatto che il metodo del compilatore non utilizza lo stack. Quindi potresti ricorrere a miliardi di volte con il tuo codice (a parità di altre cose).
Nick Gammon

È possibile forzare il compilatore a non ottimizzare la ricorsione. Tornerò e pubblicherò un esempio più tardi.
Gabriel Staples,

Fatto! Esempio qui: arduino.stackexchange.com/a/51098/7727 . Il segreto è prevenire l'ottimizzazione aggiungendo #pragma GCC optimize ("-O0") all'inizio del programma Arduino. Credo che tu debba farlo nella parte superiore di ogni file a cui vuoi che venga applicato, ma non lo cerco da anni, quindi cerca di esserne sicuro.
Gabriel Staples,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.