Briques de base en C (9)

Creative Commons License
Cette création est mise à disposition sous un contrat Creative Commons.

L'article original se trouve sur http://ymettier.free.fr/articles_lmag/.

Article publié dans le numéro 43 (octobre 2002) de GNU/Linux France Magazine


Table des matières

1. Introduction
2. execve
2.1. Les arguments
2.2. Utilisation
3. Les pipes et les descripteurs de fichiers standards
3.1. Les descripteurs de fichiers standards
3.2. Les pipes
3.3. Manipulation des descripteurs de fichiers
4. Lancement d'un greffon avec exec*()
4.1. Un peu de code
4.2. Les explications
5. Greffons en forme de bibliothèques partagées
5.1. Rappel sur la création d'une bibliothèque partagée
5.2. Différence entre une bibliothèque partagée et un greffon
5.3. Pointeurs sur fonctions
5.4. Le fichier d'en-tête de notre greffon
6. dlopen() et ses amies
7. Le code du chargeur
8. Conclusion
9. L'auteur
10. Références

Résumé

Quelques méthodes pour créer et utiliser des greffons. Au menu: fork (encore), les tubes et le chargement dynamique des bibliothèques partagées

1. Introduction

Dans le précédent article, j'avais annoncé la couleur: nous allons créer des greffons. Un greffon (en anglais plug'in) est un élément exécutable (directement ou indirectement via son chargeur, peu importe) qu'un programme va charger dynamiquement et exécuter afin d'exéctuer une tâche pour laquelle le greffon est spécifiquement programmé. Dans la suite de l'article, j'appellerai chargeur, ou programme chargeur, le programme qui charge et exécute le greffon.

Dans cet article, je discuterai de deux types de greffons.

  • Le premier type est un exécutable quelconque ou presque, que le chargeur lance afin d'en récupérer les résultats.

  • Le second type est une bibliothèque partagée, que le chargeur sera capable de charger dynamiquement au cours de son exécution contrairement au mode habituel où le chargeur fait confiance au chargeur dynamique ld pour faire ce travail.

2. execve

execve() est un appel système qui remplace l'image mémoire du processus en cours par un nouveau processus spécifié en argument. En d'autres termes, cela permet à votre programme d'exécuter un autre programme. Or justement, si notre greffon est un programme, cette instruction nous intéresse au plus haut point !

2.1. Les arguments

execve() s'appelle rarement directement. On préfère habituellement utilise un de ses frontaux à la place, pour plus de convivialité. Ces frontaux sont execl(), execlp(), execle(), execv(), execvp().

Les trois premiers se distinguent des deux derniers par leurs arguments : on spécifie à partir du second argument la liste (liste terninée par un élément NULL) des arguments à fournir au programme à exécuter. Les deux derniers frontaux ne prennent au contraire que deux arguments, et le second est un tableau de chaînes de caractères terminé par un élément NULL. Les trois premiers frontaux sont préférés lorsque vous voulez exécuter un programme dont le nombre d'arguments est connu à l'avance. Les deux derniers s'utilisent au contraire lorsque vous ne savez rien du nombre d'arguments ou qu'il est variable. Dans ce cas, vous allouez dynamiquement la place pour un tableau, vous remplissez ce tableau et vous le donnez en second argument au frontal d'execve(). Notez enfin que dans tous les cas, le premier élément donné comme argument n'est pas le premier argument mais le nom du programme à exécuter.

J'ai parlé des arguments des frontaux à l'exception du premier argument. Celui-ci est le chemin dans lequel se trouve le programme à exécuter pour les frontaux execl, execle, execv. C'est un fichier pour les frontaux execlp et execvp.

Le frontal execle a cette particularité que vous pouvez aussi spécifier à la suite des arguments une liste indiquant l'environnement. Référez-vous à la page de manuel pour l'utiliser si vous en avez vraiment besoin un jour.

2.2. Utilisation

Execve() s'utilise quasiment toujours en association avec fork() car elle ne revient jamais. En effet, si vous voulez exécuter un programme à l'intérieur du votre, vous devez d'abord créer un nouveau processus, dont vous allez envoyer l'exécution sur le programe que vous voulez lancer.

Techniquement parlant, vous utiliserez fork(). Dans le fils, vous pouvez lancer le programme à appeler directement via un appel à exec*(). Pensez tout de même à mettre un message d'erreur après, suivi d'un exit(). Ce message d'erreur apparaîtra dans le cas où le exec*() ne se lance pas pour une raison quelconque.

Dans le processus père, vous avez deux possibilités suivant ce que vous voulez faire. L'une est d'attendre que le programme que vous avez lancé se finisse pour reprendre l'exécution du père. Dans d'autres langages comme le Perl, ceci est simulé par l'appel system(). Dans ce cas, en C, vous n'avez qu'à faire un appel à wait4(pid,NULL,0,NULL). Cet appel est bloquant et s'occupe d'attendre la fin du fils, dont le PID est contenu dans la variable pid spécifiée en premier argument à wait4(). Cette variable contient la valeur de retour de l'appel fork() puisque fork() retourne le numéro de PID du processus fils pour le processus père.

L'autre possibilité est de ne pas attendre la fin du processus fils pour continuer l'exécution du processus père. Dans ce cas, pour le père, faites comme si de rien n'était et enchaînez les lignes de code. Cependant, tout père doit attendre la fin de son fils avec un appel à wait3(), wait4() ou waitpid(). En effet, lorsque le processus fils finit, il ne meurt pas tout à fait pour le système: il devient un processus zombie. Un appel à wait*() est le moyen pour le père de mettre effectivement fin au processus fils, et d'enlever ce dernier de la table des processus. Si cette table qui n'est pas infinie déborde, il vous devient impossible de faire de nouveaux fork() ou même de lancer de nouveaux programmes depuis le shell.

L'appel à wait*() peut se placer à deux endroits stratégiques. Le premier, le plus intuitif, est de le placer dans la boucle principale du processus père. Seulement cet appel doit être non bloquant. Il faut utiliser WNOHANG comme option. Cela donne par exemple wait4(pid,status,WNOHANG,NULL). Alors, l'appel est non bloquant et si le fils n'est pas encore mort, le père ne reste pas bloqué. Notez que le second argument est un pointeur vers un entier, et que cet entier n'est rien d'autre que le status de sortie. Vous pouvez connaître le code retour avec WEXITSTATUS(*status). Nous utiliserons cela plus tard.

L'autre endroit stratégique est à l'intérieur d'une fonction de traitement de signal. En effet, lorsqu'un fils finit son exécution, il le signale à son père via un signal SIGCHLD sans que vous n'ayez rien à programmer de spécifique. Il vous suffit de capter ce signal, et dans la fonction de traitement de ce signal, un appel à wait*() supprimera le zombie de la table des processus.

3. Les pipes et les descripteurs de fichiers standards

Avant de se lancer dans l'écriture d'un chargeur et d'un greffon, quelques notions nous seront utiles.

3.1. Les descripteurs de fichiers standards

Lorsque vous lancez votre programme, trois descripteurs de fichiers sont ouverts par défaut. Ce sont l'entrée standard, appelée habituellement STDIN, la sortie standard STDOUT et la sortie d'erreur STDERR. Faites un fprintf(stdout,"bonjour, vous!\n"); et vous devriez voir apparaître le message sur la console, comme si vous aviez fait un printf("bonjour, vous!\n");. Vous pouvez aussi lire ce que l'utilisateur tape au clavier en lisant dans stdin avec fread() de la même manière que si vous aviez lu dans un fichier.

3.2. Les pipes

Un tuyau, en anglais pipe n'est rien d'autre qu'un tuyau dont chaque ouverture est identifiée par un descripteur de fichier. L'instruction pipe() prend un argument qui est un tableau de deux entiers. Elle met dans le premier entier la valeur du descripteur de fichiers pour la lecture, et dans le second la valeur du descripteur de fichiers pour l'écriture. Ainsi, tout ce qui est écrit via le premier descripteur de fichiers est disponible en lecture via le second descripteur de fichiers.

Voici un petit exemple qui illustre l'utilisation des tubes, avec en prime un fork() et les instructions getpid() et getppid().

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <unistd.h>
 4 
 5 int
 6 main (int argc, char *argv[])
 7 {
 8   int p[2];
 9   int pid;
10   int val;
11   int i;
12 
13   if (pipe (p))
14     {
15       printf ("Erreur de creation du pipe\n");
16       exit (EXIT_FAILURE);
17     }
18 
19   switch (pid = fork ())
20     {
21     case -1:
22       printf ("Erreur de fork\n");
23       exit (EXIT_FAILURE);
24     case 0:
25       for (i = 0; i < 10; i++)
26 	{
27 	  read (p[0], &val, sizeof (int));
28 	  printf ("%d %d\n", i, val);
29 	}
30       break;
31     default:
32       for (i = 0; i < 10; i++)
33 	{
34 	  val = random () / 1000;
35 	  write (p[1], &val, sizeof (int));
36 	}
37     }
38   printf ("Fini (pid=%d; pere=%d)\n", getpid (), getppid ());
39   exit (EXIT_SUCCESS);
40 }

Quelques explications rapides. Ligne 13, nous avons l'appel à la fonction pipe() qui crée le tube. pipe() renvoie 0 si tout s'est bien passé. Puis ligne 19, nous avons le fork() sur lequel je me suis déjà étendu dans un article précédent, avec le cas ligne 21 où le fork() a échoué, le cas ligne 24 où nous exécutons le fils, et le cas ligne 31 où nous exécutons le père. Dans cet exemple, le père remplit le tube avec dix entiers aléatoires. Et le fils les lit via le tube et les affiche. La ligne 38 nous affiche le numéro de PID du processus courant (getpid()) et celui de son père (getppid()), à la fois pour notre processus père et notre processus fils. Peut-être remarquerez-vous une bizarrerie, à savoir que le père du processus fils porte le numéro 1? Dans ce cas, cela signifie que le père a fini son travail avant, qui a quitté, et le système a rattaché le processus fils, devenu orphelin, au processus 1. Dernière remarque: pour être propre, cet exemple devrait faire un appel à wait3(), wait4() ou waitpid() pour que le processus fils ne devienne pas un processus zombie. Je ne l'ai pas fait pour ne pas alourdir l'exemple.

3.3. Manipulation des descripteurs de fichiers

Les descripteurs de fichiers sont contenus dans une table. Pour le système, tout est contenu dans des tables, et les descripteurs de fichiers n'échappent pas à la règle. Les descripteurs de fichiers STDIN, STDOUT et STDERR portent les numéros définis dans unistd.h:

  • STDIN_FILENO

  • STDOUT_FILENO

  • STDERR_FILENO

On vous dira que ces constantes sont partout fixées à 0, 1 et 2 mais cela a peu d'importance: utilisez les constantes.

Dupliquons un de ces descripteurs de fichiers. dup() est faite pour cela. Elle prend un descripteur de fichier en argument et renvoie un autre descripteur de fichiers, correspondant au même fichier que le descripteur utilisé en argument. Cela est intéressant par exemple pour sauvegarder un descripteur de fichier.

Si je ferme un de ces fichiers, par exemple close(STDOUT_FILENO), il me sera impossible d'afficher quoi que ce soit à l'écran par la suite, via la sortie standard. Cette opération est donc à déconseiller sauf dans quelques cas particuliers. De plus, si j'ouvre un fichier ensuite, son descripteur de fichier sera celui de la sortie standard puisque l'entrée dans la table des descripteurs est devenue disponible! Attention aux dysfonctionnements!

Un cas particulier, justement, nous intéresse: Que se passe-t-il si je crée un tube (descipteurs de fichiers p[0] et p[1] après l'appel à pipe(p)), que je ferme l'entrée standard, et que je duplique le descripteur de fichier p[0]?

Explication.

  • Je crée le tube. J'obtiens donc deux descripteurs de fichiers p[0] en lecture (comme STDIN_FILENO, qui est 0) et p[1] en écriture. Mettons que p[0] soit égal à 3 et p[1] à 4

  • Je ferme l'entrée standard. L'entrée numéro zéro est donc disponible.

  • Je duplique p[0]. Le résultat, appelons my_stdin, va donc correspondre à l'entrée (lecture) du tube et aura le numéro 0, comme anciennement l'entrée standard.

  • Je peux optionnellement fermer p[0], ce qui ne fermerait pas le tube, mais fait que son entrée aurait pour descripteur de fichiers uniquement my_stdin (et plus p[0]), et que sa sortie reste toujours p[1].

Je ne pourrai plus lire sur la véritable entrée standard, mais cette manipulation me permet, si vous m'avez suivi, de simuler l'entrée standard. Ainsi, toutes les instructions telles que getchar() ou scanf() utilisant l'entrée stanrdard, ces instructions liront tout ce que VOUS écrirez via le descripteur de fichier p[1].

4. Lancement d'un greffon avec exec*()

4.1. Un peu de code

Nous avons tout pour que je puisse vous proposer le code suivant, qui exécute un exécutable que j'appellerai greffon, récupère tout ce qu'il écrit sur ses entrées standards et d'erreur, et qui récupère aussi son code de sortie. Voici le code:

  1 #define _USE_BSD
  2 #include <stdio.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 #include <sys/types.h>
  6 #include <sys/select.h>
  7 #include <sys/time.h>
  8 #include <sys/resource.h>
  9 #include <sys/wait.h>
 10 #include <errno.h>
 11 #include <sys/stat.h>
 12 #include <fcntl.h>
 13 
 14 int
 15 main (int argc, char *argv[])
 16 {
 17   int flux_in, flux_out, flux_err;
 18   int real_stdin, real_stdout, real_stderr;
 19   int p[2];
 20   int pid;
 21   int c;
 22   fd_set readfds;
 23   int retval;
 24   int status;
 25   int retpid;
 26   int plugin_has_quit = 0;
 27 
 28   real_stdin = dup (STDIN_FILENO);
 29   real_stdout = dup (STDOUT_FILENO);
 30   real_stderr = dup (STDERR_FILENO);
 31 
 32   printf ("OK\n");
 33 
 34   if (pipe (p))
 35     {
 36       printf ("Erreur a la creation du pipe\n");
 37       exit (EXIT_FAILURE);
 38     }
 39   flux_in = p[1];
 40   close (STDIN_FILENO);
 41   dup (p[0]);
 42   close (p[0]);
 43 
 44   if (pipe (p))
 45     {
 46       printf ("Erreur a la creation du pipe\n");
 47       exit (EXIT_FAILURE);
 48     }
 49   flux_out = p[0];
 50   close (STDOUT_FILENO);
 51   dup (p[1]);
 52   close (p[1]);
 53 
 54   if (pipe (p))
 55     {
 56       printf ("Erreur a la creation du pipe\n");
 57       exit (EXIT_FAILURE);
 58     }
 59   flux_err = p[0];
 60   close (STDERR_FILENO);
 61   dup (p[1]);
 62   close (p[1]);
 63 
 64   switch (pid = fork ())
 65     {
 66     case 0:
 67       printf ("test\n");
 68       execlp ("/tmp/plugin", "plugin", NULL);
 69       break;
 70     case -1:
 71       printf ("Erreur lors du fork()\n");
 72       exit (EXIT_FAILURE);
 73     }
 74 
 75   close (STDIN_FILENO);
 76   dup (real_stdin);
 77   close (STDOUT_FILENO);
 78   dup (real_stdout);
 79   close (STDERR_FILENO);
 80   dup (real_stderr);
 81 
 82   FD_ZERO (&readfds);
 83   FD_SET (flux_out, &readfds);
 84   FD_SET (flux_err, &readfds);
 85 
 86   retval =
 87     select ((flux_err > flux_out) ? flux_err + 1 : flux_out + 1, &readfds,
 88 	    NULL, NULL, NULL);
 89   retpid = wait4 (pid, &status, WNOHANG, NULL);
 90   while ((!plugin_has_quit) || (retpid < 0))
 91     {
 92       if (retval > 0)
 93 	{
 94 	  if (flux_out >= 0)
 95 	    {
 96 	      if (FD_ISSET (flux_out, &readfds))
 97 		{
 98 		  int r;
 99 		  if ((r = read (flux_out, &c, sizeof (char))) == -1)
100 		    printf ("retval=%d errno=%d\n", retval, errno);
101 		  else if (r == sizeof (char))
102 		    printf ("%c", c);
103 		  else if (r == 0)
104 		    flux_out = -1;
105 		}
106 	    }
107 	  if (flux_err >= 0)
108 	    {
109 	      if (FD_ISSET (flux_err, &readfds))
110 		{
111 		  int r;
112 		  if ((r = read (flux_err, &c, sizeof (char))) == -1)
113 		    printf ("retval=%d errno=%d\n", retval, errno);
114 		  else if (r == sizeof (char))
115 		    printf ("%c", c);
116 		  else if (r == 0)
117 		    flux_err = -1;
118 		}
119 	    }
120 	  if (!plugin_has_quit)
121 	    {
122 	      FD_ZERO (&readfds);
123 	      if (flux_out >= 0)
124 		FD_SET (flux_out, &readfds);
125 	      if (flux_err >= 0)
126 		FD_SET (flux_err, &readfds);
127 	      if ((flux_out == -1) && (flux_err == -1))
128 		plugin_has_quit = 1;
129 	      else
130 		retval =
131 		  select ((flux_err > flux_out) ? flux_err + 1 : flux_out + 1,
132 			  &readfds, NULL, NULL, NULL);
133 	    }
134 	  else
135 	    {
136 	      retval = 0;
137 	      sleep (1);
138 	    }
139 	  if (retpid < 0)
140 	    retpid = wait4 (pid, &status, WNOHANG, NULL);
141 	}
142     }
143   printf ("\nStatus de retour: %d\n", WEXITSTATUS (status));
144 
145   exit (EXIT_SUCCESS);
146 }

Si vous voulez un greffon de test, en voici le listing au cas où vous auriez une panne d'imagination. Appelons l'exécutable généré par ce code du nom très original plugin et plaçons-le dans le répertoire /tmp:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 int
 5 main (int argc, char *argv[])
 6 {
 7   printf ("resultat\n");
 8   fprintf (stderr, "erreur?\n");
 9   exit (4);
10 }

4.2. Les explications

Lignes 28, 29 et 30, je sauvegarde l'entrée standard, la sortie standard et la sortie d'erreur standard. En effet, il me faudra faire du bricolage par la suite, et le chargeur aura besoin de revenir à l'état de départ. Notez qu'il y a au moins deux autres manières de faire. L'une est de faire le bricolage qui suit uniquement pour le processus fils, sans toucher au père (c'est plus propre et je vous le conseille, mais ca m'empèche de vous montrer comment on fait pour sauvegarder les flux standards). Une autre est de ne rien sauvegarder du tout, et de réouvrir /dev/tty avec les options qui vont bien des que l'on en a de nouveau besoin. La restauration de ces flux a lieu lignes 75 à 80.

Lignes 34 à 42, je crée un tube, et en fermant l'entrée standard, je simule celle-ci par la suite. C'est exactement ce que j'ai décrit plus haut dans le paragraphe Manipulation des descripteurs de fichiers. Pas besoin de commentaires supplémentaires: vous avez le droit de relire le paragraphe en question - ou voici un artifice pour faire durer plus longtemps le plaisir de la lecture du magazine sans en écrire plus -

Lignes 44 à 52 et 54 à 62, le principe est le même que dans le bloc précédent, à ceci près que nous traitons la sortie standard puis la sortie d'erreur standard. De plus, ce sont des sorties et pas des entrées: il faut inverser p[0] et p[1].

Lignes 64 à 73, nous avons le fork() avec en ligne 68 le lancement du greffon via execlp(). Ceci était l'objet du début de l'article: vous en avez l'exemple ici.

A partir de la ligne 82, vous avez du code qui n'est exécuté que par le père, et qui consiste à lire tout ce que le greffon peut vouloir afficher, ainsi que la lecture du code d'erreur. Dois-je revenir sur cette partie qui est en fait un appel à la fonction select() avec les arguments qu'elle demande, puis la gestion de ce qu'elle retourne? Référez-vous à l'opus numéro 8 des briques en C qui traite justement de cette fonction, en long en large et en travers. Par contre, notez que si read() renvoie zéro, c'est que le descripteur de fichier a été fermé en face - il n'y a plus rien à lire -. Je mets donc de descripteur de fichier à -1, et lorsque les deux descripteurs de fichiers qui m'interessent sont à -1, je suis prêt à quitter la boucle principale. Prêt? Presque, car il faut encore aussi que wait4 m'ait renvoyé le status de retour du greffon, lorsque celui-ci a fini son exécution.

Enfin, je voudrais attirer votre attention sur la ligne 137 et l'appel à sleep(1). Sans cet appel, vous pouvez avoir un bug qui n'apparaît que dans les applications parallèles. Or l'exécution d'un greffon parallèlement à son chargeur n'est rien d'autre qu'une application parallèle! Quant au bug, il est que vous avez une boucle principale dans le chargeur, et que s'il n'y a plus rien a lire, la boucle va tourner à vide, avec pour seul appel celui à wait4(). Le risque est que, dans une telle boucle, le processeur soit monopolisé par la boucle et ne puisse plus exécuter quoi que ce soit d'autre. l'appel à wait4() permet de redonner la main au système un court instant. Comme cet appel est non bloquant, le chargeur risque donc de monopoliser le processeur pour ne rien faire, à attendre que le greffon finisse, mais en l'empechant de finir par cette monopolisation du processeur! L'appel à sleep() permet donc de redonner la main au système et de ne pas monopoliser le processeur. Notez cependant que dans l'exemple que je vous ai donné, ce cas a peu de chances de se matérialiser quand même. Mais souvenez-vous en pour plus tard!

5. Greffons en forme de bibliothèques partagées

Dans le précédent article, je vous avais parlé de dlopen(). Je vous ai tenu en haleine jusqu'à cet article, et pendant quelques paragraphes encore. Mais chose promise, chose due. Voici le fonctionnement de dlopen(). Deux choses encore: un, l'article précédent vous sera nécessaire si vous ne savez pas créer de bibliothèque dynamique, et deux, utiliser dlopen() et ses amis est une chose simple.

5.1. Rappel sur la création d'une bibliothèque partagée

Dans l'article précédent, j'avais utilisé le code suivant qui nous servira à nouveau, accessoirement en tant que bibliothèque partagée, et principalement en tant que greffon.

 1 #include <fstest.h>
 2 #include <sys/vfs.h>
 3 
 4 int
 5 fs_test (char *path, fstest_t * f)
 6 {
 7   struct statfs buf;
 8   if (!path)
 9     return (-1);
10   if (statfs (path, &buf) >= 0)
11     {
12       f->free = (buf.f_bsize / 1024) * buf.f_bavail / 1024;
13       f->total = (buf.f_bsize / 1024) * buf.f_blocks / 1024;
14       f->percent_free = 100 - (100 * buf.f_bavail) / buf.f_blocks;
15     }
16   else
17     {
18       return (-1);
19     }
20   return (0);
21 }

Appelez ce fichier fstest.c et compilez-le avec:

$ gcc -fPIC -c -Wall -I. fstest.c
$ gcc -shared -Wl,-soname,libfstest.so.1 -Wall fstest.o -o libfstest.so.1.0

Ceci est tout ce que je rappellerai de l'article précédent puisque seul ceci est nécessaire, le reste étant accessoire pour cet article.

5.2. Différence entre une bibliothèque partagée et un greffon

Si vous n'avez pas remarqué, votre compilateur vous l'a reproché: il manque le fichier fstest.h>. Je ne reprendrai pas le même que celui de l'article précédent. En effet, si la structure fstest_t est inchangée, la déclaration du prototype de la fonction fs_test() change. La différence entre une bibliothèque partagée généraliste et un greffon réside dans le fait que vous écrivez le prototyle des fonctions que vous appelez dans le fichier d'en-tête pour une bibliothèque partagée et que vous l'écrivez dans le programme chargeur dans le cas du greffon. Cependant, pour ne pas risquer d'avoir une API différente entre le chargeur et le greffon, nous allons quand même mettre le prototype de la fonction d'une manière différente dans le fichier d'en-tête de la bibliothèque partagée.

5.3. Pointeurs sur fonctions

Dans le programme principal, comme nous ne connaissons rien du greffon, nous allons devoir récupérer l'adresse des fonctions et mettre ces adresses dans des pointeurs. Ce sont les pointeurs sur fonction. Comment les déclare-t-on? Cette déclaration est un savant mélange entre un pointeur classique et un prototype de fonction. En fait, c'est facile: il suffit d'écrire le prototype de la fonction, avec bien sur le point-virgule à la fin. Puis vous mettez entre parenthèses le nom de la fonction, et vous le faites précéder par une étoile qui est aussi à l'intérieur des parenthèses. Vous pouvez changer le nom si vous le voulez puisque ce n'est alors plus le nom de la fonction mais le nom du pointeur sur la fonction qui se trouve entre parenthèses et précédé de l'étoile. Exemple : Voici une fonction:

int ajouter_deux_entiers (int a, int b);

Pour déclarer un pointeur sur cette fonction, pointeur que l'on appellera p_ajouter_deux_entiers, voici la ligne correspondante:

int (*p_ajouter_deux_entiers) (int a, int b);

Comme nous allons utiliser un pointeur sur fonction dans le chargeur, il nous faut recopier le prototype défini dans le fichier d'en-tête de la bibliothèque partagée pour le transformer comme nous l'avons vu. Cela est indésirable puisque cela entraîne de la redondance de code, ce qui est toujours mauvais. Que se passe-t-il si l'API du greffon change? Lors de la compilation du chargeur, le préprocesseur n'y verra rien et le greffon sera incompatible avec le chargeur sans que personne n'ait été averti.

Il nous faut utiliser typedef pour la fonction tout comme nous l'utilisons déjà pour la structure. Si vous venez de faire votre baptème du pointeur sur fonction dans les lignes précédentes, vous n'allez pas forcément avoir l'idée de la définition du typedef immédiatement. Pourtant elle est facile: il suffit de prendre la définition du pointeur sur fonction que nous avons vu avant, et de faire précéder l'ensemble par typedef. Définissons le type f_ajouter_deux_entiers à l'aide d'un typedef:

typedef int (*f_ajouter_deux_entiers) (int a, int b);

Et dans la suite du code, au lieu de mettre ceci

int (*p_ajouter_deux_entiers) (int a, int b);

vous mettre cela:

f_ajouter_deux_entiers p_ajouter_deux_entiers;

L'appel à p_ajouter_deux_entiers() se fera ensuite naturellement comme si la fonction faisait partie du chargeur. Il reste juste à faire pointer le pointeur sur fonction sur la fonction, ce que nous allons voir dans quelques paragraphes.

5.4. Le fichier d'en-tête de notre greffon

 1 #ifndef __LIBFSTEST_H__
 2 #define __LIBFSTEST_H__
 3 
 4 typedef struct
 5 {
 6   int percent_free;
 7   long free;
 8   long total;
 9 }
10 fstest_t;
11 
12 typedef int (*fs_test_f) (char *path, fstest_t * f);
13 
14 #endif

Par rapport à l'article précédent, la seule différence est la ligne 12 que j'ai transformée pour créer le type fs_test_f. Contrairement à l'article précédent où fs_test() était déclarée dans ce fichier d'en-tête inclus dans le programme principal, la fonction n'est déclarée nulle part à partir de maintenant. Comme je l'ai dit, nous la déclareront comme n'importe quelle autre variable, comme n'importe quel pointeur, avec ceci:

fs_test_f fs_test;

Vous pouvez donc maintenant vraiment compiler la bibliothèque partagée en appelant ce fichier fstest.h;

6. dlopen() et ses amies

Pour charger une bibliothèque partagée au vol, il vous suffit d'effectuer un appel à dlopen(), de chercher les fonctions et de faire pointer vos pointeurs dessus grâce à dlsym, et lorsque vous avez fini d'utiliser la bibliothèque partagée, un appel à dlclose() ferme tout.

dlopen() prend deux arguments. Le premier est le nom de la bibliothèque partagée. Si cette bibliothèque n'est pas trouvée, elle est cherchée dans les chemins par défaut, ceux spécifiés dans /etc/ld.so.conf pour GNU/Linux, et dans les chemins contenus dans la variable d'environnement $LD_LIBRARY_PATH pour tous les unices, GNU/Linux inclus. Le second argument est soit RTLD_LAZY qui dit que la résolution des symboles (inclus les noms des fonctions) se fera lorsqu'on en aura besoin, ou RTLD_NOW qui force cette résolution de manière à ce qu'elle soit faite lorsque dlopen() vous rend la main. Avec un ou binaire, vous pouvez ajouter l'option RTLD_GLOBAL qui rend les symboles externes accessibles aussi aux autres bibliothèques partagées que vous seriez susceptibles de charger ensuite. dlopen() exécute aussi le code se trouvant dans une fonction appelée _init() si elle en trouve une.

dlopen(), de la mème manière que les appels servant à ouvrir des fichiers, renvoie un identificateur. Cet identificateur nous sert de premier argument à la fonction dlsym(), alors que le second argument est le nom d'une fonction (ou tout autre symbole externe) qui nous intéresse. La valeur renvoyée par dlsym() est un pointeur sur cette fonction (ou sur le symbole externe voulu), si tout s'est bien passé.

Si quelque chose s'est mal passé, la fonction dlerror(), qui ne prend pas d'argument, renvoie une chaîne de caractères contenant le message d'erreur.

Pour décharger la bibliothèque dynamique, c'est-à-dire quand vous avez fini d'utiliser les fonctions qu'elles contient et non pas quand vous avez récupéré les pointeurs sur les fonctions, vous pouvez utiliser dlclose() en fournissant l'identificateur de dlopen() en premier et unique argument. Ici comme pour dlopen(), une fonction peut être exécutée avant le déchargement de la bibliothèque dynamique. Cette fonction doit s'appeler _fini().

Exemple avec libfstest.so:

 1 void *libfstest_handle;
 2 fs_test_f fs_test;
 3 fs_test_t result;
 4 
 5 if (!(libfstest_handle = dlopen ("libfstest.so", RTLD_LAZY)))
 6   {
 7     printf ("Erreur dlopen: %s\n", dlerror ());
 8     exit (EXIT_FAILURE);
 9   }
10 if (!(fs_test = dlsym (libfstest_handle, "fs_test")))
11   {
12     printf ("Erreur dlsym: %s\n", dlerror ());
13     dlclose (libfstest_handle);
14     exit (EXIT_FAILURE);
15   }
16 fs_test ("/", &result);
17 dlclose (libfstest_handle);
18 exit (EXIT_SUCCESS);

Je pense que les commentaires sont inutiles ici: nous avons une simple illustration de ce qu'il faut avant et après l'appel à fs_test() ligne 16.

7. Le code du chargeur

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <dlfcn.h>
 4 #include <fstest.h>
 5 
 6 void
 7 usage (char *name)
 8 {
 9   printf ("%s path\n", name);
10   exit (EXIT_FAILURE);
11 }
12 
13 int
14 main (int argc, char *argv[])
15 {
16   fstest_t buf;
17   void *fstest_handle;
18   fs_test_f fs_test;
19 
20   if (argc <= 1)
21     usage (argv[0]);
22 
23   if (!(fstest_handle = dlopen ("libfstest.so", RTLD_LAZY)))
24     {
25       printf ("An error occured: %s\n", dlerror ());
26       exit (EXIT_FAILURE);
27     }
28 
29   if (!(fs_test = dlsym (fstest_handle, "fs_test")))
30     {
31       printf ("An error occured: %s\n", dlerror ());
32       dlclose (fstest_handle);
33       exit (EXIT_FAILURE);
34     }
35 
36   if (fs_test (argv[1], &buf) >= 0)
37     {
38       printf ("%s\t%ld/%ld %d%%\n", argv[1], buf.free, buf.total,
39 	      buf.percent_free);
40     }
41   else
42     {
43       printf ("An error occured\n");
44     }
45   dlclose (fstest_handle);
46   exit (EXIT_SUCCESS);
47 }

Vous avez-là le même programme que celui qui servait d'exemple dans l'article précédent. Seulement, si vous le compilez, vous n'avez pas besoin de le lier à libfstest.so. Pour compiler ce fichier, qui s'appelle test.c, et en supposant que la bibliothèque partagée est dans /tmp/fstest (seul le fichier d'en-tête et son chemin nous intéresse), faites ceci:

$ gcc -I/tmp/fstest/include  -Wall test.c -ldl  -o test_dlopen

Vous devez obtenir un binaire nommé test_dlopen. Avant de l'exécuter, faites ldd test_dlopen. Chez moi, cela renvoie:

$ ldd test_dlopen
        libdl.so.2 => /lib/libdl.so.2 (0x4001e000)
        libc.so.6 => /lib/i686/libc.so.6 (0x40021000)
        /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)

Vous voyez donc que le binaire n'est pas lié à libfstest.so et n'a rien à voir avec.

Lancez le binaire, avec comme argument un chemin, et vous obtiendrez l'occupation de la partition concernée, comme c'était déjà le cas dans l'article précédent. Si cela ne marche pas, vérifiez que /tmp/fstest/lib est bien dans les chemins de chargement des bibliothèques partagées, c'est-à-dire dans /etc/ld.so.conf ou dans la variable d'environnement $LD_LIBRARY_PATH. Si cela marche, vous avez chargé le greffon avec succès et vous l'avez utilisé comme si le chargeur était lié au greffon alors que ce n'est pas le cas.

8. Conclusion

Nous avons vu deux manières de charger un greffon. L'une utilise fork() et l'autre dlsym. La première a le gros avantage de laisser le programmeur de greffon libre du langage à utiliser (C, C++, script, Perl...). La seconde a le gros avantage d'être rapide, puisque le chargement de bibliothèques partagés est bien plus rapide qu'un fork() suivi d'un execve(). Et il est d'autant plus rapide que le greffon est compilé, ce qui ne sera probablement pas le cas pour la première manière de lancer un greffon.

Chacune de ces deux méthodes a un intérêt, et vous êtes seul juge pour savoir quelle méthode est la plus appropriée. Mais je me permettrai d'influencer le juge en lui disant qu'il suffit d'avoir un greffon, à charger avec dlopen(), capable de faire un fork() pour qu'un chargeur conçu uniquement pour les greffons à charger via dlopen() soit capable de charger aussi les greffons autres. Ce greffon est alors lui-même un chargeur.

9. L'auteur

Yves Mettier, consultant CMG, participe activement au développement des logiciels libres avec en particulier, la coordination de projets comme GTKtalog pour classer ses disques, le tout petit cardpics pour vos jeux de cartes ou le futur jeu de tarot distribué maitretarot.

Adresse électronique: ymettier@libertysurf.fr

10. Références

  • les pages de manuel (fork, pipe, execv, execve, dlopen)

  • Les briques en C opus numéro 8 (linux magazine numéro 42)

création est mise à disposition sous un contrat Creative Commons