1 Introduction 2 Printf
Transcription
1 Introduction 2 Printf
Stage de programmation Mise au point d’un programme Olivier Aumage LIP, bureau 302 email : [email protected] Septembre 2001 1 Introduction Le TD d’aujourd’hui a pour but de faire un rapide rappel sur les techniques usuelles de mise au point de programmes C. L’objectif principal est de limiter autant que possible les séances de corrections de programmes dans les périodes de stress intensif. La recherche d’une erreur au sein d’un programme est avant tout une recherche d’information, sur le contexte, les symptômes, les causes possibles de l’erreur, et finalement sur sa localisation et sur la manière de la corriger. La méthode traditionnelle consistant à utiliser la commande printf (et la seule disponible dans certains contextes) en divers endroits du programme est l’expression de cette recherche d’information. Les outils tels que GDB vont dans le même sens, en essayant précisémment de faciliter l’obtention d’informations sur les programmes. 2 Printf La méthode à base de printf est utilisée dans les cas où on ne peut (ou ne veut) pas utiliser de debugger. L’exemple boom.c de la figure 1 en montre le danger si elle est mal utilisée. Quel est l’affichage généré par un tel programme ? 01 02 03 04 05 06 07 08 09 10 11 12 13 14 #include <stdio.h> int main() { int *p; printf("1"); p = NULL; printf("2"); *p = 1; printf("3"); return 0; } F IG . 1 – boom.c Ce genre d’erreur peut faire perdre un temps considérable en suggérant une localisation erronée du problème. Il convient donc d’éviter autant que possible d’introduire des bugs dans le code sensé aider à les 1 supprimer ! Ici, la solution est d’ajouter un retour à la ligne \n à la fin de chaque chaîne affichée par printf, ou mieux, d’utiliser fprintf(stderr, "..."). Dans ce dernier cas, les messages sont envoyés sur la sortie standard d’erreur qui n’est pas tamponnée. Si on tient absolument à utiliser printf, sans pour autant être obligé d’ajouter un retour à la ligne après chaque message, on peut également effectuer un appel à fflush(stdout) après chaque printf pour forcer les informations à être affichées. 3 GDB Lorsqu’ils sont utilisables, les debuggers tels que GDB du projet GNU remplacent avantageusement la technique précédente dans la mesure où ils permettent non seulement d’analyser l’état du programme à un instant donné ou d’en observer l’évolution, mais également de le modifier en cours d’exécution. 3.1 Principes de base 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #include <stdio.h> #include <stdlib.h> #define N 256 int *tab = NULL; void f(int n) { char i = 0; for (i = 0; i <= n; i++); { tab[i] = 1; } } int main() { printf("Debut\n"); tab = calloc(N, sizeof(int)); f(N); free(tab); printf("Fin\n"); return 0; } F IG . 2 – boucle.c Compilation Au cours de ces quelques paragraphes, nous utiliserons le programme boucle.c (fig. 2). Il s’agit bien évidemment d’un cas d’école. Pour le compiler, utilisez la commande suivante : > gcc -o boucle boucle.c Pour le lancer, la commande est : > ./boucle Pour l’arrêter, utilisez CTRL-C. 2 L’option -Wall de GCC active un certain nombre de warnings à la compilation permettant d’identifier les erreurs et fautes de frappe les plus courantes. L’utiliser systématiquement est donc une bonne habitude. Le warning affiché par la commande ci-dessous donne par exemple une première indication de la présence de problèmes au niveau du programme boucle.c. > gcc -Wall -o boucle boucle.c Note : contrairement à ce qu’on peut penser, -Wall n’active pas tous les warnings. On peut en rajouter au gré des besoins (pour détecter des problèmes tels que des comparaisons de type signé/non signé ou des problèmes affectant la portabilité) en se référant à la documentation de GCC (info gcc, Invoking GCC/Warning options). Pour exploiter au mieux les capacités de GDB sans avoir à apprécier la convivialité de l’assembleur, il est nécessaire de compiler les programmes avec des informations de débogage. Avec le compilateur GCC, l’option permettant d’inclure ces informations est l’option -g : > gcc -Wall -g -o boucle boucle.c Ces informations permettent notamment à GDB de faire le lien entre les instructions assembleur du programme compilé et les instructions C (ou l’un des autres langages supportés par GDB). Elles permettent également de mettre en relation les adresses mémoires utilisées par le processus analysé et les structures de données, variables et fonctions définies dans le code source du programme. Pour garantir une précision maximale de ces informations, il est préférable d’éviter l’usage d’options d’optimisations (i.e. options de type -Ox avec GCC) pour compiler un programme en phase de mise au point, ces options opérant des modifications sur l’ordonnancement du code ainsi que des simplifications de ce dernier. Chargement d’un processus dans GDB Une session de débogage avec GDB est démarrée, dans le cas le plus simple, de la manière suivante : > gdb boucle Cette commande lance l’environnement de GDB et charge le fichier exécutable boucle. Si le fichier exécutable spécifié en paramètre dispose d’informations de débogage et si ses fichiers source sont disponibles, ces derniers sont également chargés. On peut également lancer uniquement GDB puis charger le programme à analyser avec la commande file L’environnement GDB est contrôlé par l’intermédiaire de commandes entrées au clavier. Les plus importantes à notre niveau sont help et quit (ou CTRL-D). Vous pouvez également essayer la commande list qui, en principe affiche le code du programme à partir de la fonction main par série de 10 lignes. Si le programme n’a pas été compilé avec l’option -g, cette commande n’est évidemment pas disponible. Exécution Pour lancer l’exécution du programme dans l’environnement, la commande la plus simple est run (ou r). Cette commande lance l’exécution du programme sans traitement particulier à partir du début jusqu‘à la fin (si elle existe). Si un signal est reçu par le processus en cours d’exécution sans être traité par celui-ci, GDB stoppe le processus. Celui-ci est alors figé dans son état à l’instant du stop. Il convient d’insister sur le fait que l’exécution est stoppée et non terminée. La commande cont permet de reprendre l’exécution d’un programme stoppé. La notion de signal UNIX proprement-dite sort du cadre de ce TD. La liste ci-dessous indique les signaux les plus courants dans notre contexte de mise au point de programmes : SIGINT (program interrupted) Résultat d’un CTRL-C ; SIGSEGV (segment violation) Accès mémoire à une adresse invalide ; SIGBUS (bus error) Accès mémoire non aligné ; SIGABRT (abort) Signal généré par la fonction abort, présentée plus loin ; SIGFPE (floating point exception) Exception au niveau du calcul flottant (ex : division par 0). Essayez la session suivante : 3 – Chargez boucle dans GDB. – Exécutez le programme avec la commande run. – Appuyez sur CTRL-C, ce qui doit avoir pour effet de stopper le programme. GDB affiche alors une information sur le signal qui a provoqué l’arrêt (ici SIGINT), sur la position du compteur ordinal du programme (adresse en hexadécimal) et si possible sur la fonction en cours d’exécution et la valeur de ses arguments. Si les informations de débogage sont disponibles, GDB indique le fichier et le numéro de la ligne du programme en cours d’exécution, et celle-ci est affichée. – Exécutez la commande list. Elle affiche une partie du fichier source contenant la ligne courante, en principe un groupe d’une dizaine de lignes centrées sur la ligne courante. Ensuite, un simple appui sur la touche Entrée permet d’obtenir la suite du code par groupe de 10 lignes (la touche Entrée exécute la dernière commande utilisée si la ligne est vide). – Essayez la commande cont pour relancer le programme, puis à nouveau CTRL-C pour le stopper. Vérifiez que le programme n’a pas progressé. – Aidez le programme à franchir la zone difficile à l’aide de la commande jump (ou j) suivie du numéro de ligne approprié (ligne 11 si vous avez scrupuleusement copié le programme boucle.c). – Le programme doit se terminer normalement. Note : il est fortement conseillé de limiter l’argument de la commande jump aux lignes de la fonction courante ! GDB permet également d’exécuter le programme pas-à-pas. Il dispose pour cela des commandes next (n) & step (s). Ces deux commandes exécutent une seule ligne de programme puis stoppent à nouveau le processus. Cependant, si la ligne à exécuter contient un appel de fonction, step stoppe le processus au niveau de la première instruction de la fonction appelée alors que next stoppe le processus au niveau de la ligne suivant l’appel de fonction. Quittez GDB, copiez boucle.c vers boucle2.c (cp boucle.c boucle2.c), puis supprimez le point-virgule en fin de ligne de 10 (après le for) dans boucle2.c. Compilez boucle2 puis chargez le dans GDB. Lancez l’exécution, interrompez le processus puis testez la commande next ou la commande step pour vérifier le comportement du programme. Pourquoi ces mêmes commandes semblent-elles ne pas fonctionner avec le programme boucle.c ? Points d’arrêt Lorsqu’on recherche une erreur, il arrive souvent qu’on ait déjà une idée de sa localisation potentielle. GDB permet donc de spécifier des points d’arrêt permettant de stopper automatiquement l’exécution du programme en un point précis du code. La commande break (b) suivie d’un nom de fonction ou d’un numéro de ligne (éventuellement associé à un fichier) insère un point d’arrêt à l’endroit spécifié. La commande clear supprime le point d’arrêt de la localisation spécifiée. Chargez boucle2 dans GDB, spécifiez un point d’arrêt sur la fonction main puis lancez l’exécution. Après un court instant, GDB stoppe le programme au niveau de l’entrée de main. Profitez de l’occasion pour vérifier la différence entre step et next. Pile et cadres Tous les processus en cours d’exécution possèdent une structure de donnée appelée pile d’exécution. Cette pile est utilisée pour stocker les données locales des fonctions du programme. Chaque appel de fonction génère l’ajout de données sur la pile, et chaque retour de fonction libère ces mêmes données. La commande backtrace (bt) indique la liste des fonctions qui ont empilé des informations sur la pile à l’instant observé, c’est-à-dire la liste des appels de fonction imbriqués à cet instant. Compilez le programme fact.c (fig. 3, page 5) puis chargez fact dans GDB. Spécifiez un point d’arrêt sur la ligne 13 (x=1) et lancez l’exécution. Lorsque le processus est stoppé, exécutez la commande backtrace. La liste affichée indique tout d’abord les appels récursifs à f et termine par main. Les fonctions sont donc listées depuis l’appel le plus imbriqué (regardez la valeur indiquée pour le paramètre n de f pour chaque cadre) vers l’appel le moins imbriqué (donc dans l’ordre inverse de l’ordre chronologique, d’où le nom backtrace). Chaque ligne constitue ce que l’on appelle un cadre de pile, ou frame en anglais. Le cadre désigne la zone de la pile associée à l’appel de fonction auquel il correspond. Les noms de fichiers et numéros de lignes 4 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #include <stdio.h> int f(int n) { int x = 0; if (n > 0) { x = n * f(n - 1); } else { x = 1; } return x; } int main() { int a = 10; int b = 0; b = f(a); printf("%d! = %d\n", a, b); return 0; } F IG . 3 – fact.c indiquent la position du compteur de programme pour chaque cadre. Par défaut, le cadre courant (ciblé par défaut par les commandes GDB) est le cadre numéroté 0. On peut passer d’un cadre à l’autre à l’aide de la commande frame suivie du numéro du cadre à sélectionner. Beaucoup de commandes de GDB font implicitement référence au cadre courant (essayez list dans le cadre 0 puis dans le cadre 11 par exemple). Le cadre courant correspond de plus au contexte dans lequel sont interprétés les noms d’objets locaux : par exemple, le nom n (argument de f) désignera des cases mémoire (et des valeurs) différentes dans les cadres 0 à 10. Dans le cadre 11 (celui de main) il sera considéré comme indéfini. Variables et expressions Affichage Il possible d’afficher les valeurs des variables du contexte courant. Les commandes print et printf de GDB permettent d’afficher le contenu de ces variables ou de calculer des expressions à partir de celles-ci. Placez un point d’arrêt sur f puis relancez l’exécution de fact avec run. Affichez le contenu de n avec print n. La commande disp est similaire à print mais provoque l’affichage du résultat de l’expression spécifiée à chaque interruption du programme. Exécutez, par exemple, disp (char)n+65 puis utilisez répétitivement cont. 5 Modification On peut également modifier le contenu des variables en cours d’exécution (par exemple pour étudier l’influence de telle ou telle valeur d’une variable sur l’apparition d’un symptôme d’erreur). La commande à utiliser est set variable VAR=EXP où VAR est le nom de la variable à modifier et EXP l’expression dont le résultat est à lui affecter. Si le nom de la variable à modifier n’entre pas en conflit avec les variables internes de GDB, on peut omettre le mot-clé variable. Rechargez fact, mettez un point d’arrêt sur f et lancez l’exécution. Lorsque le GDB reprend la main sur le point d’arrêt, modifiez la valeur de n, supprimez le point d’arrêt et utilisez la commande cont pour laisser le programme terminer son exécution. Comparez le résultat obtenu avec 10 ! pour vérifier que le changement a bien été pris en compte. Watchpoints Il est également possible d’utiliser des points d’arrêts activés par le changement de valeur d’expressions ou de zones de données. Ces points d’arrêts sont appelés points d’observation ou watchpoints et sont mis en place à l’aide de la commande watch. Les objets référencés par noms doivent impérativement être dans le contexte. Essayons cette commande pour observer la valeur de b dans main par exemple. Pour cela il faut d’abord se placer dans le contexte de main : rechargez fact, mettez un point d’arrêt classique sur main et lancez l’exécution pour arriver au contexte de main. Quand GDB reprend le contrôle, entrez la commande watch b, puis exécutez répétitivement cont. A chaque arrêt, GDB affiche l’ancienne valeur et la nouvelle valeur de b. Lorsque l’exécution sort de main, le contexte de main est détruit et le point d’observation est supprimé. Note : la commande watch détecte uniquement des accès en écriture à des variables ou zones de mémoire. GDB propose également les commandes rwatch et awatch, non détaillées ici, pour détecter les accès en lecture (uniquement ou non). La disponibilité et la fiabilité de ces deux commandes reposent entièrement sur les capacités du processeur. 3.2 Exploitation de fichiers core Lorsqu’un processus UNIX est interrompu pour cause d’erreur interne, le système génère un fichier représentant une image de l’état du processus à l’instant où l’erreur s’est produite. Ce fichier est habituellement nommé core et est écrit dans le répertoire courant du processus. Les utilisateurs ont la possibilité d’interdire la génération de ces fichiers ou d’en limiter la taille. Utilisez la commande ulimit -c avec un shell de type sh ou limit coredumpsize avec un shell csh pour vérifier la taille maximale autorisée pour vos fichiers core. Si nécessaire, changez la valeur en unlimited. Note : pour que le changement soit permanent, il faut mettre cette commande dans votre fichier .cshrc ou .bashrc (ou assimilé). Dans une phase de développement, il est très important d’autoriser la génération de fichiers core. En effet, certains types de bug génèrent des symptômes non déterministes : certaines exécutions du programme se déroulent normalement tandis que d’autres exécutions échouent, sans changement apparent des conditions initiales du programme. Or la fréquence des échecs peut être extrêmement faible, et il est donc important de pouvoir exploiter ces échecs dès qu’ils surviennent. Dans cette optique, l’image de l’état du processus au moment du crash est une source d’informations considérable. GDB permet d’exploiter le contenu d’un fichier core et d’explorer l’état du processus à l’instant de la faute avec les outils habituels. Compilez boom.c (c.f. fig. 1, page 1) et exécutez boom dans un simple shell (i.e. hors de GDB). Si tout se passe bien, boom doit terminer sur un signal SIGSEGV et générer un fichier core. Chargez boom et le core dans GDB avec : > gdb boom core Ensuite, déterminez la cause de l’erreur avec les commandes de GDB vues précédemment. 3.3 Prise de contrôle en cours d’exécution Parfois, une erreur de programmation ne se traduit pas par un arrêt brutal du programme mais par un blocage apparent, généralement causé par une boucle infinie. Les programmes boucle et boucle2.c en 6 sont des exemples. Dans une telle situation, les deux questions qui se posent sont souvent : “À quel endroit le programme boucle-t-il ?” et “Pourquoi le programme boucle-t-il ?” GDB dispose de fonctionnalités permettant de répondre plus facilement à ces questions.Vous pouvez utiliser un seul ou deux terminaux pour cette séquence. Avec un seul terminal, lancez le programme boucle en tâche de fond ./boucle &. Exemple : > ./boucle & [1] 584 Dans ce cas, le shell indique une information comme ci-dessus. Le 1 entre crochets est le numéro de job, il ne nous concerne pas ici. Le nombre 584 est le numéro attribué au processus boucle qui vient d’être lancé. On l’appelle également process id ou PID. Avec deux terminaux, lancez boucle dans un des terminaux, et déterminez son PID avec la commande ps dans l’autre terminal (utilisez man ps pour les détails d’utilisation de ps). Dans le terminal unique, ou dans le terminal ne contenant pas boucle, suivant votre situation, lancez GDB avec comme paramètre le fichier exécutable boucle et le PID du processus déterminé ci-dessus. Ex : gdb boucle 584. L’environnement GDB est lancé et le processus de numéro PID est stoppé (en supposant que le PID spécifié soit valide et que vous soyez propriétaire du processus). Après avoir arrêté le processus, GDB indique la position du compteur de programme à l’instant de l’arrêt et donne accès aux fonctionnalités habituelles (observation, altération, exécution). Utilisez par exemple à nouveau la commande jump pour aider le programme à terminer. 3.4 Auto-contrôle Pour faciliter le travail de mise-au-point de programme avec GDB, la bibliothèque standard du langage C intègre un certain nombre de fonctionnalités permettant par exemple de vérifier des assertions et de générer un fichier core en cas de problème de cohérence interne. La base de ces mécanismes est la fonction abort. Cette fonction envoie simplement un signal SIGABRT à son propre processus, ce qui a généralement pour effet de l’arrêter brutalement et de faire générer un fichier core par le système (ou de redonner le contrôle à GDB si le programme est exécuté au sein de GDB). Comme dans toutes les situations vues précédemment, l’intérêt d’un tel comportement est de permettre de mener des investigations sur les causes ayant conduit à une incohérence au niveau du programme. Rajoutez un test dans la fonction f de fact.c appelant abort (utilisez man abort pour connaître l’entête contenant la déclaration de abort) si l’argument de la fonction est négatif. Puis vérifier le bon fonctionnement de ce test en exécutant fact dans GDB ou hors de GDB. Lorsque la phase de mise au point est terminée, on peut la remplacer par la fonction exit qui termine le programme un peu moins violemment, mais ne génère pas de fichier core. Les différentes variantes de la macrocommande assert peuvent être disséminées dans le code source pour vérifier des assertions et générer un appel à abort en cas de problème. Elles sont désactivées à la compilation par la définition du symbole NDEBUG. Si ces macrocommandes peuvent largement aider dans une phase de débogage, il est très important de ne pas en abuser. Ceci pour ne pas nuire à la lisibilité du code qui reste le facteur fondamental en ce qui concerne la facilité d’exploitation d’un programme. 3.5 Outils annexes Bibliothèques spécialisées Pour réduire encore le temps de débogage, il existe des bibliothèques proposant des fonctionnalités d’analyse et de détection de problèmes plus spécifiques et pouvant travailler de concert avec GDB. Pour l’essentiel, ces bibliothèques sont ciblées sur le contrôle des allocations mémoire, l’un des points les plus sensibles de la programmation en C. Cette courte section présente l’une de ces bibliothèques appelée Electric Fence (disponible sous Linux et Solaris notamment). Il en existe beaucoup d’autres plus ou moins similaires, et même la GLIBC intègre des mécanismes de débogage des allocations mémoire. 7 Afin de disposer d’un exemple de travail, copiez boucle2.c vers boucle3.c (cp boucle2.c boucle3.c), puis remplacez ‘char i’ par ‘int i’ dans f (dans boucle3.c). E.F. vise principalement à détecter les problèmes suivant : – les débordements, par exemple l’écriture de 32 octets alors que seulement 16 ont été alloués ; – les libérations de zones mémoires non allouées (ou déjà libérées, ce qui revient au même). Ces deux types d’erreurs sont relativement courants (le premier type intervient justement dans boucle3.c). En l’absence d’outils appropriés, le premier type d’erreur écrase les structures internes de l’allocateur et le second type place ce même allocateur dans un état incohérent. Dans les deux cas, la manifestation du problème (souvent un crash) peut survenir beaucoup plus tard que l’erreur elle-meme. E.F. substitue donc à l’allocateur de mémoire habituel un allocateur plus robuste capable de résister (dans une certaine mesure) aux blagues du programmeur et de les détecter (également dans une certaine mesure). Pour tester E.F., compilez boucle3.c avec la commande suivante : > gcc -Wall -g -o boucle3 boucle3.c -lefence L’option -lefence a pour effet de linker la bibliothèque libefence.a avec les autres composants de l’exécutable final. Note : sur le compte “Solaris” de l’ÉNS, cette bibliothèque est en principe fournie par Soft-Élèves. Exécutez boucle3. Si tout se passe bien, le programme doit terminer sur un signal SIGSEGV (ou similaire). Exécutez le programme dans GDB et constatez que le signal est émis exactement au niveau de l’instruction fautive. Par curiosité, vous pouvez recompiler boucle3.c sans E.F. et constater que le programme termine correctement et que l’erreur passe inaperçue. Note : ce dernier point est toutefois un peu aléatoire. Il est également possible que le programme échoue au niveau du free, suivant les détails d’implémentation de l’allocateur. Cependant, même dans ce cas, l’information obtenue sur l’erreur potentielle est beaucoup moins précise que celle obtenue avec E.F. Expérience Pour terminer, rien ne remplace un peu d’expérience et de rigueur. Par exemple, parmi les trois erreurs majeures décorant le programme boucle.c (et que vous aurez — je l’espère — identifiées), l’une d’elle est signalée par l’option -Wall de GCC et une autre est indiquée par le système d’indentation automatique d’Emacs en mode C (et vraisemblablement la plupart des éditeurs de texte disposant de ce type de fonctionnalité). La troisième est indiquée immédiatement par E.F. En revanche, sans une utilisation appropriée des outils mis à disposition des développeurs, ces erreurs peuvent facilement passer inaperçues (et faire perdre inutilement du temps), même dans un code de seulement quelques centaines de lignes. À ces remarques, on peut ajouter ces quelques conseils, à compléter par votre propre expérience : – ne jamais appeler un programme test ; – exécuter un programme en le préfixant avec ./ ; – vérifier que le programme édité est le même que le programme exécuté ; – initialiser chaque variable dès sa déclaration ; – utiliser un code aéré, convenablement indenté et de qualité ; D’une manière générale, une bonne approche est de préférer à la concision la simplicité du code et des expressions : – écrire uniquement des fonctions courtes ; – utiliser une seule instruction par ligne ; – n’utiliser des abréviations (ex ? :) que si elles simplifient la lecture du programme ; – limiter la portée des variable au strict nécessaire ; Il est d’ailleur intéressant de noter que ces dernières recommandations facilitent non seulement la lecture du code par le programmeur, mais également l’optimisation du code par le compilateur. Par exemple, l’écriture de fonctions courtes augmente considérablement les possibilités d’expansions inline et la limitation de la portée des variables améliore très sensiblement la qualité des analyses de dépendances... C.f. cours de compilation ! 8 4 Question subsidiaire Pour ceux d’entre vous qui auront terminé ce TP prématurément, cette question vous propose d’exercer vos (nouveaux ?) talents en implémentant un petit programme chaine.c de gestion de listes simplement chaînées d’entiers : chaque maillon possède uniquement un entier et un pointeur vers le maillon suivant. Les insertions de maillons doivent préserver l’ordre croissant des entiers. Après avoir défini vos structures de données, écrivez et testez les fonctions suivantes : nil insert extract disp dup ... Permet d’obtenir une liste vide. Ajoute un entier dans la liste. Retourne le premier entier de la liste passée en paramètre après avoir supprimé le maillon correspondant de la liste ; si la liste est vide, vous pouvez faire un appel à abort par exemple. Affiche le contenu de la liste. Duplique la liste. Laissez libre cours à vos idées ! Ensuite, suivant la qualité de votre programme, utilisez les outils que nous avons vu au cours de ce TD pour le corriger ou utilisez votre programme pour explorer ces outils plus en détail (l’affichage et le parcours de structures de données complexes dans GDB par exemple)... -=-=-=- 9