Questo documento breve (relativamente) e sgangherato vuole essere una veloce introduzione al linguaggio C per studenti che conoscono già altri linguaggi e devono mettere le mani in un sistema operativo. La scusa per la sua stesura è il corso di «Sitemi real-time» che mi è stato affidato presso l'Università di Pavia nell'anno accademico 2006-2007.
Il linguaggio in se è molto piccolo, quindi questo documento copre quasi tutto, anche se in maniera abbastanza concisa. Non è quindi "dalla A alla Z", ma nemmeno solo "dalla A alla C" come pensavo inizialmente.
Per mantenere il testo compatto e non replicare inutilmente trattazioni più complete e organiche, non esiterò a riferirmi a questioni inerenti a Unix e GNU/Linux per l'integrazione del codice del sistema e al gcc per le specificità di compilazione. Allo stesso modo, non mi trattengo dall'usare mezze-bugie per semplificare l'esposizione degli argomenti.
Il linguaggio C è molto vicino alla macchina. È stato pensato come sostituto dell'assembler per aumentare la portabilità dei programmi; la traduzione in codice macchina risulta molto diretta e le tecniche di ottimizzazione del codice sono ben sviluppate. A causa della vicinanza al codice macchina, è il linguaggio più usato per la scrittura dei nuclei di sistema operativo e altre operazioni di basso livello.
Gli «oggetti» trattati dal linguaggio sono tutti oggetti semplici. In pratica sono tutti numeri interi, preferenzialmente della dimensione dei registri del processore in uso. Non esiste il concetto di «oggetto», di «classe» di «istanza» e tutte le altre belle cose che vanno di moda oggi, anche se è possibile usare uno stile di programmazione orientata agli oggetti nella stesura dei propri programmi -- ed è sempre meglio farlo.
Non esiste nel linguaggio il tipo «boolean»: se un valore è zero viene considerato falso, se è diverso da zero viene considerato vero. Ogni definizione di tipi booleani nel linguaggio è artificiosa e secondo me da evitarsi come inutile e dannosa.
Il C è un linguaggio procedurale, ogni programma è perciò espresso come una sequenza di procedure che vengono dette funzioni. Ogni funzione è visibile globalmente in tutto l'applicativo, riceve un certo numero di argomenti e ritorna un solo valore oppure nessuno.
Le variabili possono essere globali o locali ad una funzione. I tipi sono semplici (numeri interi) o composti (strutture dati). Un puntatore è l'indirizzo in memoria di un altro dato o di una funzione. Il «puntatore nullo» corrisponde all'indirizzo zero e non è mai un puntatore valido.
Gli identificatori (nomi di funzioni e variabili) sono composti da
lettere, cifre e sottolineature («underscore»), il primo carattere
deve essere una lettera. Maiuscole e minuscole sono differenti;
personalmente sconsiglio fortemente l'uso di lettere maiuscole (per esempio
nomi come SortArrayOfNames
), che
rallentano la scrittura (su tastiera) e la lettura (ad alta voce) dei
programmi.
Il compilatore legge il codice sorgente una volta sola, quindi in certi casi occorre dichiarare una variabile o una funzione, oltre a definirla. Per esempio, se una funzione ne chiama un'altra definita più avanti nello stesso file sorgente, occorre dichiarare in anticipo tale funzione. Le dichiarazioni delle funzioni di libreria vengono raccolte in file «header», intestazioni, che vengono inclusi all'inizio di ogni sorgente C.
Il preprocessore è un programma che opera sostituzioni tipografiche sul codice sorgente prima che tale codice venga visto dal compilatore vero e proprio ma fa parte del compilatore e delle specifiche del linguaggio; ogni sorgente C viene preprocessato.
Tutte le righe nel codice sorgente che iniziano con il carattere '#' («diesis», «cancelletto», «hash») sono direttive per il preprocessore. Tali direttive permettono di includere (fisicamente) altri file all'interno del proprio sorgente, ridefinire il significato degli identificatori (tramite sostituzione puramente tipografica nel codice sorgente), disabilitare condizionalmente parti di codice in fase di compilazione (anche qui, eliminando fisicamente il testo prima che il compilatore lo veda). Come si intuisce, si tratta di uno strumento potente ma molto pericoloso; per esempio, il compilatore non può effettuare il controllo degli errori sulle parti di codice disabilitate.
L'esecuzione inizia dalla funzione main
, che riceve
alcuni argomenti (che per ora ignoriamo) e ritorna un numero intero.
Quando main
ritorna, il programma termina: se ritorna zero
vuol dire che il programma ha avuto successo, se ritorna non-zero vuol
dire che c'è stato un errore; il numero specifico può indicare il
tipo di errore riscontrato, se chi ha eseguito questo programma sa
come differenziarli.
Nel caso di programmi a se stanti, che non girano sotto un sistema
operativo, come nel caso di kernel e boot-loader,
la funzione main
non
ha alcun ruolo particolare e potrebbe non esistere.
Le andate a capo, gli spazi e i tab sono equivalenti. Lo stile di impaginazione è quindi libero e programmatori diversi usano stili diversi. È comunque importante non abusare di questa libertà e scrivere codice ordinato e leggibile, facendo rientrare opportunamente i blocchi logici.
Una istruzione può essere un'espressione terminata da punto-e-virgola, un costrutto di controllo o un blocco delimitato da graffe. Il concetto di «espressione» include tutto, compresi gli assegnamenti a una variabile, tranne i costrutti di controllo.
I costrutti di controllo sono i seguenti (il corsivo indica un elemento sintattico, la parentesi quadra indica elementi che possono essere o meno presenti):
if ( expr ) istr [ else istr ] while ( expr ) istr for ( expr ; expr ; expr ) istr do istr while ( expr ) ; switch ( expr-intera ) { case: .... } break ; continue ; return [ expr ] ;
Il costrutto switch
è un grosso caso particolare e
merita una sezione di approfondimento, per ora trascuriamolo.
Una funzione viene definita scrivendo il tipo del valore di ritorno seguito dal nome della funzione e dalla lista degli argomenti preceduto dal loro tipo; dopo di che il codice delimitato da graffe. Una dichiarazione di funzione (un «prototipo») è come la sua definizione con il blocco di codice sostituito da un punto e virgola.
Una variabile viene definita scrivendone il tipo seguito dal nome e da punto-e-virgola. Se è dichiarata all'esterno delle funzioni è globale, se dichiarata all'interno di un blocco (cioè tra graffe) è locale a quel blocco.
Esempio: un prototipo, una funzione, una variabile globale, un'altra funzione (quella prototipizzata all'inizio):
int somma(int a, int b); int media(int x, int y) { int sum; sum = somma(x, y); return sum/2; } int globalv; int somma(int a, int b) { return a + b; }
Il preprocessore viene principalmente usato per includere altri file e definire nomi simbolici per riferirsi a dati numerici. Se il file incluso è specificato con le parentesi ad angolo viene cercato tra quelli di sistema, se è specificato con le virgolette viene cercato prima nella directory corrente. Esempio:
#include <stdio.h> #include "application.h" #define ERR_NOERROR 0 #define ERR_INVALID 1 #define ERR_NODATA 2 #define ERR_PERMISSION 3
Per una convenzione universalmente accettata, le costanti definite tramite preprocessore si scrivono in maiuscolo come mostrato qui sopra, in modo da essere subito riconoscibili leggendo il testo del programma, per non confonderle con le variabili.
I commenti sono delimitati da /*
e */
oppure si estendono da //
a fine riga. La seconda forma
viene dal C++ e non è molto apprezzata dai programmatori C. È sempre
buona norma commentare bene i propri programmi spiegando come e
perché il programma fa una certa cosa, non cosa fa, perché
il cosa sta già nel codice. Questa regola vale per tutti i linguaggi,
ma è così importante che val la pena di ripeterla.
I dati semplici sono numeri interi o in virgola mobile (che non ci
interessa e non tratteremo), o puntatori. I tipi interi predefiniti
del linguaggio sono i seguenti (normalmente signed
non si
usa perché è il comportamento predefinito):
char signed char unsigned char short signed short unsigned short int signed int unsigned int long signed long unsigned long
Sulla lunghezza di tali tipi non si possono fare assunzioni, ma
in pratica è garantito che char
sia di 8 bit.
Il tipo int
è normalmente di 32 bit, a meno che il processore
ospite non sia a 16 bit (esempio: sistema operativo DOS), ma come detto
non si possono fare assunzioni e i programmi non devono dipendere da una
particolare dimensione dei tipi base.
I puntatori sono tutti della stessa dimensione, e sono a 32 o 64 bit
a seconda del processore su cui si lavora. Su tutte le piattaforme
un unsigned long
e un puntatore hanno la stessa dimensione.
Un puntatore si definisce scrivendo il tipo cui si punta,
l'asterisco e il nome della variabile. Per esempio ``int
*p;
''. Conviene leggere il carattere asterisco come «il puntato
da»; nel caso precedente quindi si legge «è intero il [valore] puntato da p».
Il kernel Linux definisce (in <linux/types.h>
)
i seguenti tipi di dimensione e segno (unsigned
o signed
) noti:
u8 s8 u16 s16 u32 s32 u64 s64
Lo standard C99 definisce i seguenti tipi di dimensione e segno noto, il
cui uso non è ancora molto diffuso. L'ultimo tipo elencato è un
intero della stessa dimensione di un puntatore (in pratica
unsigned long
):
uint8_t int8_t uint16_t int16_t uint32_t int32_t uint64_t int64_t intptr_t
Una struttura dati è un tipo di dati composto, i componenti si chiamano campi e possono essere tipi semplici o altre strutture dati. Una struttura viene dichiarata nel seguente modo:
struct nome { tipo-campo nome-campo ; [tipo-campo nome-campo ; ... ] } ;
Dopo la dichiarazione, "struct nome
" è il nome
di un nuovo tipo che può essere usato per dichiarare variabili
e puntatori. Esempio:
int count; struct stat stbuf; struct stat *stptr;
Le strutture si possono inizializzare in tre modi
diversi. Elencando i campi separati da virgola (sintassi tradizionale),
dichiarando i campi con i due-punti (sintassi di gcc fin da prima della
standardizzazione), usando l'assegnamento ai campi (sintassi standard
C99, supportata anche dal gcc). La
prima forma è da evitarsi in quanto poco leggibile, la seconda è
sconsigliata in quanto non standard. In tutti e tre i casi,
ogni campo non esplicitamente inizializzato
viene azzerato bit-per-bit dal compilatore.
In questo esempio le tre strutture sono uguali, con il campo priv
inizializzato a zero:
struct item {int id; char *name; int value; int priv;}; struct item i1 = {3, "aldo", 45}; struct item i2 = {id: 3, name: "aldo", value: 45}; struct item i3 = {.id = 3, .name = "aldo", .value = 45};
Ogni funzione ritorna un solo valore (un tipo semplice o una
struttura dati) oppure void
(cioè nulla) e riceve
uno o più argomenti.
Gli argomenti sono tipi semplici o strutture dati e sono sempre passati per valore; possono essere modificati all'interno della funzione stessa come se fossero variabili locali.
Anche se è consentito, normalmente non vengono passate strutture dati né come argomenti né come valori di ritorno. Si preferisce invece, per ragioni di efficienza, allocare le strutture dati separatamente e passare solo i puntatori ad esse, effettuando così un passaggio per riferimento.
Se una funzione deve ritornare più di un valore (per esempio un numero intero e un codice di errore), è possibile passare un puntatore come argomento ulteriore, in modo che la funzione possa scrivere il secondo valore di ritorno in una variabile del chiamante. Esempio:
int findid(struct obj *item, int *errorptr) { if (isvalid(item) == 0) { *errorptr = ERR_INVALID; return 0; } *errprptr = ERR_NOERROR; return internal_findid(item); }
Si possono definire funzioni con numero variabile di argomenti
(«variadiche»). L'esempio piu comune è printf
con tutte
le sue varianti. Definire la propria funzione variadica richiede un
certo stomaco e non viene trattato in questa sede.
Chiamare una funzione variadica è invece molto frequente e basta
specificare correttamente tutti gli argomenti. Nel caso dei derivati
di printf
, uno dei primi argomenti è una stringa
che specifica il numero e
il tipo degli argomenti successivi. La funzione variadica usa
la stringa per sapere cosa sono gli argomenti ulteriori; data
la standardizzazione del formato della stringa, il compilatore può
controllare tutti gli argomenti passati e avvertire del possibile errore
in caso di incongruenze. Per funzioni variadiche non assimilabili
a printf
il controllo del compilatore non è previsto.
Non esiste il polimorfismo delle funzioni in C: ogni nome di funzione può essere presente una volta sola in un programma e ogni chiamata deve passare sempre lo stesso numero e tipo di argomenti. Tranne, ovviamente, per le funzioni variadiche, nel qual caso possono essere passati un numero arbitrario (anche 0) argomenti ulteriori.
Come già detto, il preprocessore serve a filtrare i sorgenti prima che vengano visti dal compilatore vero e proprio.
L'inclusione dei file di header serve a poter accedere ai prototipi delle funzioni, alle dichiarazioni delle strutture dati e delle variabili globali definite esternamente al proprio programma. Normalmente la documentazione di una funzione di libreria specifica quale header occorre includere per passare al compilatore le informazioni necessarie.
Con #define
si possono definire nomi simbolici
costanti (come nell'esempio più sopra) oppure «macro» che ricevono
degli argomenti. In ogni caso la sostituzione è puramente tipografica
e questo rende facile incorrere in errori come il seguente:
#define square(a) a*a
square(1+2)
" che diventa
"1+2*1+2
" cioè 5. Inoltre, quando un argomento di macro
appare più di una volta nell'espansione, la macro non può essere equivalente
ad una funzione perché operatori come "++
" appaiono
ripetuti nel testo effettivo del programma, con effetti non desiderati.
Il costrutto " Le librerie contengono codice e dati, cioè funzioni globali e
variabili globali. Molte funzioni usate dai programmi C sono state
standardizzate, così come i nomi degli header da includere prima di
usarle. Abbiamo quindi Non ci interessa, nell'ambito del corso di sistemi real-time,
prendere confidenza con la molteplicità di funzioni della libreria
standard. Ci basta sapere che tutte queste funzioni e le variabili
globali (come Quando si usano librerie aggiuntive, come per esempio
Il compilatore prende il codice C, lo fa elaborare tipograficamente
dal preprocessore e lo traduce in codice assembler, lo passa quindi
all'assemblatore per ottenere dei file «oggetto», contenenti codice
macchina.
Tali file oggetto contengono codice e dati unitamente ad elenchi di
«simboli» non definiti. Un simbolo, a questo livello, è solo
un nome a cui deve essere associato un indirizzo di memoria.
La risoluzione finale dei simboli non definiti viene effettuata da un
programma chiamato «linker» il cui file eseguibile è « Alcuni errori di compilazione, quindi, vengono riportati dal
linker e non dal compilatore vero e proprio; tipicamente questo
succede con errori di «undefined symbol» quando il sorgente C contiene
un nome di funzione digitato erroneamente. A seconda di quanta
informazione simbolica è presente nei file oggetto, il messaggio di
errore può riferirsi ad una riga specifica di uno specifico
file sorgente o mancare di riferimenti precisi al codice sorgente.
A differenza di quanto accade con preprocessore e assembler, può
succedere di aver bisogno di controllare direttamente il comportamento
del linker, per esempio per dire in quali librerie cercare i simboli
mancanti. Questo non succede, comunque, per i programmi più semplici.
Nel caso di programmi che non girano all'interno del sistema
operativo, come kernel e boot-loader, il linker viene istruito perché
non includa la libreria standard nella fase di risoluzione dei simboli.
Quanto detto fin'ora riassume le caratteristiche principali
del linguaggio C e dovrebbe essere sufficiente a non sentirsi
completamente spaesati quando si legge del codice ben scritto.
Ci sono comunque alcuni approfondimenti che reputo importanti e che ho
separato in un'altro documento: A-C-X-more.html.
Come libro, se si vuole prenderne uno, suggerisco il Kernighan
Ritchie, un ottimo testo. Gli altri sono solitamente pessimi, non conosco
testi di qualità intermedia.
«C for Java Programmers», http://www.cs.cornell.edu/courses/cs414/2001SP/tutorials/cforjava.htm, anche se non copre le parti che a me interessano di più per il corso di sistemi real-time e va in dettaglio su argomenti che non reputo interessanti.
Errori tipici del programmatore Java quando passa al C: http://www.dcs.ed.ac.uk/home/iok/cforjavaprogrammers.phtml.
Wikpedia: http://en.wikipedia.org/wiki/C_%28programming_language%29 e http://it.wikipedia.org/wiki/C_%28linguaggio%29.
Home page di Dennis Ritchie, con riferimenti storici su C e Unix:
http://cm.bell-labs.com/cm/cs/who/dmr/.
I loro ideatori ammettono che Unix e il linguaggio C sono una burla:
http://www.gnu.org/fun/jokes/unix-hoax.html
#ifdef X
- #else
-
#endif
" valuta solo se il simbolo X
è
definito (nel senso di #define
) o meno. Il costrutto
"#if expr -
#else
-
#endif
" valuta una espressione intera costante (il valore
deve essere noto all'atto della compilazione). In #if
oltre a numeri, simboli
definiti in precedenza e operatori interi è possibile usare la
forma "defined(X)
". Per evitare troppi livelli
condizionali e troppi #endif
si può usare #elif
con il significato di "else if".
Le librerie
<stdio.h>
per lavorare con i
file, <string.h>
per poter chiamare le funzioni
relative alle stringhe (lunghezza, confronto, sottostringa, ...) e
mille altri header.
stdin
e stdout
) sono contenute
nella «libreria C», che viene usata automaticamente dal compilatore
per risolvere i simboli non definiti nei file sorgente. Il
compilatore può disporre anche di una sua propria libreria (per
esempio libgcc
), contenente procedure chiamate dal codice
oggetto generato dal compilatore stesso; anche questa libreria viene
inclusa automaticamente durante la fase finale di compilazione.
libjpeg
, il sorgente C deve includere gli header
appropriati. Tali header, però, non sono le librerie: mentre la
struttura struct jpeg_compress_struct
è dichiarata in
<jpeglib.h>
, la funzione
jpeg_start_compress
è composta da codice macchina che
risiede in un altro file, la libreria, usato dal linker -- non dal
preprocessore.
Il linker
ld
».
Approfondimenti
Riferimenti esterni
Alessandro Rubini
Last modified: Novembre 2009