Adresse d'une variable automatique et appel terminal
Le
Manuel Pégourié-Gonnard

Bonjour,
On sait bien que c'est une mauvaise idée pour une fonction de retourner
l'adresse d'une variable automatique, car l'objet correspondant aura
cessé de vivre à la fin de la fonction.
Par contre, je me demande si c'est légal et portable de passer l'adresse
d'une variable automatique en argument à une autre fonction, en
particulier quand l'appel est terminal. Par exemple :
#include <stdio.h>
void bar(int *n) {
printf("%i", *n);
}
void foo(void) {
int a = 42;
bar(&a);
}
int main(void) {
foo();
return 0;
}
Il me semble que, l'appel à bar() étant terminal, le compilo devrait
l'optimiser et réutiliser la stack frame de foo() pour bar(), terminant
ainsi la vie de la variable 'a' au moment de l'appel (saut) à bar(), et
que donc les choses devraient mal se passer.
Pourtant, si je fais l'essai, ça « marche » : le compilo (gcc -ansi
-pedantic -Wall -Wextra -O2) ne bronche pas et à l'exécution, ça écrit
bien 42.
En fait, si j'examine l'assembleur généré, je me rends compte qu'avec
-O0 l'appel récursif n'est pas optimisé, et qu'à partir de -O1, foo et
bar sont inlinés, de sorte que le problème ne se pose plus. Dans la
situation un peu plus complexe où je me posai initialement la question,
à partir de -O1 aussi une des fonctions en jeu est inlinée.
Est-ce juste un hasard que dans les situations où j'ai testé, avec le
compilateur et les options que j'ai essayées, ça « marche » à tous les
coup, ou pas ?
--
Manuel Pégourié-Gonnard - http://people.math.jussieu.fr/~mpg/
On sait bien que c'est une mauvaise idée pour une fonction de retourner
l'adresse d'une variable automatique, car l'objet correspondant aura
cessé de vivre à la fin de la fonction.
Par contre, je me demande si c'est légal et portable de passer l'adresse
d'une variable automatique en argument à une autre fonction, en
particulier quand l'appel est terminal. Par exemple :
#include <stdio.h>
void bar(int *n) {
printf("%i", *n);
}
void foo(void) {
int a = 42;
bar(&a);
}
int main(void) {
foo();
return 0;
}
Il me semble que, l'appel à bar() étant terminal, le compilo devrait
l'optimiser et réutiliser la stack frame de foo() pour bar(), terminant
ainsi la vie de la variable 'a' au moment de l'appel (saut) à bar(), et
que donc les choses devraient mal se passer.
Pourtant, si je fais l'essai, ça « marche » : le compilo (gcc -ansi
-pedantic -Wall -Wextra -O2) ne bronche pas et à l'exécution, ça écrit
bien 42.
En fait, si j'examine l'assembleur généré, je me rends compte qu'avec
-O0 l'appel récursif n'est pas optimisé, et qu'à partir de -O1, foo et
bar sont inlinés, de sorte que le problème ne se pose plus. Dans la
situation un peu plus complexe où je me posai initialement la question,
à partir de -O1 aussi une des fonctions en jeu est inlinée.
Est-ce juste un hasard que dans les situations où j'ai testé, avec le
compilateur et les options que j'ai essayées, ça « marche » à tous les
coup, ou pas ?
--
Manuel Pégourié-Gonnard - http://people.math.jussieu.fr/~mpg/
pourrait, les optimisations sont rarement obligatoires,
La durée de vie logique de a va jusqu'au retour de foo, le compilateur ne
peut pas (sous peine de ne plus être conforme), la réduire. Et comme la
transformation d'un appel terminal en saut n'est pas une optimisation
classique des compilateurs C, ne pas être conforme sur ce point poserait
des problèmes.
A+
--
Jean-Marc
FAQ de fclc: http://www.levenez.com/lang/c/faq
Site de usenet-fr: http://www.usenet-fr.news.eu.org
on voit bien la tail-recursion se mettre en place seulement si elle
est raisonnable.
foo:
.LFB11:
subq $24, %rsp
.LCFI0:
leaq 20(%rsp), %rdi
movl $42, 20(%rsp)
call bar
addq $24, %rsp
ret
et si je declare a comme static, hop d'un coup:
foo:
.LFB11:
movl $a.2536, %edi
jmp bar
Et pour repondre a ta question. Oui, bien sur, ton code est valide.
Les optimisations n'entrent pas vraiment en compte dans la norme.
Plus precisement: le compilo a le droit d'optimiser tout ce qu'il veut:
1/ tant qu'il ne perd pas la validite d'un code conforme;
2/ tant qu'il ne sort pas de la description de ses comportements "definis
par l'implementation"
3/ tant qu'il ne modifie pas un comportement observable.
Le 1/ etant particulierement piegant, obeissant au vieil adage
"Garbage In, garbage out": un compilo devrait sortir un diagnostic sur
du code non-conforme, s'il peut --- pas toujours decidable, typiquement pour
les problemes d'intervalles de valeur, mais il a le droit de degainer des
optimisations qui ne sont valides que sur du code conforme, puisque justement,
un code non conforme, par definition, va faire n'importe quoi... ;)
Même quand l'appel est terminal, la fin de la fonction c'est lorsqu'elle
revient à son appelant (éventuellement au travers du RET d'une autre
fonction), pas lorsqu'elle saute à la fonction terminale.
Réutiliser le cadre de pile ne signifie pas forcément le détruire...
Cela dépend évidemment des architectures (ABI), mais tu peux parfois
élargir le cadre de pile, à charge pour la fonction terminale, à _sa_
sortie, de détruire l'ensemble du cadre, l'extension comme la base.
Le compilateur sait qu'il doit générer la destruction du cadre de pile,
c'est dans son arbre; ainsi, dans un compilateur minimaliste, s'il y a
nécessité de destruction de cadre de pile, un 'return' intermédiaire va
générer un saut vers l'instruction finale de destruction, tandis que
s'il n'y en a pas le compilateur peut placer un simple RET.
Donc, s'il sait que cette instruction doit être exécutée, et s'il y a
appel terminal et qu'il y ait possibilité d'optimisation, quand le
compilateur réordonne (intervertit) les deux opérations il se doit de
vérifier que cela n'invalide pas quelque invariant ; un oubli à ce
niveau serait clairement un bogue du compilateur.
Antoine
C'est exactement comme ça que fonctionnait le cross-compilateur
68k de Whitesmiths. Sjmsb, on pouvait lui donner des limites à
l'entassement des stackframes qu'il était autorisé à faire avant
de "nettoyer" la pile. Et en 89/91, vu la taille des RAMs, la
jonglerie était parfois nécessaire :)
--
Nous vivons dans un monde étrange/
http://foo.bar.quux.over-blog.com/
Ah, en fait je pense que ma confusion est plus en cause que l'âge de ton
gcc : je réalise en relisant que le code donc j'avais examiné la sortie
en assembleur n'est pas précisément celui que j'ai posté, la différence
étant que foo et bar étaient static dans mon code. Ce qui semble une
explication plausible à la différence observée. (Encore que le compilo
pourrait peut-être décider d'inliner certains appels à ces fonctions
*et* d'en garder une version non inlinée pour lier avec le reste du
code, j'imagine.)
Ah, bien vu, je cherchais un moyen de voir si le compilo évitait
volontairement cette optimisation dans ce cas, je n'avais pas pensé à
déclarer a comme static pour voir.
Ok, c'est très clair. Merci à toi, Jean-Marc et Antoine pour vos réponses.
En fait, comme l'optimisation des appels terminaux me parait
particulièrement importante, je craignais qu'il n'y ait une exception (à
la durée de vie des variables) prévue pour la faciliter. Pour une fois,
les choses sont claires et la durée de vie est celle qu'on attend dans
tous les cas, ce n'est pas moi qui vait m'en plaindre.
--
Manuel Pégourié-Gonnard - http://people.math.jussieu.fr/~mpg/
Oui, totalement.
D'ailleurs c'est pour ca qu'on n'a pas de fonctions static dans le
noyau d'OpenBSD: le compilo pourrait les optimiser violemment, et on
prefere avoir un truc un peu moins optimal, mais plus simple a debugguer
en cas de panic().