Dalla A alla X passando per C:
un distillato del linguaggio C.

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.

Concetti di base

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.

Sintassi spicciola

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.

Tipi di dati

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

Strutture dati

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};

Funzioni

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.

Il preprocessore

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
L'errore si manifesta per esempio in "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 "#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

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 <stdio.h> per lavorare con i file, <string.h> per poter chiamare le funzioni relative alle stringhe (lunghezza, confronto, sottostringa, ...) e mille altri header.

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 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.

Quando si usano librerie aggiuntive, come per esempio 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

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 è «ld».

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.

Approfondimenti

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.

Riferimenti esterni

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


Alessandro Rubini
Last modified: Novembre 2009