|
Appunti informatica |
|
Visite: 2299 | Gradito: | [ Grande appunti ] |
Leggi anche appunti:Allocazione dinamica della memoriaAllocazione dinamica della memoria Quando è dichiarata una variabile, il compilatore I puntatoriI puntatori Una variabile è un'area di memoria alla quale è associato un nome L'accessibilitÀ e la durata delle variabiliL'accessibilità e la durata delle variabili In C le variabili possono essere |
Una variabile è un'area di memoria alla quale è associato un nome simbolico, scelto dal programmatore (vedere pag. ). Detta area di memoria è grande quanto basta per contenere il tipo di dato indicato nella dichiarazione della variabile stessa, ed è collocata dal compilatore, automaticamente, in una parte di RAM non ancora occupata da altri dati. La posizione di una variabile in RAM è detta indirizzo , o address. Possiamo allora dire che, in pratica, ad ogni variabile il compilatore associa sempre due valori: il dato in essa contenuto e il suo indirizzo, cioè la sua posizione in memoria.
Proviamo ad immaginare la memoria come una sequenza di piccoli contenitori, ciascuno dei quali rappresenta un byte: ad ogni 'contenitore', talvolta detto 'locazione', potremo attribuire un numero d'ordine, che lo identifica univocamente. Se il primo byte ha numero d'ordine 0, allora il numero assegnato ad un generico byte ne individua la posizione in termini di spostamento (offset) rispetto al primo byte, cioè rispetto all'inizio della memoria. Così, il byte numero 12445 dista proprio 12445 byte dal primo, il quale, potremmo dire, dista 0 byte da se stesso. L'indirizzamento (cioè l'accesso alla memoria mediante indirizzi) avviene proprio come appena descritto: ogni byte è accessibile attraverso il suo offset rispetto ad un certo punto di partenza, il quale, però, non necessariamente è costituito dal primo byte di memoria in assoluto. Vediamo perché.
Nella CPU del PC sono disponibili alcuni byte, organizzati come vere e proprie variabili, dette registri (register). La CPU è in grado di effettuare elaborazioni unicamente sui valori contenuti nei propri registri (che si trovano fisicamente al suo interno e non nella RAM); pertanto qualunque valore oggetto di elaborazione deve essere 'caricato', cioè scritto, negli opportuni registri. Il risultato delle operazioni compiute dalla CPU deve essere conservato, se necessario, altrove (tipicamente nella RAM), al fine di lasciare i registri disponibili per altre elaborazioni.
Anche gli indirizzi di memoria sono soggetti a questa regola.
I registri del processore Intel 8086 si compongono di 16 bit ciascuno, pertanto il valore massimo che essi possono esprimere è quello dell'integer, cioè (esadecimale FFFF): il massimo offset gestibile dalla CPU permette dunque di indirizzare una sequenza di 65536 byte (compreso il primo, che ha offset pari a 0), corrispondenti a 64Kb.
Configurazioni di RAM superiori (praticamente tutte) devono perciò essere indirizzate con uno stratagemma: in pratica si utilizzano due registri, rispettivamente detti registro di segmento (segment register) e registro di offset (offset register). Segmento e offset vengono solitamente indicati in notazione esadecimale, utilizzando i due punti (' ') come separatore, ad esempio 045A:10BF. Ma non è tutto.
Se segmento e offset venissero semplicemente affiancati, si potrebbero indirizzare al massimo 128Kb di RAM: infatti si potrebbe avere un offset massimo di 65535 byte a partire dal byte numero 65535. Quello che occorre è invece un valore in grado di numerare, o meglio di indirizzare, almeno 1Mb: i fatidici 640Kb, ormai presenti su tutte le macchine in circolazione, più gli indirizzi riservati al BIOS e alle schede adattatrici . Occorre, in altre parole, un indirizzamento a 20 bit
Questo si ottiene sommando al segmento i 12 bit più significativi dell'offset, ed accodando i 4 bit rimanenti dell'offset stesso: tale tecnica consente di trasformare un indirizzo segmento :offset in un indirizzo lineare . L'indirizzo seg:off di poco fa (045A:10BF) corrisponde all'indirizzo lineare 0565F, infatti 045A+10B = 565 (le prime 3 cifre di un valore esadecimale di 4 cifre, cioè a 16 bit, corrispondono ai 12 bit più significativi).
Complicato? Effettivamente Ma dal momento che le cose stanno proprio così, tanto vale adeguarsi e cercare di padroneggiare al meglio la situazione. In fondo è anche questione di abitudine.
Il C consente di pasticciare a volontà, ed anche troppo, con gli indirizzi di memoria mediante particolari strumenti, detti puntatori, o pointers.
Un puntatore non è altro che una normalissima variabile contenente un indirizzo di memoria. I puntatori non rappresentano un tipo di dato in sé, ma piuttosto sono tipizzati in base al tipo di dato a cui puntano, cioè di cui esprimono l'indirizzo. Perciò essi sono dichiarati in modo del tutto analogo ad una variabile di quel tipo, anteponendo però al nome del puntatore stesso l'operatore ' ', detto operatore di indirezione (dereference operator).
Così, la riga
int unIntero;
dichiara una variabile di tipo int avente nome unIntero, mentre la riga
int *puntaIntero;
dichiara un puntatore a int avente nome puntaIntero (il puntatore ha nome puntaIntero, non l'int ovvio!). E' importante sottolineare che si tratta di un puntatore a integer: il compilatore C effettua alcune operazioni sui puntatori in modo automaticamente differenziato a seconda del tipo che il puntatore indirizza , ma è altrettanto importante non dimenticare mai che un puntatore contiene semplicemente un indirizzo (o meglio un valore che viene gestito dal compilatore come un indirizzo). Esso indirizza, in altre parole, un certo byte nella RAM; la dichiarazione del tipo 'puntato' permette al compilatore di 'capire' di quanti byte si compone l'area che inizia a quell'indirizzo e come è organizzata al proprio interno, cioè quale significato attribuire ai singoli bit (vedere pag.
Si possono dichiarare più puntatori in un'unica riga logica, come del resto avviene per le variabili: la riga seguente dichiare tre puntatori ad intero.
int *ptrA, *ptrB, *ptrC;
Si noti che l'asterisco, o meglio, l'operatore di indirezione, è ripetuto davanti al nome di ogni puntatore. Se non lo fosse, tutti i puntatori dichiarati senza di esso sarebbero in realtà normalissime variabili di tipo int. Ad esempio, la riga che segue dichiara due puntatori ad intero, una variabile intera, e poi ancora un puntatore ad intero.
int *ptrA, *ptrB, unIntero, *intPtr;
Come si vede, la dichiarazione mista di puntatori e variabili è un costrutto sintatticamente valido; occorre, come al solito, prestare attenzione a ciò che si scrive se si vogliono evitare errori logici piuttosto insidiosi. Detto tra noi, principianti e distratti sono i più propensi a dichiarare correttamente il primo puntatore e privare tutti gli altri dell'asterisco nella convinzione che il tipo dichiarato sia int*. In realtà, una riga di codice come quella appena riportata dichiara una serie di oggetti di tipo int; è la presenza o l'assenza dell'operatore di indirezione a stabilire, singolarmente per ciascuno di essi, se si tratti di una variabile o di un puntatore.
Mediante l'operatore & (detto 'indirizzo di ', o address of) è possibile, inoltre, conoscere l'indirizzo di una variabile:
float numero; // dichiara una variabile float
float *numPtr; // dichiara un puntatore ad una variabile float
numero = 12.5; // assegna un valore alla variabile
numPtr = № // assegna al puntatore l'indirizzo della variabile
E' chiaro il rapporto tra puntatori e variabili? Una variabile contiene un valore del tipo della dichiarazione, mentre un puntatore contiene l'indirizzo, cioè la posizione in memoria, di una variabile che a sua volta contiene un dato del tipo della dichiarazione. Dopo le operazioni dell'esempio appena visto, numPtr non contiene , ma l'indirizzo di memoria al quale si trova.
Anche un puntatore è una variabile, ma contiene un valore che non rappresenta un dato di un particolare tipo, bensì un indirizzo. Anche un puntatore ha il suo bravo indirizzo, ovviamente. Riferendosi ancora all'esempio precedente, l'indirizzo di numPtr può essere conosciuto con l'espressione &numPtr e risulta sicuramente diverso da quello di numero, cioè dal valore contenuto in numPtr. Sembra di giocare a rimpiattino
Proviamo a confrontare le due dichiarazioni dell'esempio:
float numero;
float *numPtr;
Esse sono fortemente analoghe; del resto abbiamo appena detto che la dichiarazione di un puntatore è identica a quella di una comune variabile, ad eccezione dell'asterisco che precede il nome del puntatore stesso. Sappiamo inoltre che il nome attribuito alla variabile identifica un'area di memoria che contiene un valore del tipo dichiarato: ad esso si accede mediante il nome stesso della variabile, cioè il simbolo che, nella dichiarazione, si trova a destra della parola chiave che indica il tipo, come si vede chiaramente nell'esempio che segue.
printf('%fn',numero);
L'accesso al valore della variabile avviene nella modalità appena descritta non solo in lettura, ma anche in scrittura:
numero = 12.5;
Cosa troviamo a destra dell'identificativo di tipo in una dichiarazione di puntatore? Il nome preceduto dall'asterisco. Ma allora anche il nome del puntatore con l'asterisco rappresenta un valore del tipo dichiarato Provate ad immaginare cosa avviene se scriviamo:
printf('%fn',*numPtr);
La risposta è: printf() stampa il valore di numero . In altre parole, l'operatore di indirezione non solo differenzia la dichiarazione di un puntatore da quella di una variabile, ma consente anche di accedere al contenuto della variabile (o, più in generale, della locazione di memoria) indirizzata dal puntatore. Forse è opportuno, a questo punto, riassumere il tutto con qualche altro esempio.
float numero = 12.5;
float *numPtr = №
Sin qui nulla di nuovo . Supponiamo ora che l'indirizzo di numero sia, in esadecimale, FFE6 e che quello di numPtr sia FFE4: non ci resta che giocherellare un po' con gli operatori address of ('&') e dereference ('
printf('numero = %fn',numero);
printf('numero = %fn',*numPtr);
printf('l'indirizzo di numero e' %Xn',&numero);
printf('l'indirizzo di numero e' %Xn',numPtr);
printf('l'indirizzo di numPtr e' %Xn',&numPtr);
L'output prodotto è il seguente:
numero = 12.5
numero = 12.5
l'indirizzo di numero è FFE6
l'indirizzo di numero è FFE6
l'indirizzo di numPtr è FFE4
Le differenza tra le varie modalità di accesso al contenuto e all'indirizzo delle veriabili dovrebbe ora essere chiarita. Almeno, questa è la speranza. Tra l'altro abbiamo imparato qualcosa di nuovo su printf(): per stampare un intero in formato esadecimale si deve inserire nella stringa, invece di %d %X se si desidera che le cifre A‑F siano visualizzate con caratteri maiuscoli, %x se si preferiscono i caratteri minuscoli.
Va osservato che è prassi usuale esprimere gli indirizzi in notazione esadecimale. A prima vista può risultare un po' scomodo, ma, operando in tal modo, la logica di alcune operazioni sugli indirizzi stessi (e sui puntatori) risulta sicuramente più chiara. Ad esempio, ogni cifra di un numero esadecimale rappresenta quattro bit in memoria: si è già visto (pag. ) come ciò permetta di trasformare un indirizzo segmentato nel suo equivalente lineare con grande facilità. Per la cronaca, tale operazione è detta anche 'normalizzazione' dell'indirizzo (o del puntatore): avremo occasione di riparlarne (pag.
Vogliamo complicarci un poco la vita? Eccovi alcune interessanti domandine, qualora non ve le foste ancora posti
a) Quale significato ha l'espressione *&numPtr
b) Quale significato ha l'espressione **numPtr
c) E l'espressione *numero
d) E l'espressione &*numPtr
e) &*numPtr e numPtr sono la stessa cosa?
f) Cosa restituisce l'espressione &&numero
g) E l'espressione &&numPtr
h) Cosa accade se si esegue *numPtr = 21.75
i) Cosa accade se si esegue numPtr = 0x24A6
j) E se si esegue &numPtr = 0xAF2B
Provate a rispondere prima di leggere le risposte nelle righe che seguono!
a) Una cosa alla volta. &numPtr esprime l'indirizzo di numPtr. L'indirezione di un indirizzo (cioè l'asterisco davanti a 'qualcosa' che esprime un indirizzo) restituisce il valore memorizzato a quell'indirizzo. Pertanto, *&numPtr esprime il valore memorizzato all'indirizzo di numPtr. Cioè il valore contenuto in numPtr. Cioè l'indirizzo di numero. Simpatico, vero?
b) Nessuno! Infatti *numPtr esprime il valore memorizzato all'indirizzo puntato da numPtr, cioè il valore di numero. Applicare una indirezione (il primo asterisco) a detto valore non ha alcun senso, perché il contenuto di numero non è un indirizzo. Per di più numero è un float, mentre gli indirizzi sono sempre numeri interi. Il compilatore ignora l'asterisco di troppo.
c) Nessuno! Di fatto, si ricade nel caso precedente, poiché *numPtr equivale a numero e **numPtr, pertanto, equivale a *numero
d) Allora: numPtr esprime l'indirizzo di numero, quindi la sua indirezione *numPtr rappresenta il contenuto di numero. L'indirizzo del contenuto di numero è l'indirizzo di numero, quindi &*numPtr equivale a numPtr (e a *&numPtr). Buffo
e) Evidentemente sì, come si vede dalla risposta precedente.
f) &&numero restituisce una segnalazione d'errore del compilatore. Infatti &numero, espressione lecita, rappresenta l'indirizzo di numero, ma che senso può avere parlare dell'indirizzo dell'indirizzo di numero? Attenzione: numPtr contiene l'indirizzo di numero, ma l'indirizzo dell'indirizzo di numero non può essere considerato sinonimo dell'indirizzo di numPtr
g) Anche &&numPtr è un'espressione illecita. L'indirizzo dell'indirizzo di una variabile (puntatore o no) non esiste
h) Viene modificato il contenuto di numero. Infatti numPtr rappresenta l'indirizzo di numero, cioè punta all'area di memoria assegnata a numero *numPtr rappresenta numero nel senso che restituisce il contenuto dell'area di memoria occupata da numero. Un'operazione effettuata sull'indirezione di un puntatore è sempre, a tutti gli effetti, effettuata sulla locazione di memoria a cui esso punta.
i) Si assegna un nuovo valore a numPtr, questo è evidente. L'effetto (forse meno evidente a prima vista) è che ora numPtr non punta più a numero, ma a ciò che si trova all'indirizzo 0x24A6. Qualsiasi cosa sia memorizzata a quell'indirizzo, se referenziata mediante numPtr (cioè mediante la sua indirezione), viene trattata come se fosse un float
j) Si ottiene, ancora una volta, un errore in compilazione. Infatti &numPtr restituisce l'indirizzo di numPtr, il quale, ovviamente, non può essere modificato in quanto stabilito dal compilatore.
I puntatori sono, dunque, strumenti appropriati alla manipolazione ad alto livello degli indirizzi delle variabili. C'è proprio bisogno di preoccuparsi dei registri della CPU e di tutte le contorsioni possibili tra indirizzi seg:off e indirizzi lineari? Eh, sì un poco è necessario; ora si tratta di capire il perché.
Poco fa abbiamo ipotizzato che l'indirizzo di numero e di numPtr fossero, rispettivamente, FFE6 e FFE4. A prescindere dai valori, realisitci ma puramente ipotetici, è interessante notare che si tratta di due unsigned int. In effetti, per visualizzarli correttamente, abbiamo passato a printf() stringhe contenenti %X, lo specificatore di formato per gli interi in formato esadecimale. Che significa tutto ciò?
Significa che il valore memorizzato in numPtr (e in qualsiasi altro puntatore ) è una word, occupa 16 bit e si differenzia da un generico intero senza segno per il solo fatto che esprime un indirizzo di memoria. E' evidente, alla luce di quanto appena affermato, che l'indirizzo memorizzato in numPtr è un offset: come tutti i valori a 16 bit esso è gestito dalla CPU in uno dei suoi registri e può variare tra 0 e 65535. Un puntatore come numPtr esprime allora, in byte, la distanza di una variabile da che cosa? Dall'indirizzo contenuto in un altro registro della CPU, gestito automaticamente dal compilatore.
Con qualche semplificazione possiamo dire che il compilatore, durante la traduzione del sorgente in linguaggio macchina, stabilisce quanto spazio il programma ha a disposizione per gestire i propri dati e a quale distanza dall'inizio del codice eseguibile deve avere inizio l'area riservata ai dati. Dette informazioni sono memorizzate in una tabella, collocata in testa al file eseguibile, che il sistema operativo utilizza per caricare l'opportuno valore in un apposito registro della CPU. Questo registro contiene la parte segmento dell'indirizzo espresso da ogni puntatore dichiarato come numPtr
Nella maggior parte dei casi l'esistenza dei registri di segmento è del tutto trasparente al programmatore, il quale non ha alcun bisogno di proccuparsene, in quanto compilatore, linker e sistema operativo svolgono automaticamente tutte le operazioni necessarie alla loro gestione. Nello scrivere un programma è di solito sufficiente lavorare con i puntatori proprio come abbiamo visto negli esempi che coinvolgono numero e numPtr: gli operatori ' ' e '&' sono caratterizzati da una notevole potenza operativa.
Le considerazioni sin qui espresse, però, aprono la via ad alcuni approfondimenti. In primo luogo, va sottolineato ancora una volta che numPtr occupa 16 bit di memoria, cioè 2 byte, proprio come qualsiasi unsigned int. E ciò è valido anche se il tipo di numero, la variabile puntata, è il float, che ne occupa 4. In altre parole, un puntatore occupa sempre lo spazio necessario a contenere l'indirizzo del dato puntato, e non il tipo di dato; tutti i puntatori come numPtr, dunque, occupano 2 byte, indipendentemente che il tipo di dato puntato sia un int, piuttosto che un float, o un double Una semplice verifica empirica può essere effettuata con l'aiuto dell'operatore sizeof() (vedere pag.
int unIntero;
long unLongInt;
float unFloating;
double unDoublePrec;
int *intPtr;
long *longPtr;
float *floatPtr;
double *doublePtr;
printf('intPtr: %d bytes (%d)n',sizeof(intPtr),sizeof(int *));
printf('longPtr: %d bytes (%d)n',sizeof(longPtr),sizeof(long *));
printf('floatPtr: %d bytes (%d)n',sizeof(floatPtr),sizeof(float *));
printf('doublePtr: %d bytes (%d)n',sizeof(doublePtr),sizeof(double *));
Tutte le printf() visualizzano due volte il valore , che è appunto la dimensione in byte di un generico puntatore. L'esempio mostra, tra l'altro, come sizeof() possa essere applicato sia al tipo di dato che al nome di una variabile (in questo caso dei puntatori); se ne trae, infine, che il tipo di un puntatore è dato dal tipo di dato puntato, seguito dall'asterisco.
Tutti i puntatori come numPtr, dunque, gestiscono un offset da un punto di partenza automaticamente fissato dal sistema operativo in base alle caratteristiche del file eseguibile. E' possibile in C, allora, gestire indirizzi lineari, o quanto meno comprensivi di segmento ed offset? La risposta è sì. Esistono due parole chiave, dette modificatori di tipo, che consentono di dichiarare puntatori speciali, in grado di gestire sia la parte segmento che la parte offset di un indirizzo di memoria: si tratta di far e huge
double far *numFarPtr;
La riga di esempio dichiara un puntatore far a un dato di tipo double. Per effetto del modificatore far numFarPtr è un puntatore assai differente dal numPtr degli esempi precedenti: esso occupa 32 bit di memoria, cioè 2 word, ed è pertanto equivalente ad un long int. Di conseguenza numFarPtr è in grado di esprimere tanto la parte offset di un indirizzo (nei 2 byte meno significativi), quanto la parte segmento (nei 2 byte più significativi ). La parte segmento è utilizzata dalla CPU per caricare l'opportuno registro di segmento, mentre la parte offset è gestita come al solito: in tal modo un puntatore far può esprimere un indirizzo completo del tipo segmento:offset e indirizzare dati che si trovano al di fuori dell'area dati assegnata dal sistema operativo al programma.
Ad esempio, se si desidera che un puntatore referenzi l'indirizzo 596A:074B, lo si può dichiarare ed inizializzare come segue:
double far *numFarPtr = 0x596A074B;
Per visualizzare il contenuto di un puntatore far con printf() si può utilizzare un formattatore speciale:
printf('numFarPtr = %Fpn',numFarPtr);
Il formattatore %Fp forza printf() a visualizzare il contenuto di un puntatore far proprio come segmento ed offset, separati dai due punti:
numFarPtr = 596A:074B
è l'output prodotto dalla riga di codice appena riportata.
Abbiamo appena detto che un puntatore far rappresenta un indirizzo seg:off. E' bene ripeterlo qui, sottolineando che quell'indirizzo, in quanto seg:off, non è un indirizzo lineare. Parte segmento e parte offset sono, per così dire, indipendenti, nel senso che la prima è considerata costante, e la seconda variabile. Che significa? la riga
char far *vPtr = 0xB8000000;
dichiara un puntatore far a carattere e lo inizializza all'indirizzo B800:0000; la parte offset è nulla, perciò il puntatore indirizza il primo byte dell'area che ha inizio all'indirizzo lineare B8000 (a 20 bit). Il secondo byte ha offset pari a , perciò può essere indirizzato incrementando di il puntatore, portandolo al valore 0xB8000001. Incrementando ancora il puntatore, esso assume valore 0xB8000002 e punta al terzo byte. Sommando ancora al puntatore, e poi ancora , e poi ancora si giunge ad un valore particolare, 0xB800FFFF, corrispondente all'indirizzo B800:FFFF, che è proprio quello del byte avente offset rispetto all'inizio dell'area. Esso è l'ultimo byte indirizzabile mediante un comune puntatore near . Che accade se si incrementa ancora vPtr? Contrariamente a quanto ci si potrebbe attendere, la parte offset si riazzera senza che alcun 'riporto' venga sommato alla parte segmento. Insomma, il puntatore si 'riavvolge' all'inizio dell'area individuata dall'indirizzo lineare rappresentato dalla parte segmento con uno alla propria destra (che serve a costruire l'indirizzo a 20 bit). Ora si comprende meglio (speriamo!) che cosa si intende per parte segmento e parte offset separate: esse sono utilizzate proprio per caricare due distinti registri della CPU e pertanto sono considerate indipendenti l'una dall'altra, così come lo sono tra loro tutti i registri del microprocessore.
Tutto ciò ha un'implicazione estremamente importante: con un puntatore far è possibile indirizzare un dato situato ad un qualunque indirizzo nella memoria disponibile entro il primo Mb, ma non è possibile 'scostarsi' dall'indirizzo lineare espresso dalla parte segmento oltre i 64Kb. Per fare un esempio pratico, se si intende utilizzare un puntatore far per gestire una tabella, la dimensione complessiva di questa non deve eccedere i 64Kb.
Tale limitazione è superata tramite il modificatore huge , che consente di avere puntatori in grado di indirizzare linearmente tutta la memoria disponibile (sempre entro il primo Mb). La dichiarazione di un puntatore huge non presenta particolarità:
int huge *iHptr;
Il segreto dei puntatori huge consiste in alcune istruzioni assembler che il compilatore introduce di soppiatto nei programmi tutte le volte che il valore del puntatore viene modificato o utilizzato, e che ne effettuano la normalizzazione. Con tale termine si indica un semplice calcolo che consente di esprimere l'indirizzo seg:off come rappresentazione di un indirizzo lineare: in modo, cioè, che la parte offset sia variabile unicamente da a F esadecimale) ed i riporti siano sommati alla parte segmento. In pratica si tratta di sommare alla parte segmento i 12 bit più significativi della parte offset, con una tecnica del tutto analoga a quella utilizzata a pag. . Riprendiamo l'esempio precedente, utilizzando questa volta un puntatore huge
char huge *vhugePtr = 0xB8000000;
L'inizializzazione del puntatore huge, come si vede, è identica a quella del puntatore far. Incrementando di il puntatore si ottiene il valore 0xB8000001, come nel caso precedente. Sommando ancora si ha 0xB8000002, e poi 0xB8000003, e così via. Sin qui, nulla di nuovo. Al quindicesimo incremento il puntatore vale 0xB800000F, come nel caso del puntatore far
Ma al sedicesimo incremento si manifesta la differenza: il puntatore far assume valore 0xB8000010, mentre il puntatore huge vale 0xB8010000: la parte segmento si è azzerata ed il sottratto ad essa ha prodotto un riporto che è andato ad incrementare di la parte segmento. Al trentunesimo incremento il puntatore far vale 0xB800001F, mentre quello huge 0xB801000F. Al trentaduesimo incremento il puntatore far diventa 0xB8000020, mentre quello huge vale 0xB8020000
Il meccanismo dovrebbe essere ormai chiaro, così come il fatto che le prime 3 cifre della parte offset di un puntatore huge sono sempre 3 zeri. Fingiamo per un attimo di non vederli: la parte segmento e la quarta cifra della parte offset rappresentano proprio un indirizzo lineare a 20 bit.
La normalizzazione effettuata dal compilatore consente di gestire indirizzi lineari pur caricando in modo indipendente parte segmento e parte offset in registri di segmento e, rispettivamente, di offset della CPU; in tal modo, con un puntatore huge non vi sono limiti né all'indirizzo di partenza, né alla quantità di memoria indirizzabile a partire da quell'indirizzo. Naturalmente ciò ha un prezzo: una piccola perdita di efficienza del codice eseguibile, introdotta dalla necessità di eseguire la routine di normalizzazione prima di utilizzare il valore del puntatore.
Ancora una precisazione: nelle dichiarazioni multiple di puntatori far e huge, il modificatore deve essere ripetuto per ogni puntatore dichiarato, analogamente a quanto occorre per l'operatore di indirezione. L'omissione del modificatore determina la dichiarazione di un puntatore 'offset' a 16 bit.
long *lptr, far *lFptr, lvar, huge *lHptr;
Nell'esempio sono dichiarati, nell'ordine, il puntatore a long a 16 bit lptr, il puntatore far a long lFptr, la variabile long lvar e il puntatore huge a long lHptr
E' forse il caso di sottolineare ancora che la dichiarazione di un puntatore riserva spazio in memoria esclusivamente per il puntatore stesso, e non per una variabile del tipo di dato indirizzato. Ad esempio, la dichiarazione
long double far *dFptr;
alloca, cioè riserva, 32 bit di RAM che potranno essere utilizzate per contenere l'indirizzo di un long double, i cui 80 bit dovranno essere allocati con un'operazione a parte
Tanto per confondere un poco le idee, occorre precisare un ultimo particolare. I sorgenti C possono essere compilati, tramite particolari opzioni riconosciute dal compilatore, in modo da applicare differenti criteri di default alla gestione dei puntatori. In particolare, vi sono modalità di compilazione che trattano tutti i puntatori come variabili a 32 bit, eccetto quelli esplicitamente dichiarati near. Ne riparleremo a pagina , descrivendo i modelli di memoria.
Per il momento è il caso di accennare a tre macro, definite in DOS.H, che agevolano in molti casi la manipolazione dei puntatori a 32 bit, siano essi far o huge: si tratta di MK_FP() , che 'costruisce' un puntatore a 32 bit dati un segmento ed un offset entrambi a 16 bit, di FP_SEG() , che estrae da un puntatore a 32 bit i 16 bit esprimenti la parte segmento e di FP_OFF() , che estrae i 16 bit esprimenti l'offset. Vediamole al lavoro:
#include <dos.h>
unsigned farPtrSeg;
unsigned farPtrOff;
char far *farPtr;
.
farPtr = (char far *)MK_FP(0xB800,0); // farPtr punta a B800:0000
farPtrSeg = FP_SEG(farPtr); // farPtrSeg contiene 0xB800
farPtrOff = FP_OFF(farPtr); // farPtrOff contiene 0
Le macro testè descritte consentono di effettuare facilmente la normalizzzione di un puntatore, cioè trasformare l'indirizzo in esso contenuto in modo tale che la parte offset non sia superiore a 0Fh
char far *cfPtr;
char huge *chPtr;
.
chPtr = (char huge *)(((long)FP_SEG(cfPtr)) << 16)+
(((long)(FP_OFF(cfPtr) >> 4)) << 16)+(FP_OFF(cfPtr) & 0xF);
Come si vede, dalla parte offset sono scartati i 4 bit meno significativi: i 12 bit più significativi sono sommati al segmento; dalla parte offset sono poi scartati i 12 bit più significativi e i 4 bit restanti sono sommati al puntatore. Circa il significato degli operatori di shift << e >> vedere pag. ; l'operatore & (che in questo caso non ha il significato di address of, ma di and su bit) è descritto a pag.
L'indirizzo lineare corrispondente all'indirizzo segmentato espresso da un puntatore huge può essere ricavato come segue:
char huge *chPtr;
long linAddr;
.
linAddr = ((((((long)FP_SEG(chPtr)) << 16)+(FP_OFF(chPtr) << 12)) >> 12) &
0xFFFFFL);
Per applicare tale algoritmo ad un puntatore far è necessario che questo sia dapprima normalizzato come descritto in precedenza.
E' facile notare che due puntatori far possono referenziare il medesimo indirizzo pur contenendo valori a 32 bit differenti, mentre ciò non si verifica con i puntatori normalizzati, nei quali segmento e offset sono sempre gestiti in modo univoco: ne segue che solamente i confronti tra puntatori huge (o normalizzati) garantiscono risultati corretti.
La dichiarazione
static float *ptr;
dichiara un puntatore static a un dato di tipo float. In realtà non è possibile, nel dichiarare un puntatore, indicare che esso indirizza un dato static essendo questo un modificatore della visibilità delle variabili, e non già del loro tipo. Si veda anche quanto detto a pagina
A pagina abbiamo anticipato che non esiste, in C, il tipo di dato 'stringa'. Queste sono gestite dal compilatore come sequenze di caratteri, cioè di dati di tipo char. Un metodo comunemente utilizzato per dichiarare e manipolare stringhe nei programmi è offerto proprio dai puntatori, come si vede nel programma dell'esempio seguente, che visualizza 'Ciao Ciao!' e porta a capo il cursore.
#include <stdio.h>
char *string = 'Ciao';
void main(void)
La dichiarazione di string può apparire, a prima vista, anomala. Si tratta infatti, a tutti gli effetti, della dichiarazione di un puntatore e la stranezza consiste nel fatto che a questo non è assegnato un indirizzo di memoria, come ci si potrebbe aspettare, bensì una costante stringa. Ma è proprio questo l'artificio che consente di gestire le stringhe con normali puntatori a carattere: il compilatore, in realtà, assegna a string, puntatore a 16 bit, l'indirizzo della costante 'Ciao'. Dunque la word occupata da string non contiene la parola 'Ciao', ma i 16 bit che esprimono la parte offset del suo indirizzo. A sua volta, 'Ciao' occupa 5 byte di memoria. Proprio 5, non si tratta di un errore di stampa: i 4 byte necessari a memorizzare i 4 caratteri che compongono la parola, più un byte, nel quale il compilatore memorizza il valore binario , detto terminatore di stringa o null terminator. In C, tutte le stringhe sono chiuse da un null terminator, ed occupano perciò un byte in più del numero di caratteri 'stampabili' che le compongono.
La prima chiamata a printf() passa quale argomento proprio string: dunque la stringa parametro indispensabile di printf() non deve essere necessariamente una stringa di formato quando l'unica cosa da visualizzare sia proprio una stringa. Lo è, però, quando devono essere visualizzati caratteri o numeri, o stringhe formattate in un modo particolare, come avviene nella seconda chiamata.
Qui va sottolineato che per visualizzare una stringa con printf() occore fornirne l'indirizzo, che nel nostro caso è il contenuto del puntatore string. Se string punta alla stringa 'Ciao', che cosa restituisce l'espressione *string? La tentazione di rispondere 'Ciao' è forte, ma se così fosse perché per visualizzare la parola occorre passare a printf() string e non *string? Il problema non si poneva con gli esempi precedenti, perché tutti i puntatori esaminati indirizzavano un unico dato di un certo tipo. Con le dichiarazioni
float numero = 12.5;
float *numPtr = №
si definisce il puntatore numPtr e lo si inizializza in modo che contenga l'indirizzo della variabile numero, la quale, in fondo proprio come string, occupa più di un byte. In questo caso, però, i 4 byte di numero contengono un dato unitariamente considerato. In altre parole, nessuno dei 4 byte che la compongono ha significato in sé e per sé. Con riferimento a string, al contrario, ogni byte è un dato a sé stante, cioè un dato di tipo char: bisogna allora precisare che un puntatore indirizza sempre il primo byte di tutti quelli che compongono il tipo di dato considerato, se questi sono più d'uno. Se ne ricava che string contiene, in realtà, l'indirizzo del primo carattere di 'Ciao', cioè la 'C'. Allora *string non può che restituire proprio quella, come si può facilmente verificare con la seguente chiamata a printf()
printf('%c è il primo caratteren',*string);
Non dimentichiamo che le stringhe sono, per il compilatore C, semplici sequenze di char: la stringa del nostro esempio inizia con il char che si trova all'indirizzo contenuto in string (la 'C') e termina con il primo byte nullo incontrato ad un indirizzo uguale o superiore a quello (in questo caso il byte che segue immediatamente la 'o'
Per accedere ai caratteri che seguono il primo è sufficiente incrementare il puntatore o, comunque, sommare ad esso una opportuna quantità (che rappresenta l'offset, cioè lo spostamento, dall'inizo della stringa stessa). Vediamo, come al solito, un esempio:
int i = 0;
while(*(string+i) != 0)
L'esempio si basa sull'aritmetica dei puntatori (pag. ), cioè sulla possibilità di accedere ai dati memorizzati ad un certo offset rispetto ad un indirizzo sommandovi algebricamente numeri interi. Il ciclo visualizza la stringa 'Ciao' in senso verticale. Infatti l'istruzione while (finalmente una 'vera' istruzione C!) esegue le istruzioni comprese tra le parentesi graffe finché la condizione espressa tra le parentesi tonde è vera (se questa è falsa la prima volta, il ciclo non viene mai eseguito; vedere pag. ): in questo caso la printf() è eseguita finché il byte che si trova all'indirizzo contenuto in string aumentato di i unità è diverso da , cioè finché non viene incontrato il null terminator. La printf() visualizza il byte a quello stesso indirizzo e va a capo. Il valore di i è inizialmente , pertanto nella prima iterazione l'indirizzo espresso da string non è modificato, ma ad ogni loop i è incrementato di (tale è il significato dell'operatore , vedere pag. ), pertanto ad ogni successiva iterazione l'espressione string+i restituisce l'indirizzo del byte successivo a quello appena visualizzato. Al termine, i contiene il valore , che è anche la lunghezza della stringa: questa è infatti convenzionalmente pari al numero dei caratteri stampabili che compongono la stringa stessa; il null terminator non viene considerato. In altre parole la lunghezza di una stringa è inferiore di al numero di byte che essa occupa effettivamente in memoria. La lunghezza di una stringa può quindi essere calcolata così:
unsigned i = 0;
while(*(string+i))
++i;
La condizione tra parentesi è implicita: non viene specificato alcun confronto. In casi come questo il compilatore assume che il confronto vada effettuato con il valore , che è proprio quel che fa al nostro caso. Inoltre, dato che il ciclo si compone di una sola riga (l'autoincremento di i), le graffe non sono necessarie (ma potrebbero essere utilizzate ugualmente)
Tutta questa chiacchierata dovrebbe avere reso evidente una cosa: quando ad una funzione viene passata una costante stringa, come in
printf('Ciao!n');
il compilatore, astutamente, memorizza la costante da qualche parte (non preoccupiamoci del 'dove', per il momento) e ne passa l'indirizzo.
Inoltre, il metodo visto poco fa per 'prelevare' uno ad uno i caratteri che compongono una stringa vale anche nel caso li si voglia modificare:
char *string = 'Rosson';
void main(void)
Il programma dell'esempio visualizza dapprima la parola 'Rosso' e poi 'Rospo'. Si noti che il valore di string non è mutato: esso continua a puntare alla medesima locazione di memoria, ma è mutato il contenuto del byte che si trova ad un offset di 3 rispetto a quell'indirizzo. Dal momento che l'indirezione di un puntatore a carattere restituisce un carattere, nell'assegnazione della lettera 'p' è necessario esprimere quest'ultima come un char, e pertanto tra apici (e non tra virgolette). La variabile string è dichiarata all'esterno di main(): a pag. scoprirete perché.
E' possibile troncare una stringa? Sì, basta inserire un NULL dove occorre:
*(string+2) = NULL;
A questo punto una chiamata a printf() visualizzerebbe la parola 'Ro' NULL è una costante manifesta (vedere pag. ) definita in STDIO.H , e rappresenta lo zero binario; infatti la riga di codice precedente potrebbe essere scritta così:
*(string+2) = 0;
E' possibile allungare una stringa? Sì, basta essere sicuri di avere spazio a disposizione. Se si sovrascrive il NULL con un carattere, la stringa si allunga sino al successivo NULL. Occorre fare alcune considerazioni: in primo luogo, tale operazione ha senso, di solito, solo nel caso di concatenamento di stringhe (quando cioè si desidera accodare una stringa ad un'altra per produrne una sola, più lunga). In secondo luogo, se i byte successivi al NULL sono occupati da altri dati, questi vengono perduti, sovrascritti dai caratteri concatenati alla stringa: l'effetto può essere disastroso. In effetti esiste una funzione di libreria concepita appositamente per concatenare le stringhe: la strcat() , che richiede due stringhe quali parametri. L'azione da essa svolta consiste nel copiare i byte che compongono la seconda stringa, NULL terminale compreso, in coda alla prima stringa, sovrascrivendone il NULL terminale.
In una dichiarazione come quella di string, il compilatore riserva alla stringa lo spazio strettamente necessario a contenere i caratteri che la compongono, più il NULL. E' evidente che concatenare a string un'altra stringa sarebbe un grave errore (peraltro non segnalato dal compilatore, perché esso lascia il programmatore libero di gestire la memoria come crede: se sbaglia, peggio per lui). Allora, per potere concatenare due stringhe senza pericoli occorre riservare in anticipo lo spazio necessario a contenere la prima stringa e la seconda una in fila all'altra. Affronteremo il problema parlando di array (pag. ) e di allocazione dinamica della memoria (pag.
Avvertenza: una dichiarazione del tipo:
char *sPtr;
riserva in memoria lo spazio sufficiente a memorizzare il puntatore alla stringa, e non una (ipotetica) stringa. I byte allocati sono 2 se il puntatore è, come nell'esempio, near; mentre sono 4 se è far o huge. In ogni caso va ricordato che prima di copiare una stringa a quell'indirizzo bisogna assolutamente allocare lo spazio necessario a contenerla e assegnarne l'indirizzo a sPtr. Anche a questo proposito occorre rimandare gli approfondimenti alle pagine in cui esamineremo l'allocazione dinamica della memoria (pag.
E' meglio sottolineare che le librerie standard del C comprendono un gran numero di funzioni (dichiarate in STRING.H) per la manipolazione delle stringhe, che effettuano le più svariate operazioni: copiare stringhe o parte di esse (strcpy() strncpy()), concatenare stringhe (strcat() strncat()), confrontare stringhe (strcmp() stricmp()), ricercare sottostringhe o caratteri all'interno di stringhe (strstr() strchr() strtok()) insomma, quando si deve trafficare con le stringhe vale la pena di consultare il manuale delle librerie e cercare tra le funzioni il cui nome inizia con 'str': forse la soluzione al problema è già pronta.
Un array (o vettore) è una sequenza di dati dello stesso tipo, sistemati in memoria in fila indiana. Una stringa è, per definizione, un array di char. Si possono dichiarare array di int, di double, o di qualsiasi altro tipo. Il risultato è, in pratica, riservare in memoria lo spazio necessario a contenere un certo numero di variabili di quel tipo. In effetti, si può pensare ad un array anche come ad un gruppo di variabili, aventi tutte identico nome ed accessibili, quindi, referenziandole attraverso un indice. Il numero di 'variabili' componenti l'array è indicato nella dichiarazione
int iArr[15];
La dichiarazione di un array è analoga a quella di una variabile, ad eccezione del fatto che il nome dell'array è seguito dal numero di elementi che lo compongono, racchiuso tra parentesi quadre. Quella dell'esempio forza il compilatore a riservare lo spazio necessario a memorizzare 15 interi, dunque 30 byte. Per accedere a ciascuno di essi occorre sempre fare riferimento al nome dell'array, iArr: il singolo int desiderato è individuato da un indice tra parentesi quadre, che ne indica la posizione.
iArr[0] = 12;
iArr[1] = 25;
for(i = 2; i < 15; i++)
iArr[i] = i;
for(i = 0; i < 15;)
Nell'esempio i primi due elementi dell'array sono inizializzati a e , rispettivamente. Il primo ciclo for inizializza i successivi elementi (dal numero al numero ) al valore che i assume ad ogni iterazione. Il secondo ciclo for visualizza tutti gli elementi dell'array. Di for ci occuperemo a pag. . Qui preme sottolineare che gli elementi di un array sono numerati a partire da 0 (e non da 1), come ci si potrebbe attendere. Dunque, l'ultimo elemento di un array ha indice inferiore di 1 rispetto al numero di elementi in esso presenti. Si vede chiaramente che gli elementi di iArr, dichiarato come array di 15 interi, sono referenziati con indice che va da a
Che accade se si tenta di referenziare un elemento che non fa parte dell'array, ad esempio iArr[15]? Il compilatore non fa una grinza: iArr[15] può essere letto e scritto tranquillamente E' ovvio che nel primo caso (lettura) il valore letto non ha alcun significato logico ai fini del programma, mentre nel secondo caso (scrittura) si rischia di perdere (sovrascrivendolo) qualche altro dato importante. Anche questa volta il compilatore si limita a mettere a disposizione del programmatore gli strumenti per gestire la memoria, senza preoccuparsi di controllarne più di tanto l'operato. Per il compilatore, iArr[15] è semplicemente la word che si trova a 30 byte dall'indirizzo al quale l'array è memorizzato. Che farne, è affare del programmatore
Un array, come qualsiasi altro oggetto in memoria, ha un indirizzo. Questo è individuato e scelto dal compilatore. Il programmatore non può modificarlo, ma può conoscerlo attraverso il nome dell'array stesso, usandolo come un puntatore. In C, il nome di un array equivale, a tutti gli effetti, ad un puntatore all'area di memoria assegnata all'array. Pertanto, le righe di codice che seguono sono tutte lecite:
int *iPtr;
printf('indirizzo di iArr: %Xn',iArr);
iPtr = iArr;
printf('indirizzo di iArr: %Xn',iPtr);
printf('primo elemento di iArr: %dn',*iArr);
printf('secondo elemento di iArr: %dn',*(iArr+1));
++iPtr;
printf('secondo elemento di iArr: %dn',*iPtr);
mentre non sono lecite le seguenti:
++iArr; // l'indirizzo di un array non puo' essere modificato
iArr = iPtr; // idem
ed è lecita, ma inutilmente complessa, la seguente:
iPtr = &iArr;
in quanto il nome dell'array ne restituisce, di per se stesso, l'indirizzo, rendendo inutile l'uso dell'operatore & (address of).
Il lettore attento dovrebbe avere notato che l'indice di un elemento di un array ne esprime l'offset, in termini di numero di elementi, dal primo elemento dell'array stesso. In altre parole, il primo elemento di un array ha offset rispetto a se stesso; il secondo ha offset rispetto al primo; il terzo ha offset , cioè dista 2 elementi dal primo
Banale? Mica tanto. Il compilatore 'ragiona' sugli arrays in termini di elementi, e non di byte. Riprenderemo l'argomento tra breve (pag.
Ripensando alle stringhe, appare ora evidente che esse non sono altro che array di char. Si differenziano solo per l'uso delle virgolette; allora il problema del concatenamento di stringhe può essere risolto con un array:
char string[100];
Nell'esempio abbiamo così a disposizione 100 byte in cui copiare e concatenare le nostre stringhe.
Puntatori ed array hanno caratteristiche fortemente simili. Si differenziano perché ad un array non può essere assegnato un valore , e perché un array riserva direttamente, come si è visto, lo spazio necessario a contenere i suoi elementi. Il numero di elementi deve essere specificato con una costante. Non è mai possibile utilizzare una variabile. Con una variabile, utilizzata come indice, si può solo accedere agli elementi dell'array dopo che questo è stato dichiarato.
Gli array, se dichiarati al di fuori di qualsiasi funzione , possono essere inizializzati:
int iArr[] = ;
char string[100] = ;
float fArr[] = ;
Per inizializzare un array contestualmente alla dichiarazione bisogna specificare i suoi elementi, separati da virgole e compresi tra parentesi graffe aperta e chiusa. Se non si indica tra le parentesi quadre il numero di elementi, il compilatore lo desume dal numero di elementi inizializzati tra le parentesi graffe. Se il numero di elementi è specificato, e ne viene inizializzato un numero inferiore, tutti quelli 'mancanti' verranno inizializzati a dal compilatore. Analoga regola vale per gli elementi 'saltati' nella lista di inizializzazione: l'array fArr contiene 3 elementi, aventi valore e rispettivamente.
Su string si può effettuare una concatenazione come la seguente senza rischi:
strcat(string,' Pippo');
La stringa risultante, infatti, è 'Ciao Pippo', che occupa 11 byte compreso il NULL terminale: sappiamo però di averne a disposizione 100.
Sin qui si è parlato di array monodimensionali, cioè di array ogni elemento dei quali è referenziabile mediante un solo indice. In realtà, il C consente di gestire array multidimensionali, nei quali per accedere ad un elemento occorre specificarne più 'coordinate'. Ad esempio:
int iTab[3][6];
dichiara un array a 2 dimensioni, rispettivamente di 3 e 6 elementi. Per accedere ad un singolo elemento bisogna, allo stesso modo, utilizzare due indici:
int i, j, iTab[3][6];
for(i = 0; i < 3; ++i)
for(j = 0; j < 6; ++j)
iTab[i][j] = 0;
Il frammento di codice riportato dichiara l'array bidimensionale iTab e ne inizializza a tutti gli elementi. I due cicli for sono nidificati, il che significa che le iterazioni previste dal secondo vengono compiute tutte una volta per ogni iterazione prevista dal primo. In tal modo vengono 'percorsi' tutti gli elementi di iTab. Infatti il modo in cui il compilatore C alloca lo spazio di memoria per gli array multidimensionali garantisce che per accedere a tutti gli elementi nella stessa sequenza in cui essi si trovano in memoria, è l'indice più a destra quello che deve variare più frequentemente.
E' evidente, d'altra parte, che la memoria è una sequenza di byte: ciò implica che pur essendo iTab uno strumento che consente di rappresentare molto bene una tabella di 3 righe e 6 colonne, tutti i suoi elementi stanno comunque 'in fila indiana'. Pertanto, l'inizializzazione di un array multidimensionale contestuale alla sua dichiarazione può essere effettuata come segue:
int *tabella[2][5] = ,};
Gli elementi sono elencati proprio nell'ordine in cui si trovano in memoria; dal punto di vista logico, però, ogni gruppo di elementi nelle coppie di graffe più interne rappresenta una riga. Dal momento che, come già sappiamo, il C è molto elastico nelle regole che disciplinano la stesura delle righe di codice, la dichiarazione appena vista può essere spezzata su due righe, al fine di rendere ancora più evidente il parallelismo concettuale tra un array bidimensionale ed una tabella a doppia entrata:
int *tabella[2][5] = ,
};
Si noti che tra le parentesi quadre, inizializzando l'array contestualmente alla dichiarazione, non è necessario specificare entrambe le dimensioni, perché il compilatore può desumere quella mancante dal computo degli elementi inizialiazzati: nella dichiarazione dell'esempio sarebbe stato lecito scrivere tabella[][5] o tabella[2][]
Dalle affermazioni fatte discende infoltre che gli elementi di un array bidimensionale possono essere referenziati anche facendo uso di un solo indice:
int *iPtr;
iPtr = tabella;
for(i = 0; i < 2*5; i++)
printf('%dn',iPtr[i];
In genere i compilatori C sono in grado di gestire array multidimensionali senza un limite teorico (a parte la disponibilità di memoria) al numero di dimensioni. E' tuttavia infrequente, per gli utilizzi più comuni, andare oltre la terza dimensione.
Quanti byte di memoria occupa un array? La risposta dipende, ovviamente, dal numero degli elementi e dal tipo di dato dichiarato. Un array di 20 interi occupa 40 byte, dal momento che ogni int ne occupa 2. Un array di 20 long ne occupa, dunque, 80. Calcoli analoghi occorrono per accedere ad uno qualsiasi degli elementi di un array: il terzo elemento di un array di long ha indice 2 e dista 8 byte (2*4) dall'inizio dell'area di RAM riservata all'array stesso. Il quarto elemento di un array di int dista 3*2 = 6 byte dall'inizio dell'array. Generalizzando, possiamo affermare che un generico elemento di un array di un qualsiasi tipo dista dall'indirizzo base dell'array stesso un numero di byte pari al prodotto tra il proprio indice e la dimensione del tipo di dato.
Fortunatamente il compilatore C consente di accedere agli elementi di un array in funzione di un unico parametro: il loro indice . Per questo sono lecite e significative istruzioni come quelle già viste:
iArr[1] = 12;
printf('%Xn',iArr[j]);
E' il compilatore ad occuparsi di effettuare i calcoli sopra descritti per ricavare il giusto offset in termini di byte di ogni elemento, e lo fa in modo trasparente al programmatore per qualsiasi tipo di dato.
Ciò vale anche per le stringhe (o array di caratteri). Il fatto che ogni char occupi un byte semplifica i calcoli ma non modifica i termini del problema
E' importante sottolineare che quanto affermato vale non solo nei confronti degli array, bensì di qualsiasi puntatore, come può chiarire l'esempio che segue.
#include <stdio.h>
int iArr[]= ;
void main(void)
Il trucco sta tutto nell'espressione ++iPtr: l'incremento del puntatore è automaticamente effettuato dal compilatore sommando 2 al valore contenuto in iPtr, proprio perché esso è un puntatore ad int, e l'int occupa 2 byte. In altre parole, iPtr è incrementato, ad ogni iterazione, in modo da puntare all'intero successivo.
Si noti che l'aritmetica dei puntatori è applicata dal compilatore ogni volta che una grandezza intera è sommata a (o sottratta da) un puntatore, moltiplicando tale grandezza per il numero di byte occupati dal tipo di dato puntato.
Questo modo di gestire i puntatori ha due pregi: da un lato evita al programmatore lo sforzo di pensare ai dati in memoria in termini di numero di byte; dall'altro consente la portabilità dei programmi che fanno uso di puntatori anche su macchine che codificano gli stessi tipi di dato con un diverso numero di bit.
Un'ultima precisazione: ai putatori possono essere sommate o sottratte solo grandezze intere (int o long, a seconda che si tratti di puntatori near o no).
Un puntatore è una variabile che contiene un indirizzo. Perciò è lecito (e, tutto sommato, abbastanza normale) fare uso di puntatori che puntano ad altri puntatori. La dichiarazione di un puntatore a puntatore si effettua così:
char **pPtr;
In pratica occorre aggiungere un asterisco, in quanto siamo ad un secondo livello di indirezione: pPtr non punta direttamente ad un char; la sua indirezione *pPtr restituisce un altro puntatore a char, la cui indirezione, finalmente, restituisce il char agognato. Presentando i puntatori è stato analizzato il significato di alcune espressioni (pag. ); in particolare si è detto che in **numPtr, ove numPtr è un puntatore a float, il primo ' ' è ignorato: l'affermazione è corretta, perché pur essendo numPtr e pPtr entrambi puntatori, il secondo punta ad un altro puntatore, al quale può essere validamente applicato il primo dereference operator ('
L'ambito di utilizzo più frequente dei puntatori a puntatori è forse quello degli array di stringhe: dal momento che in C una stringa è di per sé un array (di char), gli array di stringhe sono gestiti come array di puntatori a char. A questo punto è chiaro che il nome dell'array (in C il nome di un array è anche puntatore all'array stesso) è un puntatore a puntatori a char . Pertanto, ad esempio,
printf(pPtr[2]);
visualizza la stringa puntata dal terzo elemento di pPtr (con una semplificazione 'umana' ma un po' pericolosa potremmo dire che viene visualizzata la terza stringa dell'array).
Un puntatore può essere dichiarato di tipo void. Si tratta di una pratica poco diffusa, avente lo scopo di lasciare indeterminato il tipo di dato che il puntatore indirizza, sino al momento dell'inizializzazione del puntatore stesso. La forma della dichiarazione è intuibile:
void *ptr, far *fvptr;
Ad un puntatore void può essere assegnato l'indirizzo di qualsiasi tipo di dato.
Ma perché proprio tale suddivisione? Perché gli indirizzi superiori al limite dei 640Kb sono stati riservati, proprio in sede di progettazione del PC, al firmware BIOS e al BIOS delle schede adattatrici (video, rete, etc.), sino al limite di 1 Mb.
In effetti, 2 = 1.048.576: provare per credere. Già che ci siamo, vale la pena di dire che le macchine basate su chip 80286 o superiore possono effettuare indirizzamenti a 21 bit: i curiosi possono leggere i particolari a pagina
Per indirizzo lineare si intende un offset relativo all'inizio della memoria, cioè relativo al primo byte della RAM. Al riguardo si veda anche pag.
Attenzione, però, all'istruzione
float *numPtr = №
Può infatti sembrare in contrasto con quanto affermato sin qui l'assegnazione di un un indirizzo (&numero) ad una indirezione (*numPtr) che non rappresenta un indirizzo, ma un float. In realtà va osservato che l'istruzione riportata assegna a numPtr un valore contestualmente alla dichiarazione di questo: l'operatore di indirezione, qui, serve unicamente a indicare che numPtr è un puntatore; esso deve cioè essere considerato parte della sintassi necessaria alla dichiarazione del puntatore e non come strumento per accedere a ciò che il puntatore stesso indirizza. Alla luce di tale considerazione l'assegnazione appare perfettamente logica.
Ciò vale se si lascia che il compilatore lavori basandosi sui propri default. Torneremo sull'argomento in tema di modelli di memoria (pag.
I processori Intel memorizzano i valori in RAM con la tecnica backwords, cioè a 'parole rovesciate'. Ciò significa che i byte più significativi di ogni valore sono memorizzati ad indirizzi di memoria superiori: ad esempio il primo byte di una word (quello composto dai primi 8 bit) è memorizzato nella locazione di memoria successiva a quella in cui si trova il secondo byte (bit 8‑15), che contiene la parte più importante (significativa) del valore.
Si dicono near i puntatori non far e non huge; quelli, in altre parole, che esprimono semplicemente un offset rispetto ad un registro di segmento della CPU.
Nella numerazione esadecimale, cioè in base 16, si calcola un riporto ogni 16 unità, e non ogni 10 come invece avviene nella numerazione in base decimale.
Ad esempio con la dichiarazione di una variabile long double o con una chiamata ad una delle funzioni di libreria dedicate all'allocazione dinamica della memoria (pag. ). Non siate impazienti
Detto tra noi, esiste un metodo più comodo per conoscere la lunghezza di una stringa: la funzione di libreria strlen(), che accetta quale parametro l'indirizzo di una stringa e restituisce, come intero, la lunghezza della medesima (escluso, dunque, il null terminator).
Insomma, il programmatore dovrebbe sempre ricordare la regola KISS (pag. ), il compilatore, da parte sua, applica con tenacia la regola MYOB (Mind Your Own Business, fatti gli affari tuoi).
Va osservato che un array di puntatori a carattere potrebbe essere anche dichiarato così:
char *stringhe[10];
La differenza consiste principalmente nella necessità di indicare al momento della dichiarazione il numero di puntatori a char contenuti nell'array stesso, e nella possibilità di inizializzare l'array:
char *stringhe[] = ;
L'array stringhe comprende 4 stringhe di caratteri, o meglio 4 puntatori a char: proprio da questo deriva la possibilità di utilizzare NULL come elemento dell'array (NULL, lo ripetiamo, è una costante manifesta definita in STDIO.H, e vale uno zero binario). In pratica, il terzo elemento dell'array è un puntatore che non punta ad aluna stringa.
Appunti su: |
|