Salta al contenuto
Note per Studenti Esercizio - Chiamata a funzione e layout della memoria (secondo appello 2021-22, teoria)

Esercizio - Chiamata a funzione e layout della memoria (secondo appello 2021-22, teoria)

Esame
In questa pagina 3

Domanda aperta 1 del secondo appello 2021-22 (11 febbraio 2022) del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →, C++ - funzioni, reference e passaggio dei parametriuna funzione ha parametri formali (0 o più, input) e un valore di ritorno (0 o 1, output); in C++ gli argomenti si passano per valore, cioè il parametro è una variabile locale inizializzata con una copia dell'argomento, e modificarla non cambia l'originale; per oggetti grandi (vector, immagini) la copia costa, e si passa per reference (T&), un alias dell'oggetto del chiamante che permette lettura e scrittura, oppure per const reference (const T&), alias in sola lettura verificata dal compilatore, che accetta anche literal (oggetto temporaneo); regola: valore per oggetti piccoli, const T& per grandi da non modificare, T& solo se la funzione deve davvero modificare o produrre più di un output; il passaggio converte l'argomento nel tipo del parametro come in un'inizializzazione e un cast esplicito (static_cast) rende visibile l'intenzione.C++ - funzioni, reference e passaggio dei parametri →; per il caso con puntatori vedi Esercizio - Passaggio per copia e per riferimento con schema dello stack (secondo appello 2022-23, teoria).

Testo. Descrivere approfonditamente il meccanismo di chiamata a funzione in C++, esplicitando le strutture coinvolte e la relativa gestione della memoria. Disegnare il layout della memoria a seguito della chiamata alla funzione f() riportata di seguito:

float g(int b);

int f(int a)
{
    double in_a = a;
    int out_a = g(in_a);
    return out_a;
}
float g(int b)
{
    int in_b = b;
    float out_bb;
    return out_bb;
}

Il meccanismo di chiamata

  1. Record di attivazione (RA). Una chiamata a funzione crea una struttura dati, il function activation record, che contiene: una copia dei parametri (passaggio per valore), le variabili locali e le informazioni per tornare al chiamante (indirizzo di ritorno) e consegnare il risultato.
  2. Stack. I RA sono impilati in una zona di memoria detta stack, struttura LIFO. La chiamata inserisce un RA in cima; l'uscita lo toglie. Il RA ha dimensione che dipende dal numero e dal tipo delle variabili locali, ma l'inserimento richiede tempo costante.
  3. Ogni chiamata ha il suo RA, anche per la stessa funzione (ricorsione): non ci sono mai variabili condivise fra due chiamate, a meno che siano static o globali (che vivono nel data/BSS, non nello stack).
  4. Passi di una chiamata y = g(x): si valuta x e lo si converte nel tipo del parametro; si crea il RA di g; il parametro b è inizializzato con la copia; si esegue il corpo; return copia il valore di ritorno al chiamante e toglie il RA di g.
  5. Altre regioni della memoria, non coinvolte nella chiamata ma presenti: text (il codice), data e BSS (globali e static), heap (new/delete).

Cosa succede in f() e g()

Conversioni lungo la strada (da notare per l'esame):

istruzione conversione
double in_a = a; int →\to double, sicura
g(in_a) argomento double →\to parametro int b: troncamento (non sicura, accettata senza errore)
int out_a = g(...) valore di ritorno float →\to int: troncamento
float out_bb; e return out_bb; variabile non inizializzata letta e restituita: valore indeterminato (comportamento indefinito); il testo la lascia così per concentrarsi sulla memoria

Layout della memoria durante l'esecuzione di g()

Supponendo la chiamata f(7) fatta da main, nel momento in cui g sta eseguendo return lo stack contiene (dall'alto in basso, a indirizzi decrescenti):

indirizzi alti
+--------------------------------+
| RA di main                     |
|   ... (variabile r, ecc.)      |
+--------------------------------+
| RA di f                        |
|   a        int      4 B   7    |  <- parametro: copia dell'argomento
|   in_a     double   8 B   7.0  |  <- variabile locale
|   out_a    int      4 B   (da assegnare)
|   info di ritorno a main       |
+--------------------------------+
| RA di g                        |   <- cima dello stack
|   b        int      4 B   7    |  <- copia di in_a troncata
|   in_b     int      4 B   7    |
|   out_bb   float    4 B   ?    |  <- non inizializzata nel testo
|   info di ritorno a f          |
+--------------------------------+
indirizzi bassi (lo stack cresce verso il basso)

text   : istruzioni di main, f, g
data/bss: nessuna variabile globale in questo codice
heap   : vuoto (nessuna new)

Quando g ritorna, il suo RA scompare e out_a riceve il valore restituito (convertito a int); poi f ritorna e scompare anche il suo RA. Nessuna variabile locale sopravvive: le variabili automatiche muoiono con il RA.

Un programma che mostra l'ordine reale dei RA (distanze in byte da una variabile di main, dove i numeri negativi vanno verso gli indirizzi bassi; con out_bb = 2.75 inizializzato):

ra_fg.cppcpp
#include <iostream>
using namespace std;

char* base;                                   // un punto di riferimento nel RA di main

long dist(const void* p) { return static_cast<long>(static_cast<const char*>(p) - base); }

float g(int b);

int f(int a)
{
    double in_a = a;                          // int -> double: conversione sicura
    int out_a = g(in_a);                      // double -> int in ingresso a g (troncamento), float -> int in uscita
    cout << "RA di f:  &a = " << dist(&a) << ", &in_a = " << dist(&in_a) << ", &out_a = " << dist(&out_a) << '\n';
    return out_a;
}

float g(int b)
{
    int in_b = b;
    float out_bb = 2.75f;                     // nel testo e' dichiarata e restituita senza valore: qui la inizializziamo
    cout << "RA di g:  &b = " << dist(&b) << ", &in_b = " << dist(&in_b) << ", &out_bb = " << dist(&out_bb) << '\n';
    return out_bb;
}

int main()
{
    char riferimento;
    base = &riferimento;
    int r = f(7);
    cout << "f(7) = " << r << '\n';           // out_bb = 2.75 -> int = 2
    return 0;
}
RA di g:  &b = -51, &in_b = -55, &out_bb = -59
RA di f:  &a = -19, &in_a = -27, &out_a = -31
f(7) = 2

L'RA di g ha indirizzi più bassi di quello di f (che sono più bassi di main): lo stack cresce verso il basso. In ciascun RA i dati sono a distanza di pochi byte. Con f(7): in_a = 7.0, g(7.0) riceve b = 7, out_bb = 2.75 torna come 2.75f e out_a diventa 2 (troncamento).

Versione ripasso

RA di main  ->  RA di f (a, in_a, out_a)  ->  RA di g (b, in_b, out_bb)  <- cima
  • Verifica con il programma: distanze da main: f a −19,−27,−31-19, -27, -31; g a −51,−55,−59-51, -55, -59: g più in basso di f. f(7) restituisce 2 (da 2.75f).
  • Errori tipici: mettere il RA nell'heap o nel free store; dire che il RA ha dimensione fissa; dimenticare che le conversioni double →\to int tagliano; disegnare le locali in ordine di indirizzo crescente senza dire in che direzione cresce lo stack.

Esercizi su questo argomento

Teoria collegata