Salta al contenuto
Note per Studenti C++ - chiamate a funzione e layout della memoria

C++ - chiamate a funzione e layout della memoria

In questa pagina 4

Che cosa succede in memoria quando si chiama una funzione, come è organizzata la memoria di un programma in esecuzione e perché esistono variabili con "durata" diversa. Serve per capire il passaggio dei parametri (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 →), i puntatori (C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →) e la memoria dinamica (C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →). Lo stesso meccanismo in assembly ARM è in Stack e chiamata di funzioni in ARMChiamata con BL e ritorno con BX LR; convenzione di chiamata AAPCS (argomenti in r0-r3, risultato in r0, registri da preservare r4-r11); stack discendente pieno con PUSH e POP; record di attivazione; funzioni foglia e non foglia; esempio ricorsivo del fattoriale con evoluzione dello stack.Stack e chiamata di funzioni in ARM →; la gestione della memoria del C in Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →; la memoria di un processo vista dal sistema operativo in Processi - stati, PCB, creazione con fork ed execUn processo è un programma in esecuzione (entità attiva) con codice (text), dati globali, heap, stack e lo stato dell'esecuzione; il kernel lo descrive con il PCB (Process Control Block: stato, program counter, registri, informazioni di scheduling, memoria, accounting, file aperti, in Linux la struct task_struct) e passa da un processo all'altro con il context switch (puro overhead); gli stati sono new, ready, running, waiting, terminated, con una ready queue e varie wait queue; in UNIX fork() crea un figlio copia del padre (restituisce 0 al figlio e il pid del figlio al padre), exec() sostituisce il programma del figlio, wait()/waitpid() raccoglie lo stato di uscita: senza di essa un figlio terminato è uno zombie, un figlio il cui padre è morto è un orfano adottato da init/systemd; i processi cooperanti comunicano con memoria condivisa (POSIX shm) o scambio di messaggi (diretto o indiretto, sincrono o asincrono, buffer a capacità zero, limitata, illimitata) e con pipe ordinarie (padre-figlio, unidirezionali) e named pipe (FIFO).Processi - stati, PCB, creazione con fork ed exec →.

Il meccanismo di chiamata

Ogni chiamata a funzione causa la creazione di una struttura dati chiamata record di attivazione (RA, function activation record), che contiene:

Parametri e variabili locali sono equivalenti in questo schema. Ogni chiamata a f o g ha il proprio RA, anche se la funzione è la stessa.

I RA sono impilati in una zona di memoria chiamata stack (pila, struttura LIFO: l'ultimo a entrare è il primo a uscire). Quando una funzione termina, il suo RA è rimosso. Il RA ha dimensione che dipende dal numero e dal tipo delle variabili locali, ma il tempo necessario per inserirlo nello stack è costante: si sposta un indirizzo.

cpp
int f(int f1, int f2)        // RA di f:  f1 | f2 | var_f1 | var_f2 | info. di ritorno
{
    int var_f1;
    int var_f2;
    // ...
}
int g(int g1)                // RA di g:  g1 | var_g1 | info. di ritorno
{
    double var_g1;
    // ...
}

Chiamate in sequenza e annidate

situazione stack
main chiama f; f esce; main chiama g main →\to main, f →\to main →\to main, g →\to main: mai due RA sopra main insieme
f chiama g, che richiama f (ricorsione indiretta) main, f, g, f(2), g(2): ogni chiamata ha il suo RA; si svuota in ordine inverso: esce g(2), poi f(2), poi g, poi f

Il programma seguente è una ricorsione (fattoriale): per ogni livello stampa dove vive la variabile locale rispetto a una variabile di main (la distanza in byte in questa piattaforma, WebAssembly).

stack_cresce.cppcpp
#include <iostream>
#include <string>
using namespace std;

// Ogni chiamata ha il proprio record di attivazione con i suoi parametri e le sue variabili locali.
long fattoriale(int n, char* base)
{
    int locale = n;                         // vive nel RA di questa chiamata
    // distanza (in byte) fra il RA di main e questo RA; il segno dice la direzione di crescita
    cout << string(2 * (5 - n), ' ') << "fattoriale(" << n << "): locale a distanza "
         << static_cast<long>(reinterpret_cast<char*>(&locale) - base) << '\n';
    if (n <= 1) return 1;
    long r = n * fattoriale(n - 1, base);   // una nuova chiamata = un nuovo RA in cima allo stack
    cout << string(2 * (5 - n), ' ') << "ritorno da fattoriale(" << n << ") = " << r << '\n';
    return r;
}

int main()
{
    char base;                              // un punto di riferimento nel RA di main
    fattoriale(5, &base);
    return 0;
}
fattoriale(5): locale a distanza -19
  fattoriale(4): locale a distanza -67
    fattoriale(3): locale a distanza -115
      fattoriale(2): locale a distanza -163
        fattoriale(1): locale a distanza -211
      ritorno da fattoriale(2) = 2
    ritorno da fattoriale(3) = 6
  ritorno da fattoriale(4) = 24
ritorno da fattoriale(5) = 120

Si legge così: le chiamate più profonde hanno indirizzi più bassi (qui lo stack cresce verso gli indirizzi bassi, come nella maggior parte delle architetture) e i RA sono tutti della stessa dimensione (48 byte) perché la funzione è la stessa: il costo di una chiamata non dipende dalla profondità. Ogni fattoriale(n) ha il suo locale e il suo n; i ritorni avvengono in ordine inverso (prima fattoriale(2), poi 3, 4, 5). Una ricorsione senza fine riempie lo stack: stack overflow.

Layout di memoria di un processo

Quando un programma viene mandato in esecuzione, il sistema operativo riserva lo spazio per il processo, organizzato in regioni:

indirizzi alti   +-------------------------+
                 | stack                   |  RA delle funzioni  (cresce verso il basso)
                 |          |              |
                 |          v              |
                 |       (libero)          |
                 |          ^              |
                 |          |              |
                 | heap                    |  new / delete       (cresce verso l'alto)
                 +-------------------------+
                 | uninitialized data (BSS)|  globali e static NON inizializzate (a 0)
                 +-------------------------+
                 | initialized data        |  globali e static inizializzate
                 +-------------------------+
indirizzi bassi  | text (code segment)     |  istruzioni eseguibili, spesso read-only
                 +-------------------------+
regione contenuto note
text (code segment) istruzioni eseguibili copiato in memoria dal file oggetto; spesso read-only
initialized data (data segment) variabili globali e statiche inizializzate lettura/scrittura; può avere una parte read-only (costanti, string literal)
uninitialized data (BSS, Block Started by Symbol, nome storico) variabili globali e statiche non inizializzate esplicitamente il sistema le azzera prima di partire
stack i RA delle funzioni: variabili locali e parametri cresce e cala con le chiamate
heap (free store) memoria allocata dinamicamente con new e liberata con delete formalmente cresce in verso opposto allo stack; la dimensione può cambiare con brk/sbrk o con blocchi non contigui (mmap)

Il file oggetto contiene già le sezioni text, data e BSS: la dimensione delle ultime due si legge col comando size file.o dopo g++ -c (esercizio svolto: Esercizio - Hello world, variabili e funzioni (laboratorio 1)). Il BSS non occupa spazio nel file perché è zero: basta ricordarne la dimensione.

Durata e classe di memorizzazione

Date le variabili

cpp
string program_name = "silly";
vector<string> v;

void f()
{
    string s;                                  // locale a f
    while (cin >> s && s != "quit") {
        string stripped;                       // locale al ciclo
        string not_letters;
        // ...
    }
}

oltre al tipo c'è un'altra differenza: la classe di memorizzazione (o durata), cioè per quanto tempo la variabile è in memoria. Non è lo stesso dello scope, che dice in quale parte del codice il nome è accessibile (C++ - scope, namespace e librerielo scope è la regione di testo in cui un nome è valido, dal punto di dichiarazione alla fine dello scope; i tipi di scope sono globale (di file), di classe, locale (blocco o lista argomenti di una funzione), di statement (for) e di namespace; funzioni dentro funzioni sono illegali, le classi locali da evitare; i namespace raggruppano nomi senza creare un tipo e si accedono col nome qualificato namespace::membro oppure con using (la using directive va evitata negli header); un header file è un insieme di dichiarazioni che chi lo include può usare (<> per i file di sistema, "" per quelli del progetto), dev'essere protetto dalle include guard (#ifndef/#define/#endif) per non includere due volte le stesse definizioni; il linker unisce i file oggetto con le librerie, statiche (copiate in ogni eseguibile, self-contained) o dinamiche (una sola copia condivisa, .so su Linux, .dll su Windows, ricompilabili senza toccare gli eseguibili).C++ - scope, namespace e librerie →).

tipo di variabile quando nasce quando muore
globale (program_name, v) prima della prima istruzione di main alla fine del programma; è statica
locale statica (static int n) la prima volta che il controllo passa dalla dichiarazione; inizializzata una sola volta alla fine del programma; mantiene il valore anche fuori dallo scope
locale automatica (s, stripped) a ogni entrata nel blocco / a ogni chiamata della funzione all'uscita del blocco; più variabili sono distrutte in ordine inverso alla creazione

Una variabile globale è sempre statica; una locale è statica solo se dichiarata static. "Automatica" vuol dire che costruzione e distruzione sono fatte dal compilatore senza intervento: le locali vivono nello stack, quindi spariscono col RA. A ogni iterazione del ciclo si costruiscono stripped e not_letters, che esistono fino a fine iterazione.

durata.cppcpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;

string program_name = "silly";   // globale: statica, esiste per tutto il programma
vector<string> v;                // globale

int global_zero;                 // globale non inizializzata: parte da 0

void f()
{
    int local_auto = 0;              // automatica: nasce e muore a ogni chiamata
    static int local_static = 0;     // statica: inizializzata UNA volta, resta fra le chiamate
    cout << "Auto: " << local_auto++ << ", static: " << local_static++ << '\n';
}

int main()
{
    f();
    f();
    f();
    cout << "global_zero = " << global_zero << ", program_name = " << program_name << '\n';
    return 0;
}
Auto: 0, static: 0
Auto: 0, static: 1
Auto: 0, static: 2
global_zero = 0, program_name = silly

local_auto riparte da 0 a ogni chiamata (stampa sempre 0, l'incremento lavora sulla copia che sparisce), local_static ricorda il valore: 0, 1, 2. La globale global_zero non inizializzata vale 0 perché sta nel BSS. Una variabile static locale e una globale non inizializzata finiscono nel BSS, quelle inizializzate nel data segment: in ogni caso non nello stack, non nell'heap.

I compilatori fanno sì che il codice si comporti come se queste azioni fossero eseguite; internamente possono ottimizzare, ma il comportamento osservabile non cambia.

Inizializzazione delle globali

Le globali hanno la memoria scritta a 0 e poi sono inizializzate prima di main. Se in file diversi (translation unit) una dipende dall'altra, non si sa in che ordine avvengano le inizializzazioni:

cpp
// file f1.cpp
int x1 = 1;
int y1 = x1 + 2;

// file f2.cpp
extern int y1;
int y2 = y1 + 2;      // y2 dipende da y1 di un altro file: ordine non definito

Se f2.cpp viene inizializzato per primo, y1 vale ancora 0 e y2 diventa 2 invece di 5. È codice da evitare.

Domande d'esame

1. Descrivere il meccanismo di chiamata a funzione e disegnare lo stack durante f() che chiama g(). Traccia. RA con copia dei parametri, variabili locali e informazioni di ritorno; impilati nello stack (LIFO), tempo di inserimento costante, dimensione dipendente dalle locali. Durante g: sullo stack main, RA di f (a, in_a, out_a), RA di g (b, in_b, out_bb). Soluzione completa in Esercizio - Chiamata a funzione e layout della memoria (secondo appello 2021-22, teoria).

2. Che cosa riserva il sistema operativo per un programma in esecuzione? Traccia. Codice eseguibile (text), variabili globali e statiche (data e BSS), stack, heap/free store. Il free store si usa solo con new.

3. Differenza tra scope e durata della memorizzazione, con una variabile che esce dallo scope restando in memoria. Traccia. Scope = dove il nome è visibile; durata = quanto vive l'oggetto. static int n dentro una funzione: fuori scope all'uscita, ma ricorda il valore alla chiamata dopo.

Versione ripasso

regione contenuto
text istruzioni, spesso read-only
initialized data globali e static inizializzate (lettura/scrittura)
BSS (uninitialized data) globali e static non inizializzate, azzerate dal sistema
stack RA: parametri e locali
heap memoria dinamica (new/delete), cresce in verso opposto allo stack (brk/sbrk, mmap)
  • size file.o (dopo g++ -c) mostra text, data e bss: aggiungere una globale inizializzata aumenta data, una non inizializzata aumenta bss, una locale automatica non cambia nulla (sta nello stack a run-time).
  • Durata (classe di memorizzazione) ≠\neq scope:
variabile nasce muore
globale (sempre statica) prima della prima istruzione di main fine programma
locale static prima volta che passa dalla dichiarazione, inizializzata una sola volta fine programma; tiene il valore
locale automatica a ogni entrata nel blocco / chiamata uscita dal blocco; distrutte in ordine inverso alla creazione
cpp
void f() { int a = 0; static int s = 0;
           cout << "Auto: " << a++ << ", static: " << s++ << '\n'; }
// tre chiamate stampano: Auto: 0, static: 0 / Auto: 0, static: 1 / Auto: 0, static: 2
  • Globali: memoria a 0 poi inizializzazione prima di main; fra file diversi l'ordine non è definito (int y2 = y1 + 2; con y1 in un altro file: codice da evitare).
  • Il compilatore può ottimizzare ma il comportamento osservabile resta quello descritto.
  • Traccia d'esame: meccanismo di chiamata (RA + stack + costo costante) e disegno dei RA di main, f, g; regioni riservate (text, data, BSS, stack, heap); scope = dove si vede il nome, durata = quanto vive l'oggetto.
  • Errori tipici: mettere il RA nell'heap o nel free store; dire che il RA ha dimensione fissa per ogni funzione indipendentemente dalle locali; dire che le locali static vivono nello stack; pensare che static cambi lo scope; credere che l'ordine di inizializzazione fra file sia fissato.
  • Esercizi svolti su questa nota: schema dei RA di due funzioni con conversioni: Esercizio - Chiamata a funzione e layout della memoria (secondo appello 2021-22, teoria); stack con un puntatore passato per copia: Esercizio - Passaggio per copia e per riferimento con schema dello stack (secondo appello 2022-23, teoria); misure con size e static locale: Esercizio - Hello world, variabili e funzioni (laboratorio 1).
  • Variabili, riepilogo: globale = data/BSS, durata statica, scope di file; static locale = data/BSS, durata statica, scope locale; automatica = stack, nasce e muore col blocco; new = heap, dura finché non c'è delete.

Esercizi su questo argomento

Teoria collegata