Écrire des logiciels GNU/Linux de qualité

Ce chapitre présente quelques techniques de base utilisées par la plupart des programmeurs GNU/Linux. En suivant grossièrement les indications que nous allons présenter, vous serez à même d’écrire des programmes qui fonctionnent correctement au sein de l’environnement GNU/Linux et correspondent à ce qu’attendent les utilisateurs au niveau de leur façon de fonctionner.

Interaction avec l'environnement d'exécution

Lorsque vous avez étudié pour la première fois le langage C ou C++, vous avez appris que la fonction spéciale main est le point d’entrée principal pour un programme. Lorsque le système d’exploitation exécute votre programme, il offre un certain nombre de fonctionnalités qui aident le programme à communiquer avec le système d’exploitation et l’utilisateur. Vous avez probablement entendu parler des deux paramètres de main, habituellement appelés argc et argv, qui reçoivent les entrées de votre programme. Vous avez appris que stdin et stdout (ou les flux cin et cout en C++) fournissent une entrée et une sortie via la console. Ces fonctionnalités sont fournies par les langages C et C++, et elles interagissent avec le système d’une certaine façon. GNU/Linux fournit en plus d’autres moyens d’interagir avec l’environnement d’exécution.

La liste d'arguments

Vous lancez un programme depuis l’invite de commandes en saisissant le nom du programme. Éventuellement, vous pouvez passer plus d’informations au programme en ajoutant un ou plusieurs mots après le nom du programme, séparés par des espaces. Ce sont des arguments de ligne de commande (vous pouvez passer un argument contenant des espaces en le plaçant entre guillemets). Plus généralement, on appelle cela la liste d’arguments du programme car ils ne viennent pas nécessairement de la ligne de commande. Dans le chapitre 3, « Processus », vous verrez un autre moyen d’invoquer un programme, avec lequel un programme peut indiquer directement la liste d’arguments d’un autre programme.

Lorsqu’un programme est invoqué depuis la ligne de commande, la liste d’arguments contient toute la ligne de commande, y compris le nom du programme et tout argument qui aurait pu lui être passé. Supposons, par exemple, que vous invoquiez la commande ls depuis une invite de commandes pour afficher le contenu du répertoire racine et les tailles de fichiers correspondantes au moyen de cette commande:

% ls -s /

La liste d’arguments que le programme ls reçoit est composée de trois éléments. Le premier est le nom du programme lui-même, saisi sur la ligne de commande, à savoir ls. Les second et troisième élément sont les deux arguments de ligne de commande, -s et /.

La fonction main de votre programme peut accéder à la liste d’arguments via ses paramètres argc et argv (si vous ne les utilisez pas, vous pouvez simplement les omettre). Le premier paramètre, argc, est un entier qui indique le nombre d’éléments dans la liste. Le second paramètre, argv, est un tableau de pointeurs sur des caractères. La taille du tableau est argc, et les éléments du tableau pointent vers les éléments de la liste d’arguments, qui sont des chaînes terminées par zéro.

Utiliser des arguments de ligne de commande consiste à examiner le contenu de argc et argv. Si vous n’êtes pas intéressé par le nom du programme, n’oubliez pas d’ignorer le premier élément.

Le listing !!argcv!! montre l’utilisation de argv et argc.

Listing (arglist.c) Utiliser argc et argv

#include <stdio.h>
 
int main (int argc, char* argv[])
{
	printf ("Le nom de ce programme est '%s'.\n", argv[0]);
	printf ("Ce programme a été invoqué avec %d arguments.\n", argc - 1);
	/* A-t-on spécifié des arguments sur la ligne de commande ? */
	if (argc > 1) {
		/* Oui, les afficher. */
		int i;
		printf ("Les arguments sont :\n");
		for (i = 1; i < argc; ++i)
			printf (" %s\n", argv[i]);
	}
	return 0;
}

Conventions de la ligne de commande GNU/Linux

Presque tous les programmes GNU/Linux obéissent à un ensemble de conventions concernant l’interprétation des arguments de la ligne de commande. Les arguments attendus par un programme sont classés en deux catégories : les options (ou drapeaux1)) et les autres arguments. Les options modifient le comportement du programme, alors que les autres arguments fournissent des entrées (par exemple, les noms des fichiers d’entrée).

Les options peuvent prendre deux formes:

  • Les options courtes sont formées d’un seul tiret et d’un caractère isolé (habituellement une lettre en majuscule ou en minuscule). Elles sont plus rapides à saisir.
  • Les options longues sont formées de deux tirets suivis d’un nom composé de lettres majuscules, minuscules et de tirets. Les options longues sont plus faciles à retenir et à lire (dans les scripts shell par exemple).

Généralement, un programme propose une version courte et une version longue pour la plupart des options qu’il prend en charge, la première pour la brièveté et la seconde pour la lisibilité. Par exemple, la plupart des programmes acceptent les options -h et –help et les traitent de façon identique. Normalement, lorsqu’un programme est invoqué depuis la ligne de commande, les options suivent immédiatement le nom du programme. Certaines options attendent un argument immédiatement à leur suite. Beaucoup de programmes, par exemple, acceptent l’option –output foo pour indiquer que les sorties du programme doivent être redirigées vers un fichier appelé foo. Après les options, il peut y avoir d’autres arguments de ligne de commande, typiquement les fichiers ou les données d’entrée.

Par exemple, la commande ls -s / affiche le contenu du répertoire racine. L’option -s modifie le comportement par défaut de ls en lui demandant d’afficher la taille (en kilooctets) de chaque entrée. L’argument / indique à ls quel répertoire lister. L’option –size est synonyme de -s, donc la commande aurait pu être invoquée sous la forme ls –size /.

Les standards de codage GNU dressent la liste des noms d’options en ligne de commande couramment utilisés. Si vous avez l’intention de proposer des options identiques, il est conseillé d’utiliser les noms préconisés dans les standards de codage. Votre programme se comportera de façon similaire aux autres et sera donc plus simple à prendre en main pour les utilisateurs. Vous pouvez consulter les grandes lignes des Standards de Codage GNU à propos des options en ligne de commandes via la commande suivante depuis une invite de commande sur la plupart des systèmes GNU/Linux:

% info "(standards)User interfaces"

Utiliser getopt_long

L’analyse des options de la ligne de commande est une corvée. Heureusement, la bibliothèque GNU C fournit une fonction que vous pouvez utiliser dans les programmes C et C++ pour vous faciliter la tâche (quoiqu’elle reste toujours quelque peu ennuyeuse). Cette fonction, getopt_long, interprète à la fois les options courtes et longues. Si vous utilisez cette fonction, incluez le fichier d’en-tête <getopt.h>.

Supposons par exemple que vous écriviez un programme acceptant les trois options du tableau !!exopt!!.

Exemple d’options pour un programme

Forme courte Forme longue Fonction
-h –help Affiche l’aide-mémoire et quitte
-o nom fichier –output nom fichier Indique le nom du fichier de sortie
-v –verbose Affiche des messages détaillés

Le programme doit par ailleurs accepter zéro ou plusieurs arguments supplémentaires, qui sont les noms de fichiers d’entrée.

Pour utiliser getopt_long, vous devez fournir deux structures de données. La première est une chaîne contenant les options courtes valables, chacune sur une lettre. Une option qui requiert un argument est suivie par deux-points. Pour notre programme, la chaîne ho:v indique que les options valides sont -h, -o et -v, la seconde devant être suivie d’un argument.

Pour indiquer les options longues disponibles, vous devez construire un tableau d’éléments struct option. Chaque élément correspond à une option longue et dispose de quatre champs. Généralement, le premier champ est le nom de l’option longue (sous forme d’une chaîne de caractères, sans les deux tirets); le second est 1 si l’option prend un argument, 0 sinon; le troisième est NULL et le quatrième est un caractère qui indique l’option courte synonyme de l’option longue. Tous les champs du dernier élément doivent être à zéro. Vous pouvez construire le tableau comme ceci:

const struct option long_options[] = {
   { "help",    0, NULL, 'h' },
   { "output",  1, NULL, 'o' },
   { "verbose", 0, NULL, 'v' },
   { NULL,      0, NULL, 0   }
};

Vous invoquez la fonction getopt_long en lui passant les arguments argc et argv de main, la chaîne de caractères décrivant les options courtes et le tableau d’éléments struct option décrivant les options longues.

  • À chaque fois que vous appelez getopt_long, il n’analyse qu’une seule option et renvoie la lettre de l’option courte pour cette option ou -1 s’il n’y a plus d’option à analyser.
  • Typiquement, vous appelez getopt_long dans une boucle, pour traiter toutes les options que l’utilisateur a spécifié et en les gérant au sein d’une structure switch.
  • Si getopt_long rencontre une option invalide (une option que vous n’avez pas indiquée comme étant une option courte ou longue valide), il affiche un message d’erreur et renvoie le caractère ? (un point d’interrogation). La plupart des programmes s’interrompent dans ce cas, éventuellement après avoir affiché des informations sur l’utilisation de la commande.
  • Lorsque le traitement d’une option requiert un argument, la variable globale optarg pointe vers le texte constituant cet argument.
  • Une fois que getopt_long a fini d’analyser toutes les options, la variable globale optind contient l’index (dans argv) du premier argument qui n’est pas une option.

Le listing !!getoptlong!! montre un exemple d’utilisation de getopt_long pour le traitement des arguments.

Listing (getopt_long.c) Utilisation de getopt_long

#include <getopt.h>
#include <stdio.h>
#include <stdlib.h>
/* Nom du programme. */
const char* program_name;
/* Envoie les informations sur l'utilisation de la commande vers STREAM 
   (typiquement stdout ou stderr) et quitte le programme avec EXIT_CODE.
   Ne retourne jamais. */
void print_usage (FILE* stream, int exit_code)
{
  fprintf (stream, "Utilisation : %s options [fichierentrée ...]\n", program_name);
  fprintf (stream,
           " -h --help               Affiche ce message.\n"
           " -o --output filename    Redirige la sortie vers un fichier.\n"
           " -v --verbose            Affiche des messages détaillés.\n");
  exit (exit_code);
}
/* Point d'entrée du programme. ARGC contient le nombre d'éléments de la liste 
   d'arguments ; ARGV est un tableau de pointeurs vers ceux-ci. */
int main (int argc, char* argv[])
{
  int next_option;
  /* Chaîne listant les lettres valides pour les options courtes. */
  const char* const short_options = "ho:v";
  /* Tableau décrivant les options longues valides. */
  const struct option long_options[] = {
    { "help",     0, NULL, 'h' },
    { "output",   1, NULL, 'o' },
    { "verbose", 0, NULL, 'v' },
    { NULL,       0, NULL, 0   }   /* Requis à la fin du tableau.  */
};
/* Nom du fichier vers lequel rediriger les sorties, ou NULL pour
    la sortie standard. */
const char* output_filename = NULL;
/* Indique si l'on doit afficher les messages détaillés. */
int verbose = 0;
/* Mémorise le nom du programme, afin de l'intégrer aux messages.
    Le nom est contenu dans argv[0]. */
program_name = argv[0];
do {
   next_option = getopt_long (argc, argv, short_options,
                              long_options, NULL);
   switch (next_option)
   {
   case 'h':   /* -h or --help */
     /* L'utilisateur a demandé l'aide-mémoire. L'affiche sur la sortie
        standard et quitte avec le code de sortie 0 (fin normale). */
     print_usage (stdout, 0);
   case 'o':   /* -o ou --output */
     /* Cette option prend un argument, le nom du fichier de sortie.  */
     output_filename = optarg;
     break;
   case 'v':   /* -v ou --verbose */
     verbose = 1;
     break;
   case '?':   /* L'utilisateur a saisi une option invalide. */
     /* Affiche l'aide-mémoire sur le flux d'erreur et sort avec le code
        de sortie un (indiquant une fin anormale). */
     print_usage (stderr, 1);
   case -1:    /* Fin des options.  */
     break;
   default:    /* Quelque chose d'autre : inattendu.  */
     abort ();
   }
}
while (next_option != -1);
/* Fin des options. OPTIND pointe vers le premier argument qui n'est pas une 
    option. À des fins de démonstration, nous les affichons si l'option
    verbose est spécifiée. */
  if (verbose) {
    int i;
    for (i = optind; i < argc; ++i)
      printf ("Argument : %s\n", argv[i]);
  }
  /* Le programme principal se place ici. */
  return 0;
}

L’utilisation de getopt_long peut sembler nécessiter beaucoup de travail, mais écrire le code nécessaire à l’analyse des options de la ligne de commandes vous-même vous prendrait encore plus longtemps. La fonction getopt_long est très sophistiquée et permet une grande flexibilité dans la spécification des types d’options acceptées. Cependant, il est bon de se tenir à l’écart des fonctionnalités les plus avancées et de conserver la structure d’options basiques décrite ici.

E/S standards

La bibliothèque standard du C fournit des flux d’entrée et de sortie standards (stdin et stdout respectivement). Il sont utilisés par printf, scanf et d’autres fonctions de la bibliothèque. Dans la tradition UNIX, l’utilisation de l’entrée et de la sortie standard est fréquente pour les programmes GNU/Linux. Cela permet l’enchaînement de plusieurs programmes au moyen des pipes2) et de la redirection des entrées et sorties (consultez la page de manuel de votre shell pour savoir comment les utiliser).

La bibliothèque C fournit également stderr, le flux d’erreurs standard. Il est d’usage que les programmes envoient les messages d’erreur et d’avertissement vers la sortie des erreurs standard plutôt que vers la sortie standard. Cela permet aux utilisateurs de séparer les messages normaux des messages d’erreur, par exemple, en redirigeant la sortie standard vers un fichier tout en laissant les erreurs s’afficher sur la console. La fonction fprintf peut être utilisée pour écrire sur stderr, par exemple:

% fprintf (stderr, "Erreur : ...");

Ces trois flux sont également accessibles via les commandes d’E/S UNIX de bas niveau (read, write, etc), par le biais des descripteurs de fichiers. Les descripteurs de fichiers sont 0 pour stdin, 1 pour stdout et 2 pour stderr.

Lors de l’appel d’un programme, il peut être utile de rediriger à la fois la sortie standard et la sortie des erreurs vers un fichier ou un pipe. La syntaxe à utiliser diffère selon les shells; la voici pour les shells de type Bourne (y compris bash, le shell par défaut sur la plupart des distributions GNU/Linux):

% programme > fichier_sortie.txt 2>&1
% programme 2>&1 | filtre

La syntaxe 2>&1 indique que le descripteur de fichiers 2 (stderr) doit être fusionné avec le descripteur de fichiers 1 (stdout). Notez que 2>&1 doit être placé après une redirection vers un fichier (premier exemple) mais avant une redirection vers un pipe (second exemple).

Notez que stdout est bufferisée. Les données écrites sur stdout ne sont pas envoyées vers la console (ou un autre dispositif si l’on utilise la redirection) avant que le tampon ne soit plein, que le programme ne se termine normalement ou que stdout soit fermé. Vous pouvez purger explicitement le tampon de la façon suivante:

fflush (stdout);

Par contre, stderr n’est pas bufferisée; les données écrites sur stderr sont envoyées directement vers la console3).

Cela peut conduire à des résultats quelque peu surprenants. Par exemple, cette boucle n’affiche pas un point toutes les secondes; au lieu de cela, les points sont placés dans le tampon, et ils sont affichés par groupe lorsque le tampon est plein.

while (1) {
  printf (".");
  sleep (1);
}

Avec cette boucle, par contre, les points sont affichés au rythme d’un par seconde:

while (1) {
  fprintf (stderr, ".");
  sleep (1);
}

Codes de sortie de programme

Lorsqu’un programme se termine, il indique son état au moyen d’un code de sortie. Le code de sortie est un entier court ; par convention, un code de sortie à zéro indique une fin normale, tandis qu’un code différent de zéro signale qu’une erreur est survenue. Certains programmes utilisent des codes de sortie différents de zéro variés pour distinguer les différentes erreurs.

Avec la plupart des shells, il est possible d’obtenir le code de sortie du dernier programme exécuté en utilisant la variable spéciale $?. Voici un exemple dans lequel la commande ls est invoquée deux fois et son code de sortie est affiché après chaque invocation. Dans le premier cas, ls se termine correctement et renvoie le code de sortie 0. Dans le second cas, ls rencontre une erreur (car le fichier spécifié sur la ligne de commande n’existe pas) et renvoie donc un code de sortie différent de 0.

% ls /
bin   coda etc    lib         misc nfs proc
boot dev    home lost+found mnt     opt root
% echo $?
0
% ls fichierinexistant
ls: fichierinexistant: Aucun fichier ou répertoire de ce type
% echo $?
1

Un programme C ou C++ donne son code de sortie en le retournant depuis la fonction main. Il y a d’autres méthodes pour fournir un code de sortie et des codes de sortie spéciaux sont assignés aux programmes qui se terminent de façon anormale (sur un signal). Ils sont traités de manière plus approfondie dans le chapitre 3.

L'environnement

GNU/Linux fournit à tout programme s’exécutant un environnement. L’environnement est une collection de paires variable/valeur. Les variables d’environnement et leurs valeurs sont des chaînes de caractères. Par convention, les variables d’environnement sont en majuscules d’imprimerie.

Vous êtes probablement déjà familier avec quelques variables d’environnement courantes. Par exemple :

  • USER contient votre nom d’utilisateur.
  • HOME contient le chemin de votre répertoire personnel.
  • PATH contient une liste de répertoires séparés par deux-points dans lesquels Linux recherche les commandes que vous invoquez.
  • DISPLAY contient le nom et le numéro d’affichage du serveur X Window sur lequel apparaissent les fenêtres des programmes graphiques X Window.

Votre shell, comme n’importe quel autre programme, dispose d’un environnement. Les shells fournissent des méthodes pour examiner et modifier l’environnement directement. Pour afficher l’environnement courant de votre shell, invoquez le programme printenv. Tous les shells n’utilisent pas la même syntaxe pour la manipulation des variables d’environnement; voici la syntaxe pour les shells de type Bourne:

  • Le shell crée automatiquement une variable shell pour chaque variable d’environnement qu’il trouve, afin que vous puissiez accéder aux valeurs des variables d’environnement en utilisant la syntaxe $nomvar. Par exemple:
% echo $USER
samuel
% echo $HOME
/home/samuel
  • Vous pouvez utiliser la commande export pour exporter une variable shell vers l’environnement. Par exemple, pour positionner la variable d’environnement EDITOR, vous utiliserez ceci:
% EDITOR=emacs
% export EDITOR

Ou, pour faire plus court:

% export EDITOR=emacs

Dans un programme, vous pouvez accéder à une variable d’environnement au moyen de la fonction getenv de <stdlib.h>. Cette fonction accepte le nom d’une variable et renvoie la valeur correspondante sous forme d’une chaîne de caractères ou NULL si cette variable n’est pas définie dans l’environnement. Pour positionner ou supprimer une variable d’environnement, utilisez les fonctions setenv et unsetenv, respectivement.

Énumérer toutes les variables de l’environnement est un petit peu plus subtil. Pour cela, vous devez accéder à une variable globale spéciale appelée environ, qui est définie dans la bibliothèque C GNU. Cette variable, de type char**, est un tableau terminé par NULL de pointeurs vers des chaînes de caractères. Chaque chaîne contient une variable d’environnement, sous la forme VARIABLE=valeur.

Le programme du listing !!printenv!!, par exemple, affiche tout l’environnement en bouclant sur le tableau environ.

Listing (print-env.c) Afficher l’environnement d’exécution

#include <stdio.h>
/* La variable ENVIRON contient l'environnement. */
extern char** environ;
int main ()
{
  char** var;
  for (var = environ; *var != NULL; ++var)
    printf ("%s\n", *var);
  return 0;
}

Ne modifiez pas environ vous-même; utilisez plutôt les fonctions setenv et getenv.

Lorsqu’un nouveau programme est lancé, il hérite d’une copie de l’environnement du programme qui l’a invoqué (le shell, s’il a été invoqué de façon interactive). Donc, par exemple, les programmes que vous lancez depuis le shell peuvent examiner les valeurs des variables d’environnement que vous positionnez dans le shell.

Les variables d’environnement sont couramment utilisées pour passer des paramètres de configuration aux programmes. Supposons, par exemple, que vous écriviez un programme qui se connecte à un serveur Internet pour obtenir des informations. Vous pourriez écrire le programme de façon à ce que le nom du serveur soit saisi sur la ligne de commande. Cependant, supposons que le nom du serveur ne soit pas quelque chose que les utilisateurs changent très souvent. Vous pouvez utiliser une variable d’environnement spéciale – disons SERVER_NAME – pour spécifier le nom du serveur ; si cette variable n’existe pas, une valeur par défaut est utilisée. Une partie de votre programme pourrait ressembler au listing !!client!!.

Listing (client.c) Extrait d’un programme client réseau

#include <stdio.h>
#include <stdlib.h>
int main ()
{
  char* server_name = getenv ("SERVER_NAME");
  if (server_name == NULL)
    /* La variable SERVER_NAME n'est pas définie. Utilisation de la valeur
       par défaut. */
    server_name = "server.my-company.com";
  printf ("Accès au serveur %s\n", server_name);
  /* Accéder au serveur ici... */
  return 0;
}

Supposons que ce programme s’appelle client. En admettant que vous n’ayez pas défini la variable SERVER_NAME, la valeur par défaut pour le nom du serveur est utilisée:

% client
Accès au serveur server.my-company.com

Mais il est facile de spécifier un serveur différent:

% export SERVER_NAME=backup-server.elsewhere.net
% client
Accès au serveur backup-server.elsewhere.net

Utilisation de fichiers temporaires

Parfois, un programme a besoin de créer un fichier temporaire, pour stocker un gros volume de données temporairement ou passer des informations à un autre programme. Sur les systèmes GNU/Linux, les fichiers temporaires sont stockés dans le répertoire /tmp. Lors de l’utilisation de fichiers temporaires, vous devez éviter les pièges suivants:

  • Plus d’une copie de votre programme peuvent être lancées simultanément (par le même utilisateur ou par des utilisateurs différents). Les copies devraient utiliser des noms de fichiers temporaires différents afin d’éviter les collisions.
  • Les permissions du fichier devraient être définies de façon à éviter qu’un utilisateur non autorisé puisse altérer la manière dont s’exécute le programme en modifiant ou remplaçant le fichier temporaire.
  • Les noms des fichiers temporaires devraient être générés de façon imprévisible de l’extérieur; autrement, un attaquant pourrait exploiter le délai entre le test d’existence du nom de fichier et l’ouverture du nouveau fichier temporaire.

GNU/Linux fournit des fonctions, mkstemp et tmpfile, qui s’occupent de ces problèmes à votre place (en plus de fonctions qui ne le font pas). Le choix de la fonction dépend de l’utilisation que vous aurez du fichier, à savoir le passer à un autre programme ou utiliser les fonctions d’E/S UNIX (open, write, etc.) ou les fonctions de flux d’E/S de la bibliothèque C (fopen, fprintf, etc.).

Utilisation de mkstemp

La fonction mkstemp crée un nom de fichier temporaire à partir d’un modèle de nom de fichier, crée le fichier avec les permissions adéquates afin que seul l’utilisateur courant puisse y accéder, et ouvre le fichier en lecture/écriture. Le modèle de nom de fichier est une chaîne de caractères se terminant par “XXXXXX” (six X majuscules); mkstemp remplace les X par des caractères afin que le nom de fichier soit unique. La valeur de retour est un descripteur de fichier; utilisez les fonctions de la famille de write pour écrire dans le fichier temporaire.

Les fichiers temporaires créés par mkstemp ne sont pas effacés automatiquement. C’est à vous de supprimer le fichier lorsque vous n’en avez plus besoin (les programmeurs devraient être attentifs à supprimer les fichiers temporaires; dans le cas contraire, le système de fichiers /tmp pourrait se remplir, rendant le système inutilisable). Si le fichier temporaire n’est destiné qu’à être utilisé par le programme et ne sera pas transmis à un autre programme, c’est une bonne idée d’appeler unlink sur le fichier temporaire immédiatement. La fonction unlink supprime l’entrée de répertoire correspondant à un fichier, mais comme le système tient à jour un décompte du nombre de références sur chaque fichier, un fichier n’est pas effacé tant qu’il reste un descripteur de fichier ouvert pour ce fichier. Comme Linux ferme les descripteurs de fichiers quand un programme se termine, le fichier temporaire sera effacé même si votre programme se termine de manière anormale.

Les deux fonctions du listing !!tempfile!! présentent l’utilisation de mkstemp. Utilisées ensemble, ces fonctions facilitent l’écriture d’un tampon mémoire vers un fichier temporaire (afin que la mémoire puisse être libérée ou réutilisée) et sa relecture ultérieure.

Listing (temp_file.c) Utiliser mkstemp

#include <stdlib.h>
#include <unistd.h>
/* Handle sur un fichier temporaire créé avec write_temp_file. Avec
   cette implémentation, il s'agit d'un descripteur de fichier. */
typedef int temp_file_handle;
/* Écrit LENGTH octets de BUFFER dans un fichier temporaire. Unlink est
   appelé immédiatement sur le fichier temporaire. Renvoie un handle sur
   le fichier temporaire. */
temp_file_handle write_temp_file (char* buffer, size_t length)
{
  /* Crée le nom du fichier et le fichier. XXXXXX sera remplacé par des
     caractères donnant un nom de fichier unique. */
  char temp_filename[] = "/tmp/temp_file.XXXXXX";
  int fd = mkstemp (temp_filename);
  /* Appelle unlink immédiatement afin que le fichier soit supprimé dès que
     le descripteur sera fermé. */
  unlink (temp_filename);
  /* Écrit le nombre d'octets dans le fichier avant tout. */
  write (fd, &length, sizeof (length));
  /* Écrit des données proprement dites. */
  write (fd, buffer, length);
  /* Utilise le descripteur de fichier comme handle
     sur le fichier temporaire. */ 
  return fd;
}
/* Lit le contenu du fichier temporaire TEMP_FILE créé avec
   write_temp_file. La valeur de retour est un tampon nouvellement alloué avec
   ce contenu, que l'appelant doit libérer avec free.
   *LENGTH est renseigné avec la taille du contenu, en octets.
   Le fichier temporaire est supprimé. */
char* read_temp_file (temp_file_handle temp_file, size_t* length)
{
  char* buffer;
  /* Le handle sur TEMP_FILE est le descripteur du fichier temporaire. */
  int fd = temp_file;
  /* Se place au début du fichier. */
  lseek (fd, 0, SEEK_SET);
  /* Lit les données depuis le fichier temporaire. */
  read (fd, length, sizeof (*length));
  /* Alloue un buffer et lit les données. */
  buffer = (char*) malloc (*length);
  read (fd, buffer, *length);
  /* Ferme le descripteur de fichier ce qui provoque la suppression du
     fichier temporaire. */
  close (fd);
  return buffer;
}

Utilisation de tmpfile

Si vous utilisez les fonctions d’E/S de la bibliothèque C et n’avez pas besoin de passer le fichier temporaire à un autre programme, vous pouvez utiliser la fonction tmpfile. Elle crée et ouvre un fichier temporaire, et renvoie un pointeur de fichier. Le fichier temporaire a déjà été traité par unlink, comme dans l’exemple précédent, afin d’être supprimé automatiquement lorsque le pointeur sur le fichier est fermé (avec fclose) ou lorsque le programme se termine.

GNU/Linux propose diverses autres fonctions pour générer des fichiers temporaires et des noms de fichiers temporaires, par exemple, mktemp, tmpnam et tempnam. N’utilisez pas ces fonctions, cependant, car elles souffrent des problèmes de fiabilité et de sécurité mentionnés plus haut.

Créer du code robuste

Écrire des programmes s’exécutant correctement dans des conditions d’utilisation “normales” est dur; écrire des programmes qui se comportent avec élégance dans des conditions d’erreur l’est encore plus. Cette section propose quelques techniques de codage pour trouver les bogues plus tôt et pour détecter et traiter les problèmes dans un programme en cours d’exécution.

Les exemples de code présentés plus loin dans ce livre n’incluent délibérément pas de code de vérification d’erreur ou de récupération sur erreur car cela risquerait d’alourdir le code et de masquer la fonctionnalité présentée. Cependant, l’exemple final du chapitre 11, « Une application GNU/Linux d'exemple », est là pour montrer comment utiliser ces techniques pour produire des applications robustes.

Utiliser assert

Un bon objectif à conserver à l’esprit en permanence lorsque l’on code des programmes est que des bogues ou des erreurs inattendues devraient conduire à un crash du programme, dès que possible. Cela vous aidera à trouver les bogues plus tôt dans les cycles de développement et de tests. Il est difficile de repérer les dysfonctionnements qui ne se signalent pas d’eux-mêmes et n’apparaissent pas avant que le programme soit à la disposition de l’utilisateur.

Une des méthodes les plus simples pour détecter des conditions inattendues est la macro C standard assert. Elle prend comme argument une expression booléenne. Le programme s’arrête si l’expression est fausse, après avoir affiché un message d’erreur contenant le nom du fichier, le numéro de ligne et le texte de l’expression où l’erreur est survenue. La macro assert est très utile pour une large gamme de tests de cohérence internes à un programme. Par exemple, utilisez assert pour vérifier la validité des arguments passés à une fonction, pour tester des préconditions et postconditions lors d’appels de fonctions (ou de méthodes en C++) et pour tester des valeurs de retour inattendues.

Chaque utilisation de assert constitue non seulement une vérification de condition à l’exécution mais également une documentation sur le fonctionnement du programme au coeur du code source. Si votre programme contient une instruction assert(condition) cela indique à quelqu’un lisant le code source que condition devrait toujours être vraie à ce point du programme et si condition n’est pas vraie, il s’agit probablement d’un bogue dans le programme.

Pour du code dans lequel les performances sont essentielles, les vérifications à l’exécution comme celles induites par l’utilisation de assert peuvent avoir un coût significatif en termes de performances. Dans ce cas, vous pouvez compiler votre code en définissant la macro NDEBUG, en utilisant l’option -DNDEBUG sur la ligne de commande du compilateur. Lorsque NDEBUG est définie, le préprocesseur supprimera les occurrences de la macro assert. Il est conseillé de ne le faire que lorsque c’est nécessaire pour des raisons de performances et uniquement pour des fichiers sources concernés par ces questions de performances.

Comme il est possible que le préprocesseur supprime les occurrences de assert, soyez attentif à ce que les expressions que vous utilisez avec assert n’aient pas d’effet de bord. En particulier, vous ne devriez pas appeler de fonctions au sein d’expressions assert, y affecter des valeurs à des variables ou utiliser des opérateurs de modification comme ++.

Supposons, par exemple, que vous appeliez une fonction, do_something, de façon répétitive dans une boucle. La fonction do_something renvoie zéro en cas de succès et une valeur différente de zéro en cas d’échec, mais vous ne vous attendez pas à ce qu’elle échoue dans votre programme. Vous pourriez être tenté d’écrire:

for (i = 0; i < 100; ++i)
  assert (do_something () == 0);

Cependant, vous pourriez trouver que cette vérification entraîne une perte de performances trop importante et décider plus tard de recompiler avec la macro NDEBUG définie. Cela supprimerait totalement l’appel à assert, l’expression ne serait donc jamais évaluée et do_something ne serait jamais appelée. Voici un extrait de code effectuant la même vérification, sans ce problème:

for (i = 0; i < 100; ++i) {
  int status = do_something ();
  assert (status == 0);
}

Un autre élément à conserver à l’esprit est que vous ne devez pas utiliser assert pour tester les entrées utilisateur. Les utilisateurs n’apprécient pas lorsque les applications plantent en affichant un message d’erreur obscur, même en réponse à une entrée invalide. Vous devriez cependant toujours vérifier les saisies de l’utilisateur et afficher des messages d’erreurs compréhensibles. N’utilisez assert que pour des tests internes lors de l’exécution.

Voici quelques exemples de bonne utilisation d’assert:

  • Vérification de pointeurs nuls, par exemple, comme arguments de fonction invalides. Le message d’erreur généré par assert (pointer != NULL),
Assertion 'pointer != ((void *)0)' failed.

est plus utile que le message d’erreur qui serait produit dans le cas du déréfencement d’un pointeur nul:

Erreur de Segmentation
  • Vérification de conditions concernant la validité des paramètres d’une fonction. Par exemple, si une fonction ne doit être appelée qu’avec une valeur positive pour le paramètre foo, utilisez cette expression au début de la fonction:
assert (foo > 0);

Cela vous aidera à détecter les mauvaises utilisations de la fonction, et montre clairement à quelqu’un lisant le code source de la fonction qu’il y a une restriction quant à la valeur du paramètre.

Ne vous retenez pas, utilisez assert librement partout dans vos programmes.

Problèmes lors d'appels système

La plupart d’entre nous a appris comment écrire des programmes qui s’exécutent selon un chemin bien défini. Nous divisons le programme en tâches et sous-tâches et chaque fonction accomplit une tâche en invoquant d’autres fonctions pour effectuer les opérations correspondant aux sous-tâches. On attend d’une fonction qu’étant données des entrées précises, elle produise une sortie et des effets de bord corrects.

Les réalités matérielles et logicielles s’imposent face à ce rêve. Les ordinateurs ont des ressources limitées; le matériel subit des pannes; beaucoup de programmes s’exécutent en même temps; les utilisateurs et les programmeurs font des erreurs. C’est souvent à la frontière entre les applications et le système d’exploitation que ces réalités se manifestent. Aussi, lors de l’utilisation d’appels système pour accéder aux ressources, pour effectuer des E/S ou à d’autres fins, il est important de comprendre non seulement ce qui se passe lorsque l’appel fonctionne mais également comment et quand l’appel peut échouer.

Les appels systèmes peuvent échouer de plusieurs façons. Par exemple:

  • Le système n’a plus de ressources (ou le programme dépasse la limite de ressources permises pour un seul programme). Par exemple, le programme peut tenter d’allouer trop de mémoire, d’écrire trop de données sur le disque ou d’ouvrir trop de fichiers en même temps.
  • Linux peut bloquer un appel système lorsqu’un programme tente d’effectuer une opération non permise. Par exemple, un programme pourrait tenter d’écrire dans un fichier en lecture seule, d’accéder à la mémoire d’un autre processus ou de tuer un programme d’un autre utilisateur.
  • Les arguments d’un appel système peuvent être invalides, soit parce-que l’utilisateur a fourni des entrées invalides, soit à cause d’un bogue dans le programme. Par exemple, le programme peut passer une adresse mémoire ou un descripteur de fichier invalide à un appel système; ou un programme peut tenter d’ouvrir un répertoire comme un fichier régulier ou passer le nom d’un fichier régulier à un appel système qui attend un répertoire.
  • Un appel système peut échouer pour des raisons externes à un programme. Cela arrive le plus souvent lorsqu’un appel système accède à un périphérique matériel. Ce dernier peut être défectueux, ne pas supporter une opération particulière ou un lecteur peut être vide.
  • Un appel système peut parfois être interrompu par un événement extérieur, comme l’arrivée d’un signal. Il ne s’agit pas tout à fait d’un échec de l’appel, mais il est de la responsabilité du programme appelant de relancer l’appel système si nécessaire.

Dans un programme bien écrit qui utilise abondamment les appels système, il est courant qu’il y ait plus de code consacré à la détection et à la gestion d’erreurs et d’autres circonstances exceptionnelles qu’à la fonction principale du programme.

Codes d'erreur des appels système

Une majorité des appels système renvoie zéro si tout se passe bien ou une valeur différente de zéro si l’opération échoue (toutefois, beaucoup dérogent à la règle; par exemple, malloc renvoie un pointeur nul pour indiquer une erreur. Lisez toujours la page de manuel attentivement lorsque vous utilisez un appel système). Bien que cette information puisse être suffisante pour déterminer si le programme doit continuer normalement, elle ne l’est certainement pas pour une récupération fiable des erreurs.

La plupart des appels système utilisent une variable spéciale appelée errno pour stocker des informations additionnelles en cas d’échec4). Lorsqu’un appel échoue, le système positionne errno à une valeur indiquant ce qui s’est mal passé. Comme tous les appels système utilisent la même variable errno pour stocker des informations sur une erreur, vous devriez copier sa valeur dans une autre variable immédiatement après l’appel qui a échoué. La valeur de errno sera écrasée au prochain appel système.

Les valeurs d’erreur sont des entiers; les valeurs possibles sont fournies par des macros préprocesseur, libellées en majuscules par convention et commençant par « E » ? par exemple, EACCESS ou EINVAL. Utilisez toujours ces macros lorsque vous faites référence à des valeurs de errno plutôt que les valeurs entières. Incluez le fichier d’entête <errno.h> si vous utilisez des valeurs de errno.

GNU/Linux propose une fonction utile, strerror, qui renvoie une chaîne de caractères contenant la description d’un code d’erreur de errno, utilisable dans les messages d’erreur. Incluez <string.h> si vous désirez utiliser strerror.

GNU/Linux fournit aussi perror, qui envoie la description de l’erreur directement vers le flux stderr. Passez à perror une chaîne de caractères à ajouter avant la description de l’erreur, qui contient habituellement le nom de la fonction qui a échoué. Incluez <stdio.h> si vous utilisez perror.

Cet extrait de code tente d’ouvrir un fichier; si l’ouverture échoue, il affiche un message d’erreur et quitte le programme. Notez que l’appel de open renvoie un descripteur de fichier si l’appel se passe correctement ou -1 dans le cas contraire.

fd = open ("inputfile.txt", O_RDONLY);
if (fd == -1) {
  /* L'ouverture a échoué, affiche un message d'erreur et quitte. */
  fprintf (stderr, "erreur lors de l'ouverture de : %s\n", strerror (errno));
  exit (1);
}

Selon votre programme et la nature de l’appel système, l’action appropriée lors d’un échec peut être d’afficher un message d’erreur, d’annuler une opération, de quitter le programme, de réessayer ou même d’ignorer l’erreur. Il est important cependant d’avoir du code qui gère toutes les raisons d’échec d’une façon ou d’une autre.

Un code d’erreur possible auquel vous devriez particulièrement vous attendre, en particulier avec les fonctions d’E/S, est EINTR. Certaines fonctions, comme read, select et sleep, peuvent mettre un certain temps à s’exécuter. Elles sont considérées comme étant bloquantes car l’exécution du programme est bloquée jusqu’à ce que l’appel se termine. Cependant, si le programme reçoit un signal alors qu’un tel appel est en cours, celui-ci se termine sans que l’opération soit achevée. Dans ce cas, errno est positionnée à EINTR. En général, vous devriez relancer l’appel système dans ce cas.

Voici un extrait de code qui utilise l’appel chown pour faire de l’utilisateur user_id le propriétaire d’un fichier désigné par path. Si l’appel échoue, le programme agit selon la valeur de errno. Notez que lorsque nous détectons ce qui semble être un bogue, nous utilisons abort ou assert, ce qui provoque la génération d’un fichier core. Cela peut être utile pour un débogage postmortem. Dans le cas d’erreurs irrécupérables, comme des conditions de manque de mémoire, nous utilisons plutôt exit et une valeur de sortie différente de zéro car un fichier core ne serait pas vraiment utile.

rval = chown (path, user_id, -1);
if (rval != 0) {
  /* Sauvegarde errno car il sera écrasé par le prochain appel système */
  int error_code = errno;
  /* L'opération a échoué ; chown doit retourner -1 dans ce cas. */
  assert (rval == -1);
  /* Effectue l'action appropriée en fonction de la valeur de errno. */
  switch (error_code) {
  case EPERM:       /* Permission refusée. */
  case EROFS:       /* PATH est sur un système de fichiers en lecture seule */
  case ENAMETOOLONG: /* PATH est trop long. */
  case ENOENT:        /* PATH n'existe pas. */
  case ENOTDIR:      /* Une des composantes de PATH n'est pas un répertoire */ 
  case EACCES:        /* Une des composantes de PATH n'est pas accessible. */
    /* Quelque chose ne va pas. Affiche un message d'erreur. */
    fprintf (stderr, "erreur lors du changement de propriétaire de %s: %s\n",
             path, strerror (error_code));
 
    /* N'interrompt pas le programme ; possibilité de proposer à l'utilisateur 
       de choisir un autre fichier... */
    break;
  case EFAULT:
    /* PATH contient une adresse mémoire invalide.
       Il s'agit sûrement d'un bogue */
    abort ();
  case ENOMEM:
    /* Plus de mémoire disponible. */
    fprintf (stderr, "%s\n", strerror (error_code));
    exit (1);
  default:
     /* Autre code d'erreur innatendu. Nous avons tenté de gérer tous les
        codes d'erreur possibles ; si nous en avons oublié un
        il s'agit d'un bogue */
    abort ();
  };
}

Vous pourriez vous contenter du code suivant qui se comporte de la même façon si l’appel se passe bien:

rval = chown (path, user_id, -1);
assert (rval == 0);

Mais en cas d’échec, cette alternative ne fait aucune tentative pour rapporter, gérer ou reprendre après l’erreur.

L’utilisation de la première ou de la seconde forme ou de quelque chose entre les deux dépend des besoins en détection et récupération d’erreur de votre programme.

Erreurs et allocation de ressources

Souvent, lorsqu’un appel système échoue, il est approprié d’annuler l’opération en cours mais de ne pas terminer le programme car il peut être possible de continuer l’exécution suite à cette erreur. Une façon de le faire est de sortir de la fonction en cours en renvoyant un code de retour qui indique l’erreur.

Si vous décidez de quitter une fonction au milieu de son exécution, il est important de vous assurer que toutes les ressources allouées précédemment au sein de la fonction sont libérées. Ces ressources peuvent être de la mémoire, des descripteurs de fichier, des pointeurs sur des fichiers, des fichiers temporaires, des objets de synchronisation, etc. Sinon, si votre programme continue à s’exécuter, les ressources allouées préalablement à l’échec de la fonction seront perdues.

Considérons par exemple une fonction qui lit un fichier dans un tampon. La fonction pourrait passer par les étapes suivantes :

  • Allouer le tampon;
  • Ouvrir le fichier;
  • Lire le fichier dans le tampon;
  • Fermer le fichier;
  • Retourner le tampon.

Le listing !!readfile!! montre une façon d’écrire cette fonction.

Si le fichier n’existe pas, l’étape 2 échouera. Une réponse appropriée à cet événement serait que la fonction retourne NULL. Cependant, si le tampon a déjà été alloué à l’étape 1, il y a un risque de perdre cette mémoire. Vous devez penser à libérer le tampon dans chaque bloc de la fonction qui en provoque la sortie. Si l’étape 3 ne se déroule pas correctement, vous devez non seulement libérer le tampon mais également fermer le fichier.

Listing (readfile.c) Libérer les ressources

#include <fcntl.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
char* read_from_file (const char* filename, size_t length)
{
  char* buffer;
  int fd;
  ssize_t bytes_read;
  /* Alloue le tampon. */
  buffer = (char*) malloc (length);
  if (buffer == NULL)
    return NULL;
  /* Ouvre le fichier. */
  fd = open (filename, O_RDONLY);
  if (fd == -1) {
    /* L'ouverture a échoué. Libère le tampon avant de quitter. */
    free (buffer);
    return NULL;
  }
  /* Lit les données. */
  bytes_read = read (fd, buffer, length);
  if (bytes_read != length) {
    /* La lecture a échoué. Libère le tampon et ferme fd avant de quitter. */
    free (buffer);
    close (fd);
    return NULL;
  }
  /* Tout va bien. Ferme le fichier et renvoie le tampon. */
  close (fd);
  return buffer;
}

Linux libère la mémoire, ferme les fichiers et la plupart des autres ressources automatiquement lorsqu’un programme se termine, il n’est donc pas nécessaire de libérer les tampons et de fermer les fichiers avant d’appeler exit. Vous pourriez néanmoins devoir libérer manuellement d’autres ressources partagées, comme les fichiers temporaires et la mémoire partagée, qui peuvent potentiellement survivre à un programme.

Écrire et utiliser des bibliothèques

Pratiquement tous les programmes sont liés à une ou plusieurs bibliothèques. Tout programme qui utilise une fonction C (comme printf ou malloc) sera lié à la bibliothèque d’exécution C. Si votre programme a une interface utilisateur graphique (Graphical User Interface, GUI), il sera lié aux bibliothèques de fenêtrage. Si votre programme utilise une base de données, le fournisseur de base de données vous fournira des bibliothèques permettant d’accéder à la base de donnée de façon pratique.

Dans chacun de ces cas, vous devez décider si la bibliothèque doit être liée de façon statique ou dynamique. Si vous choisissez la liaison statique, votre programme sera plus gros et plus difficile à mettre à jour, mais probablement plus simple à déployer. Si vous optez pour la liaison dynamique, votre programme sera petit, plus simple à mettre à jour mais plus compliqué à déployer. Cette section explique comment effectuer une liaison statique et dynamique, examine les deux options en détail et donne quelques règles empiriques pour décider quelle est la meilleure dans votre cas.

Archives

Une archive (ou bibliothèque statique) est tout simplement une collection de fichiers objets stockée dans un seul fichier objet (une archive est en gros équivalent à un fichier .LIB sous Windows). Lorsque vous fournissez une archive à l’éditeur de liens, il recherche au sein de cette archive les fichiers dont il a besoin, les extrait et les lie avec votre programme comme si vous aviez fourni ces fichiers objets directement.

Vous pouvez créer une archive en utilisant la commande ar. Les fichiers archives utilisent traditionnellement une extension .a plutôt que l’extension .o utilisée par les fichiers objets ordinaires. Voici comment combiner test1.o et test2.o dans une seule archive libtest.a:

% ar cr libtest.a test1.o test2.o

Le drapeau cr indique à ar de créer l’archive5). Vous pouvez maintenant lier votre programme avec cette archive en utilisant l’option -ltest avec gcc ou g++, comme le décrit la Section 1.2.2, « Lier les fichiers objets » du chapitre 1, « Pour commencer ».

Lorsque l’éditeur de liens détecte une archive sur la ligne de commande, il y recherche les définitions des symboles (fonctions ou variables) qui sont référencés dans les fichiers objets qui ont déjà été traités mais ne sont pas encore définis. Les fichiers objets qui définissent ces symboles sont extraits de l’archive et inclus dans l’exécutable final. Comme l’éditeur de liens effectue une recherche dans l’archive lorsqu’il la rencontre sur la ligne de commande, il est habituel de placer les archives à la fin de la ligne de commande. Par exemple, supposons que test.c contienne le code du listing !!testc!! et que app.c contienne celui du listing !!appc!!.

Listing (test.c) Contenu de la bibliothèque

int f ()
{
    return 3;
}

Listing (app.c) Programme utilisant la bibliothèque

int main()
{
    return f ();
}

Supposons maintenant que test.o soit combiné à un autre fichier objet quelconque pour produire l’archive libtest.a. La ligne de commande suivante ne fonctionnerait pas:

% gcc -o app -L. -ltest app.o
app.o: In function ?main?:
app.o(.text+0x4): undefined reference to ?f?
collect2: ld returned 1 exit status

Le message d’erreur indique que même si libtest.a contient la définition de f, l’éditeur de liens ne la trouve pas. C’est dû au fait que libtest.a a été inspectée lorsqu’elle a été détectée pour la première fois et à ce moment l’éditeur de liens n’avait pas rencontré de référence à f.

Par contre, si l’on utilise la ligne de commande suivante, aucun message d’erreur n’est émis:

% gcc -o app app.o -L. -ltest

La raison en est que la référence à f dans app.o oblige l’éditeur de liens à inclure le fichier objet test.o depuis l’archive libtest.a.

Bibliothèques partagées

Une bibliothèque partagée (également appelée objet partagé ou bibliothèque dynamique) est similaire à une archive en ceci qu’il s’agit d’un groupe de fichiers objets. Cependant, il y a beaucoup de différences importantes. La différence la plus fondamentale est que lorsqu’une bibliothèque partagée est liée à un programme, l’exécutable final ne contient pas vraiment le code présent dans la bibliothèque partagée. Au lieu de cela, l’exécutable contient simplement une référence à cette bibliothèque. Si plusieurs programmes sur le système sont liés à la même bibliothèque partagée, ils référenceront tous la bibliothèque, mais aucun ne l’inclura réellement. Donc, la bibliothèque est « partagée » entre tous les programmes auxquels elle est liée.

Une seconde différence importante est qu’une bibliothèque partagée n’est pas seulement une collection de fichiers objets, parmi lesquels l’éditeur de liens choisit ceux qui sont nécessaires pour satisfaire les références non définies. Au lieu de cela, les fichiers objets qui composent la bibliothèque sont combinés en un seul fichier objet afin qu’un programme lié à la bibliothèque partagée inclut toujours tout le code de la bibliothèque plutôt que de n’inclure que les portions nécessaires.

Pour créer une bibliothèque partagée, vous devez compiler les objets qui constitueront la bibliothèque en utilisant l’option -fPIC du compilateur, comme ceci:

% gcc -c -fPIC test1.c

L’option -fPIC indique au compilateur que vous allez utiliser test1.o en tant qu’élément d’un objet partagé.

Code indépendant de la position (position-independent code, PIC)
PIC signifie code indépendant de la position. Les fonctions d’une bibliothèque partagée peuvent être chargées à différentes adresses dans différents programmes, le code de l’objet partagé ne doit donc pas dépendre de l’adresse (ou position) à laquelle il est chargé. Cette considération n’a pas d’impact à votre niveau, en tant que programmeur, excepté que vous devez vous souvenir d’utiliser l’option -fPIC lors de la compilation du code utilisé pour la bibliothèque partagée.

Puis, vous combinez les fichiers objets au sein d’une bibliothèque partagée, comme ceci:

% gcc -shared -fPIC -o libtest.so test1.o test2.o

L’option -shared indique à l’éditeur de liens de créer une bibliothèque partagée au lieu d’un exécutable ordinaire. Les bibliothèques partagées utilisent l’extension .so, ce qui signifie objet partagé (shared object). Comme pour les archives statiques, le nom commence toujours par lib pour indiquer que le fichier est une bibliothèque.

Lier un programme à une bibliothèque partagée se fait de la même manière que pour une archive statique. Par exemple, la ligne suivante liera le programme à libtest.so si elle est dans le répertoire courant ou dans un des répertoires de recherche de bibliothèques du système:

% gcc -o app app.o -L. -ltest

Supposons que libtest.a et libtest.so soient disponibles. L’éditeur de liens doit choisir une seule des deux bibliothèques. Il commence par rechercher dans chaque répertoire (tout d’abord ceux indiqués par l’option -L, puis dans les répertoires standards). Lorsque l’éditeur de liens trouve un répertoire qui contient soit libtest.a soit libtest.so, il interrompt ses recherches. Si une seule des deux variantes est présente dans le répertoire, l’éditeur de liens la sélectionne. Sinon, il choisit la version partagée à moins que vous ne lui spécifiiez explicitement le contraire. Vous pouvez utiliser l’option -static pour utiliser les archives statiques. Par exemple, la ligne de commande suivante utilisera l’archive libtest.a, même si la bibliothèque partagée libtest.so est disponible:

% gcc -static -o app app.o -L. -ltest

La commande ldd indique les bibliothèques partagées liées à un programme. Ces bibliothèques doivent être disponibles à l’exécution du programme. Notez que ldd indiquera une bibliothèque supplémentaire appelée ld-linux.so qui fait partie du mécanisme de liaison dynamique de GNU/Linux.

Utiliser LD_LIBRARY_PATH

Lorsque vous liez un programme à une bibliothèque partagée, l’éditeur de liens ne place pas le chemin d’accès complet à la bibliothèque dans l’exécutable. Il n’y place que le nom de la bibliothèque partagée. Lorsque le programme est exécuté, le système recherche la bibliothèque partagée et la charge. Par défaut, cette recherche n’est effectuée que dans /lib et /usr/lib par défaut. Si une bibliothèque partagée liée à votre programme est placée à un autre endroit que dans ces répertoires, elle ne sera pas trouvée et le système refusera de lancer le programme.

Une solution à ce problème est d’utiliser les options -Wl, -rpath lors de l’édition de liens du programme. Supposons que vous utilisiez ceci:

% gcc -o app app.o -L. -ltest -Wl,-rpath,/usr/local/lib

Dans ce cas, lorsqu’on lance le programme app, recherche les bibliothèques nécessaires dans /usr/local/lib.

Une autre solution à ce problème est de donner une valeur à la variable d’environnement LD_LIBRARY_PATH au lancement du programme. Tout comme la variable d’environnement PATH, LD_LIBRARY_PATH est une liste de répertoires séparés par deux-points. Par exemple, si vous positionnez LD_LIBRARY_PATH à /usr/local/lib:/opt/lib alors les recherches seront effectuées au sein de /usr/local/lib et /opt/lib avant les répertoires standards /lib et /usr/lib. Vous devez être conscient que si LD_LIBRARY_PATH est renseigné, l’éditeur de liens recherchera les bibliothèques au sein des répertoires qui y sont spécifiés avant ceux passés via l’option -L lorsqu’il crée un exécutable6).

Bibliothèques standards

Même si vous ne spécifiez aucune bibliothèque lorsque vous compilez votre programme, il est quasiment certain qu’il utilise une bibliothèque partagée. Car GCC lie automatiquement la bibliothèque standard du C, libc, au programme. Les fonctions mathématiques de la bibliothèque standard du C ne sont pas inclues dans libc; elles sont placées dans une bibliothèque séparée, libm, que vous devez spécifier explicitement. Par exemple, pour compiler et lier un programme compute.c qui utilise des fonctions trigonométriques comme sin et cos, vous devez utiliser ce code:

% gcc -o compute compute.c -lm

Si vous écrivez un programme C++ et le liez en utilisant les commandes c++ ou g++, vous aurez également la bibliothèque standard du C++, libstdc++.

Dépendances entre bibliothèques

Les bibliothèques dépendent souvent les unes des autres. Par exemple, beaucoup de systèmes GNU/Linux proposent libtiff, une bibliothèque contenant des fonctions pour lire et écrire des fichiers images au format TIFF. Cette bibliothèque utilise à son tour les bibliothèques libjpeg (routines de manipulation d’images JPEG) et libz (routines de compression).

Le listing !!libtiff!! présente un très petit programme qui utilise libtiff pour ouvrir une image TIFF.

Listing (tifftest.c) Utilisation de libtiff

#include <stdio.h>
#include <tiffio.h>
int main (int argc, char** argv)
{
  TIFF* tiff;
  tiff = TIFFOpen (argv[1], "r");
  TIFFClose (tiff);
  return 0;
}

Sauvegardez ce fichier sous le nom de tifftest.c. Pour le compiler et le lier à libtiff, spécifiez -ltiff sur votre ligne de commande:

% gcc -o tifftest tifftest.c -ltiff

Par défaut, c’est la version partagée de la bibliothèque libtiff, située dans /usr/lib/libtiff.so, qui sera utilisée. Comme libtiff utilise libjpeg et libz, les versions partagées de ces bibliothèque sont également liées (une bibliothèque partagée peut pointer vers d’autres bibliothèques partagées dont elle dépend). Pour vérifier cela, utilisez la commande ldd:

% ldd tifftest
        libtiff.so.3 => /usr/lib/libtiff.so.3 (0x4001d000)
        libc.so.6 => /lib/libc.so.6 (0x40060000)
        libjpeg.so.62 => /usr/lib/libjpeg.so.62 (0x40155000)
        libz.so.1 => /usr/lib/libz.so.1 (0x40174000)
        /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
        ...

Pour lier ce programme de façon statique, vous devez spécifier les deux autres bibliothèques:

% gcc -static -o tifftest tifftest.c -ltiff -ljpeg -lz -lm

De temps en temps, deux bibliothèques dépendent mutuellement l’une de l’autre. En d’autres termes, la première archive fait référence à des symboles définis dans la seconde et vice versa. Cette situation résulte généralement d’une mauvaise conception mais existe. Dans ce cas, vous pouvez fournir une même bibliothèque plusieurs fois sur la ligne de commande. L’éditeur de liens recherchera les symboles manquants à chaque apparition de la bibliothèque. Par exemple, cette ligne provoque deux recherches de symboles au sein de libfoo:

% gcc -o app app.o -lfoo -lbar -lfoo

Donc, même si libfoo.a fait référence à des symboles de libbar.a et vice versa, le programme sera compilé avec succès.

Avantages et inconvénients

Maintenant que vous savez tout à propos des archives statiques et des bibliothèques partagées, vous vous demandez probablement lesquelles utiliser. Il y a quelques éléments à garder à l’esprit.

Un avantage majeur des bibliothèques partagées est qu’elles permettent d’économiser de la place sur le système où le programme est installé. Si vous installez dix programmes et qu’ils utilisent tous la même bibliothèque partagée, vous économiserez beaucoup d’espace à utiliser une bibliothèque partagée. Si vous utilisez une archive statique à la place, l’archive est incluse dans les dix programmes. Donc, utiliser des bibliothèques partagées permet d’économiser de l’espace disque. Cela réduit également le temps de téléchargement si votre programme est distribué via le Web.

Un avantage lié aux bibliothèques partagées est que les utilisateurs peuvent mettre à niveau les bibliothèques sans mettre à niveau tous les programmes qui en dépendent. Par exemple, supposons que vous créiez une bibliothèque partagée qui gère les connexions HTTP. Beaucoup de programmes peuvent dépendre de cette bibliothèque. Si vous découvrez un bogue dans celle-ci, vous pouvez mettre à niveau la bibliothèque. Instantanément, tous les programmes qui en dépendent bénéficieront de la correction ; vous n’avez pas à repasser par une étape d’édition de liens pour tous les programmes, comme vous le feriez avec une archive statique.

Ces avantages pourraient vous faire penser que vous devez toujours utiliser les bibliothèques partagées. Cependant, il existe des raisons valables pour utiliser les archives statiques. Le fait qu’une mise à jour de la bibliothèque affecte tous les programmes qui en dépendent peut représenter un inconvénient. Par exemple, si vous développez un logiciel critique, il est préférable de le lier avec une archive statique afin qu’une mise à jour des bibliothèques sur le système hôte n’affecte pas votre programme (autrement, les utilisateurs pourraient mettre à jour les bibliothèques, empêchant votre programme de fonctionner, puis appeler votre service client en disant que c’est de votre faute !).

Si vous n’êtes pas sûr de pouvoir installer vos bibliothèques dans /lib ou /usr/lib, vous devriez définitivement réfléchir à deux fois avant d’utiliser une bibliothèque partagée (vous ne pourrez pas installer vos bibliothèques dans ces répertoires si votre logiciel est destiné à pouvoir être installé par des utilisateurs ne disposant pas des droits d’administrateur). De plus, l’astuce de l’option -Wl, -rpath ne fonctionnera pas si vous ne savez pas où seront placées les bibliothèques en définitive. Et demander à vos utilisateurs de positionner LD_LIBRARY_PATH leur impose une étape de configuration supplémentaire. Comme chaque utilisateur doit le faire individuellement, il s’agit d’une contrainte additionnelle non négligeable.

Il faut bien peser ces avantages et inconvénients pour chaque programme que vous distribuez.

Chargement et déchargement dynamiques

Il est parfois utile de pouvoir charger du code au moment de l’exécution sans lier ce code explicitement. Par exemple, considérons une application qui supporte des « plugins », comme un navigateur Web. Le navigateur permet à des développeurs tiers de créer des plugins pour fournir des fonctionnalités supplémentaires. Ces développeurs créent des bibliothèques partagées et les placent à un endroit prédéfini. Le navigateur charge alors automatiquement le code de ces bibliothèques.

Cette fonctionnalité est disponible sous Linux en utilisant la fonction dlopen. Vous ouvrez une bibliothèque appelée libtest.so en appelant dlopen comme ceci:

dlopen ("libtest.so", RTLD_LAZY)

(Le second paramètre est un drapeau qui indique comment lier les symboles de la bibliothèque partagée. Vous pouvez consulter les pages de manuel de dlopen pour plus d’informations, mais RTLD_LAZY est généralement l’option conseillée). Pour utiliser les fonctions de chargement dynamique, incluez l’entête <dlfcn.h> et liez avec l’option -ldl pour inclure la bibliothèque libdl.

La valeur de retour de cette fonction est un void * utilisé comme référence sur la bibliothèque partagée. Vous pouvez passer cette valeur à la fonction dlsym pour obtenir l’adresse d’une fonction chargée avec la bibliothèque partagée. Par exemple, si libtest.so définit une fonction appelée my_function, vous pourriez l’appeler comme ceci:

void* handle = dlopen ("libtest.so", RTLD_LAZY);
void (*test)() = dlsym (handle, "my_function");
(*test)();
dlclose (handle);

L’appel système dlsym peut également être utilisé pour obtenir un pointeur vers une variable static de la bibliothèque partagée.

dlopen et dlsym renvoient toutes deux NULL si l’appel échoue. Dans ce cas, vous pouvez appeler dlerror (sans paramètre) pour obtenir un message d’erreur lisible décrivant le problème.

La fonction dlclose décharge la bibliothèque. Techniquement, dlopen ne charge la bibliothèque que si elle n’est pas déjà chargée. Si elle l’est, dlopen se contente d’incrémenter le compteur de références pour la bibliothèque. De même, dlclose décrémente ce compteur et ne décharge la bibliothèque que s’il a atteint zéro.

Si vous écrivez du code en C++ dans votre bibliothèque partagée, vous devriez déclarer les fonctions et variables que vous voulez rendre accessible de l’extérieur avec le modificateur extern “C”. Par exemple, si la fonction C++ my_function est dans une bibliothèque partagée et que vous voulez la rendre accessible par dlsym, vous devriez la déclarer comme suit:

extern "C" void my_function ();

Cela évite que le compilateur C++ ne modifie le nom de la fonction ce qui résulterait en un nom différent à l’aspect sympathique qui encode des informations supplémentaires sur la fonction. Un compilateur C ne modifie pas les noms; il utilise toujours les noms que vous donnez à votre fonction ou variable.

1) NdT. flags en anglais
2) NdT. appelés aussi parfois tubes ou canaux.
3) En C++, la même distinction s’applique à cout et cerr, respectivement. Notez que le token endl purge un flux en plus d’y envoyer un caractère de nouvelle ligne; si vous ne voulez pas purger le flux (pour des raisons de performances par exemple), utilisez une constante de nouvelle ligne, ‘\n‘, à la place.
4) En réalité, pour des raisons d’isolement de threads, errno est implémentée comme une macro, mais elle est utilisée comme une variable globale.
5) Vous pouvez utiliser d’autres options pour supprimer un fichier d’une archive ou effectuer d’autres opérations sur l’archive. Ces opérations sont rarement utilisées mais sont documentées sur la page de manuel de ar.
6) Vous pourriez voir des références à LD_RUN_PATH dans certaines documentations en ligne. Ne croyez pas ce que vous lisez, cette variable n’a en fait aucun effet sous GNU/Linux.
 
livre/chap2/ecrire_des_logiciels_gnu_linux_de_qualite.txt · Dernière modification: 2007/09/01 11:35 par david
 
Recent changes RSS feed Creative Commons License Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki Hosted by TuxFamily