• 2. Comprendre les directives préprocesseur

      Il existe un langage dédié au préprocesseur pour ne compiler qu’une partie du code ou choisir de modifier des valeurs avec #define. Dans le premier exemple, l’utilisateur va choisir de donner à la définition CHOIX soit la valeur PETIT, soit la valeur GRAND, cela va alors permettre de générer la constante TAILLE avec la bonne valeur. L’intérêt de cette méthode est que la modification de l’exécutable ne demande pas de modification du code, mais seulement une modification d’une valeur définie par #define.

      Exemple 1 : code utilisant les directives préprocesseurs
      Résultat

      #include <stdio.h>

      #include <stdlib.h>

      #define CHOIX PETIT

      #if CHOIX == PETIT

      #define TAILLE 10

      #elif CHOIX == GRAND

      #define TAILLE 20

      #else

      #define TAILLE 5

      #endif

      int main() {

          int tab[TAILLE];

          for (int i = 0; i < TAILLE; i++) {

              tab[i] = i;

          }

          return 0;

      }

      En fonction de la valeur de CHOIX le tableau aura une taille de :

      • 10 pour #define CHOIX PETIT
      • 20 pour #define CHOIX GRAND
      • 5 sinon (CHOIX non défini par exemple)

      Une deuxième utilisation courante dans un programme des directives préprocesseur est l’utilisation d’un #define DEBUG , qui permet l’affichage de données supplémentaires pour la mise au point. Lorsque le programme est fonctionnel, on va alors mettre en commentaire cette ligne de définition et le code compilé sera plus rapide puisque tous les affichages seront enlevés de la compilation.

      Exemple 2 : code utilisant les directives préprocesseurs pour le DEBUG

      #define DEBUG

      void init_random(int tab, int taille) {

        for (int i = 0; i < taille; i++)

           tab[i] = rand() % 10;

      }

      int main() {

        int taille;

        printf("entrer taille du tableau:");

        scanf("%d", &taille);

        int* tab;

        tab = (int*)malloc(taille * sizeof(int));

        srand(time(0));

        init_random(tab, taille);

      #ifdef DEBUG

        for (int i = 0; i < taille; i++)

          printf("%d ", tab[i]);

      #endif // DEBUG

        return 0;

      }

    • 3. Programmation modulaire

      En général les projets de logiciels comportent au minimum des milliers de lignes de code. On comprend donc que placer le code dans un seul fichier, même avec une découpe en fonction peut devenir compliqué pour son développement et sa maintenance. C'est pour cette raison que l'on découpe le projet en fonctionnalités (par exemple dans le cas d'un logiciel de comptabilité) que l'on placera dans des fichiers séparées. Par exemple on trouvera dans le fichier calculs.c toutes les fonctions métiers nécessaire au logiciel de comptabilité. Dans la figure ci-dessous on trouve les différentes fonctionnalités d'un logiciel que l'on va découper en autant de fichier.

      Prenons un exemple simple pour illustrer le principe de la programmation modulaire intégrant des calculs sur les tableaux. Nous allons donc séparé les fonctions de travail sur les tableaux, de la fonction principale :

      • Le fichier principal main.c va accueillir le programme principal
      • le fichier array.c va accueillir toutes les fonctions de travail sur les tableaux, on prendra pour convention de placer à la fin du nom des fonction le nom du fichier ou elles se trouvent (ici xxxArray).
      • un troisième fichier array.h comportera les prototypes (ou déclarations) des fonctions.
      main.c
      array.c

      #define DEBUG

      #include <stdio.h>

      #include <stdlib.h>

      #include "array.h"

      int main() {

          int taille;

          printf("entrer taille du tableau:");

          scanf_s("%d", &taille);

          int* tab;

          tab = (int*)malloc(taille * sizeof(int));

          randomArray(tab, taille,10,20);

      #ifdef DEBUG

          displayArray(tab, taille);

      #endif // DEBUG

          return 0;

      }

      #include <stdio.h>

      #include<stdlib.h>

      #include <time.h>

      void randomArray(int* tab, int taille, int min, int max) {

          srand(time(NULL));

          for (int i = 0; i < taille; i++) {

              tab[i] = min + rand() % (max - min);

          }

      }

      void displayArray(int* tab, int taille) {

          for (int i = 0; i < taille; i++) {

              printf("%d ", tab[i]);

          }

          printf("\n");

      }

       

      A la différences avec les 2 fichiers précédents, le fichier array.h ne comporte pas de code et ne sera pas ajouter au projet (le fait d'ajouter des fichiers de code au projet permet de définir les fichiers qui doivent être compilés pour générer l'exécutable).

      array.h

      #pragma once

      void randomArray(int* tab, int taille, int min, int max);

      void displayArray(int* tab, int taille);

       
    • 4. Chaine de compilation

      La génération d'un exécutable se fait en plusieurs étapes :

      • la première étape consiste à venir appeler le preprocesseur qui va s'occuper de modifier le code c en fonction des directives de compilations (#include, #define,#ifdef,...)
      • La deuxième et principale étape va lire les fichiers (sans les directives préprocesseurs) et générer le code assembleur et le code machine de chaque fichier. 
      • Une troisième étape si elle est activée consiste à optimiser le code assembleur (soit en vitesse, soit en taille, soit les 2), cette étape peut éventuellement, enlever des  variables qui ne sont pas utilisées dans le programme (ou pas affichés) pour limiter cela on pourra utiliser le mot clé volatile avant la définition d'une variable pour que l'optimisation du code ne s'exécute pas sur cette variable (qui pourrait disparaitre).
      • la dernière étape appelée linker va à partir des différents code assembleur (et machine) généré un seul fichier exécutable.

      Voyons dans le détail les 3 principales étapes de compilation.

      1. Le préprocesseur

      Le préprocesseur va insérer dans le fichier tous les fichiers des bibliothèques (stdio.h, stdlib.h,...) et modifier le code pour intégrer les directives (#define,#ifdef,...), il est possible de générer le fichier intermédiaire aprés passage du préprocesseur (extension main.i) dans les options de compilation de Visual Studio (ou de tout autre compilateur) qui n'est pas visible en général.

      main.c

      main.i

      #define DEBUG

      #define TAILLE 10

      #include <stdio.h>

      #include <stdlib.h>

      #include "array.h"

      int main() {

          int taille=TAILLE;

          printf("entrer taille du tableau:");

          scanf_s("%d", &taille);

          int* tab;

          tab = (int*)malloc(taille * sizeof(int));

          randomArray(tab, taille,10,20);

      #ifdef DEBUG

          displayArray(tab, taille);

      #endif // DEBUG

          return 0;

      }

       

      #define DEBUG

      #define TAILLE 10

      #include <stdio.h>

      #include <stdlib.h>

      #include "array.h« 

      int main() {

          int taille = 10;

          printf("entrer taille du tableau:");

          scanf_s("%d", &taille);

          int* tab;

          tab = (int*)malloc(taille * sizeof(int));

          randomArray(tab, taille, 10, 20);

          displayArray(tab, taille);

          return 0;

      }

      2. Le compilateur

      Sous Visual Studio le résultat du compilateur peut être affiché (options de compilation) ainsi que le choix du type d'optimisation (dans notre exemple, sans optimisation pour rendre le code plus simple à comprendre). On retrouve :

      • en bleu l'adresse du code (qui ne sera pas l'adresse définitive de l'exécutable)
      • en vert le code machine de chaque fichier compilé
      • le code assembleur (code machine compréhensible par les humains).

      main.i

      main.cod

      int main() {

          int taille = 10;

          printf("entrer taille du tableau:");

          scanf_s("%d", &taille);

          int* tab;

          tab = (int*)malloc(taille * sizeof(int));

          randomArray(tab, taille, 10, 20);

          displayArray(tab, taille);

          return 0;

      }

      00013c7  44 24 30 0a 00 00 00 mov DWORD PTR taille$[rsp], 10

      0001b48  8d 0d 00 00 00 00    lea rcx, OFFSET:$SG4294967035

      00022e8  00 00 00 00          call printf

      0006841  b9 14 00 00 00       mov r9d, 20

      0006e41  b8 0a 00 00 00       mov r8d, 10

      000748b  54 24 30             mov edx, DWORD PTR taille$[rsp]

      0007848  8b 4c 24 20          mov rcx, QWORD PTR tab$[rsp]

      0007de8  00 00 00 00          call randomArray

      Le code assembleur et machine complet de la fonction displayArray est donné ci-dessous:

      array.i

      array.cod

      void displayArray(int* tab, int taille) {

          for (int i = 0; i < taille; i++) {

              printf("%d ", tab[i]);

          }

          printf("\n");

      }

       0000089  54 24 10             mov DWORD PTR [rsp+16], edx

       0000448  89 4c 24 08          mov QWORD PTR [rsp+8], rcx

       0000948  83 ec 38             sub rsp, 56; 00000038H

       0000dc7  44 24 20 00 00 00 00 mov DWORD PTR i$1[rsp], 0

       00015eb  0a                   jmp SHORT $LN4@displayArr

       000178b  44 24 20             mov eax, DWORD PTR i$1[rsp]

       0001bff  c0                   inc eax

       0001d89  44 24 20             mov DWORD PTR i$1[rsp], eax

      $LN4@displayArr:

       000218b 44 24 48              mov eax, DWORD PTR taille$[rsp]

       0002539 44 24 20              cmp DWORD PTR i$1[rsp], eax

       000297d 1b                    jge SHORT $LN3@displayArr

      3. Le linker

      Les fichiers objets compilés sont ensuite assemblés pour générer l'exécutable. Cette phase si elle génère des erreurs sera du au fait qu'une fonction compilée dans un autre fichier n'a pas été trouvée. Dans l'exemple ci-dessous, la déclaration de la fonction test permet au compilateur de compiler consoleApplication7.c ou se trouve la fonction main et c'est le linker lorsqu'il doit générer l'exécutable qui ne va pas trouver le code de cette fonction et va générer une erreur de type LNK (linker) "symbole externe non résolu".

    • 5. introduction aux structures de données

      Prenons l'exemple du calcul de la somme de 2 vecteurs OP1 et OP2. La fonction ajoute qui va calculer la somme de ces 2 vecteurs va nécessiter 6 arguments (2 arguments pour chaque vecteur dont 2 de type pointeurs pour OP3). Ce n'est pas très pratique, et ça pourrait être pire si l'on avait des vecteurs sur le plan X,Y et Z, il faudrait alors 9 paramètres à la fonction. On comprend que cette solution n'est pas satisfaisante, il est temps d'introduire la notion de structure de données qui va nous permettre de simplifier grandement l'appel des fonctions.

      Exemple de code permettant de faire la somme de vecteurs
      But du programme : faire la somme des vecteurs OP et OP2

      void ajoute(double* p3_x, double* p3_y, double p1_x, double p1_y, double p2_x, double p2_y) {

          *p3_x = p1_x + p2_x;

          *p3_y = p1_y + p2_y;

      }

      void affiche(double p_x, double p_y) {

          printf("(%.2lf,%.2lf)\n", p_x, p_y);

      }

      int main() {

          double p1_x=2.0, p1_y=1.0, p2_x=1.0, p2_y=2.0,p3_x,p3_y;

          ajoute(&p3_x, &p3_y, p1_x, p1_y, p2_x, p2_y);

          affiche(p3_x, p3_y);

          return 0;

      }

    • 6. Les structures de données

      Avant de parler de structure de données, voyons un nouveau mot clé du C : typedef. On peut définir un nouveau type de donnée grace au mot clé typedef. Ainsi dans l’exemple ci-dessous, on peut redéfinir le type monType comme étant un int. Quel intérêt ? aucun, c’est juste pour l’exemple.

      typedef int monType;

      monType maValeur=2.

      La ou c'est plus intéressant c'est lorsque l'on ouvre stdint.h qui redéfinie comme nous l'avons déjà vu les types uint8_t,uint16_t ,etc...  Ce fichier sera différent en fonction de l'OS et du type de processeur et permet quelle que soit la machine utilisé de définir le type int64_t comme étant un entier codé sur 64 bits.

      typedef unsigned char      uint8_t;

      typedef unsigned short     uint16_t;

      typedef unsigned int       uint32_t;

      typedef unsigned long long uint64_t;

      Le mot clé typedef peut aussi être utilisé lors de la définition des structures de données, on aura donc 2 solutions pour créer et utiliser des structures de données :

      1. utiliser struct maStruct x  pour définir x de type structure maStruct
      2. utiliser MaStruct (à condition d'avoir défini avec typedef que MaStruct est de type struct ). c'est cette deuxième solution qui est le plus souvent utilisée

      Reprenons notre exemple de calcul de vecteurs, la structure point va nous permettre de répondre plus simplement au cahier des charges, puisqu'un point est constitué de 2 variables x,y.

      Remarque : à la différence avec un tableau dont toutes les valeurs sont de même type, une structure de donnée peut être constitué de différents types de variables,

      Solution 1 : création de struct point
      Solution 2: utilisation de typedef struct

      struct point {

          double x;

          double y;

      };

      int main()

      {

          struct point p1 = { 2.0,1.0 };

          printf("P1(%.2lf,%.2lf)", p1.x, p1.y);

      }

      typedef struct {

          double x;

          double y;

      } Point;

      int main()

      {

          Point p2 = { 2.0,1.0 } ;

          printf("P2(%.2lf,%.2lf)\n",p2.x, p2.y);

      }

    • 7. Fonctions et pointeurs sur structures de données

      Comme pour toute variable il est possible de créer un pointeur sur structure de données comme montré dans l'exemple ci-dessous, pour accéder aux variables de cette structure 2 solutions :

      • (*p_x).largeur avec *p_x qui correspond à x (qui est une structure de données)
      • p_x->densite qui est l'autre solution permettant de lire la variable densite de la structure pointée par p_x

      Ce programme permet d'afficher les valeurs de x (mais en passant par le pointeur p_x qui pointe sur x:

      typedef struct {

          int largeur;

          double densite;

      }Cube;

      int main() {

          Cube x = { 10,1.23 };

          Cube* p_x;

          p_x = &x;

          printf("%d %.2lf", (*p_x).largeur, p_x->densite);

      }

      Comment modifier une structure de données dans une fonction ? avec le mot clé return s'il n'y a qu'une variable à modifier ou bien avec les pointeurs.  Dans le programme ci-dessous on peut voir plusieurs fonctions dont la fonction déplace qui prend comme paramètre un pointeur sur structure.

      Exemple de programme utilisant des fonctions (utilisation de pointeurs)
      Résultat

      typedef struct {

          double x;

          double y;

      } Point;

      void affiche(Point p) {

          printf("(%.2lf,%.2lf)\n", p.x, p.y);

      }

      double longueur(Point p1, Point p2) {

          return(sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y)));

      }

      void deplace(Point *p, double x, double y) {

          p->x += x;

          p->y += y;

      }

      Point ajoute(Point p1, Point p2) {

          Point p;

          p.x = p1.x + p2.x;

          p.y = p1.y + p2.y;

          return p;

      }

      int main()

      {

          Point p1 = { 2.0,1.0 };

          Point p2 = { 2.0,1.0 } ;

          Point p3;

          deplace(&p2, 2, 2);

          p3 = ajoute(p1, p2);

          affiche(p1);

          affiche(p2);

          affiche(p3);

      }

      (2.00,1.00)

      (4.00,3.00)

      (6.00,4.00)

    • 8. Les énumérations

      En C, une énumération (ou enum en anglais) est un type de données défini par l'utilisateur qui permet de créer des variables qui ne peuvent contenir que certaines valeurs prédéfinies, appelées énumérateurs. Cela permet de rendre le code plus lisible et plus facile à maintenir, en donnant des noms significatifs aux valeurs possibles d'une variable

      Prenons un premier exemple : nous voulons créer une enumération nommée booleen qui peut prendre 2 nom faux ou vrai. On peut alors créer une variable de ce type enum booleen et utiliser les noms faux ou vrai à la place des valeurs 0 ou 1.

      enum booleen { faux = 0, vrai = 1 };//création de l'énumération booléen

      enum booleen b;                    //création de la variable b de type booléen

      b = vrai;                          //initialisation de b, b=1

      printf("b = %d\n", b);             // affiche b = 1

      Prenons un deuxième exemple : supposons que l'on veuille coder une machine à état. Rappelons qu'une machine à état permet de spécifier de façon graphique un problème séquentiel que l'on va ensuite pouvoir coder (trés utilisé en informatique embarquée). On peut voir que la solution 2 utilisant les énumérations est plus lisible que les états 0,1,2 de la solution 1.

      Remarque: si aucune valeur n'est donnée aux énumération alors les valeurs associées commencent à 0, puis s'incrémentent. Dans l'exemple de la solution 2 printf("%d", ST_START);  afficherait 0 puisque c'est le premier énumérateur et printf("%d", ST_BP_LOW); afficherait 1

      Exemple de machine à état
      Solution 1 : sans les énumérations
      Solution 2 : avec les énumérations (plus lisible)

      int main() {

          int state = 0;

          while (1) {

              switch (state) {

              case 0: if (bp == 0)

                  state = 1; break;

              case 1: if (bp == 1)

                  state = 2; break;

              case 2: if (reset == 0)

                  state = 0;

              }

          }

      }

      enum States {

          ST_START,

          ST_BP_LOW,

          ST_SIRENE

      };

      int main() {

          enum States state = ST_START;

          while (1) {

              switch (state) {

              case ST_START: if (bp == 0)

                  state = ST_BP_LOW; break;

              case ST_BP_LOW: if (bp == 1)

                  state = ST_SIREN; break;

              case ST_SIRENE: if (reset == 0)

                  state = ST_START;

              }

          }

      }

    • 11. Test de fin de chapitre

    • 14.1 L’opérateur sizeof()
      14.2 Les énumérations : mot clé enum
      14.3 Les directives préprocesseurs :
      14.4 La programmation modulaire :
      14.5 Chaine de compilation    
      14.6 Les structures de données
      14.7 Définition de type : typedef    
      14.8 Allocation dynamique :