stdarg.h et ses secrets
Publié le 15 mai 2017 par Thomas Touhey.
Si vous avez lu ma description (voir À propos de moi) 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 :
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 ¶m, 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 = ¶m + 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*)¶m + 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 (voir
À propos de moi). J'en ferai probablement d'autres concernant les
bibliothèques C. :)