Accueil Nos publications Blog JVM Hardcore – Part 15 – Bytecode – Variables locales et Maths, le retour

JVM Hardcore – Part 15 – Bytecode – Variables locales et Maths, le retour

academic_duke
Après une dizaine d’articles traitant de sujets connexes nous allons enfin reprendre notre étude des instructions de la JVM et des différents éléments constituant un fichier .class. Aujourd’hui nous étudierons deux instructions, une liée à la manipulation des variables locales et l’autre aux opérations arithmétiques, que nous avions laissées de côté.

Cet article et les suivants auront tous le même format. Pour chaque instruction ou élément d’un fichier .class, nous verrons quel est son utilité et comment l’utiliser en Java à l’aide de PJBA ou dans un fichier .pjb. Et pour finir, dans la dernière partie de chaque article nous nous intéresserons à l’implémentation de toutes les instructions – vues – dans PJBA.

Le code est disponible sur Github (tag et branche)

Tous les articles déjà publiés de la série portent le tag jvmhardcore.

Représentation de la pile (Rappel)

La JVM étant basée sur le modèle de la pile, il est essentiel de connaître quel est l’impact des instructions sur celle-ci. Pour représenter l’état avant/après l’exécution d’une instruction, nous allons reprendre le format utiliser par la JVMS et qui est le suivant :

..., valeur1, valeur2 → ..., résultat, où les valeurs les plus à droite sont au sommet de la pile. valeur1 et valeur2 étant les deux valeurs utilisées pour le calcul et résultat le résultat.

Il est important de noter que dans cette représentation les long et les double sont considérés comme une seule valeur. Par conséquent, lorsque nécessaire nous présenterons les différents cas d’utilisation d’une instruction en utilisant plusieurs formes.

iinc

L’instruction iinc (0x84) permet d’additionner à un valeur de type int, présente dans les variables locales, une constante donnée.

iinc <index dans les variables locales> <constante>

Les deux paramètres de cette instruction font chacun un octet. Le premier représente un index dans les variables locales (la valeur est donc un nombre non signé compris dans l’intervalle [0; 255]) et le second représente une constante dont la valeur est signée (comprise dans l’intervalle[-128; 127]).

Les expressions Java i++, ++i, i += 1 et i = i + 1, peuvent toutes être traduites en PJB de la manière suivante (en considérant que i est le premier paramètre d’une méthode statique) :

iinc 0 1    @ incrémente la valeur à l'index 0 de 1

De même, les expressions i--, --i et i -= 1 et i = i - 1 peuvent être traduites par :

iinc 0 -1   @ décrémente la valeur à l'index 0 de 1

L’instruction iinc n’a aucun impacte sur la pile. État de la pile avant → après exécution : ... → ...

Exemple

Cette instruction n’ayant rien de compliqué, nous nous contenterons d’un seul exemple.

.method public static iinc(I)I
  iinc 0 -1
  iload_0
  ireturn
.methodend

Source

Ce morceau de code peut être écrit en Java de la manière suivante :

builder.newMethod(Method.MODIFIER_PUBLIC
                | Method.MODIFIER_STATIC, "iinc", "(I)I")
  .iinc((byte) 0, (byte) -1)
  .iload_0()
  .ireturn();

Le test Java

@Test
public void iinc() {
  final int i = TestIinc.iinc(10);
  Assert.assertEquals(9, i);
}

Source

wide

L’instruction wide (0xc4) permet d’augmenter la taille des arguments des instructions xload, xstore et iinc, pour pouvoir accéder aux index des variables locales supérieurs à 255, et dans le cas de l’instruction iinc d’utiliser une constante de deux octets.

Cette instruction est à elle seule une exception, puisqu’elle prend deux ou trois arguments en fonction de l’instruction qu’elle étend :

wide <opcode xload ou xstore> <index dans les variables locales>

wide <opcode iinc> <index dans les variables locales> <constante>

Le premier argument représente l’opcode de l’instruction étendue. Il a une taille de un octet. Le deuxième représente un index dans les variables locales (et tout comme le troisième lorsqu’il est utilisé) a une taille de deux octets.

Sachant que les règles de l’instruction étendue s’appliquent toujours. L’index dans les variables locales est toujours un nombre non signé compris dans l’intervalle [0; 65535] et la constante est un nombre signé compris dans l’intervalle [-32768, 32767].

L’impact de cette instruction sur la pile dépend de l’instruction étendue.

  • Si wide est associée aux instructions du type xstore : ..., v -> ...
  • Si wide est associée aux instructions du type xload : ... -> v
  • Si wide est associée à l’instruction iinc : ... -> ...

Le choix a été fait de ne pas permettre l’utilisation de l’instruction wide dans un fichier .pjb puisque sa seule raison d’exister est d’étendre une instruction existante. Il nous suffit donc d’utiliser directement l’instruction pouvant être étendue et de faire une vérification dans les méthodes xload(), xstore() et iinc() de la classe MethodBuilder pour savoir quelle instruction doit être utilisée.

Exemples

Les instructions xload et xstore sont plus difficiles à tester puisque nous ne pouvons pas indiquer plus de 255 paramètres à une méthode. Néanmoins, pour l’instant nous considérerons que PJBA fonctionne correctement et que les valeurs présentes dans le fichier .class assemblé correspondent aux index indiqués dans le fichier .pjb.

.method public static wide_istore_iload(I)I
  iload_0
  istore 65231
  iload 65231
  ireturn
.methodend

Source

Ou en Java :

builder.newMethod(Method.MODIFIER_PUBLIC
                | Method.MODIFIER_STATIC, "wide_istore_iload", "(I)I")
  .iload_0()
  .istore((short) 65231)
  .iload((short) 65231)
  .ireturn();

Le test Java n’a rien d’extraordinaire puisque nous testons uniquement que la valeur passée en paramètre est bien retournée :

@Test
public void wide_istore_iload() {
  final int i = TestWide.wide_istore_iload(99_999);

  Assert.assertEquals(99_999, i);
}

Source

Le test de l’instruction iinc est tout autant sans surprise. Notons tout de même que pour que l’instruction puisse effectuer une addition sur la valeur à l’index 350, il est nécessaire de stocker le paramètre de la méthode à cette index, d’où les deux premières instructions iload_0 et istore 350

.method public static iinc(I)I
  iload_0
  istore 350
  iinc 350 -30999
  iload 350
  ireturn
.methodend

Source

Ou en Java :

builder.newMethod(Method.MODIFIER_PUBLIC
                | Method.MODIFIER_STATIC, "wide_istore_iload", "(I)I")
  .iload_0()
  .istore((short) 350)
  .iinc((short) 350, (short) -30999)
  .iload((short) 350)
  .ireturn();

Et pour terminer le test :

@Test
public void wide_iinc() {
  final int i = TestWide.iinc(31000);

  Assert.assertEquals(1, i);
}

Source

Implémentation dans PJBA

iinc

Nous avions laissé de côté l’instruction iinc comme la suivante (wide) puisque leur implémentation dans PJBA
est loin d’être triviale et qu’il était nécessaire de comprendre le fonctionnement nominal de l’assembleur avant d’introduire des cas exceptionnels. Pour l’instant intéressons-nous uniquement à l’instruction iinc.

Il s’agit de la seule instruction prenant deux paramètres d’un octet chacun. De fait, nous pouvons créer une classe propre à cette instruction.

public class IincInstruction extends Instruction {

  final private int indexInLV;
  final private int constant;

  public IincInstruction(int opcode, int stack, int locals,
                         int indexInLV, int constant) {
    super(opcode, stack, locals, 3);
    this.indexInLV = indexInLV;
    this.constant = constant;
  }
}

Source

Ce qui nous permet de rajouter la méthode dédiée dans la classe Instructions.

public static Instruction iinc(byte indexInLV, byte constant) {
  return new IincInstruction(0x84, 0, 0, indexInLV, constant);
}

Source

Mais aussi dans la classe MethodBuilder.

public MethodBuilder iinc(byte indexInLV, byte constant) {
  this.code.addInstruction(Instructions.iinc(indexInLV, constant));
  return this;
}

De plus rajouter une nouvelle classe XxxInstruction implique la création d’une nouvelle fabrique IincInstructionFactory et d’une nouvelle MetaInstruction</codea href="https://github.com/yohanbeschi/jvm_hardcore/blob/part15/03_projects/pjba/01_src/main/java/org/isk/jvmhardcore/pjba/instruction/meta/IincMetaInstruction.java" target="_blank">IincMetaInstruction</a>. Ensuite, nous devons ajouter une nouvelle valeur dans l'énumération <a href="https://github.com/yohanbeschi/jvm_hardcore/blob/part15/03_projects/pjba/01_src/main/java/org/isk/jvmhardcore/pjba/instruction/meta/MetaInstruction.java#L46" target="_blank" rel="noopener">ArgsType</a> (<code class="language-plaintext">IINC) et ajouter l’instruction dans la classe MetaInstructions.

list.add(new IincMetaInstruction("iinc", ArgsType.IINC,
             new IincInstructionFactory() {

  @Override
  public Instruction buildInstruction(byte indexInLV, byte constant) {
    return Instructions.iinc(indexInLV, constant);
  }
}));

Source

Exceptionnellement, nous pouvons modifier notre assembleur pour simplifier le travail des dumpers en rajoutant une méthode à notre interface Visitor. Cette nouvelle méthode prenant en paramètre les deux arguments de l’instruction.

void visitInstructionIinc(int indexInLV, int constant);

Source

Sans cette nouvelle instruction, la méthode accept() de la classe IincInstruction aurait appelé deux fois la méthode visitInstructionByte() ce qui aurait impliqué la nécessité de la part des dumpers de traiter l’instruction iinc comme une exception en gardant en mémoire la position de l’argument traité pour ajouter au bon endroit un retour à la ligne.

La tâche d’ajouter deux octets au fichier .class a donc été déléguée à la classe Assembler. Ce qui permet aux classes Disassembler, HexDumper et PjbDumper une intégration de la nouvelle instruction sans modification de notre architecture.

Pour terminer, la classe PjbParser n’a subi qu’une légère modification pour prendre en compte le type ArgsType.IINC en utilisant des méthodes existantes de la classe PjbTokenizer.

wide

PJBA n’ayant pas été conçu pour pouvoir gérer des instructions à arguments variables, ajouter l’instruction wide – sans tout casser – nécessite d’adopter une nouvelle stratégie, tout en gardant à l’esprit que nous ne souhaitons pas avoir à gérer des exceptions et que la seule classe devant avoir connaissance de l’opcode d’une instruction doit être la classe Instructions.

Pour ce faire, nous allons créer deux nouvelles classes, une pour chacune des configurations :

public class WideLoadStoreInstruction extends Instruction {

  final private int widenedOpcode;
  final private int indexInLV;

  public WideLoadStoreInstruction(int opcode, int stack, int locals,
                                  int widenedOpcode, int indexInLV) {
    super(opcode, stack, locals, 4);
    this.widenedOpcode = widenedOpcode;
    this.indexInLV = indexInLV;
  }
}

Source

public class WideIincInstruction extends Instruction {

  final private int widenedOpcode;
  final private int indexInLV;
  final private int constant;

  public WideIincInstruction(int opcode, int stack, int locals,
                 int widenedOpcode, int indexInLV, int constant) {
    super(opcode, stack, locals, 6);
    this.widenedOpcode = widenedOpcode;
    this.indexInLV = indexInLV;
    this.constant = constant;
  }
}

Source

Nous avons aussi gardé la hiérarchie existante pour laquelle chaque type d’instruction implémente l’interface Instruction. Par conséquent, WideIincInstruction implémente Instruction mais n’hérite pas de WideLoadStoreInstruction.

A la classe Instructions nous ajoutons une méthode pour chaque instruction étendue :

public static Instruction wide_iload(short indexInLV) {
  return new WideLoadStoreInstruction(0xc4, 1,
                                      BytecodeUtils.unsign(indexInLV) + 1,
                                      0x15, indexInLV);
}

// ...

public static Instruction wide_istore(short indexInLV) {
  return new WideLoadStoreInstruction(0xc4, -1,
                                      BytecodeUtils.unsign(indexInLV) + 1,
                                      0x36, indexInLV);
}

// ...

public static Instruction wide_iinc(short indexInLV, short constant) {
  return new WideIincInstruction(0xc4, 0, indexInLV + 1, 0x84, indexInLV, constant);
}

Source

De plus comme précédemment, pour aider les dumpers nous allons ajouter deux méthodes à l’interface Visitor.

void visitInstructionWideIinc(int widenedOpcode,
                              int indexInLV, int constant);
void visitInstructionWideLoadStore(int widenedOpcode,
                                   int indexInLV);

Source

Pour finir dans les modifications liées au cœur de PJBA, nous devons modifier toutes les méthodes xload(), xstore() et iinc() de la classesMethodBuilder tout en interdisant l’utilisation explicite de l’instruction wide.

public MethodBuilder iload(short indexInLV) {
  if (indexInLV >= Byte.MIN_VALUE && indexInLV <= Byte.MAX_VALUE) {
    this.code.addInstruction(Instructions.iload((byte) indexInLV));
  } else {
    this.code.addInstruction(Instructions.wide_iload(indexInLV));
  }
  return this;
}

// ...

public MethodBuilder istore(short indexInLV) {
  if (indexInLV >= Byte.MIN_VALUE && indexInLV <= Byte.MAX_VALUE) {
    this.code.addInstruction(Instructions.istore((byte) indexInLV));
  } else {
    this.code.addInstruction(Instructions.wide_istore(indexInLV));
  }
  return this;
}

//...

public MethodBuilder iinc(short indexInLV, short constant) {
  if (   indexInLV >= Byte.MIN_VALUE && indexInLV <= Byte.MAX_VALUE
      && constant >= Byte.MIN_VALUE && constant <= Byte.MAX_VALUE) {
    this.code.addInstruction(Instructions.iinc((byte) indexInLV, (byte) constant));
  } else {
    this.code.addInstruction(Instructions.wide_iinc(indexInLV, constant));
  }
  return this;
}

Source

Bien que nous ayons créé deux classes pour l’instruction, nous n’avons besoin que d’une seule fabrique WideInstructionFactory – ayant deux méthodes – et d’une seule MetaInstruction.

public class WideMetaInstruction extends MetaInstruction {
  final private static short SHORT_ZERO = 0;

  private WideInstructionFactory instructionBuilder;

  public WideMetaInstruction(final String mnemonic,
                                final ArgsType argsType,
                                final WideInstructionFactory instructionBuilder) {
    super(mnemonic, mnemonic, argsType);

    this.instructionBuilder = instructionBuilder;
    this.opcode = instructionBuilder
                    .buildInstruction(SHORT_ZERO, SHORT_ZERO)
                    .getOpcode();
  }

  public Instruction buildInstruction(byte widenedOpcode, short indexInLV) {
    return this.instructionBuilder.buildInstruction(widenedOpcode, indexInLV);
  }

  public Instruction buildInstruction(short indexInLV, short constant) {
    return this.instructionBuilder.buildInstruction(indexInLV, constant);
  }
}

Source

Pour savoir quelle méthode buildXxx() doit être utilisée, nous pouvons ajouter une valeur à l’énumération ArgsType, WIDE. De plus en partant du principe qu’un argument représentant un index dans les variables locales peut avoir une taille de un ou deux octets nous pouvons en ajouter une deuxième, LV_INDEX. Ceci nous permettra aussi de corriger un bogue présent dans PJBA depuis l’article précédent.

Pour inclure la nouvelle MetaInstruction dans PJBA, ainsi que les deux nouvelles valeurs de l’énumération, la première étape consiste à ajouter la MetaInstruction dans la classe MetaInstructions.

list.add(new WideMetaInstruction("wide", ArgsType.WIDE,
             new WideInstructionFactory() {

  @Override
  public Instruction buildInstruction(short indexInLV, short constant) {
    return Instructions.wide_iinc(indexInLV, constant);
  }

  @Override
  public Instruction buildInstruction(byte widenedOpcode, short indexInLV) {
    return Instructions.wide_load_store(widenedOpcode, indexInLV);
  }
}));

Source

La méthode wide_load_store() est un simple switch permettant de sélectionner la bonne instruction :

public static Instruction wide_load_store(byte widenedOpcode,
                                          short indexInLV) {
  switch (widenedOpcode) {
    case 0x15:
      return wide_iload(indexInLV);
    case 0x16:
      return wide_lload(indexInLV);
    case 0x17:
      return wide_fload(indexInLV);
    case 0x18:
      return wide_dload(indexInLV);
    case 0x19:
      return wide_aload(indexInLV);
    case 0x36:
      return wide_istore(indexInLV);
    case 0x37:
      return wide_lstore(indexInLV);
    case 0x38:
      return wide_fstore(indexInLV);
    case 0x39:
      return wide_dstore(indexInLV);
    case 0x3a:
      return wide_astore(indexInLV);
    default:
      return null;
  }
}

Source

La seconde consiste à remplacer toutes les valeurs ArgsType.BYTE des MetaInstruction liées aux instructions xload et xstore dans la classe MetaInstructions.

Et pour finir, la troisième consiste à prendre en compte cette modification dans les classes PjbDumper, HexDumper et PjbParser, ce qui corrigera le bogue mentionné un peu plus haut.

Actuellement, les arguments des instructions xload et xstore sont liés au type ArgsType.BYTE tout comme celle de l’instruction bipush. Or bipush prend en argument un nombre signé, alors que les instructions xload et xstore prennent en argument un nombre non signé. De fait, les classes HexDumper et PjbParser peuvent afficher des index négatifs si la valeur est supérieure à 127. D’un autre côté, la classe PjbParser appelle la méthode getByteValue() du tokenizer, qui en interne utilise la méthode suivante :

byte Byte.valueOf(String value)

Malheureusement, bien que la méthode convertisse la chaîne en int avant de la convertir en byte, elle effectue une vérification permettant de s’assurer que la valeur est bien incluse dans l’intervalle autorisé par le type Byte. Ce qui signifie que iload 234 génère une exception, alors que iload -10 fonctionne parfaitement.

En partant du principe que l’on ne peut pas utiliser l’instruction wide dans un fichier .pjb, les MetaInstructions retournées à la méthode processInstruction() seront toujours du type ByteArgMetaInstruction ou IincMetaInstruction, nous pouvons écrire le code ci-dessous, tout en gardant le fonctionnement actuel pour le type ArgsType.BYTE_VALUE :

case IINC:
  this.tokenizer.consumeWhitespaces();
  final int iincIndexInLV = this.tokenizer.getIntValue();
  this.tokenizer.consumeWhitespaces();
  final short constant = this.tokenizer.getShortValue();
  this.methodBuilder.iinc((short)iincIndexInLV, constant);
  break;
case LV_INDEX:
  this.tokenizer.consumeWhitespaces();
  final int lvIndexInLV = this.tokenizer.getIntValue();

  if (lvIndexInLV >= Byte.MIN_VALUE && lvIndexInLV <= Byte.MAX_VALUE) {
    instruction = ((ByteArgMetaInstruction) metaInstruction)
                      .buildInstruction((byte)lvIndexInLV);
  } else {
    final byte opcode = (byte)metaInstruction.getOpcode();
    instruction = Instructions.wide_load_store(opcode), (short)lvIndexInLV);
  }

  this.methodBuilder.instruction(instruction);

  break;

Source

Notons que nous autorisons toujours l’utilisation d’index négatifs ce qui n’est en rien problématique.

La classe Disassembler doit quant à elle prendre en compte le nouveau type WideMetaInstruction

Nous commençons à nous rendre compte que l’utilisation des types byte et short ayant pour but d’aider l’utilisateur – de PJBA – n’est pas idéal. Dans le même ordre d’idées, tous les switch/case fait sur des valeurs de type ArgsType commencent à devenir extrêmement long. En définitive, le code de la partie que l’on peut désigner comme utilitaire (le désassembleur, les dumpers et l’analyseur syntaxique) est loin d’être parfait. Mais nous devons garder à l’esprit que nous sommes loin d’avoir terminé, et une amélioration de la conception à chaque étape est une perte de temps inutile. Pour l’instant ce qui nous importe est que notre code fonctionne. Et c’est probablement l’une des choses les plus compliqué pour un “codeur”, que d’accepter d’avoir un code non parfait, tout du moins selon ses propres critères. Critères qui comme chacun le sait varient d’un développeur à l’autre.

Notes sur les tests

Comme déjà mentionné l’instruction wide est difficile à tester. Nous n’avons aucune certitude quant à l’index réel utilisé. La question principale étant : “Y a-t-il eu un problème de conversion – quelque part – dans PJBA ?”
Nous répondrons à cette question après avoir étudié les tableaux. Pour l’instant contentons-nous de simples tests visuels en utilisant PjbDumper et HexDumper.

What’s next

Dans le prochain article nous nous intéresserons à une première série d’instructions de comparaison et de contrôle
qui nous permettront de voir comment sont gérées en bytecode les conditions et les boucles.