Débusquer et apprivoiser les bugs en C

Formation

En Ligne

Prix sur demande

Appeler le centre

Avez-vous besoin d'un coach de formation?

Il vous aidera à comparer différents cours et à trouver la solution la plus abordable.

Description

  • Typologie

    Formation

  • Méthodologie

    En ligne

Grâce à cette formation vous pourrez acquérir les connaissances nécessaires qui vous permettrons d’ajouter des compétences à votre profil et obtenir de solides aptitude qui vous offriront de nombreuses opportunités professionnelles.

Questions / Réponses

Ajoutez votre question

Nos conseillers et autres utilisateurs pourront vous répondre

À qui souhaitez-vous addresser votre question?

Saisissez vos coordonnées pour recevoir une réponse

Nous ne publierons que votre nom et votre question

Les Avis

Les matières

  • C++

Le programme

Introduction du cours

Bonjour ! :)

Ce tutoriel a pour but de mieux vous faire comprendre ce qui se passe et comment réagir lorsque votre code compile, mais ne s'exécute pas correctement. Nous en profiterons pour tordre le cou à quelques mauvaises pratiques sources d'erreur.

Nous verrons en premier lieu ce qui peut amener un programme à « crasher », puis les erreurs fréquentes empêchant nos chers codes de tourner. Nous terminerons par de nombreux conseils de prévention pour éviter de tomber dans de mauvaises situations.

Notez que le traitement et la correction des erreurs est un sujet extrêmement vaste. Ce tutoriel n'est qu'une petite introduction, n'hésitez pas à creuser le sujet par vous-mêmes !

C'est parti pour la chasse aux bugs !

Qu'est-ce qu'un bug en C ?

Nous allons partir à la chasse aux bugs, soit. Mais qu'est-ce qu'un bug, concrètement ? Il existe plusieurs définitions de ce terme. Nous proposons la suivante :

Un bug est un comportement non-désiré d'un programme.

Ainsi, dès que votre programme fait quelque chose que vous ne voulez pas, nous dirons qu'il s'agit d'un bug. C'est un concept très large, qui peut aller d'un affichage moins joli que celui que vous imaginiez à un plantage brutal du programme. L'appréciation de ce qui est un bug ou non peut parfois être subjective : si votre logiciel favori ne se comporte pas comme vous vous y attendez, vous conclurez à un bug. Mais il s'agit peut-être d'un comportement voulu par l'auteur du programme ! Il arrive que les développeurs, lorsqu'on leur signale un bug, rétorquent avec humour « ce n'est pas un bug, c'est une fonctionnalité » (“it's not a bug, it's a feature”). Ainsi, ils prétendent parfois que les plantages inexpliqués de leurs programmes sont introduits exprès. Bien entendu, il n'est pas recommandé de faire ceci. ^^

Dans ce tutoriel, nous allons nous concentrer sur les bugs rencontrés le plus couramment par les débutants en C : les comportements indéterminés.

Qu'est-ce qu'un comportement indéterminé ?

Pour l'expliquer, il faut dans un premier temps présenter la norme du langage C. La norme (pour faire court) est le document qui répond à la question : « qu'est-ce que le langage C ? » Elle explique chaque aspect du langage, depuis la liste des directives du préprocesseur jusqu'à la signification des fonctions de la bibliothèque standard. Il s'agit d'un gros document, rédigé en anglais. Elle constitue la référence ultime pour le langage C : ce qui est du C est ce qui obéit à la description donnée dans cette norme, ni plus, ni moins.

Télécharger la norme du langage C
En anglais, format PDF.

Pour être tout à fait franc, il ne s'agit pas de la norme définitive, mais d'un brouillon (draft) de celle-ci. Il est extrêmement proche de la « vraie » norme, dont le téléchargement est payant. À moins que vous ne vouliez développer un compilateur C certifié, le draft gratuit conviendra parfaitement. :)

Ainsi donc, la norme est la définition complète du langage C. Qu'en est-il de ces fameux comportements indéterminés ? Il arrive que la norme ne spécifie pas le comportement que doit avoir une certaine instruction du langage C. Voyons un exemple simple :

Citation : La norme, point 6.7.2.2

All declarations that refer to the same object or function shall have compatible type; otherwise, the behavior is undefined.

Citation : Traduction libre

Toutes les déclarations désignant un même objet ou une même fonction devront avoir des types compatibles ; autrement, le comportement est indéterminé.

Cette phrase signifie que si une fonction est déclarée plusieurs fois dans un même programme, tous les types sous lesquels elle est déclarée devront être équivalents. Il arrive par exemple que le prototype d'une même fonction apparaisse dans deux fichiers d'en-têtes différents. Cette situation est autorisée à condition que les prototypes de cette fonction soient les mêmes, ou reviennent au même.

Et sinon, que se passe-t-il ? Le compilateur renvoie une erreur ?

La norme vous donne la réponse : si cette règle n'est pas respectée, le comportement est indéterminé (the behavior is undefined). Cela signifie que la norme ne prévoit pas ce qui arrive ; chaque compilateur peut donc faire ce qu'il veut. En d'autres termes, tout, absolument tout peut arriver. Le compilateur affichera probablement un message d'erreur, mais il n'est pas toujours capable de détecter ce type de situations. Il se pourra donc que votre programme compile sans le moindre problème... Et il est impossible de prévoir ce qui arrivera lorsque la fonction aux multiples prototypes sera appelée !

Le gros danger des comportements indéterminés est le suivant : lorsque votre programme a un comportement indéterminé, il se peut qu'il fonctionne tout à fait correctement.

C'est plutôt positif, non ? Nous avons fait une erreur, mais finalement tout se passe bien !

Personne ne vous garantit que tout se passera bien dans le futur ! Après tout, qu'est-ce qui vous fait croire que la fonction printf affiche du texte sur la sortie standard ? Le fait que ce soit une fonction du langage C... Et donc qu'elle soit définie dans la norme. Si cette même norme anonce : « dans cette situation, tout peut arriver », alors il y a danger. Peut-être que votre programme plantera demain, ou qu'il plantera sur la machine de votre voisin... En d'autres termes, ce n'est pas parce qu'un code a l'air de marcher qu'il fonctionne vraiment. Les logiciels évitent parfois les plantages par pure chance.

Si votre programme a un plantage brutal, ou affiche des caractères bizarres, il a de grandes chances d'avoir un comportement indéterminé. Dans la sous-partie suivante, nous verrons quelques UB (undefined behaviors, comportement indéterminé en anglais) courants. Dans les deux dernières sous-parties, nous apprendrons à nous en prémunir, dans une certaine mesure.

Bestiaire des erreurs les plus courantes

Nous allons voir ici deux grandes catégories d'erreurs : celles concernant la mémoire, et celles concernant les fichiers. J'aborderai aussi, pour la culture, quelques petits problèmes plus rares, mais qui peuvent arriver.

Jouons avec la mémoire

La mauvaise manipulation des pointeurs 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 :

#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, locaux et globaux ¹.

¹ En vérité, les cases de tableaux sont des variables particulières. Ce troisième point est donc redondant, mais il est toujours bon à rappeler. :)

Toutes les autres adresses sont invalides ; y lire ou écrire aura un comportement indéterminé. 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...

#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]; /* équivalent à somme += *(tableau+i), pour rappel */ } 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 :

#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, qui est toujours une adresse invalide ! 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 :

#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 les constantes ne possèdent pas d'adresses ? o_O

Le piège ici est que minimum renvoie l'adresse de l'un de ses paramètres. Or, 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. 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.

Jouons avec les fichiers

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) ;

  • la fonction fflush est appelée sur le flux de fichier ;

  • pour certains flux², une fin de ligne (caractère '\n') est envoyée.

² Les flux en question sont dits line buffered ; ce sont notamment les flux console (stdin, stdout et stderr). Les autres flux sont dits block buffered, ce sont surtout les...

Appeler le centre

Avez-vous besoin d'un coach de formation?

Il vous aidera à comparer différents cours et à trouver la solution la plus abordable.

Débusquer et apprivoiser les bugs en C

Prix sur demande