Informatique

Le langage C, ses extensions et ses applications

Né en 1972 pour écrire le système Unix, le C est toujours le langage dans lequel sont écrits les noyaux des systèmes, les interpréteurs des autres langages et les programmes des objets embarqués. Cinq planches pour comprendre pourquoi : il parle presque directement à la machine.

Un langage « de haut niveau » très proche de la machine

En 1969, aux laboratoires Bell, Ken Thompson écrit Unix en assembleur, puis invente un petit langage, B. Dennis Ritchie le transforme en 1972 : il lui ajoute des types (entier, caractère, nombre à virgule) et des structures, et l'appelle C. En 1973, le noyau d'Unix est réécrit en C : pour la première fois, un système d'exploitation peut être transporté d'un ordinateur à un autre en recompilant son code. Le livre de Kernighan et Ritchie (1978), puis les normes ANSI (1989) et ISO (C99, C11, C17, C23), fixent le langage.

Sa philosophie tient en une phrase : faire confiance au programmeur. Le C donne accès aux adresses de la mémoire, ne vérifie presque rien à l'exécution, et produit un code très rapide. C'est sa force et sa difficulté.

FamilleLangage ou extensionAnnéeCe qu'il ajoute au C
ObjetsObjective-C1984Les messages entre objets de Smalltalk ; langage historique d'Apple.
C++ (Bjarne Stroustrup)1985Classes, héritage, fonctions virtuelles, modèles génériques ; presque tout programme C reste valide.
ParallélismeOpenMP1997Des directives #pragma qui répartissent une boucle sur plusieurs cœurs.
CUDA C (Nvidia)2007Des fonctions exécutées par des milliers de fils sur la carte graphique.
OpenCL2009La même idée, pour des processeurs de toutes marques.
Héritiers de la syntaxeJava, C#, JavaScript, Go, Rust…1995 –Accolades, point-virgule, for (i = 0; i < n; i++) : la grammaire du C est devenue une langue commune.
Planche 1

De la source à l'exécutable : les quatre étapes de la compilation

Un programme C passe par quatre traducteurs successifs. Le préprocesseur recopie les fichiers inclus et remplace les macros, comme un traitement de texte. Le compilateur traduit le C en assembleur. L'assembleur le traduit en octets. L'éditeur de liens réunit ces octets avec ceux des bibliothèques (ici, la fonction printf) pour former un fichier exécutable. Tout ce qui suit a été réellement produit par le compilateur GCC 13.

La chaîne de compilation. Touchez une étape (ou utilisez les boutons) pour voir le texte qu'elle produit.

—

Taille à cette étape (mesurée sur les vrais fichiers)
—

Le piège des macros

La ligne #define CARRE(x) x * x n'est pas une fonction : le préprocesseur recopie le texte sans parenthèses. CARRE(n + 1) devient n + 1 * n + 1, que la priorité des opérations lit comme n + n + 1.

10
CARRE(n + 1) avec la macro naïve
—
((n + 1) * (n + 1)), macro bien parenthésée
—

Faire tourner le vrai code machine

Le petit simulateur ci-dessous exécute, instruction par instruction, les 15 instructions x86 produites par GCC pour somme_carres : les registres edi (n), eax (le compteur i), ecx et edx (la somme s).

5
4
    Les quatre registres utilisés. Celui que l'instruction vient de modifier est en rouge.
    Instructions exécutées
    —
    Nombre prévu : 6 n + 7 (n ≥ 1)
    —
    Résultat rendu dans eax
    —
    Formule n(n + 1)(2n + 1) / 6
    —
    Ce qu'il faut retenir. Le programme de 16 lignes devient 829 lignes après le préprocesseur (l'en-tête stdio.h est recopié en entier), puis 15 instructions et 41 octets de code machine pour la fonction, et enfin un exécutable de 16 008 octets, presque tout entier occupé par la mécanique de démarrage et les liens vers la bibliothèque. Le compilateur a transformé la boucle for en un test, un saut et six instructions répétées n fois : la boucle C se lit presque ligne à ligne dans le code machine. C'est ce qu'on appelle un langage « proche de la machine ». Quant à la macro, elle affiche 21 au lieu de 121 pour n = 10 : une raison pour laquelle le C moderne préfère les fonctions inline.
    Planche 2

    La mémoire telle que C la voit : octets, adresses et pointeurs

    Pour le C, la mémoire est une longue suite d'octets numérotés : le numéro d'un octet est son adresse. Une variable occupe quelques octets consécutifs : 4 pour un int, 1 pour un char. Un pointeur est une variable qui contient une adresse. L'opérateur & donne l'adresse d'une variable, l'opérateur * va lire ou écrire à l'adresse contenue dans un pointeur. Suivez ce petit programme ligne par ligne (machine à adresses de 32 bits).

      —

      Les 32 octets utilisés, en hexadécimal, huit par ligne. Chaque variable a sa couleur ; la flèche rouge part du pointeur p vers l'octet dont il contient l'adresse.
      Valeur de a
      —
      Octets de a, adresse croissante
      —
      Contrôle : octet₀ + 256·octet₁ + 256²·octet₂ + 256³·octet₃
      —
      Contenu de p (une adresse) et *p
      —
      Tableau t
      —
      Ce qu'il faut retenir. 258 s'écrit 02 01 00 00 en mémoire : le processeur range d'abord l'octet de poids faible (on dit qu'il est « petit-boutiste »). Après p = t, le pointeur désigne le premier élément du tableau : en C, un tableau et l'adresse de son premier élément se confondent. Surtout, p + 2 n'ajoute pas 2 à l'adresse mais 2 × 4 = 8, la taille de deux int : c'est l'arithmétique des pointeurs, et t[2] n'est qu'une autre écriture de *(t + 2). Cet accès direct aux adresses rend le C irremplaçable pour piloter un circuit, dont les registres sont à des adresses fixes ; il impose aussi au programmeur de ne jamais sortir des zones qui lui appartiennent, car le langage ne le vérifie pas.
      Planche 3

      L'allocation dynamique : malloc, free et la liste chaînée

      Quand on ne sait pas d'avance combien de données on aura, on demande de la mémoire au fur et à mesure dans une zone appelée le tas, avec la fonction malloc, et on la rend avec free. Une liste chaînée en est l'exemple type : chaque maillon contient une valeur et l'adresse du maillon suivant. En C, rien ne rend la mémoire automatiquement : un maillon oublié reste réservé jusqu'à la fin du programme, c'est une fuite de mémoire.

      struct maillon { int valeur; struct maillon *suivant; };   /* 4 + 4 = 8 octets */
      
      struct maillon *m = malloc(sizeof *m);   /* demander 8 octets au tas */
      m->valeur = v;  m->suivant = tete;  tete = m;   /* ajouter en tête */
      7
      En haut, la liste telle qu'on se la représente ; en bas, le tas tel qu'il est : 32 blocs de 8 octets à partir de l'adresse 0x4000. Un maillon de la liste occupe un bloc ; une fuite apparaît en orange hachuré.
      Maillons accessibles depuis tete
      —
      Octets réservés dans le tas
      —
      Octets perdus (fuite)
      —
      Plus grand bloc libre d'un seul tenant
      —
      Somme obtenue en suivant les pointeurs
      —
      Contrôle : somme des valeurs enregistrées
      —
      Ce qu'il faut retenir. Les maillons voisins dans la liste ne le sont pas forcément dans le tas : après quelques ajouts et retraits, ils sont éparpillés, et seul le champ suivant permet de les retrouver. Retirez des maillons au milieu de nombreux ajouts : la mémoire libre se morcelle (la fragmentation). Et si l'on perd le pointeur de tête sans appeler free, toute la chaîne reste réservée mais devient inaccessible. Les langages à ramasse-miettes (Lisp, Java, Python) récupèrent ces blocs automatiquement ; C++ (avec ses destructeurs) et Rust (avec son système de propriété) les libèrent au bon moment sans ramasse-miettes.
      Planche 4

      Les extensions : des objets au calcul parallèle

      Le C a été étendu dans deux directions. Vers les objets d'abord : C++ ajoute des classes, et ce qu'il ajoute peut s'écrire en C pur avec des pointeurs de fonctions, c'est d'ailleurs ainsi que fonctionnait le premier compilateur C++, qui produisait du C. Vers le parallélisme ensuite : quand les processeurs ont cessé d'accélérer vers 2005, il a fallu répartir les calculs sur plusieurs cœurs, puis sur les milliers de petits processeurs des cartes graphiques.

      // C++ (1985)
      struct Forme {
          virtual double aire() = 0;
      };
      struct Cercle : Forme {
          double r;
          double aire() { return 3.14159265 * r * r; }
      };
      Forme *f = new Cercle{2.0};
      f->aire();         // quelle fonction ?
      /* ce que le compilateur en fait, en C */
      struct vtable { double (*aire)(void *); };
      struct Cercle { struct vtable *vptr; double r; };
      
      double aire_cercle(void *o) {
          struct Cercle *c = o;
          return 3.14159265 * c->r * c->r;
      }
      /* f->aire()  devient : */
      f->vptr->aire(f);
      2
      L'objet en mémoire commence par un pointeur caché, vptr, vers la table virtuelle de sa classe ; la table contient l'adresse de la bonne fonction. L'appel suit ces deux flèches.
      Étape de l'appel
      —
      Fonction atteinte
      —
      Aire calculée par l'appel
      —
      Contrôle : formule de géométrie
      —
      /* OpenMP (1997) : une seule ligne ajoutée suffit à répartir la boucle */
      #pragma omp parallel for reduction(+:s)
      for (int i = 0; i < N; i++)
          s += f(i);
      4
      10 %
      Chronogramme : une ligne par cœur. Gris : la partie qui ne se découpe pas (lecture des données, initialisation). Bleu : les 240 tâches de la boucle réparties entre les cœurs.
      Accélération mesurée par la simulation (points) et loi d'Amdahl (courbe), en fonction du nombre de cœurs. La droite pointillée est l'accélération idéale.
      Durée sur 1 cœur / sur k cœurs (unités de temps)
      —
      Accélération mesurée
      —
      Loi d'Amdahl : 1 / (s + (1 − s) / k)
      —
      Plafond quand k devient infini : 1 / s
      —
      Ce qu'il faut retenir. Une fonction virtuelle coûte deux lectures de mémoire de plus qu'un appel ordinaire : c'est le prix, très faible, de la souplesse des objets. Pour le parallélisme, la loi énoncée par Gene Amdahl en 1967 fixe une limite sévère : avec 10 % de travail non découpable, mille cœurs ne donneront jamais plus qu'une accélération de 10. Quand le nombre de tâches ne se divise pas exactement par le nombre de cœurs, certains cœurs attendent les autres, et la mesure tombe un peu sous la formule. CUDA (2007) pousse l'idée à l'extrême : un même petit programme C est exécuté par des milliers de fils sur la carte graphique, ce qui fait aujourd'hui tourner le calcul scientifique et l'apprentissage des réseaux de neurones.
      Planche 5 · signature

      Un vrai programme C, compilé, qui tourne dans votre navigateur

      Depuis 2017, les navigateurs savent exécuter le WebAssembly, un code machine portable conçu pour recevoir des programmes compilés depuis le C, le C++ ou Rust. Le programme ci-dessous, le crible d'Ératosthène, a été compilé par Clang 18 en un module WebAssembly de 672 octets, inclus tel quel dans cette page. Il compte les nombres premiers jusqu'à n ; la même fonction, réécrite en JavaScript, sert de témoin.

      
          
      200
      15
      Chaque case est un octet du tableau t. Le nombre i en cours est en rouge ; ses multiples, à partir de i², sont barrés (t[j] = 0). Les cases restées vertes sont les nombres premiers.
      i en cours / affectations t[j] = 0 faites
      —
      Nombres premiers trouvés
      —
      Contrôle : même calcul par le module C compilé
      —
      7
      π(n), nombre de nombres premiers jusqu'à n, trouvé par le programme C (points rouges), comparé à n / ln n (courbe grise), en échelles logarithmiques.

      Extrait du code WebAssembly produit

      π(n) calculé par le C compilé
      —
      π(n) calculé par le JavaScript
      —
      Valeur de référence (tables)
      —
      Durées mesurées ici : C compilé / JavaScript
      —
      π(n) / (n / ln n)
      —
      Ce qu'il faut retenir. Les mêmes quelques lignes de C, écrites une fois, peuvent être compilées pour un processeur x86, pour un microcontrôleur de quelques kilo-octets ou, comme ici, pour une machine virtuelle de navigateur : c'est la portabilité qui avait fait choisir le C pour Unix. Les deux versions trouvent 664 579 nombres premiers jusqu'à dix millions, la valeur des tables. Les moteurs JavaScript actuels compilent eux aussi leur code à la volée, et l'écart de vitesse varie selon le navigateur ; mais le module C a un avantage décisif, il est petit, prévisible, et réutilise des bibliothèques C existantes. C'est ainsi que des logiciels entiers écrits en C, comme la base de données SQLite, tournent aujourd'hui dans des pages web.

      Où trouve-t-on du C aujourd'hui ?

      Systèmes

      Les noyaux d'Unix, de Linux, et une large part de ceux de Windows et de macOS ; les pilotes de périphériques et les micrologiciels de démarrage.

      Langages

      L'interpréteur officiel de Python (CPython), ceux de Lua, de Ruby et de PHP, et les machines virtuelles de nombreux autres langages sont écrits en C.

      Bibliothèques

      SQLite, la base de données la plus répandue, zlib pour la compression, OpenSSL pour le chiffrement, Git pour les versions : du C, appelé depuis tous les langages.

      Objets embarqués

      Microcontrôleurs des voitures, appareils ménagers, capteurs, satellites : là où la mémoire se compte en kilo-octets, le C reste la langue d'usage.

      Frise des jalons

      En résumé

      Compiler

      Quatre étapes transforment un texte en octets ; le C se lit presque mot à mot dans le code machine produit.

      Adresser

      Pointeurs et arithmétique des pointeurs donnent un accès direct aux octets de la mémoire, sans filet.

      Gérer

      malloc et free rendent le programmeur responsable de la mémoire : souplesse, mais risque de fuites.

      Porter

      Écrit une fois, le C se compile pour tous les processeurs, jusqu'au WebAssembly des navigateurs.

      Pour aller plus loin

      Les textes de la planche 1 sont la sortie réelle de GCC 13.3 (options -O1) sur une machine x86-64 sous Linux ; le module WebAssembly de la planche 5 a été produit par Clang 18 (options -O2) et est inclus en base 64 dans le code de cette page.