C1 — Prendre en main le langageDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Programmation en C

Cours 1Prendre en main le langage

Faire tourner un premier programme, comprendre ce que fabrique la chaîne de compilation, et savoir lire ce que le compilateur reproche.

2 chapitres · 12 h de travail estimé

  1. 1. De la source à l'exécutable5 h
  2. 2. Types, variables, opérateurs7 h

Chapitre 1 · 5 h

De la source à l'exécutable

Préprocesseur, compilation, édition de liens ; structure d'un programme et rôle de main ; lire un message d'erreur du compilateur.

Un débutant tape gcc hello.c, obtient undefined reference to printf, et cherche l'erreur dans sa syntaxe. Il ne la trouvera pas : ce message ne vient pas du compilateur. Il vient de l'éditeur de liens, un programme différent, appelé bien après, et qui reproche autre chose.

C'est le premier obstacle du cours, et il n'est pas conceptuel : gcc est un pilote qui enchaîne quatre programmes distincts, chacun avec ses erreurs propres. Savoir lequel a parlé, c'est déjà avoir résolu la moitié du problème.

Pourquoi le C

Le choix demande d'être justifié, car le C n'est ni le plus accueillant ni le plus productif des langages.

Il est le complément direct de l'architecture : un pointeur est une adresse au sens du chapitre 5, un char est un octet, un décalage de bits est l'instruction du chapitre 6. Ce que ce cours-là décrivait de l'extérieur devient ici manipulable.

Il est le langage des systèmes : le noyau Linux, les bibliothèques standard, les interpréteurs de Python et de Java sont écrits en C, et tous les appels système du cours de systèmes se présentent comme des fonctions C.

Il est enfin un contrepoint utile à Java en programmation orientée objet. Avoir géré la mémoire à la main rend le ramasse-miettes intelligible au lieu de le rendre invisible — on sait ce qu'il fait, parce qu'on l'a fait soi-même.

Il vient avec une contrepartie qu'il vaut mieux annoncer : le C ne protège de rien. Pas de vérification des bornes, pas de gestion automatique de la mémoire, et des comportements dits indéfinis où le programme peut faire n'importe quoi — y compris fonctionner. C'est le prix de la transparence, et c'est aussi ce qui rend le bloc VI indispensable.

Les quatre étages

hello.c   │  ① préprocesseur          cpp        gcc -E hello.chello.i        source pur C, sans directive, avec les en-têtes recopiés   │  ② compilation            cc1        gcc -S hello.ihello.s        assembleur — celui du chapitre 6 d'architecture   │  ③ assemblage             as         gcc -c hello.shello.o        fichier objet : code machine, mais adresses non résolues   │  ④ édition de liens       ld         gcc hello.o -o hellohello          exécutable

Le préprocesseur ne comprend pas le C : il fait de la manipulation de texte. Il recopie le contenu des fichiers désignés par #include, remplace les #define, et supprime les portions écartées par #ifdef. Un #include n'est pas un import : c'est un copier-coller, et c'est ce qui explique la taille du fichier .i — plusieurs centaines de kilooctets pour un hello.c de cinq lignes.

Le compilateur traduit le C en assembleur. C'est lui qui signale les erreurs de syntaxe et de typage, et lui seul travaille un fichier à la fois : il ne sait rien des autres fichiers du projet.

L'assembleur traduit l'assembleur en code machine. Il produit un fichier objet dont les appels à des fonctions extérieures sont laissés en blanc, sous forme de symboles non résolus.

L'éditeur de liens rassemble les fichiers objets et les bibliothèques, et remplit ces blancs. Il ne connaît que des noms de symboles : il ignore le C, les types et les lignes de code.

D'où le diagnostic annoncé en ouverture. undefined reference to truc signifie : « quelqu'un appelle truc, et je ne trouve nulle part sa définition ». Trois causes possibles, et aucune n'est une faute de syntaxe — le fichier qui définit truc n'a pas été passé à la compilation, la bibliothèque manque (-lm pour les fonctions mathématiques), ou la fonction est déclarée sans être définie.

Un premier programme

#include <stdio.h> int main(void) {    printf("Bonjour\n");    return 0;}

Quatre remarques, une par ligne.

#include <stdio.h> recopie les déclarations des fonctions d'entrée/sortie, dont printf. L'en-tête ne contient pas le code de printf — seulement sa signature, pour que le compilateur sache la vérifier. Le code, lui, est dans la bibliothèque C, et c'est l'éditeur de liens qui l'y trouvera. Les chevrons désignent les en-têtes du système ; les guillemets, ceux du projet.

int main(void) est le point d'entrée. Le système lance toujours cette fonction-là, et son type de retour n'est pas décoratif : c'est le code de retour du chapitre 2 du cours de systèmes, celui que le shell teste dans un if.

printf écrit sur la sortie standard — le descripteur 1. Le \n n'est pas de la décoration : sans lui, la sortie reste dans le tampon dont parlait le chapitre 8 du même cours, et peut ne jamais apparaître si le programme plante.

return 0 signifie « tout s'est bien passé ». Toute autre valeur signale une erreur, et c'est la convention que respecte l'ensemble du monde Unix.

Quiz · 1 question

Un projet compile sans erreur avec gcc -c sur chaque fichier, mais l'édition de liens échoue sur « undefined reference to calculer ». Que peut-on en déduire ?

  • La fonction calculer contient une erreur de syntaxe que seul l'éditeur de liens détecteerreur de syntaxe
  • La fonction est DÉCLARÉE quelque part — sinon le compilateur aurait protesté — mais sa DÉFINITION n'est fournie à personne : fichier objet oublié à l'édition de liens, bibliothèque manquante, ou fonction jamais écritedéfinition absente
  • Il manque un #include dans le fichier appelantinclude manquant

Réponse : La clé est de savoir QUI parle. Le compilateur a validé chaque fichier isolément : la syntaxe et les types sont bons, et il a donc vu une déclaration de calculer — sans quoi il aurait signalé un identifiant inconnu. L'éditeur de liens, lui, ne connaît ni C ni syntaxe : il rassemble des symboles et remplit les blancs. Son message dit exactement « quelqu'un appelle ce nom, je n'en trouve pas la définition ». Trois causes : le .o contenant la définition n'a pas été passé à la ligne de commande, la bibliothèque manque (le classique -lm pour sqrt), ou la fonction a été déclarée dans un en-tête mais jamais écrite. Un #include manquant produirait au contraire une erreur du COMPILATEUR, en amont.

Lire un message d'erreur

C'est la compétence la plus rentable du bloc I, et elle s'apprend par trois règles.

Toujours commencer par la première erreur. Le compilateur, une fois désorienté, en produit souvent des dizaines de fantômes. Un point-virgule oublié ligne 12 peut engendrer quarante messages sur les lignes 13 à 50, qui disparaissent tous en corrigeant le premier.

Lire le numéro de ligne comme un indice, pas comme une adresse. L'erreur est signalée là où le compilateur s'est aperçu du problème, souvent une ligne après sa cause — c'est typiquement le cas du point-virgule manquant.

Distinguer erreur et avertissement. Une erreur arrête la compilation. Un avertissement la laisse passer, et signale un code légal mais suspect : conversion qui perd de l'information, variable utilisée sans être initialisée, comparaison entre signé et non signé. Ce sont souvent de vrais bogues.

D'où la ligne de commande à prendre comme habitude dès le premier TP :

gcc -Wall -Wextra -g -o programme source.c

-Wall -Wextra activent les avertissements — le compilateur en tait beaucoup par défaut, pour des raisons de compatibilité historique. -g conserve les informations de débogage, sans lesquelles gdb du chapitre 10 ne pourra pas afficher vos noms de variables. Et l'on prend l'habitude de traiter tout avertissement comme une erreur : c'est plus rapide que de le diagnostiquer trois heures plus tard au débogueur.

Quiz · 1 question

Pourquoi le fichier produit par gcc -E sur un hello.c de cinq lignes fait-il plusieurs centaines de kilooctets ?

  • Parce que le préprocesseur y ajoute le code machine des fonctions utiliséescode machine ajouté
  • Parce que #include est un COPIER-COLLER de texte : tout le contenu de stdio.h, et de tous les en-têtes qu'il inclut à son tour, se retrouve dans le fichiercopier-coller récursif
  • Parce que le fichier est encodé en un format intermédiaire plus verbeuxformat verbeux

Réponse : Le préprocesseur ne comprend pas le C : il fait de la manipulation de texte, et #include signifie littéralement « recopie ici le contenu de ce fichier ». Or stdio.h inclut lui-même d'autres en-têtes, qui en incluent d'autres : le résultat est l'union de plusieurs dizaines de fichiers de déclarations. Ce ne sont QUE des déclarations — les signatures des fonctions, des types, des macros — et pas une ligne de code exécutable : le code de printf vit dans la bibliothèque C compilée, et c'est l'éditeur de liens qui l'y trouvera. C'est aussi ce qui explique la lenteur de compilation des gros projets C, et l'existence des gardes d'inclusion qui empêchent qu'un même en-tête soit recopié dix fois.

À vous

Le C ne s'exécute pas dans un navigateur : l'exercice simule donc la chaîne de compilation en JavaScript, ce qui a l'avantage de rendre visible ce que chaque étage fait — et surtout ce qu'il refuse.

Vous écrirez un préprocesseur qui traite #define et #include, puis un éditeur de liens qui collecte les symboles définis et les symboles appelés, et signale les non résolus. Le jeu de tests contient les trois erreurs classiques : un symbole jamais défini, une définition présente dans un fichier qu'on a oublié de passer, et une double définition.

Exercice de code

Simulez le préprocesseur et l'éditeur de liens, puis provoquez les deux erreurs classiques.

Point de départ

// ── Les fichiers du projet ────────────────────────────────────────────────
const FICHIERS = {
  "calcul.h": [
    "#define PI 3.14159",
    "int aire(int r);",              // déclaration seule
    "int perimetre(int r);",         // déclarée, jamais définie : piège
  ].join("\n"),

  "calcul.c": [
    '#include "calcul.h"',
    "int aire(int r) { return PI * r * r; }",
  ].join("\n"),

  "main.c": [
    '#include "calcul.h"',
    "int main(void) {",
    "  int a = aire(3);",
    "  int p = perimetre(3);",       // appelée, jamais définie
    "  return a + p;",
    "}",
  ].join("\n"),
};

// ── ① Préprocesseur ───────────────────────────────────────────────────────
// Manipulation de TEXTE : recopier les #include, remplacer les #define.
function preprocesseur(nom, vus = new Set()) {
  if (vus.has(nom)) return "";        // garde d'inclusion
  vus.add(nom);
  const defines = {};
  const sortie = [];
  for (const ligne of FICHIERS[nom].split("\n")) {
    const inc = /^#include "(.+)"$/.exec(ligne.trim());
    if (inc) { sortie.push(preprocesseur(inc[1], vus)); continue; }
    const def = /^#define (\w+) (.+)$/.exec(ligne.trim());
    if (def) { defines[def[1]] = def[2]; continue; }
    // ← à écrire : remplacer chaque macro connue par sa valeur
    sortie.push(ligne);
  }
  return sortie.join("\n");
}

// ── ② Compilation : un fichier à la fois ──────────────────────────────────
// On récolte les symboles DÉFINIS et les symboles APPELÉS.
function compiler(source) {
  const definis = [...source.matchAll(/^(?:int|void) (\w+)\s*\([^)]*\)\s*\{/gm)].map((m) => m[1]);
  const appeles = [...source.matchAll(/(\w+)\s*\(/g)].map((m) => m[1])
    .filter((n) => !["int", "void", "if", "while", "for", "return"].includes(n));
  return { definis, appeles: [...new Set(appeles)].filter((n) => !definis.includes(n)) };
}

// ── ④ Édition de liens ────────────────────────────────────────────────────
function editerLiens(objets) {
  const erreurs = [];
  // ← à écrire : rassembler tous les symboles définis, puis vérifier que
  //   chaque symbole appelé en trouve un. Signaler aussi les doublons.
  return erreurs;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Faites remplacer les macros par le préprocesseur (PI doit disparaître).
// 2. Écrivez editerLiens, avec les DEUX erreurs : symbole non résolu et
//    définition multiple.
// 3. Compilez main.c SEUL, puis main.c + calcul.c. Quelle erreur reste ?

console.log("— calcul.c après préprocesseur —");
console.log(preprocesseur("calcul.c"));

Solution

const FICHIERS = {
  "calcul.h": ["#define PI 3.14159", "int aire(int r);", "int perimetre(int r);"].join("\n"),
  "calcul.c": ['#include "calcul.h"', "int aire(int r) { return PI * r * r; }"].join("\n"),
  "main.c": ['#include "calcul.h"', "int main(void) {", "  int a = aire(3);",
             "  int p = perimetre(3);", "  return a + p;", "}"].join("\n"),
  "geo.c": ['#include "calcul.h"', "int aire(int r) { return 3 * r * r; }"].join("\n"),
};

function preprocesseur(nom, vus = new Set()) {
  if (vus.has(nom)) return "";
  vus.add(nom);
  const defines = {};
  const sortie = [];
  for (const ligne of FICHIERS[nom].split("\n")) {
    const inc = /^#include "(.+)"$/.exec(ligne.trim());
    if (inc) { sortie.push(preprocesseur(inc[1], vus)); continue; }
    const def = /^#define (\w+) (.+)$/.exec(ligne.trim());
    if (def) { defines[def[1]] = def[2]; continue; }
    // Substitution textuelle pure : le préprocesseur ne sait pas ce qu'est
    // une variable, il remplace un motif par un autre. D'où les surprises
    // classiques des macros mal parenthésées.
    let l = ligne;
    for (const [nomMacro, valeur] of Object.entries(defines)) {
      l = l.replace(new RegExp("\\b" + nomMacro + "\\b", "g"), valeur);
    }
    sortie.push(l);
  }
  return sortie.filter((x) => x !== "").join("\n");
}

function compiler(source) {
  const definis = [...source.matchAll(/^(?:int|void) (\w+)\s*\([^)]*\)\s*\{/gm)].map((m) => m[1]);
  const appeles = [...source.matchAll(/(\w+)\s*\(/g)].map((m) => m[1])
    .filter((n) => !["int", "void", "if", "while", "for", "return"].includes(n));
  return { definis, appeles: [...new Set(appeles)].filter((n) => !definis.includes(n)) };
}

function editerLiens(objets) {
  const erreurs = [];
  const table = {};
  // Une seule passe pour collecter, une seconde pour résoudre : l'éditeur
  // de liens ne connaît que des NOMS, ni types ni lignes de code.
  for (const o of objets) {
    for (const d of o.definis) {
      if (table[d]) erreurs.push("multiple definition of " + d +
        " (" + table[d] + " et " + o.nom + ")");
      else table[d] = o.nom;
    }
  }
  for (const o of objets) {
    for (const a of o.appeles) {
      if (!table[a]) erreurs.push("undefined reference to " + a + " (appelé depuis " + o.nom + ")");
    }
  }
  return erreurs;
}

function construire(noms) {
  const objets = noms.map((n) => ({ nom: n.replace(".c", ".o"), ...compiler(preprocesseur(n)) }));
  console.log("   objets : " + objets.map((o) =>
    o.nom + " définit [" + o.definis.join(",") + "] appelle [" + o.appeles.join(",") + "]").join("  |  "));
  const erreurs = editerLiens(objets);
  if (erreurs.length === 0) console.log("   édition de liens : ok");
  else for (const e of erreurs) console.log("   ld: " + e);
}

console.log("— calcul.c après préprocesseur (PI a disparu) —");
console.log(preprocesseur("calcul.c"));

console.log("");
console.log("— gcc -c main.c  puis  ld main.o —");
construire(["main.c"]);
// Deux symboles manquent : aire, dont la définition est dans un fichier
// qu'on n'a pas passé, et perimetre, qui n'existe nulle part.

console.log("");
console.log("— gcc main.c calcul.c —");
construire(["main.c", "calcul.c"]);
// aire est résolu. perimetre reste introuvable : il était DÉCLARÉ dans
// l'en-tête, donc le compilateur n'a rien eu à redire — c'est bien
// l'éditeur de liens qui s'en aperçoit, et lui seul.

console.log("");
console.log("— gcc main.c calcul.c geo.c —");
construire(["main.c", "calcul.c", "geo.c"]);
// Et l'erreur symétrique : deux fichiers définissent aire.

En travaux pratiques

Travaux pratiques 1 · 2 h

Ouvrir la chaîne de compilation

Exécuter séparément les quatre étapes qu'une seule commande cache, puis poser le squelette du programme qui grandira pendant tout le semestre.

Avant de commencer

  • gcc et make installés
  • Un éditeur de texte, et un terminal

Énoncé

  1. Les quatre étapes, une par uneÀ partir d'un programme d'une ligne, produisez successivement le fichier prétraité, l'assembleur, l'objet, puis l'exécutable. Regardez la taille de chacun. Indice : Les options -E, -S, -c arrêtent gcc à chaque étape.
  2. Ce que fait le préprocesseurComptez les lignes de votre source, puis celles du fichier prétraité. Trouvez d'où viennent les autres, et cherchez-y la déclaration de printf.
  3. Activer les avertissementsÉcrivez un programme qui utilise une variable non initialisée et oublie une valeur de retour. Compilez sans option, puis avec -Wall -Wextra. Comparez.
  4. Séparer en deux fichiersCréez le fil rouge du semestre : un fichier principal et un module de comptage, avec son en-tête. Compilez les deux séparément puis liez-les.
  5. L'erreur de l'éditeur de liensDéclarez une fonction dans l'en-tête sans l'écrire. Compilez chaque fichier — ça passe. Liez — ça échoue. Expliquez la différence entre les deux messages.
  6. Le MakefileÉcrivez un Makefile qui reconstruit uniquement ce qui a changé. Modifiez un seul fichier et vérifiez que l'autre n'est pas recompilé.

C'est réussi quand

  • Vous nommez les quatre étapes et ce qui entre et sort de chacune
  • -Wall -Wextra vous signale au moins deux problèmes que la compilation muette laissait passer
  • make ne recompile que le fichier modifié
  • Vous distinguez une erreur de compilation d'une erreur d'édition de liens

Correction

Les quatre étapes
gcc -E bonjour.c -o bonjour.i   # préprocesseur  : 800 lignes
gcc -S bonjour.i -o bonjour.s   # compilation    : assembleur
gcc -c bonjour.s -o bonjour.o   # assemblage     : code machine
gcc    bonjour.o -o bonjour     # édition de liens : exécutable

bonjour.c : 120 octets
bonjour.i :  26 Ko      ← stdio.h a été recopié en entier
bonjour.o : 1,5 Ko
bonjour   :  16 Ko

Une seule commande cache quatre programmes différents. Savoir lequel a échoué divise par dix le temps de diagnostic : une erreur de macro vient du premier, une erreur de syntaxe du deuxième, un symbole introuvable du quatrième.

Ce que fait vraiment #include
grep -n "printf" bonjour.i
extern int printf (const char *__restrict __format, ...);

#include ne « charge » rien : il RECOPIE le fichier à cet endroit

L'en-tête ne contient que des DÉCLARATIONS — le nom, les types, rien du corps. Le code de printf est ailleurs, dans la bibliothèque, et n'est raccroché qu'à l'édition de liens. C'est cette séparation qui permet de compiler un fichier sans posséder le code des autres, et c'est elle qui explique l'étape 5.

Le silence coupable
int main(void) {
  int x;
  printf("%d\n", x + 1);   /* x non initialisé */
}

gcc essai.c                    → aucun message. Compile.
gcc -Wall -Wextra essai.c
warning: 'x' is used uninitialized
warning: control reaches end of non-void function

Par défaut, le compilateur en dit le minimum. -Wall -Wextra ne sont pas des options pour perfectionnistes : ce sont les seules qui rendent le C utilisable. La discipline à prendre dès aujourd'hui, c'est -Wall -Wextra -Werror — le dernier transformant tout avertissement en erreur, ce qui empêche de les accumuler.

Le fil rouge, premier étatjournal.h / journal.c / main.c
/* journal.h — les DÉCLARATIONS */
#ifndef JOURNAL_H
#define JOURNAL_H
int compter_lignes(const char *chemin);
#endif

/* journal.c — les DÉFINITIONS */
#include "journal.h"
int compter_lignes(const char *chemin) { … }

/* main.c */
#include "journal.h"
int main(int argc, char **argv) {
  if (argc < 2) { fprintf(stderr, "usage: journal FICHIER\n"); return 1; }
  printf("%d lignes\n", compter_lignes(argv[1]));
  return 0;
}

Les gardes d'inclusion empêchent qu'un en-tête inclus deux fois ne déclare deux fois la même chose. C'est le premier réflexe de tout en-tête C, sans exception. Ce squelette est le point de départ : chaque TP du semestre lui ajoutera une capacité.

Les deux erreurs, à ne pas confondre
# compilation : le fichier COURANT est incohérent
journal.c:12: error: 'compter' undeclared

# édition de liens : tous les fichiers compilent, rien ne les relie
/usr/bin/ld: undefined reference to 'compter_lignes'

Le compilateur ne voit qu'un fichier à la fois : une fonction DÉCLARÉE lui suffit, il fait confiance. L'éditeur de liens, lui, voit tout et exige que chaque promesse soit tenue. « undefined reference » signifie donc toujours : déclarée, jamais définie, ou définie dans un fichier que vous avez oublié de lier.

Le MakefileMakefile
CC     = gcc
CFLAGS = -Wall -Wextra -std=c17 -g

journal: main.o journal.o
$(CC) $^ -o $@

main.o:    main.c journal.h
journal.o: journal.c journal.h

clean:
rm -f *.o journal

make compare les DATES : une cible plus ancienne que ses dépendances est reconstruite, les autres non. C'est pourquoi journal.h figure dans les dépendances — modifier l'en-tête doit tout recompiler, et l'oublier produit les incohérences les plus déroutantes qui soient, où le programme exécuté ne correspond plus au code lu.

Ce que la suite en fait

Le chapitre 2 remplit le programme : types, variables, opérateurs, et les entrées/sorties formatées dont printf n'est que le représentant le plus visible. On y verra que la taille d'un type n'est pas une abstraction mais un nombre d'octets — celui du chapitre 2 d'architecture.

La chaîne de compilation reviendra au chapitre 4, quand le projet cessera de tenir dans un seul fichier : les en-têtes, la compilation séparée et le Makefile sont exactement la gestion de ce que ce chapitre vient de décrire. Et le chapitre 10 y ajoutera les outils qui inspectent le résultat.

À retenir

Flashcards · 4 cartes

Quels sont les quatre étages de la chaîne de compilation, et que produit chacun ?
1) PRÉPROCESSEUR : manipulation de texte — recopie les #include, remplace les #define, écarte les #ifdef → fichier .i. 2) COMPILATEUR : traduit le C en assembleur, signale syntaxe et typage, travaille UN FICHIER À LA FOIS → .s. 3) ASSEMBLEUR : produit du code machine avec des symboles non résolus → .o. 4) ÉDITEUR DE LIENS : rassemble les .o et les bibliothèques, remplit les blancs — il ne connaît que des NOMS → exécutable.
Que signifie « undefined reference to truc », et qui parle ?
C'est l'ÉDITEUR DE LIENS, pas le compilateur : ce n'est donc jamais une erreur de syntaxe. Il dit « quelqu'un appelle ce nom, je n'en trouve pas la définition ». Trois causes : le fichier objet contenant la définition n'a pas été passé à la ligne de commande, une bibliothèque manque (-lm pour les fonctions mathématiques), ou la fonction est déclarée dans un en-tête sans être écrite.
Que fait exactement #include, et pourquoi le fichier prétraité est-il énorme ?
C'est un COPIER-COLLER de texte, pas un import : le contenu du fichier désigné est recopié à cet endroit, et ce fichier en inclut d'autres à son tour. D'où plusieurs centaines de kilooctets pour un hello.c de cinq lignes. Ce ne sont QUE des déclarations — signatures, types, macros — et pas une ligne exécutable : le code de printf est dans la bibliothèque compilée. Chevrons pour les en-têtes du système, guillemets pour ceux du projet.
Quelles sont les trois règles de lecture d'un message d'erreur, et quelle ligne de commande adopter ?
1) Toujours corriger la PREMIÈRE erreur : un point-virgule oublié peut engendrer quarante messages fantômes. 2) Le numéro de ligne est un indice, pas une adresse : l'erreur est signalée là où le compilateur s'en aperçoit, souvent une ligne après sa cause. 3) Distinguer erreur (arrête) et AVERTISSEMENT (laisse passer un code légal mais suspect — souvent un vrai bogue). Habitude : gcc -Wall -Wextra -g, et traiter tout avertissement comme une erreur.

Chapitre 2 · 7 h

Types, variables, opérateurs

Types de base et taille mémoire, déclaration et initialisation, priorités des opérateurs, conversions implicites et explicites, printf et scanf et leurs pièges.

En Python, 7 / 2 vaut 3,5. En C, 7 / 2 vaut 3, sans le moindre avertissement. Le résultat n'est pas arrondi, il est tronqué, parce que les deux opérandes sont des entiers et qu'en C le type des opérandes décide de l'opération, pas le résultat attendu.

C'est le fil de ce chapitre. En C, un type n'est pas une catégorie abstraite : c'est un nombre d'octets et une façon de les interpréter. Le chapitre 2 d'architecture a décrit ces interprétations — complément à deux, IEEE 754, ASCII — et l'on va maintenant les manipuler directement.

Un type, c'est une taille et une lecture

TypeTaille usuelleDomaine
char1 octet−128 à 127, ou 0 à 255 selon la plateforme
short2 octets−32 768 à 32 767
int4 octetsenviron ±2,1 milliards
long8 octets sous Linux 64 bits±9,2 × 10¹⁸
float4 octetsIEEE 754 simple précision, 7 chiffres significatifs
double8 octetsIEEE 754 double précision, 15 chiffres
unsigned int4 octets0 à 4 294 967 295

Graphique

Taille des types en octets, sous Linux 64 bits

  • char : 11
  • short : 22
  • int : 44
  • float : 44
  • long : 88
  • double : 88
  • pointeur : 88
Ces valeurs sont USUELLES, pas garanties : la norme n'impose que des minimums et l'ordre char ≤ short ≤ int ≤ long. Un int fait 2 octets sur certains microcontrôleurs, et un pointeur 4 octets sur une machine 32 bits. C'est pourquoi on écrit sizeof(int) plutôt que 4, et pourquoi stdint.h existe : int32_t garantit exactement 32 bits, partout.

Le mot-clé sizeof rend la taille en octets d'un type ou d'une variable, et il s'évalue à la compilation. C'est le seul moyen correct d'écrire du code portable : sizeof(int) plutôt que 4, sizeof(tableau) / sizeof(tableau[0]) pour le nombre d'éléments — une formule qu'on retrouvera au chapitre 5, avec ses limites.

Deux pièges du tableau méritent d'être signalés tout de suite. Le type char est signé ou non selon la plateforme : c'est un des rares endroits où la norme laisse le choix, et un code qui suppose l'un ou l'autre est faux ailleurs. Et l'entier littéral 2147483648 ne tient pas dans un int : le débordement du chapitre 2 d'architecture n'est pas une curiosité, il arrive.

Déclarer, initialiser

int compteur = 0;        /* déclaration AVEC initialisation */int total;               /* déclaration seule */double moyenne = 12.5;char lettre = 'A';       /* apostrophes : un caractère, donc un entier */

Une règle vaut d'être posée en majuscules : une variable non initialisée ne vaut pas zéro. Elle contient ce que la pile contenait à cet endroit — le résidu d'un appel précédent. Le programme fonctionnera peut-être en test et échouera en production, ou l'inverse, selon ce qui traînait. C'est un comportement indéfini, et c'est la première ligne que -Wall signale.

La règle pratique est donc d'initialiser à la déclaration, systématiquement.

Opérateurs et priorités

Les opérateurs arithmétiques n'ont rien de surprenant, sauf deux.

La division entière tronque, comme on l'a vu. 7 / 2 vaut 3 ; pour obtenir 3,5 il faut qu'au moins un opérande soit flottant : 7.0 / 2. Et la troncature va vers zéro, donc -7 / 2 vaut −3, pas −4.

Le modulo % ne s'applique qu'aux entiers, et son signe suit celui du dividende : -7 % 2 vaut −1. Pour tester la parité d'un nombre possiblement négatif, on écrit donc n % 2 != 0 plutôt que n % 2 == 1.

Les opérateurs d'incrémentation ont deux formes qu'il ne faut pas mélanger : i++ rend l'ancienne valeur puis incrémente, ++i incrémente puis rend la nouvelle. Utilisés seuls, ils sont équivalents ; dans une expression, ils ne le sont pas.

Viennent enfin les opérateurs bit à bit&, |, ^, ~, <<, >> — qui sont exactement les portes du chapitre 4 d'architecture, et les décalages du chapitre 1. Ils ne doivent pas être confondus avec les opérateurs logiques && et ||, qui travaillent sur des conditions et ont la propriété d'être paresseux : dans a && b, b n'est pas évalué si a est faux. C'est cette paresse qui rend possible l'idiome if (p != NULL && p->valeur > 0) du chapitre 7.

Le piège le plus classique du C tient à un caractère : = est l'affectation, == est la comparaison. Écrire if (x = 5) est légal — cela affecte 5 à x puis teste 5, donc c'est toujours vrai — et le compilateur ne le signale qu'avec -Wall.

Les conversions

C'est le point qui produit le plus de bogues silencieux du chapitre.

Les conversions implicites ont lieu sans que rien ne soit écrit. Dans une expression mêlant des types, le C promeut vers le type le plus « large » : int + double donne un double. Cela paraît anodin, et pourtant :

int a = 7, b = 2;double r = a / b;        /* r vaut 3.0, pas 3.5 */

La division est effectuée avant l'affectation, donc entre deux int : le résultat 3 est ensuite converti en double. La conversion arrive trop tard. Il faut forcer avant : (double)a / b.

Plus dangereuse encore, la conversion signé vers non signé. Dans une comparaison entre un int et un unsigned int, le C convertit le signé en non signé — et 1-1 devient 4 294 967 295, la plus grande valeur possible.

int i = -1;unsigned int n = 1;if (i < n) ...          /* FAUX : -1 devient 4294967295 */

C'est la raison pour laquelle une boucle for (unsigned i = n - 1; i >= 0; i--) ne se termine jamais : une variable non signée est toujours supérieure ou égale à zéro.

La conversion explicite, ou transtypage, s'écrit (type)expression. Elle est nécessaire — comme ci-dessus — mais elle doit rester rare : un transtypage ajouté pour faire taire un avertissement masque généralement un vrai problème.

Quiz · 1 question

Que vaut r après int a = 7, b = 2; double r = a / b; et comment obtenir 3,5 ?

  • 3.5 : l'affectation à un double provoque une division flottante3.5
  • 3.0 : la division est faite entre deux int, donc tronquée à 3, et c'est ce 3 qui est converti en double. Il faut forcer AVANT : (double)a / b3.0
  • Une erreur de compilation : on ne peut pas affecter un int à un doubleerreur

Réponse : L'ordre des opérations est ce qui trompe. La division a / b est évaluée d'abord, avec deux opérandes int : le C fait donc une division ENTIÈRE, qui tronque à 3. C'est ce résultat, déjà perdu, qui est ensuite converti en double lors de l'affectation. La conversion arrive trop tard — elle porte sur le résultat, pas sur les opérandes. Pour obtenir 3,5 il faut qu'au moins un opérande soit flottant AVANT la division : (double)a / b, ou a / (double)b, ou même a / 2.0. Le compilateur ne signale rien, puisque le code est parfaitement légal : c'est le prototype du bogue silencieux, et la raison pour laquelle on relit toujours les expressions mêlant entiers et flottants.

Entrées/sorties formatées

printf et scanf méritent une section, parce qu'ils sont la source d'erreurs la plus fréquente du premier semestre — et qu'ils reposent sur un mécanisme fragile.

printf("%d ans, %.2f euros, %c, %s\n", age, prix, initiale, nom);scanf("%d", &age);
FormatType attendu
%dint
%uunsigned int
%fdouble (en printf), float * (en scanf)
%lfdouble en scanf
%cchar
%schaîne, c'est-à-dire char * (chapitre 6)
%ppointeur (chapitre 7)

La fragilité tient à ceci : printf ne vérifie rien à l'exécution. Elle reçoit une chaîne de format et une liste d'arguments sans type, et elle croit le format. Passer un double là où %d est écrit produit un nombre absurde, pas une erreur. Heureusement, gcc -Wall sait comparer le format aux arguments et signale l'écart — encore une raison de l'activer.

Trois pièges de scanf, et ils tombent tous au premier TP.

L'esperluette est obligatoire : scanf("%d", &age). La fonction doit modifier votre variable, donc elle a besoin de son adresse — c'est le passage par adresse du chapitre 7, et scanf en est la première rencontre. L'oublier écrit à une adresse aléatoire.

scanf("%d") laisse le retour à la ligne dans le tampon, ce qui fait qu'un scanf("%c") suivant lit ce retour au lieu d'attendre une frappe.

scanf rend le nombre de conversions réussies, et on l'ignore toujours. Si l'utilisateur tape des lettres là où un nombre est attendu, la variable n'est pas modifiée — elle garde sa valeur précédente, ou son contenu indéfini si elle n'était pas initialisée. Tester ce retour est le minimum : if (scanf("%d", &n) != 1) ....

Constantes

Trois façons de nommer une valeur fixe, et elles ne sont pas équivalentes.

#define TAILLE 100          /* préprocesseur : substitution de texte */const int taille = 100;     /* vraie variable, typée, non modifiable */enum { TAILLE_MAX = 100 };  /* constante entière, typée, visible au débogueur */

Le #define est traité par le préprocesseur du chapitre 1 : c'est un remplacement de texte, sans type et sans portée. Il reste indispensable pour les tailles de tableaux statiques, que la norme C89 exige constantes à la compilation. Le const est une vraie variable que le compilateur refuse de laisser modifier — mieux typée, visible au débogueur, mais inutilisable comme taille de tableau en C89. L'enum combine les deux avantages pour les entiers.

Quiz · 1 question

Pourquoi la boucle for (unsigned int i = n - 1; i >= 0; i--) ne se termine-t-elle jamais ?

  • Parce que i-- sur un unsigned est interdit et provoque un comportement indéfinidécrémentation interdite
  • Parce qu'un unsigned est toujours supérieur ou égal à zéro : la condition est toujours vraie, et quand i vaut 0 la décrémentation le fait passer à 4 294 967 295toujours positif
  • Parce que n - 1 vaut −1 quand n vaut 0, ce qui est le seul cas problématiquecas n = 0

Réponse : Un type non signé n'a pas de valeurs négatives : la condition i >= 0 est une tautologie, toujours vraie, et gcc -Wall la signale d'ailleurs comme comparaison toujours vraie. Le déroulé est instructif : la boucle descend correctement jusqu'à i = 0, exécute son corps, puis i-- fait déborder le compteur par le bas et i devient 4 294 967 295 — l'arithmétique modulo 2^32 du chapitre 2 d'architecture. La boucle repart alors pour quatre milliards de tours, en indexant très au-delà de tout tableau. Deux remèdes : utiliser un int signé, ou écrire la boucle en montant. Le cas n = 0 est un piège de plus — n − 1 vaut alors 4 294 967 295 dès le départ — mais ce n'est pas la cause principale.

À vous

L'exercice reproduit l'arithmétique du C en JavaScript, qui ne l'a pas : entiers de largeur fixe, troncature, débordement modulo, et comparaison signé contre non signé.

Trois choses à faire tomber. La division entière et son signe. Le débordement d'un int sur 32 bits, en retrouvant les valeurs exactes du chapitre 2 d'architecture. Et la boucle unsigned qui ne se termine pas — vous la ferez dérailler avec une garde, et vous verrez la valeur passer de 0 à 4 294 967 295.

Exercice de code

Reproduisez l'arithmétique entière du C : troncature, débordement modulo, signé contre non signé.

Point de départ

// ── Entiers de largeur fixe ───────────────────────────────────────────────
// JS calcule sur des flottants 64 bits : pour imiter le C, on tronque.
const MASQUE32 = 0xFFFFFFFF;

function versInt32(x) {
  // Complément à deux sur 32 bits : au-delà de 2^31 − 1, on repasse négatif.
  const brut = x & MASQUE32;
  return brut >= 2 ** 31 ? brut - 2 ** 32 : brut;
}

function versUint32(x) {
  return 0;   // ← à écrire : même masque, mais lecture NON signée
}

// ── Division entière du C ─────────────────────────────────────────────────
function divisionC(a, b) {
  return a / b;   // ← à écrire : le C TRONQUE vers zéro, il n'arrondit pas
}

function moduloC(a, b) {
  return a % b;   // en C comme en JS, le signe suit le DIVIDENDE
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez versUint32 et divisionC.
// 2. Retrouvez le débordement du chapitre 2 d'architecture : que vaut
//    INT_MAX + 1 lu en signé ? Et 255 + 1 sur 8 bits ?
// 3. Faites dérailler la boucle unsigned, avec une garde.

console.log("7 / 2   en C :", divisionC(7, 2), " (attendu 3)");
console.log("-7 / 2  en C :", divisionC(-7, 2), " (attendu -3, pas -4)");
console.log("-7 % 2  en C :", moduloC(-7, 2), " (attendu -1)");

Solution

const MASQUE32 = 0xFFFFFFFF;

function versInt32(x) {
  const brut = x & MASQUE32;
  return brut >= 2 ** 31 ? brut - 2 ** 32 : brut;
}

function versUint32(x) {
  // Mêmes 32 bits, autre lecture. C'est tout le chapitre 2 d'architecture :
  // le motif ne porte pas son interprétation, c'est le TYPE qui décide.
  return (x & MASQUE32) >>> 0;
}

function divisionC(a, b) {
  // Troncature VERS ZÉRO, et non arrondi : Math.trunc, pas Math.floor.
  // Math.floor(-3.5) donne -4, ce qui serait faux.
  return Math.trunc(a / b);
}

function moduloC(a, b) { return a % b; }

console.log("— division entière —");
for (const [a, b] of [[7, 2], [-7, 2], [7, -2], [10, 3]]) {
  console.log("   " + String(a).padStart(3) + " / " + String(b).padStart(2) +
    " = " + String(divisionC(a, b)).padStart(3) +
    "   reste " + String(moduloC(a, b)).padStart(3));
}
console.log("   parité d'un négatif : n % 2 == 1 échoue sur -7 (" + moduloC(-7, 2) +
            "), il faut n % 2 != 0");

console.log("");
console.log("— débordement, comme au chapitre 2 d'architecture —");
const INT_MAX = 2 ** 31 - 1;
console.log("   INT_MAX      = " + INT_MAX);
console.log("   INT_MAX + 1  = " + versInt32(INT_MAX + 1) + "   (repasse au minimum)");
console.log("   lu non signé = " + versUint32(INT_MAX + 1));
console.log("   -1 non signé = " + versUint32(-1) + "   (le fameux 4 294 967 295)");

// Sur 8 bits, l'exemple du cours d'architecture.
const versUint8 = (x) => x & 0xFF;
const versInt8 = (x) => (x & 0xFF) >= 128 ? (x & 0xFF) - 256 : (x & 0xFF);
console.log("   sur 8 bits : 255 + 1 = " + versUint8(256) +
            " | 100 + 50 lu en signé = " + versInt8(150) + " (et non 150)");

console.log("");
console.log("— la comparaison signé / non signé —");
const i = -1, n = 1;
console.log("   en JS  : -1 < 1 vaut " + (i < n));
console.log("   en C   : -1 est converti en non signé -> " + versUint32(i) +
            " < 1 vaut " + (versUint32(i) < n));

console.log("");
console.log("— la boucle unsigned qui ne se termine pas —");
let tours = 0;
let u = versUint32(3 - 1);
while (u >= 0) {                 // toujours vrai sur un non signé
  if (tours++ > 6) { console.log("   ... et ça continue. Garde déclenchée."); break; }
  console.log("   i = " + String(u).padStart(10) + (u > 1000 ? "   << débordement par le bas" : ""));
  u = versUint32(u - 1);
}
// La boucle descend bien 2, 1, 0 — puis 0 − 1 déborde et donne 4 294 967 295.
// Le programme repart pour quatre milliards de tours, en indexant très
// au-delà de tout tableau. Remède : un int signé, ou boucler en montant.

En travaux pratiques

Travaux pratiques 2 · 2 h

Les types mentent, mesurez-les

Vérifier expérimentalement ce que valent les types sur votre machine, et rencontrer les trois conversions implicites qui produisent le plus de bogues silencieux.

Avant de commencer

  • Le TP 1 : compilation avec avertissements
  • Le TP 2 d'Architecture, sur le complément à deux

Énoncé

  1. MesurerAffichez la taille en octets et les bornes de char, short, int, long, size_t et double. Ne les recopiez pas d'un cours : mesurez-les.
  2. Le débordementFaites déborder un int non signé puis un int signé. Comparez ce que fait le programme dans les deux cas, avec et sans -O2.
  3. La comparaison qui échoueComparez un int valant -1 avec un unsigned int valant 1. Prédisez le résultat, puis exécutez. Indice : Quand les deux opérandes n'ont pas le même type, l'un des deux est converti — lequel ?
  4. La division entièreCalculez la moyenne de 7 et 8 en int, puis le pourcentage que 3 représente sur 7. Corrigez les deux, de deux façons différentes.
  5. Lire une entréeÉcrivez un programme qui lit un entier au clavier avec scanf. Tapez des lettres. Puis tapez un nombre suivi de lettres. Expliquez les deux comportements.
  6. Lire correctementRéécrivez la lecture avec fgets et strtol, en signalant toute saisie invalide. Testez avec les mêmes entrées.
  7. Au fil rougeAjoutez à journal une option qui prend un seuil numérique en argument, en validant la conversion. Refusez proprement un argument non numérique.

C'est réussi quand

  • Vos tailles mesurées correspondent à votre machine, pas à une table apprise
  • Vous prédisez correctement le résultat de la comparaison signé/non signé
  • Votre programme refuse « abc » sans planter et sans boucler

Correction

Mesurer, pas supposer
printf("int   : %zu octets, %d..%d\n", sizeof(int), INT_MIN, INT_MAX);
printf("long  : %zu octets\n", sizeof(long));
printf("size_t: %zu octets\n", sizeof(size_t));

Linux 64 bits : int 4, long 8, size_t 8
Windows 64 bits : int 4, long 4, size_t 8   ← long DIFFÈRE

La norme ne garantit que des minimums : int fait au moins 16 bits, long au moins 32. Supposer que long fait 64 bits est un bogue de portabilité classique — il n'apparaît qu'en changeant de plateforme. Quand la taille compte, on utilise int32_t ou int64_t de stdint.h, qui la garantissent.

Les deux débordements
unsigned u = UINT_MAX; u + 1  →  0     DÉFINI (modulo 2^32)
int      i = INT_MAX;  i + 1  →  ???   INDÉFINI

gcc -O0 : -2147483648
gcc -O2 : le compilateur suppose que ça n'arrive pas
        et peut supprimer le test qui suit

« Indéfini » ne veut pas dire « imprévisible mais raisonnable » : cela veut dire que le compilateur a le droit de tout faire, y compris supprimer du code qui n'aurait de sens que si le débordement arrivait. Un test écrit APRÈS l'opération peut donc disparaître. La seule bonne pratique est de tester avant : si (a > INT_MAX - b) alors refuser.

La conversion qui retourne le test
int i = -1;
unsigned u = 1;
if (i < u) printf("attendu\n"); else printf("SURPRISE\n");

→ SURPRISE

/* i est converti en unsigned : -1 devient 4294967295 */

le cas réel :
for (int i = 0; i < strlen(s) - 1; i++)   /* si s est vide : */
 strlen("") - 1 = (size_t)-1 = énorme → boucle presque infinie

Dès qu'un signé et un non signé se rencontrent, le signé est converti — jamais l'inverse. Comme strlen, sizeof et la plupart des tailles renvoient un size_t non signé, le piège est omniprésent. La parade : ne jamais soustraire sur un type non signé sans avoir vérifié l'ordre, et laisser -Wextra signaler ces comparaisons.

La division entière, et ses deux corrections
(7 + 8) / 2      →  7    (et non 7,5)
3 / 7 * 100      →  0    (3/7 vaut 0, puis 0 × 100)

corrections :
100.0 * 3 / 7    →  42.857   /* un opérande flottant d'abord */
100 * 3 / 7      →  42       /* multiplier AVANT de diviser */

La division entière tronque toujours vers zéro, sans avertissement d'aucune sorte. La seconde correction est souvent la meilleure : elle reste en entier, donc exacte, et elle évite les flottants du TP 2 d'Architecture. L'ordre des opérations n'est pas un détail esthétique — il décide du résultat.

scanf, et pourquoi le remplacer
scanf("%d", &n);

entrée « abc » : scanf renvoie 0, n est INCHANGÉ (souvent indéterminé),
               et « abc » RESTE dans le tampon
               → une boucle qui relit tourne indéfiniment

entrée « 12abc » : n vaut 12, « abc » reste dans le tampon

/* la lecture correcte */
char ligne[64];
if (!fgets(ligne, sizeof ligne, stdin)) return 1;

char *fin;
errno = 0;
long v = strtol(ligne, &fin, 10);
if (fin == ligne)                    return erreur("pas un nombre");
while (*fin == ' ' || *fin == '\n') fin++;
if (*fin != '\0')                   return erreur("caractères en trop");
if (errno == ERANGE)                 return erreur("hors bornes");

strtol donne les trois informations que scanf ne donne pas : où la conversion s'est arrêtée, si elle a commencé, et s'il y a eu débordement. Le prix est une dizaine de lignes — et c'est le prix d'un programme qui ne boucle pas indéfiniment sur une saisie invalide. Tester la valeur de retour de scanf est le minimum ; ne pas l'utiliser du tout est mieux.

Ce que la suite en fait

Le chapitre 3 emploie ces types dans les structures de contrôle, et traduit en C le pseudocode d'Algorithmique 1. Les conversions y reviendront par la bande : une condition en C est un entier, où zéro est faux et tout le reste vrai, ce qui explique que if (x = 5) compile.

Le bloc IV en fera un usage plus fondamental. L'arithmétique des pointeurs du chapitre 7 dépend directement de sizeof : avancer un int * d'une unité déplace l'adresse de quatre octets, et c'est le type qui décide du pas.

À retenir

Flashcards · 5 cartes

Qu'est-ce qu'un type en C, et pourquoi écrit-on sizeof(int) plutôt que 4 ?
Un nombre d'OCTETS et une façon de les INTERPRÉTER — complément à deux, IEEE 754, ASCII. Les tailles usuelles (char 1, short 2, int 4, long et double 8) ne sont pas garanties : la norme n'impose que des minimums et l'ordre char ≤ short ≤ int ≤ long. Un int fait 2 octets sur certains microcontrôleurs. sizeof s'évalue à la compilation et rend du code portable ; stdint.h va plus loin avec int32_t, garanti partout.
Que vaut 7 / 2 en C, et comment obtenir 3,5 ?
3. La division entre deux int est ENTIÈRE et tronque vers zéro (donc −7 / 2 vaut −3, pas −4). Écrire double r = a / b ne change rien : la division est évaluée avant l'affectation, entre deux int, et la conversion arrive trop tard. Il faut forcer AVANT : (double)a / b. Le compilateur ne signale rien — le code est légal — c'est le prototype du bogue silencieux.
Pourquoi une variable non initialisée est-elle dangereuse en C ?
Parce qu'elle ne vaut PAS zéro : elle contient ce que la pile contenait à cet endroit, le résidu d'un appel précédent. Le programme peut fonctionner en test et échouer en production, ou l'inverse, selon ce qui traînait. C'est un comportement indéfini, signalé par -Wall. Règle : initialiser à la déclaration, systématiquement.
Que se passe-t-il quand on compare un int et un unsigned int ?
Le C convertit le SIGNÉ en NON SIGNÉ : −1 devient 4 294 967 295, la plus grande valeur possible. Donc int i = −1; unsigned n = 1; if (i < n) est FAUX. Même cause pour la boucle for (unsigned i = n−1; i >= 0; i--) qui ne se termine jamais : la condition est une tautologie, et quand i vaut 0 la décrémentation le fait déborder à 4 294 967 295.
Quels sont les trois pièges de scanf ?
1) L'ESPERLUETTE est obligatoire — scanf doit modifier votre variable, donc elle a besoin de son adresse ; l'oublier écrit à une adresse aléatoire. 2) scanf("%d") laisse le RETOUR À LA LIGNE dans le tampon, qu'un %c suivant lira au lieu d'attendre une frappe. 3) scanf rend le NOMBRE DE CONVERSIONS RÉUSSIES, qu'on ignore toujours : si l'utilisateur tape des lettres, la variable garde sa valeur précédente. Tester ce retour est le minimum.