9. Modello di Esecuzione di un Programma C

Transcript

9. Modello di Esecuzione di un Programma C
66
9. Modello di Esecuzione di un
Programma C
In questa sezione presenteremo un semplice modello astratto per comprendere la
semantica del linguaggio C, e cioè come viene eseguito un programma. (Non saranno
affrontati gli aspetti del linguaggio più avanzati, quali la ricorsione e l’allocazione
dinamica, anche se questi possono essere spiegati usando lo stesso modello). La
definizione di tale modello è informale e ha lo scopo di fornire uno strumento di
ragionamento per comprendere gli aspetti fondamentali della gestione della memoria
nel linguaggio. Le conclusioni che si possono trarre da esso sono corrette rispetto al
comportamento del linguaggio, anche se l'implementazione reale si può discostare
anche in modo significativo dall'impostazione del modello astratto.
La struttura del modello è simile a quella reale solo a livello molto generale. Per
esempio, l'attivazione delle funzioni determina veramente l'allocazione di un record di
attivazione, ma la rappresentazione dei binding è molto differente da quella del
modello. I nomi nell'implementazione spariscono e vengono sostituiti da indirizzi o,
meglio, dagli indirizzi relativi alla base dei record di attivazione. Inoltre, questo
modello non è adatto, senza modifiche, a rappresentare caratteristiche più specifiche e
“di basso livello” del linguaggio come, per esempio, le funzioni con un numero
variabile di argomenti. Tali aspetti possono essere giustificato solo specializzando
meglio il modello per renderlo più vicino all'implementazione reale.
Ricordiamo che in C tutte le funzioni possono essere dichiarate solo al livello del
main, se presente, o comunque allo stesso livello che potrebbe avere il main se ci
fosse. Questa è una limitazione rispetto ad altri linguaggi, come il PASCAL, che
consentono dichiarazioni di procedure una dentro l'altra a qualsiasi livello. Tuttavia, il
C, anche con queste restrizioni, presenta gli aspetti più importanti relativi alle
procedure e al passaggio dei parametri. Il suo studio è quindi sufficiente per avere una
buona conoscenza di questi aspetti anche negli altri linguagi imperativi.
9.1 Definizioni di Base
La memoria in C si può pensare sostanzialmente divisa in due parti:
• lo Stack o Pila
• lo Heap
Lo Heap, che viene utilizzato per contenere le strutture dinamiche, non verrà discusso
in questa sezione. Nello Stack la memoria viene gestita dinamicamente ed è
organizzata in segmenti, detti record di attivazione, che vengono allocati in
corrispondenza di ogni chiamata di funzione e disallocati in corrispondenza del
ritorno della funzione.
Chiamiamo valori gli elementi degli insiemi che costituiscono i tipi di base del C,
come gli interi, i reali, e i caratteri. Consideriamo per ora solo questi tipi elementari.
Dati strutturati e indirizzi verranno considerati in seguito. L'associazione di un valore
ad una variabile viene rappresentata mediante il binding, formato da un identificatore
67
(cioè un nome definito dall'utente) I e da un valore v. Indicheremo un binding in
questo modo:
I:
v
Nella rappreentazione grafica, la cornice rappresenta la porzione di memoria (che
può essere costituita da uno o più byte, a seconda del tipo di I ) in cui verra'
memorizzato il valore v di I. Per semplicità (e per omogeneità con la letteratura)
indicheremo spesso i bindings semplicemente con la notazione I : v , omettendo di
rappresentare la cornice. I binding sono aggregati in unità di memoria dette record di
attivazione, riferiti come r.a. in seguito, ognuno dei quali è associato all'attivazione di
una funzione.
Diciamo che si verifica una attivazione di una funzione F tutte le volte che viene
iniziata l'esecuzione del suo codice per effetto di una chiamata. L'attivazione termina
quando viene terminata l'esecuzione del codice con un return, esplicito o implicito.
Diciamo che l'attivazione è aperta fino a quando non termina. Un'attivazione di F
puo' venir sospesa (restando comunque aperta) quando l'esecuzione del codice di F
viene interrotta per eseguire un'altra chiamata di funzione G, che occorre nel corpo di
F (G puo' essere F stessa se è ricorsiva) e viene ripresa al ritorno dell'esecuzione di
F. In C (come in tutti i linguaggi che permettono la ricorsione) è possibile che una
stessa funzione abbia più attivazioni aperte contemporaneamente.
I r.a. sono allocati e deallocati dinamicamente sullo Stack. In un r.a. sono contenuti
tutti i binding relativi ai parametri e alle variabili locali della funzione associata. In un
r.a. è inoltre contenuto un indirizzo di ritorno rappresentato da un’ ‘etichetta’ (che noi
indicheremo con una lettera greca) che individuerà il punto del programma alla fine
del quale dovrà essere passato il controllo dopo l'esecuzione della funzione. (Nelle
reali implementazioni il r.a.contiene anche altre informazioni che qui non discutiamo
perchè non rilevanti al nostro modello).
Useremo per i r.a. la seguente rappresentazione grafica:
I1:
I2:
v1
v2
………
In:
vn
I.R. : α
I:
v
k
k
dove ogni
è un binding contenuto nel r.a. I primi sono relativi
alle variabili locali della funzione e i secondi ai parametri. Inoltre α rappresenta
l'indirizzo di ritorno.
68
Nella zona iniziale dello Stack sono contenuti i bindings relativi alle variabili globali.
Possiamo assimilare questi ad un r.a. ‘globale’ che corrisponde al caricamento del
programma e non viene mai tolto dallo Stack. Inoltre, l’indirizzo di ritorno della
funzione main non verrà specificato.
Consideriamo ora un programma C che suporremo costituito da un insieme di
dichiarazioni globali e un insieme di funzioni. La (eventuale) divisione in moduli
riguarda più che altro i meccanismi di visibilità dei nomi e di assemblaggio del
codice. Noi supponiamo che il programma sia sintatticamente corretto e non ci
interessiamo ai problemi di compilazione. Il nostro scopo è quello di essere in grado
di simulare ‘a mano’ l'esecuzione del codice di un programma C. In tale simulazione
assumiamo che ogni chiamata funzionale nel programma, sia della forma
F(esp1, ….., espn),
dove F rappresenta il nome di una funzione ed esp1,…..,espn le espressioni che sono i
parametri attuali della chiamata in oggetto. Denotiamo la chiamata con una etichetta
rappresentata da una lettera greca:
F(esp1, ..., espn)α
Assumiamo che ogni chiamata funzionale sia etichettata con una diversa etichetta.
Ciò servirà per ricordarci da quale punto del programma è partita una chiamata alla
funzione F.
Supponiamo ora di essere in fase di esecuzione di un programma C . Indichiamo con
m lo stato dello Stack, che chiameremo anche genericamente ‘memoria’. Sia esp una
generica espressione in fase di valutazione. La valutazione di esp, nella memoria m,
produrrà un valore del tipo di esp.La valutazione di esp viene fatta seguendo le regole
del linguaggio, che corrispondono sostanzialmente alle usuali regole dell'aritmetica
cui vengono aggiunti aspetti più specifici come conversioni ditipo, gestioni di
espressioni condizionali etc.. e che si suppongono note. Per quanto riguarda gli
identificatori I (definiti dall'utente) la loro valutazione consiste nel trovare il valore
corrispondente nella memoria m. Tale valore è quello associato a I nel binding attivo
per I in m, dove il binding attivo per I in m è così definito.
•
•
Se nel r.a. r che si trova in cima allo Stack m e che corrisponde all'attivazione
della funzione in esecuzione esiste un binding per I, questo è il binding attivo per I
in m. Questo si verifica se I è il nome di un parametro formale o una variabile
locale.
Altrimenti il binding attivo per I in m è quello che si trova nell'area dati globale.
I controlli effettuati dal compilatore assicurano che in fase di esecuzione di un
programma il binding attivo degli identificatori che devono essere valutati sia sempre
definito e determinano quale binding usare effettivamente. Questa definizione di
binding attivo giustifica le regole di visibilità degli identificatori nel linguaggio.
E' ancora utile vedere come viene valutata un'istruzione di assegnazione della forma:
I = esp ;
in una memoria m. In tal caso:
• Viene valutata l'espressione esp in m. Sia v il valore ottenuto.
69
•
Se I:
v
è il binding attivo per I in m, questo viene modificato
sostituendo il valore v con v’.
Quando viene eseguita una chiamata di funzione
F(esp1,…..,espn)α
dove F è una funzione ovviamente definita nel programma ed esp1,…..,espn
rappresentano i parametri attuali di questa specifica chiamata, vengono effettuate le
seguenti operazioni. Sia ancora m la memoria in cui tale chiamata viene eseguita.
•
•
Vengono valutate nella memoria m (quella in cui viene eseguita la chiamata) le
espressioni esp1,…..,espn, siano v1,…..,vn, i corrispondenti valori ottenuti.
Viene aggiunto sullo Stack un nuovo r.a. r in cui:
- l'indirizzo di ritorno associato è α
- vengono inseriti i binding fra i parametri formali Ik di F e i valori vk
(0≤ k ≤n). In un programma staticamente corretto (cioe' accettato dal
compilatore) i valori vk sono tanti quanti parametri formali di F e
corrispondono ad essi come tipo ad uno ad uno.
- vengono inoltre inseriti in r tutti i binding relativi alle dichiarazioni di variabili
definite localmente a F.
Viene iniziata la valutazione del corpo di F nella nuova memoria cosi' ottenuta.
Quando viene inserito un nuovo binding I:
in un r.a. il valore v è
v
ottenuto dall’espressione associata alla variabile I come valore iniziale (come per
esempio in dichiarazioni del tipo int X = 3;). Se per I non è specificato nessun
valore iniziale non è in genere prevedibile quale sarà il valore iniziale associato ad I
durante l'esecuzione. Nel codice finale una successione di bit sarà certamente
contenuta nel registro di memoria corrispondente ad I, spesso tutti 0. Non è però
corretto fare affidamento su un eventuale valore iniziale associato “per default” ad
una variabile. Nel nostro modello associamo come valore iniziale nei binding relativi
alle variabili in cui tale valore non è esplicitamente dichiarato un valore particolare ?
(unico per tutti i tipi) la cui funzione è appunto quella di segnalare che il valore della
variabile associata non è ancora stato definito e quindi non è determinato.
Considereremo non corretti quei programmi nella cui esecuzione si tenta di usare un
valore ?. In questi casi bisogna modificare il codice per fare sì che la definizione del
valore di una variabile preceda sempre il suo uso.
Quando viene terminata l'esecuzione del corpo di una funzione (supponiamo si tratti
ancora dell'attivazione di F si eseguono invece le seguanti operazioni.
- L'ultimo r.a. sullo Stack (quello relativo all'attivazione di F viene rimosso
dallo Stack.
- viene trasferito il controllo al punto del programma da cui era partita la
chiamata che aveva attivato la funzione. Tale punto e' individuato
dall'etichetta contenuta nel r.a. appena rimosso.
Le regole di apertura e chiusura dei r.a. assicurano che il r.a. che viene a trovarsi in
cima allo Stack è sempre relativo alla funzione di cui viene ripresa l'esecuzione.
Al ritorno da una chiamata funzionale viene sempre restituito un valore (irrilevante
per le funzioni di tipo void). Se la chiamata di una funzione occorreva all'interno di
70
un'espressione tale valore viene usato nella valutazione dell'espressione stessa.
Altrimenti il valore ritornato dalla funzione è ignorato.
In C ogni programma inizia sempre con la valutazione del main, il cui r.a. sarà
quindi sempre il primo a comparire sullo Stack e l'ultimo a venire rimosso. La
rimozione del r.a. del main corrisponde alla fine del programma.
Negli Esempi 37 e 38 del Capitolo 10 sarà rappresentato lo stato della memoria
durante l'attivazione delle funzioni in due semplici programmi. Nell'Esempio 37 viene
attivato solo il main che utilizza due variabili, una locale ed una globale. L'Esempio
38 invece rappresenta l'esecuzione di un programma che contiene una funzione con
due parametri semplici.
L'apertura di blocchi interni al corpo di una funzione, con dichiarazioni di variabili
locali, viene gestita come una chiamata di funzione, con la semplificazione che non è
necessario ricordare un indirizzo di ritorno e non si devono gestire i parametri.
L'uscita dal blocco viene gestita come un ritorno da funzione. Si noti che la normale
gestione dello Stack è sufficiente a garantire la corretta apertura e chiusura del r.a..
Bisogna però fare ora attenzione a ridefinire la nozione di binding attivo per tenere
conto della possibiltà di annidamento dei blocchi.
9.2. Puntatori
Rappresentiamo l'indirizzo di una variabile X, detto anche puntatore a X, con una
freccia la cui punta è diretta verso il binding per X. Quindi, una variabile di tipo
puntatore p è associata, nel suo binding, ad una freccia diretta verso la variabile il cui
indirizzo è in p. Per esempio se si hanno le seguenti dichiarazioni
int X = 2;
int *p = &X;
si produce nella memoria una situazione del tipo:
X:
2
p:
Se la variabile X a cui punta p è stata dichiarata come array o come struttura, la
freccia sarà rivolta alla prima locazione di X.
Per gestire espressioni e assegnazioni contenenti puntatori si possono seguire queste
semplici regole.
- Il valore di un'espressione della forma *p, dove p è una variabile di tipo
puntatore, si ottiene seguendo la freccia associata a p nel suo binding (poichè p è
un puntatore, il suo valore deve essere associato ad una freccia) e prendendo il
71
-
valore contenuto nel binding cui punta la freccia. Per esempio nel caso della
figura precedente, il valore dell'espressione *p è 21.
Nel valutare un'assegnazione del tipo
*p = exp
si calcola (come al solito) il valore di exp e lo si sostituisce nel binding cui punta
la freccia associata a p nel suo binding attivo.
Nel caso di più livelli di indirizzamento indiretto (come **p) ci si comporta
analogamente al caso precedente. Per esempio un'espressione del tipo
**p + 1
verra valutata aggiungendo 1 al contenuto del registro puntato dalla freccia contenuta
nel registro puntato dalla freccia associata a p nel suo binging attivo e così via.
Un indirizzo si può esprimere in C mediante l'operatore & posto davanti al nome di
una variabile. Così l'espressione &X sarà rappresentata come una freccia che punta al
registro associato ad X . La situazione della figura, per esempio, si può realizzare
eseguendo l'assegnazione
p = &X
Due puntatori si possono confrontare ed assegnare. Due puntatori sono uguali se, visti
come freccie, puntano allo stesso registro (si pensi ad essi come indirizzi).
Un'assegnazione q = p si esegue mettendo nel registro associato a q l'origine di
una freccia che punta allo stesso registro puntato dalla freccia contenuta nel registro
associato a p.
Mediante l'uso dei puntatori si possono definire parametri di funzioni che permettono
di passare informazioni dalla funzione chiamata a quella chiamante senza utilizzare il
volore di ritorno della funzione (specificato dalla istruzione return). Questo
permette un'interazione più complessa e a volte utile tra le funzioni, ma che
facilmente può produrre errori e va quindi usata con attenzione. Nell'Esempio 39
verrà raffigurata la configurazioe della memoria in una funzione che ritorna un
risultato alla funzione chiamante mediante un parametro passato per indirizzo (o
riferimento). Passando come parametro un puntatore ad una variabile della funzione
chiamante si rende di fatto disponibile alla funzione chiamata l'uso del registro
associato a tale variabile. Questo permette di comunicare dati (in entrambe le
direzioni) tra la funzione chiamate e quella chiamata.
9.3. Gestione degli Arrays
Una dichiarazione di array globale o all'interno di una procedura (o di main) produce
l'inserimento nel r.a. corrispondente di una successione di byte adiacenti, il cui
numero dipende dal tipo dei dati e dalle dimensioni dell'array che deve essere ben
definita nel momento in cui viene processata la dichiarazione. Si consideri per
esempio una dichiarazione del tipo:
int A[n];
1
Anche p ha un valore, ma tale valore è un indirizzo e cioè (nel nostro caso) una
freccia. Il C consente di eseguire delle operazioni anche sui puntatori (la cosiddetta
aritmetica dei puntatori) come, per esempio, sommare una costante a p.
72
dove n deve avere un valore definito al momento in cui viene valutata la
dichiarazione dell'array: sia n tale valore. La valutazione di questa definizione
determina l'allocazione nel r.a. di un blocco di 2n bytes consecutivi (assumendo che
un intero sia codificato su due byte). Tale successione verrà rappresentata nel modo
seguente:
…………
.
.
.
.
.
A[1]
v1
A[0]
v0
…………
dove v0, v1, … rappresentano i valori contenuti nelle successive locazioni indicate con
A[0], A[1]…. per la memorizzazione degli elementi di A. In generale per un'array
di tipo T di dimensione n verranno riservati k n byte, dove k è il numero di byte
occupati per memorizzare ogni elemento di tipo T. Gli elementi dell'array saranno
indirizzabili mediante l'uso di indici con espressioni del tipo A[exp] dove exp
indica una generica espressioni di tipo intero il cui valore deve essere compreso tra 0
e (n - 1).
Poichè il nome di un'array in C è sempre associato ad un puntatore, A sarà associato
nel suo binding ad un puntatore (una freccia) alla base della zona di memoria in cui
vengono sistemati i suoi elementi. Nella rappresentazione di un'array omettiamo di
rappresentare i nomi A[0], A[1] …. associati ai vari registri e ci limiteremo a
rappresentare la freccia associata ad A nel seguente modo.
…………
.
.
.
A:
v1
v0
……………
Notiamo però che anche se A è associato ad una freccia questo indirizzo non è
contenuto in un registro interno al r.a. come avviene, per esempio per i puntatori.
73
L'associazione di A con l'indirizzo del primo byte del'array è invece ottenuta in fase di
compilazione2. Questa è la ragione per cui il binding per A nel r.a. in cui A stesso è
definito, non è associato ad un registro, una “cornice”, ma solo ad una freccia. Per
esempio un' espressione come &A non ha senso (e produce un errore in fase di
compilazione) mentre *A è perfettamente lecita A è di tipo puntatore). E' però
possibile trovare dei puntatori ad array contenuti effettivamente in r.a. (e quindi tali,
per esempio, che l'espressione &A abbia senso) quando l'array è stata passata come
parametro alla funzione (si veda l'Esempio 40), e la sua area di memoria si trova
quindi in un altro r.a..
Un riferimento A[expr] dove (dove expr è una generica espressione di tipo intero)
individua quindi il registro di memoria che si ottiene:
• valutando expr nella memoria corrente. Sia i il valore (intero) di expr
• seguendo la freccia associata ad A nel suo binding attivo.
• considerando l' i-esimo registro (contato partendo da 0) a partire dalla base
dell'allocazione dell'array.
Notiamo che poichè le freccie sono in realtà indirizzi, l'indirizzo di A[i] si ottiene
aggiungendo ik all'indirizzo associato ad A (dove k è il numero di bytes occupato da
ogni elemento dell'array, k= 2 nel caso degli interi). In questo senso A[i] è
equivalente ad A+i secondo l'interpretazione standard del C per cui in questo caso
“sommare i” è inteso nel senso di sommare i per l'occupazione in byte di ogni
elemento dell'array. Un riferimento A[i] è considerato a tutti gli effetti una variabile.
Quindi si può anche, per esempio, passare come parametro “per riferimento”
attraverso il suo indirizzo &A[i]3.
Coerentemente a questa rappresentazione, quando un'array A è passata come
parametro attuale in una invocazione di funzione (la quale avrà tra i suoi parametri
formali uno della forma “…. X[]”) il valore che viene associato ad X nel r.a. della
funzione chiamata sarà il valore associato ad A nel suo binding attivo r.a. della
procedura chiamante, e cioè il puntatore al blocco di bytes che memorizzano A4.
Passando un array come parametro in C non si copiano quindi mai i dati dell'array
nel r.a. della procedura chiamata ma solo un puntatore. In un certo senso si può dire
che le array in C sono passate solo per riferimento. Questo ha anche la conseguenza
che le modifiche effettuate su un array A da una funzione che ha A tra i suoi parametri
attuali sono mantenute anche quando la funzione ha finito la sua attivazione. Si
confronti questo con il passaggio dei parametri di tipo semplice (per esempio interi).
2
Comunque queste distinzioni tra ciò che avviene in fase di compilazione e in fase di esecuzione,
anche se utili per conoscere a fondo il tutti gli aspetti del lingaggio e della sua implementazione,
sono irrilevanti al livello di astrazione cui noi considerianmo il linguaggio.
3
Poichè A è un puntatore &A[i] è equivalente a A+i. Per quanto apparentemente più complessa le
forma &A[i] è però più “naturale” perchè rappresenta in forma esplicita l'indirizzo della variabile
A[i].
4
In altre parole “…X[]” e' equivalente a “ ….*X”. Si raccomanda tuttavia di usare la forma X[]
piuttosto che quella *X perchè la prima mette più in evidenza che il parametro è inteso come un
array.
74
Gli Esempi 40 e 41 rappresentano lo stato della memoria in programmi che
contengono array passati come parametri.
9.4. Strutture
Le strutture vengono memorizzate utilizzando in una successione contigua di byte,
come nel caso degli array. Per esempio, una variabile x di tipo struttura definita nel
modo seguente
struct esempio {int a[4];}
int b } x;
verrà rappresentata in un r.a. nel modo seguente:
.
.
.
.
x:
b:
v1
a:
v0
I vari campi della struttura possono ovviamente essere sottointesi nella
rappresentazione se non necessari alla comprensione del programma.
Ad una struttura ed ai suoi vari campi e sottostrutture corrispondono degli indirizzi
che somo quelli del primo registro del blocco di bytes che memorizza il campo o la
sottostruttura. Così facendo riferimento alla figura si ha che &x è l'indirizzo del primo
elemento di x, rappresentato dalla freccia sulla destra. Poichè a è il primo campo di x
si ha anche che &(x.a) è rappresentato dallo stesso indirizzo. Quindi &x==&(x.a)
è ver in C. Elementi di tipo “struttura” si possono passare come parametri a funzioni
e possono essere ottenuti come valori di ritorno di funzioni. Si possono anche fare
assegnazioni a variabili di tipo struttura. In questo caso, se c e y sono variabili dello
stesso tipo struttura, eseguendo un'assegnazione x=y tutti i campi di y vengono
copiati nei rispettivi campi di x.
Contrariamente a quanto avviene per le array, le strutture quando sono parametri di
funzioni vengono passati per valore (come i dati di tipo semplice), anche se
contengono array nel loro interno5. Quindi una struttura passata come parametro ad
una procedura viene copiata integralmente nel r.a. della procedura chiamata, come
5
Però se si passa come parametro un campo di una strutura di tipo array questo viene passato come
puntatore come nel caso di un array “normale”. Nel caso dell'esempio della figure, se si passa come
parametro la struttura x (di tipo struct esempio ,questa viene ricopiata integralmente (quindi
array b compresa), ma se si passa x.b come parametro (di tipo int[]) viene passato solo il
puntatore alla base di b.
75
dimostrato dalla simulazione presentata nell'Esempio 41. Se questo non è l’effetto
desiderato si può utilizzare il passaggio tramite puntatore, che va gestito
esplicitamente.