Je suggère de regarder tout ce qui se cache derrière de simples lignes d'initialisation d'objets, d'appeler des méthodes et de passer des paramètres. Bien sûr, l'utilisation de ces informations dans la pratique soustrait la pile de la méthode d'appel.
Clause de non-responsabilité
Avant de commencer l'histoire, je vous recommande fortement de lire le premier post sur
StructLayout , car il y a un exemple qui sera utilisé dans cet article.
Tout le code derrière le niveau supérieur est présenté pour le mode
débogage , c'est lui qui montre la base conceptuelle. En outre, tout ce qui précède est pris en compte pour une plate-forme 32 bits. L'optimisation JIT est un sujet important et distinct qui ne sera pas examiné ici.
Je tiens également à avertir que cet article ne contient pas de matériel qui devrait être utilisé dans des projets réels.
Commencez avec la théorie
Tout code devient finalement un ensemble de commandes machine. Le plus compréhensible est leur représentation sous forme d'instructions en langage d'assemblage qui correspondent directement à une (ou plusieurs) instructions machine.
Avant de passer à un exemple simple, je vous suggère de vous familiariser avec ce qu'est une pile logicielle.
La pile logicielle est principalement un morceau de mémoire qui est utilisé, en règle générale, pour stocker différents types de données (en règle générale, elles peuvent être appelées
données temporaires ). Il convient également de rappeler que la pile se développe vers des adresses inférieures. Autrement dit, plus l'objet est poussé tard dans la pile, moins son adresse sera.
Examinons maintenant le morceau de code suivant 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 à laquelle vous devez prêter attention est les registres
EBP et
ESP et les opérations avec eux.
Une idée fausse parmi mes amis est que le registre
EBP est en quelque sorte lié à un pointeur vers le haut de la pile. Je dois dire que ce n'est pas le cas.
Le registre
ESP est responsable du pointeur vers le haut de la pile. En conséquence, avec chaque
commande PUSH (il place la valeur en haut de la pile), la valeur de ce registre est décrémentée (la pile croît vers des adresses inférieures), et avec chaque opération
POP elle est incrémentée. La commande
CALL pousse également l'adresse de retour sur la pile, décrémentant ainsi également la valeur du registre
ESP . En fait, la modification du registre
ESP n'est pas effectuée uniquement lorsque ces instructions sont exécutées (par exemple, lorsque des appels d'interruption sont exécutés, la même chose se produit lorsque les instructions
CALL sont exécutées).
Considérez StubMethod.
Sur la première ligne, le contenu du registre
EBP est enregistré (poussé sur la pile). Avant de revenir de la fonction, cette valeur sera restaurée.
La deuxième ligne stocke la valeur actuelle du haut de l'adresse de pile (la valeur du registre
ESP est entrée dans
EBP ). Dans ce cas, le registre
EBP est une sorte de zéro dans le contexte de l'appel en cours. L'adressage est effectué par rapport à lui. 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.
Tout ce qui précède est appelé une fonction prologue.
Après cela, l'accès aux variables sur la pile se fait via l'
EBP stocké, qui indique l'endroit où les variables de cette méthode particulière commencent.
Vient ensuite l'initialisation des variables locales.
Rappel sur
fastcall : le .net natif utilise la
convention d' appel
fastcall .
L'accord régit l'emplacement et l'ordre des paramètres passés à la fonction.
Avec
fastcall, les premier et deuxième paramètres sont passés respectivement par les registres
ECX et
EDX , et les paramètres suivants sont passés par la pile.
Pour les méthodes non statiques, le premier paramètre est implicite et contient l'adresse de l'objet sur lequel la méthode est appelée (adresse this).
Aux lignes 4 et 5, les paramètres qui ont été transmis via les registres (les 2 premiers) sont stockés sur la pile.
Ensuite, nettoyez l'espace de la pile pour les variables locales et initialisez les variables locales.
Il convient de rappeler que le résultat de la fonction est dans le registre
EAX .
Aux lignes 12 à 16, les variables nécessaires sont ajoutées. J'attire votre attention sur la ligne 15. L'adresse est adressée, plus que le début de la pile, c'est-à-dire la pile de la méthode précédente. Avant d'appeler, la méthode appelante pousse le paramètre en haut de la pile. Ici, nous le lisons. Le résultat de l'addition est extrait du registre
EAX et poussé sur la pile. Comme il s'agit de la valeur de retour de StubMethod, elle est à nouveau placée dans
EAX . Bien sûr, de tels ensembles absurdes d'instructions ne sont inhérents qu'au mode de débogage, mais ils montrent à quoi ressemble notre code sans un optimiseur intelligent qui fait la part du lion du travail.
Les lignes 18 et 19 restaurent l'
EBP précédent (la méthode appelante) et le pointeur en haut de la pile (au moment où la méthode a été appelée).
La dernière ligne revient. À propos de la valeur 0x4, je dirai un peu plus bas.
Cette séquence de commandes est appelée l'épilogue de la fonction.
Jetons maintenant un œil à CallingMethod. Allons directement à la ligne 18. Ici, nous plaçons le troisième paramètre au-dessus 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 placés dans des registres (
fastcall ). Vient ensuite l'appel à la méthode StubMethod.
Rappelez maintenant l'instruction
RET 0x4 . La question suivante est possible ici: 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 que l'octet doit être effacé de la pile après l'appel de fonction. Puisqu'il y avait un paramètre, vous devez effacer 4 octets.
Voici un exemple d'image de pile:

Ainsi, si nous nous retournons et voyons ce qui se trouve à l'arrière de la pile immédiatement après l'appel de méthode, la première chose que nous verrons est l'
EBP poussé sur la pile (en fait, cela s'est produit sur la première ligne de la méthode actuelle). Ensuite, il y aura une adresse de retour qui indique où se poursuivra l'exécution (utilisée par l'instruction
RET ). Et à travers ces champs, nous verrons les paramètres eux-mêmes de la fonction actuelle (à partir du 3ème, les paramètres sont transmis via les registres avant). Et derrière eux se trouve la pile de la méthode d'appel elle-même!
Les premier et deuxième champs mentionnés expliquent le décalage à + 0x8 lors de la référence aux paramètres.
Par conséquent, les paramètres doivent se situer en haut de la pile dans un ordre strictement défini lors de l'appel de la fonction. Par conséquent, avant d'appeler la méthode, chaque paramètre est poussé dans la pile.
Mais que se passe-t-il si vous ne les poussez pas et que la fonction les acceptera quand même?
Un petit exemple
Ainsi, tous les faits énoncés ci-dessus m'ont donné un désir irrésistible de lire la pile d'une méthode qui appellera ma fonction. La pensée que littéralement dans une position du troisième argument (elle sera la plus proche de la pile de la méthode d'appel) sont les données précieuses que je veux tant obtenir, ne m'a pas laissé dormir.
Ainsi, pour lire la pile de la méthode d'appel, j'ai besoin d'aller un peu plus loin que les paramètres.
En référence aux paramètres, le calcul de l'adresse d'un paramètre est basé uniquement sur le fait que la méthode appelante les a tous poussés sur la pile.
Mais le passage implicite du paramètre
EDX (peu importe - le
dernier article ) suggère que nous pouvons déjouer le compilateur dans certains cas.
L'outil que j'ai fait est appelé StructLayoutAttribute (fonctionnalités dans le
premier article ). // Un jour, j'apprendrai autre chose que cet attribut, je le promets.
Nous utilisons tous la même technique préférée avec les types de référence.
En même temps, si les méthodes qui se chevauchent ont un nombre différent de paramètres, nous obtenons que le compilateur ne poussera pas ceux dont nous avons besoin sur la pile (comme l'imaginaire, car il ne sait pas lesquels).
Cependant, la méthode qui est réellement appelée (avec le même décalage par rapport à un autre type) adresse les adresses plus par rapport à sa pile, c'est-à-dire celles où elle prévoit de trouver les paramètres.
Mais là, il ne les trouve pas et commence à lire la pile de la méthode appelante.
Code de 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 vais pas donner le code du langage assembleur, tout est assez clair ici, mais si vous avez 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.