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 :
-
traduction des modules sources en modules
objet
-
édition de liens entre les modules
objets, pour former le programme exécutable final
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.
-
-o nom-de-module-objet
-
-g génère
un code utilisable par un metteur-au-point
-
-c spécifie que
l'on ne veut pas générer le programme exécutable final,
mais simplement compiler les modules donnés
-
nom-des-modules à compiler,
toujours suffixés par .c
-
-I nom-de-répertoire
chemins d'accès aux répertoires contenant les fichiers inclus
-
-L nom-de-répertoire
chemins d'accès aux répertoires contenant les librairies
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 :
-
définir les prototypes des fonctions
et variables exportées avec une variable de compilation nommée
EXTERN dans le fichier include M2.h
-
définir EXTERN comme valant <espace>
dans le fichier source (M2.c) contenant le code des fonctions exportées
-
definir EXTERN comme valant extern dans
le fichier source (M1.c) utilisant ces fonctions
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 :
-
SRC : sources des modules définissant
le programme
-
INC : fichiers includes des modules
définissant le programme
-
OBJ : fichier exécutable, ainsi
que fichiers objets des modules définissant le programme.
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
-
Créez votre arborescence de développement
-
Rapatriez dans
votre répertoire MODELE les fichiers modele.c
et modele.h.
|
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 :
-
recherche d'un fichier de nom makefile (ou Makefile) dans le répertoire
courant
-
recherche de la première cible (mexec dans l'exemple)
-
pour chaque cible à traiter, Make parcourt les règles de
dépendances associées (m1.o et m2.o dans l'exemple). Lorsqu'une
dépendance n'est pas définie comme une cible, alors cette
dépendance est un fichier (m1.c par exemple). Dans ce cas, Make
examine la date de dernière modification du fichier et, si celle-ci
est plus récente que celle de la cible, alors Make exécute
la règle de production de la cible.
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 :
-
$@ : nom de la cible en train d'être compilée.
-
$? : liste des fichiers modifiés dans la ligne de dépendance
en train d'être traitée.
-
$< : nom du fichier source correspondant à la dépendance
en train d'être traitée.
-
$^ : toutes les dépendances
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 :
-
-n : les commandes prévues par make
sont affichées, mais pas exécutées
-
-i : les erreurs provoquées durant l'exécution
des commandes sont ignorées
-
-f : prend un nom de fichier particulier à
la place du fichier makefile
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 :
-
ddd <nom d'exécutable>
-
ddd <nom d'exécutable> core, pour inspecter le comportement du
programme qui a généré le fichier core.
-
ddd <nom d'exécutable> <pid>, pour débugger un
processus en cours d'exécution.
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 :
-
la fenêtre des données, qui permet de visualiser l'état
des données du programme courant,
-
la fenêtre source, qui permet de visualiser le code source du programme
courant,
-
la fenêtre console, qui permet à l'utilisateur de donner des
commandes au débuggeur.
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.