Partie 1 - Bases de C

Support présentation

Cette partie a pour objectif de rafraîchir vos connaissances en C et de commencer à aborder les points que nous verrons dans la suite du cours ou de ce que vous avez pu commencer à voir dans d’autres cours (allocation de la mémoire sur le stack ou la heap, représentation de variables en binaire,…). Ce n’est pas un cours complet ni un cours pour débutants en informatique, nous couvrirons les bases du langage pour les exploiter dans le cours de programmation système. Je considère, ici et dans le reste du cours, que vous avez des bases en algorithmie et en programmation (peu importe le langage) et que vous savez utiliser un minimum le terminal.

Voir les ressources pour des cours et liens/indications vers la documentation.

Voir les outils que nous utiliserons dans ce cours.

Qu’est-ce que le C ?

Le C est un langage de programmation impératif et compilé, inventé dans les années 1970 par Dennis Ritchie. Il est considéré aujourd’hui comme un langage de bas niveau, comparé aux langages modernes, et de haut niveau comparé à l’assembleur.

Malgré ses ≈50 ans, il reste très utilisé dans de nombreuses applications grâce à sa vitesse d’exécution et sa portabilité, et le restera très probablement pour de nombreuses années (voir Zig ou Rust qui se présentent comme des alternatives modernes au C, Rust étant le 3ème langage à rejoindre le développement du noyau Linux après l’assembleur et le C).

Il est notamment employé, entre autres, pour le développement de systèmes d’exploitation, de pilotes ou de systèmes embarqués, mais pas seulement.

Normes du C

Le langage C a connu plusieurs normes qui ont permis d’améliorer le langage et de donner une direction dans l’implémentation des compilateurs. Un certain niveau de rétrocompatibilité avec les versions précédentes est maintenu dans la plupart des cas.

  • C K&R (1978)

    Première version non standardisée, présentée dans le livre « The C Programming Language » de Brian Kernighan et Dennis Ritchie.

  • ANSI C/C89 (1989) - ISO C / C90 (1990)

    Première normalisation officielle par l’ANSI. Ajout des prototypes de fonctions, de mots-clés et meilleure portabilité du code. Présentée dans le livre « The C Programming Language 2nd edition ».

  • C99 (1999)

    Mise à jour majeure avec entre autres, les commentaires //, les déclarations dans les boucles, les types entiers fixes (<stdint.h>), les tableaux de taille variable (VLA), les initialisations désignées, et une meilleure prise en charge du standard IEEE pour les flottants.

  • C11 (2011)

    Mise à jour majeure avec un support standardisé des threads (<threads.h>), des opérations atomiques (<stdatomic.h>), de nouveaux mots-clés et des améliorations de sécurité.

  • C17/C18 (2018)

    Révision mineure de C11, corrections sans nouvelles fonctionnalités.

  • C23/C2x (2023)

    Modernisation du langage et meilleure portabilité avec le C++.

  • C2y (202?)

    En cours de développement avec pour objectifs d’améliorer la sécurité et l’interopérabilité.

Comme pour le C++, le comité de standardisation du langage C définit les évolutions du langage. Les développeurs de compilateurs (comme GCC, Clang, MSVC, …) doivent ensuite implémenter ces spécifications. Il peut donc exister un décalage de plusieurs années entre la publication d’une norme et sa prise en charge complète dans les compilateurs (voir C compiler support et C++ compiler support).

Hello World!

Le premier programme classique pour débuter dans un langage est d’afficher Hello World! dans la console. Voici un exemple en C :

1 #include <stdio.h>
2 #include <stdlib.h>
3
4 int main(void) {
5     printf("Hello World!\n");
6     return EXIT_SUCCESS;
7 }

Voici le détail ligne par ligne des différentes étapes :

 1// import de printf
 2// (stdio → standard input output, entrée sortie standard)
 3#include <stdio.h>
 4// import de EXIT_SUCCESS (0 → fin normale)
 5// et EXIT_FAILURE (1/autre → erreur)
 6// (stdlib → standard library, bibliothèque standard)
 7#include <stdlib.h>
 8
 9// le point d'entrée du programme :
10// la première fonction appelée à l'exécution
11int main(void) {
12    // écrit un message sur la sortie standard (stdout)
13    printf("Hello World!\n");
14
15    // fin du programme
16    // retourne un code de succès au système d'exploitation
17    return EXIT_SUCCESS;
18}

Le C étant un langage compilé, le code source écrit par le programmeur doit être traduit en langage machine avant de pouvoir l’exécuter. Ce processus est effectué par un compilateur, qui génère d’abord un code objet (binaire intermédiaire), avant de l’assembler et de faire l’édition de liens pour produire un exécutable prêt à être lancé par le système d’exploitation. Voir la partie Compilation de la page Outils pour plus de détails.

Pour compiler cet exemple avec GCC (GNU Compiler Collection) :

gcc -std=c2x -Wall -Wextra -pedantic hello.c -o hello
  • -std=c2x : indique au compilateur d’utiliser la norme C23.

  • -Wall : active les avertissements classiques (erreurs de base, variables inutilisées, etc.).

  • -Wextra : active encore plus d’avertissements utiles.

  • -pedantic : signale toute construction non conforme strictement à la norme.

  • -o : change le nom du programme créé (a.out par défaut).

Ces options permettent de rendre le compilateur plus bavard : elles ne modifient pas le comportement du programme, mais aident à détecter des erreurs ou mauvaises pratiques dès la compilation.

Une fois le programme compilé, nous pouvons l’exécuter avec :

$ ./hello
Hello World!

Les variables

Une variable se compose de quatre éléments :

  • un type (qui détermine la nature et la taille de la donnée),

  • un nom (l’identifiant que vous choisissez),

  • un espace mémoire réservé (dont la taille dépend du type) identifié grâce à son adresse,

  • une valeur (optionnelle à l’initialisation, mais obligatoire avant toute utilisation) stockée dans l’espace mémoire réservé.

Les variables sont déclarées sous la forme :

// Déclaration
type nom_variable;
// Déclaration et initialisation (fortement recommandé)
type nom_variable = valeur;

Pour le nom de la variable :

  • il doit commencer par une lettre ou un _ (underscore)

  • il ne peut contenir que des lettres, chiffres et underscores

  • il ne doit pas être un mot-clé du langage (ex. : int, return, if, …)

Par convention, on utilise souvent le snake_case (ma_variable) en C (convention utilisée dans ce cours).

Pour la valeur :

  • elle doit correspondre au type déclaré

  • utiliser une variable non initialisée peut conduire à des comportements indéterminés

  • la valeur peut être modifiée si la variable n’est pas déclarée constante avec le mot-clé const

Pour le type, voici un sous ensemble des principaux types en C que nous utiliserons dans ce cours (voir liste plus complète sur Wikipédia) :

 1#include <stdbool.h> // pour importer bool (inutile en C23)
 2#include <stdio.h>
 3#include <stdlib.h>
 4
 5int main(void) {
 6
 7    // booléen
 8    bool b = true;
 9    printf("bool b = '%b'\t(taille: %zu octet(s))\n", b, sizeof(b));
10
11    // caractère pouvant être signé ou non signé avec unsigned
12    // (attention : utiliser des apostrophes simples 'A' et non "A")
13    char c = 'A';
14    printf("char c = '%c'\t(taille: %zu octet(s))\n", c, sizeof(c));
15
16    // entier signé
17    // ou non signé avec unsigned
18    int i = 42;
19    printf("int i = %d\t(taille: %zu octet(s))\n", i, sizeof(i));
20
21    // entier non signé pour les tailles et index
22    // ou signé avec ssize_t
23    // (spécifique aux fonctions système et mémoire)
24    size_t s = sizeof(int);
25    printf("size_t s = %zu\t(taille: %zu octet(s))\n", s, sizeof(s));
26
27    // nombre à virgule flottante
28    // float et double
29    double d = 3.1415926535;
30    printf("double d = %1.2lf\t(taille: %zu octet(s))\n", d, sizeof(d));
31
32    // pointeur vers entier
33    int *p = &i;
34    printf("int* p = %p\t(pointe vers i = %d)\n", (void *)p, *p);
35
36    // pointeur générique (void*)
37    void *vp = &c; // peut pointer vers n'importe quoi
38    printf("void* vp = %p\t(pointe vers char c)\n", vp);
39
40    return EXIT_SUCCESS;
41}

L’opérateur sizeof retourne la taille en mémoire (en octets, ou bytes en anglais, un octet = 8 bits dont les valeurs sont 0 ou 1) de la variable ou du type donné.

Résultat (dépendant de l’architecture de votre machine, 32 bits ou 64 bits) :

bool b = '1'	(taille: 1 octet(s))
char c = 'A'	(taille: 1 octet(s))
int i = 42	(taille: 4 octet(s))
size_t s = 4	(taille: 8 octet(s))
double d = 3.14	(taille: 8 octet(s))
int* p = 0x7ffdcec93f54	(pointe vers i = 42)
void* vp = 0x7ffdcec93f52	(pointe vers char c)

Avec le mot-clé const, une variable devient une constante et ne peut plus être modifiée :

const int my_int = 42;
my_int = 4; // Erreur : assignment of read-only variable ‘my_int'

L’output suivant montre les adresses et valeurs en mémoire pour ces variables :

bool b = true;
unsigned char uc = 200;
int i1 = 255;
int i2 = 256;
int i3 = 2147483647;
int32_t i32 = 1500;
int64_t i64 = 345623132;
float f = 3.1415926f;
double d = -12345.6789;
bool           b  = true  (base=0x7ffe6c04df72, taille=1 octet)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df72  0x01    1    0000 0001

unsigned char uc = 200  (base=0x7ffe6c04df73, taille=1 octet)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df73  0xC8  200    1100 1000

int           i1 = 255  (base=0x7ffe6c04df74, taille=4 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df74  0xFF  255    1111 1111
    1  +1           0x7ffe6c04df75  0x00    0    0000 0000
    2  +2           0x7ffe6c04df76  0x00    0    0000 0000
    3  +3           0x7ffe6c04df77  0x00    0    0000 0000

int           i2 = 256  (base=0x7ffe6c04df78, taille=4 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df78  0x00    0    0000 0000
    1  +1           0x7ffe6c04df79  0x01    1    0000 0001
    2  +2           0x7ffe6c04df7a  0x00    0    0000 0000
    3  +3           0x7ffe6c04df7b  0x00    0    0000 0000

int           i3 = 2147483647  (base=0x7ffe6c04df7c, taille=4 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df7c  0xFF  255    1111 1111
    1  +1           0x7ffe6c04df7d  0xFF  255    1111 1111
    2  +2           0x7ffe6c04df7e  0xFF  255    1111 1111
    3  +3           0x7ffe6c04df7f  0x7F  127    0111 1111

int32_t       i32= 1500  (base=0x7ffe6c04df80, taille=4 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df80  0xDC  220    1101 1100
    1  +1           0x7ffe6c04df81  0x05    5    0000 0101
    2  +2           0x7ffe6c04df82  0x00    0    0000 0000
    3  +3           0x7ffe6c04df83  0x00    0    0000 0000

int64_t       i64= 345623132  (base=0x7ffe6c04df88, taille=8 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df88  0x5C   92    0101 1100
    1  +1           0x7ffe6c04df89  0xCA  202    1100 1010
    2  +2           0x7ffe6c04df8a  0x99  153    1001 1001
    3  +3           0x7ffe6c04df8b  0x14   20    0001 0100
    4  +4           0x7ffe6c04df8c  0x00    0    0000 0000
    5  +5           0x7ffe6c04df8d  0x00    0    0000 0000
    6  +6           0x7ffe6c04df8e  0x00    0    0000 0000
    7  +7           0x7ffe6c04df8f  0x00    0    0000 0000

float         f  = 3.1415926f  (base=0x7ffe6c04df84, taille=4 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df84  0xDA  218    1101 1010
    1  +1           0x7ffe6c04df85  0x0F   15    0000 1111
    2  +2           0x7ffe6c04df86  0x49   73    0100 1001
    3  +3           0x7ffe6c04df87  0x40   64    0100 0000

double        d  = -12345.6789  (base=0x7ffe6c04df90, taille=8 octets)
Idx  Offset  Adresse             Hex    Dec    Binaire
---  ------  ------------------  -----  -----  ---------
    0  +0           0x7ffe6c04df90  0xA1  161    1010 0001
    1  +1           0x7ffe6c04df91  0xF8  248    1111 1000
    2  +2           0x7ffe6c04df92  0x31   49    0011 0001
    3  +3           0x7ffe6c04df93  0xE6  230    1110 0110
    4  +4           0x7ffe6c04df94  0xD6  214    1101 0110
    5  +5           0x7ffe6c04df95  0x1C   28    0001 1100
    6  +6           0x7ffe6c04df96  0xC8  200    1100 1000
    7  +7           0x7ffe6c04df97  0xC0  192    1100 0000

Les colonnes Hex/Dec/Binaire sont des vues du même octet ; l’ordre des octets dans la mémoire (indice 0,1,2,3,…) dépend de l’endianness (l’ordre dans lequel les octets doivent être lus).

Les entiers signés sont en complément à deux (d’où INT_MAX = 0x7FFFFFFF).

float/double suivent IEEE-754 : val = (-1)^s x (1+mantisse) x 2^(exp-bias) (hors cas spéciaux).

L’alignement explique les frontières d’adresses « propres » (x4 pour int/float, x8 pour int64_t/double).

LSB (Least Significant Bit/Byte) : poids faible — le bit (ou l’octet) qui compte le moins, côté droit.

MSB (Most Significant Bit/Byte) : poids fort — le bit (ou l’octet) qui compte le plus, côté gauche.

  • Endianness On voit partout que l’octet d’indice 0 (adresse la plus basse) contient le poids faible de la valeur : i1 = 255 FF 00 00 00 ; i2 = 256 00 01 00 00. La machine est donc en little-endian. En big-endian, on aurait 00 00 00 FF et 00 00 01 00 le sens plus « naturel » pour un humain pour lire la valeur.

  • Unités d’allocation Chaque ligne d’octet a une adresse qui s’incrémente de +1. Les types >1 octet occupent un bloc contigu (4 pour int, 8 pour int64_t/double, etc.).

  • Alignement Les bases respectent les alignements usuels x86_64 :

    • int/float à des adresses multiples de 4 (…74, …78, …7C, …80, …84),

    • int64_t/double à des adresses multiples de 8 (…88, …90).

    Le compilateur peut insérer du padding ou réordonner légèrement pour respecter ces contraintes. Comme pour le float f qui est placé à la suite du int32_t i32 et avant le int64_t i64 qui est pourtant déclaré ensuite.

Entrées - sorties

Sorties

Comme vu avec le programme « Hello World! » ou les variables et leurs types, printf permet d’afficher des informations dans la console, la sortie standard ou stdout.

La fonction printf est définie dans la bibliothèque standard <stdio.h> (doc, man 3 printf).

int printf( const char* restrict format, ... );

Elle affiche du texte formaté à l’aide d’un template (le texte donné comme premier argument) pouvant contenir des spécificateurs de format (%d par exemple) suivi de valeurs ou variables.

Voici quelques spécificateurs de format courants :

  • %c : char

  • %d : int

  • %f : float / double

  • %lf : double (pour scanf)

  • %s : char *

  • %zu : size_t

  • %p : void * (il faut caster le pointeur en (void *))

Il est possible d’ajouter un padding comme %04d pour ajouter autant de 0 qu’il faut pour que l’affichage prenne 4 caractères, ou %4d pour des espaces, voir exemple de la doc.

Entrées

Pour récupérer des données de l’utilisateur depuis le clavier, entrée standard ou stdin, nous pouvons utiliser, entre autres, scanf (doc, man 3 scanf).

int scanf( const char* restrict format, ... );

Elle récupère l’entrée utilisateur grâce au format demandé et à l’adresse de la variable où saisir la valeur. L’adresse d’une variable est donnée avec & (par exemple, &my_int) et permet à la fonction scanf d’aller directement modifier l’espace mémoire de la variable avec l’entrée utilisateur.

int age;
printf("Quel est votre âge ? ");
scanf("%d", &age);
printf("Vous avez %d ans.\n", age);

Les spécificateurs de format sont les mêmes que pour printf.

scanf peut être suffisant pour lire des entiers ou flottants simples, mais il présente plusieurs limites que nous verrons avec les chaînes de caractères.

Les opérateurs

Les opérateurs en C sont les suivants :

  • Opérateurs arithmétiques (a @ b), pour effectuer des opérations mathématiques :

    • addition : +

    • soustraction : -

    • multiplication : *

    • division : /

    • modulo : % (reste de la division entière)

      int a = 7;
      int b = 3;
      printf("%d\n", a + b);  // 10
      printf("%d\n", a % b);  // 1
      
  • Opérateurs de comparaison (a @ b), qui retournent un booléen (0 ou 1) :

    • inférieur à : <

    • inférieur ou égal à : <=

    • supérieur à : >

    • supérieur ou égal à : >=

    • égal à : ==

    • différent de : !=

  • Opérateurs logiques (a @ b), pour manipuler des valeurs booléennes vrai/faux (0 ou 1) :

    • non logique : ! (!a, inverse la valeur booléenne)

    • et logique : &&

    • ou logique : ||

  • Opérateurs d’affectation (a @ b), pour modifier la valeur d’une variable :

    • affectation simple : =

    • addition puis affectation : +=

    • soustraction puis affectation : -=

    • multiplication puis affectation : *=

    • division puis affectation : /=

    • modulo puis affectation : %=

  • Incrémentation / décrémentation (@a pour préfixe, a@ pour postfixe) :

    • incrémente de 1 : ++

    • décrémente de 1 : --

      int i = 1;
      printf("%d\n", ++i);  // affiche 2, puis i vaut 2
      printf("%d\n", i++);  // affiche 2, puis i vaut 3
      
  • Opérateurs bit à bit (a @ b), pour manipuler les bits individuels dans les variables entières (utilisés en programmation bas niveau) :

    • ou bit à bit : |

    • et bit à bit : &

    • ou exclusif (XOR) : ^

    • non bit à bit : ~ (~a)

    • décalage à gauche : <<

    • décalage à droite : >>

      unsigned int x = 5;      // binaire 0101
      unsigned int y = x << 1; // décalage à gauche : 1010 (10 en décimal)
      

Comme en mathématiques, les opérateurs ont des priorités.

Les parenthèses peuvent être utilisées pour forcer l’ordre d’évaluation. En l’absence d’ordre clair entre opérateurs de même priorité, le choix dépend du compilateur et de ses optimisations ou, dans certains cas, l’ordre des opérandes est non spécifié et certaines combinaisons provoquent un comportement indéfini. Ce comportement peut entraîner des décalages importants dans les résultats avec les nombres flottants en fonction du compilateur et du niveau d’optimisation choisis.

Contrôle du flux

Le contrôle du flux permet de diriger l’exécution du programme selon des conditions.

Les instructions if, else if et else permettent d’exécuter des blocs de code en fonction de conditions.

if (condition1) {
    // instructions si condition1 vraie
} else if (condition2) {
    // instructions si condition2 vraie
} else {
    // instructions si aucune condition vraie
}

Exemple :

int a = 5;

if (a != 8) {
    printf("a n'est pas un 8");
}

if (a % 2 == 0) {
    printf("a est pair\n");
} else {
    printf("a est impair\n");
}


if (a > 10) {
    printf("a est plus grand que 10\n");
} else if (a > 3) {
    printf("a est entre 4 et 10\n");
} else {
    printf("a est 3 ou moins\n");
}

La structure switch permet de sélectionner une action parmi plusieurs selon la valeur d’une variable entière ou d’un caractère.

switch (variable) {
    case valeur1:
        // instructions
        break;
    case valeur2:
        // instructions
        break;
    ...
    default:
        // instructions par défaut
}

Sans le mot-clé break, toutes les instructions sous le case sont exécutées.

Exemple :

char grade = 'B';

switch (grade) {
    case 'A':
        printf("Excellent\n");
        break;
    case 'B':
        // pas de break
        // donc affiche le Bien du cas 'C'
    case 'C':
        printf("Bien\n");
        break;
    case 'D':
        printf("Passable\n");
        break;
    default:
        printf("Grade inconnu\n");
}

Les boucles

Les boucles permettent de répéter une série d’instructions tant qu’une condition est remplie. Le langage C propose trois types principaux de boucles : for, while et do...while.

La boucle for est souvent utilisée quand le nombre d’itérations est connu à l’avance. Elle se compose de trois parties : - initialisation ; - condition (test à chaque tour) ; - mise à jour (à chaque fin d’itération).

for (initialisation; condition; mise à jour) {
    // instructions à répéter
}

Exemple :

for (int i = 0; i < 5; i++) {
    printf("i vaut %d\n", i);
}

La boucle while teste la condition avant chaque itération. Si la condition est fausse dès le départ, le bloc ne sera jamais exécuté.

while (condition) {
    // instructions à répéter
}

Exemple :

int i = 0;
while (i < 5) {
    printf("i vaut %d\n", i);
    i++;
}

La boucle do...while est similaire à while, mais la condition est testée après l’exécution du bloc. Le corps de la boucle est donc exécuté au moins une fois, même si la condition est fausse.

do {
    // instructions à répéter
} while (condition);

Exemple :

int i = 0;
do {
    printf("i vaut %d\n", i);
    i++;
} while (i < 5);

Une boucle peut être interrompue ou passée :

  • break : sort immédiatement de la boucle

    for (int i = 0; i < 10; i++) {
        if (i == 5){
            break;
        }
        printf("%d ", i);
    }
    
    // affiche : 0 1 2 3 4
    
  • continue : saute à l’itération suivante (ignore les instructions restantes du bloc courant)

    for (int i = 0; i < 5; i++) {
        if (i == 2) {
            continue;
        }
        printf("%d ", i);
    }
    
    // affiche : 0 1 3 4
    

Les fonctions

Une fonction permet de regrouper un ensemble d’instructions sous un même nom afin de pouvoir les réutiliser facilement.

En C, une fonction se compose : - d’un type de retour (le type de la valeur renvoyée), - d’un nom, - de paramètres (éventuellement), - d’un corps (le bloc d’instructions à exécuter).

La déclaration (ou prototype) indique au compilateur l’existence de la fonction :

void bonjour(void);
int get_number(void);
int addition(int a, int b);

La définition fournit le corps de la fonction :

void bonjour(void) {
    printf("Bonjour !\n");
}

int get_number(void) {
    return 42;
}

int addition(int a, int b) {
    return a + b;
}

Une fonction doit être déclarée avant son appel. Pour appeler une fonction, on écrit son nom suivi des arguments entre parenthèses :

bonjour();

int number = get_number();
printf("Numéro : %d\n", number);

int result = addition(3, 4);
printf("Résultat : %d\n", result);

Note

Avant C23, sans le void dans la déclaration des paramètres, un appel comme bonjour("coucou") compile sans erreur.

Il est donc fortement recommandé de préciser le void quand c’est nécessaire.

On place souvent les prototypes de fonction en haut du fichier .c ou dans un fichier .h.

#include <stdio.h>

// Déclaration/Prototype
int carre(int n);

int main(void) {
    int x = 5;
    printf("Le carré de %d est %d\n", x, carre(x));
    return 0;
}

// Définition
int carre(int n) {
    return n * n;
}

La première fonction appelée à l’exécution d’un programme en C est la fonction spéciale main. Elle constitue le point d’entrée du programme.

Cette fonction peut être définie de plusieurs manières :

// sans arguments
int main(void)
int main() // préférer la forme explicite avec void

// ou
// avec argc et argv comme arguments
int main(int argc, char **argv)
int main(int argc, char *argv[])
int main(int argc, char *argv[argc + 1])

Les paramètres du main correspondent aux arguments passés par la ligne de commande :

  • argc (argument count) : un entier qui représente le nombre d’arguments passés en ligne de commande, y compris le nom du programme lui-même.

  • argv (argument vector) : un tableau de chaînes de caractères (pointeurs vers char) contenant chacun des arguments passés au programme.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[argc + 1]) {
    printf("Nombre d'arguments : %d\n", argc);
    for (int i = 0; i < argc; i++) {
        printf("Argument %d : %s\n", i, argv[i]);
    }
    return EXIT_SUCCESS;
}

// gcc -std=c2x -Wall -Wextra -pedantic exemple.c -o exemple

// ./exemple abc 123 a1b2c3 3.14
// Nombre d'arguments : 5
// Argument 0 : ./exemple
// Argument 1 : abc
// Argument 2 : 123
// Argument 3 : a1b2c3
// Argument 4 : 3.14

La fonction main doit toujours retourner un entier (int), généralement :

  • return 0; ou return EXIT_SUCCESS; indique que le programme s’est terminé normalement.

  • Un autre entier (souvent return 1; ou return EXIT_FAILURE;) peut signaler une erreur.

Les tableaux

Un tableau en C est une structure permettant de stocker plusieurs valeurs de même type en mémoire contiguë.

Déclaration d’un tableau d’entiers :

// tableau de 5 entiers
int tableau[5];

// avec initialisation
int notes[5] = {10, 12, 15, 9, 14};
int zéros[3] = {0}; // les autres éléments valent aussi 0
int valeurs[6] = {
    [1] = 12,
    [3] = 45,
    [5] = 78,
}; // les autres valeurs sont initialisées à 0

// en laissant le compilateur trouver la taille
int valeurs[] = {1, 2, 3, 4};  // taille = 4

La taille du tableau doit être connue à la compilation, elle doit donc être définie avec #define TAILLE 5 ou directement donnée dans la taille du tableau.

Danger

En C99, les VLA (Variable Length Arrays) ont été introduits dans la norme puis mis en optionnels dans la norme C11 (les compilateurs ne sont donc pas obligés de les implémenter depuis C11, comme MSVC sous Windows qui ne les implémente pas).

Ils permettent d’allouer des tableaux sur le stack (pile) avec une taille inconnue à la compilation (avec une variable, une entrée utilisateur, lecture dans un fichier, …). Ils peuvent donc causer des stack overflow (dépassement de la taille du stack, voir gestion mémoire). Ils sont peu portables et ne peuvent pas être utilisés dans des structures.

Si vous voulez un tableau de taille inconnue à la compilation, préférez l’allocation dynamique sur la heap (tas) que nous verrons plus bas avec malloc.

On accède aux éléments d’un tableau par leur indice, en commençant à 0 :

printf("Première note : %d\n", notes[0]);
notes[2] = 16;  // modifier la 3e note

for (int i = 0; i < 5; i++) {
    printf("Note %d : %d\n", i, notes[i]);
}

Avertissement

Attention : il n’y a pas de vérification des limites du tableau. L’accès hors limites provoque un comportement indéfini.

On peut déclarer des tableaux de tableaux avec plusieurs dimensions :

// tableau 1D
// [indice]
int tableau[5] = {10, 12, 15, 9, 14};

// tableau 2D
// [lignes][colonnes]
int matrice[3][2] = {
    {1, 2},
    {3, 4},
    {5, 6},
};

printf("%d\n", matrice[1][0]); // affiche 3

// tableau 3D
// [profondeur][lignes][colonnes]
int cube[2][3][4] = {
    {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12},
    },
    {
        {13, 14, 15, 16},
        {17, 18, 19, 20},
        {21, 22, 23, 24},
    },
};

printf("%d\n", cube[1][2][3]); // affiche 24
// indices: profondeur=1, ligne=2, colonne=3

// tableau 4D
// [t][z][y][x] (x = dimension la plus à droite)
int hyper[2][2][2][3] = {
    {
        // t = 0
        {{1, 2, 3}, {4, 5, 6}},    // z=0: y=0 puis y=1
        {{7, 8, 9}, {10, 11, 12}}, // z=1
    },
    {
        // t = 1
        {{13, 14, 15}, {16, 17, 18}},
        {{19, 20, 21}, {22, 23, 24}},
    },
};

printf("%d\n", hyper[1][1][1][2]); // affiche 24 (t=1,z=1,y=1,x=2)

Les chaînes de caractères

Une chaîne de caractères est un tableau de char terminé par un caractère nul (\0).

Déclaration d’une chaîne :

// utilisation similaire à celle d'un tableau
char nom1[6] = {'A', 'l', 'i', 'c', 'e', '\0'};

// utilisation plus concise et recommandée
char nom2[] = "Bob";  // équivalent à {'B','o','b','\0'}

printf("Nom : %s\n", nom2);
// affiche :
// Nom : Bob

La bibliothèque standard (<string.h>) propose plusieurs fonctions pour manipuler les chaînes (voir man 3 nom_fonction) :

#include <string.h>

char nom[20] = "Alice";
char nom_complet[40];

// copie nom → nom_complet
strcpy(nom_complet, nom);

// concaténation
strcat(nom_complet, " Dupont");

// longueur de "Alice" = 5
// strlen ignore le \0
size_t longueur = strlen(nom);

// comparaison
if (strcmp(nom, "Alice") == 0) {
    printf("C'est bien Alice !\n");
}

Attention : la taille de la chaîne doit inclure le \0 et toute chaîne doit finir par \0.

scanf peut être suffisant pour lire des entiers ou flottants simples, mais il présente plusieurs limites :

  • Il ignore les espaces lors de la lecture de chaînes (%s s’arrête au premier espace).

  • Il peut laisser des caractères non lus dans le tampon d’entrée (stdin), ce qui peut perturber les lectures suivantes.

  • Il est difficile à utiliser pour des saisies plus complexes ou robustes (détection d’erreurs, chaînes multi-mots, etc.).

Pour des lectures plus sécurisées et robustes, on peut utiliser fgets, pour lire une ligne entière depuis l’entrée utilisateur (jusqu’au retour à la ligne).

char *fgets(char *buffer, int taille, FILE *flux);
  • Elle lit une ligne depuis le flux (stdin ici) et la stocke dans buffer.

  • Lit jusqu’à taille - 1 caractères ou jusqu’à \n.

  • Ajoute toujours \0 à la fin.

  • Si le retour à la ligne est présent, il est conservé (\n).

char nom[100];
printf("Entrez votre nom : ");
fgets(nom, sizeof(nom), stdin);
printf("Bonjour %s!", nom);  // Attention, le \n est conservé !

Pour supprimer le retour à la ligne :

nom[strcspn(nom, "\n")] = '\0';

Les pointeurs

Un pointeur est une variable qui contient l’adresse mémoire d’une autre variable.

Pour déclarer un pointeur vers un type T, on écrit T *mon_pointeur. Pour donner l’adresse d’une variable on utilise &, mon_pointeur = &ma_variable donne à mon_pointeur l’adresse de ma_variable. Puis, on peut modifier la valeur à l’adresse pointée avec l’opérateur de déréférencement *, *p = 5.

int x = 42;
// p pointe vers x
int *p = &x;

// %p pour afficher une adresse avec le cast en (void *)
printf("Adresse de x : %p\n", (void *)&x);
printf("Adresse contenue dans p : %p\n", (void *)p);
printf("Valeur pointée par p : %d\n", *p);

printf("Modification de *p\n");
*p = 5;

printf("Valeur pointée par p : %d\n", *p);
printf("Valeur de x : %d\n", x);

Résultat :

Adresse de x : 0x7fff907fa4dc
Adresse contenue dans p : 0x7fff907fa4dc
Valeur pointée par p : 42
Modification de *p
Valeur pointée par p : 5
Valeur de x : 5

Les pointeurs permettent de modifier une variable dans une fonction (passage par adresse) :

void doubler(int *n) {
    *n = *n * 2;
}

int main(void) {
    int a = 10;
    doubler(&a);
    printf("%d\n", a);  // affiche 20
    return 0;
}

Le nom d’un tableau décroît en pointeur vers son premier élément dans la plupart des expressions :

int tab[3] = {1, 2, 3};
int *p = tab;  // équivalent à &tab[0]

printf("%d\n", *(p + 1));  // affiche 2
printf("%d\n", tab[1]);    // équivalent

Le *(p + 1) pratique ce que l’on appelle l’arithmétique de pointeurs, on demande au compilateur d’avancer dans la mémoire d’une case de la taille du type pointé. Ici p est un pointeur sur int, donc lorsque l’on fait p + 1, le compilateur comprend ajoute 1 * sizeof(int) à l'adresse contenue dans p. Nous n’avons pas besoin de lui dire de combien d’octets avancer, le compilateur le saura de lui-même :

int tab[3] = {1, 2, 3};
int *p = tab;

// affiche : sizeof(int) = 4
printf("sizeof(int) = %zu\n", sizeof(int));
// affiche : sizeof(tab) = 12
// sizeof d'un tableau renvoie sizeof(type des éléments) * nombre d'éléments
printf("sizeof(tab) = %zu\n", sizeof(tab));
// affiche : sizeof(p)   = 8
printf("sizeof(p)   = %zu\n", sizeof(p));

// affiche : tab     : 0x...9c
// 9c en hex → 156 en dec
printf("tab     : %p\n", (void *)tab); // ou &tab[0]
// affiche : p       : 0x...9c
// 9c en hex → 156 en dec
printf("p       : %p\n", (void *)p);
// affiche : &tab[1] : 0x...a0
// a0 en hex → 160 en dec
printf("&tab[1] : %p\n", (void *)(&tab[1]));
// affiche : p + 1   : 0x...a0
// a0 en hex → 160 en dec
printf("p + 1   : %p\n", (void *)(p + 1));
// affiche : &tab[2] : 0x...a4
// a4 en hex → 164 en dec
printf("&tab[2] : %p\n", (void *)(&tab[2]));
// affiche : p + 2   : 0x...a4
// a4 en hex → 164 en dec
printf("p + 2   : %p\n", (void *)(p + 2));

Dans l’exemple, le tableau est à l’adresse 0x...9c soit ...156 en décimal, tout comme le pointeur p. L’adresse à tab[1] ou p + 1 est à 0x...a0 soit ...160 en décimal, donc 4 octets plus loin (la taille d’un int étant de 4). L’adresse à tab[2] ou p + 2 est à 0x...a4 soit ...164 en décimal, donc encore 4 octets plus loin.

On peut donc parcourir un tableau avec un pointeur :

for (int *ptr = tab; ptr < tab + 3; ptr++) {
    printf("%p %d\n", (void *)ptr, *ptr);
}

Affiche :

0x...9c 1
0x...a0 2
0x...a4 3

Un pointeur peut aussi pointer vers un pointeur :

int x = 5;
int *p = &x;
int **pp = &p;

printf("%d\n", **pp);  // affiche 5

Un pointeur peut ne rien pointer : il vaut alors NULL (ou nullptr à partir de C23) :

int *p = NULL;
int *p = nullptr; // à partir de C23

Il est prudent de tester qu’un pointeur n’est pas NULL avant de l’utiliser :

if (p != NULL) {
    printf("%d\n", *p);
}

En C, les paramètres passés aux fonctions sont des copies des valeurs des variables données à la fonction. On parle de passage par valeur ou passage par copie :

int doubler(int a) {
    return a * 2;
}

int main(void){
    int ma_variable = 5;
    // ici ma_variable est copiée à l'appel de la fonction
    // le résultat est retourné par la fonction
    ma_variable = doubler(ma_variable);

    return 0;
}

Cependant, on peut vouloir modifier directement les variables (par exemple plusieurs à la fois), ou réserver la valeur de retour à un code d’erreur. On parle alors de passage par adresse :

void doubler(int *a) {
    *a *= 2;
}

int main(void) {
    int ma_variable = 5;
    // ici on donne l'adresse de ma_variable (adresse qui sera copiée)
    // le résultat est directement modifié par la fonction
    doubler(&ma_variable);

    return 0;
}

Il est possible de créer des pointeurs sur fonction avec la syntaxe type_retour (*nom_pointeur)(types_parametres) :

int addition(int a, int b) {
    return a + b;
}

int soustraction(int a, int b) {
    return a - b;
}

int operation(int a, int b, int (*op)(int, int)) {
    return op(a, b);
}

int main(void) {
    // affiche : 2 + 3 = 5
    printf("2 + 3 = %d\n", operation(2, 3, addition));
    // affiche : 2 - 3 = -1
    printf("2 - 3 = %d\n", operation(2, 3, soustraction));

    return 0;
}

void * est pointeur générique vers un objet (mémoire de données) de n’importe quel type. Vous rencontrerez beaucoup de fonctions prenant en entrée ou retournant en sortie un void *. Par exemple, malloc et free présentés juste après :

void *malloc(size_t size);
void free(void *ptr);
  • malloc prend en entrée une taille en octets et retourne un pointeur vers la mémoire allouée, pour éviter de créer une fonction malloc pour chaque type imaginable, la fonction retourne un void * qui peut gérer tous les types

  • free, de la même manière, prend en entrée un pointeur vers n’importe quel type pour le libérer

Les structures

Une structure permet de regrouper plusieurs variables (de types éventuellement différents) sous un même nom. C’est un moyen de créer ses propres types de données. Les variables d’une structure sont appelées champs ou membres de la structure.

On utilise le mot-clé struct :

struct Point {
    int x;
    int y;
};

Cela déclare un nouveau type struct Point avec deux champs : x et y que l’on peut accéder avec ..

struct Point p1;
p1.x = 3;
p1.y = 4;

// ou initialisation directe
struct Point p2 = {5, 6};

// ou initialisation avec les noms de champs :
struct Point p3 = {.x = 5, .y = 4};

// accès
printf("x : %d, y : %d\n", p2.x, p2.y);

Lorsqu’une structure est passée à une fonction, celle-ci est copiée :

// copie p à chaque appel
void afficher_point(struct Point p) {
    printf("x = %d, y = %d\n", p.x, p.y);
}

Il vaut donc mieux passer les structures par adresse avec l’aide d’un pointeur pour éviter une surconsommation de mémoire et de perte de temps de calcul :

// passage par adresse, seul le pointeur est copié
void afficher_point(struct Point *p) {
    printf("x = %d, y = %d\n", p->x, p->y);
}

void deplacer_point(struct Point *p) {
    p->x += 1;
    p->y += 1;
}

Dans le cas d’un pointeur sur struct, il ne faut plus utiliser le . pour accéder à la valeur d’un champ mais -> (qui est équivalent à (*p).x).

Pour simplifier l’utilisation des structures, nous pouvons utiliser typedef :

typedef struct {
    int x;
    int y;
} Point;

Point p = {1, 2};  // plus besoin d'écrire 'struct Point'

Une structure peut contenir d’autres structures :

typedef struct {
    Point coin_haut_gauche;
    Point coin_bas_droit;
} Rectangle;

Rectangle r = {{0, 0}, {10, 5}};

On peut créer des tableaux de structures :

Point points[3] = {{0, 0}, {1, 2}, {2, 4}};
for (int i = 0; i < 3; i++) {
    printf("(%d, %d)\n", points[i].x, points[i].y);
}

Gestion mémoire / Allocation dynamique

La mémoire dynamique permet d’allouer de la mémoire à l’exécution. Grâce à l’allocation dynamique, nous pouvons adapter la quantité de mémoire au moment de l’exécution en fonction des besoins. Par exemple, le nombre de livres d’une bibliothèque n’est pas connu au moment de la compilation du programme de gestion de l’inventaire et pourra varier au cours du temps, en allouant dynamiquement la mémoire, on peut se rapprocher au plus près des besoins exacts de l’utilisateur.

On utilise la bibliothèque <stdlib.h> qui propose ces fonctions :

  • malloc : Alloue un bloc de mémoire non initialisé

    int *tab = malloc(5 * sizeof *tab);
    // ou :
    // int *tab = malloc(5 * sizeof(int));
    
    // on demande alors un espace mémoire de taille 5 * la taille d'un int
    // (ou de la taille d'un élément pointé par tab, donc un int)
    
    // si malloc renvoie NULL, alors l'allocation ne s'est pas bien passée
    if (tab == NULL) { // ou if (!tab)
        // dans ce cas malloc écrit la raison de l'erreur dans la variable globale errno
        // et nous pouvons l'afficher avec perror
        // voir la section gestion des erreurs pour plus d'informations
        perror("Erreur malloc");
        exit(EXIT_FAILURE);
    }
    
    // on peut ensuite accéder aux éléments comme avec un tableau classique
    for (int i = 0; i < 5; i++) {
        tab[i] = i * 10;
    }
    
    // lorsque l'on n'a plus besoin de la mémoire allouée
    // on l'indique au système qui va la libérer
    // avec free
    free(tab);
    // tab pointe alors sur une partie de la mémoire qui ne lui appartient plus
    // il est préférable de le faire pointer sur NULL
    // pour éviter d'avoir un "dangling pointer"
    // (pointeur qui référence une zone invalide)
    tab = NULL;
    

    malloc demande la taille de la mémoire à initialiser et retourne un pointeur vers l’adresse du début de cette mémoire. La taille peut être passée en indicant le type, taille * sizeof(int) où on va demander taille blocs de 4 octets (taille d’un int), si taille = 5, on demande alors 5 * 4 = 20 octets consécutifs en mémoire.

    malloc ne modifie pas la mémoire allouée, il faut donc bien initialiser les valeurs avant de les utiliser.

  • calloc : Alloue et initialise à zéro

    int *zeroes = calloc(5, sizeof(*zeroes));  // 5 entiers à 0
    

    calloc demande le nombre d’éléments puis la taille d’un élément et retourne un pointeur vers un bloc mémoire où tous les bits sont à zéro.

  • realloc : Réalloue un bloc (agrandir/réduire)

    int * tab = malloc(5 * sizeof *tab);
    
    // ... on travaille avec tab et nous avons besoin de plus de ressources
    
    // on crée un autre pointeur qui va uniquement servir à vérifier que
    // tout se passe bien lors de l'allocation mémoire
    // realloc demande l'adresse de la mémoire à modifier (diminuer ou agrandir)
    // et la taille que doit faire le bloc à la fin de l'opération
    int *tmp = realloc(tab, 10 * sizeof *tab);
    
    // si la réallocation a échoué, tmp contient NULL
    if (tmp == NULL) {
        // mais tab possède toujours la mémoire allouée de 5 int
        // il faut donc la libérer avant de terminer le programme
        free(tab);
        tab = NULL;
        exit(EXIT_FAILURE);
    }
    // si tmp != NULL alors la réallocation s'est bien passée
    // on peut alors faire pointer tab vers tmp
    tab = tmp;
    // pour éviter un futur dangling pointer
    tmp = NULL;
    

    Dans le cas où on veut agrandir l’espace mémoire, la réallocation peut :

    • échouer et retourner NULL (plus assez de place en mémoire, par exemple)

    • réussir à agrandir l’espace précédent en gardant la mémoire contiguë sans avoir à le déplacer

    • ne pas avoir la place pour agrandir l’espace déjà alloué en gardant un bloc de mémoire contiguë et devoir donc déplacer les données précédentes vers une zone qui peut tout contenir de manière contiguë. Dans ce cas l’opération coûte plus cher en temps.

  • free : Libère une zone précédemment allouée

    Important : toujours libérer avec free() quand on n’a plus besoin de la mémoire. Attention cependant à ne pas la libérer deux fois (comportement indéfini).

    Une fuite mémoire se produit lorsqu’un bloc alloué n’est pas libéré avec free(). Cela peut épuiser la mémoire à long terme.

    Mettre ensuite le pointeur à NULL est une bonne pratique pour bien indiquer que le pointeur ne pointe sur plus rien.

    Voir l’utilisation de valgrind ou AddressSanitizer pour la détection de fuites ou d’erreurs de manipulation de la mémoire.

    free(tab);
    tab = NULL;
    

Quand on alloue dynamiquement, on reçoit un pointeur vers le bloc alloué.

Jusqu’ici, l’allocation que nous avons utilisé pour déclarer des variables dans le main ou les fonctions, par exemple avec int mon_int = 4;, est une allocation automatique sur le stack (ou pile en français) dont la durée de vie est limitée à celle du bloc où elle est déclarée.

Avec l’aide de malloc, nous avons accès à l’allocation dynamique qui permet de stocker des variables sur la heap (ou tas en français), grâce à ceci, la durée de vie de la variable est entre l’appel à malloc et sa libération avec free.

Nous pouvons aussi allouer des tableaux de structures :

#include <stdint.h> // SIZE_MAX
#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int hauteur;
    int largeur;
} Rectangle;

static void affiche_rectangle(const Rectangle *r) {
    int perimetre = 2 * (r->hauteur + r->largeur);
    int aire = r->hauteur * r->largeur;
    printf("Rectangle %dx%d (périmètre : %d, aire : %d)\n",
        r->hauteur,
        r->largeur,
        perimetre,
        aire);
}

int main(void) {
    size_t taille;

    printf("Combien de rectangles voulez-vous ? ");
    // flush assure que l'affichage se fasse bien vu qu'il n'y a pas de retour à
    // la ligne dans le printf
    fflush(stdout);
    if (scanf("%zu", &taille) != 1) {
        fprintf(stderr, "Entrée invalide.\n");
        return EXIT_FAILURE;
    }
    if (taille == 0) {
        // puts affiche un texte suivi d'un retour à la ligne dans stdout
        puts("Aucun rectangle à traiter.");
        return 0;
    }

    // Garde contre overflow : taille * sizeof(Rectangle)
    if (taille > SIZE_MAX / sizeof(Rectangle)) {
        fprintf(stderr, "Taille trop grande.\n");
        return EXIT_FAILURE;
    }

    Rectangle *rectangles = malloc(taille * sizeof *rectangles);
    if (!rectangles) {
        perror("malloc");
        return EXIT_FAILURE;
    }

    printf("Initialisation des rectangles :\n");
    for (size_t i = 0; i < taille; ++i) {
        printf("\t%2zu. Taille du rectangle (hauteur largeur) : ", i + 1);
        fflush(stdout);
        if (scanf("%d %d", &rectangles[i].hauteur, &rectangles[i].largeur) !=
            2) {
            fprintf(stderr, "Entrée invalide.\n");
            free(rectangles);
            return EXIT_FAILURE;
        }
        if (rectangles[i].hauteur <= 0 || rectangles[i].largeur <= 0) {
            fprintf(stderr, "Dimensions positives requises.\n");
            free(rectangles);
            return EXIT_FAILURE;
        }
    }

    puts("\nVoici vos rectangles :");
    for (size_t i = 0; i < taille; ++i) {
        printf("\t%2zu. ", i + 1);
        affiche_rectangle(&rectangles[i]);
    }

    free(rectangles);
    rectangles = NULL;
    return 0;
}

Dans le cas de tableaux dynamiques à deux dimensions (ou plus), deux choix s’offrent à nous :

  1. avoir un tableau de pointeur vers des tableaux qui ne sont pas forcément contigus en mémoire

  2. avoir un pointeur sur un espace mémoire que l’on traite comme un tableau à plusieurs dimensions

Dans le cas 1, on va donc allouer un premier pointeur sur pointeur sur notre type (int dans l’exemple) qui va nous donner les lignes du tableau, puis pour chaque pointeur sur le début de la ligne, allouer les colonnes :

#include <stdio.h>
#include <stdlib.h>

int main(void) {

    // le nombre de lignes et colonnes du tableau
    size_t nb_row = 5;
    size_t nb_col = 10;

    // tab2d va contenir les pointeurs vers les tableaux alloués dynamiquement
    // tab2d[i] pointera vers les colonnes de la ligne i
    int **tab2d = malloc(nb_row * sizeof *tab2d);
    if (!tab2d) {
        perror("Erreur malloc row");
        exit(EXIT_FAILURE);
    }

    for (size_t r = 0; r < nb_row; ++r) {
        tab2d[r] = malloc(nb_col * sizeof *tab2d[r]);
        if (!tab2d[r]) {
            perror("Erreur malloc col");
            // l'allocation n'a pas pu se faire
            // dans ce cas il faut libérer toute la mémoire déjà allouée
            for (size_t i = 0; i < r; ++i) {
                free(tab2d[i]);
            }
            free(tab2d);
            exit(EXIT_FAILURE);
        }
    }

    // ensuite nous pouvons initialiser le tableau et travailler avec
    for (size_t r = 0; r < nb_row; ++r) {
        for (size_t c = 0; c < nb_col; ++c) {
            tab2d[r][c] = c + r * nb_col;
        }
    }

    printf("Matrice :\n");
    printf("Colonne  | ");
    for (size_t c = 0; c < nb_col; ++c) {
        printf("%2zu ", c);
    }
    printf("\n");
    for (size_t c = 0; c < nb_col * 3 + 11; ++c) {
        printf("_");
    }
    printf("\n");
    for (size_t r = 0; r < nb_row; ++r) {
        printf("Ligne %2zu | ", r);
        for (size_t c = 0; c < nb_col; ++c) {
            printf("%2d ", tab2d[r][c]);
        }
        printf("\n");
    }

    // pour finir, nous devons libérer chaque zone mémoire allouée
    for (size_t r = 0; r < nb_row; ++r) {
        free(tab2d[r]);
    }
    // puis libérer le tableau de pointeur sur les lignes
    free(tab2d);
    tab2d = NULL;
    return 0;
}

Output :

Matrice :
Colonne  |  0  1  2  3  4  5  6  7  8  9
_________________________________________
Ligne  0 |  0  1  2  3  4  5  6  7  8  9
Ligne  1 | 10 11 12 13 14 15 16 17 18 19
Ligne  2 | 20 21 22 23 24 25 26 27 28 29
Ligne  3 | 30 31 32 33 34 35 36 37 38 39
Ligne  4 | 40 41 42 43 44 45 46 47 48 49

Dans le second cas, nous utilisons une matrice plate, on déclare un seul espace mémoire d’un bloc et on utilise la taille (en particulier la taille d’une colonne) de la matrice pour le parcourir :

#include <stdio.h>
#include <stdlib.h>

int main(void) {

    // le nombre de lignes et colonnes du tableau
    size_t nb_row = 5;
    size_t nb_col = 10;

    // flat_matrix va contenir la matrice aplatie
    int *flat_matrix = malloc(nb_col * nb_row * sizeof *flat_matrix);
    if (!flat_matrix) {
        perror("Erreur malloc");
        exit(EXIT_FAILURE);
    }

    // ensuite nous pouvons initialiser le tableau et travailler avec
    for (size_t r = 0; r < nb_row; ++r) {
        for (size_t c = 0; c < nb_col; ++c) {
            flat_matrix[c + r * nb_col] = c + r * nb_col;
        }
    }

    printf("Matrice :\n");
    printf("Colonne  | ");
    for (size_t c = 0; c < nb_col; ++c) {
        printf("%2zu ", c);
    }
    printf("\n");
    for (size_t c = 0; c < nb_col * 3 + 11; ++c) {
        printf("_");
    }
    printf("\n");
    for (size_t r = 0; r < nb_row; ++r) {
        printf("Ligne %2zu | ", r);
        for (size_t c = 0; c < nb_col; ++c) {
            printf("%2d ", flat_matrix[c + r * nb_col]);
        }
        printf("\n");
    }

    // pour finir, on libère le tableau
    free(flat_matrix);
    flat_matrix = NULL;
    return 0;
}

Output :

Matrice :
Colonne  |  0  1  2  3  4  5  6  7  8  9
_________________________________________
Ligne  0 |  0  1  2  3  4  5  6  7  8  9
Ligne  1 | 10 11 12 13 14 15 16 17 18 19
Ligne  2 | 20 21 22 23 24 25 26 27 28 29
Ligne  3 | 30 31 32 33 34 35 36 37 38 39
Ligne  4 | 40 41 42 43 44 45 46 47 48 49

Dans le cas de la matrice plate, l’équivalent de matrix[r][c] est flat_matrix[r * nb_col + c ]. On se décale r fois du nombre de colonnes (quand r = 3, on se décale de 3 * 10 = 30 on se retrouve sur la ligne 3) puis on ajoute l’indice de la colonne (quand c = 7 partant de l’indice 30, on avance de 7 et on trouve le 37).

Préprocesseur

Le préprocesseur C agit avant la compilation proprement dite. Il traite toutes les directives commençant par #.

On peut retrouver les #include qui insèrent le contenu d’un fichier que ça soit d’une bibliothèque standard, externe ou l’un de vos fichiers d’en-tête (.h). Les #define pour définir des constantes ou macros. Les #ifdef, #ifndef, #if, #else, #endif pour donner des directives conditionnelles de compilation, par exemple pour éviter les inclusions multiples d’un fichier (la directive #pragma once permet en une ligne d’éviter les inclusions multiples et est acceptée par la majorité des compilateurs).

my_super_functions.h :

 1// soit utiliser pragma once
 2#pragma once
 3// ou utiliser ifndef, pas les deux
 4#ifndef MY_SUPER_FUNCTIONS_H
 5#define MY_SUPER_FUNCTIONS_H
 6
 7#define MY_MAGIC_NUMBER 42
 8#define SQUARE(x) ((x) * (x))
 9
10/**
11* @brief My formula that compute the sum of the square of two numbers
12*
13* @param a first number
14* @param b second number
15* @return int a^2 + b^2
16*/
17int my_formula(int a, int b);
18
19/**
20* @brief My formula that compute the sum of the square of two numbers and add
21* some sprinkles of magic number
22*
23* @param a first number
24* @param b second number
25* @return int a^2 + b^2 + MY_MAGIC_NUMBER
26*/
27int my_magic_formula(int a, int b);
28
29#endif // MY_SUPER_FUNCTIONS_H

my_super_functions.c :

 1#include "my_super_functions.h"
 2
 3
 4int my_formula(int a, int b) {
 5    return SQUARE(a) + SQUARE(b);
 6}
 7
 8int my_magic_formula(int a, int b) {
 9    return SQUARE(a) + SQUARE(b) + MY_MAGIC_NUMBER;
10}

main.c :

 1#include <stdio.h>
 2
 3#include "my_super_functions.h"
 4
 5int main(void) {
 6    int a = 5;
 7    int b = 8;
 8    int result1 = my_magic_formula(a, b);
 9    int result2 = my_magic_formula(result1, b);
10
11    printf("result = %d\n", result2);
12
13    return 0;
14}

Gestion des erreurs

En C, il n’existe pas de mécanisme intégré comme les exceptions en C++/Java/TypeScript/… La gestion des erreurs repose sur plusieurs principes :

  1. Valeurs de retour La plupart des fonctions de la libc retournent une valeur spéciale en cas d’erreur (souvent -1 ou NULL). Exemple avec fopen :

    1FILE *f = fopen("fichier.txt", "r");
    2if (f == NULL) {
    3    perror("fopen");
    4    exit(EXIT_FAILURE);
    5}
    

    Ici, fopen retourne NULL si le fichier n’existe pas. perror affiche un message d’erreur basé sur la variable globale errno.

  2. errno errno (défini dans <errno.h>) est une variable globale modifiée par les fonctions système en cas d’erreur. Exemple :

     1#include <stdio.h>
     2#include <stdlib.h>
     3#include <errno.h>
     4#include <string.h>
     5
     6int main(void) {
     7    FILE *f = fopen("inexistant.txt", "r");
     8    if (f == NULL) {
     9        printf("Erreur %d : %s\n", errno, strerror(errno));
    10        return EXIT_FAILURE;
    11    }
    12    fclose(f);
    13    return EXIT_SUCCESS;
    14}
    

    Fonctions utiles : - perror("msg") : affiche msg suivi de la description de l’erreur courante. - strerror(errno) : retourne une chaîne décrivant l’erreur.

  3. Codes retour du programme Un programme en C retourne un entier à sa fin via return dans main ou exit. - EXIT_SUCCESS (0) → exécution correcte - EXIT_FAILURE (1) → échec Exemple :

    if (ptr == NULL) {
        fprintf(stderr, "Erreur malloc\n");
        exit(EXIT_FAILURE);
    }
    

Bonnes pratiques :

  • Toujours vérifier la valeur de retour des fonctions critiques (malloc, fopen, fork, etc.).

  • Utiliser perror ou strerror pour des messages d’erreur explicites.

  • Écrire les erreurs sur stderr plutôt que stdout (fait automatiquement avec perror).

  • Libérer les ressources (fichiers, mémoire) avant de quitter sur erreur.

Aléatoire

La fonction rand() (standard C, stdlib.h, man 3 rand) génère un nombre pseudo-aléatoire. Sa qualité est limitée, mais elle est disponible partout où le langage C est supporté.

int rand(void);

Retourne un entier compris entre 0 et RAND_MAX (au minimum 32767, valeur dépendante de l’implémentation).

Avant utilisation, il est conseillé d’initialiser le générateur avec srand() :

void srand(unsigned int seed);
  • Pour toujours avoir le même résultat à l’exécution :

    srand(42);
    
  • Pour avoir des résultats différents à chaque exécution, vous pouvez utiliser time(NULL) (man 2 time) qui renvoie le nombre de secondes écoulées depuis 1970-01-01 00:00:00 +0000 (UTC).

    srand((unsigned) time(NULL));
    

Exemple :

#include <stdlib.h>
#include <time.h>
#include <stdio.h>

int main(void) {
    srand((unsigned) time(NULL));

    // Nombre pseudo-aléatoire entre 0 et 99
    int r = rand() % 100;

    printf("%d\n", r);

    return 0;
}

Note

L’utilisation directe de l’opérateur modulo (rand() % n) introduit un biais si RAND_MAX+1 n’est pas multiple de n. Pour une distribution plus uniforme, on peut normaliser :

int rand_0_99(void) {
    return (int)((double)rand() / ((double)RAND_MAX + 1) * 100);
}

Autres mots-clés

En plus des mots clés comme if, while, … le langage C propose des mots-clés spécifiques qui influencent la durée de vie, la portée ou le comportement mémoire des variables.

static :

  • Utilisé dans une fonction, une variable static conserve sa valeur entre les appels.

    void compteur(void) {
        static int i = 0;
        i++;
        printf("Appel %d\n", i);
    }
    
  • Utilisé hors d’une fonction, static limite la portée à un fichier source.

extern :

Permet de déclarer une variable ou une fonction définie dans un autre fichier. Elle est souvent utilisée pour partager des variables globales entre fichiers.

// dans global.h
extern int compteur_global;

// dans main.c
#include "global.h"
int compteur_global = 0;

// dans autre.c
#include "global.h"

void incrementer(void) {
    compteur_global++;
}

volatile :

Le mot-clé volatile signale au compilateur qu’une variable peut changer à tout moment, sans que le code courant ne l’indique explicitement. Cela désactive certaines optimisations (ex. mise en cache). Utilisé pour les interactions avec le matériel ou les threads.

volatile int flag = 0;

void interruption(void) {
    flag = 1;  // modifié par une interruption matérielle
}

restrict :

restrict est utilisé avec des pointeurs pour indiquer qu’ils ne se chevauchent pas. Cela permet plus d’optimisations par le compilateur.

void addition(int * restrict a, int * restrict b, int * restrict c) {
    for (int i = 0; i < 1000; i++) {
        c[i] = a[i] + b[i];
    }
}

restrict garantit ici que a, b et c pointent vers des zones de mémoire distinctes.

const :

Déjà abordé dans la section sur les variables, const signifie que la valeur ne peut pas être modifiée. const améliore la sécurité et la clarté du code.

const int a = 5;         // variable constante
const int *p = &a;       // pointeur vers une valeur constante
int b = 5;
int * const q = &b;      // pointeur constant vers une valeur (modifiable si non const)

 void addition(const int *const restrict a,
             const int *const restrict b,
             int *const restrict c) {
     for (int i = 0; i < 1000; i++) {
         c[i] = a[i] + b[i];
     }
 }

Dans addition, les pointeurs vers a et b ne peuvent pas être modifiés, ni les valeurs pointées, pour c seules les valeurs pointées peuvent être modifiées. Avec les restrict le compilateur a l’assurance que a, b et c pointent vers des zones mémoires différentes.

inline :

Permet au compilateur d’insérer le code d’une fonction à l’endroit où elle est appelée, au lieu de faire un appel réel. Cela réduit le coût d’appel mais augmente la taille du binaire.

inline int carre(int x) {
    return x * x;
}