Le langage C

Huitième partie

par Guilhem de Wailly

Résumé

Dans cette série d'articles, nous présentons le langage C. Il ne s'agit pas de réécrire les ouvrages de référence donnés en annexe, mais de donner, au travers du C, une méthode de programmation basée sur la notion d'interface.

Au fur et à mesure de notre progression, nous décrirons les éléments indispensables du langage et conduirons le lecteur à consulter les ouvrages de références. Le but poursuivi est clairement de parvenir à construire des programmes de manière segmentée.

Dans cet article, nous décrivons la manière de construire des bibliothèques statiques puis dynamiques.

Introduction

Lors des articles précédents, nous avons vu comment construire des bibliothèques de fonctions. Nous avons notamment définit des interfaces contenues dans des fichiers d'entête.

Nous nous sommes aperçus de l'importance de cette notion de bibliothèque en programmation modulaire.

Ce mois-ci, nous allons examiner comment bâtir une vraie bibliothèque binaire statique, puis dynamique.

Les bibliothèques dynamiques sont extrêmement utilisées dans les systèmes d'exploitation modernes comme Linux ou Windows. Elles permettent de réduire la taille des exécutables, de réduire l'occupation en mémoires des binaires et de reconfigurer un binaires de manière dynamique, sans qu'il ait besoin de le recompiler.

Le noyau Linux lui-même utilise des bibliothèques dynamiques spéciales que l'on appelle modules : cela permet de charger dynamiquement dans le noyau un pilote de périphérique sans avoir besoin de recompiler le noyeau.

De cette manière, nous aurons les éléments pour construire les programmes de manière modulaire où chaque partie est reliée aux autres par son interface.

Bibliothèque

Lors de nos précédentes expériences, nous avons vu comment programmer une bibliothèque de fonction. Il s'agissait de la bibliothèque des nombres rationnels constituée d'un fichier entête ou header et de plusieurs fichiers constituant la bibliothèque.

L'entête déclare les fonctionnalités de la bibliothèque qui pourront être utilisées. C'est sa partie publique, sorte de contrat entre le réalisateur de la bibliothèque, et son utilisateur.

Le corps de la bibliothèque est la partie privée réalisant les fonctions de l'interface. Cette partie ne concerne que le réalisateur de la bibliothèque, qui est libre de changer les algorithmes, tant que ceux respectent la spécification de l'interface.

Du point de vue de la programmation, nous avons vu comment programmer une telle bibliothèque, mais nous ne savons pas encore comment réaliser un binaire pré compilé.

La réalisation de la bibliothèque binaire passe par l'utilisation du compilateur est d'autres outils.

Le compilateur va être utilisé pour produire un fichier objet à partir de chaque fichier source. Attention, ici le mot objet n'a rien à voire avec la programmation orientée objets : Il s'agit en fait d'un fichier contenant du code machine. Le code machine est directement compris par le processeur de l'ordinateur. Le code des modules objets est presque utilisable tel quel : il reste encore quelques modifications à lui apporter avant de pouvoir réellement l'exécuter.

Lorsque chaque fichier aura été compilé, on utilisera l'archiveur pour rassembler les différents fichiers objets dans la bibliothèque binaire proprement dit.

Reprenons l'exemple des nombres rationnels présenté dans les articles précédents pour construire une bibliothèque dynamique.

La bibliothèque est constituée de trois fichiers, l'entête et deux fichiers d'implémentation :

        /* rationnel.h */
        /* type de donnée */
        typedef struct rationnel {
          int num;
          int den;
        } rationnel;
        
        /* constructeur/destructeur */
        rationnel r_construit (int num,
                               int den);
        void      r_detruit   (rationnel x);
        
        /* sélecteurs */
        int       r_den       (rationnel x);
        int       r_num       (rationnel x);
        
        /* utilitaires */
        rationnel r_plus      (rationnel x,
                               rationnel y);
        void      r_affiche   (rationnel x);
      

Le premier fichier d'implémentation _rationnel.c de primitives contient le constructeur, le destructeur et les sélecteurs :

        /* _rationnel.c */
        #include <rationnel.h>
        
        /* constructeur */
        rationnel r_construit (int num, int den) {
          rationnel r;

          r.den = den;
          r.num = num;

          return (r);
        }

        /* destructeur */
        void r_detruit (rationnel x) {
          return;
        }
        
        /* sélecteurs */
        int r_den (rationnel x) {
          return (x.den);
        }
        
        int r_num (rationnel x) {
          return (x.num);
        }
      

On remarque que le destructeur est une fonction ne contenant aucune expression. En fait, l'utilisateur de la bibliothèque ne sait pas cela. Il ne voit que l'interface qui spécifie de détruire avec cette fonction les objets rationnels créés. Par la suite, nous verrons que cette fonction sera chargée de libérer la mémoire allouée dynamiquement.

Enfin, le fichier rationnel.c rassemble des fonctionnalités de plus haut niveau, comme des opérateurs arithmétiques et l'affichage :

        /* rationnel.c */
        /* additionneur */
        rationnel r_plus (rationnel x,
                          rationnel y) {
          rationnel r;
          int       n, d ;
          
          n =   r_num (x) * r_den (y)
              + r_num (y) * r_den (x);
          d = r_den (x) * r_den (y);
          r = r_construit (n, d);

          return (r);
        }
        
        /* afficheur */
        void r_affiche (rationnel x) {
          printf ("%d/%d",
          r_num (x),
          r_den (x));
        }
      

Ici, nous n'avons programmé que la fonction d'addition. Le lecteur pourra implémenter sur le même modèle les autres opérateurs arithmétiques.

Pour compiler les fichiers sources séparément, nous tapons successivement les commandes :

        $ gcc -I. -c _rationnel.c
        $ gcc -I. -c rationnel.c
      

L'option -c indique au compilateur gcc de seulement compiler les fichiers, sans provoquer l'édition des liens : dans ce cas, il produit un fichier objet dont l'extension est .o au lieu de construire un binaire exécutable.

L'option -I. indique au compilateur de rechercher les fichiers à inclure aussi dans le répertoire courant. Cette option est nécessaire à cause des lignes contenant la directive #include des fichiers C qui spécifient de rechercher le fichier d'entête rationnel.h dans les répertoires d'inclusion standard du compilateur (voir article sur le pré-processeur C).

Cette commande provoque la création de deux fichiers objet _rationnel.o et rationnel.o. Ces fichiers contiennent le code machine correspondant aux fichiers source en langage C.

Maintenant, il est possible de rassembler les fichier objet dans une archive, aussi appeler bibliothèque, avec la commande ar.

Pour cela, on tape :

        $ ar qa librat.a _rationnel.o _rationnel.o
      

Ceci crée le fichier librat.a qui est une bibliothèque rassemblant les fichiers rationnel.o et _rationnel.o. Cette bibliothèque peut maintenant être utilisée par l'éditeur des liens ou le compilateur.

Il est amusant des constater que dans les environnements Unix, on réutilise au maximum les outils existant : la commande ar était initialement utilisée pour créer des archives, éventuellement sur bande. On lui préfère maintenant la commande tar...

Considérons main.c,le programme utilisateur suivant :

        #include <rationnel.h>

        void main (void) {
          /* déclarations */
          rationnel u, v, w;
          
          /* construction */
          u = r_construit (12, 31);
          v = r_construit (7, 3);
          
          /* addition */
          w = r_plus (u, v);
          
          /* affichage */
          r_affiche (u);
          printf (" + ");
          r_affiche (v);
          printf (" = ");
          r_affiche (w);
          
          /* destruction */
          r_detruit (u);
          r_detruit (v);
          r_detruit (w);
        }
      

Ce fichier est compilé avec la commande :

        $ gcc -I. -c main.c
      

Cette commande provoque la création d'un fichier main.o, code objet correspondant aux sources C contenu dans main.c.

Pour lier le fichier main.o à la librairie binaire des nombres rationnels, nous utilisons encore gcc :

        $ gcc main.o librat.a
      

Cette commande provoque la création du binaire exécutable a.out qui est le nom par défaut. Pour spécifier un autre nom, on pourra utiliser l'option -o nom du compilateur pour créer un binaire nommé nom.

Ici, nous utilisons le compilateur gcc comme éditeur de lien. Dans Linux, l'éditeur de lien est normalement le programme ld. Ce programme nécessite des options supplémentaires pour fonctionner, et, ici, il est plus simple d'utiliser gcc qui positionne pour nous ces options.

Nous venons donc de concevoir notre première bibliothèque binaire pour les nombres rationnels. Il est maintenant possible de distribuer la bibliothèque avec le fichier entête. Attention, sans ce fichier, la bibliothèque n'est pas utilisable car il est très difficile de connaître la manière d'appeler les fonctions quelle contient.

On voit bien maintenant apparaître la notion de partie publique, le fichier d'entête, et la partie privée, la bibliothèque binaire. Seul le fichier d'entête est lisible et il spécifie l'interface de la bibliothèque. Tant que ce fichier reste inchangé, l'utilisateur pourra utiliser telles quelles les différentes versions de la bibliothèque binaire sans modifier ses programmes. D'un autre coté, le réalisateur de la bibliothèque pourra changer la manière de programmer la bibliothèque sans obliger les utilisateurs à modifier leurs programmes, tant que l'interface est respectée.

Par contre, l'utilisation des bibliothèques binaires conventionnelles oblige les utilisateurs à recompiler leur programme pour prendre en compte une nouvelle version de la bibliothèque.

Il existe un moyen d'éviter cette recompilation en permettant aux programmes utilisateur de se lier aux nouvelles bibliothèques au moment de l'exécution : ce sont les bibliothèques dynamiques, connues sous le nom de DLL (Dynamic Linked Library) sous Windows ou SO sous Unix. Attention cependant, la gestion des bibliothèques dynamiques est bien plus facile et naturelle dans Unix que dans Windows.

Bibliothèque dynamique

Dans la section précédente, nous avons créé une bibliothèque binaire librat.a, son interface rationnel.h et un programme utilisateur main.c.

Pour relier le tout dans un programme exécutable, nous avons utilisé le compilateur gcc comme éditeur de lien. Il nous a fabriqué un programme exécutable a.out. Ce programme contient tout le code binaire correspondant aux fichiers sources : maintenant qu'il est fabriqué, on ne peut plus modifier le comportement de l'exécutable. On dit que la bibliothèque librat.a est une bibliothèque binaire statique.

Les bibliothèques dynamiques ressemblent aux bibliothèques statiques, mais la liaison aux programmes utilisateurs est effectuée non pas au moment de l'édition des liens, mais au moment de l'exécution du programme : il devient alors possible de changer la bibliothèque dynamique par une version plus récente respectant la même interface ; les modifications apportées à la bibliothèque seront prises en compte dès l'exécution du programme, sans qu'il n'y ait rien à faire.

Le programme exécutable ne contient pas le code de la bibliothèque, mais seulement son nom et le nom des fonctions auxquelles il est lié. Lorsque le programme est exécuté, le système effectue alors l'édition dynamique des liens. Il en résulte que le programme est plus petit car il ne contient pas le code des bibliothèques. Le temps de chargement peut être un peu plus long, ralenti par l'édition dynamique des liens. Si le programme est lancé plusieurs fois en même temps, les bibliothèques dynamiques ne sont placées en mémoire qu'une seule fois, seules leurs données sont dupliquées. Il en résulte une consommation moindre de la mémoire. De plus, le système est capable d'enlever de la mémoire les bibliothèques dynamiques non utilisées, améliorant ainsi la gestion des ressources du système.

Par exemple, la version statique de l'environnement de programmation OpenScheme n'occupe pas loin de 6Mo dans sa version statique, et moins de 100Ko dans la version dynamique.

Pour construire la version dynamique de la bibliothèque des nombres rationnels, nous procédons tout d'abord à la création des fichiers objets comme précédemment :

        $ gcc -I. -c _rationnel.c
        $ gcc -I. -c rationnel.c
      

Mais au lieu d'utiliser l'archiveur pour créer la bibliothèque, nous utilisons gcc :

        $ gcc -o librat.so -shared _rationnel.o rationnel.o
      

C'est tout ! Notons que l'extension .so n'est pas obligatoire et que ce n'est qu'une convention pour nommer les bibliothèques dynamiques sous Unix.

Maintenant, nous construisons le fichier objet correspondant à main.c :

        $ gcc -I. -c main.c
      

Puis nous construisons le binaire exécutable :

        $ gcc main.o librat.so
      

Cette commande indique à l'éditeur des liens de lier main.o et la librairie librat.so et de fabriquer l'exécutable a.out. Mais attention, aucun code de librat.so ne va se trouver dans l'exécutable final, seuls s'y trouvent les références aux fonctions importées.

Fiers de nous, nous tapons :

        $ ./a.out 
        ./a.out : error in loading shared libraries : librat.so :
        cannot open shared object file : no such file or directory
      

Le système indique une erreur : il n'arrive pas à trouver la bibliothèque dynamiques librat.so.

Première conclusion, a.out ne peut pas être exécuté tout seul et il est lié à librat.so.

Deuxième conclusion, le système ne considère pas le répertoire courant comme pouvant contenir des bibliothèques dynamiques.

Pour connaître les bibliothèques dynamiques liées à un binaire, nous utilisons le programme ldd :

        $ ldd ./a.outlibrat.so 
	=> .not foundlibc.so.6
	=> /lib/libc.so.6 (0x234234)lib/ld-linux.so.2
	=> /lib/ld-linux.so.2 (0x353535)
      

Nous voyons que notre programme et lié à librat, notre bibliothèque, libc, la bibliothèque des fonctions standard du langage C et à ld-linux, le chargeur de bibliothèques dynamiques de Linux. Nous constatons que tous les programmes sont de toutes façons liés à au moins deux bibliothèques dynamiques.

Dans notre cas, la bibliothèque librat.so n'est pas trouvée et c'est la raison qui a provoqué l'erreur.

Pour indiquer au système que le répertoire courant héberge notre bibliothèque, il faut modifier la variable d'environnement LD_LIBRARY_PATH comme cela :

        $ export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
      

Cette commande ajoute le répertoire courant (.) à la liste des répertoires où le système peut trouver des bibliothèques dynamiques. Les répertoires sont séparés par des deux-points.

Maintenant, ldd retourne :

        $ ldd ./a.outlibrat.so 
	=> ./librat.so (0x40013000)libc.so.6
	=> /lib/libc.so.6 (0x234234)lib/ld-linux.so.2
	=> /lib/ld-linux.so.2 (0x353535)
      

Là, librat.so est trouvée dans le répertoire courant. Maintenant, notre binaire a.out fonctionne parfaitement.

L'aspect très intéressant des bibliothèques dynamiques est que l'on peut modifier la bibliothèque librat.so, sans avoir à toucher au programme a.out, dès lors que l'on respecte l'interface rationnel.h.

Le système Linux possède des répertoires où se situent les bibliothèques standard statiques et dynamiques. Ce sont les répertoires /lib, /usr/lib et /usr/X11/lib. On peut trouver d'autres répertoires suivant les systèmes. Le fichier /etc/ld.so.conf indique au démarrage quels sont ces répertoires. Bien que ce ne soit pas recommandé, on peut y placer ses propres bibliothèques. En réalité, il est préférable d'utiliser la variable d'environnement LD_LIBRARY_PATH, car de cette manière, on préserve l'intégrité du système

Il est possible dans un programme de charger volontairement une bibliothèque avec la fonction dlopen(). On peut alors effectuer soi-même l'éditions des liens et utilisant la fonction dlsym(). Le lecteur trouvera dans son système Linux de la documentation sur ce sujet, soi dans les pages de manuel, soi dans les fichiers infos.

Cette technique permet d'adapter le comportement d'un programme de manière très fine, en le construisant au moment ou on l'exécute.

L'auteur

Guilhem de Wailly (gdw at free dot fr)

Références