Mise au point de bibliothèque dynamique (3/3)

Publié par cpb
Fév 12 2012

(english translation here)

Nous avons commencé la mise au point d’une bibliothèque dynamique sous Linux dans les deux articles précédents : dans le premier nous avons vu comment compiler la bibliothèque et gérer les numéros de versions à l’aide de liens symboliques, dans le second nous avons effectué du suivi d’appel et du débogage pas-à-pas. Nous allons désormais nous intéresser à la vérification de la couverture de la bibliothèque.

La couverture de code est une mesure indiquant le pourcentage de lignes de code qui ont été effectivement parcourues pendant une exécution d’un programme. On élargit peu à peu le jeu de tests afin d’obtenir une couverture de 100% (et s’assurer que le code a été intégralement vérifié).

 L’outil par excellence sous Linux est gcov, qui instrumente le code source et nous fournit des statistiques détaillées après exécution. Il est facile de l’utiliser pour vérifier la couverture d’un fichier source compilé directement dans un exécutable ; nous allons l’employer pour le code d’une bibliothèque ce qui nécessite quelques suppléments d’attention.

 Compilation

Reprenons les mêmes fichiers et répertoires (regroupés dans cette archive) que pour les articles précédents. Un répertoire  principal nommé « factorielle » contient quatre sous-répertoires : src, include et lib où se trouvent respectivement les fichiers sources, les fichiers d’en-tête et les fichiers compilés de la bibliothèque. Le quatrième sous-répertoire « test » contient les fichiers source et exécutable d’un programme utilisant notre bibliothèque libfact.

[~]$ cd factorielle/
[factorielle]$ ls
include  lib  src  test
[factorielle]$ ls include/
fact.h
[factorielle]$ ls lib/
libfact.so  libfact.so.2  libfact.so.2.0
[factorielle]$ ls src/
fact.c
[factorielle]$ ls test/
calcule-factorielle.c
[factorielle]$

NB: la bibliothèque était déjà compilée ci-dessus, mais nous allons la régénérer.

La première étape consiste à compiler le code de la bibliothèque. Nous allons procéder comme dans le premier article, mais cette fois en ajoutant l’option --coverage de gcc. Cette option a deux rôles différents .

  • Lors de la phase de compilation elle a la même signification que -fprofile-arcs et -ftest-coverage (que l’on utilisaient avec les versions précédentes de gcc) : la première ajoute dans le code exécutable des informations d’instrumentation (compteurs de passage), la seconde crée une table de correspondance entre ces éléments et les lignes de code source (un fichier nommé à partir du fichier source avec l’extension .gcno)
  • Pendant la phase d’édition des liens, elle est équivalente à -lgcov (qui était ajoutée automatiquement par -ftest-coverage) qui incorpore les points d’entrée nécessaire pour l’emploi ultérieur de gcov.

Voici la compilation de notre bibliothèque.

[factorielle]$ gcc -c --coverage -fPIC -I include/ -o ./src/fact.o ./src/fact.c 
[factorielle]$ ls src/
fact.c  fact.gcno  fact.o
[factorielle]$

Nous voyons qu’avec l’option --coverage la compilation a généré, outre le fichier objet fact.o, un fichier fact.gcno contenant les relations entre les blocs de code et les numéros de lignes. Continuons.

[factorielle]$ gcc -shared -I include/ -Wl,-soname,libfact.so.2 -o lib/libfact.so.2.0 ./src/fact.o --coverage 
[factorielle]$ ls -l lib/
total 20
lrwxrwxrwx 1 cpb cpb    12 2012-02-11 13:39 libfact.so -> libfact.so.2
lrwxrwxrwx 1 cpb cpb    14 2012-02-11 13:39 libfact.so.2 -> libfact.so.2.0
-rwxrwxr-x 1 cpb cpb 16866 2012-02-11 17:10 libfact.so.2.0
[factorielle]$

Nous avons re-créé la bibliothèque libfact.so.2.0. Les liens symboliques permettent de gérer les numéros de version majeurs et mineurs, comme nous l’avons vu dans le premier article. Compilons à présent un fichier exécutable, sans l’option --coverage (ou conservons le fichier exécutable des articles précédents).

[factorielle]$ gcc -I include/ -L lib/ -o test/calcule-factorielle test/calcule-factorielle.c -lfact
[factorielle]$ ls test/
calcule-factorielle  calcule-factorielle.c
[factorielle]$

Exécution

L’exécution du programme se déroule tout à fait normalement (bien qu’en pratique il soit légèrement ralenti). Il faut, bien sûr, penser à renseigner la variable d’environnement LD_LIBRARY_PATH pour indiquer où l’éditeur de lien dynamique trouvera la bibliothèque nécessaire au fonctionnement de l’application.

[factorielle]$ export LD_LIBRARY_PATH=lib/
[factorielle]$ test/calcule-factorielle 4 5 6
4! = 24
5! = 120
6! = 720
[factorielle]$ ls src/
fact.c  fact.gcda  fact.gcno  fact.o
[factorielle]$

Un nouveau fichier fact.gcda est apparu, qui contient les statistiques d’exécution des blocs de fact.gcno (et des transitions entre blocs).

Exploitation des résultats

Pour obtenir des informations sur la couverture du code d’un fichier source, nous invoquons gcov en indiquant le nom du fichier source. Les données sont en effet valable indépendamment pour chaque fichier source de l’application (ou de la bibliothèque).

Nous allons utiliser l’option -o de gcov pour préciser le nom du répertoire où se trouvent les fichiers .c, .gcno et .gcda.

[factorielle]$ gcov -o src/ fact.c
File './src/fact.c'
Lignes exécutées: 87.50% de 8
./src/fact.c:creating 'fact.c.gcov'

[factorielle]$

gcov nous indique que nous n’avons exécuté que 87.5% des huit lignes de code de notre fonction. Comment savoir ce qui s’est passé ? Nous voyons que gcov a aussi créé un fichier fact.c.gcov dans lequel il reprend notre code source, numérote les lignes, ajoute un en-tête et une colonne de statistiques en début de ligne.

[factorielle]$ ls
fact.c.gcov  include  lib  src  test
[factorielle]$ cat fact.c.gcov 
        -:    0:Source:./src/fact.c
        -:    0:Graph:src/fact.gcno
        -:    0:Data:src/fact.gcda
        -:    0:Runs:1
        -:    0:Programs:1
        -:    1:#include
        -:    2:
        3:    3:int factorielle(long int n, long long int * result)
        -:    4:{
        3:    5:	* result = 1;
        3:    6:	if (n < 0)
    #####:    7:		return -1;
        -:    8:	do {
       12:    9:		(*result) = (*result) * n;
       12:   10:		n = n - 1;
       12:   11:	} while (n > 1);
        3:   12:	return 0;
        -:   13:}
        -:   14:
[factorielle]$

L’en-tête décrit les fichiers concernés et le nombre d’exécutions (ici une seule pour le moment). La colonne de gauche indique le nombre de passages sur chaque ligne. Les lignes contenant un tiret « - » ne correspondent à aucun code compilé. Nous voyons que les lignes 3, 5, 6 et 12 ont été parcourues à trois reprises (une invocation pour chaque argument de la ligne de commande), et les lignes 9, 10, et 11 ont exécutées 12 fois (les itérations pour calculer les factorielles).

Si nous réitérons l’opération, les compteurs se cumulent.

$ test/calcule-factorielle 3 8
3! = 6
8! = 40320
[factorielle]$ gcov -o src/ fact.c
File './src/fact.c'
Lignes exécutées: 87.50% de 8
./src/fact.c:creating 'fact.c.gcov'

[factorielle]$ test/calcule-factorielle 3 8
3! = 6
8! = 40320
[factorielle]$ cat fact.c.gcov 
        -:    0:Source:./src/fact.c
        -:    0:Graph:src/fact.gcno
        -:    0:Data:src/fact.gcda
        -:    0:Runs:2
        -:    0:Programs:1
        -:    1:#include
        -:    2:
        5:    3:int factorielle(long int n, long long int * result)
        -:    4:{
        5:    5:	* result = 1;
        5:    6:	if (n < 0)
    #####:    7:		return -1;
        -:    8:	do {
       21:    9:		(*result) = (*result) * n;
       21:   10:		n = n - 1;
       21:   11:	} while (n > 1);
        5:   12:	return 0;
        -:   13:}
        -:   14:
[factorielle]$

Et la ligne 7 ? Pourquoi ces « ##### » ?

Contrairement aux tableurs courants, ce symbole ne signifie pas que le nombre est trop grand pour tenir dans la colonne, mais que la ligne (qui correspond bien à du code compilé) n’a jamais été exécutée. Deux intérêts à cette notation :

  • attirer l’oeil lors du parcours du listing plutôt qu’un simple « 0 » ;
  • permettre une recherche automatisée des lignes non exécutées à l’aide de grep.

Voyons :

[factorielle]$ gcov -o src/ fact.c
File './src/fact.c'
Lignes exécutées: 87.50% de 8
./src/fact.c:creating 'fact.c.gcov'

[factorielle]$ grep "#####" fact.c.gcov
    #####:    7:		return -1;
[factorielle]$

Le numéro de ligne (7) étant affiché ainsi que son contenu, cela permet un aperçu rapide du code non parcouru.

Correction

Nous avons détecté un problème de test de notre bibliothèque, puisqu’une branche n’a jamais été exécutée. Ceci peut être dû à plusieurs raisons.

  • Un jeu de test incomplet. C’est le cas ici et nous allons y remédier facilement ci-dessous.
  • Du code ancien qui n’est plus jamais appelé (code mort). Il est important de le faire disparaître car il perturbe la maintenance du programme.
  • Des lignes de code qui servent à gérer des cas d’erreur rares, difficiles à tester. Nous y reviendrons dans le prochain paragraphe.

Ici la solution est simple, la ligne non testée correspond à une invocation de la fonction avec un argument négatif. C’est facile à produire.

[factorielle]$ test/calcule-factorielle -1
-1! n'existe pas
[factorielle]$ gcov -o src/ fact.c
File './src/fact.c'
Lignes exécutées: 100.00% de 8
./src/fact.c:creating 'fact.c.gcov'

[factorielle]$

A présent, gcov nous indique que toutes les lignes de notre programme ont été couvertes par notre jeu de test. Cela réduit la probabilité de bug restant.

Traitement d’erreur

Une grosse difficulté pour assurer une couverture de code à 100% lors des tests d’un logiciel est de valider les comportements en cas d’erreur système.

Prenons le cas d’un appel-système classique : malloc(). On lui demande de nous allouer une zone mémoire d’une certaine taille (précisée en octets) et il nous renvoie un pointeur. Toutes les documentations précisent qu’en cas de manque de mémoire, malloc() renvoie un pointeur NULL. (Bien qu’en pratique sous Linux ce soit particulièrement difficile à produire, nous en reparlerons dans un futur article).
Aussi, le programmeur consciencieux écrira-t-il quelque chose comme.

    char * buffer;
    buffer = malloc(TAILLE_BUFFER);
    if (buffer == NULL) {
        signaler_erreur("Manque de memoire");
        enregistrer_code_d_erreur(-ENOMEM);
        return -1;
    }
    // ...

Malheureusement les lignes de la portion entre accolades sont difficiles à tester car on ne peut pas « forcer » malloc() à échouer ; les circonstances reposent sur trop de paramètres externes à l’application pour être réellement reproductible.

Dans le cas précis de malloc(), la GlibC nous offre des points d’entrées que l’on peut utiliser pour remplacer la fonction – voir malloc_hook(3) – mais ça n’est pas souvent le cas avec les appels-système.

Il existe néanmoins plusieurs solutions. L’une d’elle, que j’ai utilisée plusieurs fois, consiste à employer une couche logicielle minimale qui reproduit les appels-système dont nous avons besoin en simulant un échec si certains critères sont remplis. Par exemple la routine suivante reproduit malloc() mais échoue au bout d’un nombre d’invocations contenu dans la variable d’environnement MALLOC_FAIL.

src/my_malloc.c:

#include <stdio.h>
#include <stdlib.h>

void * my_malloc(size_t length)
{
	char * string;
	char buffer[32];
	int count;
	string = getenv ("MALLOC_FAIL");
	if (string != NULL) {
		if (sscanf(string, "%d", & count)  == 1) {
			count --;
			if (count == 0)
				return NULL;
			snprintf(buffer, 80, "%d", count);
			setenv("MALLOC_FAIL", buffer, 1 );
		}
	}
	return malloc(length);
}

On peut la déclarer dans un fichier d’en-tête ainsi:

include/my_malloc.h:

#ifndef MY_MALLOC_H
#define MY_MALLOC_H

#ifndef NDEBUG
        extern void * my_malloc(size_t);
#else
#define my_malloc(L) malloc(L)
#endif

#endif

De cette manière, suivant la présence ou non de la constante NDEBUG, qui représente traditionnellement la compilation en version « production » pour la bibliothèque C, notre routine sera compilée comme le malloc() habituel ou avec notre gestion de la variable d’environnement.

Compilons une bibliothèque dynamique avec notre couche d’abstraction minimale.

[factorielle]$ gcc -c -fPIC -Wall -I include/ -o src/my_malloc.o src/my_malloc.c 
[factorielle]$ gcc -shared -Wl,-soname,libmytest.so.1 -o lib/libmytest.so.1.0 src/my_malloc.o
[factorielle]$ ldconfig -n lib/
[factorielle]$ ln -sf libmytest.so.1 lib/libmytest.so
[factorielle]$ ls -l lib/libmy*
lrwxrwxrwx 1 cpb cpb   14 2012-02-12 04:12 lib/libmytest.so -> libmytest.so.1
lrwxrwxrwx 1 cpb cpb   16 2012-02-12 04:11 lib/libmytest.so.1 -> libmytest.so.1.0
-rwxrwxr-x 1 cpb cpb 7014 2012-02-12 04:11 lib/libmytest.so.1.0
[factorielle]$

Créons un petit executable qui utilise notre bibliothèque de test en bouclant autour du my_malloc()

test/test-mymalloc.c:

#include <stdio.h>
#include <stdlib.h>
#include <my_malloc.h>

int main(void)
{
        int i = 1;
        while(1) {
                fprintf(stderr, "i = %2d...", i);
                if (my_malloc(10) == NULL) {
                        fprintf(stderr, "echec !n");
                        break;
                }
                fprintf(stderr, "okn");
                i ++;
        }
        return 0;
}

Compilation…

[factorielle]$ gcc -I include/ -L lib/ -Wall -o test/test-mymalloc test/test-mymalloc.c -lmytest
[factorielle]$

Et premier test, sans définir la variable d’environnement.

[factorielle]$ unset MALLOC_FAIL
[factorielle]$ ./test/test-mymalloc 
i =  1...ok
i =  2...ok
i =  3...ok
i =  4...ok
i =  5...ok
[...]
i = 21928...ok
i = 21929...ok
i = 21930...ok
i = 21931...ok
i = 21932...
  (Contrôle-C)
[factorielle]$

Bien entendu, notre programme ne s’arrête pas. Ou plutôt il s’arrêtera au bout d’un long moment après avoir épuisé ses 3Go d’espace d’adressage (sur une machine 32 bits).

Ré-essayons en forçant un échec au quatrième appel de malloc().

[factorielle]$ export MALLOC_FAIL=4
[factorielle]$ ./test/test-mymalloc
i =  1...ok
i =  2...ok
i =  3...ok
i =  4...echec !
[factorielle]$

Naturellement, ce principe consistant à faire échouer les appels aux fonctions système sous le contrôle d’une variable d’environnement – ou d’autres paramètres (variable globale, fichier, zone de mémoire partagée, etc.) – peut s’appliquer autant au contenu d’une bibliothèque qu’à celui d’un exécutable lorsqu’on a besoin d’assurer une couverture de code de 100% sur l’ensemble du jeu de tests d’une application.

Conclusion

Nous avons observé dans cette petite série d’articles, comment créer, déboguer et tester une bibliothèque dynamique. Je vous encourage à faire vos propres essais, en vous reportant à la documentation de gcc, gdb, gcov, mais également d’autres outils complémentaires comme gprof, ldconfig, valgrind, etc.

Tous les commentaires, remarques, corrections, etc. sont les bienvenus.

2 Réponses

  1. Nick dit :

    Bonjour Monsieur Blaess.

    Tout d’abord, je tiens à vous remercier pour cette suite d’articles très pédagogiques.

    Je désire créer une bibliothèque dynamique (partagée entre plusieurs programmes). Dans votre exemple dans cet article, pour créer la « libfact », le seul header inclus dans votre « fact.c » est « fact.h ».

    Dans mon cas, ma bibliothèque doit utiliser des fonctions d’autres librairies existantes Ex: « libcurl (#include ) » et/ou libgsoap (#include « soapH.h ») etc …

    Il y a-il des dispositions particulières à prendre dans ce cas? Pouvez-vous s’il vous plait, me conseiller sur la gestion de version de ma bibliothèque par rapport à l’évolution ces bibliothèques tierces inclues?

    Je vous remercie.

    • cpb dit :

      Bonjour,

      Il n’y a pas de souci pour inclure dans le code .c d’une bibliothèque les fichiers d’en-tête .h d’autres bibliothèques. Bien sûr il faudra les inclure au moment de l’édition des liens.

      Pour la gestion des dépendances, il y aura un peu de travail. Tout d’abord, bien sûr, pour surveiller les bibliothèques externes et faire les modifications nécessaires.
      Il faut s’arranger pour que l’API qu’on exporte soit la plus insensible possible face aux modifications imposées dans le code interne de la bibliothèque.

      Enfin, en ce qui concerne la gestion des numéros de version, il y a une bonne description du Semantic Versioning ici : https://semver.org/lang/fr/.

URL de trackback pour cette page