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.