File Sorgenti e Processo di Compilazione in Linguaggio C
Un programma scritto in linguaggio C può essere composto da centinaia o migliaia di linee di codice.
Nel caso di programmi semplici, scrivere tutto il codice in un unico file sorgente può andare bene. Ma all'aumentare della complessità conviene suddividere il programma in più file.
I compilatori per il linguaggio C permettono di suddividere un programma in più file sorgenti. Ma per comprendere appieno come si può sfruttare la suddivisione è necessario approfondire il processo di compilazione.
In questa lezione partiremo dal vedere come funziona il processo di compilazione per un programma composto da un singolo file. Poi approfondiremo il processo per programmi composti da più file sorgenti.
Linguaggio C e File Sorgenti
Finora, quando abbiamo realizzato i nostri programmi in linguaggio C nel corso di questa guida, abbiamo sempre scritto tutto il codice all'interno di un unico file sorgente.
Abbiamo, infatti, sempre scritto file sorgenti che contenevano sia il codice principale del programma, all'interno della funzione main
, sia tutte le altre funzioni ausiliarie che il programma utilizzava.
Questo approccio può andar bene per programmi semplici, ma diventa rapidamente insostenibile quando il programma diventa più complesso.
Non è raro, infatti, che programmi complessi contengano migliaia di righe di codice, suddivise in decine o centinaia di funzioni. Realizzare tali programmi in un unico file sorgente diventa impraticabile.
Per questo motivo, il linguaggio C permette di suddividere un programma in più file sorgenti, ognuno dei quali contiene una parte del codice del programma.
L'approccio di suddividere un programma in più file sorgenti ha una serie di vantaggi:
-
Modularità: il programma è diviso in moduli indipendenti, ognuno dei quali svolge un compito specifico. Questo rende il programma più facile da comprendere e da manutenere.
Ad esempio, supponiamo di voler realizzare un programma che legge un file in input contenente dei dati che devono essere elaborati per poi salvare il risultato in un file di output.
Possiamo suddividere il programma in tre moduli:
- Un primo modulo che contiene il codice per leggere i dati in input e scrivere i risultati in output;
- Un secondo modulo che contiene il codice per elaborare i dati;
- Un terzo modulo che contiene il programma principale che mette insieme i due moduli precedenti.
-
Riusabilità: i moduli possono essere riutilizzati in altri programmi.
Ritornando all'esempio di sopra, il modulo che contiene il codice per elaborare i dati in ingresso al programma può essere riutilizzato in altri programmi che necessitano di elaborare dati in modo simile.
-
Parallelizzazione: i moduli possono essere sviluppati in parallelo da più programmatori.
Un programma C può essere suddiviso in più file sorgenti
Un qualunque programma C può essere scomposto in più file sorgenti, file con estensione .c
, ognuno dei quali contiene una parte del codice del programma.
L'unica limitazione è che il codice, quindi la definizione, di una funzione deve essere contenuto in un solo file sorgente.
Per poter suddividere un programma in più file sorgenti, è necessario risolvere una serie di problemi fondamentali. Prima di addentrarci, però, in questi problemi è necessario rivedere il processo di compilazione di un programma in linguaggio C.
Processo di Compilazione rivisitato
Nelle precedenti lezioni abbiamo accennato al processo di compilazione di un programma in linguaggio C. Nell'ottica di comprendere come funziona la suddivisione di un programma in più file sorgenti, è necessario approfondire questo processo.
Partiamo prima dal caso in cui il nostro programma sia composto da un unico file .c
.
In tal caso il processo di compilazione si compone di tre passi fondamentali:
-
Precompilazione o Preprocessing:
Il preprocessore prende in ingresso il file sorgente ed elabora le direttive di precompilazione, come le direttive
#include
e#define
.L'output di questa fase è un file sorgente modificato in cui le direttive sono assenti.
-
Compilazione:
Il compilatore prende in ingresso il file sorgente precompilato e genera un file oggetto, contenente il codice macchina relativo al file sorgente.
Un dettaglio fondamentale è che il risultato del passo di compilazione non è, ancora, un file eseguibile. Il file oggetto contiene solo il codice macchina relativo al file sorgente. Da esso mancano tutte le informazioni ed il codice relativo alle funzioni standard della libreria standard del C, nonché il codice relativo alle funzioni definite in altri file sorgenti.
Ad esempio, se nel nostro file sorgente utilizziamo la funzione
printf
, il file oggetto non conterrà il codice macchina relativo alla funzioneprintf
. Esso conterrà esclusivamente un riferimento ad essa. -
Collegamento o Linking:
Il file oggetto risultante dal passo precedente viene dato in ingresso ad un altro componente che prende il nome di linker o collegatore.
Lo scopo di questo programma è quello di prendere il file oggetto e risolvere i riferimenti alle funzioni non presenti in esso. In pratica, il linker cerca tutti quei riferimenti presenti nel file oggetto a funzioni, ed altre entità, non definite nel file oggetto stesso.
Una volta trovati questi richiami, il linker cerca le definizioni di queste entità in una serie di librerie predefinite e le collega al file oggetto.
Un caso tipico è quello della libreria standard del C. Se il nostro file sorgente utilizza la funzione
printf
, il linker cerca la definizione di questa funzione nella libreria standard del C e la collega al file oggetto.Il risultato è, finalmente, un file eseguibile che contiene tutto il codice necessario per eseguire il programma.
Processo di Compilazione in Linguaggio C
Il processo di compilazione di un programma in linguaggio C si compone di tre passi fondamentali:
- Precompilazione o Preprocessing: il preprocessore elabora le direttive di precompilazione presenti nel file sorgente e genera un nuovo file.
- Compilazione: il compilatore prende in ingresso il file sorgente precompilato e genera un file oggetto contenente il codice macchina relativo al file sorgente.
- Collegamento o Linking: il linker prende in ingresso il file oggetto e risolve i riferimenti a funzioni e variabili non definite nel file oggetto, collegando queste entità al file oggetto.
Esempio di compilazione con gcc
Per comprendere appieno tutto il processo, proviamo, usando il compilatore open source gcc
e il sistema operativo Linux, a compilare un programma in linguaggio C composto da un unico file sorgente.
Supponiamo di avere il seguente file sorgente, chiamato hello.c
:
/* hello.c */
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
Normalmente, con gcc
, compiliamo il programma con un unico comando:
$ gcc hello.c -o hello
Il compilatore gcc
è molto avanzato e in automatico esegue tutti e tre i passi del processo di compilazione, invocando il preprocessore, il compilatore e il linker.
Tuttavia possiamo invocare esplicitamente i tre passi del processo di compilazione, per capire meglio cosa succede.
Per prima cosa, eseguiamo il preprocessore sul file sorgente hello.c
. Il preprocessore vero e proprio si chiama cpp
, da c pre-processor. Tuttavia, gcc
permette di invocare il preprocessore direttamente con l'opzione -E
:
$ gcc -E hello.c -o hello.i
Come si può vedere, l'opzione -E
dice al compilatore di eseguire solo il preprocessore. L'output di questa fase è un file sorgente modificato, privo di direttive di precompilazione.
Il file hello.i
generato sarà molto più grande del file sorgente originale, poiché conterrà tutto il codice contenuto nei file header inclusi con la direttiva #include
.
A questo punto, possiamo compilare il file sorgente precompilato hello.i
:
$ gcc -c hello.i -o hello.o
L'opzione -c
dice al compilatore di eseguire solo la fase di compilazione. L'output di questa fase è un file oggetto, contenente il codice macchina relativo al file sorgente.
il file hello.o
non è più un file di testo, come nel caso del file sorgente, ma un file binario che contiene il codice macchina parziale del nostro programma.
Possiamo esaminare il file oggetto hello.o
con l'utility nm
che permette di visualizzare i simboli presenti nel file oggetto. Un simbolo è, in sostanza, un'etichetta assegnata ad un'entità del programma, come una variabile o una funzione.
$ nm hello.o
Il risultato del comando sarà qualcosa di simile a:
0000000000000000 T main
U printf
L'output ci dice due cose fondamentali:
- Nel nostro file oggetto è presente un simbolo
main
, che corrisponde alla funzionemain
del nostro programma. Questo simbolo corrisponde a del codice macchina vero e proprio come indicato dalla letteraT
(Text) che precede il simbolo. - Nel file è inoltre presente un simbolo
printf
, che corrisponde alla funzioneprintf
utilizzata nel nostro programma. Tuttavia, questo simbolo è preceduto dalla letteraU
(Undefined), che indica che il simbolo è non definito nel file oggetto. Questo significa che il codice macchina relativo alla funzioneprintf
non è presente nel file oggetto.
Quindi, il file oggetto hello.o
non contiene tutte le informazioni necessarie per eseguire il programma. Manca un pezzo fondamentale: il codice macchina relativo alla funzione printf
.
Per risolvere questo problema, dobbiamo collegare il file oggetto hello.o
con la libreria standard del C, che contiene la definizione della funzione printf
.
Sebbene il collegatore vero e proprio sia ld
, invocare direttamente ld
è molto complesso. Per questo motivo, usiamo comunque gcc
come interfaccia per invocare il linker.
$ gcc hello.o -o hello
In tal caso, gcc
si accorge che il file hello.o
è un file oggetto e invoca il linker per collegare il file oggetto con la libreria standard del C.
Il risultato è un file eseguibile hello
che possiamo eseguire con il comando:
$ ./hello
Se tutto è andato a buon fine, vedremo stampato a video il messaggio Hello, World!
.
Ricapitolando:
Processo di Compilazione di un Programma usando gcc
Il processo di compilazione di un programma in linguaggio C usando il compilatore gcc
può essere eseguito usando un singolo comando:
$ gcc nome_file.c -o nome_file
Oppure, scomponendo il processo in tre passi:
-
Precompilazione:
$ gcc -E nome_file.c -o nome_file.i
-
Compilazione:
$ gcc -c nome_file.i -o nome_file.o
-
Collegamento:
$ gcc nome_file.o -o nome_file
Le operazioni di precompilazione e compilazione possono essere eseguite in un unico passaggio con il comando:
$ gcc -c nome_file.c -o nome_file.o
Compilazione di file sorgenti multipli
Il processo di compilazione di un programma composto da un singolo file sorgente è abbastanza lineare, come abbiamo visto.
Quando il programma è composto da più file sorgenti, il processo di compilazione diventa più complesso.
Studiamo un altro esempio adoperando, come prima, il compilatore gcc
e il sistema operativo Linux.
Supponiamo di voler scrivere un semplice programma che calcoli l'area di un cerchio. Supponiamo, inoltre, di suddividere il programma in due file sorgenti:
main.c
che conterrà la funzionemain
, ossia l'entry point del programma, e che conterrà il codice per leggere il raggio del cerchio da tastiera e per stampare l'area del cerchio risultante;circle.c
che conterrà la definizione della funzionearea_cerchio
che calcola l'area del cerchio.
Possiamo scrivere i due file sorgenti in questo modo:
/* main.c */
#include <stdio.h>
int main() {
float raggio;
float area;
printf("Inserisci il raggio del cerchio: ");
scanf("%f", &raggio);
area = area_cerchio(raggio);
printf("L'area del cerchio di raggio %.2f è %.2f\n", raggio, area);
return 0;
}
/* circle.c */
float area_cerchio(float raggio) {
return 3.14159 * raggio * raggio;
}
Adesso, dobbiamo compilare i due file sorgenti.
Partiamo dal file circle.c
. Precompiliamo e compiliamo il file circle.c
in un unico passaggio:
$ gcc -c circle.c -o circle.o
Il risultato sarà un file oggetto circle.o
che contiene il codice macchina relativo alla funzione area_cerchio
. Infatti, se analizziamo il file oggetto con nm
, vedremo il simbolo area_cerchio
definito nel file oggetto:
$ nm circle.o
0000000000000000 T area_cerchio
Come si può osservare, il file oggetto circle.o
contiene esclusivamente il codice macchina relativo alla funzione area_cerchio
.
A questo punto, dobbiamo compilare il file main.c
. Tuttavia, il file main.c
contiene una chiamata alla funzione area_cerchio
definita nel file circle.c
.
Se proviamo a compilare main.c
così com'è otteniamo un errore:
$ gcc -c main.c -o main.o
main.c: In function ‘main’:
main.c:11:12: error: implicit declaration of function ‘area_cerchio’ [-Wimplicit-function-declaration]
11 | area = area_cerchio(raggio);
| ^~~~~~~~~~~~
Infatti, ci troviamo in presenza di una funzione implicita nel file main.c
. Il compilatore ci sta dicendo che main.c
chiama una funzione area_cerchio
di cui non sa nulla.
In particolare, al compilatore non serve sapere come la funzione sia fatta internamente, ma gli serve conoscere quali e quanti parametri accetta e quale tipo di valore restituisce.
Del resto, per generare un file oggetto, al compilatore non serve conoscere il codice sorgente della funzione area_cerchio
, ma solo la sua dichiarazione. Questo concetto è molto importante:
File oggetto e Dichiarazioni di Funzioni
In fase di compilazione, per poter generare un file oggetto, il compilatore non ha bisogno di conoscere il codice sorgente di una funzione, ma solo la sua firma e il tipo del valore restituito.
Il codice macchina della funzione servirà solo in fase di collegamento, quando il linker cercherà la definizione della funzione in una libreria o in un altro file oggetto.
Solo al linker serve conoscere il codice sorgente della funzione area_cerchio
, per poterla collegare al file oggetto main.o
.
Come possiamo, allora, risolvere questo problema?
Una prima soluzione naïve è quella di aggiungere il prototipo della funzione area_cerchio
all'inizio del file main.c
:
/* main.c */
#include <stdio.h>
float area_cerchio(float raggio);
int main() {
float raggio;
float area;
printf("Inserisci il raggio del cerchio: ");
scanf("%f", &raggio);
area = area_cerchio(raggio);
printf("L'area del cerchio di raggio %.2f è %.2f\n", raggio, area);
return 0;
}
Modificando il file main.c
in questo modo e provando a ricompilare il programma, otteniamo:
$ gcc -c main.c -o main.o
In tal caso il compilatore non si lamenta più della mancanza di una dichiarazione della funzione area_cerchio
e genera il file oggetto main.o
.
Proviamo ad analizzare il file oggetto main.o
con nm
:
$ nm main.o
0000000000000000 T main
U area_cerchio
U printf
U scanf
Possiamo osservare che nel file oggetto ci sono:
- Il simbolo
main
, che corrisponde alla funzionemain
del nostro programma; Tale simbolo corrisponde a del codice macchina vero e proprio come indicato dalla letteraT
(Text) che precede il simbolo. - Tre riferimenti a simboli non presenti nel file oggetto,
area_cerchio
,printf
escanf
. Questi simboli sono preceduti dalla letteraU
(Undefined), che indica che i simboli non sono definiti nel file oggetto.
A questo punto, possiamo collegare i due file oggetto main.o
e circle.o
per ottenere il file eseguibile:
$ gcc main.o circle.o -o area_cerchio
Il risultato è un file eseguibile area_cerchio
che possiamo eseguire con il comando:
$ ./area_cerchio
In pratica, quando si ha a che fare con programmi composti da più file sorgenti, solo i passi di precompilazione e compilazione sono eseguiti per ogni file sorgente. Il passo di collegamento è eseguito solo una volta, alla fine, per collegare tutti i file oggetto tra loro.
Ricapitolando:
Compilazione di Programmi con più File Sorgenti usando gcc
Per compilare un programma composto da più file sorgenti, dobbiamo seguire questi passi:
-
Per ogni file sorgente
.c
:Si compila il file ottenendo il file oggetto
.o
corrispondente:$ gcc -c nome_file.c -o nome_file.o
-
Collegamento dei file oggetto:
Una volta ottenuti tutti i file oggetto, si collegano tra loro per ottenere il file eseguibile:
$ gcc nome_file1.o nome_file2.o ... -o nome_file
Questi due passaggi possono essere eseguiti in un unico comando:
$ gcc nome_file1.c nome_file2.c ... -o nome_file
Problematiche relative alla compilazione di file sorgenti multipli
Il modo in cui abbiamo compilato il programma dell'esempio di sopra, area_cerchio
, ha funzionato ma abbiamo dovuto apportare delle modifiche al file main.c
.
In particolare, abbiamo dovuto aggiungere il prototipo della funzione area_cerchio
all'inizio del file main.c
.
Questo approccio presenta dei problemi.
Supponiamo che vogliamo cambiare la funzione area_cerchio
in modo che, anziché lavorare con dei float
, lavori con dei double
:
/* circle.c */
double area_cerchio(double raggio) {
return 3.14159 * raggio * raggio;
}
Facendo così, dobbiamo poi modificare il prototipo della funzione area_cerchio
all'inizio del file main.c
, più il resto del codice che fa uso della funzione area_cerchio
.
/* main.c */
#include <stdio.h>
double area_cerchio(double raggio);
int main() {
double raggio;
double area;
printf("Inserisci il raggio del cerchio: ");
scanf("%lf", &raggio);
area = area_cerchio(raggio);
printf("L'area del cerchio di raggio %.2f è %.2f\n", raggio, area);
return 0;
}
Il problema di questo approccio è che è poco scalabile.
Infatti, in questo caso c'è solo un file sorgente che chiama la funzione area_cerchio
. Se ci fossero più file sorgenti che chiamano la funzione area_cerchio
, dovremmo modificare il prototipo della funzione in tutti i file sorgenti che la chiamano.
La seconda problematica riguarda il fatto che il file circle.c
contiene una sola funzione, area_cerchio
. Se il programma diventa più complesso e il file circle.c
contiene più funzioni, dobbiamo modificare tutti i protoipi delle funzioni nei file sorgenti che le adoperano.
Si innesca un procedimento a cascata che può provocare errori e comportare un notevole dispendio di tempo.
Per questo motivo, il linguaggio C mette a disposizione un meccanismo che permette di risolvere questi problemi: i file header.
Attraverso i file header o file di intestazione si risolve il problema di condividere le dichiarazioni delle funzioni e delle variabili tra più file sorgenti. Studieremo i file header nella prossima lezione.
In Sintesi
In questa lezione abbiamo imparato che:
- Un programma scritto in linguaggio C può essere composto da più file sorgenti. Questo permette di suddividere il programma in moduli indipendenti, rendendolo più facile da comprendere e da manutenere.
- Il processo di compilazione di un programma in linguaggio C si compone di tre passi fondamentali: precompilazione, compilazione e collegamento.
- Il passo di precompilazione è eseguito dal preprocessore, il passo di compilazione dal compilatore e il passo di collegamento dal linker.
- Il processo di compilazione di un programma composto da più file sorgenti prevede che per ogni file sorgente si eseguano i passi di precompilazione e compilazione. Il passo di collegamento è eseguito solo una volta, alla fine, per collegare tutti i file oggetto tra loro.
- Il compilatore ha bisogno di conoscere solo la firma delle funzioni, non il loro codice sorgente, per generare un file oggetto.
- I file header permettono di condividere le dichiarazioni delle funzioni e delle variabili tra più file sorgenti, risolvendo i problemi di scalabilità e manutenibilità dei programmi composti da più file sorgenti.
Nella prossima lezione inizieremo lo studio approfondito dei file header che ci semplificano la scrittura di programmi multi-sorgente.