Je propose de regarder les internes qui sont derrière les simples lignes d'initialisation des objets, les méthodes d'appel et le passage des paramètres. Et, bien sûr, nous utiliserons ces informations dans la pratique - nous soustraireons la pile de la méthode appelante.
Clause de non-responsabilité
Avant de continuer avec l'histoire, je vous recommande fortement de lire le premier post sur
StructLayout , il y a un exemple qui sera utilisé dans cet article.
Tout le code derrière celui de haut niveau est présenté pour le mode
débogage , car il montre la base conceptuelle. L'optimisation JIT est un grand sujet distinct qui ne sera pas traité ici.
Je tiens également à avertir que cet article ne contient pas de matériel qui devrait être utilisé dans des projets réels.
Première - théorie
Tout code devient finalement un ensemble de commandes machine. Le plus compréhensible est leur représentation sous la forme d'instructions en langage d'assemblage qui correspondent directement à une (ou plusieurs) instructions machine.
Avant de passer à un exemple simple, je vous propose de vous familiariser avec stack.
La pile est principalement un morceau de mémoire qui est généralement utilisé pour stocker différents types de données (généralement, elles peuvent être appelées
données temporelles ). Il convient également de rappeler que la pile se développe vers des adresses plus petites. C'est plus tard qu'un objet est placé sur la pile, moins il aura d'adresse.
Jetons maintenant un coup d'œil sur le prochain morceau de code en langage assembleur (j'ai omis certains des appels inhérents au mode débogage).
C #:
public class StubClass { public static int StubMethod(int fromEcx, int fromEdx, int fromStack) { int local = 5; return local + fromEcx + fromEdx + fromStack; } public static void CallingMethod() { int local1 = 7, local2 = 8, local3 = 9; int result = StubMethod(local1, local2, local3); } }
Asm:
StubClass.StubMethod(Int32, Int32, Int32) 1: push ebp 2: mov ebp, esp 3: sub esp, 0x10 4: mov [ebp-0x4], ecx 5: mov [ebp-0x8], edx 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x10], edx 10: nop 11: mov dword [ebp-0xc], 0x5 12: mov eax, [ebp-0xc] 13: add eax, [ebp-0x4] 14: add eax, [ebp-0x8] 15: add eax, [ebp+0x8] 16: mov [ebp-0x10], eax 17: mov eax, [ebp-0x10] 18: mov esp, ebp 19: pop ebp 20: ret 0x4 StubClass.CallingMethod() 1: push ebp 2: mov ebp, esp 3: sub esp, 0x14 4: xor eax, eax 5: mov [ebp-0x14], eax 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x8], edx 10: xor edx, edx 11: mov [ebp-0x4], edx 12: xor edx, edx 13: mov [ebp-0x10], edx 14: nop 15: mov dword [ebp-0x4], 0x7 16: mov dword [ebp-0x8], 0x8 17: mov dword [ebp-0xc], 0x9 18: push dword [ebp-0xc] 19: mov ecx, [ebp-0x4] 20: mov edx, [ebp-0x8] 21: call StubClass.StubMethod(Int32, Int32, Int32) 22: mov [ebp-0x14], eax 23: mov eax, [ebp-0x14] 24: mov [ebp-0x10], eax 25: nop 26: mov esp, ebp 27: pop ebp 28: ret
La première chose à noter est les registres
EBP et
ESP et les opérations avec eux.
Une idée fausse selon laquelle le registre
EBP est en quelque sorte lié au pointeur vers le haut de la pile est courante chez mes amis. Je dois dire que non.
Le registre
ESP est chargé de pointer vers le haut de la pile. De même, avec chaque instruction
PUSH (en mettant une valeur en haut de la pile), la valeur du registre
ESP est décrémentée (la pile se développe vers des adresses plus petites), et avec chaque instruction
POP elle est incrémentée. De plus, la commande
CALL pousse l'adresse de retour sur la pile, diminuant ainsi la valeur du registre
ESP . En fait, le changement du registre
ESP est effectué non seulement lorsque ces instructions sont exécutées (par exemple, lorsque des appels d'interruption sont effectués, la même chose se produit avec les instructions
CALL ).
Considérera
StubMethod () .
Dans la première ligne, le contenu du registre
EBP est enregistré (il est mis sur une pile). Avant de revenir d'une fonction, cette valeur sera restaurée.
La deuxième ligne stocke la valeur actuelle de l'adresse du haut de la pile (la valeur du registre
ESP est déplacée vers
EBP ). Ensuite, nous déplaçons le haut de la pile sur autant de positions que nécessaire pour stocker les variables et paramètres locaux (troisième ligne). Quelque chose comme l'allocation de mémoire pour tous les besoins locaux -
cadre de pile . Dans le même temps, le registre
EBP est un point de départ dans le contexte de l'appel en cours. L'adressage est basé sur cette valeur.
Tout ce qui précède est appelé
le prologue de la fonction .
Après cela, les variables de la pile sont accessibles via le registre
EBP stocké, qui indique l'endroit où les variables de cette méthode commencent. Vient ensuite l'initialisation des variables locales.
Rappel
Fastcall : en .net, la convention d'appel
fastcall est utilisée.
La convention d'appel régit l'emplacement et l'ordre des paramètres passés à la fonction.
Les premier et deuxième paramètres sont transmis via les registres
ECX et
EDX , respectivement, les paramètres suivants sont transmis via la pile. (C'est pour les systèmes 32 bits, comme toujours. Dans les systèmes 64 bits, quatre paramètres sont passés par des registres (
RCX ,
RDX ,
R8 ,
R9 ))
Pour les méthodes non statiques, le premier paramètre est implicite et contient l'adresse de l'instance sur laquelle la méthode est appelée (cette adresse).
Aux lignes 4 et 5, les paramètres passés par les registres (les 2 premiers) sont stockés sur la pile.
Ensuite, nettoyez l'espace sur la pile pour les variables locales (
cadre de pile ) et initialisez les variables locales.
Il est à noter que le résultat de la fonction est dans le registre
EAX .
Aux lignes 12-16, l'addition des variables souhaitées se produit. J'attire votre attention sur la ligne 15. Il y a une valeur d'accès par l'adresse qui est supérieure au début de la pile, c'est-à-dire à la pile de la méthode précédente. Avant d'appeler, l'appelant pousse un paramètre en haut de la pile. Ici, nous le lisons. Le résultat de l'addition est obtenu à partir du registre
EAX et placé sur la pile. Comme il s'agit de la valeur de retour de
StubMethod () , elle est à nouveau placée dans
EAX . Bien sûr, ces ensembles d'instructions absurdes ne sont inhérents qu'au mode de débogage, mais ils montrent exactement à quoi ressemble notre code sans optimiseur intelligent qui fait la part du lion du travail.
Dans les lignes 18 et 19, à la fois l'
EBP précédent (méthode d'appel) et le pointeur vers le haut de la pile sont restaurés (au moment où la méthode est appelée). La dernière ligne est le retour de la fonction. À propos de la valeur 0x4, je le dirai un peu plus tard.
Une telle séquence de commandes est appelée épilogue de fonction.
Jetons maintenant un coup d'œil à
CallingMethod () . Allons directement à la ligne 18. Ici, nous plaçons le troisième paramètre en haut de la pile. Veuillez noter que nous le faisons en utilisant l'instruction
PUSH , c'est-à-dire que la valeur
ESP est décrémentée. Les 2 autres paramètres sont mis dans des registres (
fastcall ). Vient ensuite l'appel de méthode
StubMethod () . Souvenons-nous maintenant de l'instruction
RET 0x4 . Ici, la question suivante est possible: qu'est-ce que 0x4? Comme je l'ai mentionné ci-dessus, nous avons poussé les paramètres de la fonction appelée sur la pile. Mais maintenant, nous n'en avons plus besoin. 0x4 indique combien d'octets doivent être effacés de la pile après l'appel de fonction. Étant donné que le paramètre était un, vous devez effacer 4 octets.
Voici une image approximative de la pile:

Ainsi, si nous nous retournons et voyons ce qui se trouve sur la pile juste après l'appel de méthode, la première chose que nous verrons
EBP , qui a été poussé sur la pile (en fait, cela s'est produit dans la première ligne de la méthode actuelle). La prochaine chose sera l'adresse de retour. Il détermine l'endroit, là pour reprendre l'exécution une fois notre fonction terminée (utilisée par
RET ). Et juste après ces champs, nous verrons les paramètres de la fonction actuelle (à partir du 3ème, les deux premiers paramètres sont passés par des registres). Et derrière eux se cache la pile de la méthode d'appel!
Les premier et deuxième champs mentionnés précédemment (
EBP et adresse de retour) expliquent le décalage en + 0x8 lorsque nous accédons aux paramètres.
De même, les paramètres doivent être en haut de la pile dans un ordre strictement défini avant l'appel de fonction. Par conséquent, avant d'appeler la méthode, chaque paramètre est poussé dans la pile.
Mais que se passe-t-il s'ils ne poussent pas, et la fonction les prendra quand même?
Petit exemple
Ainsi, tous les faits ci-dessus m'ont causé un désir irrésistible de lire la pile de la méthode qui appellera ma méthode. L'idée que je ne suis que dans une position du troisième argument (il sera le plus proche de la pile de la méthode appelante) est que les données chères que je veux tant recevoir ne m'ont pas laissé dormir.
Ainsi, pour lire la pile de la méthode d'appel, j'ai besoin de monter un peu plus loin que les paramètres.
En se référant aux paramètres, le calcul de l'adresse d'un paramètre particulier est basé uniquement sur le fait que l'appelant les a tous poussés sur la pile.
Mais le passage implicite du paramètre
EDX (qui est intéressé -
article précédent ) me fait penser que nous pouvons déjouer le compilateur dans certains cas.
L'outil que j'ai utilisé pour ce faire s'appelle StructLayoutAttribute (toutes les fonctionnalités sont dans
le premier article ). // Un jour, j'apprendrai un peu plus que cet attribut, je le promets
Nous utilisons la même méthode préférée avec des types de référence qui se chevauchent.
Dans le même temps, si les méthodes qui se chevauchent ont un nombre différent de paramètres, le compilateur ne pousse pas ceux requis sur la pile (du moins parce qu'il ne sait pas lesquels).
Cependant, la méthode qui est réellement appelée (avec le même décalage d'un type différent) se transforme en adresses positives par rapport à sa pile, c'est-à-dire celles où elle prévoit de trouver les paramètres.
Mais personne ne passe les paramètres et la méthode commence à lire la pile de la méthode appelante. Et l'adresse de l'objet (avec la propriété Id, qui est utilisée dans
WriteLine () ) est à l'endroit où le troisième paramètre est attendu.
Le code est dans le spoiler using System; using System.Runtime.InteropServices; namespace Magic { public class StubClass { public StubClass(int id) { Id = id; } public int Id; } [StructLayout(LayoutKind.Explicit)] public class CustomStructWithLayout { [FieldOffset(0)] public Test1 Test1; [FieldOffset(0)] public Test2 Test2; } public class Test1 { public virtual void Useless(int skipFastcall1, int skipFastcall2, StubClass adressOnStack) { adressOnStack.Id = 189; } } public class Test2 { public virtual int Useless() { return 888; } } class Program { static void Main() { Test2 objectWithLayout = new CustomStructWithLayout { Test2 = new Test2(), Test1 = new Test1() }.Test2; StubClass adressOnStack = new StubClass(3); objectWithLayout.Useless(); Console.WriteLine($"MAGIC - {adressOnStack.Id}"); // MAGIC - 189 } } }
Je ne donnerai pas le code du langage d'assemblage, tout est assez clair ici, mais s'il y a des questions, j'essaierai d'y répondre dans les commentaires
Je comprends parfaitement que cet exemple ne peut pas être utilisé dans la pratique, mais à mon avis, il peut être très utile pour comprendre le schéma général de travail.