In x86, dove si trovano gli indirizzi di memoria dei valori nello stack?

4

Supponiamo di avere un programma C molto semplice che fa esattamente questo:

int i = 6;
int j = 4;
int k = 5;
int a = i + j + k;

Poiché i , j e k sono nello stack, saranno posizionati rispetto al puntatore dello stack. Mi è stato detto che il compilatore determina queste posizioni relative; la mia ipotesi è che il compilatore traduca int a = i + j + k in "Aggiungi i valori situati a (stack pointer - 3x), (stack pointer - 2x), e (stack pointer - x) e spinga il risultato nello stack." Sono corretto?

    
posta moonman239 05.12.2015 - 00:56
fonte

3 risposte

8

Dipende dal compilatore C, in quanto la specifica lo lascia al compilatore. (Esistono tuttavia dei requisiti specifici, ad esempio se si prende l'indirizzo di un parametro, che può ulteriormente limitare l'implementatore del compilatore.)

Una tipica funzione C genera un codice "prologo", il codice "corpo" e un codice "epilogo". Il prologo assegnerà lo spazio variabile locale. In generale, un compilatore NON passerà attraverso il processo che hai delineato. Quindi diciamo che stiamo parlando del codice x86 a 16 bit e di questa funzione in C:

int f( void ) {
    int i = 6;
    int j = 4;
    int k = 5;
    int a = i + j + k;
    return a;
}

Si noti che nel contesto che ho citato, le variabili avranno una dimensione di 2 byte. Il compilatore conterrà il numero di byte richiesti per tutte le variabili locali. In questo caso, ne avete quattro e poiché richiedono 2 byte ciascuno, il compilatore "calcola" che 8 byte sono necessari per tutto questo. (Si noti inoltre che anche se si includono definizioni di variabili all'interno di blocchi di codice addizionali all'interno della funzione C di cui sopra, il compilatore C conterà TUTTI A LORO UNA VOLTA. Non conta solo quelli definiti a livello esterno.) Quindi il compilatore potrebbe genera il seguente prologo in assembly:

push bp
mov bp, sp
sub sp, 8

In genere, il registro BP viene utilizzato come "puntatore del frame" per il contesto corrente della funzione. BP è anche noto come puntatore al "riquadro di attivazione". Quindi il compilatore C ha bisogno di due istruzioni per salvare il vecchio puntatore del frame di attivazione sullo stack e quindi inizializzarlo per puntare a quello corrente per questa chiamata della funzione.

La terza istruzione è semplice e alloca TUTTO lo spazio necessario per gli 8 byte necessari per TUTTE le variabili locali. Sposta semplicemente il puntatore dello stack sugli 8 byte necessari. BP continuerà a puntare alla base di questo riquadro di attivazione. Ma ora l'SP si trova dall'altra parte, quindi ulteriori push e chiamate non scriveranno sulle variabili locali.

Il compilatore C assegnerà anche internamente un valore di offset per ciascuna delle variabili locali. Forse qualcosa come IVAR = 0, JVAR = 2, KVAR = 4 e AVAR = 6. Ora, il compilatore C deve creare un codice BODY. Poiché imposti questi valori delle variabili locali, qualcosa del genere (completamente non ottimizzato):

mov IVAR[BP], 6
mov JVAR[BP], 4
mov KVAR[BP], 5
mov ax, IVAR[BP]
add ax, JVAR[BP]
add ax, KVAR[BP]

Si noti che in questo contesto a 16 bit, è anche comune che il valore di ritorno di una funzione sia inserito in AX, se è adatto a questo. In questo caso, lo fa. Quindi la risposta è già nel posto giusto in questo momento. Quindi ora è necessario un epilogo:

mov sp, bp
pop bp
ret

E questo è tutto.

Ora, un ottimizzatore farà un GRANDE AFFARE per migliorare il codice sopra in questo caso. Probabilmente rimuoverà TUTTE le variabili locali, in quanto non sono affatto necessarie. Può pre-calcolare l'intero risultato come 15 e basta fare quanto segue:

mov ax, 15
ret

Non è necessario gestire il frame di attivazione. Basta restituire il valore. (Ma poi non avresti alcuna idea su come le variabili locali potrebbero essere gestite.)

Spero che questo aiuti un po '.

    
risposta data 05.12.2015 - 01:20
fonte
2

Sì, hai ragione. Il compilatore non determina mai effettivamente le posizioni esatte della memoria, ma determina solo gli offset relativi di queste posizioni dal puntatore dello stack. Il valore che il puntatore dello stack avrà durante l'esecuzione è sconosciuto al compilatore al momento della compilazione.

(In realtà, su architetture Intel a 16 e 32 bit queste posizioni sono relative al cosiddetto "puntatore di base", (registro BP , in altre architetture noto come "il puntatore del frame",) il cui valore è determinato dal puntatore dello stack, ma è irrilevante. L'hanno risolto a 64 bit e ora è necessario solo il puntatore dello stack.)

    
risposta data 05.12.2015 - 01:34
fonte
1

Since i,j, and k are on the stack, they will be located relative to the stack pointer.

Prima di tutto, questa affermazione è chiaramente sbagliata in un caso comune con i compilatori moderni. Sembra che tu ti abitui a guardare solo a compilatori molto vecchi, che hanno davvero funzionato nel modo in cui assegnano le posizioni sullo stack, mappano le variabili e utilizzano i registri solo quando richiesto esplicitamente (con register parola chiave) o per valori temporanei. (Realizzi qualche compito con i compilatori MS-DOS e 16-bit?) Questo è un modo molto antico.

I compilatori moderni (GCC, Clang / LLVM, ecc.) utilizzano SSA e le tecniche di accompagnamento. Con SSA, non ci sono "variabili" internamente; ci sono valori quasi immutabili (eccetto i punti di fusione del percorso di esecuzione), ognuno di essi può ottenere un proprio spazio, in un registro o in pila, a seconda della frequenza con cui viene utilizzato e di quanto verrà utilizzato nuovamente. Se hai chiamato una variabile "int i", può essere in EAX in una parte della funzione, in EDI in un'altra, allo stack quando viene scambiata dal pool di registri ...

A volte, una variabile viene completamente ottimizzata, se il compilatore presuppone che sia più facile utilizzarne un'altra o anche una costante. A volte, è presente, ma modificato (ad esempio, il ciclo da i da 1 a N, se accede a un array come x [i-1], viene modificato in ciclo da 0 a N-1 e accesso come x [i]) . E così via e così via, la lista di ottimizzazione è quasi infinita.

Puoi essere certo che una variabile ha il suo posto nello stack solo quando ottieni il suo indirizzo. Tuttavia, è un indirizzo variabile solo quando questo indirizzo è valido. Se chiami un'altra funzione come g(&i) , i viene posta sullo stack prima di chiamare g , ma può essere immediatamente spostata nel registro o utilizzata come argomento di espressione quando g () finisce, perché questo indirizzo non viene mai più utilizzato .

Esempio. La funzione C è:

int f(int i, int j, int k) {
  return i + j + k;
}

È tradotto per x86_64 / Unix per:

    addl    %esi, %edi
    leal    (%rdi,%rdx), %eax
    ret

Nessuna pila è usata affatto. La convenzione di chiamata specifica i registri per l'argomento e il posizionamento dei risultati, ed è così che viene implementato. (Si noti che la stessa aggiunta è add e quindi lea - questo è il flusso di istruzioni che si estende tra diverse unità di esecuzione.)

Aggiungi un'azione esterna per i :

void g(int*);
int f(int i, int j, int k) {
  g(&i);
  return i + j + k;
}

Output di montaggio:

    pushq   %rbp
    movl    %esi, %ebp
    pushq   %rbx
    movl    %edx, %ebx
    subq    $24, %rsp
    movl    %edi, 12(%rsp) ; <-- storing i onto stack
    leaq    12(%rsp), %rdi ; <-- getting &i
    call    g
    addl    12(%rsp), %ebp ; <-- using possibly modified i
    addq    $24, %rsp
    leal    0(%rbp,%rbx), %eax
    popq    %rbx
    popq    %rbp
    ret

Qui, i arriva in RDI, quindi viene collocato sullo stack prima di g(&i) , e poi usato come sommatore direttamente dallo stack, senza tornare a un registro. (Inoltre, il puntatore del frame è regolato perché questa non è più una funzione foglia.)

Considera di nuovo che stai utilizzando la macchina del tempo e / o un ambiente molto integrato. SSA è una tecnica costosa. La mappatura della variabile set to stack (o, prima, memoria) è il modo in cui i compilatori sono stati elaborati fin dall'inizio, ovvero le prime versioni di Fortran. Per quel caso, hai ragione. E la domanda in questo caso è come tenere traccia delle possibili modifiche agli indirizzi.

Su S / 360, un compilatore utilizza un blocco di memoria dopo il corpo della funzione per le sue variabili e utilizza la seguente sequenza di istruzioni per ottenere un indirizzo di base per questo blocco:

    BALR 15,0
    USING *,15

il registro 15 è convenzionale per questo obiettivo e dopo questo riceve l'indirizzo subito dopo il comando. Quindi, l'accesso alla memoria basato su offset viene utilizzato per le variabili, ad esempio:

    LR 3,196(15)

qui il valore a 4 byte da questa base più 196 viene caricato nel registro 3. Per l'intera esecuzione della funzione, i sarà a 196 (15).

Si noti che questo approccio non è basato sullo stack; una funzione non è rientranti.

Con PDP-11, ma ancora non Fortran basato su stack, questo può essere fatto con accesso basato su puntatore di istruzioni, come

    MOV 196(PC), R3

perché non è necessario allocare un registro per tale accesso alla memoria. Ma gli offset varieranno (un'istruzione immediatamente successiva dovrà usare 192 invece di 196).

Con stack, questo cambia in accesso basato su SP e nessuna area variabile vicino al corpo della funzione:

    MOV 16(SP), R3

ma vedi sotto per la variazione dell'offset quando SP cambia.

Con x86 prima di i386, l'accesso allo stack basato su BP era obbligatorio; poiché i386, è ancora utilizzabile, ma non unico. Dopo l'impostazione di BP al prologo di funzione, gli offset rimangono fissi, quindi è possibile utilizzare la stessa espressione, come [EBP-20] (Intel), -20 (% ebp) (AT & T), per una variabile durante la durata della chiamata di funzione. Con accesso basato su ESP / RSP (dal i386), gli offset vengono modificati con qualsiasi push / pop. Se i era [ESP + 20], una singola spinta di 4 byte "la converte" in [ESP + 24]; "converte" significa che l'indirizzo di memoria è lo stesso, ma la formula per esso è cambiata con la regolazione ESP. Ad esempio, se viene chiamato g(i,&i,&i) , un compilatore può produrre la seguente sequenza per formare l'elenco degli argomenti (le convenzioni di chiamata x86_32 sono principalmente basate sullo stack):

## assume i is now at 24(%esp)
    leal     24(%esp), %eax ; &i to eax
    pushl    %eax
    leal     28(%esp), %eax ; &i to eax
    pushl    %eax
    movl     32(%esp), %eax ; i to eax
    pushl    %eax

(È ovviamente non ottimale, ma che esprime.)

    
risposta data 05.12.2015 - 09:16
fonte

Leggi altre domande sui tag