Thomas 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 :
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 :
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 :
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 :
Finissons les macros facile d’abord, avec la dernière, va_copy(dest, src)
.
Nous avons juste à copier le pointeur :
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 :
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 :
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. J’en ferai probablement d’autres concernant les bibliothèques C. :)