web-dev-qa-db-fra.com

Pourquoi cet appel de fonction se comporte-t-il sensiblement après l'avoir appelé via un pointeur de fonction transtypé?

J'ai le code suivant. Il existe une fonction qui prend deux int32. Ensuite, je prends un pointeur vers lui et le transforme en une fonction qui prend trois int8 et l'appelle. Je m'attendais à une erreur d'exécution mais le programme fonctionne bien. Pourquoi est-ce même possible?

main.cpp:

#include <iostream>

using namespace std;

void f(int32_t a, int32_t b) {
    cout << a << " " << b << endl;
}

int main() {
    cout << typeid(&f).name() << endl;
    auto g = reinterpret_cast<void(*)(int8_t, int8_t, int8_t)>(&f);
    cout << typeid(g).name() << endl;
    g(10, 20, 30);
    return 0;
}

Production:

PFviiE
PFvaaaE
10 20

Comme je peux le voir, la signature de la première fonction nécessite deux ints et la deuxième fonction nécessite trois caractères. Char est plus petit qu'int et je me suis demandé pourquoi a et b sont toujours égaux à 10 et 20.

35
Divano

Comme d'autres l'ont souligné, il s'agit d'un comportement indéfini, donc tous les paris sont désactivés sur ce qui peut en principe se produire. Mais en supposant que vous êtes sur une machine x86, il y a une explication plausible pour expliquer pourquoi vous voyez cela.

Sur x86, le compilateur g ++ ne passe pas toujours les arguments en les poussant sur la pile. Au lieu de cela, il stocke les premiers arguments dans les registres. Si nous démontons la fonction f, notez que les premières instructions déplacent les arguments hors des registres et explicitement dans la pile:

    Push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     DWORD PTR [rbp-4], edi  # <--- Here
    mov     DWORD PTR [rbp-8], esi  # <--- Here
    # (many lines skipped)

De même, notez comment l'appel est généré dans main. Les arguments sont placés dans ces registres:

    mov     rax, QWORD PTR [rbp-8]
    mov     edx, 30      # <--- Here
    mov     esi, 20      # <--- Here
    mov     edi, 10      # <--- Here
    call    rax

Étant donné que le registre entier est utilisé pour contenir les arguments, la taille des arguments n'est pas pertinente ici.

De plus, étant donné que ces arguments sont transmis via des registres, il n'y a aucun problème à redimensionner la pile de manière incorrecte. Certaines conventions d'appel (cdecl) laissent l'appelant faire le nettoyage, tandis que d'autres (stdcall) demandent à l'appelé de faire le nettoyage. Cependant, ni l'un ni l'autre n'a vraiment d'importance ici, car la pile n'est pas touchée.

36
templatetypedef

Comme d'autres l'ont souligné, c'est probablement comportement indéfini, mais les programmeurs C de la vieille école savent que ce type de chose fonctionne.

De plus, comme je peux sentir la langue dans laquelle les avocats rédigent leurs documents de litige et leurs requêtes judiciaires pour ce que je vais dire, je vais lancer un sort de undefined behavior discussion. Il est jeté en disant undefined behavior trois fois en tapant mes chaussures ensemble. Et cela fait disparaître les avocats de la langue afin que je puisse expliquer pourquoi des choses étranges se produisent simplement sans être poursuivies.

Retour à ma réponse:

Tout ce que je discute ci-dessous est un comportement spécifique au compilateur. Toutes mes simulations sont avec Visual Studio compilé en code x86 32 bits. Je soupçonne que cela fonctionnera de la même manière avec gcc et g ++ sur une architecture 32 bits similaire.

Voici pourquoi votre code fonctionne et quelques mises en garde.

  1. Lorsque les arguments d'appel de fonction sont poussés dans la pile, ils sont poussés dans l'ordre inverse. Lorsque f est invoqué normalement, le compilateur génère du code pour pousser l'argument b sur la pile avant l'argument a. Cela permet de faciliter les fonctions d'arguments variadiques telles que printf. Ainsi, lorsque votre fonction, f accède à a et b, elle accède simplement aux arguments en haut de la pile. Lorsqu'il était invoqué via g, un argument supplémentaire était poussé vers la pile (30), mais il a été poussé en premier. 20 a été poussé ensuite, suivi de 10 qui est en haut de la pile. f ne regarde que les deux premiers arguments de la pile.

  2. L'IIRC, au moins dans les classiques ANSI C, caractères et shorts, est toujours promu int avant d'être placé sur la pile. C'est pourquoi, lorsque vous l'invoquez avec g, les littéraux 10 et 20 sont placés sur la pile sous forme d'entiers de taille normale au lieu de 8 bits. Cependant, au moment où vous redéfinissez f pour prendre des longs 64 bits au lieu de 32 bits, la sortie de votre programme change.

    void  f(int64_t a, int64_t b) {
        cout << a << " " << b << endl;
    }

Cela se traduit par la sortie de votre main (avec mon compilateur)

85899345930 48435561672736798

Et si vous convertissez en hexadécimal:

140000000a effaf00000001e

14 est 20 et 0A est 10. Et je soupçonne que 1e est ton 30 être poussé vers la pile. Ainsi, les arguments ont été poussés vers la pile lors de leur appel via g, mais ont été munis d'une manière spécifique au compilateur. (comportement indéfini à nouveau, mais vous pouvez voir les arguments poussés).

  1. Lorsque vous appelez une fonction, le comportement habituel est que le code appelant corrige le pointeur de pile au retour d'une fonction appelée. Encore une fois, c'est pour le bien des fonctions variadiques et pour d'autres raisons héritées de la compatibilité avec K&R C. printf n'a aucune idée du nombre d'arguments que vous lui avez réellement transmis, et il dépend de l'appelant pour réparer la pile quand il Retour. Ainsi, lorsque vous avez appelé via g, le compilateur a généré du code pour envoyer 3 entiers à la pile, appeler la fonction, puis coder pour supprimer ces mêmes valeurs. Au moment où vous modifiez votre option de compilation pour que l'appelé nettoie la pile (ala __stdcall sur Visual Studio):
    void  __stdcall f(int32_t a, int32_t b) {
        cout << a << " " << b << endl;
    }

Maintenant, vous êtes clairement en territoire de comportement indéfini. L'invocation via g a poussé trois arguments int sur la pile, mais le compilateur n'a généré que du code pour f pour faire sortir deux arguments int de la pile lors de son retour. Le pointeur de pile est corrompu au retour.

9
selbie

Comme d'autres l'ont souligné, c'est un comportement entièrement indéfini, et ce que vous obtiendrez dépendra du compilateur. Cela ne fonctionnera que si vous avez une convention d'appel spécifique, qui n'utilise pas la pile mais s'inscrit pour passer les paramètres.

J'ai utilisé Godbolt pour voir l'Assemblée générée, que vous pouvez vérifier en entier ici

L'appel de fonction correspondant est ici:

mov     edi, 10
mov     esi, 20
mov     edx, 30
call    f(int, int) #clang totally knows you're calling f by the way

Il ne pousse pas les paramètres sur la pile, il les met simplement dans des registres. Ce qui est le plus intéressant, c'est que l'instruction mov ne modifie pas seulement les 8 bits inférieurs du registre, mais tous car il s'agit d'un déplacement de 32 bits. Cela signifie également que peu importe ce qui était dans le registre auparavant, vous obtiendrez toujours la bonne valeur lorsque vous relisez 32 bits comme le fait f.

Si vous vous demandez pourquoi le déplacement 32 bits, il s'avère que dans presque tous les cas, sur une architecture x86 ou AMD64, les compilateurs utiliseront toujours des déplacements littéraux 32 bits ou des déplacements littéraux 64 bits (si et seulement si la valeur est trop grande). pour 32 bits). Le déplacement d'une valeur de 8 bits ne remet pas à zéro les bits supérieurs (8-31) du registre et peut créer des problèmes si la valeur finissait par être promue. L'utilisation d'une instruction littérale 32 bits est plus simple que d'avoir une instruction supplémentaire pour mettre à zéro le registre en premier.

Une chose dont vous devez vous souvenir est qu'il essaie vraiment d'appeler f comme s'il avait des paramètres à 8 bits, donc si vous mettez une grande valeur, il tronquera le littéral. Par exemple, 1000 va devenir -24, comme les bits inférieurs de 1000 sont E8, lequel est -24 lors de l'utilisation d'entiers signés. Vous recevrez également un avertissement

<source>:13:7: warning: implicit conversion from 'int' to 'signed char' changes value from 1000 to -24 [-Wconstant-conversion]
1
meneldal

Le premier compilateur C, ainsi que la plupart des compilateurs qui ont précédé la publication de la norme C, traiteraient un appel de fonction en poussant les arguments dans l'ordre de droite à gauche, utiliseraient l'instruction "sous-routine d'appel" de la plate-forme pour appeler la fonction, puis puis après le retour de la fonction, pop tous les arguments ont été poussés. Les fonctions attribueraient des adresses à leurs arguments dans un ordre séquentiel commençant juste après les informations poussées par l'instruction "call".

Même sur des plates-formes telles que le Macintosh classique où la responsabilité de l'explosion d'arguments incomberait normalement à la fonction appelée (et où le fait de ne pas pousser le bon nombre d'arguments corromprait souvent la pile), les compilateurs C utilisaient généralement une convention d'appel qui se comportait comme la première Compilateur C. Un qualificatif "Pascal" était nécessaire lors de l'appel ou sur des fonctions appelées par du code écrit dans d'autres langages (comme Pascal).

Dans la plupart des implémentations du langage qui existaient avant la norme, on pouvait écrire une fonction:

int foo(x,y) int x,y
{
  printf("Hey\n");
  if (x)
  { y+=x; printf("y=%d\n", y); }
}

et l'invoquer comme par ex. foo(0) ou foo(0,0), la première étant légèrement plus rapide. Tenter de l'appeler par exemple foo(1); corromprait probablement la pile, mais si la fonction n'utilisait jamais d'objet y il n'était pas nécessaire de le transmettre. Cependant, la prise en charge d'une telle sémantique n'aurait pas été pratique sur toutes les plates-formes, et dans la plupart des cas, les avantages de la validation des arguments l'emportent sur le coût, de sorte que la norme n'exige pas que les implémentations soient capables de prendre en charge ce modèle, mais autorise celles qui peuvent prendre en charge le modèle. pour étendre la langue de manière pratique.

0
supercat