• 2. Code machine et code assembleur

      Nous allons voir dans cette partie à quoi servent les pointeurs et pour cela nous allons analyser le code assembleur et le code machine généré par le compilateur. Pourquoi ? Tout simplement pour montrer que les pointeurs sont à la base du langage machine (toute variable a une adresse en mémoire et donc il faudra pour lire sa valeur avoir un pointeur qui possède l'adresse de cette variable) et comme le langage C est connu pour être le langage le plus proche de la machine (et donc rapide) il est normal que la notion de pointeur existe en C.

      Entrons dans le détail du fonctionnement d’un programme simple et analysons grace au debug sous Visual Studio Community 2022 le code exécutable généré. Voici l’extrait du du fichier main.c 

       int n1 = 2, n2 = 3, n3=0;

       n3 = n1 * n2 + 20;

      Que peut-on voir dans cette copie d’écran du programme C désassemblé par le débugger ?

      1. Les données et le programme sont en RAM à des adresses définies par l’OS (pour un OS 64 bits, chaque adresse est codée sur 8 octets)
      2. Le compilateur a généré un code assembleur qui est le langage du processeur. Chaque instruction assembleur est codée sous forme de 0 et 1 (affichage en hexadecimal) en mémoire RAM (langage machine). Toutes ces instructions sont exécutées ligne par ligne par le processeur à la fréquence du processeur (2Milliards d’instructions par seconde pour une fréquence de 2GHz).
      3. pour 2 instructions du programme C, le compilateur a généré 7 instructions assembleur pour un code exécutable qui fait 34 octets.
      4. En assembleur INTEL, l’accés aux variables n1,n2,n3 se fait via un pointeur 64 bits dword ptr [n1], dword ptr [n2], dword ptr [n3].

      Après compilation du fichier main.c, l’exécutable main.exe est copié sur le disque dur. Au moment de l’exécution, le fichier main.exe est copié en mémoire RAM à une adresse fixée par l’OS (cette adresse varie à chaque lancement du programme). Si l’on utilisait un desassembleur pour faire du reverse engineering (objdump par exemple), on trouverait une suite d’octets à l’endroit de la mémoire ou l’exécutable à été copié par l’OS.

      Prenons un exemple, à l’adresse 0x000D167F se trouve le code 83 C0 14. Chaque instruction possède un code machine. 83 C0 est le code machine de l’instruction add eax, (valeur). La valeur qui suit étant 14h, l’instruction code machine 83 C0 14 est donc exécutée par le CPU qui exécute eax=eax+0x14

      Code C

      Code après compilation (assembleur)

      Commentaire

      int n1 = 2, n2 = 3, n3=0;

      mov         dword ptr [n1],2 

      mov         dword ptr [n2],3 

      mov         dword ptr [n3],0 

      n1 = 2

      n2 = 3

      n3 = 0

      n3 = n1 * n2 + 20;

      mov         eax,dword ptr [n1] 

      imul        eax,dword ptr [n2] 

      add         eax,14h

      mov               dword ptr [n3],eax 

      eax = n1

      eax = eax * n2

      eax = eax + 20

      n3 = eax

      Les calculs se font au travers de l’unité de calcul (ALU ou Arithmetic and Logic Unit) du CPU et utilisent des registres (en général eax, mais il en existe d’autres ebx, ecx, edx,..). 

      Le microprocesseur ou CPU est constitué d’un compteur d’instructions qui vient s’incrémenter pour pointer vers l’instruction suivante (en RAM) à chaque cycle machine. Ce qui veut dire que si le code machine du programme compilé est constitué de 100 instructions, il faudra au processeur 100ns (pour un CPU cadencé à 1GHz) pour exécuté le code. C'est grace au compteur d'instructions qu'un programme commence en début de fichier et se termine en fin de fichier (les instructions sont lues du haut vers le bas, c'est à dire de l'adresse la plus basse à l'adresse la plus haute).

      Chaque instruction est décodée et exécutée par le microprocesseur. Lorsqu’il y a des calculs, le CPU va utiliser sont unité de calcul (ALU) qui va exécuter chaque calcul entre un registre (ici eax) et une variable ou une valeur. Le résultat est renvoyé vers le registre.

      Dans la figure ci-contre, imul et add sont des instructions qui utilise l’unité de calcul  ou ALU du CPU.

    • 3. Travailler avec les pointeurs en C

      Le code exécutable (et donc l’assembleur) travaille continuellement avec des pointeurs sur des variables. Or le langage C est proche du processeur, il est donc indispensable que le langage C sache manipuler les pointeurs vers les données. Il existe 3 concepts liés aux pointeurs :
      1.    Comment connaitre l’adresse d’une variable :  &
      2.    Comment créer une variable de type pointeur int *pAge
      3.    Comment lire le contenu d’une variable pointée par un pointeur : *pAge

      1. Connaitre l’adresse d’une variable

       Il est possible de visualiser cette adresse en utilisant le & devant la variable :

      • age désigne la valeur de la variable
      • &age désigne l'adresse de la variable.
      • "%p" permet d’afficher l’adresse de la variable. Votre OS est un OS 64 bits (8 octets) et donc l’affichage de l’adresse se fait sur 16 chiffres en hexadécimal.

      int age=18;
printf(""adresse : %p, valeur :%d",&age,age);

      2. Créer une variable de type pointeur

      On peut définir une variable de type pointeur en utilisant * devant la variable.
      int *pAge ; est une variable particulière (de type pointeur) puisqu’elle aura une adresse et non une valeur.

      int age=18;
int *pAge = &age;
printf("adresse de age = %p",pAge);

      3. valeur pointée par :

      Peut-on afficher la valeur pointée par pAge ? oui et ce grace à *.
       Attention donc à ne pas confondre int *pAge qui définit un pointeur et *pAge qui permet de lire la valeur pointée par pAge, c’est-à-dire la valeur 18.

      int age = 28;
int *pAge = &age;
printf("valeur de age = %d", *pAge);

      Récapitulons :

      extrait de code permettant de tester la notion de pointeur
      résultat sur pythontutor

       int* p;//création du pointeur (non initialisé)

       int n = 3;//création et initialisation d'une variable

       p = &n;//p pointe vers n ou p prend l'adresse de n

       *p = *p + 1;//équivalent à n=n+1

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

       printf("adresse de n=0x%x\n", p);

      int *p;
int n=3;
p=&n;
*p = *p + 1:
printf("n=%d",n);

    • 4. Pointeurs et tableaux

      Tout tableau en C est en fait un pointeur constant (non modifiable).

       int tab[5]; //tab est un pointeur constant dont la valeur est l'adresse du premier élément du tableau.   

       

      tab est un pointeur vers le début du tableau. Ainsi tab[0] est équivalent à *tab et tab[4] à *(tab+4). 

      Evidemment on préfèrera le formalisme tableau, même si ce qui se cache derrière ce formalisme est un pointeur que l’on décale du nombre de case nécessaire

      Afin de montrer qu'un tableau est un pointeur vers la première case du tableau, prenons un exemple que nous allons tester sur pythontutor (mais nous pourrions aussi bien le tester sur Visual Studio ou tout autre compilateur). Nous allons afficher l'adresse du début et de la fin du tableau ainsi que le premier et dernier élément du tableau en utilisant le formalisme tableau (tab[0]) et le formalisme pointeur (*tab) et nous allons voir que nous obtenons le même résultat.

       Programme de test

      #include <stdio.h>

      int main() {

          int tab[5] = { 1,5,9,13,17 };

          printf("adresse du  debut du tableau %p | tab[0]=%2d ou *tab    =%2d\n", tab,     tab[0] ,  *tab      );

          printf("adresse de la fin du tableau %p | tab[4]=%2d ou *(tab+4)=%2d\n", tab + 4, tab[4] , *(tab + 4) );

      }

      Résultat

      adresse du début  du tableau 0xfff000bc0 | tab[0]= 1  ou *tab    = 1

      adresse de la fin du tableau 0xfff000bd0 | tab[4]=17  ou *(tab+4)=17

      Si l'on résume le résultat de notre programme (on affiche les cases mémoires du bas vers le haut, c'est à dire l'adresse la plus basse en bas et l'adresse la plus haute en haut), on peut voir que notre tableau de 5 entiers commence à l'adresse 0xfff000bc0 (adresse de tab) et se termine à l'adresse 0xfff000bd0 (adresse de tab+4), puisque chaque case mémoire de type int est codée sur 4 octets. L'incrémentation du pointeur sur un int prend en compte le type pointée (donc le pointeur s'incrémente de 4 octets en 4 octets).

      Prenons un deuxième exemple dans lequel on crée un tableau de 5 cases et un pointeur p qui prend l'adresse du tableau, l'accés au tableau peut se faire de 3 façons:

      • En utilisant le formalisme tableau tab[i]
      • En utilisant le formalisme pointeur *(tab + i)
      • En utilisant le pointeur p et en l'incrémentant *(p++)  pour se déplacer dans le tableau.
      Extrait de code
      Résultat

      #include <stdio.h>

      int main() {

         int tab[5] = { 1,3,10,20,2 };

         int* p;

         p = tab;

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

            printf("%2d %2d %2d\n", tab[i], *(tab + i) , *(p++) );

      }

       1  1  

       3  3  

      10 10 10 

      20 20 20 

       2  2  


    • 5. Pointeurs et chaines de caractères

      Les chaines de caractères sont aussi des tableaux de caractères, on peut donc appliquer les mêmes règles entre le formalisme tableau et le formalisme pointeur. Dans les 2 exemples ci-dessous, on peut voir 2 façons de parcourir une chaine de caractères jusqu'au '\0' terminal : 

      • solution 1 : déplacement dans le tableau en utilisant un index; sans modifier l'adresse de la chaine de caractère chaine[i] ou *(chaine+i)
      • solution 2 : déplacement dans le tableau en modifiant l'adresse de la chaine chaine++; cette solution n'est possible que si on définit au préalable un pointeur sur la chaine de caractère (rappelons que char chaine[]="bon"; est équivalent à const char* chaine="bon";; et donc on ne peut pas modifier l'adresse de chaine pour un tableau de caractères). 
      Solution 1 : déplacement avec un indexe i 
      Solution 2: déplacement en incrémentant l'adresse de chaine
        int i;

       char chaine[] = "bon";

       for(i=0; chaine[i]!='\0';i++);

       printf("nombre de caracteres = %d\n", i);

       int i;

       char* chaine = "bon";

       for (i = 0; *chaine != '\0' ; i++)

           chaine++;

       printf("nombre de caracteres = %d\n", i);


    • 6. Le mot clé sizeof

      L’opérateur sizeof() permet de calculer la taille en octet d’un type ou d’une variable. Dans l’extrait ci-dessous, valeur est un double (codée sur 8 octets), int (le type) est codé sur 4 octest, le tableau de 10 entiers prend 40 octets, on peut donc en déduire le nombre de case du tableau d'entier en divisant par 4 ou par sizeof(int).

      Exemple d'utilisation de sizeof
      résultat

          int tab[10];

          double valeur = 2.0;

          printf("taille en octets de valeur  :%3d\n", sizeof(valeur) );

          printf("taille en octets d'un int   :%3d\n", sizeof(int) );

          printf("taille en octets du tableau :%3d\n", sizeof(tab) );

          int taille = sizeof(tab) / sizeof(int) ;// taille du tableau =10

          printf("nombre de valeurs du tableau :%3d\n", taille);

      taille en octets de valeur  :  8

      taille en octets d'un int   :  4

      taille en octets du tableau : 40

      nombre de valeurs du tableau : 10

    • 7. Pointeurs et tableaux dynamiques

      Il existe 2 méthodes permettant de créer un tableau dont la taille n’est pas connue à la compilation :

      • La plus intuitive noté VLA (Variable Length Array) n’est pas implémentée sur tous les compilateurs. Elle fonctionne sur Code ::Blocks (C99) mais pas sur les compilateurs modernes.
      • La deuxième est universelle et c’est la méthode qui est préconisée puisqu’elle fonctionne sur tous les compilateurs et fonctionne sur le principe de l’allocation dynamique de mémoire (avec l’utilisation d’un pointeur sur le tableau).


      On utilise l’opérateur sizeof() pour trouver la taille en octet .

      Allocation dynamique type VLA (ne fonctionne pas avec les compilateurs modernes -> n'est plus utilisé)

      Véritable allocation dynamique (malloc ou calloc)

      #include <stdio.h>

      #include <stdlib.h>

      #include <time.h>

      int main() {

          int taille;

          printf("entrer taille du tableau:");

          scanf("%d", &taille);

          int tab[taille];// allocation de type VLA

          srand(time(0));

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

              tab[i] = rand() % 10;

          }

          return 0;

      }

      #include <stdio.h>

      #include <stdlib.h>

      #include <time.h>

      int main() {

          int taille;

          printf("entrer taille du tableau:");

          scanf("%d", &taille);

          int* tab;

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

          srand(time(0));

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

              tab[i] = rand() % 10;

          }

          return 0;

      }

      Donc pour créer un tableau dynamique, nous avons besoin d'un pointeur et d'une fonction permettant d'allouer un nombre d'octets à ce tableau. Il existe 2 fonctions permettant d'allouer cette mémoire :

      • malloc  qui alloue de la mémoire non initialisée
      • calloc qui alloue de la mémoire initialisée (toutes les cases mémoires ont été forcées à 0). 
      int* tab = (int*) calloc(5,sizeof(int)); int* tab = (int*) malloc (5 * sizeof(int));

    • 8. Projet son : créer un tableau dynamique

      Revenons sur notre projet son et voyons comment créer un tableau dynamique pour lire les échantillons de son. Jusqu'à présent nous avons créer un tableau statique de 1024 octets sans prendre en compte la véritable dimension des données du fichier.

      Mais comment faire pour avoir un tableau de la taille des échantillons se trouvant dans le fichier? Rappelons que le fichier sinus.wav dure 3s et est échantillonné à 8kHz, il y aura donc 3*8000 échantillons de son. La taille de ces données est fourni dans le header à l'emplacement 20. Pour créer le tableau de la taille des échantillons, il faut donc lire la taille des échantillons à l'emplacement 20 puis créer un tableau de la taille lue en utilisant malloc.

      Programme utilisant l'allocation dynamique
      Programme sans allocation dynamique (utilisé précédemment).

      // pour pouvoir utiliser fopen

      #define _CRT_SECURE_NO_WARNINGS

      #include <stdio.h>

      int main() {

          FILE* file;

          unsigned char* tab;

          // Ouvrir le fichier en mode lecture & modification binaire

          file = fopen("..\\ressources\\sinus.wav", "rb+");

          if (file == NULL) {

              printf("Impossible d'ouvrir le fichier.\n");

              return 1;

          }

          //lecture de la taille du tableau (nombre d'échantillons de son)

          fseek(file, 40, SEEK_SET);

          fread(&taille, sizeof(int), 1, file);

          // création d'un tableau dynamique

          tab = malloc(taille);

          // Aller à la position de début des données audio (44 octets)

          fseek(file, 44, SEEK_SET);

          // Lire 1024 octets dans le fichier et les placer dans le tableau

          fread(tab, 1, 1024, file);

          // Afficher les données audio (fichier en mono et sur 8 bits)

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

              printf("tab[%2d]=%hhX\n", i, tab[i]);

          }

          // fermeture fichier

          fclose(file);

          return 0;

      }

      // pour pouvoir utiliser fopen

      #define _CRT_SECURE_NO_WARNINGS

      #include <stdio.h>

      int main() {

          FILE* file;

          unsigned char tab[1024];

      //Ouvrir le fichier en mode lecture

          file = fopen("..\\ressources\\sinus.wav", "rb+");

          if (file == NULL) {

              printf("Impossible d'ouvrir le fichier.\n");

              return 1;

          }

       

       

      // Aller à la position de début des données(44 octets)

          fseek(file, 44, SEEK_SET);

      // Lire 1024 octets dans le fichier 

          fread(tab, 1, 1024, file);

      // Afficher les données audio 

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

              printf("tab[%2d]=%hhX\n", i, tab[i]);

          }

          // fermeture fichier

          fclose(file);

          return 0;

      }

    • 9. Test de fin de chapitre

    • 12.1 Code machine et code assembleur
      12.2 Définition et utilisation des pointeurs en C
      12.3 Tableaux et pointeurs