Portrait de Philippe PradosPrados.FRProgrammation, architecture & sécurité — depuis 1998

Debug et traces indentées en C++

Ce document propose une API objet de trace de source C++.

Par Philippe PRADOS - 1996
www.prados.fr

Pour pouvoir tracer l'exécution d'un programme, il faut utiliser des outils logiciels spécialisés. Ce document explique la librairie proposée. Celle-ci permet de sélectionner les éléments à tracer.

Chaque fichier peut être tracé individuellement. Il peut également appartenir à un groupe de fichiers (Files Package). Il est alors possible de tracer l'ensemble des fichiers appartenant à ce groupe. De plus, un groupe peut hériter d'un ou de plusieurs autres groupes de fichiers. Il est alors possible de tracer une hiérarchie complète de groupe de fichiers.

Prenons un exemple. Les classes d'un métier sont séparées dans deux fichiers sources : produit.cpp et commande.cpp. L'interface Homme-Machine (IHM) est décrite dans trois fichiers : winproduit.cpp, wincmdes.cpp et winmain.cpp. Un langage auteur de traitement par lot (batch) et décrit dans deux fichiers : lex.cpp et yacc.cpp.

L'IHM hérite du Métier. L'ensemble Batch hérite également du Métier.

Lors du déverminage du programme, le développeur désire par exemple, afficher les traces du fichier winproduit.cpp. Si les informations ne sont pas suffisantes, il peut désirer afficher l'ensemble des traces concernant l'IHM. Avec cette option, les fichiers winproduit.cpp, wincmdes.cpp et winmain.cpp seront tracés. Peut-être que cela n'est toujours pas suffisant, dans ce cas, il est possible de demander la trace du Métier et de tous les paquets de fichiers dérivés. Il est possible d'exclure certains paquets de fichiers ou un fichier particulier de la trace. Toutes ces modifications se font sans recompilation !

Pour gérer cela, chaque fichier peut déclarer appartenir à un paquet de fichiers. Cette déclaration est optionnelle. Un fichier peut n'appartenir à aucun paquet particulier.

Un fichier possède un paramètre pouvant avoir trois valeurs possibles : Unknow, Off et On. La valeur Unknow indique que la validation de la trace pour ce fichier n'est pas déclarée. C'est la valeur par défaut de tous les fichiers. La trace sera effectuée ou non suivant l'état du paquet de fichiers correspondant. Les valeurs On et Off indiquent l'état de la trace pour ce fichier. Ces valeurs sont prioritaires sur la valeur du paramètre du paquet de fichiers associé.

De même, un paquet de fichiers possède un paramètre ayant trois valeurs possibles. Unknow indique que l'état de trace de ce paquet n'est pas connu. Le paramètre global résoudra cela (voir plus bas). Les valeurs On et Off indiquent l'état de trace du paquet. Tous les fichiers de ce paquet ayant l'état Unknow sont valorisés de même.

Un paquet de fichiers peut hériter d'un ou de plusieurs autres paquets. Dans ce cas, si au moins un des paquets de base indique la valeur On¸ ce paquet est à On. Sinon, si au moins un des paquets est à Off, ce paquet est à Off. Sinon, l'état est Unknow.

Il existe un paramètre global de validation du mécanisme de trace. Ce paramètre permet d'offrir ou de supprimer l'ensemble des traces. Ce paramètre peut avoir trois valeurs possibles : Off, All et On. Off indique l'absence totale de trace. All indique que tous les fichiers n'étant pas déclarés explicitement comme Off sont à On. La valeur On pour ce paramètre indique que tout fichier explicitement déclarés a On est tracé. Les autres ne le sont pas.

Voici la table de vérité de ces trois paramètres.

GlobalPaquetFichierÉtat
Off//Off
AllUnknownUnknownOn
AllUnknownOffOff
AllUnknownOnOn
AllOffUnknownOn
AllOffOffOff
AllOffOnOn
AllOnUnknownOn
AllOnOffOff
AllOnOnOn
OnUnknownUnknownOff
OnUnknownOffOff
OnUnknownOnOn
OnOffUnknownOff
OnOffOffOff
OnOffOnOn
OnOnUnknownOn
OnOnOffOff
OnOnOnOn

Global

Paquet

Fichier

État

Off

/

/

Off

All

Unknown

Unknown

On

All

Unknown

Off

Off

All

Unknown

On

On

All

Off

Unknown

On

All

Off

Off

Off

All

Off

On

On

All

On

Unknown

On

All

On

Off

Off

All

On

On

On

On

Unknown

Unknown

Off

On

Unknown

Off

Off

On

Unknown

On

On

On

Off

Unknown

Off

On

Off

Off

Off

On

Off

On

On

On

On

Unknown

On

On

On

Off

Off

On

On

On

On

Pour simplifier, lorsque le paramètre global est à On, les paramètres sont consultés via une “chaîne de responsabilité”.

Finalement, cela est intuitif. Le comportement est en général celui que l'on imagine.

La librairie

Pour bénéficier des services de trace, il faut inclure le fichier "debug.h". Celui-ci inclut les fichiers <assert.h>, <stdlib.h>, <iostream.h>, <ibase.h> et "indent.h".

Déclaration des paquets de classes

Il faut maintenant regarder comment déclarer l'association entre un fichier et le paquet correspondant. Pour cela, il faut déclarer une variable globale de type CDbg::CDeclareFilePackage. Le constructeur de cette classe demande deux chaînes de caractères. La première indique le nom du fichier ; la deuxième le nom du paquet de classe.

static CDbg::CDeclareFilePackage dclFilePackage(__FILE__,"Metier");

Seul le nom du fichier est pris en compte. Le répertoire ou l'extension éventuelle ne sont pas mémorisés. Cela permet de considérer les fichiers en-tête de même nom, comme ayant le même comportement vis-à-vis de la trace que le fichier “ .cpp ”. Par exemple, les fichiers produit.cpp et produit.h sont vus comme les mêmes. Ils ont le même comportement vis-à-vis de la trace.

Pour déclarer un héritage entre paquets de fichiers, il faut de même instancier une variable locale du type CDeclareDerivedPackage. Le constructeur de cette classe attend deux chaînes de caractères. La première indique le paquet de classe dérivé, et la deuxième le paquet de classe dont il hérite. Il peut y avoir plusieurs héritages pour le même paquet de fichiers. Dans ce cas, il faut déclarer autant de variables globales que de paquets de bases.

static CDeclareDerivedPackage _dclDerivedPackage1("Windows","Metier");
static CDeclareDerivedPackage _dclDerivedPackage2("Windows","IHM");

Ces lignes de déclarations doivent être encadrées par :

#ifndef NDEBUG
...
#endif
afin de les supprimer lors de la compilation finale du produit. Le #define NDEBUG est compatible avec le standard ANSI C. Il permet de supprimer toutes les assertions du programme.

D'autre part, le paramétrage global des traces est présent dans la variable CDBG::global. Celle-ci est déclarée en volatile pour pouvoir être modifiée par un débogueur.

Les traces

Pour afficher des informations dans le programme, il faut utiliser une collection de macros. Celles-ci permettent de tester l'application lors de son exécution. L'ensemble des macros est supprimable à l'aide du #define NDEBUG. Ce #define est le même que pour supprimer les assert() conformément à la norme C ANSI. L'affichage de ces macros dépend du fichier où elles sont utilisées. Les règles de validation étant celles décrites ci-dessus. La variable du préprocesseur __FILE__ est utilisée pour reconnaître le fichier d'utilisation d'une macro.

Flux de debug

Il existe deux flux particuliers pour utiliser les traces.

Le flux ctrace est utilisé dans les macros suivantes.

Les assertions

Il y a trois catégories de macros. La première permet de vérifier le programme.

#define ASSERTMSG(tst,msg)
#define PRECONDITION(tst,msg)
#define POSTCONDITION(tst,msg)
#define INVARIANT(tst,msg)

Ces macros permettent d'effectuer le même traitement qu'un appel à un assert() classique mais avec la possibilité d'ajouter un texte explicatif. La macro ASSERTMSG est identique à assert() sauf qu'il faut ajouter un message. Celui-ci est du type flux C++. Il est possible d'écrire :

ASSERTMSG(pt!=NULL,"Pointeur a null dans l'objet " << *this);

Il devient intéressant d'avoir un opérateur de flux pour tous les objets. Ainsi, il est possible de tracer les objets lors d'erreurs.

{
  A a;
  ASSERTMSG(i<10,a);
}

Le message affiché lors d'une assertion invalide est le suivant

Assertion failed : tst, msg, file f, line l

Le fichier et la ligne où l'assert est écrit sont indiqués dans le message. Le traitement est ensuite interrompu par un abort(). Cela génère un core ce qui permet d'appeler le débuggeur pour connaître le contexte de l'erreur.

La macro PRECONDITION est identique à la macro ASSERTMSG sauf que le message est préfixé de "Pre-condition".

La macro POSTCONDITION est identique à la macro ASSERTMSG sauf que le message est préfixé de "Post-condition". Il est possible de supprimer de la compilation tous les appels à cette macro en déclarant #define NOPOST.
La macro INVARIANT est identique à la macro ASSERTMSG sauf que le message est préfixé de "Invariant". Il est possible de supprimer de la compilation tous les appels à cette macro en déclarant #define NOINVARIANT.

Une fois les tests unitaires faits, les Post-conditions et les Invariants peuvent être considérées comme toujours valides. Je ne recommande pas la suppression de ces tests, sauf si les performances deviennent nettement meilleures et que cela constitue un frein à l'intégration. Il est préférable de garder dans le code le plus longtemps possible les "auto-tests" car ils ont une sémantique forte, qualité qu'un simple "core" n'a pas.

Les passages

La deuxième catégorie de macro permet d'afficher des traces du passage par certains points du programme.

#define PASS(msg)
#define PASSINC(msg)
#define PASSDEC(msg)
#define ENTRY(name)
#define EXIT(name)

La macro PASS permet d'afficher un message du type

Pass in file(line), msg

La macro PASSINC est identique à la macro PASS sauf que l'indentation courante du flux ctrace est incrémentée de un.

La macro PASSDEC est identique à la macro PASS sauf que l'indentation courante du flux ctrace est décrémentée de un.

La macro ENTRY permet d'afficher un message en entrée de fonction du type

Pass in file(line), enter name

le flux ctrace incrémente l'indentation.

La macro EXIT permet d'afficher un message en sortie de fonction du type

Pass in file(line), exit name

le flux ctrace décrémente l'indentation.

Les traces

La troisième catégorie de macro permet d'afficher une trace d'une variable. Pour une écriture du type :

{
  int i=99;
  TRACE(i);
}

la trace affiche :

i=99

Tous les objets pouvant être affichés par un flux peuvent être manipulés par cette macro.

{
  A a;
  TRACE(a); // a=...
}

Exemple

Voici un exemple d'utilisation de ces macros.

void f()
{
  ENTRY("f");
  PASS("Je suis dans f");
  EXIT("f");
}

void main()
{
  ENTRY("main");
  PASSINC("Debut Etape 1");
  int i=99;
  //...
  TRACE(i);
  PASSDEC("Fin Etape 1");
  PASSINC("Debut Etape 2");
  PRECONDITION(i==99,"i doit etre a 99");
  f();
  f();
  //...
  PASSDEC("Fin Etape 2");

  EXIT("main");
}

La trace affichée à l'écran est celle-là :

Pass in tst.cc(10), enter main
  Pass in tst.cc(11), Debut Etape 1
  i==99
  Pass in tst.cc(13), Fin Etape 1
  Pass in tst.cc(14), Debut Etape 2
    Pass in tst.cc(5), enter f
      Pass in tst.cc(6), je suis dans f
    Pass in tst.cc(7), exit  f
    Pass in tst.cc(5), enter f
      Pass in tst.cc(6), je suis dans f
    Pass in tst.cc(7), exit  f
  Pass in tst.cc(16), Fin Etape 2
Pass in tst.cc(17), exit main

L'indentation permet de visualiser les appels imbriqués.

D'autres outils

Pour pouvoir vérifier lors de la compilation, de l'héritage d'une classe par une autre, il existe deux outils. La fonction checkBase(Obj1,Obj2) retourne true si la classe de Obj1 est héritée par la classe de Obj2. La fonction checkDerived(Obj1,Obj2) retourne true si les classes de Obj1 et Obj2 possèdent une relation d'héritage (checkBase(Obj1,Obj2) || checkBase(Obj2,Obj1)). Ces fonctions sont inline et ne sont compilées que par les valeurs true ou false.

Sous Unix, les fonctions isHeap() et isData() permettent de vérifier la localisation d'un pointeur. isHeap(pt) retourne true si pt représente une adresse du tas. isData(pt) retourne true si pt représente l'adresse d'une variable globale. De plus, une capture des signaux permet de générer un core pour pouvoir analyser la pile.

Environnement

Le paramètre global des traces est la variable d'environnement DEBUG. Celle-ci peut prendre les valeurs : On, Off, all, O, N, A, 1 ou 0.

Pour indiquer l'état de trace pour chacun des fichiers et des paquets de fichiers, il faut rédiger un fichier "debug.ini". Celui-ci utilise une syntaxe qui se traduit dans le format BNF comme cela :

blocs                :        files
                        packages
                        blocs
files                :        [Files] \n list-files
list-files        :
                        filename = yes-no
                        list-files
packages        :        [Packages] \n list-packages
list-packages        :
                        filename = yes-no
                        list-packages*
yes-no                :        on off
                        yes y on 1
                        non off 0

Voici un exemple de cette syntaxe :

[Packages]

Metier=On

Batch=Off

[Files]

winmain=Off

Il peut y avoir plusieurs sections [Packages] ou [Files].