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:
- una copia dei parametri (il passaggio per valore: 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 →);
- le variabili locali;
- le informazioni per tornare al chiamante e consegnare il risultato (return).
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.
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 main, f main main, g 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).
#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) = 120Si 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
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.
#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 = sillylocal_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:
// 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 definitoSe 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
- Chiamata a funzione crea un RA (record di attivazione) con copia dei parametri, variabili locali e informazioni di ritorno. I RA sono impilati nello stack (LIFO): l'ultimo chiamato esce per primo. Dimensione variabili locali; inserimento in tempo costante. Parametri e locali equivalenti; ogni chiamata ha il suo RA. Ricorsione senza fine = stack overflow. Rimandi: 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 →, 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 →.
- Sequenza:
mainchiamaf, esce, poig. Annidate:fchiamagche chiamaf: stackmain, f, g, f(2), g(2); si svuota in ordine inverso. - Programma
fattoriale(n):localedi ogni livello a indirizzi sempre più bassi (distanze 19, 67, 115, 163, 211 byte damain: RA di 48 byte, stack che cresce verso il basso), stampa dei ritorni in ordine 2, 3, 4, 5. - Layout di memoria:
| 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(dopog++ -c) mostra text, data e bss: aggiungere una globale inizializzata aumentadata, una non inizializzata aumentabss, una locale automatica non cambia nulla (sta nello stack a run-time).- Durata (classe di memorizzazione) 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 |
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;cony1in 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
staticcambi 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
sizeestaticlocale: Esercizio - Hello world, variabili e funzioni (laboratorio 1). - Variabili, riepilogo: globale = data/BSS, durata statica, scope di file;
staticlocale = data/BSS, durata statica, scope locale; automatica = stack, nasce e muore col blocco;new= heap, dura finché non c'èdelete.
Esercizi su questo argomento
- Esercizio - Allocazione dinamica e layout della memoria (primo appello 2022-23, teoria)
- Esercizio - Chiamata a funzione e layout della memoria (secondo appello 2021-22, teoria)
- Esercizio - Hello world, variabili e funzioni (laboratorio 1)
- Esercizio - Passaggio per copia e per riferimento con schema dello stack (secondo appello 2022-23, teoria)
- Esercizio - Quiz e domande su funzioni virtuali, passaggio degli argomenti, dangling pointer e doppia deallocazione (quarto appello 2023-24, teoria)
- Esercizio - Quiz e domande su polimorfismo, allocazione dinamica e garanzie sulle eccezioni (secondo appello 2023-24, teoria)
- Esercizio - Quiz e domande su spostamento, passaggio dei parametri e contenitori STL (primo appello 2023-24, teoria)
- Esercizio - Quiz su contenitori STL, override, sizeof, void e virtual table (secondo appello 2021-22, quiz)
- Esercizio - Quiz su conversioni, constexpr, free store e downcasting (terzo appello 2021-22, quiz)
- Esercizio - Quiz su include guard, librerie dinamiche, explicit e function object (primo appello 2021-22, quiz)
- Esercizio - Quiz su inizializzazione, free store, polimorfismo e passaggio di argomenti (quarto appello 2022-23, quiz)
- Esercizio - Quiz su record di attivazione, new[], dangling pointer e struct (terzo appello 2022-23, quiz)
- Esercizio - Quiz su scope, riferimenti, conversioni e puntatori (secondo appello 2022-23, quiz)
- Esercizio - Scope e durata della memorizzazione (terzo appello 2022-23, teoria)
Teoria collegata
- C++ - allocazione dinamica della memoria con new e delete
- C++ - ereditarietà, funzioni virtuali e layout degli oggetti
- C++ - funzioni, reference e passaggio dei parametri
- C++ - introduzione, compilazione e linking
- C++ - puntatori, array e cast
- C++ - scope, namespace e librerie
- C++ - tipi, variabili, espressioni e dichiarazioni