Stage rentrée UNIX - Partie II
2ème année Ecole d'informatique - Filière RICM2




Contenu de la partie II
 1. Rappels liés à la programmation en langage C
 2. Comment mettre en place et utiliser un environnement de travail pour programmer en C
 3. Utilisation de l'outil Make
 4. Utilisation du débuggeur ddd

1.  Rappels liés à la programmation en C

Module

Tout fichier contenant une ou plusieurs fonctions C et/ou une ou plusieurs déclarations de types de données ou de variable est appelé module. Une programme C est constitué d'un ou plusieurs modules, dont l'un contient la définition de la fonction principale du programme appelée main. La modularité est un mécanisme qui permet de concevoir et de mettre au point des programmes par fragments.
 

Compilateur

Le compilateur est l'outil qui traduit du un programme source en code objet, c'est à dire en code compréhensible par la machine. Le code source traduit par le compilateur ne correspond pas forcément à un programme complet ; ce peut être un ou plusieurs fragments (compilation de modules contenant seulement une partie du programme).  La compilation comprend en effet deux étapes principales :

Pour exécuter une compilation C, il faut taper la commande gcc suivie des paramètres listés ci-après dont tous sont optionnels sauf le quatrième. La programmation en C impose, entre autres, de définir les prototypes des fonctions utilisées dans les différents modules pour permettre au compilateur de vérifier la cohérence entre les modules.

Un prototype est constitué des éléments suivants :
 <portée> <type> <nom fonction> (<types paramètres>)

La portée s'exprime en utilisant les mots-clés "static" (privé) et "extern" (importé).
 
Selon le cas, une fonction f définie dans un module M aura une portée globale ou locale :
static f (<paramètres>) ................................... la portée de f est locale au module M
f (<paramètres>) ............................................ la portée de f est globale

Par ailleurs, lorsqu'un module M utilise une fonction f définie dans un autre module, il est recommendé d'inclure le prototype de f comme suit dans M :
extern f (<types paramètres>)

Les mots-clés static et extern servent également pour définir la portée des variables.

Un fichier include est un fichier associé à un module source, qui donne précisément accès aux fonctions et variables définies dans ce module.  Par exemple, pour qu'un module M1.c puisse utiliser les fonctions définies dans un autre module  (soit M2.c), alors il faut placer dans le fichier M1.c la directive d'inclusion suivante : #include M2.h, ou M2.h est le fichier include associé au module M2.c.

Les librairies sont également des morceaux de programmes utilisables dans d'autres programmes. A la différence des fichiers include, les librairies sont déja compilées. On trouve en général sur toutes les machines Unix des librairies standart, donnant par exemple accès à des fonctions mathématiques, ou à des fonctions graphiques.
Dans le fichier M2.h, il est recommendé de définir les prototypes des fonctions et des variables de la manière suivante, pour n'avoir qu'un fichier include pour deux usages :


Voici un exemple :
 
m1.h :
EXTERN void fct1m1 (char*);
...
m1.c :
#include "M1.h"
/* Les déclarations faites dans M1.h sont préfixées par <espace> */
...
void fct1m1(char * msg) {...}
static int fct2m1 (int i) {...}
...
m2.c :
#include "M1.h"
/* Les déclarations faites dans M1.h sont préfixées par extern */
...
/* appel a fct1m1 */
fct1m1("toto va a la plage");
...

Pour simplifier l'utilisation de ces règles de programmation, nous vous proposons d'utiliser des modèles de modules sources et de fichiers include. Le modèle de fichier include gère la définition de la variable EXTERN, ce qui permet de limiter les erreurs :

2- Environnement de programmation

Il est important de mettre en place un environnement de développement de programme C simple et modulaire. Une arborescence possible est donnée ci-après. Le répertoire PROGRAMS sert à stocker les programmes C. Ce répertoire contient un sous-répertoire par programme. Vous pouvez également créer un répertoire de nom UTIL, pour stocker les modules contenant des fonctions d'utilité générale, comme par exemple les fonctions d'entrée/sorties.
Chaque répertoire correspondant à un programme contient trois sous-répertoires :

Chaque module composant un programme C doit en outre suivre des normes de programmation (voir l'URL :  http://sirac.inrialpes.fr/~boyer/cours/UNIX/regprog.html ), qui assurent un certain niveau de qualité quant à la lisibilité et à la maintenance du module.
 
 
 
Une arborescence possible :
                                                                                  C 
                                             /                           /                   \                              \ 
                                       PGM_X                .....                 UTIL                      MODELE 
                               /           |          \                            /           |         \                /              \ 
                            SRC     INC    OBJ                       SRC    INC   OBJ      modele.c    modele.h 

EXERCICE

  1. Créez votre arborescence de développement
  2. Rapatriez dans votre répertoire MODELE les fichiers modele.c et modele.h.

 
 
3- Utilisation de make
L'outil Make permet d'automatiser, et par là-même de rendre plus fiable et plus efficace la construction de programmes. Make évite les compilations inutiles grâce à la compilation séparée des modules. Lorsque seuls certains modules sont modifiés, alors la compilation séparée permet de ne recompiler que ces modules avant de regénérer le programme exécutable.
Make ne peut être utilisé que si les dépendances entre les fichiers cibles lui sont précisées. Par exemple, si le module cible m2 utilise les prototypes exportés dans m1.h, toute modification du fichier m1.h doit engendrer une recompilation du module m2.o. La dernière règle donnée dans l'exemple ci-après précise que la cible m2.o doit être recompilée si l'un des fichiers m2.c, m2.h, ou m1.h est modifié. La syntaxe élémentaire des cibles est la suivante :
<cible> : <dépendance1> <dépendance2> <dépendance3>
<tab> <règle de production>
Pour simplifier la lecture, les longues lignes peuvent être séparées en plusieurs lignes à l'aide du signe \. (La logique de syntaxe rapelle celle des scripts).
# compilation du programme mexec
mexec : m1.o m2.o 
         gcc -o mexec m1.o m2.o

m1.o : m1.c m1.h
        gcc -c m1.c

m2.o : m2.c m2.h m1.h
       gcc -c m2.c

L'exécution de Make, déclenchée par la commande make, se passe ainsi :

L'ordre dans lequel les dépendances sont décrites dans le fichier Makefile est important. Make commence en effet par traiter la première cible décrite, ici mexec.o, puis s'arrête. Si l'on veut construire une cible en particulier, m2.o dans notre exemple, il faut donc la placer en premier dans le fichier Makefile, ou bien la préciser en tapant la commande suivante :
make m2.o
 
Une solution couramment utilisée pour éviter de se souvenir du nom de la cible est de rajouter dans le fichier Makefile une ligne décrivant une cible fictive all qui dépend justement du programme que l'on souhaite compiler. On tape alors toujours par défaut :
make all
 
où Makefile est le même fichier que celui de l'exemple précédent, avec cette ligne en plus:
all : mexec

Les cibles fréquemment utilisées (all, clean, install)

Outre la cible all, on utilise aussi fréquemment les cibles clean et install.
La cible clean permet de " nettoyer " les répertoires en supprimant tous les fichiers inutiles. Exemple d'utilisation :
clean :
    rm *~ *.o core
Lorsque l'on tape make clean, tous les fichiers se terminant par ~ ou .o ainsi que l'éventuel fichier core (créé lors d'une erreur grave à l'exécution d'un programme) sont supprimés. Cependant, lorsque Make rencontre une erreur, il s'arrête brutalement. S'il n'existe pas de fichier core par exemple, Make s'arrêtera. Pour éviter ceci, on peut utiliser une dépendance spéciale appelée .PHONY, comme ceci :
.PHONY : clean (pour plus de détails, voir http://sirac.inrialpes.fr/~rippert/cours)
 
La cible install permet "d'installer" les exécutables au bon endroit. On peut en effet souhaiter que l'exécutable soit partagé par plusieurs utilisateurs. Il faut alors le placer dans un répertoire accessible à tous. Exemple d'utilisation :
install :
    cp mexec /users/untel/ProjetCommun

Les Macros

L'outil Make permet d'utiliser des "Macros", sortes de variables, qui peuvent, par exemple, contenir des noms de fichiers ou des chemins d'accès. (Les macros rappellent les variables que l'on utilise dans les scripts du shell, le fichier Makefile est en effet un script qui utilise des variables particulières)
 
Par exemple, tous les noms des fichiers objets peuvent être stockés dans une seule macro :
OBJETS = echange.o tri_bulle.o tri_ins.o tri_fusion.o tri_rapide.o eval_tri.o
Lorsque l'on veut utiliser le contenu d'une macro, on fait précèder son nom, entouré de parenthèses, du signe $ : $(OBJETS)
 
D'autres exemples de macros souvent utilisées sont :
CC = gcc
CFLAGS = -g -ansi
Ainsi, si l'on veut changer de compilateur (et utiliser cc par exemple), il est inutile de changer toutes les règles de compilation du fichier Makefile car il suffit de modifier la macro CC. Idem pour les options avec CFLAGS.
Voici ce que donne le fichier Makefile précédent, lorsque l'on utilise des macros :
#Makefile pour compiler le programme mexec 
 
DIR = /users/durand/PGMS
OBJETS = $(DIR)/m1.o $(DIR)/m2.o 
CC = gcc 
CFLAGS = -g -ansi 
all : mexec

mexec : $(OBJETS) 

   $(CC) $(CFLAGS) $(OBJETS) -o ${DIR}/mexec 
$(DIR)/m1.o : ${DIR}/m1.c ${DIR}/m1.h
   $(CC) $(CFLAGS) -c $(DIR)/m1.c 
$(DIR)/m2.o : $(DIR)/m2.c $(DIR)/m2.h $(DIR)/m1.h
   $(CC) $(CFLAGS) -c $(DIR)/m2.c


clean : 

-rm $(DIR)/*~ $(DIR)/*.o 
 
CC et CFLAGS sont des macros prédéfinies. Si l'on ne précise pas leur contenu comme ci-dessus, elles valent par défaut (pour connaître le contenu des macros prédéfinies, taper make -p) :
CC : compilateur C habituel (cc)
CFLAGS : -O, compilation avec optimisation
 
Il existe d'autres macros prédéfinies. Les plus utiles sont :

Les règles génériques de compilation

On peut aussi fournir des règles générales de compilation. Par exemple, puisque la syntaxe est toujours la même, on peut donner une règle pour la compilation d'un fichier objet (.o) à partir d'un fichier source (.c) :
%.c:%.o:
$(CC) $(CFLAGS) -c $<
Il suffit ensuite de définir les couples cible/dépendance, sans devoir réécrire à chaque fois la règle de compilation.
DIR = /users/durand/PGMS
OBJETS = $(DIR)/m1.o $(DIR)/m2.o 
CC = gcc 
CFLAGS = -g -ansi 
%.c:%.o: 
   $(CC) $(CFLAGS) -c $< 
all : mexec
mexec : $(OBJETS) 
   $(CC) $(CFLAGS) $(OBJETS) -o ${DIR}/mexec 
$(DIR)/m1.o : $(DIR)/m1.c $(DIR)/m1.h
 
$(DIR)/m2.o : $(DIR)/m2.c $(DIR)/m2.h $(DIR)/m1.h
clean : 
   rm $(DIR)/*~ $(DIR)/*.o 
 

Les bibliothèques de fonctions (librairies)

Dans une directive de compilation, un argument de la forme -lnom, précise à l'éditeur de lien que les fonctions non décrites doivent être recherchées dans la bibliothèque de nom libnom.a.
Par exemple, si un module utilise une fonction mathématique comme cos(), il faut préciser que la librairie libm.a doit être appelée.
La règle de compilation (lors de l'édition de lien) est alors :
gcc -g -ansi module.o -lm -o prog
 
Lorsqu'il fait appel à une bibliothèque, l'éditeur de lien n'inclut que les fonctions de cette bibliothèque qui fournissent une définition jusqu'alors manquante (comme par exemple cos() utilisée par un module, mais non définie). C'est pour cette raison que, dans la directive de compilation, les bibliothèques doivent être précisées après les fichiers objets qui nécessitent leur appel.
 
Par défaut, les bibliothèques sont recherchées dans les répertoires /usr/lib ou /usr/ccs/lib. Si l'on veut spécifier un chemin d'accès à des bibliothèques personnelles, il faut utiliser l'option -L suivie du chemin d'accès (-L doit précéder -l) :
gcc -g -ansi module.o -L ./ -lmabiblio -o prog
Avec cette directive, la bibliothèque de nom libmabiblio.a sera recherchée dans le répertoire courant, puis dans les répertoires par défaut (/usr/lib ou /usr/ccs/lib).

Chemins d'accès aux fichiers include (.h)

Lorsqu'on lance le compilateur C, un préprocesseur est d'abord appelé. Ce préprocesseur repère (entre autres) les directives #include et inclut les fichiers d'en-tête nécessaires. L'option -I permet de spécifier un chemin d'accès pour les fichiers à inclure.
cc -g -ansi module.o -I /users/untel/Projet/Entetes -o prog
Ainsi si un module contient #include "entete.h", le fichier entete.h sera d'abord recherché dans le répertoire contenant le module, puis dans les répertoires suivant l'option -I, dans l'ordre, et enfin dans /usr/include.
Les fichiers inclus grâce a une directive du style #include <entete.h> ne sont pas recherchés dans le répertoire contenant le module, mais dans les répertoires précisés par -I et dans /


Options de la commande make
L'appel à make suit la syntaxe :
make [options] [définitions de macro] [fichiers]

Les principales options prévues sont :

Exercice
Après avoir mis en place votre environnement de programmation, et rappatrié les fichiers modèles, vous allez préparer le développement d'un programme composé de plusieurs modules permettant :
  • de gérer une liste chaînée de manière générique (module list)
  • de gérer une table de hachage (module hashtab), utilisant une implémentation à base de listes chaînées
  • de tester les modules précédents avec une application de gestion d'annuaire par exemple (module annuaire), qui utilise une table de hashage pour accéder rapidement aux informations concernant une personne, étant donné son nom.
Pour l'instant, vous allez suivre les étapes suivantes :
  • préparer un fichier Makefile qui permet de compiler chaque module, et de compiler le programme annuaire. Les dépendances entre les modules seront établies au fur et à mesure de votre développement. 
  • définir ou compléter les interfaces (ou prototypes des fonctions) exportées par les modules list (list.h) et hashtab (hashtab.h). 
  • définir ou compléter le fichier hashtab.c (éventuellement avec un contenu vide pour les fonctions exportées)
  • programmer le module annuaire.
  • mettre au point votre programme en utilisant le débuggeur ddd (voir section 4). Attention, pour pouvoir utiliser un débuggeur, il faut compiler en spécifiant l'option générant de l'information pour le débuggeur (gcc -g).

Les fichiers suivants sont fournis afin de vous permettre de réaliser cette application plus rapidement :

4. Utilisation du débuggeur ddd

4.1 Lancement de ddd

Le débuggeur ddd peut être lancé avec différentes options :
Le débuggeur ddd n'est en fait qu'une interface graphique englobant un débuggeur de plus bas niveau, qui peut être  gdb, dbx, xdb, etc. Par défaut, ddd  utilise le debuggeur gdb.

Une fois lancé, trois fenêtres principales composent l'interface graphique de ddd :

4.2 Edition en ligne avec dbx

  Il faut sélectionner le menu File->Open source pour visualiser un fichier source donné. Pour pouvoir modifier un fichier source, ddd permet d'activer un éditeur au travers du menu Source->Edit Source. Pour choisir le type d'éditeur a activer, il faut sélectionner le menu  Options->Preferences. Ce menu permet de configurer diverses options du débuggeur. Toute configuration peut être sauvée en cliquant sur le menu Save Options.
Lorsqu'un fichier source est modifié à l'extérieur du débuggeur, alors pour la prise en compte des modifications par ddd est obtenue en sélectionnant Source -> Reload Source.

4.3 Contrôle de programme

Points d'arrêts
Le débuggeur ddd permet la définition de points d'arrêt dans votre programme. Pour poser un point d'arrêt au niveau d'une instruction de code source, il suffit de cliquer à gauche de la ligne correspondante avec le bouton 1 de la souris, puis de cliquer sur le bouton Break at ().
Pour positionner un point d'arrêt sur une fonction donnée, alors il faut sélectionner le nom de la fonction avec la souris avant de cliquer sur le bouton Break at ().

Il est possible de positionner des points d'arrêts temporaires. Ceux-ci sont détruits dès qu'ils sont atteints. Pour ce faire, il faut cliquer à gauche de la ligne avec le bouton 3 de la souris, puis sélectionner le choix Set Temporary Breakpoint.

Pour enlever un point d'arrêt, il faut sélectionner l'icône graphique symbolisant le point d'arrêt, et via le bouton 3 de la souris, sélectionner le choix Delete du menu affiché.

Pour éditer et manipuler l'ensemble des points d'arrêts, il faut sélectionner le menu Source ->Edit Breakpoint.

Lancement du programme
Pour lancer le programme, il faut sélectionner Program->Run. Par défaut, les entrées / sorties sont redirigées vers la fenêtre console du débuggeur.

Arrêt/ reprise du programme
L'exécution est stoppée dès qu'un point d'arrêt est atteint. Dans la fenêtre source, le point d'arrêt est montré à l'utilisateur par une icône graphique (flèche).
Pour relancer le programme, il faut cliquer sur le bouton Continue. Si l'on souhaite poursuivre l'exécution jusqu'à la fin de la fonction courante, alors il faut sélectionner le bouton Finish. Si l'on souhaite poursuivre l'exécution mais en se limitant à l'exécution de la prochaine instruction de code source, alors il faut cliquer sur le bouton Step (au lieu de Continue). Pour ne pas "entrer" dans les fonctions appelées, il faut utiliser Next au lieu de Step.
La différence entre next et step se situe en effet dans le cas où une ligne contient un appel vers une fonction. La commande step arrête le programme dans la fonction, alors que next exécute la fonction et s'arrête à la suite.

Observation de l'état des données
On peut utiliser le menu New Display (ou bien les commandes de la console) pour visualiser des structures de données plus ou moins complexes. Il faut sélectionner la donnée dans le code source avant de cliquer sur New Display, ou bien donner une expression identifiant la donnée après avoir cliqué sur New Display.

Pour déréférencer un pointeur, il faut sélectionner le nom du pointeur dans la fenêtre des données   et cliquer sur le bouton Display *().

Modification de l'état des données
Utiliser le bouton Set, ou Set Value dans le menu Data.