Partie 1 - Bases de C¶
Support présentationCette partie a pour objectif de rafraîchir vos connaissances en C et d’aborder des notions utiles pour la suite du cours ou déjà vues dans d’autres cours (allocation de la mémoire dans le stack (pile) ou la heap (tas), représentation des variables en binaire, etc.). Ce n’est ni 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 algorithmique et en programmation (peu importe le langage) et une pratique minimale du terminal.
Voir les ressources pour des cours et liens/indications vers la documentation.
Voir les outils que nous utiliserons dans ce cours.
Le glossaire rappelle le sens (et souvent l’origine anglaise) des termes et abréviations du 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.
Dennis Ritchie – concepteur du C |
Brian Kernighan – a contribué à la formalisation et à la diffusion du C (co-auteur de The C Programming Language) |
Malgré ses quelque 50 ans, il reste très utilisé grâce à sa vitesse d’exécution et à sa portabilité, et le restera très probablement pendant de nombreuses années. Zig et Rust se présentent comme des alternatives modernes au C. Rust est d’ailleurs le 3e langage à rejoindre le développement du noyau Linux, après l’assembleur et le C.
Il est notamment employé pour le développement de systèmes d’exploitation, de pilotes ou de systèmes embarqués.
Pour aller plus loin : 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 et de nouveaux mots-clés, 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 de taille fixe (<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 (finalisé en 2023, publié en 2024)
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 (GCC, Clang, MSVC, etc.) 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// déclare printf
2// (stdio → standard input output, entrées-sorties standard)
3#include <stdio.h>
4// définit 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 être exécuté. Ce processus est effectué par le compilateur : il traduit le C en assembleur, l’assembleur produit un fichier objet (binaire intermédiaire), puis l’éditeur de liens produit 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 -g 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 ;-g: ajoute les informations de debug (utiles pour valgrind et gdb, voir la partie Debug de la page Outils) ;-o: change le nom du programme créé (a.outpar défaut).
Les options -W… et -pedantic rendent 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 (par exemple
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 à un comportement indéfini ;
la valeur peut être modifiée si la variable n’est pas déclarée constante avec le mot-clé
const.
Types et sizeof¶
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 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 = %d\t(taille : %zu octet(s))\n", b, sizeof(b));
10
11 // caractère : signé ou non selon la plateforme,
12 // utiliser signed char / unsigned char pour l'imposer
13 // (attention : utiliser des apostrophes simples 'A' et non "A")
14 char c = 'A';
15 printf("char c = '%c'\t(taille : %zu octet(s))\n", c, sizeof(c));
16
17 // entier signé
18 // ou non signé avec unsigned
19 int i = 42;
20 printf("int i = %d\t(taille : %zu octet(s))\n", i, sizeof(i));
21
22 // entier non signé pour les tailles et indices
23 // ou signé avec ssize_t
24 // (spécifique aux fonctions système et mémoire)
25 size_t s = sizeof(int);
26 printf("size_t s = %zu\t(taille : %zu octet(s))\n", s, sizeof(s));
27
28 // entier non signé d'au moins 64 bits
29 // (compteurs ou sommes qui dépassent la capacité d'un int)
30 unsigned long long grand = 10000000000ULL;
31 printf("unsigned long long grand = %llu\t(taille : %zu octet(s))\n", grand, sizeof(grand));
32
33 // nombre à virgule flottante
34 // float et double
35 double d = 3.1415926535;
36 printf("double d = %.2f\t(taille : %zu octet(s))\n", d, sizeof(d));
37
38 // pointeur vers entier (voir la section Pointeurs)
39 int *p = &i;
40 printf("int *p = %p\t(pointe vers i = %d)\n", (void *)p, *p);
41
42 // pointeur générique (void *, voir la section Pointeurs)
43 void *vp = &c; // peut pointer vers n'importe quoi
44 printf("void *vp = %p\t(pointe vers char c)\n", vp);
45
46 return EXIT_SUCCESS;
47}
L’opérateur sizeof renvoie la taille en mémoire, en octets (bytes ; 1 octet = 8 bits), de la variable ou du type donné.
Pour une variable, on peut écrire sizeof x ou sizeof(x) ; pour un type, les parenthèses sont obligatoires : sizeof(int).
Sortie (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))
unsigned long long grand = 10000000000 (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)
Constantes¶
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'
Les octets¶
La mémoire est un ensemble de blocs de 8 bits appelés octets et repérés par une adresse.
Il existe plusieurs manières de représenter les octets :
en binaire :
0000 0010, avec des bits valant 0 ou 1 ;en hexadécimal :
0x02, avec un chiffre de 0 à F pour chaque groupe de 4 bits (appelé nibble) ;en décimal :
2, avec des chiffres de 0 à 9.
Les adresses (et plus généralement le contenu de la mémoire) sont représentées en hexadécimal car chaque octet correspond exactement à 2 chiffres hexadécimaux, ce qui rend la lecture des octets directe :
en décimal, un octet va de
0à255, soit 1 à 3 chiffres ;en hexadécimal, un octet va de
0x00à0xFF, soit toujours 2 chiffres.
Les variables en mémoire¶
Sur l’image ci-dessus, chaque ligne représente 8 octets consécutifs : on retrouve les octets de chaque variable, écrits en hexadécimal.
Les bool et char occupent 1 octet en mémoire, les int 4 octets contigus et les double 8 octets.
Ordre des octets (endianness) : sur nos machines (x86_64), l’octet de poids faible est rangé en premier, à l’adresse la plus basse (little-endian) :
int i = 256(0x00000100) est rangé00 01 00 00.Alignement : un
intcommence à une adresse multiple de 4, undoubleà une adresse multiple de 8. Le compilateur ajoute si besoin des octets de remplissage (padding), comme l’octet hachuré de l’image.
Un pointeur contient une adresse : en pratique, sur les machines utilisées, il occupe donc la même taille quel que soit le type pointé, que ce soit un pointeur sur un int ou sur un bool (8 octets sur une machine 64 bits, 4 en 32 bits).
Pour aller plus loin : contenu de la mémoire octet par octet
La sortie suivante montre les adresses et valeurs en mémoire pour les variables déclarées ci-dessous (le programme qui l’affiche n’est pas donné, seules les déclarations le sont ; les adresses et l’ordre des variables en mémoire dépendent du compilateur et de ses options) :
// #include <stdbool.h> pour bool
// #include <stdint.h> pour int32_t et int64_t
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 (indices 0, 1, 2, 3…) dépend de l’endianness (l’ordre de rangement des octets d’une valeur en mémoire).
Les entiers signés sont en complément à deux (d’où INT_MAX = 0x7FFFFFFF).
Les float/double suivent IEEE-754 : val = (-1)^s × (1+mantisse) × 2^(exp-bias) (hors cas spéciaux).
L’alignement explique les frontières d’adresses « propres » (multiples de 4 pour int/float, multiples de 8 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 aurait00 00 00 FFet00 00 01 00, soit l’ordre le plus « naturel » pour un humain.Unités d’allocation : chaque ligne (un octet) a une adresse qui augmente de 1 d’une ligne à l’autre. Les types de plus d’un octet occupent un bloc contigu (4 pour
int, 8 pourint64_t/double, etc.).Alignement : les adresses de début (
base) respectent les alignements usuels de 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 (octets de remplissage) ou réordonner légèrement les variables pour respecter ces contraintes. C’est le cas du
float f, placé aprèsint32_t i32et avantint64_t i64alors qu’il est déclaré après ce dernier.
Entrées-sorties¶
Sorties¶
Comme vu précédemment, printf permet d’afficher des informations dans la console (la sortie standard ou stdout).
La fonction printf et sa variante fprintf, qui écrit sur le flux de son choix, sont définies dans la bibliothèque standard <stdio.h> (doc, man 3 printf).
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
stream: pourfprintf, flux sur lequel écrire (stdout,stderr, ou un fichier ouvert) ;format: chaîne de caractères à formater ;...: éléments à afficher, dans l’ordre d’apparition des spécificateurs de format dans la chaîneformat.
Retourne le nombre de caractères affichés, ou une valeur négative en cas d’erreur.
La chaîne format peut contenir des spécificateurs de format (%d par exemple), remplacés dans l’affichage par les valeurs qui la suivent.
Voici quelques spécificateurs de format courants :
%c:char;%d:int;%u:unsigned int;%ld:long;%llu:unsigned long long;%f:float/double;%lf:double(obligatoire pourscanf, accepté parprintf) ;%s:char *;%zu:size_t;%p:void *(il faut convertir le pointeur en(void *), voir Conversions explicites (cast)).
On peut préciser une largeur minimale : %04d complète avec des 0 jusqu’à 4 caractères, %4d avec des espaces (voir l’exemple de la doc).
C’est une largeur minimale : une valeur plus longue est affichée en entier.
Pour les nombres à virgule, on peut aussi préciser le nombre de chiffres après la virgule : %.2f en affiche 2 (3.14 pour 3.1415926535).
Dans les chaînes, certains caractères s’écrivent avec une séquence d’échappement : \n (retour à la ligne), \t (tabulation), \\ (antislash), \" (guillemet) et \0 (caractère nul, fin de chaîne).
Un programme dispose dès son lancement de trois flux : l’entrée standard (stdin, le clavier par défaut), la sortie standard (stdout) et la sortie d’erreur (stderr), ces deux dernières affichées par défaut dans le terminal.
printf écrit sur stdout ; les messages d’erreur s’écrivent sur stderr avec fprintf(stderr, ...).
Les erreurs restent ainsi séparées des résultats du programme : si la sortie standard est redirigée vers un fichier (./prog > resultats.txt), les erreurs s’affichent toujours dans le terminal.
La sortie standard passe par un buffer (tampon en français) : quand elle est affichée dans un terminal, le texte n’est souvent affiché qu’à la fin d’une ligne (\n) ; redirigée vers un fichier ou un tube, elle n’est vidée que lorsque le buffer est plein ou à la fin du programme (le rôle du buffer est détaillé dans la partie Fichiers, voir Bufferisation).
fflush force l’écriture immédiate du contenu du buffer :
int fflush(FILE *stream);
stream: flux dont le buffer doit être vidé (NULLvide tous les flux ouverts en écriture).
Retourne 0, ou EOF en cas d’erreur (errno indique la cause).
fflush(stdout) est utile par exemple avant une saisie quand le message ne se termine pas par \n.
Conséquence pour le debug : un message encore dans le buffer est perdu si le programme plante (ici à cause d’un accès invalide, voir Les pointeurs).
printf("avant"); // pas de \n : le texte reste dans le buffer
int *p = NULL;
*p = 1; // accès invalide (voir les pointeurs) : le programme plante
// et "avant" n'est jamais affiché
Pour un affichage de debug, terminez le message par \n ou écrivez sur stderr (fprintf(stderr, ...)), qui n’a pas de buffer.
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 *format, ...);
format: format des éléments à lire ;...: pointeurs vers les éléments à modifier.
Retourne le nombre d’éléments lus et modifiés, ou EOF (End Of File) si la fin de l’entrée est atteinte ou si une erreur survient avant la première conversion (errno indique la cause en cas d’erreur).
Elle récupère l’entrée utilisateur grâce au format demandé et à l’adresse de la variable où stocker la valeur lue.
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 (voir Les pointeurs).
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, à quelques exceptions près : dans scanf, %f attend un float * et %lf un double *.
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.
Exemple d’entrées-sorties (Input/Output ou IO en anglais), qui utilise puts (affiche une chaîne suivie d’un \n) ainsi que fgets et strcspn, présentés dans la section Les chaînes de caractères :
1#include <stdio.h>
2#include <stdlib.h>
3#include <string.h>
4
5int main(void) {
6 char nom[100];
7 puts("Votre nom ?");
8 fgets(nom, sizeof(nom), stdin); // fgets pour chaînes
9 nom[strcspn(nom, "\n")] = '\0'; // retire le \n
10
11 int age;
12 puts("Quel est votre âge ?");
13 scanf("%d", &age); // scanf pour nombres
14
15 printf("Bonjour %s, %d ans.\n", nom, age);
16 return EXIT_SUCCESS;
17}
Les opérateurs¶
Voici les principaux opérateurs du C (la notation a @ b indique où se placent les opérandes, @ représentant l’opérateur) :
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); // 2 : entre deux entiers, / est une division entière printf("%d\n", a % b); // 1
Opérateurs de comparaison (
a @ b), qui retournent unintvalant 0 (faux) ou 1 (vrai) :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 (le résultat est unintvalant 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 (
@apour 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)
Opérateur conditionnel (ternaire)
condition ? a : b: vautasi la condition est vraie,bsinon.int x = -4; int abs_x = (x >= 0) ? x : -x; // 4
Comme en mathématiques, les opérateurs ont des priorités.
Les parenthèses peuvent être utilisées pour forcer l’ordre d’évaluation.
Pour aller plus loin : ordre d’évaluation
La priorité et l’associativité (ordre entre opérateurs de même priorité, par exemple de gauche à droite pour a - b - c) sont fixées par la norme.
En revanche, l’ordre d’évaluation des opérandes n’est pas spécifié (dans f() + g(), f ou g peut être appelée en premier), et modifier deux fois une variable dans une même expression (i++ + i++) est un comportement indéfini.
Avec les nombres flottants, l’ordre des opérations compte : (a + b) + c et a + (b + c) peuvent donner des résultats différents à cause des arrondis.
Conversions explicites (cast)¶
L’opérateur de conversion (cast) (type) expression convertit une valeur vers un autre type :
int a = 7;
int b = 2;
double moyenne = (double)a / b; // 3.5 (sans le cast : division entière, 3)
size_t n = 10;
int i = (int)n; // size_t → int
printf("%p\n", (void *)&a); // conversion en void * pour %p
Une conversion peut perdre de l’information (partie décimale, valeur trop grande pour le type cible) : écrire le cast explicitement indique au compilateur, et au lecteur, que ce choix est volontaire.
Autre moyen de fixer un type : un littéral entier peut porter un suffixe, 60u est un unsigned int, 60L un long.
Contrôle du flux¶
Le contrôle du flux permet de diriger l’exécution du programme selon des conditions.
if, else if, else¶
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 égal à 8\n");
}
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");
}
switch¶
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 break, l’exécution se poursuit dans les case suivants (fall-through) jusqu’au prochain break ou à la fin du switch.
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("Note inconnue\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.
for¶
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);
}
while¶
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++;
}
do…while¶
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);
break et continue¶
Une boucle peut être interrompue (break) ou une itération sautée (continue) :
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).
Déclaration et définition¶
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;
}
Appel¶
Une fonction doit être déclarée avant son appel (sinon, erreur de compilation : implicit declaration of function, voir l’encadré ci-dessous).
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 toujours écrire (void) pour une fonction sans paramètre.
On place souvent les prototypes de fonction en haut du fichier .c ou dans un fichier .h.
#include <stdio.h>
#include <stdlib.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 EXIT_SUCCESS;
}
// Définition
int carre(int n) {
return n * n;
}
Erreur fréquente : fonction utilisée avant d’être déclarée
error: implicit declaration of function 'carre' [-Wimplicit-function-declaration]
La fonction est appelée (ici dans main) avant que le compilateur ait vu son prototype ou sa définition.
Ajoutez le prototype en haut du fichier, avant main.
La fonction main¶
La première fonction de votre programme appelée à l’exécution est la fonction spéciale main (le code de démarrage de la libc s’exécute avant et l’appelle).
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])
Arguments de la ligne de commande : argc et argv¶
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 verschar, voir Les pointeurs) contenant chacun des arguments passés au programme.
La forme char *argv[argc + 1] indique que le tableau contient argc + 1 éléments (indication pour le lecteur, le compilateur ne la vérifie pas) : la norme garantit que argv[argc] vaut NULL, ce qui marque la fin des arguments.
#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 -g 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
En mémoire, pour ./exemple abc 123 (argc vaut 3), argv pointe vers un tableau de argc + 1 pointeurs, chacun pointant vers une chaîne de caractères (voir Les chaînes de caractères et Les pointeurs) :
Valeur de retour¶
La fonction main a pour type de retour int ; la valeur renvoyée est transmise au système d’exploitation :
return EXIT_SUCCESS;(oureturn 0;) indique que le programme s’est terminé normalement ;return EXIT_FAILURE;(ou un autre entier non nul) signale une erreur.
Depuis C99, en l’absence de return à la fin de main, le programme renvoie 0.
Les tableaux¶
Un tableau en C est une structure permettant de stocker plusieurs valeurs de même type en mémoire contiguë.
Dans la figure ci-dessous, la partie haute montre un int sur 4 octets, aux adresses 0x7f...00 à 0x7f...03, avec la valeur de chaque octet en binaire (octet de poids faible en premier, en little-endian).
À droite, on en donne une représentation simplifiée : une seule case, qui commence à l’adresse 0x7f...00 (et occupe les 3 octets contigus suivants) et contient 9.
Pour représenter un tableau de 5 int (partie basse), nous réservons 5 × 4 octets en mémoire (soit 20 octets), qui commencent ici à l’adresse 0x7f...00 ; le dernier int commence à l’adresse 0x7f...10 et finit donc à l’adresse 0x7f...13.
Déclaration¶
Déclaration d’un tableau d’entiers en C :
// tableau de 5 entiers
int tableau[5];
// avec initialisation
int notes[5] = {10, 12, 15, 9, 14};
int zeros[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 autres[] = {1, 2, 3, 4}; // taille = 4
La taille du tableau doit être connue à la compilation : elle doit donc être donnée directement ou définie avec une constante du préprocesseur, #define TAILLE 5 (voir Préprocesseur).
Pour un tableau dont la taille n’est connue qu’à l’exécution (saisie de l’utilisateur, lecture d’un fichier…), on utilisera l’allocation dynamique avec malloc (voir Gestion mémoire / Allocation dynamique).
Pour aller plus loin : tableaux de taille variable (VLA)
Les VLA (Variable Length Arrays) ont été introduits en C99, puis rendus optionnels en C11 : les compilateurs ne sont plus obligés de les implémenter (MSVC, sous Windows, ne le fait pas).
C23 rend de nouveau obligatoires les types de taille variable (utilisés par exemple dans les paramètres, comme char *argv[argc + 1]) : seuls les VLA déclarés dans le stack restent optionnels.
Ils permettent d’allouer des tableaux dans le stack avec une taille inconnue à la compilation (variable, entrée utilisateur, lecture dans un fichier, etc.). Ils peuvent donc causer des stack overflows (dépassements du stack, voir gestion mémoire). Ils sont peu portables et ne peuvent pas être utilisés comme champs de structures.
Accès aux éléments¶
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
Il n’y a pas de vérification des limites du tableau. L’accès hors limites provoque un comportement indéfini.
Tableaux à plusieurs dimensions¶
On peut déclarer des tableaux de tableaux avec plusieurs dimensions :
// tableau 1D
// [indice]
int tab1d[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
Pour aller plus loin : 3 dimensions et plus
// 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)
Le passage d’un tableau en paramètre d’une fonction est présenté dans la section Tableaux et fonctions.
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 et longueur¶
Déclaration d’une chaîne :
// utilisation similaire à celle d'un tableau
char prenom[6] = {'A', 'l', 'i', 'c', 'e', '\0'};
// utilisation plus concise et recommandée
char nom[] = "Bob"; // équivalent à {'B','o','b','\0'}
printf("Nom : %s\n", nom);
// affiche :
// Nom : Bob
printf("%zu %zu\n", strlen(nom), sizeof(nom));
// affiche : 3 4
// strlen (<string.h>) compte les caractères avant le \0
// sizeof donne la taille du tableau, \0 compris
Sans \0 final, les fonctions qui parcourent la chaîne (printf("%s"), strlen…) ne savent pas où elle s’arrête et continuent à lire la mémoire qui suit.
Pour parcourir une chaîne caractère par caractère, on s’arrête au \0 :
for (size_t i = 0; nom[i] != '\0'; ++i) {
printf("%c ", nom[i]);
}
// affiche : B o b
Fonctions de <string.h>¶
La bibliothèque standard (<string.h>) propose plusieurs fonctions pour manipuler les chaînes (voir man 3 nom_fonction) :
size_t strlen(const char *s);
char *strcpy(char *dest, const char *src);
char *strncpy(char *dest, const char *src, size_t n);
char *strcat(char *dest, const char *src);
int strcmp(const char *s1, const char *s2);
size_t strcspn(const char *s, const char *reject);
s,s1,s2: chaînes lues (terminées par\0) ;dest: tableau de destination, assez grand pour contenir le résultat,\0compris ;src: chaîne copiée dansdest(strcpy,strncpy) ou ajoutée à la fin de la chaînedest(strcat) ;n: nombre maximal de caractères copiés parstrncpy;reject: caractères recherchés parstrcspn.
strlen retourne le nombre de caractères avant le \0.
strcpy, strncpy et strcat retournent dest.
strcmp retourne 0 si les deux chaînes sont égales, une valeur négative si s1 est avant s2 dans l’ordre lexicographique (comparaison des codes des caractères), une valeur positive sinon.
strcspn retourne l’indice du premier caractère de s qui fait partie de reject, ou la longueur de s s’il n’y en a aucun.
strncpy copie au plus n caractères : si src compte moins de n caractères, la fin de dest est complétée par des \0 ; sinon, dest n’est pas terminée par \0.
Exemple :
#include <string.h>
char nom[20] = "Alice";
char nom_complet[40];
strcpy(nom_complet, nom); // nom_complet : "Alice"
strcat(nom_complet, " Dupont"); // nom_complet : "Alice Dupont"
size_t longueur = strlen(nom); // 5
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.
strcpy et strcat ne vérifient pas la place disponible : si la destination est trop petite, elles écrivent au-delà du tableau (comportement indéfini).
strncpy limite la copie à la taille donnée, mais il faut alors ajouter soi-même le \0 final :
char court[4];
strncpy(court, nom_complet, sizeof(court) - 1); // copie au plus 3 caractères
court[sizeof(court) - 1] = '\0'; // court : "Ali"
Les opérateurs = et == ne copient ni ne comparent les caractères d’une chaîne :
char copie[20];
// copie = nom; // erreur de compilation : on ne peut pas affecter un tableau
strcpy(copie, nom); // correct : copie les caractères
// if (copie == "Alice") { } // compare des adresses, pas les caractères
if (strcmp(copie, "Alice") == 0) { // correct : compare les caractères
printf("Identiques\n");
}
<string.h> contient aussi memset, qui travaille sur des octets plutôt que sur des chaînes :
void *memset(void *s, int c, size_t n);
s: adresse de la zone à remplir ;c: valeur donnée à chaque octet (convertie enunsigned char) ;n: nombre d’octets à remplir.
Retourne s.
memset sert surtout à mettre une zone à zéro, par exemple memset(buffer, 0, sizeof(buffer)).
Comme elle remplit octet par octet, memset(tab, 1, sizeof(tab)) sur un tableau d”int ne met pas chaque case à 1 (chaque int vaut 0x01010101).
Lire une chaîne au clavier¶
Comme annoncé plus haut, scanf présente plusieurs limites :
%ss’arrête au premier blanc (espace, tabulation, retour à la ligne) et, sans largeur maximale (%99s), peut déborder du tableau ;il peut laisser des caractères non lus dans le buffer d’entrée (
stdin), comme le\naprès unscanf("%d", ...), 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 *s, int size, FILE *stream);
s: tableau où stocker la ligne lue ;size: taille du tableau ;stream: flux à lire (stdinpour l’entrée standard).
Retourne s si la lecture réussit, ou NULL en fin de fichier ou en cas d’erreur (le contenu de s ne doit alors pas être utilisé).
La fonction fgets :
lit une ligne depuis le flux et la stocke dans
s;s’arrête après
size - 1caractères ou après le\n;ajoute
\0à la fin si la lecture réussit ;conserve le retour à la ligne (
\n) s’il a été lu.
char nom[100];
printf("Entrez votre nom : ");
if (fgets(nom, sizeof(nom), stdin) != NULL) {
printf("Bonjour %s !", nom); // Attention, le \n est conservé !
}
Pour supprimer le retour à la ligne :
nom[strcspn(nom, "\n")] = '\0';
strcspn renvoie ici l’indice du premier \n (ou la longueur de la chaîne s’il n’y en a pas) : on y place le \0 de fin.
Pour convertir une chaîne en entier, on peut utiliser atoi (<stdlib.h>, man 3 atoi) :
int atoi(const char *s);
s: chaîne à convertir (les blancs du début sont ignorés, puis un signe+ou-facultatif et des chiffres sont lus).
Retourne l’entier lu au début de s, ou 0 si s ne commence pas par un nombre. Le comportement est indéfini si la valeur ne tient pas dans un int.
La lecture s’arrête au premier caractère qui n’est pas un chiffre : atoi("42abc") retourne 42.
atoi ne signale aucune erreur : atoi("0") et atoi("abc") retournent tous les deux 0.
strtol fait la même chose mais permet de détecter les erreurs (voir man 3 strtol).
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 obtenir l’adresse d’une variable, on utilise & : mon_pointeur = &ma_variable donne à mon_pointeur l’adresse de ma_variable.
On peut ensuite modifier la valeur à l’adresse pointée avec l’opérateur de déréférencement * : *mon_pointeur = 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);
Sortie :
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
Dans la figure ci-dessous, my_int est un int stocké à l’adresse 0x7f...00 et dont la valeur est 9.
my_ptr est un pointeur sur int stocké à l’adresse 0x7f...08 dont la valeur est l’adresse de my_int (0x7f...00).
Nous utilisons principalement deux opérateurs avec les pointeurs :
&: donne l’adresse d’une variable ;*: utilisé dans deux cas :à la déclaration d’une variable : il indique que la variable est un pointeur vers un type ;
devant une variable pointeur (hors déclaration) : il permet de déréférencer le pointeur, c’est-à-dire d’accéder à la valeur stockée à l’adresse pointée.
Passage par valeur, passage par adresse¶
En C, une fonction reçoit une copie de la valeur de chaque argument : on parle de passage par valeur (ou passage par copie). Modifier le paramètre dans la fonction ne modifie donc pas la variable de l’appelant :
void incrementer(int x) {
x = x + 1; // modifie la copie seulement
}
int main(void) {
int a = 10;
incrementer(a);
printf("%d\n", a); // affiche 10
return EXIT_SUCCESS;
}
Pour qu’une fonction modifie directement une variable de l’appelant (par exemple pour produire plusieurs résultats, ou pour réserver la valeur de retour à un code d’erreur), on lui donne l’adresse de la variable : c’est le passage par adresse.
L’adresse est elle aussi copiée dans le paramètre, mais elle permet d’atteindre la variable d’origine avec * :
void incrementer(int *x) {
*x = *x + 1; // modifie la variable dont l'adresse est dans x
}
int main(void) {
int a = 10;
incrementer(&a);
printf("%d\n", a); // affiche 11
return EXIT_SUCCESS;
}
Chaque appel de fonction réserve dans le stack (pile) une zone pour ses paramètres et ses variables locales, appelée stack frame ; la stack frame disparaît au return.
Récapitulatif : passer de la version par valeur à la version par adresse change trois endroits.
void incrementer(int *x) { // 1. le type du paramètre prend une * : int devient int *
*x = *x + 1; // 2. dans la fonction, *x désigne la variable de l'appelant
}
incrementer(&a); // 3. à l'appel, on donne l'adresse de la variable : &a
Ce qu’on passe dépend de ce que la fonction doit modifier :
une variable (
int, une structure, et même un pointeur) : on passe son adresse avec&, et le paramètre a une*de plus que le type de la variable ;les cases d’un tableau : on passe le tableau directement, car son nom est déjà l’adresse de sa première case (voir Tableaux et fonctions).
Un exemple où la fonction modifie un pointeur de l’appelant (paramètre int **) est donné avec l’allocation dynamique (voir Allouer dans une fonction).
Erreur fréquente : oubli de & ou de *
warning: passing argument 1 of 'incrementer' makes pointer from integer without a cast [-Wint-conversion]
warning: assignment to 'int *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
« makes pointer from integer » : un entier est utilisé là où un pointeur est attendu.
Le premier message signale un & oublié à l’appel (incrementer(a) au lieu de incrementer(&a)), le second une * oubliée dans la fonction (x = 5; au lieu de *x = 5;).
Pointeurs et tableaux¶
Dans la plupart des expressions, le nom d’un tableau est converti implicitement en pointeur vers son premier élément :
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
*(p + 1) utilise l’arithmétique des pointeurs : p + 1 désigne l’élément suivant, sizeof(int) octets (4) plus loin que p.
Le compilateur connaît le type pointé : il calcule lui-même le décalage en octets.
p[i] est équivalent à *(p + i).
printf("sizeof(tab) = %zu\n", sizeof(tab)); // 12 : 3 int de 4 octets
printf("sizeof(p) = %zu\n", sizeof(p)); // 8 : la taille d'une adresse
printf("%p %p\n", (void *)p, (void *)(p + 1));
// affiche par exemple : 0x7f...20 0x7f...24 (4 octets d'écart)
On peut donc parcourir un tableau avec un pointeur :
for (int *ptr = tab; ptr < tab + 3; ptr++) {
printf("%p %d\n", (void *)ptr, *ptr);
}
Sortie :
0x7f...20 1
0x7f...24 2
0x7f...28 3
Tableaux et fonctions¶
Un tableau passé en paramètre d’une fonction est converti en pointeur vers son premier élément : la fonction ne connaît donc pas sa taille, qu’il faut passer en paramètre. Les deux écritures suivantes sont équivalentes :
void inverser_signes(int *tab, size_t n);
// ou :
void inverser_signes(int tab[], size_t n);
void inverser_signes(int *tab, size_t n) {
for (size_t i = 0; i < n; ++i) {
tab[i] = -tab[i];
}
}
// affichage : const car la fonction ne modifie pas le tableau
void afficher_tab(const int *tab, size_t n);
int tab[4] = {1, -2, 3, -4};
inverser_signes(tab, 4); // tab vaut {-1, 2, -3, 4}
Comme le tableau n’est pas copié (passage par adresse), la fonction modifie directement le tableau de l’appelant.
Bonnes pratiques : utiliser size_t pour les tailles et const si la fonction ne modifie pas le tableau.
Avec const int *tab, la fonction s’engage à ne pas modifier les cases : le compilateur refuse tab[0] = 1;.
C’est aussi pour cela que les fonctions sur les chaînes (strlen, strcpy, fgets…) prennent un char * : une chaîne est un tableau de char, converti en pointeur vers son premier caractère.
Pour lire une chaîne, le \0 indique où elle s’arrête ; pour écrire dans un tableau (fgets), il faut en donner la taille.
Pour un tableau 2D, il faut préciser la taille de la 2e dimension (le nombre de colonnes), pour que le compilateur sache calculer l’adresse de m[i][j] :
void afficher_mat(int m[][2], size_t lignes);
// ou (équivalent) : pointeur vers des tableaux de 2 int
void afficher_mat(int (*m)[2], size_t lignes);
int matrice[3][2] = {{1, 2}, {3, 4}, {5, 6}};
afficher_mat(matrice, 3);
Pointeurs de pointeurs¶
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 pointer vers rien : il vaut alors NULL (ou nullptr, à partir de C23) :
int *p1 = NULL;
int *p2 = nullptr; // à partir de C23
Il est prudent de tester qu’un pointeur n’est pas NULL avant de l’utiliser :
if (p1 != NULL) {
printf("%d\n", *p1);
}
On trouve souvent la forme courte if (!p1), équivalente à if (p1 == NULL) (un pointeur NULL est considéré comme faux).
Pointeurs de fonctions¶
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 EXIT_SUCCESS;
}
Le nom d’une fonction, sans parenthèses (addition), désigne son adresse : on la donne en argument comme n’importe quelle valeur, et la fonction qui la reçoit l’appelle quand elle en a besoin.
Nous nous en servirons pour indiquer la fonction à exécuter à la réception d’un signal (sigaction, partie Processus) et la fonction exécutée par un nouveau thread (pthread_create, partie Threads).
Pointeur générique¶
void * est un 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 plus loin dans la section Gestion mémoire / Allocation dynamique :
mallocprend en entrée une taille en octets et retourne un pointeur vers la mémoire allouée. Pour éviter de créer une fonctionmallocpour chaque type imaginable, elle retourne unvoid *, qui peut être converti vers n’importe quel type de pointeur ;free, de la même manière, prend en entrée unvoid *, donc un pointeur vers n’importe quel type, pour libérer la mémoire pointée.
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.
Déclaration¶
On utilise le mot-clé struct :
struct Point {
int x;
int y;
};
struct Point p1; // le type s'appelle struct Point
Cela déclare un nouveau type struct Point avec deux champs, x et y.
Pour ne pas répéter struct à chaque utilisation, on lui donne un nom plus court avec typedef (c’est la forme utilisée dans la suite du cours) :
typedef struct {
int x;
int y;
} Point;
Point p1; // on écrit simplement Point
Pour aller plus loin : structure anonyme et étiquette
Ici, la structure est anonyme : le type s’appelle uniquement Point et struct Point n’existe pas.
Pour pouvoir utiliser les deux noms (nécessaire pour une structure qui contient un pointeur vers elle-même, voir l’exemple du nœud), on donne aussi une étiquette (tag) à la structure :
typedef struct Point {
int x;
int y;
} Point;
struct Point p1 = {1, 2}; // les deux écritures
Point p2 = {3, 4}; // désignent le même type
Initialisation et accès aux champs¶
On accède aux champs avec l’opérateur . :
Point p1;
p1.x = 3;
p1.y = 4;
// ou initialisation directe
Point p2 = {5, 6};
// ou initialisation avec les noms de champs :
Point p3 = {.x = 5, .y = 4};
// accès
printf("x : %d, y : %d\n", p2.x, p2.y);
Copie d’une structure¶
L’affectation = copie tous les champs : on obtient deux structures indépendantes.
Point a = {3, 4};
Point b = a; // copie des champs x et y
b.x = 99; // a.x vaut toujours 3
De la même manière, lorsqu’une structure est passée à une fonction, elle est copiée :
// copie p à chaque appel
void afficher_point(Point p) {
printf("x = %d, y = %d\n", p.x, p.y);
}
Pour une petite structure comme Point (8 octets), cette copie ne coûte presque rien.
Pointeur vers une structure¶
Pour les structures volumineuses, ou pour les modifier dans une fonction, on passe plutôt un pointeur (const si on ne modifie pas la structure), afin d’éviter une surconsommation de mémoire et une perte de temps de calcul :
// passage par adresse, seul le pointeur est copié
void afficher_point_ptr(const Point *p) {
printf("x = %d, y = %d\n", p->x, p->y);
}
void deplacer_point(Point *p) {
p->x += 1;
p->y += 1;
}
Point a = {3, 4};
deplacer_point(&a); // a vaut {4, 5}
Avec un pointeur sur une structure, on n’utilise plus . pour accéder à un champ mais -> : p->x est équivalent à (*p).x.
Règle simple : . sur une variable de type structure, -> sur un pointeur vers une structure.
Les champs d’une structure sont rangés les uns après les autres en mémoire :
Structures imbriquées et tableaux de structures¶
Une structure peut contenir d’autres structures :
typedef struct {
Point debut;
Point fin;
} Segment;
Segment s = {{0, 0}, {10, 5}};
printf("%d\n", s.fin.x); // affiche 10
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);
}
Une structure peut aussi contenir un pointeur vers une structure du même type.
C’est le cas d’un nœud de liste chaînée, qui contient une valeur et l’adresse du nœud suivant (NULL pour le dernier) :
typedef struct Noeud {
int valeur;
struct Noeud *suivant; // adresse du nœud suivant
} Noeud;
Noeud n3 = {3, NULL}; // dernier nœud
Noeud n2 = {2, &n3};
Noeud n1 = {1, &n2};
// parcours de la liste à partir du premier nœud
Noeud *courant = &n1;
while (courant != NULL) {
printf("%d ", courant->valeur);
courant = courant->suivant;
}
// affiche : 1 2 3
À l’intérieur de la structure, le nom Noeud donné par typedef n’existe pas encore (il n’est défini qu’après l’accolade fermante) : on donne donc aussi une étiquette à la structure (struct Noeud) pour pouvoir écrire le type du champ suivant.
Gestion mémoire / Allocation dynamique¶
L’allocation de mémoire dynamique permet de demander de la mémoire au système pendant l’exécution, quand sa taille n’est pas connue à la compilation. 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 ajuster la mémoire utilisée aux besoins réels.
Stack et heap¶
Jusqu’ici, les variables déclarées dans le main ou dans les fonctions, par exemple int mon_int = 4;, utilisent l’allocation automatique dans le stack (pile) : leur taille est connue à la compilation et elles disparaissent à la fin du bloc où elles sont déclarées.
Avec malloc, on accède à l’allocation dynamique dans la heap (tas) : la taille est choisie à l’exécution et la zone existe de l’appel à malloc jusqu’au free, même après la fin de la fonction qui l’a allouée.
Le pointeur lui-même reste une variable locale comme les autres : il est dans le stack, qu’il désigne une zone du stack ou de la heap. C’est seulement la zone pointée qui est allouée dans la heap.
Fonctions d’allocation¶
On utilise la bibliothèque <stdlib.h> qui propose ces fonctions :
malloc: alloue un bloc de mémoire non initialisé ;void *malloc(size_t size);
size: nombre d’octets à demander au système.
Retourne un pointeur vers la zone allouée dans la heap, ou
NULLen cas d’erreur (errnoindique la cause).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 errno (sous POSIX) // et nous pouvons l'afficher avec perror puis quitter avec exit // (voir la section Gestion des erreurs) 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;
mallocdemande la taille de la mémoire à allouer et retourne l’adresse du début de cette zone. La taille peut être passée en indiquant le type,taille * sizeof(int): on demande alorstailleblocs de 4 octets (taille d’unintsur les plateformes utilisées dans le cours). Sitaille = 5, on demande5 * 4 = 20octets consécutifs en mémoire.sizeof(*tab)ne lit pas la mémoire : il donne seulement la taille du type pointé (iciint), même sitabn’est pas encore initialisé. Cette écriture reste juste si on change le type detab.mallocn’initialise pas la mémoire (son contenu est indéterminé) : il faut donc initialiser les valeurs avant de les utiliser.Les appels à
perroretexitsont détaillés dans la section Gestion des erreurs.calloc: alloue et initialise à zéro ;void *calloc(size_t nmemb, size_t size);
nmemb: nombre d’éléments dans le tableau ;size: taille des éléments en octets (taille d’un seul élément).
Retourne un pointeur vers la zone allouée dans la heap et initialisée à zéro, ou
NULLen cas d’erreur (errnoindique la cause).int *zeros = calloc(5, sizeof(*zeros)); // 5 entiers à 0
callocdemande 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) ;void *realloc(void *ptr, size_t size);
ptr: zone de la mémoire à modifier ;size: nouvelle taille de la zone pointée parptr.
Retourne un pointeur vers la zone redimensionnée, éventuellement déplacée (
realloccopie alors lui-même les données), ouNULLen cas d’erreur (errnoindique la cause).int *tab = malloc(5 * sizeof(*tab)); // ... on travaille avec tab et on a besoin de plus de place // 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 :
réussir à agrandir l’espace précédent en gardant la mémoire contiguë, sans avoir à le déplacer ;
ne pas pouvoir agrandir le bloc sur place : les données sont alors copiées dans un nouveau bloc assez grand (opération plus coûteuse) et l’ancien bloc est libéré ;
échouer et retourner
NULL(plus assez de place en mémoire, par exemple) : l’ancien bloc reste valide, d’où le pointeurtmp(tab = realloc(tab, ...)perdrait son adresse en cas d’échec).
Dans les deux premiers cas, les anciennes valeurs sont conservées et les nouvelles cases ne sont pas initialisées.
free: libère une zone précédemment allouée.void free(void *ptr);
ptr: zone de la mémoire à libérer.
Libère la zone mémoire pointée par
ptrqui doit avoir été allouée avecmalloc,callocourealloc. Sifree(ptr)a déjà été appelé sur la mémoire, le comportement est indéfini.free(NULL)ne fait rien.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 à
NULLest une bonne pratique pour bien indiquer que le pointeur ne pointe plus sur 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;
Ne pas renvoyer l’adresse d’une variable locale¶
Avertissement
Une fonction ne doit jamais retourner l’adresse d’une de ses variables locales : la variable est détruite à la sortie de la fonction et le pointeur renvoyé désigne une zone invalide (dangling pointer).
// À ne jamais faire !
int *mauvais(void) {
int x = 42; // x est dans la stack frame de mauvais
return &x; // x n'existe plus après le return
}
// Correct : la zone allouée dans la heap survit à la fonction
int *init_tab_dynamique(size_t n) {
int *tab = malloc(n * sizeof(*tab));
if (!tab) {
return NULL;
}
for (size_t i = 0; i < n; ++i) {
tab[i] = (int)i;
}
return tab; // le free sera à faire par la fonction appelante
}
Allouer dans une fonction¶
Plutôt que de retourner l’adresse allouée, une fonction peut la ranger dans une variable pointeur de l’appelant.
Comme pour toute variable modifiée par une fonction (voir Passage par valeur, passage par adresse), on lui passe l’adresse de ce pointeur : le paramètre a une * de plus (int ** pour un int *).
// modifie les cases du tableau : reçoit l'adresse de la première case
void remplir(int *t, size_t n) {
for (size_t i = 0; i < n; ++i) {
t[i] = 0;
}
}
// modifie la variable tab de l'appelant (un int *) : reçoit l'adresse de tab
void allouer(int **t, size_t n) {
*t = malloc(n * sizeof(**t)); // range l'adresse renvoyée par malloc dans tab
}
int main(void) {
int notes[5];
remplir(notes, 5); // notes est un tableau : pas de &
int *tab = NULL;
allouer(&tab, 5); // on modifie la variable tab elle-même : &tab (type int **)
if (tab == NULL) {
return EXIT_FAILURE;
}
remplir(tab, 5); // tab contient déjà l'adresse des cases : pas de &
free(tab);
return EXIT_SUCCESS;
}
Propriété de la mémoire¶
Toute allocation a un propriétaire unique, responsable du free (on parle aussi d”ownership).
Une fonction qui renvoie de la mémoire allouée transfère cette responsabilité à l’appelant : il faut le documenter.
Une fonction qui reçoit un pointeur pour l’utiliser ou le modifier ne le libère pas.
Pour une structure allouée dynamiquement, on écrit souvent une fonction qui alloue et initialise (une fabrique) et une fonction qui libère :
typedef struct {
int x;
int y;
} Point;
// alloue et initialise : la propriété est transférée à l'appelant
Point *point_creer(int x, int y) {
Point *p = malloc(sizeof(*p));
if (!p) {
return NULL;
}
p->x = x;
p->y = y;
return p;
}
// utilise le point sans en prendre la propriété
void point_afficher(const Point *p) {
printf("(%d, %d)\n", p->x, p->y);
}
// libère le point
void point_detruire(Point *p) {
free(p);
}
int main(void) {
Point *p = point_creer(3, 4);
if (!p) {
perror("point_creer");
return EXIT_FAILURE;
}
point_afficher(p); // (3, 4)
p->y += 10;
point_afficher(p); // (3, 14)
point_detruire(p);
p = NULL;
return EXIT_SUCCESS;
}
Pour aller plus loin : mettre le pointeur de l’appelant à NULL dans la fonction
point_detruire reçoit une copie de p : elle ne peut pas mettre à NULL la variable p du main.
Pour cela, il faut lui passer l’adresse de p, donc un Point ** (voir le récapitulatif du passage par adresse) :
void point_detruire(Point **pp) {
if (pp && *pp) {
free(*pp);
*pp = NULL; // modifie la variable p de l'appelant
}
}
// dans le main :
point_detruire(&p); // p vaut NULL au retour
Tableau de structures¶
On peut aussi allouer dynamiquement un tableau de structures :
typedef struct {
int hauteur;
int largeur;
} Rectangle;
size_t nb_rectangles = 3;
Rectangle *rectangles = malloc(nb_rectangles * sizeof(*rectangles));
if (!rectangles) {
perror("malloc");
return EXIT_FAILURE;
}
for (size_t i = 0; i < nb_rectangles; ++i) {
rectangles[i].hauteur = (int)i + 1; // rectangles[i] est un Rectangle : on utilise .
rectangles[i].largeur = 2;
}
Rectangle *r = &rectangles[1]; // r est un pointeur : on utilise ->
printf("%d x %d\n", r->hauteur, r->largeur); // affiche : 2 x 2
free(rectangles);
rectangles = NULL;
Tableau à deux dimensions¶
Dans le cas de tableaux dynamiques à deux dimensions (ou plus), deux choix s’offrent à nous :
avoir un tableau de pointeurs vers des tableaux qui ne sont pas forcément contigus en mémoire ;
avoir un pointeur sur un espace mémoire que l’on traite comme un tableau à plusieurs dimensions.
Dans le cas 1, on alloue d’abord un tableau de nb_row pointeurs (int **), un par ligne, puis, pour chaque ligne, un tableau de nb_col entiers :
#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;
// matrice va contenir les pointeurs vers les lignes allouées dynamiquement
// matrice[r] pointera vers les colonnes de la ligne r
int **matrice = malloc(nb_row * sizeof(*matrice));
if (!matrice) {
perror("Erreur malloc row");
exit(EXIT_FAILURE);
}
for (size_t r = 0; r < nb_row; ++r) {
matrice[r] = malloc(nb_col * sizeof(*matrice[r]));
if (!matrice[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(matrice[i]);
}
free(matrice);
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) {
matrice[r][c] = (int)(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 ", matrice[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(matrice[r]);
}
// puis libérer le tableau de pointeurs sur les lignes
free(matrice);
matrice = NULL;
return EXIT_SUCCESS;
}
Sortie :
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
On obtient la configuration suivante (représentée avec 4 colonnes au lieu de 10 pour rester lisible) : un premier tableau de pointeurs dont chaque élément pointe vers un tableau d”int, chaque ligne étant un bloc séparé en mémoire :
La libération se fait dans l’ordre inverse de l’allocation : d’abord chaque ligne (free(matrice[r])), puis le tableau des lignes (free(matrice)).
Si on libère matrice en premier, les adresses des lignes sont perdues et les lignes ne peuvent plus être libérées (fuite mémoire).
Pour aller plus loin : matrice plate
Dans le second cas, nous utilisons une matrice plate : on alloue un seul bloc mémoire et on le parcourt en utilisant le nombre de colonnes (longueur d’une ligne) :
#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 va contenir la matrice aplatie
int *flat = malloc(nb_col * nb_row * sizeof(*flat));
if (!flat) {
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[c + r * nb_col] = (int)(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[c + r * nb_col]);
}
printf("\n");
}
// pour finir, on libère le tableau
free(flat);
flat = NULL;
return EXIT_SUCCESS;
}
Sortie :
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
Nous nous retrouvons avec cette configuration (ici aussi avec 4 colonnes), où tout le tableau est contigu en mémoire :
Dans le cas de la matrice plate, l’équivalent de matrice[r][c] est flat[c + r * nb_col].
On se décale de r fois le nombre de colonnes : quand r = 3, on se décale de 3 × 10 = 30, ce qui nous place au début de la ligne 3 ; on ajoute ensuite l’indice de colonne (quand c = 7, on avance de 7 à partir de l’indice 30 et on trouve 37).
Tableau de chaînes¶
Un tableau de chaînes de taille variable se construit de la même manière, avec des char à la place des int : un tableau de pointeurs (char **), puis une zone par chaîne.
Chaque zone doit contenir les caractères et le \0 final, soit strlen(chaine) + 1 octets.
Exemple : copie de 3 mots dans un tableau de chaînes alloué dynamiquement.
char source[3][10] = {"chat", "chien", "lapin"};
size_t n = 3;
char **mots = malloc(n * sizeof(*mots)); // n pointeurs
if (!mots) {
perror("malloc");
exit(EXIT_FAILURE);
}
for (size_t i = 0; i < n; ++i) {
mots[i] = malloc(strlen(source[i]) + 1); // + 1 pour le \0
if (!mots[i]) {
perror("malloc");
for (size_t j = 0; j < i; ++j) {
free(mots[j]);
}
free(mots);
exit(EXIT_FAILURE);
}
strcpy(mots[i], source[i]); // copie des caractères
}
// ... utilisation de mots[0], mots[1], mots[2]
for (size_t i = 0; i < n; ++i) {
free(mots[i]); // chaque chaîne
}
free(mots); // puis le tableau de pointeurs
mots = NULL;
mots[i] = source[i]; ne copierait pas les caractères : on copierait seulement une adresse, et les deux tableaux partageraient les mêmes caractères.
Pour obtenir une copie indépendante, il faut allouer une zone et y copier la chaîne avec strcpy.
Durée de vie et portée des variables¶
En C, chaque variable possède une portée (scope) et une durée de vie (lifetime) qui déterminent où elle peut être utilisée et combien de temps elle existe en mémoire.
Portée (scope)¶
La portée d’une variable définit la région du code où elle est accessible.
Variables locales, ou de bloc (dans le stack, allocation automatique) :
Déclarées à l’intérieur d’un bloc (corps d’une fonction, boucle, if, etc.), elles ne sont accessibles que dans ce bloc.
void ma_fonction(void) {
int x = 5; // x n'existe que dans ma_fonction
printf("%d\n", x);
}
int main(void) {
ma_fonction();
// printf("%d\n", x); // Erreur : x n'existe pas ici
return EXIT_SUCCESS;
}
Depuis C99, on peut déclarer des variables n’importe où dans un bloc, y compris dans l’en-tête d’une boucle for : leur portée est alors limitée à ce bloc.
for (int i = 0; i < 5; i++) {
int temp = i * 2; // temp n'existe que dans cette itération
printf("%d ", temp);
}
// i et temp n'existent plus ici
Variables globales (stockées dans le segment data si elles sont initialisées à une valeur non nulle, BSS sinon ; allocation statique ; ces segments sont présentés dans la partie gestion mémoire) :
Déclarées en dehors de toute fonction, elles sont accessibles partout dans le fichier (et dans d’autres fichiers avec extern).
int compteur = 0; // variable globale (initialisée à 0 : segment BSS)
void incrementer(void) {
compteur++; // accessible ici
}
int main(void) {
compteur = 10; // et ici aussi
return EXIT_SUCCESS;
}
Avertissement
Les variables globales peuvent rendre le code difficile à maintenir. Privilégiez les variables locales quand c’est possible.
Durée de vie (lifetime)¶
La durée de vie définit combien de temps une variable existe en mémoire.
Durée de vie automatique (stack) :
Par défaut, les variables locales ont une durée de vie automatique : elles sont créées à l’entrée du bloc et détruites à sa sortie.
void fonction(void) {
int a = 42; // a est créée ici
printf("%d\n", a);
} // a est détruite ici
Durée de vie statique :
Avec le mot-clé static, une variable locale conserve sa valeur entre les appels de fonction. Elle est initialisée une seule fois.
void compteur(void) {
static int count = 0; // initialisée une seule fois
count++;
printf("Appel n°%d\n", count);
}
int main(void) {
compteur(); // affiche : Appel n°1
compteur(); // affiche : Appel n°2
compteur(); // affiche : Appel n°3
return EXIT_SUCCESS;
}
Les variables globales ont aussi une durée de vie statique : elles existent pendant toute l’exécution du programme.
Durée de vie dynamique (heap) :
Les zones allouées avec malloc existent jusqu’à ce qu’elles soient libérées avec free.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(*p)); // création dans la heap
if (!p) {
perror("malloc");
return EXIT_FAILURE;
}
*p = 42;
// p est une variable locale (dans le stack)
// la zone pointée par p existe jusqu'à free(p)
free(p); // libération explicite
return EXIT_SUCCESS;
}
Type |
Portée |
Durée de vie |
Stockage |
|---|---|---|---|
Variable locale |
Bloc |
Automatique |
Stack (pile) |
Variable locale |
Bloc |
Statique (programme) |
Segment data/BSS |
Variable globale |
Fichier (ou programme avec |
Statique (programme) |
Segment data/BSS |
Variable dynamique |
Via pointeur |
Contrôlée ( |
Heap (tas) |
Préprocesseur¶
Le préprocesseur C agit avant la compilation proprement dite. Il traite toutes les directives commençant par #.
Il travaille sur le texte du programme : #include recopie le contenu du fichier inclus, et #define remplace chaque occurrence du nom par le texte donné.
On y retrouve :
#include: insère le contenu d’un fichier, que ce soit un en-tête d’une bibliothèque (standard ou externe) ou l’un de vos fichiers d’en-tête (.h) ;#define: définit des constantes ou des macros ;#ifdef,#ifndef,#if,#else,#endif: compilation conditionnelle, par exemple pour éviter les inclusions multiples d’un fichier (la directive#pragma oncepermet d’éviter les inclusions multiples en une ligne et est acceptée par la majorité des compilateurs).
my_super_functions.h :
1// soit utiliser pragma once :
2// #pragma once
3// soit 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 Calcule la somme des carrés de deux nombres
12*
13* @param a premier nombre
14* @param b second nombre
15* @return int a^2 + b^2
16*/
17int my_formula(int a, int b);
18
19/**
20* @brief Calcule la somme des carrés de deux nombres et y ajoute
21* une pincée de nombre magique
22*
23* @param a premier nombre
24* @param b second nombre
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
3int my_formula(int a, int b) {
4 return SQUARE(a) + SQUARE(b);
5}
6
7int my_magic_formula(int a, int b) {
8 return SQUARE(a) + SQUARE(b) + MY_MAGIC_NUMBER;
9}
main.c :
1#include <stdio.h>
2#include <stdlib.h>
3
4#include "my_super_functions.h"
5
6int main(void) {
7 int a = 5;
8 int b = 8;
9 int result1 = my_magic_formula(a, b);
10 int result2 = my_magic_formula(result1, b);
11
12 printf("result = %d\n", result2);
13
14 return EXIT_SUCCESS;
15}
Comme le remplacement est textuel, les parenthèses de SQUARE sont nécessaires : sans elles, #define SQUARE(x) x * x transformerait SQUARE(a + 1) en a + 1 * a + 1.
On compile en donnant tous les fichiers .c (les .h sont inclus par le préprocesseur) :
$ gcc -std=c2x -Wall -Wextra -pedantic -g main.c my_super_functions.c -o main
$ ./main
result = 17267
Gestion des erreurs¶
En C, il n’existe pas de mécanisme intégré d’exceptions (comme en C++, Java, TypeScript, etc). La gestion des erreurs repose sur plusieurs principes :
Valeurs de retour
La plupart des fonctions de la libc retournent une valeur spéciale en cas d’erreur (souvent
-1ouNULL). Exemple avec fopen, qui ouvre un fichier et renvoie unFILE *(les fichiers sont détaillés dans la partie Fichiers) :1FILE *f = fopen("fichier.txt", "r"); 2if (f == NULL) { 3 perror("fopen"); 4 exit(EXIT_FAILURE); 5}
Ici,
fopenretourneNULLen cas d’échec (par exemple si le fichier n’existe pas).perroraffiche un message d’erreur basé surerrno, etexittermine le programme avec le code donné (ces deux fonctions sont détaillées ci-dessous).errno
errno(défini dans<errno.h>) contient le code de la dernière erreur, positionné par les fonctions système et de nombreuses fonctions de la libc en cas d’erreur. Il se manipule comme une variable globale, mais c’est en pratique une macro qui désigne une valeur propre à chaque thread. 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 fprintf(stderr, "Erreur %d : %s\n", errno, strerror(errno)); 10 // proche de perror("Erreur"), qui affiche "Erreur: message" 11 return EXIT_FAILURE; 12 } 13 fclose(f); // ferme le fichier 14 return EXIT_SUCCESS; 15}
Deux fonctions donnent la description d’un code d’erreur :
perror(<stdio.h>) l’affiche,strerror(<string.h>) la retourne.void perror(const char *s); char *strerror(int errnum);
s: message affiché avant la description de l’erreur (en général le nom de la fonction qui a échoué) ;errnum: code d’erreur à décrire (souventerrno).
perrorne retourne rien : elle écrit surstderrle messages,": ", puis la description de l’erreur correspondant à la valeur courante deerrno.strerrorretourne une chaîne décrivant l’erreurerrnum(chaîne à ne pas modifier).strerrorsert aussi pour les fonctions pthread, qui retournent leur code d’erreur au lieu de le placer danserrno(voir pthread_create).Toute fonction appelée (y compris
printfoufprintf) peut modifiererrno, même quand elle réussit : si on veut utiliser la valeur plus tard, il faut la sauvegarder immédiatement (int err = errno;).perrorn’a de sens que si la fonction qui a échoué positionneerrno: c’est indiqué dans les sections RETURN VALUE et ERRORS de sa page de manuel (malloc,fopen, les appels système…). Pour une erreur détectée par votre programme (saisie invalide, valeur hors limites,scanfqui ne lit rien…),errnon’est pas positionné : utilisezfprintf(stderr, "...").Codes retour du programme
Un programme en C retourne un entier à sa fin via
returndansmainouexit:EXIT_SUCCESS(0) → exécution correcte ;EXIT_FAILURE(1 sous Linux) → échec.
La fonction
exit(<stdlib.h>,man 3 exit) termine le programme depuis n’importe quelle fonction :void exit(int status);
status: code de retour du programme, transmis au système d’exploitation comme la valeur retournée parmain(EXIT_SUCCESSouEXIT_FAILURE).
Ne retourne pas : le programme se termine, après avoir vidé les buffers des flux ouverts (
stdout, fichiers ouverts avecfopen…).Dans
main,return EXIT_FAILURE;etexit(EXIT_FAILURE);sont équivalents ; dans une autre fonction,returnrevient à l’appelant alors queexitarrête tout le programme.Exemple :
if (ptr == NULL) { perror("Erreur malloc"); exit(EXIT_FAILURE); }
Bonnes pratiques :
toujours vérifier la valeur de retour des fonctions critiques (
malloc,fopen,fork, etc.) ;utiliser
perroroustrerrorpour des messages d’erreur explicites ;écrire les erreurs sur
stderrplutôt questdout(fait automatiquement avecperror) ;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 pris en charge.
int rand(void);
Retourne un entier pseudo-aléatoire 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);
seed: graine pour une nouvelle séquence du générateur pseudo-aléatoire.
Avec une même seed donnée à srand, rand donnera la même séquence de nombres pseudo-aléatoires (sur une même libc : la séquence dépend de l’implémentation).
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 comme graine l’heure courante, donnée par
time(<time.h>,man 2 time) :time_t time(time_t *t);
t: si ce n’est pasNULL, adresse où ranger aussi le résultat ; on appelle en généraltime(NULL).
Retourne le nombre de secondes écoulées depuis 1970-01-01 00:00:00 +0000 (UTC), ou
(time_t)-1en cas d’erreur (errnoindique la cause).
Exemple :
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void) {
unsigned int seed = (unsigned int)time(NULL);
srand(seed);
// Nombre pseudo-aléatoire entre 0 et 99
int r = rand() % 100;
printf("%d\n", r);
return EXIT_SUCCESS;
}
srand s’appelle une seule fois, au début du main : rappeler srand(time(NULL)) avant chaque tirage (dans une boucle ou dans une fonction de tirage) réinitialise la séquence avec la même graine pendant toute la seconde en cours, et donne donc les mêmes valeurs.
Autres mots-clés¶
En plus des mots-clés comme if, while, etc., 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¶
dans une fonction, une variable
staticconserve sa valeur entre les appels (voir l’exemple de la section Durée de vie (lifetime)) ;utilisé hors d’une fonction (devant une variable globale ou une fonction),
staticlimite la portée à un fichier source.
extern¶
Permet de déclarer une variable ou une fonction définie dans un autre fichier. Il est souvent utilisé 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 l’indique explicitement. Cela désactive certaines optimisations (ex. conservation de la valeur dans un registre). Il est utilisé pour les interactions avec le matériel (registres de périphériques) ou pour les variables modifiées par un gestionnaire de signal (volatile sig_atomic_t, voir la partie sur les processus).
volatile ne suffit pas à synchroniser des threads (il ne garantit ni l’atomicité ni l’ordre des accès mémoire) : il faut pour cela utiliser _Atomic ou des mutex (voir la partie sur les threads).
L’exemple ci-dessous utilise un gestionnaire de signal, présenté dans la partie Processus : vous pourrez y revenir à ce moment-là.
volatile sig_atomic_t flag = 0;
void gestionnaire(int sig) {
(void)sig;
flag = 1; // modifié par un gestionnaire de signal
}
const¶
Déjà abordé dans la section sur les variables, const signifie que la valeur ne peut pas être modifiée.
Avec un pointeur, la position de const compte : il porte sur ce qui est écrit juste à sa droite.
int a = 5;
int b = 6;
const int *p = &a; // const int : la valeur pointée ne peut pas être modifiée via p
// *p = 7; // erreur
p = &b; // possible : p peut pointer ailleurs
int *const q = &a; // const q : le pointeur ne peut pas changer de cible
*q = 7; // possible : a vaut 7
// q = &b; // erreur
La forme const T * est la plus courante : c’est celle des paramètres qu’une fonction lit sans les modifier (const char * dans strlen, const int *tab…).
restrict¶
Vous verrez restrict dans les signatures de la documentation (int printf(const char *restrict format, ...)) : c’est une promesse faite au compilateur que la zone pointée n’est accessible que par ce pointeur, ce qui lui permet d’optimiser. Vous pouvez l’ignorer à la lecture : les signatures données dans ce cours l’omettent.