Come funziona esattamente il callstack?


103

Sto cercando di comprendere più a fondo come funzionano le operazioni di basso livello dei linguaggi di programmazione e soprattutto come interagiscono con il sistema operativo / CPU. Probabilmente ho letto ogni risposta in ogni thread relativo allo stack / heap qui su Stack Overflow, e sono tutti brillanti. Ma c'è ancora una cosa che non ho ancora capito completamente.

Considera questa funzione in pseudo codice che tende ad essere un codice Rust valido ;-)

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(a, b);
    doAnotherThing(c, d);
}

Questo è il modo in cui presumo che lo stack appaia sulla riga X:

Stack

a +-------------+
  | 1           | 
b +-------------+     
  | 2           |  
c +-------------+
  | 3           | 
d +-------------+     
  | 4           | 
  +-------------+ 

Ora, tutto ciò che ho letto su come funziona lo stack è che obbedisce rigorosamente alle regole LIFO (ultimo entrato, primo uscito). Proprio come un tipo di dati stack in .NET, Java o qualsiasi altro linguaggio di programmazione.

Ma se è così, cosa succede dopo la riga X? Perché ovviamente, la prossima cosa di cui abbiamo bisogno è lavorare con ae b, ma ciò significherebbe che il sistema operativo / CPU (?) Deve uscire de cprima tornare a ae b. Ma poi si sparerebbe nel piede, perché ha bisogno ce dnella riga successiva.

Quindi, mi chiedo cosa succede esattamente dietro le quinte?

Un'altra domanda correlata. Considera di passare un riferimento a una delle altre funzioni in questo modo:

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(&a, &b);
    doAnotherThing(c, d);
}

Da come ho capito le cose, ciò significherebbe che i parametri in doSomethingstanno essenzialmente puntando allo stesso indirizzo di memoria come ae bin foo. Ma poi di nuovo questo significa che non ci sarà nessun pop up fino a quando non arriveremo aa eb accadranno.

Questi due casi mi fanno pensare che non ho compreso appieno come funziona esattamente lo stack e come segue rigorosamente le regole LIFO .


14
LIFO è importante solo per riservare spazio in pila. Puoi sempre accedere a qualsiasi variabile che sia almeno sul tuo stack frame (dichiarato all'interno della funzione) anche se si trova sotto molte altre variabili
VoidStar

2
In altre parole, LIFOsignifica che puoi aggiungere o rimuovere elementi solo alla fine dello stack e puoi sempre leggere / modificare qualsiasi elemento.
HolyBlackCat

12
Perché non smonti una semplice funzione dopo aver compilato con -O0 e guardi le istruzioni generate? È carino, beh, istruttivo ;-). Scoprirai che il codice fa un buon uso della parte R della RAM; accede agli indirizzi direttamente a piacimento. Puoi pensare a un nome di variabile come un offset a un registro di indirizzi (il puntatore dello stack). Come hanno detto gli altri, lo stack è solo LIFO rispetto allo stacking (buono per la ricorsione, ecc.). Non è LIFO per quanto riguarda l'accesso. L'accesso è completamente casuale.
Peter - Ripristina Monica il

6
È possibile creare la propria struttura di dati dello stack utilizzando un array e archiviando semplicemente l'indice dell'elemento superiore, incrementandolo quando si preme, diminuendolo quando si apre. Se lo facessi, saresti comunque in grado di accedere a qualsiasi singolo elemento dell'array in qualsiasi momento senza spingerlo o aprirlo, proprio come puoi sempre con gli array. Approssimativamente la stessa cosa sta accadendo qui.
Crowman

3
Fondamentalmente, la denominazione di stack / heap è sfortunata. Hanno poca somiglianza con lo stack e l'heap nella terminologia delle strutture dati, quindi chiamarli allo stesso modo è molto confuso.
Siyuan Ren

Risposte:


117

Lo stack di chiamate potrebbe anche essere chiamato stack di frame.
Le cose che vengono impilate dopo il principio LIFO non sono le variabili locali ma l'intero stack frame ("chiamate") delle funzioni chiamate . Le variabili locali vengono inserite e inserite insieme a quei frame nel cosiddetto prologo della funzione e nell'epilogo , rispettivamente.

All'interno del frame l'ordine delle variabili è completamente non specificato; I compilatori "riordinano" le posizioni delle variabili locali all'interno di un frame in modo appropriato per ottimizzare il loro allineamento in modo che il processore possa recuperarle il più rapidamente possibile. Il fatto cruciale è che l'offset delle variabili rispetto a un indirizzo fisso è costante per tutta la durata del frame , quindi è sufficiente prendere un indirizzo di ancoraggio, ad esempio l'indirizzo del frame stesso, e lavorare con gli offset di quell'indirizzo per le variabili. Un tale indirizzo di ancoraggio è effettivamente contenuto nella cosiddetta base o frame pointerche è memorizzato nel registro EBP. Gli offset, d'altra parte, sono chiaramente noti in fase di compilazione e sono quindi codificati nel codice macchina.

Questo grafico da Wikipedia mostra come è strutturato il tipico stack di chiamate 1 :

Immagine di una pila

Aggiungiamo l'offset di una variabile a cui vogliamo accedere all'indirizzo contenuto nel frame pointer e otteniamo l'indirizzo della nostra variabile. Detto così brevemente, il codice vi accede direttamente tramite offset costanti del tempo di compilazione dal puntatore di base; È semplice aritmetica del puntatore.

Esempio

#include <iostream>

int main()
{
    char c = std::cin.get();
    std::cout << c;
}

gcc.godbolt.org ci fornisce

main:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp

    movl    std::cin, %edi
    call    std::basic_istream<char, std::char_traits<char> >::get()
    movb    %al, -1(%rbp)
    movsbl  -1(%rbp), %eax
    movl    %eax, %esi
    movl    std::cout, %edi
    call    [... the insertion operator for char, long thing... ]

    movl    $0, %eax
    leave
    ret

.. per main. Ho diviso il codice in tre sottosezioni. Il prologo della funzione è costituito dalle prime tre operazioni:

  • Il puntatore di base viene inserito nella pila.
  • Il puntatore dello stack viene salvato nel puntatore di base
  • Il puntatore allo stack viene sottratto per fare spazio alle variabili locali.

Quindi cinviene spostato nel registro EDI 2 e getviene chiamato; Il valore restituito è in EAX.

Fin qui tutto bene. Ora accade la cosa interessante:

Il byte di ordine inferiore di EAX, designato dal registro a 8 bit AL, viene preso e memorizzato nel byte subito dopo il puntatore di base : ovvero -1(%rbp), l'offset del puntatore di base è -1. Questo byte è la nostra variabilec . L'offset è negativo perché lo stack cresce verso il basso su x86. L'operazione successiva viene memorizzata cin EAX: EAX viene spostato in ESI, coutviene spostato in EDI e quindi viene chiamato l'operatore di inserimento con coute cessendo gli argomenti.

Finalmente,

  • Il valore restituito di mainè memorizzato in EAX: 0. Ciò è dovuto returnall'istruzione implicita . Potresti anche vedere xorl rax raxinvece di movl.
  • lasciare e tornare al sito della chiamata. leavesta abbreviando questo epilogo e implicitamente
    • Sostituisce il puntatore dello stack con il puntatore di base e
    • Apre il puntatore di base.

Dopo che questa operazione retè stata eseguita, il frame è stato effettivamente rimosso, sebbene il chiamante debba ancora ripulire gli argomenti poiché stiamo usando la convenzione di chiamata cdecl. Altre convenzioni, ad esempio stdcall, richiedono al chiamato di riordinare, ad esempio passando la quantità di byte a ret.

Omissione del puntatore del fotogramma

È anche possibile non utilizzare offset dal puntatore base / frame ma invece dallo stack pointer (ESB). Ciò rende il registro EBP che altrimenti conterrebbe il valore del puntatore del frame disponibile per un uso arbitrario, ma può rendere impossibile il debug su alcune macchine e sarà implicitamente disattivato per alcune funzioni . È particolarmente utile quando si compila per processori con solo pochi registri, incluso x86.

Questa ottimizzazione è nota come FPO (frame pointer omission) e impostata da -fomit-frame-pointerin GCC e -Oyin Clang; si noti che è implicitamente attivato da ogni livello di ottimizzazione> 0 se e solo se il debugging è ancora possibile, poiché non ha costi a parte questo. Per ulteriori informazioni vedere qui e qui .


1 Come sottolineato nei commenti, il puntatore del frame è presumibilmente destinato a puntare all'indirizzo dopo l'indirizzo del mittente.

2 Notare che i registri che iniziano con R sono le controparti a 64 bit di quelli che iniziano con E. EAX designa i quattro byte di ordine inferiore di RAX. Ho usato i nomi dei registri a 32 bit per chiarezza.


1
Bella risposta. Il problema con l'indirizzamento dei dati per offset era il bit mancante per me :)
Christoph

1
Penso che ci sia un piccolo errore nel disegno. Il puntatore al frame dovrebbe trovarsi sull'altro lato dell'indirizzo del mittente. L'abbandono di una funzione viene solitamente fatto come segue: sposta il puntatore dello stack sul puntatore del frame, estrai il puntatore del frame del chiamante dallo stack, ritorna (cioè
fai

Kasperd ha assolutamente ragione. O non si usa affatto il puntatore al frame (ottimizzazione valida e in particolare per architetture affamate di registri come x86 estremamente utile) o lo si utilizza e si memorizza il precedente sullo stack, di solito subito dopo l'indirizzo di ritorno. Il modo in cui il frame viene impostato e rimosso dipende in gran parte dall'architettura e dall'ABI. Ci sono un bel po 'di architetture (ciao Itanium) in cui l'intera faccenda è .. più interessante (e ci sono cose come elenchi di argomenti di dimensioni variabili!)
Voo

3
@Christoph penso che ti stai avvicinando a questo da un punto di vista concettuale. Ecco un commento che, si spera, chiarirà questo aspetto: RTS, o RunTime Stack, è un po 'diverso dagli altri stack, in quanto è uno "stack sporco" - in realtà non c'è nulla che ti impedisca di guardare un valore che non è t in alto. Si noti che nel diagramma, l '"indirizzo di ritorno" per il metodo verde, che è necessario per il metodo blu! è dopo i parametri. In che modo il metodo blu ottiene il valore di ritorno, dopo che il frame precedente è stato estratto? Beh, è ​​una pila sporca, quindi può semplicemente raggiungerla e afferrarla.
Riking

1
Il puntatore al fotogramma in realtà non è necessario perché è sempre possibile utilizzare invece gli offset dal puntatore dello stack. GCC che mira alle architetture x64 per impostazione predefinita utilizza il puntatore allo stack e si libera rbpper fare altro lavoro.
Siyuan Ren

27

Perché ovviamente, la prossima cosa di cui abbiamo bisogno è lavorare con aeb, ma ciò significherebbe che il sistema operativo / CPU (?) Deve prima uscire d e c per tornare a aeb. Ma poi si sparerebbe nel piede perché ha bisogno di ced nella riga successiva.

In breve:

Non è necessario inserire gli argomenti. Gli argomenti passati dal chiamante fooalla funzione doSomethinge le variabili locali in doSomething possono essere tutti referenziati come un offset dal puntatore di base .
Così,

  • Quando viene effettuata una chiamata di funzione, gli argomenti della funzione vengono PUSH in pila. Questi argomenti sono ulteriormente referenziati dal puntatore di base.
  • Quando la funzione ritorna al suo chiamante, gli argomenti della funzione restituita vengono inseriti in POP dallo stack utilizzando il metodo LIFO.

In dettaglio:

La regola è che ogni chiamata di funzione produce una creazione di uno stack frame (con il minimo che è l'indirizzo a cui tornare). Quindi, se funcAchiama funcBe funcBchiama funcC, tre stack frame vengono impostati uno sopra l'altro. Quando una funzione ritorna, il suo frame diventa non valido . Una funzione ben comportata agisce solo sul proprio stack frame e non supera quello di un altro. In altre parole, il POPing viene eseguito sullo stack frame in alto (al ritorno dalla funzione).

inserisci qui la descrizione dell'immagine

Lo stack nella tua domanda viene impostato dal chiamante foo. Quando doSomethinge doAnotherThingvengono chiamati, quindi impostano il proprio stack. La figura può aiutarti a capire questo:

inserisci qui la descrizione dell'immagine

Si noti che, per accedere agli argomenti, il corpo della funzione dovrà spostarsi verso il basso (indirizzi superiori) dalla posizione in cui è memorizzato l'indirizzo di ritorno, e per accedere alle variabili locali, il corpo della funzione dovrà attraversare lo stack (indirizzi inferiori ) relativo al luogo in cui è memorizzato l'indirizzo del mittente. In effetti, il tipico codice generato dal compilatore per la funzione farà esattamente questo. Il compilatore dedica un registro chiamato EBP per questo (Base Pointer). Un altro nome per lo stesso è frame pointer. Il compilatore in genere, come prima cosa per il corpo della funzione, inserisce il valore EBP corrente nello stack e imposta l'EBP sull'ESP corrente. Ciò significa che, una volta fatto ciò, in qualsiasi parte del codice della funzione, l'argomento 1 è EBP + 8 di distanza (4 byte per ciascuno degli EBP del chiamante e l'indirizzo di ritorno), l'argomento 2 è EBP + 12 (decimale) di distanza, variabili locali sono EBP-4n di distanza.

.
.
.
[ebp - 4]  (1st local variable)
[ebp]      (old ebp value)
[ebp + 4]  (return address)
[ebp + 8]  (1st argument)
[ebp + 12] (2nd argument)
[ebp + 16] (3rd function argument) 

Dai un'occhiata al seguente codice C per la formazione dello stack frame della funzione:

void MyFunction(int x, int y, int z)
{
     int a, int b, int c;
     ...
}

Quando il chiamante lo chiama

MyFunction(10, 5, 2);  

verrà generato il codice seguente

^
| call _MyFunction  ; Equivalent to: 
|                   ; push eip + 2
|                   ; jmp _MyFunction
| push 2            ; Push first argument  
| push 5            ; Push second argument  
| push 10           ; Push third argument  

e il codice assembly per la funzione sarà (impostato dal chiamato prima di tornare)

^
| _MyFunction:
|  sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
|  ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
|  ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] =   [esp]
|  mov ebp, esp
|  push ebp
 

Riferimenti:


1
La ringrazio per la risposta. Inoltre i link sono davvero interessanti e mi aiutano a fare più luce sull'infinita domanda su come funzionano effettivamente i computer :)
Christoph

Cosa intendi per "spinge il valore EBP corrente nello stack" e anche il puntatore dello stack viene memorizzato nel registro o anche quello occupa una posizione nello stack ... sono un po 'confuso
Suraj Jain

E non dovrebbe essere * [ebp + 8] non [ebp + 8].?
Suraj Jain

@Suraj Jain; Sai cos'è EBPe ESP?
haccks

esp è il puntatore dello stack e ebp è il puntatore di base. Se ho qualche conoscenza mancante, ti preghiamo gentilmente di correggerla.
Suraj Jain

19

Come altri hanno notato, non è necessario inserire i parametri finché non escono dall'ambito.

Incollerò qualche esempio da "Pointers and Memory" di Nick Parlante. Penso che la situazione sia un po 'più semplice di quanto immaginavi.

Ecco il codice:

void X() 
{
  int a = 1;
  int b = 2;

  // T1
  Y(a);

  // T3
  Y(b);

  // T5
}

void Y(int p) 
{
  int q;
  q = p + 2;
  // T2 (first time through), T4 (second time through)
}

I punti nel tempo T1, T2, etc. sono contrassegnati nel codice e lo stato della memoria in quel momento è mostrato nel disegno:

inserisci qui la descrizione dell'immagine


2
Ottima spiegazione visiva. Ho cercato su Google e ho trovato il documento qui: cslibrary.stanford.edu/102/PointersAndMemory.pdf Documento davvero utile!
Christoph

7

Processori e linguaggi diversi utilizzano diversi modelli di stack. Due modelli tradizionali sia sull'8x86 che sul 68000 sono chiamati convenzione di chiamata Pascal e convenzione di chiamata C; ogni convenzione viene gestita allo stesso modo in entrambi i processori, ad eccezione dei nomi dei registri. Ciascuno utilizza due registri per gestire lo stack e le variabili associate, chiamati stack pointer (SP o A7) e frame pointer (BP o A6).

Quando si chiama la subroutine utilizzando una delle due convenzioni, tutti i parametri vengono inseriti nello stack prima di chiamare la routine. Il codice della routine quindi inserisce il valore corrente del puntatore del frame nello stack, copia il valore corrente del puntatore dello stack nel puntatore del frame e sottrae dal puntatore dello stack il numero di byte utilizzati dalle variabili locali [se presenti]. Fatto ciò, anche se vengono inseriti dati aggiuntivi nello stack, tutte le variabili locali verranno memorizzate in variabili con uno spostamento negativo costante dal puntatore dello stack e tutti i parametri che sono stati inseriti nello stack dal chiamante potranno essere spostamento positivo costante dal puntatore del fotogramma.

La differenza tra le due convenzioni sta nel modo in cui gestiscono un'uscita dalla subroutine. Nella convenzione C, la funzione di restituzione copia il puntatore al fotogramma nel puntatore allo stack [ripristinandolo al valore che aveva subito dopo che il vecchio puntatore al fotogramma è stato premuto], estrae il vecchio valore del puntatore al fotogramma ed esegue un ritorno. Tutti i parametri che il chiamante aveva inserito nello stack prima della chiamata rimarranno lì. Nella convenzione Pascal, dopo aver estratto il vecchio frame pointer, il processore inserisce l'indirizzo di ritorno della funzione, aggiunge allo stack pointer il numero di byte di parametri spinti dal chiamante, e poi va all'indirizzo di ritorno estratto. Sul 68000 originale era necessario utilizzare una sequenza di 3 istruzioni per rimuovere i parametri del chiamante; i processori 8x86 e tutti i processori 680x0 dopo l'originale includevano un "ret N"

La convenzione Pascal ha il vantaggio di salvare un po 'di codice dal lato del chiamante, poiché il chiamante non deve aggiornare il puntatore allo stack dopo una chiamata a una funzione. Richiede, tuttavia, che la funzione chiamata sappia esattamente quanti byte di parametri il chiamante metterà nello stack. Non riuscire a inserire il numero corretto di parametri nello stack prima di chiamare una funzione che utilizza la convenzione Pascal è quasi garantito per causare un arresto anomalo. Ciò è compensato, tuttavia, dal fatto che un po 'di codice extra all'interno di ciascun metodo chiamato salverà il codice nei punti in cui viene chiamato il metodo. Per questo motivo, la maggior parte delle routine originali della casella degli strumenti Macintosh utilizzava la convenzione di chiamata Pascal.

La convenzione di chiamata C ha il vantaggio di consentire alle routine di accettare un numero variabile di parametri ed essere robusta anche se una routine non utilizza tutti i parametri che vengono passati (il chiamante saprà quanti byte di parametri ha spinto e sarà così in grado di ripulirli). Inoltre, non è necessario eseguire la pulizia dello stack dopo ogni chiamata di funzione. Se una routine chiama quattro funzioni in sequenza, ognuna delle quali utilizza parametri per un valore di quattro byte, può - invece di usare una ADD SP,4chiamata dopo ogni chiamata, usarne una ADD SP,16dopo l'ultima chiamata per ripulire i parametri da tutte e quattro le chiamate.

Oggigiorno le convenzioni di chiamata descritte sono considerate alquanto antiquate. Poiché i compilatori sono diventati più efficienti nell'utilizzo dei registri, è comune che i metodi accettino alcuni parametri nei registri piuttosto che richiedere che tutti i parametri vengano inseriti nello stack; se un metodo può utilizzare i registri per contenere tutti i parametri e le variabili locali, non è necessario utilizzare un frame pointer e quindi non è necessario salvare e ripristinare quello vecchio. Tuttavia, a volte è necessario utilizzare le convenzioni di chiamata precedenti quando si richiamano le librerie collegate per utilizzarle.


1
Wow! Posso prendere in prestito il tuo cervello per una settimana o giù di lì. Hai bisogno di estrarre un po 'di roba grintosa! Bella risposta!
Christoph

Dove vengono memorizzati il ​​frame e il puntatore dello stack nello stack stesso o in qualsiasi altro luogo?
Suraj Jain

@SurajJain: in genere, ogni copia salvata del puntatore del fotogramma verrà memorizzata con uno spostamento fisso rispetto al nuovo valore del puntatore del fotogramma.
supercat

Signore, ho questo dubbio da molto tempo. Se nella mia funzione scrivo if (g==4)then int d = 3e gprendo input usando scanfdopo di che definisco un'altra variabile int h = 5. Ora, come fa ora il compilatore a dare d = 3spazio nello stack. Come viene eseguito l'offset perché se gnon lo è 4, allora non ci sarebbe memoria per d nello stack e verrebbe semplicemente dato offset a he se g == 4poi offset sarà prima per g e poi per h. Come fa il compilatore a farlo in fase di compilazione, non conosce il nostro input perg
Suraj Jain

@SurajJain: Le prime versioni di C richiedevano che tutte le variabili automatiche all'interno di una funzione dovessero apparire prima di qualsiasi istruzione eseguibile. Rilassando leggermente quella complicata compilazione, ma un approccio consiste nel generare codice all'inizio di una funzione che sottrae a SP il valore di un'etichetta dichiarata in avanti. All'interno della funzione, il compilatore può in ogni punto del codice tenere traccia di quanti byte di valore locale sono ancora nell'ambito e anche tenere traccia del numero massimo di byte di valore locale che sono sempre nell'ambito. Alla fine della funzione, può fornire il valore per il precedente ...
supercat

5

Ci sono già alcune ottime risposte qui. Tuttavia, se sei ancora preoccupato per il comportamento LIFO dello stack, pensalo come uno stack di frame, piuttosto che uno stack di variabili. Quello che intendo suggerire è che, sebbene una funzione possa accedere a variabili che non sono in cima allo stack, sta ancora operando solo sull'elemento in cima allo stack: un singolo stack frame.

Ovviamente ci sono delle eccezioni. Le variabili locali dell'intera catena di chiamate sono ancora allocate e disponibili. Ma non sarà possibile accedervi direttamente. Invece, vengono passati per riferimento (o per puntatore, che in realtà è diverso solo semanticamente). In questo caso è possibile accedere a una variabile locale di uno stack frame molto più in basso. Ma anche in questo caso, la funzione attualmente in esecuzione opera ancora solo sui propri dati locali.Sta accedendo a un riferimento memorizzato nel proprio stack frame, che può essere un riferimento a qualcosa nell'heap, nella memoria statica o più in basso nello stack.

Questa è la parte dell'astrazione dello stack che rende le funzioni richiamabili in qualsiasi ordine e consente la ricorsione. Lo stack frame superiore è l'unico oggetto a cui si accede direttamente dal codice. A qualsiasi altra cosa si accede indirettamente (tramite un puntatore che risiede nello stack frame superiore).

Potrebbe essere istruttivo guardare l'assemblaggio del tuo programmino, specialmente se compili senza ottimizzazione. Penso che vedrai che tutto l'accesso alla memoria nella tua funzione avviene attraverso un offset dal puntatore dello stack frame, che è il modo in cui il codice per la funzione verrà scritto dal compilatore. Nel caso di un passaggio per riferimento, si vedranno istruzioni di accesso indiretto alla memoria tramite un puntatore memorizzato a un certo offset dal puntatore dello stack frame.


4

Lo stack di chiamate non è effettivamente una struttura di dati dello stack. Dietro le quinte, i computer che usiamo sono implementazioni dell'architettura della macchina ad accesso casuale. Quindi, è possibile accedere direttamente a aeb.

Dietro le quinte, la macchina:

  • ottenere "a" equivale a leggere il valore del quarto elemento sotto lo stack top.
  • ottenere "b" equivale a leggere il valore del terzo elemento sotto lo stack top.

http://en.wikipedia.org/wiki/Random-access_machine


1

Ecco un diagramma che ho creato per lo stack di chiamate di C. È più preciso e contemporaneo rispetto alle versioni di immagini di Google

inserisci qui la descrizione dell'immagine

E corrispondente alla struttura esatta del diagramma sopra, ecco un debug di notepad.exe x64 su Windows 7.

inserisci qui la descrizione dell'immagine

Gli indirizzi bassi e gli indirizzi alti vengono scambiati in modo che lo stack salga verso l'alto in questo diagramma. Il rosso indica la cornice esattamente come nel primo diagramma (che utilizzava rosso e nero, ma ora il nero è stato riproposto); il nero è lo spazio domestico; il blu è l'indirizzo di ritorno, che è un offset nella funzione chiamante dell'istruzione dopo la chiamata; l'arancione è l'allineamento e il rosa è il punto in cui il puntatore dell'istruzione punta subito dopo la chiamata e prima della prima istruzione. Il valore homespace + return è il frame più piccolo consentito su Windows e poiché l'allineamento rsp a 16 byte all'inizio della funzione chiamata deve essere mantenuto, questo include sempre anche un allineamento a 8 byte.BaseThreadInitThunk e così via.

I riquadri funzione rossi delineano ciò che la funzione chiamata "possiede" logicamente + legge / modifica (può modificare un parametro passato nello stack che era troppo grande per essere passato in un registro su -Ofast). Le linee verdi delimitano lo spazio che la funzione si alloca dall'inizio alla fine della funzione.


RDI e altri argomenti di registro vengono riversati nello stack solo se si compila in modalità di debug e non si garantisce che una compilazione scelga quell'ordine. Inoltre, perché gli argomenti dello stack non vengono visualizzati nella parte superiore del diagramma per la chiamata di funzione più vecchia? Non c'è una chiara demarcazione nel diagramma tra quale frame "possiede" quali dati. (Un chiamato possiede i suoi argomenti stack). Omettere gli argomenti dello stack dalla parte superiore del diagramma rende ancora più difficile vedere che "i parametri che non possono essere passati nei registri" sono sempre proprio sopra l'indirizzo di ritorno di ogni funzione.
Peter Cordes l'

L'output asm di @PeterCordes goldbolt mostra clang e gcc callees che spingono un parametro passato in un registro allo stack come comportamento predefinito, quindi ha un indirizzo. Su gcc l'utilizzo di registerdietro il parametro ottimizza questo aspetto, ma si potrebbe pensare che sarebbe comunque ottimizzato visto che l'indirizzo non viene mai preso all'interno della funzione. Sistemerò il telaio superiore; devo ammettere che avrei dovuto mettere i puntini di sospensione in una cornice vuota separata. 'un chiamato possiede i suoi argomenti dello stack', cosa compresi quelli che il chiamante spinge se non possono essere passati nei registri?
Lewis Kelsey,

Sì, se compili con l'ottimizzazione disabilitata, il chiamato lo riverserà da qualche parte. Ma a differenza della posizione degli argomenti dello stack (e probabilmente salvato-RBP), nulla è standardizzato su dove. Re: callee possiede il suo stack args: sì, le funzioni possono modificare i loro arg in arrivo. Gli argomenti del registro che si riversa da solo non sono argomenti dello stack. I compilatori a volte lo fanno, ma IIRC spesso spreca spazio nello stack utilizzando lo spazio sotto l'indirizzo di ritorno anche se non rileggono mai l'arg. Se un chiamante desidera effettuare un'altra chiamata con gli stessi argomenti, per essere sicuro deve memorizzare un'altra copia prima di ripetere ilcall
Peter Cordes

@ PeterCordes Bene, ho reso gli argomenti parte dello stack del chiamante perché stavo delimitando gli stack frame in base a dove punta rbp. Alcuni diagrammi mostrano questo come parte dello stack chiamato (come fa il primo diagramma su questa domanda) e alcuni lo mostrano come parte dello stack del chiamante, ma forse ha senso renderli parte dello stack del chiamato visto come l'ambito del parametro non è accessibile al chiamante nel codice di livello superiore. Sì, sembra registere le constottimizzazioni fanno la differenza solo su -O0.
Lewis Kelsey,

@PeterCordes l'ho cambiato. Potrei cambiarlo di nuovo però
Lewis Kelsey il
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.