Zoom Icon

I puntatori

From UIC Archive

I puntatori

Contents


I puntatori
Author: anonymous
Email: .
Website: .
Date: 23/06/2008 (dd/mm/yyyy)
Level: Very Easy, if you can read this, you can do it
Language: Italian Flag Italian.gif
Comments: A gran richiesta, cerchiamo di riprendere il corso di C



Introduzione

Salve a tutti. E’ molto tempo che non mi vedo in questa situazione: 4.30 di notte, concentrato per scrivere ancora per la UIC. Purtroppo con l’università non ho mai molto tempo, ma questa volta ho deciso che devo riprendermi un attimo. Questa lezione è la ripresa del corso di C ANSI inizialmente creato da Phobos ma mai concluso (si è fermato alla prima lezione) perché anche il nostro Pho ha problemi di tempo. Per questo ho parlato con lui proponendogli di fare uno sforzo per completare un bel corso di C per la UIC, anche con il mio aiuto.


I concetti di variabile e di memoria virtuale

Senza dilungarmi troppo in chiacchiere inutili, inizio dunque a parlarvi delle variabili puntatore, comunemente dette puntatori. Per poterne parlare, riprendiamo prima di tutto i concetti di variabile e di memoria virtuale. Detto brutalmente, una variabile è una coppia <var_name,var_value> che associa, a un nome, un valore (in particolare, ogni volta che utilizzeremo l’identificatore var_name, staremo usando il valore var_value). Passando subito avanti, all’interno di un programma compilato, concettualmente, per ogni variabile, viene riservato uno spazio in memoria. La memoria di cui parliamo è virtuale in quanto viene logicamente considerata entro e non oltre i limiti concettuali di ciascun processo. Per vedere le cose un po’ più a livello pratico, supponiamo che su di un calcolatore ci siano due processi e che il primo di questi occupi 1 GB in memoria. Se ci mettessimo per un attimo “nei panni” del secondo processo, ci accorgeremmo che, nonostante la presenza del processo precedente, questo ha a disposizione ancora tutta la memoria, come se fosse l’unico residente nel calcolatore in questione. Questo è possibile grazie al fatto che la memoria fisica è una sorta di risorsa usata dal calcolatore per astrarre un livello di memoria virtuale, in modo da emulare una memoria virtuale distinta per ciascun processo residente. In altre parole, la memoria fisica viene usata in modo tale da poter implementare questa distinzione logica. Per quanto riguarda il corso e i nostri studi, ci servirà solo la memoria virtuale. Per lo sviluppo di questa lezione, lavoreremo su di una memoria virtuale che indirizza il BYTE come unità minima e faremo, almeno per ora, esempi di programmazione con variabili di tipo intero senza segno, della dimensione di 4 byte; non faremo inoltre alcuna considerazione sul funzionamento dello stack e dei registri utilizzati da un compilatore: ogni cosa sarà residente in memoria.


Puntatori

Diamo subito inizio alle danze, dichiarando un paio di variabili di tipo intero, di nome a e b: .. unsigned int a = 5; unsigned int b = 13; .. e proviamo subito a immaginarle in memoria ma “in C”, considerando il (probabile) caso che esse non siano allocate in porzioni contigue della memoria virtuale:

I puntatori img1.jpg


I valori a sinistra (gli indirizzi) sono inventati, del tutto casuali. Dalla figura si intuisce che la variabile a sia stata allocata nella cella di indirizzo 601 mentre la variabile b sia stata allocata nella cella 607; supponiamo che le altre celle della memoria siano vuote o comunque non significative. E’ importante tenere a mente che questa allocazione avviene concettualmente sempre, per qualsiasi variabile, di qualsiasi tipo e dimensione. Fate anche attenzione al fatto che dopo la cella 601, si passa alla cella 605 invece che alla cella 602. Questo succede perché tra le premesse avevamo detto che:

1) le variabili intere (quindi dichiarate con la direttiva int del C) hanno la dimensione di 4 byte; 2) l’unità che indirizza la memoria è il byte. Le celle vuote sono dunque grandi un byte mentre le nostre due variabili ne richiedono complessivamente otto. Continuiamo con i nostri esperimenti, notando che un’istruzione del tipo .. a = b+3 .. comporta una modifica allo stato della nostra memoria di questo tipo:

I puntatori img2.jpg

Quindi (come è intuitivo) modificando la variabile a, di tipo int, andiamo a modificare il suo valore. Se qualcuno di voi è furbo, inizia a intuire che, per quanto riguarda, ad esempio, la variabile a, ci sono in gioco due numeretti al livello della stessa cella: uno è 601 e l’altro è 16. Ma che cos’è esattamente 601? Beh si tratta dell’indirizzo, in memoria virtuale, a cui è stata allocata la variabile a. Pensate a questo indirizzo come ad un indice di un grande array concettuale chiamato MV (memoria virtuale). Esiste una relazione del tipo


MV[601] == a == 16

Più in generale,

MV[&a] == a == 16

dove la ‘e’ commerciale (&) dietro la ‘a’ della variabile ha il significato de “l’indirizzo di a in memoria virtuale”. L’array MV è, come detto prima, uno strumento concettuale, inventato dal sottoscritto per farvi capire come funziona la memoria virtuale; invece il simbolo di ‘e’ commerciale (&) esiste ed è usato in C quando si inizia a parlare di puntatori. In particolare, in C possiamo “scoprire” quale sia l’indirizzo, in memoria virtuale, di qualsiasi variabile o, più in generale, di qualsiasi entità che abbiamo dichiarato. Per utilizzare questo indirizzo, dobbiamo inscatolare anch’esso in una variabile (allo stesso modo di quando ci serve una variabile per utilizzarne il suo contenuto, adesso ci serve una variabile per utilizzare un indirizzo). Per adesso, teniamoci lontani dagli asterischi, ma supponiamo di dover affrontare una richiesta del genere: io vi dico che la variabile pa, ad un certo tempo, assume il valore 601 (corrispondente all’indirizzo in MV di a) e vi chiedo di modificare l’area di memoria virtuale con un nuovo valore, ad esempio 43, proprio a partire dall’indirizzo pa. In parole povere, SENZA usare la variabile a, vi chiedo di modificarne il suo valore, a patto di conoscerne l’indirizzo. Se lo conosco, in C posso scrivere: .. pa = 601;

  • pa = 43;

.. La prima istruzione, semplicemente assegna il valore 601 alla variabile pa. L’assegnamento della seconda istruzione è leggermente diverso e significa una cosa del tipo "scrivi 43 in memoria virtuale, partendo dall’indirizzo pa". La situazione che troviamo dopo la computazione delle due righe, è questa:

I puntatori img3.jpg

Volendo, non è difficile, e non è difficile nemmeno la parte sintattica per tutto questo. La variabile pa è una variabile speciale che, come valore, ha l’indirizzo della variabile intera a. In questo caso, si dice che pa è un puntatore a intero, precisamente è un puntatore ad a. Se in C volessimo ricostruire tutto il lavoro fatto fino ad ora faremmo questo:


.. unsigned int a = 5; unsigned int b = 16; unsigned int *pa; /* dichiariamo un puntatore a unsigned int */

a = b+3; pa = &a; /* pa prende l’indirizzo, in MV, di a */

  • pa = 43; /* modifichiamo la memoria virtuale */

.. Fate molta attenzione alla differenza che c’è tra la dichiarazione di pa e il suo utilizzo in assegnamento. Entrambe le righe presentano un asterisco alla sinistra del nome “pa”. Nel primo caso, si tratta di una dichiarazione di variabile, dove, con l’asterisco, indichiamo che stiamo dichiarando un puntatore che punterà ad una zona di memoria della grandezza di un unsigned int (4 byte); la seconda istruzione invece ha la succitata semantica di scrittura in MV a partire dall’indirizzo memorizzato in pa. Sì, bello. Ma a cosa servono questi puntatori? In realtà gli usi dei puntatori sono veramente molteplici e mi riesce un po’ scomodo farvelo capire a suon di frasi in un tutorial. Quando avrete sviluppato la sensibilità necessaria a trattare questo tipo di dato, automaticamente vi sembrerà tutto chiaro. Per il momento limitiamoci ad affrontare uno dei problemi più semplici e didatticamente tipici che viene proposto con i puntatori: quello di avere una funzione che restituisca più di un valore. In linea generale, le funzioni più comuni, hanno la caratteristica di restituire (o meno) un “valore di ritorno” dopo la loro invocazione. Ad esempio la funzione unsigned int quadrato(int x) restituisce un valore di tipo unsigned int e viene utilizzata in questo modo: .. int val1 = 8; unsigned int q1; q1 = quadrato(val1); .. e dopo la sua computazione avremo che q1 vale 64. Supponiamo però di voler realizzare una funzione quadrato_bis che riceva, in input, due interi, v1 e v2, e restituisca due unsigned int, q1 e q2, che valgano, dopo la sua invocazione, rispettivamente quadrato(v1) e quadrato(v2). Dato che una funzione restituisce al massimo un valore, agiamo diversamente. La funzione quadrato_bis prenderà quattro argomenti: due serviranno per il passaggio dei valori di v1 e di v2; i rimanenti due serviranno invece per indicarle dove, in memoria virtuale, andare a scrivere i quadrati ottenuti con il calcolo. Capite che se, in questi ultimi due parametri, passiamo gli indirizzi in MV di q1 e q2, quello che otterremo è proprio l’effetto desiderato, ovvero che q1 varrà quadrato(v1) e che q2 varrà quadrato(v2). La funzione si realizza dunque a questa maniera: void quadrato_bis(const int v1, unsigned int *pq1,

                 const int v2, unsigned int *pq2)

{

   *pq1 = quadrato(v1);
   *pq2 = quadrate(v2);
   return;

} Come spiegato in precedenza, nei due assegnamenti, le variabili pq1 e pq2 sono precedute a sinistra da un asterisco. L’effetto della prima riga del blocco sarà quello di modificare MV all’indirizzo dato dal valore di pq1; analogamente la seconda riga modifica MV all’indirizzo dato dal valore di pq2. Se vogliamo dare una descrizione semantica più precisa, la funzione diventa (in pseudocodice) una cosa che fa queste due operazioni:

MV[pq1] = quadrato(v1);
MV[pq2] = quadrato(v2);

Tornando al C vero e proprio, la funzione quadrato_bis, si utilizza in questo modo:

.. int v1 = 45; int v2 = -3; int q1; int q2; quadrato_bis(v1, &q1, v2, &q2); .. Coerentemente con la specifica di funzionamento della funzione quadrato_bis, al secondo e al terzo parametro, indichiamo a quale indirizzo di MV bisogna andare a memorizzare i risultati ottenuti dal calcolo (ovvero i due quadrati). Dunque indichiamo che il primo quadrato deve essere memorizzato nella cella individuata dall’indirizzo a cui è allocata la variabile q1, mentre il secondo deve essere memorizzato nella cella individuata dall’indirizzo a cui è allocata la variabile q2. Importante: in modo analogo a quello con cui abbiamo effettuato la modifica di MV a partire da un indirizzo, possiamo fare la lettura da MV, sempre partendo da un indirizzo. Prendiamo sempre le nostre variabili a e b di prima, con a allocata all’indirizzo 601. Il discorso significa che se abbiamo una situazione del genere:

.. int a = 5; int *pa = &a; /* pa prende 601 */ int b;

  • pa = 61; /* MV[pa], ovvero MV[601], ovvero a, prende 61 */

b = *pa; /* b prende MV[pa], ovvero MV[601], ovvero a, ovvero 61 */ .. Il risultato sarà questo: pa = 601 a = 61 b = 61. Non è difficile ma bisogna pensarci un attimo. Per chi è nuovo con il linguaggio C, queste cose non sono immediate, ed è del tutto normale.


Puntatori a puntatore

Una volta che abbiamo assaggiato la nozione di puntatore a intero, non dovrebbe essere particolarmente difficile parlare di puntatori “doppi”. Nel modo più semplice ed intuitivo che conoscete, possiamo dichiarare un puntatore che punti a un puntatore. Che poi quest’ultimo punti ad un intero è un altro paio di maniche. In sostanza abbiamo questo: .. int a = 16; int *pa = &a; int **ppa = &pa; .. In sostanza ci sono tre variabili: a, pa e ppa. Visto che sono variabili, occuperanno tre posti in memoria virtuale e la configurazione complessiva sarà questa:

I puntatori img4.jpg

A questo punto, proviamo a modificare il contenuto di a, senza usare a. Nel caso usassimo pa, dovremmo attraversare un solo livello di indirettezza e fare ..

  • pa = 55; /* MV[pa] = 55 */

.. Se invece usassimo ppa, dovremmo attraversare due livelli di indirettezza e fare ..

    • ppa = 55; /* MV[MV[ppa]] = 55 */

.. E questo ragionamento si può fare per qualsiasi livello di indirettezza. Fate molta attenzione a una cosa: se nell’ultimo esempio avessi usato un asterisco in meno, avrei usato un solo livello di indirettezza su un puntatore doppio. Come effetto avrei ottenuto un warning dal compilatore per un errore di type-checking (ma comunque il programma verrebbe compilato) e un codice che modificherebbe la memoria virtuale con un salto in meno! Quindi avrei modificato la variabile pa invece di modificare a. Questa operazione non è illegittima.. è solo che nel nostro caso è semanticamente sbagliata, solo per il fatto che io avevo intenzione di modificare a (come indicato nelle premesse). Nelle situazioni che dovrete affrontare, dovete stabilire il livello di indirettezza in funzione del numero di salti indiretti che avete intenzione di fare. In linea di massima quando ricevete errori di type-checking dal compilatore, potrebbe significare che avete scordato un livello di indirettezza (o che ne avete messo uno di troppo). Se invece vi rendete conto che volevate fare proprio quello che avete scritto, per evitare i succitati warning, vi basta usare un cast.


I puntatori visti come array

Vi ho spiegato per bene nei paragrafi precedenti cosa siano e come funzionino i puntatori, quindi pretendo  che capiate brevemente come mai questi possono essere visti come array. Molto semplicemente, per prima cosa prendiamo questo array .. int arr[] = { 34, 32, 67, 9, -4 }; .. e immaginiamolo in memoria virtuale:

I puntatori img5.jpg

Attenti al fatto che gli indirizzi vanno a quattro a quattro e poi pensate cosa succederebbe se io facessi questo: .. int arr[] = { 34, 32, 67, 9, -4 }; int *parr = arr; /* senza simbolo & */ .. Succederebbe che la variabile parr assumerebbe il valore 600, ovvero il valore della base dell’array arr (la base è l’indirizzo del primo elemento). Ecco fatto che anche parr allora è un array. Infatti, in C possiamo scrivere parr[0] o parr[1] (o equivalentemente *(parr+0) e *(parr+1)) per riferirci agli elementi arr[0] e arr[1]. Se dovessimo invece effettuare l’operazione parr = parr+1; avremmo, come effetto, il fatto che parr punterà, da quel momento in poi, all’elemento dell’array di indice 1. Anche se il puntatore viene incrementato di 1, se prima l’indirizzo a cui puntava era 600, dopo l’incremento questo non sarà 601 ma 604. Questo è merito del compilatore. Ogni incremento che viene fatto all’array viene moltiplicato per la dimensione dell’elemento puntato. Visto che l’elemento puntato, in questo caso, è grande 4 byte, parr viene incrementato di 1*4 byte. Lo stesso discorso vale per le altre operazioni che modificano il valore del puntatore. Questa notazione è utile per strani problemi di manipolazione di array (o di stringhe) oppure, più comunemente, viene utilizzata per scrivere funzioni che operino su array. Ad esempio, una funzione che conti il numero di interi pari di un array lungo N, si scrive così: int pari(const int x) {

   return (x%2 == 0);

}

unsigned int conta_pari(const int *arr,

                       const unsigned int N)

{

   unsigned int count = 0;
   int i;
   for(i=0; i<N; i++)
       count += pari(arr[i]);
   return count;

}


Puntatori a struttura

Passiamo ora a vedere l’ultimo esempio con i nostri puntatori. Supponiamo di avere a disposizione la seguente struct:

struct mydate {

   unsigned int year;
   unsigned int month;
   unsigned int day;

};

che, evidentemente, memorizza elementi che descrivono una data del calendario. Se noi decidessimo di allocare questa struct in memoria, staticamente, e modificarne qualche valore, faremmo così: .. struct mydate md; .. md.month = 2; md.year = 2008; md.day = 18; .. Accederemmo quindi ad essa tramite la notazione nome-punto-campo, pluriconosciuta. Questo scenario, in memoria virtuale appare a questa maniera:

I puntatori img6.jpg

Supponendo che la struct sia allocata in MV a partire dall’indirizzo 600 (notate che occupa 12 byte, 4 byte per ogni intero). Se invece volessimo farla puntare da un puntatore, la cosa da fare sarebbe questa: .. struct mydate md; struct mydate *pmd = &md; .. pmd->month = 2; pmd->year = 2008; pmd->day = 18; .. Questa volta, la variabile pmd non è di tipo unsigned int * ma è di tipo struct mydate *, proprio perché stavolta non deve puntare a intero ma a struttura mydate. La differenza rispetto a prima è che, questa volta, accediamo alla struttura usando pmd invece di md. Visto che questo è un puntatore, al posto della notazione nome-punto-valore, si usa la notazione nota-freccia-valore; nel modo più semplice, proprio perché pmd è un puntatore mentre md no. Quest’ultimo scenario, in memoria virtuale, è rappresentato i n questo modo:

I puntatori img7.jpg

restando il fatto che la struttura sia allocata a partire dall’indirizzo 600 e che la variabile pmd sia allocata alla cella 614 (notate che occupa solo 4 byte, come tutti i puntatori sulla nostra architettura, a prescindere dalla dimensione del tipo a cui puntano). Anche nel caso delle strutture, un puntatore a struttura può essere interpretato come un array di strutture (attenzione: array di strutture, NON array di puntatori a struttura). Supponiamo di voler fare una funzione che conti quanti sono, in un array, lungo N, di strutture mydate, le date del 2008: unsigned int conta_2008(const struct mydate *pmd,

                       const unsigned int N)

{

   unsigned int count = 0;
   int i;
   for(i=0; i<N; i++)
       if(pmd[i].year == 2008)
           count++;
   return count;

} Per quello che ho scritto prima, dopo pmd[i], NON si usa la freccia: se pmd è un array di strutture, pmd[i] è una struttura e non un puntatore. Per questo motivo, non ci vuole la freccia ma il punto.


Note Finali

Note finali.


Disclaimer

I documenti qui pubblicati sono da considerarsi pubblici e liberamente distribuibili, a patto che se ne citi la fonte di provenienza. Tutti i documenti presenti su queste pagine sono stati scritti esclusivamente a scopo di ricerca, nessuna di queste analisi è stata fatta per fini commerciali, o dietro alcun tipo di compenso. I documenti pubblicati presentano delle analisi puramente teoriche della struttura di un programma, in nessun caso il software è stato realmente disassemblato o modificato; ogni corrispondenza presente tra i documenti pubblicati e le istruzioni del software oggetto dell'analisi, è da ritenersi puramente casuale. Tutti i documenti vengono inviati in forma anonima ed automaticamente pubblicati, i diritti di tali opere appartengono esclusivamente al firmatario del documento (se presente), in nessun caso il gestore di questo sito, o del server su cui risiede, può essere ritenuto responsabile dei contenuti qui presenti, oltretutto il gestore del sito non è in grado di risalire all'identità del mittente dei documenti. Tutti i documenti ed i file di questo sito non presentano alcun tipo di garanzia, pertanto ne è sconsigliata a tutti la lettura o l'esecuzione, lo staff non si assume alcuna responsabilità per quanto riguarda l'uso improprio di tali documenti e/o file, è doveroso aggiungere che ogni riferimento a fatti cose o persone è da considerarsi PURAMENTE casuale. Tutti coloro che potrebbero ritenersi moralmente offesi dai contenuti di queste pagine, sono tenuti ad uscire immediatamente da questo sito.

Vogliamo inoltre ricordare che il Reverse Engineering è uno strumento tecnologico di grande potenza ed importanza, senza di esso non sarebbe possibile creare antivirus, scoprire funzioni malevole e non dichiarate all'interno di un programma di pubblico utilizzo. Non sarebbe possibile scoprire, in assenza di un sistema sicuro per il controllo dell'integrità, se il "tal" programma è realmente quello che l'utente ha scelto di installare ed eseguire, né sarebbe possibile continuare lo sviluppo di quei programmi (o l'utilizzo di quelle periferiche) ritenuti obsoleti e non più supportati dalle fonti ufficiali.