Les adresses invalides et débordements de tableaux
C'est un grand classique. Comme vous le savez tous, les pointeurs contiennent des adresses de cases mémoires, et ces adresses sont des entiers. Pourtant, le code suivant est faux :
Code : C | #include <stdio.h>
int main(void)
{
int *mon_pointeur = 0x13374242;
*mon_pointeur = 170;
printf("%d\n", *mon_pointeur);
return 0;
}
|
Ici, j'ai déclaré un pointeur contenant l'adresse-mémoire 13374242 (en hexadécimal). J'ai ensuite demandé à écrire dans cette case mémoire, puis à afficher la valeur que j'y avais écrite. Ce code est faux car je n'ai pas le droit d'écrire à une telle adresse ! La case en question pourrait très bien appartenir à un autre programme, avec lequel je n'ai pas à interférer. Si vous tentez de compiler puis d'exécuter ce code, il est probable que votre système d'exploitation ferme brutalement votre programme pour l'empêcher de faire des dégâts. Il vous parlera alors d'une
erreur de segmentation (
segmentation fault, ou
segfault pour faire court). La majorité des « crashes » que vous pouvez observer dans vos logiciels favoris sont dus à des segfaults.
Il faut donc
toujours prendre garde à manipuler des adresses qui vous appartiennent. On les appelle les
adresses valides, ce sont :
- les adresses de vos variables, locales et globales ;
- les adresses des cases contenues dans les blocs renvoyés par un appel réussi à malloc, calloc ou realloc ;
- les adresses des cases de tableaux déclarés statiquement.
Toutes les autres adresses sont
invalides, il vous est interdit de lire ou d'écrire dans les cases mémoires qu'elles désignent. Notez que
NULL est
toujours une adresse invalide.
N'essayez jamais de lire ou d'écrire à une adresse invalide.
Bien entendu, il arrive rarement que l'on déclare un pointeur « en dur » comme dans le code précédent. Mais il est facile de se laisser distraire, voyez plutôt...
Code : C 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | #include <stdio.h>
int main(void)
{
int i = 0;
int tableau[4] = {1, 2, 3, 4};
int somme = 0;
/* FAUX ! */
for(i = 0; i <= 4; i++)
{
somme += tableau[i];
}
printf("%d\n", somme);
return 0;
}
|
Ici, nous déclarons un tableau de quatre cases :
tableau[0],
tableau[1],
tableau[2],
tableau[3]. Les indices d'un tableau à n cases vont de
0 à
n-1. Aussi, lorsque i vaudra 4 dans la boucle, nous tenterons de lire
tableau[4]... Qui est en réalité la case mémoire située immédiatement après notre tableau. Et nous n'avons aucune idée de ce qu'elle contient !
Le programme va donc planter ?
Pas nécessairement, puisqu'il s'agit d'un comportement indéterminé. Tout peut arriver. En l'occurrence, il est assez probable que
tableau[4] désigne soit la variable
i, soit la variable
somme, selon votre processeur, votre système d'exploitation et votre compilateur. Vous vous retrouverez donc avec le double de la somme voulue, ou bien avec
somme+4... L'origine d'un tel problème peut être un véritable mystère pour le programmeur non-averti !
Ne pas tester les retours de fopen ou malloc
Un autre cas de figure menant à l'écriture à une adresse invalide est le suivant :
Code : C 1
2
3
4
5
6
7
8
9
10
11
12
13 | #include <stdio.h>
int main(void)
{
char buffer[40];
FILE *monfichier = fopen("donnees.txt", "r");
fgets(buffer, 40, monfichier);
puts(buffer);
fclose(monfichier);
return 0;
}
|
Si le fichier «
donnees.txt » existe bel et bien, tout se passera correctement. Mais qu'en est-il si le fichier n'existe pas, ou bien si nous n'avons pas le droit de le lire ? La fonction
fopen va alors renvoyer
NULL (définie comme valant 0 dans la norme). Or
NULL n'est jamais une adresse valide ! Lorsque nous passons cette adresse à la fonction
fgets, cette dernière tentera innocemment de lire la case pointée par
NULL... Et déclenchera un plantage du programme ! Ainsi, chaque fois que vous appelez une fonction renvoyant un pointeur, il faut
systématiquement vérifier que ce pointeur n'est pas
NULL. Cette remarque est également valable pour
malloc et consorts.
Ne partez jamais du principe que votre fichier sera toujours là, ou qu'une allocation réussira toujours. Les êtres humains font des erreurs, et la réalité du monde du développement a plus d'imagination que vous. En testant la valeur de retour de fopen, vous vous épargnez beaucoup de temps perdu à chercher le problème si un fichier est accidentellement supprimé.
Nous verrons dans la dernière sous-partie de ce tutoriel comment se prémunir contre les fonctions dont l'appel échoue. Pour le moment, nous allons voir un dernier cas de figure pouvant mener à manipuler des adresses invalides.
Renvoyer l'adresse d'une variable locale
Une dernière manière d'obtenir une adresse invalide :
Code : C 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 | #include <stdio.h>
/* Ce code est FAUX, Archi-FAUX, mais il a des chances de fonctionner tout de même ! */
int *minimum(int a, int b)
{
if(a < b)
{
return &a;
}
else
{
return &b;
}
}
int main(void)
{
int *ptr = minimum(3, 4);
printf("%d\n", *ptr);
return 0;
}
|
Vous avez sans doute remarqué que ce code est inhabituel... L'idée est la suivante : nous avons une fonction
minimum, qui prend en paramètre deux entiers et renvoie l'adresse du plus petit d'entre eux. Puis, nous appelons cette fonction avec deux constantes, 3 et 4. Mais quelles sont les adresses de ces constantes ?
Bien entendu, ce code a été préparé spécialement pour mettre au jour le problème. Mais il faut toujours garder à l'esprit que, dans une fonction, les variables locales et les paramètres sont des objets
temporaires. Sitôt que nous sortons de la fonction, ils cessent d'exister... Et les cases mémoires qu'ils occupaient sont alors réutilisées pour autre chose. Elles deviennent donc invalides pour nous.
Les adresses de variables ou paramètres locaux sont uniquement valides pendant la période où ces variables existent.
Remarquez que le code précédent
a des chances de fonctionner correctement chez vous. La raison est simple : bien que la case mémoire dont nous renvoyons l'adresse n'est plus utilisée, elle n'est pas encore affectée à un autre usage au moment de l'affichage. Elle contient donc toujours son ancienne valeur, « 3 », qui s'affichera ainsi correctement. Cependant, il s'agit ici de pure chance. Il est tout à fait possible que l'OS récupère cette case pour une autre fonction de votre programme, voire pour un autre logiciel. Nous avons donc une belle illustration du fait qu'un code très dangereux peut
avoir l'air de marcher.
Un code qui s'exécute correctement n'est donc pas synonyme d'un code juste.
La fonction fflush et stdin
Nous passons maintenant à un tout autre type de problème.
Connaissez-vous
fflush ? Il s'agit d'une fonction vidant le tampon (
buffer) d'un fichier.
Qu'est-ce qu'un buffer ?
Lorsque vous écrivez dans un fichier, il est rare que vos données soient envoyées directement sur le disque dur. En effet, l'accès au disque est une action relativement lente ; les systèmes d'exploitation préfèrent donc attendre d'avoir plusieurs choses à écrire pour envoyer effectivement les données sur le disque. Dans l'intervalle, les octets à écrire restent dans un
buffer, c'est-à-dire une zone de la mémoire vive. Le contenu du
buffer est écrit sur disque dès que l'une des trois situations suivantes survient :
- le buffer est plein (impossible à contrôler) ;
- une fin de ligne (caractère '\n') est envoyée ;
- la fonction fflush est appelée sur le flux de fichier.
La fonction
fflush sert donc précisément à cela : elle force le système d'exploitation à vider le
buffer, provoquant ainsi l'écriture des données sur le disque dur.
Notez qu'il existe également un
buffer pour accéder à un fichier en lecture. Plutôt que d'aller chercher les données sur le disque au fur et à mesure que vous les demandez, le système préfère lire tout un bloc d'octets en une seule fois. Le contenu de ce bloc est alors envoyé vers le
buffer, dans lequel les fonctions telles que
fgets vont venir lire. Lorsque le
buffer est vide, un nouveau bloc est lu depuis le disque.
Voici un schéma de la
bufferisation (utilisation d'un
buffer) des flux :
Il y a un
buffer par flux, c'est-à-dire un
buffer pour chaque variable
FILE*.
Cette
bufferisation s'applique également aux entrées et sorties sur la console. Par conséquent, le texte d'un
printf ne s'affichera qu'après un
flush (retour à la ligne,
buffer plein, appel à
fflush) du flux
stdout. Il en va de même pour le flux d'erreur
stderr. Dans le cas de l'entrée clavier, si le
buffer est vide, le programme s'arrête et demande à l'utilisateur de taper une ligne. Cette ligne (terminée par le caractère
'\n') est ensuite copiée dans le buffer où elle est lue par scanf et consorts.
Ce comportement est à l'origine de nombreuses erreurs impliquant la fonction
scanf. En effet, si, lors d'un appel à
scanf, l'intégralité du
buffer n'est pas lue, l'appel suivant lira la fin du
buffer et ne s'arrêtera donc pas pour laisser l'utilisateur taper quelque chose. Il s'agit d'un problème fréquemment rencontré par les débutants ; voici donc une liste d'articles traitant du sujet plus en profondeur :
De nombreux programmeurs conseillent de vider le
buffer d'entrée avant chaque utilisation de
scanf. Nous pourrions être tentés d'utiliser la fonction
fflush sur
stdin pour le faire, mais...
Si vous appelez fflush sur un flux en lecture, le comportement est indéterminé.
Pour certains compilateurs, l'effet va être de vider le
buffer. Dans ce cas, l'endroit où la lecture reprend sur le disque n'est pas spécifié. Sur d'autres plates-formes, l'effet sera totalement aléatoire. Dans tous les cas, vous n'avez aucune idée de ce qui va se passer,
abstenez-vous donc d'appeler fflush sur des flux en entrée, en particulier
stdin.
Il ne
faut pas utiliser la fonction
fflush, mais plutôt quelque chose du style :
Code : C | void vider_buffer(void)
{
int c;
do
{
c = fgetc(stdin);
} while(c != EOF && c != '\n');
}
|
Les fichiers ouverts en lecture-écriture
Les comportements indéterminés, comme on l'a vu, peuvent survenir aussi bien au sein du langage lui-même que dans la bibliothèque standard. Voici un nouvel exemple de ce dernier cas : si vous ouvrez un fichier en lecture-écriture (modes
"r+" ou
"w+"), il doit suivre des règles bien précises.
- Règle 1. Pour faire une lecture juste après une écriture, il faut d'abord appeler fflush ou déplacer le curseur du fichier (avec fseek par exemple).
- Règle 2. Pour faire une écriture juste après une lecture, il faut d'abord déplacer le curseur du fichier (avec fseek par exemple), sauf si la lecture a atteint la fin du fichier (EOF).
Le non-respect de ces deux règles entraîne un comportement indéterminé. Pour faire simple, il est préférable de repositionner le curseur du fichier chaque fois que l'on passe de « lecture » à « écriture », et inversement. D'une façon générale, les fichiers ouverts en lecture-écriture sont délicats à manier et source d'erreur. Les cas où leur utilisation est justifiée sont assez rares, il est donc recommandé de les éviter lorsque c'est possible.
Les changements de valeur multiples
Un dernier comportement indéterminé, peut-être plus ésotérique : les affectations multiples. La norme spécifie que toute instruction
ne peut changer la valeur d'une variable qu'une seule fois, au maximum. Ainsi, des lignes telles que :
Code : C
ont un comportement indéterminé. La valeur de
i est en effet incrémentée une première fois par l'opérateur
++, puis changée par l'affectation
=. La traduction que devrait avoir ce genre d'instructions en assembleur n'est pas claire ; aussi, le langage C ne précise pas la signification qu'elle doit avoir. Tout peut donc arriver, encore une fois, c'est donc une très mauvaise idée.
Ce genre d'erreurs survient parfois lorsqu'on tente d'écrire des
oneliners, ces fonctions en une seule ligne. Prenons par exemple cet essai de fonction supprimant le premier caractère d'une chaîne :
Code : C | void *my_chop(char *s)
{
while(*s = * ++s) {}
}
|
Si vous êtes amateurs de ce genre de lignes cryptiques, les changements de valeurs multiples en une unique instruction ne sont jamais bien loin.
Nous avons fait un petit tour des comportements indéterminés et autres erreurs courantes en langage C. Cette liste est évidemment loin d'être exhaustive ; un recensement de toutes les erreurs possibles en langage C serait bien trop long pour être exploitable. Ceux qui veulent aller plus loin avec les UB peuvent consulter cette série d'articles :
ce que tout programmeur C devrait savoir sur les comportements indéterminés -
partie 2 -
partie 3.
Nous allons plutôt voir des conseils généraux pour prévenir les erreurs qui peuvent survenir. Nous verrons également comment faciliter la détection et la résolution des problèmes. En avant !