Gestion des erreurs en langage C

Palaiseau, le mercredi 21 avril 2021

Cher Journal,

le langage C en lui-même n'a pas de mécanisme de gestion d'erreur, là où d'autres langages comme le C++, Java, ou Python, vont proposer typiquement des directives try, catch, except, etc, accompagnées de types dédiés. Je pourrais m'étendre sur les défauts du langage C, et il y en a quand même pas mal, surtout si on contraste avec le langage Rust, qui a été écrit dans le but de remplacer le C là où ses défauts deviennent critiques, mais ce n'est pas le but de l'article.

Au lieu de mécanismes implantés directement dans le langage, le C intègre une bibliothèque standard, dans laquelle les fonctions s'accordent sur des conventions. Pour la gestion des erreurs, une des conventions les plus intéressantes est la variable errno, documentée dans la page de manuel éponyme, section 3. Cette variable est utilisée pour décrire le type d'erreur système rencontrée par un appel qui n'a pas pu terminer comme attendu. Si une telle fonction échoue, alors en fonction de son implémentation, elle va retourner des valeurs particulières, en général inattendues lors du fonctionnement normal de la fonction. Ce peut être :

À noter cependant, d'après le manuel, des fonctions peuvent tout à faire régler la valeur de l'errno sans pour autant avoir fini en erreur, donc le réglage de l'errno arrive normalement en complément d'un des deux comportement mentionnés précédemment. Vu le comportement d'errno comme variable globale, je me suis demandé ce qu'il se passait dans les programmes a multiples fils d'exécution. À en juger par le manuel, la valeur est propre au fil, donc il n'y a pas de risques de collisions de ce côté là, heureusement.

Je me suis développé une petite fonction sympathique pour détecter et rapporter, rapidement mais grossièrement, quick & dirty comme on dit outre atlantique, les erreurs systèmes faisant usage d'errno. C'est la fonction or_die ci-dessous, directement inspirée de la fonction die du langage Perl :

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

/* or_die prints out a message similarly to perror, but with support for
 * formatting, standardized "error: " prefix, errno message postfix, and
 * then immediately stop the program in error.
 */
void
or_die(const char *fmt, ...)
{
	int errcode = errno;        /* immediately save errno for future use */
	va_list ap;                 /* macro storing variadic arguments      */
	if (errno == 0) return;     /* do nothing when errno reports Success */
	fflush(stdout);             /* force showing remaining stdout        */
	fprintf(stderr, "error: "); /* prepend "error: " to the message      */
	va_start(ap, fmt);          /* start use of variadic arguments       */
	vfprintf(stderr, fmt, ap);  /* print formatted output to stderr      */
	va_end(ap);                 /* end use of variadic arguments         */
	fprintf(stderr, ": ");      /* add colon for the upcoming notice     */
	errno = errcode;            /* reset errno (*printf may erase it)    */
	perror("");                 /* print message matching errno          */
	exit(errcode);              /* end program with same status as errno */
}

/* main section of program illustrating or_die function usage  */
int
main(int argc, char *argv[])
{
	int *addr;
	size_t mem = 256;
	size_t gb = 1024 * 1024 * 1024;

	addr = calloc(mem, gb);
	or_die("allocating %lu GB of memory", mem);

	printf("%p -> %d\n", addr, *addr);
	return (0);
}

Sur un système comme le mien, où l'allocation excédentaire de mémoire n'est pas autorisée, le programme va planter avec un message qui dit que tenter d'allouer 256 Gio de mémoire, ce n'est vraiment pas raisonnable :

$ ./a.out 
error: allocating 256 GB of memory: Cannot allocate memory

Sans le contrôle d'erreur, le programme en C suit sont cours malgré la panne qui s'est manifestée lors de l'appel à calloc(3). Le résultat immédiat à l'exécution, est que la tentative d'accès à *addr dans l'appel de la fonction printf(3) va provoquer une erreur de segmentation. Cela se présente avec le message d'erreur suivant qui est tout de suite moins parlant pour le quidam :

$ ./a.out 
Segmentation fault

$ echo $?
139

Attention : d'après le manuel d'errno(3), la valeur n'est jamais remise à zéro. Si des contrôles de type or_die sont effectués dans le programme, c'est-à-dire sans vérifier les codes de retour auxiliaires de la fonction dont on veut surveiller le comportement, alors il est tout à fait possible de rapporter des erreurs provoquées par des appels de fonctions antérieures. J'ai déjà eu un plantage déclenché par un or_die sur un appel à la fonction free(3), et dont le errno provenait en réalité d'un plantage d'un appel antérieur à madvise(2) que je n'avais pas vérifié. Ma petite fonction or_die, ou les fonctions de la bibliothèque standard. err(3), ou error, sont donc bien pratiques, mais bien souvent insuffisantes pour le naïf que je suis.

La fonction error(3), une extension Gnu de la bibliothèque standard, permet de faire peu ou prou la même chose que or_die, en permettant d'adapter le code de retour du programme pour le Shell en fonction de l'erreur rencontrée. Bien évidemment, j'ai découvert comment m'en servir en rédigeant cette entrée, et dans beaucoup de cas, je peux gérer les erreurs de manière très expressives, juste en me tenant à l'usage des fonctions de la bibliothèque standard. En recyclant l'exemple de l'allocation excessive de mémoire, cela donne un code un peu moins compact, mais un peu plus expressif, et même moins dangereux :

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

/* main section of program illustrating or_die function usage  */
int
main(int argc, char *argv[])
{
	int *addr;
	size_t mem = 256;
	size_t gb = 1024 * 1024 * 1024;
	int code = 0;

	addr = calloc(mem, gb);
	if (addr == NULL) {
		code = errno;
		error(code, errno, "allocating %lu GB of memory", mem);
	}

	printf("%p -> %d\n", addr, *addr);
	return (0);
}

À la limite, rien ne m'empêche de définir une fonction or_die_on_nullptr, sur le modèle de or_die, histoire de rendre ces contrôles plus robustes, mais je suis d'accord pour dire qu'on est loin d'un try & catch. Pour référence, le message d'erreur devient le suivant ; pour des raisons que j'ai évoqué il y a quelque mois, la chaine de caractères “error: ” me manque un peu, tout de même :

$ ./a.out 
./a.out: allocating 256 GB of memory: Cannot allocate memory

La fonction error_at_line(3), que j'ai découvert en même temps que error, est également extrêmement pratique, notamment pour identifier, dans le code du programme, grâce aux macros __FILE__ et __LINE__, ou de manière un peu plus utile dans un analyseur syntaxique, d'où vient l'erreur précisément :

error_at_line(code, errno, __FILE__, __LINE__,
              "allocating %lu GB of memory", mem);
$ ./a.out 
./a.out:example.c:18: allocating 256 GB of memory: Cannot allocate memory

Pouvoir user et abuser de ces fonctions est une bénédiction. Du côté utilisation, cela permet d'avoir un comportement similaire entre toute une famille de programmes. Du côté développement, cela permet de ne pas avoir à trop se casser la tête sur la détection et le rapport des erreurs. Par contre, je trouve qu'il est très dommage qu'il n'y ait pas plus de publicité sur ce genre de fonctions. Notamment, interpréter correctement errno permet d'établir des stratégies de contournement quand le programme rencontre des situations d'erreur inattendues, comme un disque plein, de la mémoire insuffisante, ou des accès restreints.

En bonus, je laisse un autre exemple de fonction utilisant les macros variadic, documentées dans stdarg(3). Cette fonction printerr est souvent pratique, même si elle ne casse pas trois pattes à un canard :

#include <stdarg.h>
#include <stdio.h>

/* printerr behaves like printf, but prints out to stderr */
int
printerr(const char *fmt, ...)
{
	int code;
	va_list ap;
	va_start(ap, fmt);
	code = vfprintf(stderr, fmt, ap);
	va_end(ap);
	return (code);
}

Dans tous les cas, pour traiter correctement chaque cas d'erreur pour chaque fonction, il est nécessaire de se référer au manuel pour connaître les comportements en cas d'erreur. Ces comportements sont différents d'une fonction à l'autre.

[ICO]NameLast modifiedSize
[PARENTDIR]Parent Directory  -

  —