Introduction au langage C

Table of Contents

Note de l'auteur (Yannick Pencolé): cette page recense les points importants du langage C pour débuter en programmation avec ce langage (initiation au C). Cette page n'est en rien une référence au langage C, son contenu ne décrit pas l'exhaustivité de ce langage (mais une très grande partie) et ne permet pas un usage avancé en programmation C. Son seul objectif est de vous sensibiliser à ce langage qui est au fondement de toutes les architectures informatiques modernes avec l'espoir de vous donner envie d'aller plus loin dans votre exploration de ce langage en exploitant des ressources plus détaillées et avancées.

1. Généralités

1.1. Ordinateur

Un ordinateur est une machine qui exécute des programmes. Un programme est une suite d'instructions en langage binaire (suites de 0 et de 1). Chaque instruction ordonne au CPU (unité centrale) des tâches élémentaires (accéder à du contenu en mémoire, accéder à des périphériques (disque, écran, souris, clavier….), faire des opérations: addition, multiplication,….).

1.2. Le programmeur, le compilateur, et l'ordinateur

Le programmeur (vous) est en charge d'écrire du code dans un langage de programmation. Dans ce module, ce langage de programmation est le langage C. Un langage de programmation est un langage qui permet au programmeur d'écrire simplement des séquences d'instructions mais ce langage n'est pas compréhensible par l'ordinateur qui ne comprend que le langage binaire. Le compilateur est un programme qui traduit le code écrit par le programmeur en langage binaire connu par l'ordinateur. Pour le langage C, le compilateur que nous utiliserons est gcc (mais il y en a d'autres). Ceux qui connaissent le langage ADA ont déjà vu ce principe (utilisation du compilateur gnat). Le langage C (comme ADA) est un langage compilé. Le développement d'un programme en C suit donc les étapes suivantes:

  1. [ Edition ] Écrire/modifier le code du programme en C (ce code est écrit dans des fichiers de suffixes .c (et .h pour les déclarations de fonctions)).
  2. [ Compilation ] Compiler les fichiers avec le compilateur (produit un fichier binaire)
  3. [ Exécution/Test ] Exécuter le programme qui est dans le fichier binaire.
  4. [ Vérification ] Si le résultat obtenu n'est pas correct refaire les étapes 1. 2. et 3.

À l'opposé, pour ceux qui connaissent le langage Python, un programme Python n'est pas compilé. Pour l'exécuter, Python dispose d'un outil (un interpréteur) qui traduit instantanément le code en langage binaire. Python est un langage interprété où la phase de compilation n'existe pas (mais la phase d'exécution est plus lente que le C ou ADA).

1.3. Le langage C

Le langage C est un langage de programmation qui a été élaboré par Dennis Ritchie dans les années 70 dans les laboratoires AT&T Bell dans le New Jersey aux États-Unis. Ce langage a les caractéristiques suivantes.

  1. Les sources d'un programme en C sont portables: peu importe l'architecture sur lequel les sources seront compilés, le comportement du programme sera identique (enfin presque, mais c'est l'idée).
  2. Le langage C est un langage abstrait compréhensible mais proche du silicium. Autrement dit, il est simple à comprendre mais il permet de programmer des actions très élémentaires sur un ordinateur, d'accéder directement à ses ressources de base (la mémoire physique notamment, les ports d'entrées/sorties,…). Bien pratique donc pour mettre en oeuvre des systèmes d'exploitation, des systèmes temps-réels, programmations de micro-contrôleurs,…
  3. La simplicité du langage et le fait qu'il soit proche du matériel produit des programmes extrêmement efficaces sur tout type de machine.

Enfin, pour finir cette introduction, pourquoi appelle-t-on ce langage, le langage C ? Et bien parce qu'il est inspiré notamment du langage B qui est lui-même une restriction du langage BCPL (Basic Combined Programming Language)

1.4. Les constituants d'un programme écrit en C

1- Un programme C est constitué d'un ensemble de fonctions.

2- Le corps de chaque fonction est une séquence d'instructions.

3- Toute instruction se termine nécessairement par un point vigule ';'.

4- Tout programme C doit disposer d'une fonction principale et d'une seule et cette fonction s'appelle main

Quand le programme binaire associé est exécuté, il démarre par la première instruction de la fonction principale main Les instructions sont exécutées en séquence. Certaines de ces instructions sont des appels à d'autres fonctions du programme. Toute fonction peut avoir des instructions qui appellent d'autres fonctions du programme. Le programme se termine (en général, c'est l'idée) par la dernière instruction de la fonction main.

Un programme C est généralement constitué d'un ensemble de fichiers écrits par le programmeur. On nomme ces fichiers les fichiers sources. Il y en a de deux types:

  1. Les fichiers suffixés par .h contiennent généralement des déclarations de variables, des déclarations de fonctions, des directives pour accéder à des bibliothèques de fonctions déjà compilées (équivalent des fichiers .ads en ADA)
  2. Les fichiers suffixés par .c contiennent la définition des fonctions, autrement dit les instructions que l'ordinateur doit exécuter lors de l'appel de ces fonctions (équivalent des fichiers .adb en ADA).

1.5. Comment écrire un commentaire dans un code C ?

Un code C de bonne qualité rime avec un code C bien documenté. La documentation se fait avec des commentaires dans les fichiers sources. Pour écrire un bloc de commentaire:

/* Ceci est un
   bloc de commentaires
*/

Il est également possible de faire un commentaire sur une ligne:

// Voici un commentaire

// Voici un autre commentaire

Enfin, il est à noter que le compilateur ignore totalement les commentaires.

1.6. Pourquoi (encore) étudier le langage C en 2024 ?

Mais pourquoi donc en 2024 une initiation à ce langage est-elle encore pertinente ? Et bien parce qu'il est encore le seul à assurer toutes les caractéristiques pour lesquelles il a été défini à la fois, (portabilité ET abstraction proche du silicium ET efficacité). Il existe une multitude de nouveaux langages de programmation, tous portables mais aucun n'a en même temps les deux autres caractéristiques. Tous les systèmes UNIX sont écrits en C, le noyau Linux est en C (et donc le noyau d'Android). Nothing better than C est une fameuse expression de Linus Torwalds le créateur de Linux. Le langage C a bien quelques challengers de nos jours, (Rust, Zig, Odin,…), mais ces nouveaux langages doivent encore faire leur preuves, affaire à suivre… Enfin, le langage C est à l'origine de C++, langage orienté objet qui reprend la syntaxe du C et qui fait l'objet du module POO que vous allez suivre.

1.7. De grands pouvoirs impliquent de grandes responsabilités !

La syntaxe du langage C est relativement simple si on la compare à d'autres langages (ADA notamment dont la syntaxe n'est pas évidente). Outre sa simplicité syntaxique, le langage C est très puissant mais aussi très permissif. Il permet de tout faire, le meilleur comme le pire. Contrairement à d'autres langages, le compilateur C n'est pas un garde-fou. Le programmeur doit savoir ce qu'il fait. Ce n'est pas parce que le compilateur ne retourne aucune erreur que votre programme va fonctionner. C'est d'ailleurs l'une des grandes critiques de C qui a fait émerger ces dernières années de nouveaux langages moins permissifs (Rust en particulier).

La suite de ce document décrit brièvement quelques éléments que l'on retrouve dans un programme C.

2. Afficher un message à l'écran printf

Afin de mieux appréhender l'apprentissage du C, la première chose à comprendre est la façon d'écrire des messages à l'écran dans un programme C.

2.1. Message simple

Voiçi notamment un premier programme bonjour.c complet qui affiche Bonjour à l'écran.

#include <stdio.h>

int main()
{
  printf("Bonjour\n");
  return 0;
}

Explication: Le programme précédent a une fonction principale, la fonction main() (voir fonction principale). Dans cette fonction, on fait appel à une fonction prédéfinie, la fonction printf qui va afficher Bonjour. La séquence \n à la fin de Bonjour demande à l'ordinateur de passer à la ligne après l'affichage de Bonjour. L'instruction return 0; permet de terminer le programme proprement en informant le système d'exploitation (linux, windows,…) que le programme s'est bien déroulé sans erreur.

La fonction printf fait partie de la bibliothèque standard du C. Pour que le compilateur puisse compiler le programme, il faut lui indiquer le fichier où est déclarée cette fonction. La fonction printf est déclarée dans le fichier stdio.h qui est bien connu du compilateur. Avec la directive #include<stdio.h>, le compilateur est en mesure de trouver la fonction printf et de compiler le programme dans sa totalité avec la commande de compilation:

gcc -o bonjour bonjour.c

La compilation a créé le fichier bonjour (option -o) qui est le programme binaire associé au programme C précédent. Pour l'exécuter:

./bonjour

2.2. Message formaté

Voiçi un deuxième exemple complet qui montre comment écrire un message formaté:

#include <stdio.h>

int main()
{
  printf("L'expression %d %c %d vaut %d\n", 2, '+', 3, 2+3);
  return 0;
}

Explication: ce programme va afficher L'expression 2 + 3 vaut 5 puis retourner à la ligne suivante. Le message affiché par printf est formaté. La notation %d signifie que printf va remplacer %d par un entier qui est donné en paramètre de printf. La notation %c signifie que printf va remplacer %c par un caractère en paramètre de printf. Dans l'exemple, il y a 4 formatages attendus (un entier suivi d'un caractère suivi de deux entiers) qui sont associés au 4 paramètres qui succède le message formaté dans printf.

3. La variable

Une variable est un nom qui sert à repérer un emplacement en mémoire dont on peut faire évoluer la valeur au fil du déroulement du programme.

3.1. Choix d'un nom pour une variable

Une variable est associée à une information, son nom doit être significatif. Un nom de variable peut être composé de lettres (minuscules, majuscules, pas d'accent), chiffres et du caractères spécial '_'. Le nom d'une variable ne peut pas commencer par un chiffre, par exemple:

temperature  Indices racine_carree amplitude_signal_en_decibel23

3.2. Type d'une variable

Une variable désigne un emplacement mémoire pour stocker de l'information. Mais cette information peut être plus ou moins grande. Par exemple, stocker un caractère simple nécessite un emplacement de 1 octet (8 bits) mais stocker un entier nécessite en général 4 octets (4 x 8 bits = 32 bits). En C, afin d'associer à une variable la bonne taille pour son emplacement, une variable est toujours associée à un type. Le type désigne la nature de la variable, cela peut être un caractère, un entier, un nombre à virgule. Exemples:

  • char: caractère (1 octet | 8 bits)
  • int: entier signé (4 octets | 32 bits) (de -2147483648 à 2147483647)
  • unsigned int: entier non signé (4 octets | 32 bits) (de 0 à 4294967295)
  • short: petit entier signé (2 octets | 16 bits) (de -32768 à 32767)
  • unsigned short: petit entier non signé (2 octets | 16 bits) de 0 à 65535
  • float: nombre à virgule flottante (sur 32 bits)
  • double: nombre à virgule flottante (sur 64 bits)
  • void: le type vide (rien)

Le langage C est un langage typé (toute variable est associée à un type). Les types en C sont statiques (une variable ne change pas de type pendant le déroulement du programme). En ce sens, les variables en C se comportent comme en ADA. Attention, les variables en C ne se comportent pas comme en Python (en Python, le type de variable est deviné par l'ordinateur, de plus ce type peut évoluer au cours du programme).

3.2.1. Au sujet du type bool

Le type booléen (vrai/faux) n'existe pas nativement en C. Il est codé par une convention sur un entier. Si l'entier est 0, cela veut dire faux. S'il vaut autre chose (notamment 1), cela signifie vrai. On peut néanmoins utiliser le type booléen explicitement dans les programmes mais il faut nécessairement insérer la directive #include <stdbool.h> avant d'utiliser ce type bool.

3.3. Déclaration d'une variable

Dans un programme C, toute variable doit être déclarée avant son utilisation. La déclaration d'une variable ordonne à l'ordinateur de trouver en mémoire un emplacement de taille suffisant pour stocker l'information associée à la variable déclarée. La déclaration d'une variable se fait par la donnée de son type suivie de son nom et se termine par un point virgule ;.

int Indices;
float racine_carree;
char lettre;
bool vrai_ou_faux;

3.4. Où déclarer les variables dans un programme C ?

Un programme C est constitué d'un ensemble de fonctions. Une variable peut se déclarer dans le corps de la fonction ou en dehors de toute fonction.

  • Si la variable est déclarée dans une fonction, la variable est locale et sera supprimée quand la fonction aura terminé son déroulement.
  • Si la variable est déclarée en dehors de toute fonction, on dit que la variable est globale, elle est connue pendant tout le déroulement du programme. Elle est créée au démarrage du programme et sera supprimée à la fin du programme.

Paradoxalement, bien que les variables globales semblent les plus simples à utiliser, il faut éviter de les utiliser. Il est préférable de déclarer des variables locales et de les passer en paramètres de fonctions.

3.5. Comment modifier le contenu d'une variable ? Affectation d'une variable.

Supposons que dans une fonction on ait déclaré la variable suivante:

int a;

Avec cette déclaration, le programme a alloué en mémoire un emplacement adéquat pour stocker un entier accessible à l'aide de la variable a. Mais que vaut cet entier après avoir fait cette déclaration ? On ne sait pas, le programme n'a pas initialisé l'emplacement mémoire avec une valeur connue. Ainsi la valeur de a est pour le moment arbitraire. On peut maintenant décider de modifier la valeur de cette variable en écrivant la valeur 53 par exemple.

a = 53;

L'instruction ci-dessus est une affectation de variable. On a demandé à l'ordinateur de mettre la valeur 53 dans l'emplacement mémoire associé à la variable a.

Supposons maintenant la déclaration suivante dans la même fonction:

int b;

La variable b est un deuxième emplacement mémoire dont la valeur est quelconque. On peut maintenant affecter à b le contenu de la variable a.

b = a;

Désormais, la variable b contient 53. Attention, b=a ne signifie pas que la variable b est devenue la variable a, cela signifie que les deux variables a et b contiennent la même valeur. La valeur de a a été copiée dans la variable b. IMPORTANT: En C l'affectation de variable est une copie de valeur. Pour s'en convaincre, on peut écrire ensuite l'instruction suivante:

b = b * 2;

Ici, l'ordinateur récupère la valeur courante de b (qui est 53), multiplie cette valeur par 2 (c'est le résultat de l'expression b*2) et affecte le résultat dans la variable b. Ainsi la valeur de b devient 106 alors que la valeur de a reste toujours 53.

3.5.1. Au sujet du type bool

Pour déclarer une variable de type bool, ne pas oublier d'insérer la directive #include<stdbool.h> avant.

#include<stdbool.h>

int main()
  {
    bool resultat;
    resultat = false;
    resultat = true;
    resultat = (2!=3) && (4 < 5);
    return 0;
  }

4. La chaîne de caractères

Une chaîne de caractères est une suite de caractères. En C, une telle suite est mise en œuvre par un tableau de caractères (plus de précisions dans la section Le Tableau). La meilleure façon de définir une chaîne de caractère est la suivante:

char prenom[] = "Jean";
printf("Mon nom est %s.",prenom); 

Explication: la variable prenom est déclarée et son type est char[] qui signifie que c'est un tableau de caractères. Cette variable est directement initialisée avec la chaine de caractères "Jean". Cette variable est ensuite utilisée dans l'écriture d'un message formaté.

5. Gestion du clavier scanf

Pour lire des informations issues du clavier dans un programme, on peut utiliser la fonction prédéfinie scanf qui se trouve dans stdio.h (comme printf).

5.1. Lire un caractère au clavier

#include<stdio.h>

int main()
  {
    printf("Ecrire un caractère et appuyer sur Entrée: ");
    char car_lu;
    scanf("%c",&car_lu); // car_lu contient le caractere donné au clavier
  }

Explication: Ce programme affiche un message pour demander que l'on rentre un caractère au clavier. On souhaite que ce caractère soit affecté à la variable car_lu. Pour cela on la déclare et ensuite on appelle scanf. L'appel à scanf contient un message formaté comme printf. Ici ce message attend un caractère %c qui sera stocké dans car_lu (le & est obligatoire, pour sa signification voir La mémoire).

5.2. Lire un entier au clavier

#include<stdio.h>

int main()
  {
    printf("Ecrire un entier et appuyer sur Entrée: ");
    int entier_lu;
    scanf("%d",&entier_lu); // entier_lu contient l'entier donné au clavier
  }

Explication: Ce programme affiche un message pour demander que l'on rentre un entier au clavier. On souhaite que cet entier soit affecté à la variable entier_lu. Pour cela on la déclare et ensuite on appelle scanf. L'appel à scanf contient un message formaté comme printf. Ici ce message attend un entier %d qui sera stocké dans entier_lu (le & est obligatoire, pour sa signification voir La mémoire).

5.3. Lire un mot de taille < 10 au clavier

#include<stdio.h>

int main()
  {
    printf("Ecrire un mot de longueur < 10 et appuyer sur Entrée: ");
    char mot_lu[10];
    scanf("%s",mot_lu); // mot_lu contient le mot de taille < 10 donné au clavier
  }

Explication: Ce programme affiche un message pour demander que l'on rentre un mot au clavier (séquence de caractères sans espace). On souhaite que ce mot soit affecté au tableau de caractère mot_lu. Pour cela on déclare mot_lu comme un tableau pouvant contenir 10 caractères et on demande à l'utilisateur qu'il donne un mot dont la longueur est strictement inférieur afin d'être sûr que ce mot sera bien affecté à mot_lu. L'appel à scanf contient un message formaté comme printf. Ici ce message attend un mot %s qui sera stocké dans mot_lu si la taille le permet, sinon c'est une erreur (attention, ici pas de & avant mot_lu car mot_lu est un tableau voir La mémoire).

6. Les structures de contrôle

Les structures de contrôle servent à exprimer des comportements conditionnels ou itératifs.

6.1. Structure de contrôle conditionnel if/else

Voiçi un exemple d'utilisation de la structure de contrôle conditionnel

...
if (age < 18)
{
  printf("Personne mineure\n");
}
else
{
  printf("Personne majeure\n");
}
...

Explication: le bloc (age < 18) est une expression booléenne. Si age est plus grand ou égal à 18, cette expression retourne 0 (qui signifie faux en C). Si age est plus petit que 18, l'expression renvoie un nombre différent de 0 (ce qui signifie vrai en C. En fonction de l'évaluation de cette expression booléenne, l'un des deux blocs est ensuite exécuté.

Une telle structure nécessite d'écrire une condition, c'est-à-dire une expression booléenne. Pour établir des conditions sur des entiers, il existe de nombreux opérateurs de comparaison:

a==b // egalite, attention ce n'est pas a=b mais double egal ==
a!=b // difference
a<b  // inférieur
a>b  // superieur
a<=b // inferieur ou egal
a>=b // superieur ou egal

6.2. Structure de sélection de cas switch

Cette structure permet de gérer les conditions quand elles sont multiples. Exemple de fonctionnement simple:

char operation_arithmetique; 
...
switch(operation_arithmetique)
{
  case '+':
    {
      printf("Addition\n");
      break;
    }
  case '-':
    {
      printf("Soustraction\n");
      break;
    }
  case '*':
    {
      printf("Multiplication\n");
      break;
    }
  case '/':
    {
      printf("Division\n");
      break;
    }
 default:
   {
     printf("Ce symbole n'est pas une opération arithmétique");
   }
}
...

Explication: l'expression suivant le switch est évaluée (ici c'est la valeur du caractère operation_arithmetique). Le bloc du code associé au case dont la valeur correspond à la valeur de operation_arithmetique est ensuite exécuté. L'instruction break qui termine chaque bloc permet de passer à la prochaine instruction après le switch. Si la valeur de l'expression n'est pas associée à l'un des cas listés (par exemple ici dans le cas où operation_arithmetique='q'), le bloc associé à default est exécuté. Attention, si vous omettez d'écrire break à chaque bloc, vous autorisez l'ordinateur à exécuter le bloc associé au cas suivant. Par exemple si operation_arithmetique='*' et que vous avez omis d'écrire break dans le cas associé alors le programme affichera Multiplication suivi de Division.

6.3. L'itération avec for

Voiçi un exemple où l'on affiche sur l'écran les multiples de 2 entre 2 et 10.

for(int i = 1; i <= 5; ++i)
  {
    printf("%d\n", 2 * i);
  }

Explication: une boucle for est constituée de deux parties: le paramètrage de l'itération (ici: (int i = 1; i <= 5; ++i)) et le bloc itératif (ici: { printf("%d\n", 2 * i); }). Le nombre de fois que le bloc itératif sera exécuté est déterminé par le paramètrage de l'itération. Ce paramètrage est constitué de trois éléments:

  1. l'initialisation (ici int i = 1;) est un bloc d'instructions qui est exécuté au démarrage de l'itération. En général, ce bloc initialise des variables dont la valeur va évoluer pendant l'itération. Dans l'exemple ci-dessus, ce bloc consiste à déclarer une variable i et à lui affecter la valeur 1.
  2. la condition de poursuite de l'itération (ici i<=5). Cette condition est une expression booléenne qui établit si le bloc itératif doit encore une fois être exécuté (condition vraie) ou non (condition fausse). Dans l'exemple ci-dessus, on demande à ce que le code du bloc iteratif soit executé si i<=5. Dès lors que i>5, le bloc itératif n'est pas réexécuté. L'itération prend fin.
  3. la mise à jour de la variable d'itération (ici ++i). C'est un bloc d'instructions qui est exécuté après chaque exécution du bloc itératif. Il sert à faire évoluer les variables d'itération en vue de exécuter le bloc itératif une nouvelle fois ou d'arrêter. Dans l'exemple, la variable i est incrémentée de 1 (equivalent à i = i + 1).

Comment fonctionne le code précédent:

  • 1 Phase d'initialisation, on déclare i qui est initialisé à 1.
  • 2 Vérification de la condition de poursuite: 1<=5 est vrai donc on doit exécuter le bloc itératif
  • 3 Bloc itératif: Affiche 2 à l'écran
  • 4 Mise à jour, i passe à 2
  • 5 Vérification de la condition de poursuite: 2<=5 est vrai donc on doit exécuter le bloc itératif
  • 6 Bloc itératif: Affiche 4 à l'écran
  • 7 Mise à jour, i passe à 3

  • 13 Mise à jour, i passe à 5
  • 14 Vérification de la condition de poursuite: 5<=5 est vrai donc on doit exécuter le bloc itératif
  • 15 Bloc itératif: Affiche 10 à l'écran
  • 16 Mise à jour, i passe à 6
  • 17 Vérification de la condition de poursuite: 6<=5 est faux, l'itération s'arrête, la prochaine instruction est celle qui suit le bloc itératif.

6.4. L'itération avec while

Reprenons l'exemple de la boucle for et transformons-le en une boucle while:

int i = 1;
...
while(i<=5)
{
  printf("%d\n", 2 * i);
  ++i;
}

Explication: dans une boucle while, le bloc itératif est exécuté autant de fois que la condition d'entrée est vraie (ici i<=5). La phase d'initialisation se fait avant l'écriture de la boucle (ici int i = 1;). La mise à jour de cette variable de contrôle de l'itération se fait dans le bloc itératif (ici ++i).

6.5. L'itération avec do..while

Reprenons l'exemple des boucles for et while et transformons-le en une boucle do ... while:

int i = 1;
...
do
{
  printf("%d\n", 2 * i);
  ++i;
}
while(i<=5)

Explication: dans une boucle do while, le bloc itératif est nécessairement exécuté au moins une fois, peu importe la condition dans le while. Ce n'est qu'après cette première exécution du bloc itératif que la condition de poursuite est testée pour vérifier si le bloc itératif doit être réexécuté. L'exemple ci-dessus produit le même résultat que pour les boucles for et while.

7. La fonction

Un programme C peut être complexe et s'il fallait écrire la séquence d'instructions complète dans un seul bloc, le programme serait impossible à analyser, maintenir ou faire évoluer. Pour une meilleure compréhension de celui-ci, un programme est décomposé en sous-programmes. Un sous-programme est un bloc d'instructions qui est exécuté en s'appuyant sur un ensemble de paramètres. Chaque sous-programme peut en appeler d'autres, etc. En C, on ne parle pas de sous-programme mais de fonction.

7.1. Définition d'une fonction

Une fonction est composée des éléments suivants:

  1. Un nom
  2. Un ensemble de paramètres
  3. Un type de retour
  4. Une séquence d'instructions

Une fonction est ainsi définie selon le schéma suivant:

type_retour nom(type_param1 param1, type_param2 param2, ...)
  {
    instruction1;
    instruction2;
    ...  
  }

Exemple:

int add(int a, int b, int c)
{
  int result;
  result = a + b + c;
  return result;
}

La fonction précédente se nomme add. Elle a trois paramètres qui sont trois variables de type int. Cette fonction retourne une valeur dont le type est int. Son bloc d'instructions déclare en premier lieu une variable result de type int puis on affecte la somme a+b+c à cette variable. Enfin, la fonction add retourne la valeur contenue dans la variable locale result à l'aide du mot-clé return.

Parfois une fonction est une séquence d'instructions qui ne retourne pas de valeur. Dans ce cas le type de retour est void. Imaginons par exemple une fonction print_int qui afficherait un entier à l'écran, sa définition serait de la forme suivante:

void print_int(int a)
{
....

}

7.2. Utilisation d'une fonction, appel de fonction

Une fonction peut être utilisée, appelée par une autre fonction dans une de ses instructions. Exemple:

void print_sum(int v1, int v2, int v3)
  {
    int sum;
    sum = add(v1,v2,v3);
    print_int(sum); 
  }

La fonction print_sum a trois paramètres et ne retourne rien. Dans un premier temps, cette fonction déclare une variable sum de type int. Ensuite, elle affecte la valeur de retour de la fonction add à la variable sum. Enfin elle appelle la fonction print_int qui va afficher la valeur de la variable sum. On rappelle que la définition de la fonction add commence comme suit:

int add(int a, int b, int c)

Que se passe-t-il donc avec l'instruction ?

sum = add(v1,v2,v3);

Quand le programme appelle la fonction add, il va d'abord déclarer les trois variables paramètres a, b et c de type int puis il va copier la valeur de v1 dans a, celle de v2 dans b, celle de v3 dans c. Il va ensuite lancer les instructions de la fonction add qui retourne une valeur de type int, cette valeur est affectée dans sum à la fin de l'éxécution de la fonction add. Afin d'illustrer globalement ce que le programme fait avec cet appel de fonction add, on pourrait le remplacer par l'ensemble des instructions suivantes:

void print_sum(int v1, int v2, int v3)
  {
    int sum;
    int a;
    int b;
    int c;
    a = v1;
    b = v2;
    c = v3;
    int result;
    result = a + b + c;
    sum = result;
    print_int(sum); 
  }

7.3. La fonction principale main

Tout programme C a nécessairement une fonction principale qui sert de point de démarrage du programme. Le nom de la fonction principale est toujours identique, c'est main.

7.3.1. Définition simple de la fonction main

La forme simple de la fonction main est la suivante.

int main()
{
  instruction1;
  instruction2;
  ...
  return 0;
}

Dans cette forme, la fonction principale n'a pas de paramètres. Son type de retour est un entier. Quand la fonction main se termine, le programme se termine. La valeur de retour de main est un code erreur qui est envoyé au système d'exploitation pour l'informer que le programme s'est déroulé sans erreur ou non. Retourner la valeur 0 signifie que le programme s'est bien terminé. Une fonction main sans paramètre signifie que le programme binaire n'attend pas de paramètre quand il sera lancé.

7.3.2. Définition de la fonction main avec paramètres d'entrée

La fonction main peut avoir des paramètres d'entrée:

int main(int argc, char *argv[])
{
  instruction1;
  instruction2;
  ...
  return 0;
}

Ces paramètres récupèrent les valeurs d'entrées disponibles sur la ligne de commande quand le programme est exécuté. La variable argc contient le nombre d'éléments sur la ligne de commande. Le tableau argv est un tableau de chaînes de caractères, stockant tous les éléments disponibles sur la ligne de commande: argv[0] est le nom du programme, argv[1] est le premier paramètre après le nom du programme, argv[2] est le deuxième paramètre… et argv[argc-1] est le dernier disponible sur la ligne de commande.

7.4. Déclaration d'une fonction, programmation modulaire

Lorsque le compilateur compile un fichier source pgm.c et que ce fichier utilise une fonction qui n'est pas définie dans pgm.c, il faut a minima que la fonction utilisée dans pgm.c soit déclarée avant son utilisation. Considérons que pgm.c soit de la forme suivante:

int main()
{
   ecrire_bonjour();
}

En l'état, le fichier ne peut pas compiler car la fonction ecrire_bonjour() n'est pas connue. Supposons maintenant que la fonction ecrire_bonjour() soit écrite dans un autre fichier fonctions.c comme suit:

#include <stdio.h>

void ecrire_bonjour()
{
   printf("Bonjour\n");
}

Pour éviter de réécrire la fonction ecrire_bonjour() dans le fichier pgm.c, de façon générale il suffit de la déclarer dans un fichier d'entête. Ici, on va écrire un nouveau fichier d'entête fonctions.h et y déclarer la fonction ecrire_bonjour().

#ifndef __FONCTIONS__HH // obligatoire dans chaque fichier d'entete (evite les doubles inclusions)
#define __FONCTIONS__HH // obligatoire dans chaque fichier d'entete 

// declaration de ecrire_bonjour
void ecrire_bonjour();

#endif // obligatoire  dans chaque fichier d'entete 

Puis, on va inclure cette déclaration dans pgm.c en utilisant la directive d'inclusion #include"fonctions.h" qui demande au compilateur de lire le fichier fonctions.h au moment de la compilation de pgm.c.

#include"fonctions.h"

int main()
{
   ecrire_bonjour();
}

N'oubliez pas que pour compiler pgm.c, le compilateur doit également compiler la fonction ecrire_bonjour(), il faut donc compiler également fonctions.c, d'où la commande de compilation suivante: gcc -o pgm pgm.c fonctions.c

En résumé, un fichier d'entête contient généralement un ensemble de déclarations de fonction et on utilise une directive d'inclusion de ce fichier dans tous les fichiers source qui nécessite l'usage de l'une de ces focntions. On peut remarquer enfin que le fichier fonctions.c contient la directive d'inclusion #include<stdio.h>, c'est le fichier d'entête standard où est déclarée la fonction printf utilisée dans ecrire_bonjour.

7.5. Fonctions prédéfinies. Bibliothèque standard du C

Pour programmer en C, l'utilisateur dispose d'un grand nombre de fonctions prédéfinies dans la bibliothèque standard. Une bibliothèque (library en anglais) est un fichier binaire précompilé qui contient le code binaire d'un ensemble de fonctions. C'est le cas notamment des fonctions d'entrées comme printf et scanf. Pour utiliser ces fonctions dans un programme C, il faut ajouter dans le programme une directive d'inclusion du fichier où est déclarée la fonction que l'on souhaite utiliser.

Exemple:

#include<stdio.h> // contient les fonctions printf, scanf et bien d'autres
#include<stdbool.h> // contient le type bool et les constantes true et false
#include<cstdlib.h> // contient les fonctions malloc, free, rand, et bien d'autres
#include<string.h>  // contient des fonctions pour manipuler des chaines de caracteres
#include<math.h>  // contient des fonctions mathématiques

8. Le tableau

Un tableau en C est une structure de donnée qui permet de stocker une liste d'éléments d'un certain type.

8.1. Définition d'un tableau

Voiçi comment créer simplement des variables de type tableau:

int tab_entiers[] = {1,2,3};
char tab_caracteres[] = {'a','b','c','d'};
int tab_entiers_arbitraires[30];

Explications: la première ligne définit une variable tab_entiers de type tableau d'entiers. La taille du tableau est de 3. Le premier élément du tableau est 1, le deuxième est 2 et le dernier est 3. La deuxième ligne définit une variable tab_caracteres de type tableau de caractères. La taille de ce deuxième tableau est 4. La dernière ligne définit un tableau de 30 entiers: les éléments du tableau sont arbitraires, non initialisés.

8.2. Accéder/Modifier un élément du tableau

On accède à un élément du tableau à l'aide de son indice. Le premier élément du tableau est à l'indice 0 et le dernier élément du tableau est à l'indice N-1 où N est la taille du tableau.

printf("Le premier élément de tab_entiers est %d et son dernier élément est %d\n",tab_entiers[0],tab_entiers[2]);
tab_entiers[1] = 6;
tab_entiers[0] = tab_entiers[1] * 4;

Explications: la première ligne accède au premier élément du tableau tab_entiers (indice 0) et au dernier (indice 2, sachant que le tableau est de taille 3). Les deuxième et troisième lignes modifient les éléments d'indice 0 et 1. Le deuxième élément du tableau devient 6. Le premier élément devient 6*4 = 24.

Chaque élément d'un tableau se comporte exactement comme une variable. Par exemple, on peut dire que le tableau tab_entiers stocke 3 variables entières. La première est à l'indice 0, la deuxième à l'indice 1…

8.3. Parcourir un tableau

Le parcours d'un tableau consiste à mettre en œuvre un moyen d'accéder automatiquement à l'ensemble des éléments d'un tableau. Cela se fait généralement à l'aide d'une structure de contrôle itérative (for, while, …).

for(int i =0; i < 3; ++i)
  {
    printf("%c",tab_caracteres[i]);
  }

Explications: on initialise une variable i qui va itérer de 0 à la longueur du tableau - 1 pour afficher chaque élément du tableau (ici cela affiche abc).

8.4. Chaine de caractères = Tableau de caractères particulier.

Si l'on définit une chaîne de caractères, on la déclare généralement comme suit:

char bonjour[] = "Bonjour";

En fait, la chaine de caractères bonjour est un tableau de caractères, on aurait pu très bien définir bonjour de la façon suivante:

char bonjour[] = {'B','o','n','j','o','u','r','\0'};

Explications: une chaîne de caractères est un tableau de caractères dont le dernier élément est le caractère nul (noté '\0' attention ce n'est pas le caractère 0 (zéro)).

bonjour[3] = 's';
bonjour[5] = 'i';
printf("%s",bonjour);

Explications: on remplace 'j' par 's' et 'u' par 'i' dans bonjour pour afficher bonsoir à l'écran.

9. La structure

Le langage C manipule des types simples prédéfinis (des entiers, des caractères, des flottants, …) voire des tableaux (tableaux d'entiers, chaînes de caractères, ….). Mais on peut aller plus loin en définissant des types plus complexes et abstraits: les types structurés.

9.1. Définition d'un type structuré (une structure)

Voiçi une façon simple de définir un type structuré:

typedef struct vecteur3D
{
  float x;
  float y;
  float z;
} vec3D;


typedef struct personne
{
  char nom[300];
  char prenom[300];
  char adresse[1000];
  int age;
  bool etudiant;
} personne;


typedef struct personne_gps
{
  personne individu;
  vec3D gps;
} personne_gps;

Explications: on définit un type vec3D qui est un type structuré composé de 3 membres (champs). Tous les membres sont de type flottant, (un membre x pour la coordonnée x, un membre y pour la coordonnée y, …). Le deuxième type personne est constitué de 5 membres. Les membres nom et prenom sont des tableaux de caractères de taille 300 pour stocker les nom et prénoms des personnes. Un membre adresse (tableau de 1000 caractères) stocke l'adresse de la personne. Un membre age de type entier pour l'âge. Et un booléen pour stocker son status d'étudiant. Enfin, le troisième type structuré personne_gps est défini par 2 membres qui sont eux-mêmes des types structurés.

9.2. Déclaration d'une variable de type structuré

Elle se fait comme pour les variables classiques.

vec3D vecteur;
personne jean_charles;
personne_gps ou_est_christophe;

9.3. Accès à un membre d'une variable structurée

Une variable structurée est une composition de ces membres, autrement dit chaque membre de la variable structurée peut être vu comme une variable en soi. Pour accéder à un membre particulier d'une variable structurée var, il faut utiliser la notation pointée: var.membre. Par exemple,

vecteur.x = 10.5; // affecte 10.5 à la coordonnée x de vecteur
vecteur.z = 123.45; // affecte 123.45 à la coordonnée z de vecteur
printf("%s habite à %s, il a %d ans",jean_charles.prenom,jean_charles.adresse,jean_charles.age);
printf("Les coordonnées de l'agent %s sont (%d,%d)\n",
        ou_est_christophe.personne.prenom,
        ou_est_christophe.gps.x,
        ou_est_christophe.gps.y);

9.4. Initialisation d'une variable de type structuré

Il y a plusieurs façon d'initialiser une variable de type structuré. Voiçi trois façons équivalentes d'initialiser une variable structurée avec le même contenu.

vec3D v1; // initialisation membre à membre
v1.x = 10.5;
v1.y = 23.8;
v1.z = 42.4;
vec3D v2 = { 10.5, 23.8, 42.4 }; // initialisation séquentielle
vec3D v3 = { .y= 23.8, .z=42.4, .x=10.5 }; // initialisation séquentielle

Explications: la méthode 1 consiste à déclarer la variable puis à initialiser chaque membre. La méthode 2 initialise tous les membres en séquence (x puis y puis z). La méthode 3 initialise tous les membres sélectionnés. Un autre exemple avec des personnes:

personne jean_charles = { "jean-charles", "bouleau", "Toulouse", 45 , false };
personne jean_charles2 = { .prenom="jean-charles", .nom="bouleau", .adresse="Toulouse", .age=45 , .etudiant=false };

10. La mémoire d'ordinateur

10.1. Introduction

L'une des activités principales d'un ordinateur est de retrouver et de modifier des valeurs qui sont stockées dans la mémoire centrale de celui-ci (RAM: Read Access Memory). Par exemple, quand on accède à la valeur d'une variable en C, on demande à l'ordinateur d'accéder à l'endroit où se trouve cette valeur et de la récupérer pour l'utiliser dans des calculs. Supposons par exemple, le code C suivant:

int a;
int b;
a = 3;
b = a + 4;

Avec la ligne int a; on demande à l'ordinateur de trouver un endroit libre en mémoire de la taille d'un entier pour y stocker une valeur (idem pour int b;). La ligne a = 3; commande à l'ordinateur d'écrire la valeur 3 à l'endroit de la mémoire qu'il a alloué à la variable a. La ligne b = a + 4 commande trois choses: accéder à la valeur mémoire de a pour en récupérer la valeur (ici 3), additionner 3 + 4 = 7, envoyer la valeur 7 dans l'emplacement mémoire de b. La question ici qui se pose est: comment l'ordinateur est en mesure de trouver tel ou tel endroit dans la mémoire ? Pour cela, il utilise un mécanisme d'adressage.

10.2. Organisation schématique de la mémoire (sur ordinateur classique 64bits)

La mémoire est constituée de bits, un bit est une case mémoire élémentaire qui ne peut stocker qu'un 0 ou un 1. La gestion de la mémoire par un ordinateur ne se fait pas au niveau du bit, cette gestion regroupe les bits de mémoire en paquets de 8, ce qui constitue un octet (attention en anglais c'est byte à différencier de bit) . Chaque bit n'appartient qu'à un seul octet (la mémoire est ainsi partitionnée en un ensemble d'octets). L'ordinateur peut accéder à tous les octets disponibles. Pour cela, tous les octets de la mémoire sont numérotés (de 0 à N-1, où N est le nombre d'octets disponibles dans l'ordinateur). Ce numéro est appelé adresse. Sur une architecture dite 64 bits, l'adresse est une valeur entière que l'on peut stocker sur 64 bits, autrement dit, un ordinateur 64bits est capable d'accéder théoriquement à 264 octets différents (mais bien sûr, il n'accédera qu'aux octets physiquement disponibles dans la machine (4Go, 8Go, 16Go sont des tailles de mémoire classiques)). Les adresses étant des valeurs sur 64bits, il est d'usage d'utiliser une représentation hexadécimale pour les visualiser. En hexadécimale, il y a 24 = 16 chiffres (0..9,A..F) représentable sur 4 bits donc une adresse mémoire peut se représenter par un nombre héxadécimal de 16 chiffres (64 bits/4):

  1. Adresse minimale: 0000000000000000
  2. Adresse quelconque: 0000A4F56892451F
  3. Adresse maximale: FFFFFFFFFFFFFFFF

10.3. Accéder à l'adresse mémoire d'une variable quelconque en C

Le langage C est proche du silicium autrement dit il est capable d'exploiter des ressources de base de l'ordinateur. L'une d'entre elle est la possibilité d'accéder aux adresses mémoires et de les exploiter. Supposons par exemple que le programme C déclare une variable de type caractère:

char c;

La variable c est un caractère donc elle est stockée dans un seul octet de la mémoire. Pour connaître la valeur, de l'adresse mémoire de c il suffit d'utiliser le symbole &:

printf("Adresse de c = %p",&c); /* affiche l'adresse memoire (avec %p) où est stockée le contenu
      de la variable c, valeur sur 64bits */

10.4. Connaitre la taille des variables en mémoire. sizeof

Une autre information importante à connaître est la taille occupée en mémoire par une variable. Pour cela, on utilise sizeof.

int main()
  {
    printf("Taille d'un entier: %zu octets",sizeof(int));
    double valeur = 0;
    printf("Taille de valeur: %zu octets",sizeof(valeur));
    int tab[10];
    printf("Taille de tab: %zu octets",sizeof(tab));
    return 0;
  }

Explications: on affiche d'abord la taille en octet d'un entier quelconque (c'est 4 octets usuellement, soit 32 bits). On affiche ensuite la taille pour une variable particulière de type double (c'est 8 octets soit 64 bits). Enfin on affiche la taille d'un tableau de 10 entiers, c'est … 40 octets. Notez que sizeof retourne un entier non signé dont la longueur dépend du type de machine. Pour être le plus portable possible (standard C99), le spécificateur associé dans un message formaté est %zu.

10.5. Connaître la longueur d'un tableau. sizeof

sizeof mesure la taille mémoire d'une variable de type tableau pas la taille du tableau. Pour obtenir la taille du tableau (nombre d'éléments dans le tableau), il faut diviser la taille en octets par le nombre d'octets utilisé par un élément du tableau.

int tab[10];
printf("Taille de tab: %zu octets",sizeof(tab));
printf("Nombre d'éléments dans tab: %zu", sizeof(tab)/sizeof(int));

Explications: On initialise un tableau de 10 entiers. Un entier est généralement codé sur 4 octets. Ainsi, la taille de la mémoire allouée au tableau tab est 10 * 4 = 40 octets. L'instruction sizeof(tab) retourne cette taille. L'instruction sizeof(int) retourne la taille en mémoire d'un entier quelconque (4 octets en général). Ainsi sizeof(tab)/sizeof(int) retourne 40 / 4 = 10, soit le nombre effectif d'entiers dans le tableau tab.

11. Le pointeur: une variable qui stocke une adresse mémoire

Les pointeurs sont ce qui fait la force du langage C mais c'est aussi ce qui rend la maîtrise de ce langage difficile. Pour comprendre la notion de pointeur, il faut d'abord comprendre le principe général du fonctionnement de la mémoire d'un ordinateur. Une adresse mémoire est une valeur et comme toute valeur, on peut la stocker dans une variable en mémoire. Ce type de variable s'appelle un pointeur car elle contient l'information qui permet de pointer (accéder à) un emplacement mémoire spécifique.

11.1. Déclaration et initialisation d'un pointeur.

Un pointeur est dans un premier temps une variable. Comme toute variable, il doit être déclaré. Ensuite, on peut l'initialiser avec une adresse mémoire. Voiçi un premier exemple:

char c;
char *ptr_c1;
ptr_c1 = &c;
char *ptr_c2;
ptr_c2 = ptr_c1;

Dans un premier temps, on déclare un caractère c. L'ordinateur va donc allouer un emplacement mémoire pour c à une adresse qu'il va choisir. Ensuite on déclare ptr_c1 comme un pointeur de caractère (char suivi de *ptr_c1). C'est le symbole * dans cette déclaration qui informe que ptr_c1 est un pointeur. Avec la ligne, ptr_c1 = &c; on stocke dans le pointeur ptr_c1 l'adresse de c. Autrement dit, on fait pointer ptr_c1 sur la variable c. On déclare ensuite un deuxième pointeur ptr_c2 et on lui affecte la valeur contenue dans ptr_c1, à savoir l'adresse de c. Le pointeur ptr_c2 pointe donc également sur la variable c. En C, on déclare un pointeur sur un type donné. On peut déclarer des pointeurs de tout type (sauf les tableaux):

int a;
a =23;
int *p1; /* pointeur sur une variable de type entier */
p1 = &a;
float b;
b =23.3;
float *p2; /* pointeur sur une variable de type flottant */
p2 = &b;
...

11.2. Utilisation des pointeurs, déréférencement *p

Une variable de type pointeur stocke une adresse et permet donc d'accéder à la mémoire de cette adresse pour consulter/modifier la valeur stockée. Pour accéder à cette mémoire à partir d'un pointeur, on procède par déréférencement: soit p un pointeur, alors son déréférencement est noté *p et se comporte comme la variable pointée par p.

int a,b;
a =9;
b = 8;
int *pa;
pa = &a;
*pa=10; // *pa c'est la variable a
pa = &b;
*pa=20; // *pa c'est la variable b
// ici a vaut 10 et b vaut 20

Dans l'exemple précédent, on a deux variables entières a et b initialisées respectivement à 9 et à 8. On déclare ensuite un pointeur de type int qui se nomme pa. Dans un premier temps, il reçoit l'adresse de a. Puis, dans l'instruction *pa=10, l'expression *pa représente a par déréférencement de pa. Autrement dit, à cet instant, l'instruction *pa=10; est équivalente à a=10;. Dans un second temps pa reçoit l'adresse de b, la deuxième instruction *pa=20; est alors équivalente à b=20 et non pas à a=20.

11.3. Utilisation des pointeurs sur des variables structurées, déréférencement p->

Reprenons la structure:

typedef struct vecteur3D
 {
   float x;
   float y;
   float z;
 } vec3D;

et supposons que nous avons déclaré la variable suivante:

vec3D  v = {.x = 0.1, .y = 1.4, .z =2.0};

On peut également déclarer un pointeur de structure et le faire pointer sur ce vecteur v:

vec3D *pv = &v;

Explications: déclaration d'un pointeur pv sur des structures de type vec3D directement initialisé avec l'adresse mémoire de l'adresse de v.

À l'aide de ce pointeur, on peut accéder/modifier la valeur des membres de v par déréférencement. On peut utiliser le déréférencement classique (*pv) ou le déréférencement pv->:

(*pv).x = 0.2; // la valeur du membre x de v passe à 0.2
pv->x = 0.3; // la valeur du membre x de v passe à 0.3

Explications: Les 2 types de déréférencement sont équivalents mais l'utilisation de -> est plus aisée en pratique, on la recommande.

11.4. Pointeur et Tableau: les deux faces d'une même pièce.

Déclarons un tableau d'entiers:

int tab_entiers[20];

La variable tab_entiers est de type tableau d'entiers (c'est à dire de type int[]). Dans la mémoire, cette variable est associée à une zone contiguë occupant 20 * 4 = 80 octets. Cette zone démarre à une certaine adresse. Comment accéder à cette adresse ? Contrairement à des variables simples, l'adresse d'un tableau n'est pas &tab_entiers mais …. tab_entiers !

printf("Le tableau tab_entiers est alloué à l'adresse %p\n",tab_entiers);

Autrement dit, on peut stocker la valeur de tab_entiers (qui est une adresse mémoire) dans un pointeur, mais la question qui se pose est: quel est le type de ce pointeur ? En C, on considère que tab_entiers est l'adresse du premier élément du tableau (celui à l'indice 0, autrement dit tab_entiers[0]. Comme c'est un tableau d'entiers, tab_entiers est l'adresse d'un entier. Le pointeur associé est donc un pointeur sur un entier.

int *p_tab_entiers = tab_entiers;
printf("Le tableau tab_entiers est alloué à l'adresse %p\n",p_tab_entiers);

Attention: on voit donc ici que le langage C ne voit pas vraiment la différence entre une variable v de type tableau contenant des éléments d'un certain type T (variable T v[]) et un pointeur v sur un élément de type T (variable T *v). Ainsi un pointeur de type T *p peut pointer sur un élément de type T ou sur un tableau d'éléments de type T. Pour le langage C, il n'y a aucune différence (à part de la taille de la zone allouée). Si un tableau est manipulé à travers l'usage d'un pointeur T *p il faudra nécessairement adjoindre à p une autre variable qui stocke la taille du tableau !

11.5. Chaîne de caractères et pointeur de type char *

Rappel: en C une chaîne de caractères est un tableau de caractères qui se termine par le caractère nul \0. Exemple:

char bonjour1[] = "Bonjour";
char bonjour2[] = {'B','o','n','j','o','u','r','\0'};

Ainsi, on peut également manipuler des chaînes de caractères avec un pointeur de type char:

char * bonjour3 = "Bonjour";
printf("%s",bonjour3);

Ici, contrairement au cas des tableaux quelconques, il est inutile d'adjoindre la taille de la chaîne. La fonction printf s'attend à ce que bonjour3 représente une chaîne de caractères et donc quand printf va lire le contenu pointé par bonjour3, la fonction va lire le tableau jusqu'à lire le caractère \0, ce qui définira la taille de la chaîne de caractères. Ne pas confondre chaîne de caractères et tableau de caractères: tout tableau de caractères n'est pas nécessairement une chaîne de caractères. Regarder l'exemple suivant:

char * p1 = "Bonjour";
char bonjour1[] = {'B','o','n','j','o','u','r','\0'};
char bonjour2[] = {'B','o','n','j','o','u','r'};
char *p = p1;
printf("%s",p); // ok affiche Bonjour
p = bonjour1;
printf("%s",p); // ok affiche Bonjour
p = bonjour2;
printf("%s",p); // essayez... vous verrez bien... ou pas...

Explications: p1 est initialisé avec une chaîne littéral. Donc p=p1 fait pointer p sur une chaîne de caractères (ok). bonjour1 est un tableau de caractères se terminant avec le caractère nul donc bonjour1 est une chaîne de caractères et p=bonjour1 fait pointer p sur une chaîne de caractères (ok). bonjour2 est un tableau de caractères ne se terminant pas avec le caractère nul donc bonjour2 n'est pas une chaîne de caractères. p=bonjour2 fait pointer p sur un tableau de caractères qui n'est pas une chaîne de caractère, dans ce cas printf("%s",p); est illégal (pas d'erreur de compilation mais une erreur dans l'exécution…).

11.6. Attention danger !! Le pointeur non initialisé !!

Un pointeur p est juste une variable qui stocke une valeur. L'utilité du pointeur est que la valeur stockée est censée être une adresse vers une zone mémoire allouée pour une variable. Et son déréférencement *p ou encore p-> permet de modifier le contenu de cette zone. Que se passerait-il si p stocke une valeur arbitraire ? Et bien *p permettrait de modifier le contenu de la mémoire à n'importe quel endroit avec des conséquences plus ou moins catastrophiques: plantage du programme genre segmentation fault, bus error, voire pire que tout à savoir un programme qui tournera bien tout le temps, sauf le jour où il plantera dans quelques années dans un système embarqué critique (voiture, avion, …) avec des vies en jeu… Le compilateur C ne vous aidera pas, vous devez absolument gérer ce que contiennent les pointeurs à tout moment de votre programme. Un pointeur non utilisé devrait être toujours (ré)initialisé à NULL. Et un déréférencement ne doit se faire que si ce pointeur n'est pas NULL.

  int *p = NULL;
  ...
  if(p)
  {
    // le pointeur n'est pas NULL, on peut déréférencer avec *p ou p->
  }
else
  {
    // le pointeur est NULL, interdiction de déréférencer !!
  }

12. Allocation dynamique de mémoire malloc,free

Depuis le début ce document, on a vu que le fait de déclarer une variable dans une fonction demandait à l'ordinateur d'allouer une zone mémoire de la bonne taille pour y stocker le contenu de cette variable.

int fonction()
  {
    int tab[20]; // allocation d'une zone de 20*4 octets 
    ...

   } // tab est detruite, la zone mémoire associée est libérée

Explications: dans le code précédent, quand fonction() est appelé, une variable de type tableau tab est déclarée, l'ordinateur va alors réserver/allouer une zone mémoire de 80 octets pour y stocker le contenu du tableau tab. À la fin de la fonction fonction(), l'ordinateur va automatiquement détruire cette variable en libérant la mémoire associée à tab. Cette variable n'est plus accessible. Si la fonction fonction() est appelée une seconde fois alors la nouvelle variable tab n'aura rien à voir avec l'ancienne (et sera très certainement stockée dans une autre zone de la mémoire). On dit que tab est une variable automatique (car sa destruction est automatique) ou encore variable locale (sa portée, son existence ne sont liées qu'à une exécution (et une seule) de la fonction où elle est déclarée).

On a vu également que l'on pouvait déclarer des variables dans le programme en dehors de fonctions. Ces variables sont dites globales. Leur zone de mémoire respective est allouée quand le programme démarre (avant la première instruction de la fonction main() et la mémoire est libérée à la fin du programme (après la dernière instruction). Ces variables sont connues de toutes les fonctions du programme et existent tout au long du programme.

Variables globales et variables locales sont deux extrêmes, l'une existe tout le long du programme, l'autre uniquement pendant l'appel d'une fonction. On veut maintenant pourvoir exploiter des zones mémoires quand on veut, pour la durée que l'on veut, c'est l'objectif de l'allocation dynamique de mémoire.

12.1. Mémoire de processus, une affaire de segments

Quand un programme démarre (on parle dans ce contexte de processus), le système d'exploitation donne de la mémoire au processus pour qu'il puisse s'exécuter, la mémoire qu'il donne est sous forme de segments

  1. le segment code est le segment de mémoire qui va stocker le code binaire du programme qui doit s'exécuter.
  2. le segment données est le segment de mémoire qui va stocker les données connues à l'initialisation du programme, ce segment va donc contenir la zone mémoire de toutes les variables globales.
  3. le segment pile (anglais stack) est le segment pour stocker à tout instant les appels de fonctions pendant l'exécution du programme et pour l'allocation des variables automatiques/locales
  4. le segment tas (anglais heap) va servir pour allouer des zones mémoires et y stocker des informations de façon dynamique.

12.2. Allocation dynamique de mémoire malloc

Pour allouer dynamiquement de la mémoire (autrement dit, pour réserver une zone de mémoire dans le segment tas), on utilise la fonction malloc de la bibliothèque standard (fonction déclarée dans stdlib.h).

#include <stdlib.h>
int fonction()
  {
    int * un_entier;
    un_entier = malloc(sizeof(int)); // *un_entier est une variable entiere

    char * un_caractere;
    un_caractere = malloc(sizeof(char)); // *un_caractere est une variable caractère

    float * tab;
    tab = malloc(4 * sizeof(float)); // tab pointe sur un tableau de 4 flottants

    personne * promotion;
    promotion = malloc(40 * sizeof(personne)); // promotion pointe sur un tableau de 40 personnes
    ....
  }

Explications Dans la fonction précédente, on fait quatre allocations dynamiques. La première est l'allocation dynamique d'un entier. Pour cela, on demande à malloc de réserver une zone mémoire de la bonne taille. Or la taille d'un entier est donnée par sizeof(int). La fonction malloc retourne l'adresse de la zone mémoire allouée, cette adresse est le seul moyen que nous avons d'accéder à cette zone mémoire, nous allons stocker cette adresse dans le pointeur un_entier: le déréférencement *un_entier est une variable de type int. La deuxième allocation est identique à la première: le déréférencement *un_caractere est une variable de type char. La troisième allocation dynamique alloue un tableau de 4 flottants, le premier flotant est accessible avec *tab le deuxième est accessible avec *(tab + 1), le troisième par *(tab + 2) etc.. On peut même allouer des tableaux de structures: le pointeur promotion ici pointe sur un tableau de 40 variables de type personne. On peut accéder au prenom de la troisième personne dans cette promo avec (promotion+2)->prenom.

12.3. Toute mémoire allouée que tu demanderas, la libérer tu devras ! free

L'allocation dynamique de mémoire est à gérer entièrement par le programmeur. Si dans le programme, une allocation dynamique d'une zone mémoire est faite à l'aide d'un malloc, cette zone doit être systématiquement libéré par free plus tard et avant la fin du programme.

  #include <stdlib.h>

int fonction2()
    {
      ....
      free(un_entier2);
      free(un_caractere2);
      free(tab2);
      free(promotion2);
      ....
        // tous les pointeurs pointent désormais sur des zones non allouées (dangereux)
      un_entier2=NULL;
      un_caractere2=NULL;
      tab2=NULL;
      promotion2=NULL;
    }

Explications dès lors qu'une zone mémoire n'est plus utile, il faut la libérer. Imaginons que les zones allouées précédemment (associées aux pointeurs un_entier un_caractere …) (voir 12.2) ne soient plus utiles après leur utilisation dans fonction2 et imaginons que ces 4 zones allouées précédemment soient pointées dans fonction2 par un_entier2 un_caractere2 …). L'appel de free sur ces pointeurs désallouent la mémoire associée. Une fois free executé, la zone mémoire n'est plus utilisable mais le pointeur n'a pas changé de valeur (il pointe toujours sur la même zone qui n'est plus exploitable). Par sécurité, il est alors préférable d'affecter NULL à tous ces pointeurs.

Attention: l'adresse d'une zone mémoire peut être stockée dans plusieurs pointeurs en même temps. Appeler free sur l'un d'entre eux invalident tous les autres. De plus ne pas utiliser free deux fois sur la même zone: on ne libère pas une zone déjà libérée. Exemple:

#include <stdlib.h>

int fonction()
{
  int *p1 = malloc(20 * sizeof(int)); // allocation de 20 entiers
  int *p2 = p1; // p2 pointe sur les 20 entiers
  ....
  free(p2); // les 20 entiers sont libérés: p2 et p1 sont invalides
  p2=NULL;
  ....
  free(p1); // ERREUR: double free !!! Le programme va s'arreter
}

12.4. Fuite de mémoire !

C est permissif et dangereux, il faut savoir gérer la mémoire avec rigueur. En effet il est très facile d'allouer de la mémoire puis d'oublier de la libérer avant que l'on ne puisse plus le faire. Un programme se comportant ainsi génère des fuites de mémoire (memory leaks) qui peuvent conduire à des instabilités du programme et des problèmes de performance. Soyez vigilant:

int fonction()
 {
   int *p1 = malloc(20 * sizeof(int)); 
 } // fuite de mémoire

Explications: la fonction fonction() alloue un tableau de 20 entiers et en stocke l'adresse dans le pointeur p1. Mais p1 est une variable locale dont le contenu disparaît à la fin de la fonction. Conséquence, une fois l'appel de fonction() terminé, l'adresse de la zone allouée est définitievement perdue et cette zone reste allouée et inaccessible jusqu'à la fin du programme. À chaque appel de fonction(), cela génère une fuite de 80 octets…

12.5. Exemple d'utilisation dynamique de la mémoire

Voiçi un petit exemple complet, d'utilisation de l'allocation dynamique.

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

typedef struct personne
{
  char * prenom;
  int age;
} personne;


personne * creer_personne(char * prenom, int age)
  {
    personne * p = malloc(sizeof(personne));
    p->prenom = prenom;
    p->age = age;
    return p;
  }

personne * change_age(personne* p,int age)
  {
    p->age = age;
    return p;
  }

personne * liberer_personne(personne *p)
  {
    free(p);
    return NULL;
  }

int main()
{
  personne * jacques = creer_personne("Jacques",20);
  printf("Cette personne s'appelle %s et elle a %d ans\n",jacques->prenom,jacques->age);
  jacques = change_age(jacques,30);
  printf("Cette personne a maintenant %d ans\n",jacques->age);
  jacques = liberer_personne(jacques);
  if(!jacques)
    {
       printf("Pas de fuite de mémoire! Ouf!\n");
    }
  return 0;
}

Explications: on définit un type structuré personne avec deux membres. La fonction creer_personne a pour objectif d'allouer dynamiquement de la mémoire pour stocker une personne et initialiser ces membres. Cette fonction retourne l'adresse de la zone allouée, adresse qui est récupérée dans le pointeur jacques dans la fonction principale. La fonction change_age prend en paramètre un pointeur p de personne et a pour objectif de changer le membre age de la personne pointée par p. Dans la fonction main cette fonction est appelée avec le pointeur jacques pour y changer l'âge de jacques avant un deuxième affichage. La fonction liberer_personne quant à elle prend en paramètre un pointeur de personne pour le désallouer. Cette fonction retourne NULL, un moyen de forcer à mettre à jour le pointeur jacques à NULL pour être rigoureux. Par ailleurs un simple test nous informe que le pointeur jacques est nul désormais. Dans ce programme, on a crée une et une seule personne, puis liberer une et une seule personne, pas de fuite de mémoire.

Author: Yannick Pencolé

Created: 2024-02-06 mar. 17:32

Validate