Thomas “Cakeisalie5” Touhey


stdarg.h et ses secrets

Si vous avez lu ma description récemment, vous avez sans doute entendu parler de mon projet de libc, la libcarrot. Avec elle, j’essaye d’offrir du support pour plusieurs compilateurs, y compris ceux dont je ne me sers pas vraiment, pour tenter de comprendre leur usage avancé et ce qu’ils ont en commun. Eh bien, il y a peu, j’ai tenté de récolter et de comprendre toutes les informations que j’ai pu avoir au sujet des listes d’arguments variables (variable argument lists) et de leurs macros, toutes étant déclarées dans un en-tête standardisé, stdarg.h (qui remplace le varargs.h du K&R C).

Tout d’abord, un rappel.

Vous utilisez des listes d’arguments variables aussitôt que le dernier argument de votre fonction est ..., par exemple :

int printf(const char *format, ...);

Le compilateur mettra les arguments après les arguments “normaux”, en utilisant une méthode non-standardisée. Une fois dans la fonction, vous ne savez rien de ces arguments, ni leur nombre, ni leurs types ou valeurs, vous devez déduire ces informations des arguments “normaux” : par exemple, si vous passez "%d %s" à printf, la fonction saura que la liste que vous lui avez envoyée est faite d’un int puis d’un char* (vous pouvez ajouter d’autres arguments après ceux-ci, printf n’aura aucune idée de leur existence).

Du coup, comment lire les arguments de la liste si vous ne savez pas comment ses éléments y sont agencés ? C’est sur ce point que les macros de stdarg.h interviennent. Au début de la fonction, définissez une variable de type va_list (les miennes sont généralement nommées ap pour “argument pointer”, mais vous pouvez aussi les appeler args, alst ou autre). Alors, vous pouvez initialiser la liste en utilisant va_start(ap, last);, où last est le nom du dernier argument avant les .... Puis, pour chaque argument, vous devrez le lire en utilisant va_arg(ap, type) (qui va retourner l’argument du type que vous lui avez demandé). Enfin, vous désinitialisez la liste en utilisant va_end(ap).

Faisons un exemple, une fonction sum_integers qui va prendre une certaine quantité d’entiers, les additionner, puis retourner le résultat. Mais voilà déjà un problème : même si nous supposons que l’utilisateur ne va nous passer que des int, nous ne savons pas combien il va nous en passer ! En réalité, ce n’est pas un problème, nous pouvons juste le lui demander. Ce qui nous donne cette fonction :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdarg.h>

int sum_integers(int num, ...)
{
	int result = 0;
	va_list ap;

	va_start(ap, num);
	while (num--)
		result += va_arg(ap, int);
	va_end(ap);

	return (result);
}

Plutôt simple, non ? Mais les listes d’arguments variables sont une abstraction avec très peu d’opérations. Si vous connaissez le champ de paramètre du format utilisé par printf, vous vous demandez probablement comment ils peuvent aller chercher un paramètre à n’importe quelle position dans la liste d’arguments. Y a-t-il une opération qui vous permet d’aller à n’importe quelle position dans la liste, comme pour les tableaux ? Non, les listes d’arguments variables sont un type itérable.

Mais il y a bel et bien une quatrième opération sur les listes variables d’arguments, bien qu’elle ne soit officiellement apparue qu’en C99 : il s’agit de va_copy(dest, source) (dans des libc ne respectant pas ce standard, il était parfois possible d’accéder à __va_copy(dest, source). Cette opération vous permet de copier la liste d’arguments variable depuis la position où elle est. Donc si vous initialisez ap0 avec va_start, que vous la copiez dans ap1 en utilisant va_copy, puis que vous lisez un argument sur ap0 en utilisant va_arg, la prochaine lecture sur ap0 sera le second argument, et la prochaine lecture sur ap1 sera le premier argument !

Toujours est-il que vous avez besoin de connaître le type des différents arguments avant celui que vous souhaitez lire. Je me demandais comment le printf de la GNU C Library sur mon PC sous x86_64 gérait ça. J’ai donc essayé les cas suivants :

printf("%2$d\n", (double)43, (int)4);
printf("%2$d %1$lf\n", (double)43, (int)4);

Et comme prévu, le premier cas a affiché 1188616344. C’est probablement parce que l’implémentation de GNU a supposé que le premier champ, dont il ne connaissait pas le type, était un int, mais comme les types double et int ne sont pas de la même taille, la fonction n’arrive pas à passer le premier argument correctement et affiche la seconde moitié du premier argument, l’interprétant comme un int. Cependant, dans le second test, 4 43.000000 est affiché avec succès, et c’est probablement parce que le printf de GNU lit le reste de la chaîne pour tenter de récupérer les types des premiers arguments pour les passer correctement. Bien joué les gars !

Les “conventions d’appel”, vous connaissez ?

Les conventions d’appels concernent comment la façon dont les fonctions sont appelées, dans quel ordre, comment les arguments sont passés dans le langage en-dessous (par exemple, l’assembleur, ou n’importe quel autre langage sous la représentation intermédiaire du compilateur), et caetera. Utiliser une bibliothèque compilée en utilisant une convention d’appel avec un programme compilé qui en utilise une autre ne va probablement pas marcher comme “prévu”, puisque la façon dont les arguments sont envoyés et celle dont ils sont lus sont probablement incohérents, et c’est pourquoi il est préférable d’utiliser la même convention d’appel par plateforme, ou au moins proposer des options pour adopter d’autres conventions d’appel pour la plateforme.

Par exemple, l’option -mhitachi/-mrenesas de GCC le force à adopter la convention d’appel de Renesas, là où par défaut, il utilise celle que les développeurs de GCC ont mis au point avant que celle de Renesas ne sorte. Si vous utilisez des bibliothèques compilées avec la convention d’appel de Renesas, et que votre programme est compilé avec la convention d’appel de GNU (choisie par défaut), tout ne se passera probablement pas comme prévu.

Mais retournons dans le vif du sujet, puisque la façon dont le compilateur gère les listes d’arguments variables fait partie de la convention d’appel, et c’est ceci qu’on souhaite adapter.

Maintenant, implémentons ce header.

De tous les compilateurs pour lesquels je voulais implémenter stdarg.h, pour certains, j’avais au moins certains headers de leur libc de base, pour d’autres, j’avais même une documentation décrivant ce qu’il fallait utiliser (built-ins, convention d’appel).

Premièrement, certains compilateurs proposent des built-ins, tels que __builtin_va_arg ou __builtin_stdarg_start, pour que vous n’ayiez pas à vous soucier de ce qui se passe en-dessous du capot. Parmi ces compilateurs se trouvent GCC et les compilateurs compatibles GCC (clang, Intel C Compiler), ainsi que le Portable C/C++ Compiler.

Puis, nous avons les autres, qui vous obligent à comprendre leur calling convention si vous n’utilisez pas leur bibliothèque (c’est à cause d’eux que j’écris cet article).

Même si le standard dit qu’il peut y avoir des implémentations utilisant des listes à deux dimensions ou autres trucs farfelus, la seule méthode (avec variations) que j’ai rencontrée jusqu’à présent, c’était d’empiler les arguments sur la stack après les arguments “normaux”. Parfois, il y a de l’alignement, comme avec le Renesas C/C++ Compiler qui aligne chaque argument à 4 octets, et parfois, la liste grandit vers le bas au lieu de grandir vers le haut, comme pour SDCC pour DS-390 (au-delà de ne proposer aucun built-in, l’implémentation des listes d’arguments variables de SDCC varie d’une architecture à une autre).

Pour notre architecture virtuelle, supposons que nous utilisons une liste qui grandit vers le haut avec aucun alignement, donc notre type va_list sera simplement un pointeur sur char :

typedef char* va_list;

Maintenant, concentrons-nous sur va_start(ap, param). Habituellement, quand le compilateur ne propose aucun built-in pour cette macro, il est possible avec lui de prendre l’adresse d’un paramètre, et c’est ce que nous allons utiliser, en prenant &param, en ajoutant la taille de ce paramètre, sizeof(param), et en affectant le résultat à ap. Le problème, c’est que cette macro doit se comporter comme une fonction qui ne retourne rien (le type de retour doit être void), donc nous devons caster l’affectation en void. Voici notre macro finale :

#define va_start(ap, param) (void)(ap = &param + sizeof(param))

Maintenant, regardons pour va_end(ap). Nous pourrions juste la définir en tant que rien, puisque nous n’avons rien à déinitialiser, mais nous allons tout de même assigner NULL (valeur de pointeur invalide) à notre pointeur utilisateur :

#define va_end(ap) (void)(ap = NULL)

Finissons les macros facile d’abord, avec la dernière, va_copy(dest, src). Nous avons juste à copier le pointeur :

#define va_copy(dest, src) (void)(dest = src)

Et maintenant, la macro un peu plus compliquée, va_arg(ap, type). Ce que nous voulons faire ici, c’est retourner notre pointeur actuel, puis lui ajouter la taille du type, mais nous ne pouvons pas le faire dans cet ordre puisque nous faisons une simple expression. Donc nous devrons ajouter la taille du type d’abord, puis retourner la valeur précédente du pointeur, que nous connaissons : il s’agit de la valeur après la somme, moins la taille du type courant ! Nous pouvons à présent faire les opérations, tout en n’oubliant pas de caster le résultat :

#define va_arg(ap, type) (*(type*)((ap = ap + sizeof(type)) - sizeof(type)))

Tant qu’à faire, il y a aussi la façon de faire de la bibliothèque du Turbo C/C++ Compiler, qui est légèrement plus courte et joue sur le fait que ++ à droite de la variable l’incrémente après lecture :

#define va_arg(ap, type) (*((type*)ap)++)

Comme vous pouvez le voir, ce n’est pas si compliqué. Ce qui peut être un peu plus compliqué est la façon dont on gère l’alignement, mais j’utilise va_ceil et va_floor pour avoir les adresses alignées à 4 octets. Voici mon implémentation pour le Renesas C/C++ Compiler, que vous devriez pouvoir comprendre maintenant que vous avez toutes les clés en main :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef char* va_list;

#define __va_ceil(addr) \
	(va_list)(((uintptr_t)(addr) & ~3) + ((uintptr_t)(addr) & 3) ? 4 : 0)
#define __va_floor(addr) \
	(va_list) ((uintptr_t)(addr) & ~3)

#define va_start(ap, param) \
	(void)(ap = __va_ceil((char*)&param + sizeof(param)))
#define va_end(_AP) \
	(void)(ap = NULL)

#define va_arg(ap, type) \
	(*(type*)__va_floor((ap = __va_ceil(ap + sizeof(type))) - sizeof(type)))

À présent, vous devriez pouvoir deviner pourquoi le printf de GNU avait des soucis pour lire le second argument sans connaître le type du premier.

Concluons.

Maintenant que vous en savez un peu plus au sujet de stdarg.h, vous devriez chercher davantage d’informations sur les conventions d’appel, notamment pour votre plateforme (Microsoft Windows pour x86, conventions d’appel standards pour SuperH, etc), et voir si votre compilateur utilise les conventions d’appel appropriées pour la plateforme.

Si vous avez aimé cet article, n’hésitez pas à me le faire savoir sur mes réseaux sociaux, trouvables sur ma page de présentation. J’en ferai probablement d’autres concernant les bibliothèques C. :)