Je crée un outil d'instrumentation de code d'octet Java à l'aide du framework ASM, et j'ai besoin de déterminer et éventuellement de modifier le type de variables locales d'une méthode. Très rapidement, j'ai rencontré un cas simple où les variables et les nœuds de carte de pile semblent un peu bizarres et ne me donnent pas suffisamment d'informations sur les variables utilisées:

public static void test() {
    List l = new ArrayList();
    for (Object i : l) {
        int a = (int)i;
    }
}

Donne le bytecode suivant (de Idea):

public static test()V
   L0
    LINENUMBER 42 L0
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 0
   L1
    LINENUMBER 43 L1
    ALOAD 0
    INVOKEINTERFACE java/util/List.iterator ()Ljava/util/Iterator;
    ASTORE 1
   L2
   FRAME APPEND [java/util/List java/util/Iterator]
    ALOAD 1
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z
    IFEQ L3
    ALOAD 1
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object;
    ASTORE 2
   L4
    LINENUMBER 44 L4
    ALOAD 2
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    ISTORE 3
   L5
    LINENUMBER 45 L5
    GOTO L2
   L3
    LINENUMBER 46 L3
   FRAME CHOP 1
    RETURN
   L6
    LOCALVARIABLE i Ljava/lang/Object; L4 L5 2
    LOCALVARIABLE l Ljava/util/List; L1 L6 0
    MAXSTACK = 2
    MAXLOCALS = 4

Comme on peut le voir, les 4 variables définies explicitement et implicitement prennent 1 slot, 4 slots sont réservés, mais seulement 2 définis, dans un ordre étrange (adresse 2 avant l'adresse 0) et avec un "trou" entre eux. L'itérateur de liste est écrit plus tard dans ce "trou" avec ASTORE 1 sans déclarer d'abord le type de cette variable. Ce n'est qu'après l'apparition de ce cadre de carte de pile d'opérations, mais je ne comprends pas pourquoi seules 2 variables y sont placées, car plus de 2 sont utilisées plus tard. Plus tard, avec ISTORE 3, int est à nouveau écrit dans un slot variable, sans aucune déclaration.

À ce stade, il semble que je doive ignorer complètement les définitions de variables et déduire tous les types en interprétant le bytecode, en exécutant la simulation de la pile JVM.

J'ai essayé l'option ASM EXPAND_FRAME, mais elle est inutile, ne changeant que le type du nœud de trame unique en F_NEW, le reste étant toujours vu exactement comme avant.

Quelqu'un peut-il expliquer pourquoi je vois un code aussi étrange et si j'ai d'autres options que d'écrire mon propre interpréteur JVM?

Conclusion, basée sur toutes les réponses (veuillez me corriger à nouveau si je me trompe):

Les définitions de variables sont uniquement destinées à faire correspondre les noms / types de variables source à des emplacements de variables spécifiques accessibles à des lignes de code spécifiques, apparemment ignorées par le vérificateur de classe JVM et pendant l'exécution du code. Peut être absent ou ne correspond pas au bytecode réel.

Les slots variables sont traités comme une autre pile, bien qu'accédés via des index de mots de 32 bits, et il est toujours possible d'écraser son contenu avec différents temporaires tant que vous utilisez des types correspondants d'instructions de chargement et de stockage.

Les nœuds de trame de pile contiennent la liste des variables allouées du début de la trame de variable à la dernière variable qui va être chargée dans le code suivant sans stockage préalable. Cette carte d'allocation devrait être la même quel que soit le chemin d'exécution emprunté pour atteindre son étiquette. Ils contiennent également une carte similaire pour la pile d'opérandes. Leur contenu peut être spécifié sous forme d'incréments par rapport au nœud de trame de pile précédent.

Les variables qui n'existent que dans des séquences linéaires de code n'apparaîtront dans le nœud de trame de pile que s'il y a des variables avec une durée de vie plus longue allouées à une adresse d'emplacement supérieure.

3
noop 30 nov. 2017 à 03:40

3 réponses

Meilleure réponse

La réponse courte est que vous devrez en effet écrire une sorte d'interpréteur si vous voulez connaître les types d'éléments de cadre de pile à chaque emplacement de code, bien que la plupart de ce travail a déjà été effectué, mais il n'est toujours pas suffisant pour restaurer les types de variables locales au niveau source et là n'est pas du tout une solution générale à cela.

Comme indiqué dans d'autres réponses, des attributs comme LocalVariableTable sont vraiment destinés à aider à restaurer les déclarations formelles de variables locales, par exemple lors du débogage, mais ne couvrent que les variables présentes dans le code source (en fait, c'est la décision du compilateur) et ne sont pas obligatoires. Il n'est pas non plus garanti d'être correct, par ex. un outil de transformation de bytecode peut avoir changé le code sans mettre à jour ces attributs de débogage, mais la JVM ne se soucie pas lorsque vous ne déboguez pas.

Comme indiqué également dans d'autres réponses, l'attribut StackMapTable est uniquement destiné à aider à la vérification du bytecode, pas à fournir des déclarations formelles. Il indiquera l'état du cadre de la pile aux points de fusion des branches, autant que nécessaire pour la vérification .

Ainsi, pour les séquences de code linéaires sans branches, le type des variables locales et des entrées de pile d'opérandes n'est déterminé que par inférence, mais ces types inférés ne sont pas du tout garantis pour correspondre aux types officiellement déclarés.

Pour illustrer le problème, les séquences de code sans branche suivantes produisent un bytecode identique:

CharSequence cs;
cs = "hello";
cs = CharBuffer.allocate(20);
{
    String s = "hello";
}
{
    CharBuffer cb = CharBuffer.allocate(20);
}

C'est la décision des compilateurs de réutiliser l'emplacement de la variable locale pour les variables avec des portées disjointes, mais tous les compilateurs concernés le font.

Pour la vérification, seule l'exactitude compte, donc lors du stockage d'une valeur de type X dans un emplacement de variable local, suivi de sa lecture et de l'accès au membre Y.someMember, alors X doit être attribuable à Y, que le type déclaré de la variable locale soit réellement Z, un supertype de X mais un sous-type de Y.

En l'absence d'attributs de débogage, vous pourriez être tenté d'analyser l'utilisation ultérieure pour deviner le type réel (je suppose, c'est ce que font la plupart des décompilateurs), par exemple le code suivant

CharSequence cs;
cs = "hello";
cs.charAt(0);
cs = CharBuffer.allocate(20);
cs.charAt(0);

Contient deux invokeinterface CharSequence.charAt instructions, indiquant que le type réel de la variable est probablement CharSequence plutôt que String ou CharBuffer, mais le bytecode est toujours identique à, par exemple

{
    String s = "hello";
    ((CharSequence)s).charAt(0);
}
{
    CharBuffer cb = CharBuffer.allocate(20);
    ((CharSequence)cb).charAt(0);
}

Car ces types de casts n'influencent que l'invocation de la méthode suivante, mais ne génèrent pas d'instructions de bytecode par eux-mêmes, car il s'agit de transtypages élargis.

Il n'est donc pas possible de restaurer précisément les types déclarés de variables de niveau source à partir du bytecode dans une séquence linéaire et les entrées de cadre de stackmap ne sont pas non plus utiles. Leur but est d'aider à vérifier l'exactitude du code suivant (qui peut être atteint via différents chemins de code) et pour cela, il n'a pas besoin de déclarer tous les éléments existants. Il n'a qu'à déclarer les éléments existants avant le point de fusion et effectivement utilisés après le point de fusion. Mais cela dépend du compilateur de savoir si (et de laquelle) les entrées réellement non nécessaires au vérificateur sont présentes.

2
Holger 6 sept. 2019 à 11:59

Pour élaborer sur la réponse d'apangin: Vous devez considérer le but des attributs que vous regardez.

LocalVariableTable est des métadonnées facultatives ajoutées à des fins de débogage. C'est ce qui permet à un débogueur d'afficher les valeurs des variables locales au programmeur, y compris leurs noms et types de niveau source. Cependant, le corollaire de ceci est que le compilateur émet uniquement des informations de débogage pour les variables de niveau source . L'emplacement 1 est pour l'itérateur généré implicitement par votre boucle for, il n'y a donc pas d'informations de débogage sensées à émettre. Quant à l'emplacement 3, c'est pour votre variable a. Je ne sais pas pourquoi il n'est pas ajouté, mais il est possible que ce soit parce que la portée de la variable se termine immédiatement après sa création. Par conséquent, la plage de bytecode pour la variable a est vide.

Comme pour le StackMapTable, les cartes de pile sont conçues pour accélérer la vérification du bytecode. Le premier corollaire de ceci est qu'il ne contient que des informations de type au niveau du bytecode - c'est-à-dire qu'il n'y a pas de génériques ou quelque chose comme ça. Le deuxième corollaire est qu'il ne contient que les informations nécessaires pour assister le vérificateur.

Avant l'introduction des cartes de pile, le vérificateur a potentiellement effectué plusieurs passages dans le code. Chaque fois qu'il y avait une branche arrière dans le code, il devrait revenir en arrière et mettre à jour les types, ce qui pourrait potentiellement modifier d'autres types inférés, etc., de sorte que le vérificateur devait itérer jusqu'à la convergence.

Les cartes de pile sont conçues pour permettre au vérificateur de vérifier le bytecode de la méthode en un seul passage de haut en bas. Par conséquent, il nécessite que les types soient spécifiés explicitement partout où il y a une cible de saut. Lorsque le bytecode arrive à cet emplacement, il peut simplement vérifier les types actuellement déduits par rapport aux types dans le cadre de la pile au lieu d'avoir à revenir en arrière tout le temps et à refaire les choses. Mais il n'y a pas besoin de cadres de pile au milieu de sections linéaires de code, puisque l'algorithme d'inférence du vérificateur fonctionne parfaitement bien pour cela.

La dernière question que vous vous posiez était de savoir pourquoi seules deux valeurs sont répertoriées dans le cadre de la pile. La raison en est que pour réduire l'espace, les cartes de pile sont codées en delta. Il existe un certain nombre de types de trames différents, et dans les cas courants, vous pouvez simplement lister les différences par rapport à la trame précédente au lieu d'émettre une trame complète qui répertorie les types de toutes les variables et empile les opérandes à chaque fois.

Il y a deux cadres de carte de pile répertoriés dans le bytecode que vous avez publié. La première est une trame append, ce qui signifie que la pile d'opérandes est vide et qu'elle a les mêmes locals que la trame précédente, sauf avec 1 à 3 variables locales supplémentaires. Dans ce cas, il existe deux sections locales supplémentaires, avec les types List et Iterator. La deuxième trame est une trame chop, ce qui signifie que la pile d'opérandes est vide et qu'elle a les mêmes locals que la trame précédente sauf que les 1-3 derniers locals sont manquants. Dans ce cas, un local est coupé car l'itérateur n'est plus dans la portée.

2
Antimony 30 nov. 2017 à 02:31

LocalVariableTable sert à faire correspondre les variables du code source aux emplacements de variables dans le bytecode de la méthode. Cet attribut facultatif est principalement destiné aux débogueurs (pour imprimer le nom correct d'une variable).

Comme vous avez déjà répondu vous-même, pour déduire un type de variable locale ou un type d'expression, vous devez parcourir le bytecode: soit à partir du début de la méthode, soit à partir de la carte de pile la plus proche. L'attribut StackMapTable contient les cartes de pile uniquement aux points de fusion.

3
apangin 30 nov. 2017 à 01:22
47564143