Capisco cos'è un puntatore allo stack, ma a cosa serve?

10

Il puntatore dello stack punta in cima allo stack, che memorizza i dati su quella che chiamiamo base "LIFO". Per rubare l'analogia di qualcun altro, è come una pila di piatti in cui metti e prendi i piatti in cima. Il puntatore dello stack, OTOH, punta al "piatto" superiore della pila. Almeno, è vero per x86.

Ma perché il computer / programma "cura" a cosa punta il puntatore dello stack? In altre parole, che scopo ha il puntatore dello stack e sapere dove punta a servire?

Sarebbe gradita una spiegazione comprensibile da parte dei programmatori C.

    
posta moonman239 04.12.2015 - 10:53
fonte

9 risposte

22

What purpose does this stack actually serve, as opposed to explaining its structure?

Hai molte risposte che descrivono accuratamente la struttura dei dati memorizzati nello stack, che noto è l'opposto della domanda che hai posto.

Lo scopo che serve allo stack è: lo stack fa parte della reificazione della continuazione in una lingua senza le coroutine .

Disimballiamo quello.

La continuazione è semplicemente messa, la risposta alla domanda "che cosa succederà dopo nel mio programma?" A ogni punto di ogni programma succederà qualcosa. Verranno calcolati due operandi, quindi il programma continua calcolando la somma, quindi il programma continua assegnando la somma a una variabile, quindi ... e così via.

Reification è solo una parola altisonante per realizzare concretamente un concetto astratto. "Cosa succede dopo?" è un concetto astratto; il modo in cui è strutturato lo stack è una parte di come il concetto astratto viene trasformato in una vera macchina che calcola davvero le cose.

Coroutine sono funzioni che possono ricordare dove erano, cedere il controllo ad un'altra coroutine per un po 'e poi riprendere da dove si erano interrotti più tardi, ma non necessariamente immediatamente dopo i rendimenti di coroutine appena chiamati. Pensa a "yield return" o "await" in C #, che devono ricordare dove si trovavano quando viene richiesto il prossimo elemento o l'operazione asincrona completa. Le lingue con coroutine o funzioni linguistiche simili richiedono strutture dati più avanzate di una pila per implementare la continuazione.

In che modo uno stack implementa la continuazione? Altre risposte dicono come. La pila memorizza (1) i valori di variabili e temporanee la cui durata è nota per non essere maggiore dell'attivazione del metodo corrente e (2) l'indirizzo del codice di continuazione associato all'attivazione del metodo più recente. Nelle lingue con gestione delle eccezioni, lo stack può anche memorizzare informazioni sulla "continuazione degli errori", ovvero su cosa farà il programma in seguito quando si verificherà una situazione eccezionale.

Fammi cogliere l'occasione per notare che lo stack non ti dice "da dove vengo?" - sebbene sia spesso usato nel debugging. Lo stack ti dice dove stai andando al prossimo , e quali saranno i valori delle variabili dell'attivazione quando sarai lì . Il fatto che in una lingua senza coroutine, dove stai andando dopo è quasi sempre da dove sei venuto, rende più facile questo tipo di debugging. Ma non esiste un requisito che un compilatore memorizzi le informazioni su da dove proviene il controllo se può allontanarsi senza farlo. Ad esempio, le ottimizzazioni delle chiamate di coda distruggono le informazioni su da dove proviene il controllo del programma.

Perché usiamo lo stack per implementare la continuazione nelle lingue senza le coroutine? Poiché la caratteristica dell'attivazione sincrona dei metodi è che il modello di "sospendere il metodo corrente, attivare un altro metodo, riprendere il metodo corrente conoscendo il risultato del metodo attivato" quando composto con se stesso forma logicamente una pila di attivazioni. Creare una struttura dati che implementa questo comportamento simile allo stack è molto economico e facile. Perché è così economico e facile? Poiché i set di chip sono stati progettati per molti decenni specificamente per rendere questo tipo di programmazione facile per gli scrittori di compilatori.

    
risposta data 04.12.2015 - 20:09
fonte
4

L'uso di base dello stack è la memorizzazione dell'indirizzo di ritorno per le funzioni:

void a(){
    sub();
}
void b(){
    sub();
}
void sub() {
    //should i got back to a() or to b()?
}

e dal punto di vista C questo è tutto. Dal punto di vista del compilatore:

  • tutti gli argomenti delle funzioni vengono passati dai registri della CPU - se non ci sono abbastanza registri gli argomenti verranno messi nello stack
  • dopo la fine della funzione (la maggior parte) i registri dovrebbero avere gli stessi valori di prima di entrarvi - quindi i registri usati sono salvati nello stack

E dal punto di vista del sistema operativo: il programma può essere interrotto in qualsiasi momento, quindi dopo aver terminato con l'attività di sistema, dobbiamo ripristinare lo stato della CPU, quindi lasciate tutto nello stack

Tutto questo funziona poiché non ci interessa quanti oggetti sono già in pila o quanti oggetti verranno aggiunti in futuro, dobbiamo solo sapere quanto abbiamo spostato il puntatore dello stack e ripristinarlo dopo che abbiamo finito .

    
risposta data 04.12.2015 - 16:05
fonte
4

LIFO vs FIFO

LIFO sta per Last In, First Out. Come in, l'ultimo oggetto messo nella pila è il primo oggetto estratto dalla pila.

Ciò che hai descritto con l'analogia dei tuoi piatti (nella prima revisione ), è una coda o FIFO, in primo luogo In, First Out.

La principale differenza tra i due, essendo che il LIFO / stack spinge (inserisce) e schiocca (rimuove) dalla stessa estremità, e una coda / coda FIFO fa da estremità opposte.

// Both:

Push(a)
-> [a]
Push(b)
-> [a, b]
Push(c)
-> [a, b, c]

// Stack            // Queue
Pop()               Pop()
-> [a, b]           -> [b, c]

Il puntatore dello stack

Diamo un'occhiata a cosa sta succedendo sotto il cofano della pila. Ecco un po 'di memoria, ogni scatola è un indirizzo:

...[ ][ ][ ][ ]...                       char* sp;
    ^- Stack Pointer (SP)

E c'è un puntatore stack che punta nella parte inferiore dello stack attualmente vuoto (se lo stack cresce o diminuisce non è particolarmente rilevante qui, quindi lo ignoreremo, ma ovviamente nel mondo reale, ciò determina quale operazione aggiunge e che sottrae dallo SP).

Quindi riproviamo di nuovo a, b, and c . Grafica a sinistra, operazione di "alto livello" nel mezzo, pseudo codice C-ish a destra:

...[a][ ][ ][ ]...        Push('a')      *sp = 'a';
    ^- SP
...[a][ ][ ][ ]...                       ++sp;
       ^- SP

...[a][b][ ][ ]...        Push('b')      *sp = 'b';
       ^- SP
...[a][b][ ][ ]...                       ++sp;
          ^- SP

...[a][b][c][ ]...        Push('c')      *sp = 'c';
          ^- SP
...[a][b][c][ ]...                       ++sp;
             ^- SP

Come puoi vedere, ogni volta che push , inserisce l'argomento nella posizione in cui il puntatore dello stack sta puntando al momento e regola il puntatore dello stack in modo che punti alla posizione successiva.

Ora facciamo un salto:

...[a][b][c][ ]...        Pop()          --sp;
          ^- SP
...[a][b][c][ ]...                       return *sp; // returns 'c'
          ^- SP
...[a][b][c][ ]...        Pop()          --sp;
       ^- SP
...[a][b][c][ ]...                       return *sp; // returns 'b'
       ^- SP

Pop è l'opposto di push , regola il puntatore dello stack in modo che punti alla posizione precedente e rimuove l'elemento che era lì (in genere per restituirlo a chiunque abbia chiamato pop ).

Probabilmente hai notato che b e c sono ancora in memoria. Voglio solo assicurarti che quelli non sono errori di battitura. Torneremo su questo a breve.

Vita senza un puntatore dello stack

Vediamo cosa succede se non abbiamo un puntatore allo stack. Iniziando con lo spingere di nuovo:

...[ ][ ][ ][ ]...
...[ ][ ][ ][ ]...        Push(a)        ? = 'a';

Er, hmm ... se non abbiamo un puntatore stack, allora non possiamo spostare qualcosa all'indirizzo a cui punta. Forse possiamo usare un puntatore che punta alla base anziché alla cima.

...[ ][ ][ ][ ]...                       char* bp; // "base pointer"
    ^- bp                                bp = malloc(...);

...[a][ ][ ][ ]...        Push(a)        *bp = 'a';
    ^- bp
// No stack pointer, so no need to update it.
...[b][ ][ ][ ]...        Push(b)        *bp = 'b';
    ^- bp

Uh oh. Poiché non possiamo modificare il valore fisso della base dello stack, abbiamo appena sovrascritto a premendo b nella stessa posizione.

Bene, perché non teniamo traccia di quante volte abbiamo spinto. E dovremo anche tenere traccia dei tempi che abbiamo spuntato.

...[ ][ ][ ][ ]...                       char* bp; // "base pointer"
    ^- bp                                bp = malloc(...);
                                         int count = 0;

...[a][ ][ ][ ]...        Push(a)        bp[count] = 'a';
    ^- bp
...[a][ ][ ][ ]...                       ++count;
    ^- bp
...[a][b][ ][ ]...        Push(a)        bp[count] = 'b';
    ^- bp
...[a][b][ ][ ]...                       ++count;
    ^- bp
...[a][b][ ][ ]...        Pop()          --count;
    ^- bp
...[a][b][ ][ ]...                       return bp[count]; //returns b
    ^- bp

Funziona bene, ma in realtà è molto simile a prima, eccetto *pointer è più economico di pointer[offset] (senza aritmetica extra), per non dire che è meno da digitare. Mi sembra una perdita.

Proviamo di nuovo. Invece di utilizzare lo stile di stringa Pascal per trovare la fine di una raccolta basata su array (tenendo traccia di quanti elementi sono presenti nella raccolta), proviamo lo stile di stringa C (scansione dall'inizio alla fine):

...[ ][ ][ ][ ]...                       char* bp; // "base pointer"
    ^- bp                                bp = malloc(...);

...[ ][ ][ ][ ]...        Push(a)        char* top = bp;
    ^- bp, top
                                         while(*top != 0) { ++top; }
...[ ][ ][ ][a]...                       *top = 'a';
    ^- bp    ^- top

...[ ][ ][ ][ ]...        Pop()          char* top = bp;
    ^- bp, top
                                         while(*top != 0) { ++top; }
...[ ][ ][ ][a]...                       --top;
    ^- bp       ^- top                   return *top; // returns '('

Potresti aver già indovinato il problema qui. Non è garantito che la memoria non inizializzata sia pari a 0. Quindi, quando cerchiamo la parte superiore per posizionare a , finiamo per ignorare una serie di posizioni di memoria inutilizzate che contengono elementi casuali. Allo stesso modo, quando eseguiamo la scansione verso l'alto, finiamo per saltare oltre il a che abbiamo appena spinto fino a quando non troviamo finalmente un'altra posizione di memoria che è appena 0 , e torniamo indietro e restituiamo la spazzatura casuale poco prima.

Questo è abbastanza facile da correggere, dobbiamo solo aggiungere operazioni a Push e Pop per assicurarci che la cima dello stack sia sempre aggiornata per essere contrassegnata con 0 , e dobbiamo inizializzare lo stack con un tale terminatore. Ovviamente ciò significa anche che non possiamo avere un 0 (o qualsiasi valore che prendiamo come terminatore) come un valore reale nello stack.

Inoltre, abbiamo anche cambiato le operazioni di O (1) in operazioni O (n).

TL; DR

Il puntatore dello stack tiene traccia della parte superiore della pila, dove si verifica tutto dell'azione. Ci sono modi per risolverlo ( bp[count] e top sono essenzialmente ancora il puntatore dello stack), ma entrambi finiscono per essere più complicati e più lenti del semplice puntatore dello stack. E non sapere dove sia la cima della pila significa che non puoi usare lo stack.

Nota: il puntatore dello stack che punta al "fondo" dello stack di runtime in x86 potrebbe essere un equivoco relativo all'intero stack di runtime capovolto. In altre parole, la base della pila viene posizionata su un indirizzo di memoria elevato e la punta della pila si riduce a indirizzi di memoria inferiori. Il puntatore dello stack fa punta sulla punta dello stack in cui si verifica tutta l'azione, solo quel suggerimento si trova su un indirizzo di memoria inferiore rispetto alla base dello stack.

    
risposta data 04.12.2015 - 20:00
fonte
2

Il puntatore dello stack viene utilizzato (con il puntatore del frame) per lo stack di chiamate (segui il link a wikipedia, dove c'è una buona immagine).

Lo stack di chiamate contiene i frame di chiamata, che contengono l'indirizzo di ritorno, le variabili locali e altri dati locali (in particolare, sversato contenuto dei registri; formali).

Leggi anche le chiamate tail (alcune chiamate ricorsive in coda non hanno bisogno di frame di chiamata), gestione delle eccezioni (come setjmp & longjmp , possono coinvolgere scoppiare molti frame di stack contemporaneamente), segnali e amp; interrompe e continuazioni . Vedi anche chiamata convenzioni e applicazione interfacce binarie (ABI), in particolare il ABI x86-64 (che definisce che alcuni gli argomenti formali vengono passati dai registri).

Inoltre, codifica alcune semplici funzioni in C, quindi usa gcc -Wall -O -S -fverbose-asm per compilarlo e guarda nel file assembler .s generato.

Appel ha scritto un vecchio documento del 1986 affermando che la raccolta dei rifiuti può essere più veloce dell'allocazione dello stack ( usando Continuing-Passing Style nel compilatore), ma questo è probabilmente falso sui processori x86 di oggi (in particolare a causa della cache effetti).

Si noti che convenzioni di chiamata, ABI e layout dello stack sono diversi su 32 bit i686 e 64 bit x86-64. Inoltre, le convenzioni di chiamata (e chi è responsabile dell'allocazione o del popping del frame di chiamata) possono essere diverse con lingue diverse (ad esempio C, Pascal, Ocaml, SBCL Common Lisp hanno convenzioni di chiamata diverse ....)

A proposito, le recenti estensioni x86 come AVX stanno imponendo vincoli di allineamento sempre più grandi sul puntatore dello stack (IIRC, un frame di chiamata su x86-64 vuole essere allineato a 16 byte, cioè due parole o puntatori).

    
risposta data 04.12.2015 - 19:33
fonte
1

In termini semplici, il programma si preoccupa perché sta usando quei dati e ha bisogno di tenere traccia di dove trovarlo.

Se dichiari le variabili locali in una funzione, lo stack è dove sono memorizzati. Inoltre, se chiami un'altra funzione, la pila è dove memorizza l'indirizzo di ritorno in modo che possa tornare alla funzione in cui ti trovavi quando quello che hai chiamato è finito e riprende da dove era stato interrotto.

Senza il SP, la programmazione strutturata come la conosciamo sarebbe essenzialmente impossibile. (Si potrebbe evitare di averlo, ma sarebbe necessario implementare la propria versione di esso, quindi non è molto diverso.)

    
risposta data 04.12.2015 - 10:57
fonte
1

Per lo stack del processore in un processore x86, l'analogia di una pila di piatti è davvero imprecisa.
Per vari motivi (per lo più storici), lo stack del processore cresce dalla parte superiore della memoria verso il fondo della memoria, quindi una migliore analogia sarebbe una catena di collegamenti a catena che pendono dal soffitto. Quando si preme qualcosa sulla pila, viene aggiunto un collegamento a catena al collegamento più in basso.

Il puntatore dello stack si riferisce al collegamento più basso della catena e viene utilizzato dal processore per "vedere" dove si trova il collegamento più basso, in modo che i collegamenti possano essere aggiunti o rimossi senza dover percorrere l'intera catena dal soffitto verso il basso.

In un certo senso, all'interno di un processore x86, lo stack è sottosopra ma viene utilizzato il normale sill-terminology stack, in modo che il link più basso venga indicato come top dello stack.

I collegamenti a catena che ho citato sopra sono in realtà celle di memoria in un computer e si abituano a memorizzare variabili locali e alcuni risultati intermedi di calcoli. I programmi per computer si preoccupano della parte superiore dello stack (cioè dove si blocca il collegamento più basso), perché la grande maggioranza delle variabili a cui una funzione deve accedere esistono vicino a dove il puntatore dello stack fa riferimento e un accesso rapido ad esse è auspicabile. / p>     

risposta data 04.12.2015 - 12:09
fonte
1

Questa risposta si riferisce specificamente a il puntatore dello stack del thread corrente (di esecuzione) .

Nei linguaggi di programmazione procedurale, un thread ha in genere accesso a uno stack 1 per i seguenti scopi:

  • Flusso di controllo, ovvero "stack di chiamate".
    • Quando una funzione chiama un'altra funzione, lo stack di chiamate memorizza dove ritornare.
    • È necessario uno stack di chiamate perché è così che vogliamo che una "chiamata di funzione" si comporti - "per riprendere da dove avevamo lasciato" .
    • Ci sono altri stili di programmazione che non hanno chiamate di funzione nel mezzo dell'esecuzione (ad esempio, è consentito specificare la funzione successiva quando viene raggiunta la fine della funzione corrente) o non hanno alcuna chiamata di funzione (solo usando goto e salti condizionali). Questi stili di programmazione potrebbero non richiedere uno stack di chiamate.
  • Parametri di chiamata funzione.
    • Quando una funzione chiama un'altra funzione, i parametri possono essere inseriti nello stack.
    • È necessario che il chiamante e il giocatore di chiamata seguano la stessa convenzione di chi è responsabile della cancellazione dei parametri dallo stack, al termine della chiamata.
  • Variabili locali che vivono all'interno di una chiamata di funzione.
    • Si noti che una variabile locale che appartiene a un chiamante può essere resa accessibile a un destinatario spostando un puntatore a quella variabile locale al destinatario.

Nota 1 : dedicata all'uso del thread, sebbene il suo contenuto sia interamente leggibile - e smashable - da altri thread.

Nella programmazione di assiemi, C e C ++, tutti e tre gli scopi possono essere soddisfatti dallo stesso stack. In alcune altre lingue, alcuni scopi possono essere soddisfatti da stack separati o memoria allocata dinamicamente.

    
risposta data 04.12.2015 - 18:19
fonte
1

Ecco una versione volutamente semplificata di ciò per cui lo stack è usato.

Immagina la pila come una pila di carte indice. Il puntatore dello stack punta alla prima carta.

Quando chiami una funzione:

  • Scrivi l'indirizzo del codice immediatamente dopo la linea che ha chiamato la funzione su una carta e la metti sulla pila. (Ad esempio, si aumenta il puntatore dello stack di uno e si scrive l'indirizzo dove punta)
  • Quindi scrivi i valori contenuti nei registri su alcune carte, e metterli sul mucchio. (ad esempio, si aumenta il puntatore dello stack per il numero di registri e si copia il contenuto del registro nel punto in cui punta)
  • Quindi metti un segnalino sul mucchio. (ad esempio, salvi il puntatore dello stack corrente).
  • Quindi scrivi il valore di ogni parametro con cui la funzione viene chiamata, una su una carta e la metti sulla pila. (aumenta il puntatore dello stack per il numero di parametri e scrivi i parametri nel punto in cui punta il puntatore dello stack.)
  • Quindi aggiungi una carta per ogni variabile locale, potenzialmente scrivendo il valore iniziale su di essa. (ad esempio, si aumenta il puntatore dello stack per il numero di variabili locali).

A questo punto, il codice nella funzione viene eseguito. Il codice è compilato per sapere dove ogni carta è relativa alla cima. Quindi sa che la variabile x è la terza carta dall'alto (cioè il puntatore dello stack - 3) e che il parametro y è la sesta carta dall'alto (cioè il puntatore dello stack - 6.)

Questo metodo indica che l'indirizzo di ogni variabile o parametro locale non deve essere inserito nel codice. Invece, tutti questi elementi di dati sono indirizzati rispetto al puntatore dello stack.

Quando la funzione ritorna, l'operazione inversa è semplicemente:

  • Cerca il segnalino e getta via tutte le carte sopra di esso. (ad esempio, imposta il puntatore dello stack sull'indirizzo salvato).
  • Ripristina i registri dalle carte salvate prima e gettale via. (ad esempio, sottrarre un valore fisso dal puntatore dello stack)
  • Avvia il codice in esecuzione dall'indirizzo sulla scheda in alto e poi buttalo via. (ad esempio sottrarre 1 dal puntatore dello stack.)

Lo stack ora è tornato nello stato in cui era prima che venisse chiamata la funzione.

Quando lo si considera, si noti due cose: l'allocazione e la deallocazione dei locals è un'operazione estremamente veloce in quanto è solo l'aggiunta di un numero o la sottrazione di un numero dal puntatore dello stack. Nota anche come naturalmente questo funziona con la ricorsione.

Questo è eccessivamente semplificato a fini esplicativi. In pratica, i parametri e i locals possono essere inseriti nei registri come un'ottimizzazione e il puntatore dello stack sarà generalmente incrementato e decrementato dalla dimensione della parola della macchina, non da uno. (Per nominare un paio di cose.)

    
risposta data 04.12.2015 - 19:11
fonte
1

I linguaggi di programmazione moderni, come ben sai, supportano il concetto di chiamate di subroutine (il più delle volte chiamate "chiamate di funzione"). Ciò significa che:

  1. Nel mezzo di un codice, puoi chiamare un'altra funzione nel tuo programma;
  2. Quella funzione non sa esplicitamente da dove è stata chiamata;
  3. Tuttavia, quando il lavoro è terminato e return s, il controllo torna al punto esatto in cui è stata avviata la chiamata, con tutti i valori delle variabili locali in vigore come quando è stata avviata la chiamata.

Come fa il computer a tenerne traccia? Mantiene una registrazione continua di quali funzioni sono in attesa delle chiamate da restituire. Questo record è uno stack e poiché è così importante, normalmente lo chiamiamo lo stack.

E dal momento che questo schema di chiamata / ritorno è così importante, le CPU sono state progettate a lungo per fornire un supporto hardware speciale. Il puntatore dello stack è una caratteristica hardware delle CPU, un registro dedicato esclusivamente a tenere traccia della parte superiore dello stack e utilizzato dalle istruzioni della CPU per la ramificazione in una subroutine e il ritorno da esso.

    
risposta data 04.12.2015 - 22:39
fonte

Leggi altre domande sui tag