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 ?
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)
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).
pour 2 instructions du programme C, le compilateur a généré 7 instructions assembleur pour un code exécutable qui fait 34 octets.
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;
movdword ptr [n1],2
movdword ptr [n2],3
movdword ptr [n3],0
n1 = 2
n2 = 3
n3 = 0
n3 = n1 * n2 + 20;
moveax,dword ptr [n1]
imuleax,dword ptr [n2]
addeax,14h
movdword 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.
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.
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.
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);
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 dudebut 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ébutdu tableau 0xfff000bc0 | tab[0]= 1ou *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 tableautab[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.
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;
charchaine[] = "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).
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;
unsignedchar* 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;
unsignedchar 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)