Accueil Nos publications Blog JVM Hardcore – Part 14 – Bytecode – Assembleur de bytecode – 2/2

JVM Hardcore – Part 14 – Bytecode – Assembleur de bytecode – 2/2

academic_duke
Aujourd’hui nous allons finaliser PJBA avec toutes nos connaissances actuelles, pour pouvoir reprendre notre étude des instructions de la JVM dès le prochain article.

Au cours de cet article nous :

  • ajouterons toutes les instructions que nous avons déjà vu (avec et sans arguments) et
  • créerons un analyseur syntaxique nous permettant de transformer des fichiers .pjb en fichiers .class.

Le code est disponible sur Github (tag et branche).

Tout comme l’article Part 8 – Mon premier analyseur syntaxique – 2/2 nous allons créer de multiples branches nous permettant de suivre l’ajout des instructions et la construction de l’analyseur pas à pas.

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

Ajout des instructions sans arguments

Branche part14_01

Ajouter les instructions n’ayant pas besoin d’argument ne semble pas compliqué.
En reprenant la classe Instructions, nous constatons qu’ajouter une instruction consiste en la créations de trois constantes (XXX, XXX_MNEMONIC et XXX_OPCODEXXX est le nom de l’instruction), l’ajout des instructions dans les maps OPCODE_TO_MNEMONIC et OPCODE_TO_INSTRUCTION et l’ajout des méthodes correspondants aux instructions.

Si cette conception peut convenir avec quatre instructions, lorsque l’on en a plus de deux cents ça ne semble plus être la cas. Nous allons donc opter pour une autre approche, dans le but de réduire le nombre de constantes en supprimant les constantes de type XXX_MNEMONIC et XXX_OPCODE qui n’ont actuellement aucune utilité, et aussi de faciliter la création des maps d’association. Mais rien ne nous empêchera dans le futur de les rajouter si le besoin s’en fait resentir.

Commençons par définir notre besoin :

  • Les instructions doivent être des constantes. Ceci nous évitera d’avoir à les instancier à chaque utilisation.
  • A chaque instruction peut être associée une méthode la retournant. Bien que pour l’instant ce ne soit pas indispensable, c’est une fonctionnalité sympathique ne coûtant rien (en terme de développement et de performances).
  • Nous devons pouvoir récupérer une instruction à partir d’un opcode (utile pour le désassembleur).
  • Nous devons pouvoir récupérer une mnémonique à partir d’un opcode (utile pour les classes HexDumper et PjbDumper).

Nous pouvons répondre aux deux premières fonctionnalités en reprenant l’existant :

Instruction ACONST_NULL = new NoArgInstruction(0x01, 1, 0);

public static Instruction aconst_null() {
  return ACONST_NULL;
}

En écrivant “en dur” l’opcode en tant que paramètre du constructeur de la classe NoArgInstruction, nous pouvons supprimer toutes les constantes de XXX_OPCODE.

Les deux autres fonctionnalités nécessitent l’utilisation de maps qui doivent être créer dynamiquement à l’initialisation de la classe. Pour se faire nous allons créer une liste temporaire contenant des MetaInstruction

list.add(new MetaInstruction("aconst_null", ACONST_NULL));

Le constructeur d’une MetaInstruction prenant en paramètre la mnémonique de l’instruction ("aconst_null") et l’instruction (ACONST_NULL). L’instruction contenant son opcode, nous passeront pas la MetaInstruction pour faire la liaison entre un opcode et une mnémonique. De même que précédemment pour les constantes de type XXX_OPCODE, la mnémonique étant écrite en dur en tant que paramètre du constructeur de la classe MetaInstruction, nous pouvons supprimer toutes les constantes de type XXX_MNEMONIC.

La classe MétaInstruction n’est qu’un simple bean.

public class MetaInstruction {

  final private int opcode;
  final private String mnemonic;
  final private Instruction instruction;

  public MetaInstruction(final String mnemonic, final Instruction instruction) {
    this.mnemonic = mnemonic;
    this.instruction = instruction;
    this.opcode = instruction.getOpcode();
  }

  // Getters
}

Source

Nous pouvons à présent générer dynamiquement nos maps :

// La liste metaInstructions ayant été remplies précédemment
// ex : list.add(new MetaInstruction("aconst_null", ACONST_NULL));
for (MetaInstruction m : metaInstructions) {
  OPCODE_TO_METAINSTRUCTION.put(m.getOpcode(), m);
  MNEMONIC_TO_METAINSTRUCTION.put(m.getMnemonic(), m);
}

La classe Instructions a l’apparence suivante :

public class Instructions {

  final public static Instruction ACONST_NULL = new NoArgInstruction(0x01, 1, 0);

  // ...

  final static private Map<Integer, MetaInstruction> OPCODE_TO_METAINSTRUCTION;
  final static private Map<String, MetaInstruction> MNEMONIC_TO_METAINSTRUCTION;

  static {
    OPCODE_TO_METAINSTRUCTION = new HashMap<>();
    MNEMONIC_TO_METAINSTRUCTION = new HashMap<>();
    final List<MetaInstruction> metaInstructions = init();
    initList(metaInstructions);
  }

   private static void initList(List<MetaInstruction> metaInstructions) {
    for (MetaInstruction m : metaInstructions) {
      OPCODE_TO_METAINSTRUCTION.put(m.getOpcode(), m);
      MNEMONIC_TO_METAINSTRUCTION.put(m.getMnemonic(), m);
    }
  }

  private static List<MetaInstruction> init() {
    final List<MetaInstruction> list = new LinkedList<>();

    list.add(new MetaInstruction("aconst_null", ACONST_NULL));

    //  ...
  }

  // ...

  public static Instruction aconst_null() {
    return ACONST_NULL;
  }

  // ...
}

Pour terminer, nous ajoutons les méthodes nous permettant de répondre au besoin que nous avons défini précédemment :

public static MetaInstruction getMetaInstruction(final String mnemonic) {
  return MNEMONIC_TO_METAINSTRUCTION.get(mnemonic);
}

public static MetaInstruction getMetaInstruction(final int opcode) {
  return OPCODE_TO_METAINSTRUCTION.get(opcode);
}

public static Instruction getInstruction(final String mnemonic) {
  return getMetaInstruction(mnemonic).getInstruction();
}

public static Instruction getInstruction(final int opcode) {
  return getMetaInstruction(opcode).getInstruction();
}

public static String getMnemonic(final int opcode) {
  return getMetaInstruction(opcode).getMnemonic();
}

Source

Il ne nous reste plus qu’a ajouter les méthodes correspondantes dans la classe MethodBuilder.

A noter, que nous avons ajouté une instruction que nous n’avons pas étudiée, dont la mnémonique est nop (pour “no operation”) et l’opcode 0x00. Cette instruction ne fait absolument rien. Néanmoins, elle peut être utilisée comme instruction temporaire lors de la génération d’un graphe d’objets ClassFile.

Au cours des deux précédents articles, nous avons omis quelque chose d’extrêmement important, les tests unitaires des instructions.

Pour tester la classe Instructions, nous allons générer une classe possédant autant de méthodes qu’il y a d’instructions à tester et où chaque méthode teste une instruction. Pour construire ces méthodes, nous appuierons sur les classes ClassFileBuilder et MethodBuilder, ce qui nous permettra de les tester aussi. La classe générée, que nous nommerons NoArg, allant être particulièrement complète, le mécanisme de désassemblage mis en place dans l’article précédent permettra de tester la classe Disassembler. En d’autres termes, nos tests extrêmement simples testent la quasi-totalité de notre assembleur tout en restant unitaires puisque liés à différentes phases de construction du projet.

Il nous faut donc créer une nouvelle classe Classes, qui générera un fichier .class :

public class Classes {
  @Test
  public void assemble0() throws Exception {
    final String fullyQualifiedName = "org/isk/jvmhardcore/pjba/NoArg";
    final ClassFileBuilder builder = new ClassFileBuilder(fullyQualifiedName);

    this.buildNop(builder);
    this.buildAConstNull(builder);
    this.buildIConstM1(builder);

    //... reste des instructions

    final ClassFile classFile = builder.build();

    org.isk.jvmhardcore.pjba.Assembling.createFile(classFile);
  }

  private void buildNop(ClassFileBuilder builder) {
    builder.newMethod("nop", "()V")
      .nop()
      .return_();
  }

  private void buildAConstNull(ClassFileBuilder builder) {
    builder.newMethod("aconst_null", "()Ljava/lang/Object;")
      .aconst_null()
      .areturn();
  }

  private void buildIConstM1(ClassFileBuilder builder) {
    builder.newMethod("iconst_m1", "()I")
      .iconst_m1()
      .ireturn();
  }

  // ...
}

Source

Les tests seront quant à eux ajoutés dans la classe AssemblerTest :

public class AssemblerTest {

  // ...

  @Test
  public void nop() {
    NoArg.nop();
  }

  @Test
  public void aconst_null() {
    final Object o = NoArg.aconst_null();
    Assert.assertNull(o);
  }

  @Test
  public void iconst_m1() {
    final int i = NoArg.iconst_m1();
    Assert.assertEquals(-1, i);
  }

  // ...
}

Source

Ajout des instructions avec arguments

Branche part14_02

Ajouter des instructions ayant besoin d’arguments implique de plus nombreuses modifications que précédemment.

Voyons tout d’abord les instructions que nous avons déjà étudiées et qui ont besoin d’arguments :

  • iload, lload, fload, dload, aload
  • istore, lstore, fstore, dstore, astore
  • bipush, sipush
  • ldc, ldc_w, ldc2_w

Les instructions xload et xstore prennent en argument un index parmi tous les index des variables locales. Néanmoins, il y a une limitation, l’argument a une taille d’un octet, ce qui signifie que l’on peut uniquement accéder aux index de 0 à 255. Or comme mentionné à plusieurs reprises, les variables locales peuvent contenir jusqu’à 65 536 valeurs. Nous verrons dans le prochain article comment faire pour accéder aux autres index.

Les instructions bipush et sipush prennent en argument la valeur à empiler, qui a respectivement pour taille un et deux octets.

Les instructions ldc, ldc_w et ldc2_w prennent en argument un index du pool de constantes. Lorsque l’on écrit ldc "Hello World" dans un fichier .pjb, l’instruction ldc, dans un fichier .class, a pour argument l’index d’un élément de type ConstantString. Lui-même pointant vers un élément de type ConstantUTF8 contenant la chaîne de caractères Hello World. Comme nous l’avons déjà vu, l’argument de l’instruction fait un octet alors que celui des instructions ldc_w et ldc2_w en fait deux.

ByteArgInstruction et ShortArgInstruction

Pour résumer, ces quinze instructions ont des arguments de un ou deux octets. Il nous suffit donc de créer deux nouvelles classes – en plus de la classe NoArgInstruction, prenant respectivement en paramètre de leur constructeur une valeur de type byte et une valeur de type short.

public class ByteArgInstruction extends Instruction {

  final private int arg;

  public ByteArgInstruction(int opcode, int stack, int locals, int arg) {
    super(opcode, stack, locals, 2);
    this.arg = arg;
  }

  @Override
  public void accept(Visitor visitor) {
    super.accept(visitor);
    visitor.visitInstructionByte(this.arg);
  }
}

Source

public class ShortArgInstruction extends Instruction {

  final private int arg;

  public ShortArgInstruction(int opcode, int stack, int locals, int arg) {
    super(opcode, stack, locals, 3);
    this.arg = arg;
  }

  @Override
  public void accept(Visitor visitor) {
    super.accept(visitor);
    visitor.visitInstructionShort(this.arg);
  }
}

Source

La taille de l’instruction passée au super constructeur sous la forme d’une littérale est respectivement 2 et 3 octets. L’opcode d’une instruction a pour taille un octet auquel nous ajoutons un ou deux octets en fonction du type d’argument.

Notons que dans les deux cas, le paramètre représentant l’argument dans le constructeur est de type int. Il conviendra donc de faire attention à la valeur de l’argument passé aux constructeurs des classes ByteArgInstruction et ShortArgInstruction pour qu’elle ne soit pas tronquée lors de la création du fichier .class, puisque nous ne faisons aucune vérification.

Visitor

Ces deux classes héritant de la classe Instruction, elles sont aussi Visitable. Nous redéfinissons donc la méthode accept(), en appelant la méthode du parent (Instruction) qui est en charge de l’opcode, puis appelons deux nouvelles méthodes (une pour chaque classe) nommées visitInstructionByte() et visitInstructionShort(), que nous devons ajouter dans l’interface Visitor.

void visitInstructionByte(byte value);
void visitInstructionShort(short value);

Source

Assembler

Des méthodes ayant été ajoutées dans l’interface Visitor nous devons les ajouter dans toutes les classes l’implémentant. Commençons par la classe Assembler – nous nous intéresserons aux deux autres (HexDumper et PjbDumper) un peu plus tard. La modification étant anecdotique :

@Override
public void visitInstructionByte(int arg) {
  this.writeByte(arg);
}

@Override
public void visitInstructionShort(int arg) {
  this.writeShort(arg);
}

Source

Instructions

Ces modifications effectuées, nous pouvons utiliser toutes les instructions prenant un argument d’un ou deux octets. Néanmoins, nous souhaitons faciliter l’utilisation de PJBA. Nous allons donc devoir enrichir la classe Instructions.

Malheureusement, les instructions ayant des arguments ne peuvent pas être représentées sous la forme de constantes. De fait, la méthode associée à l’instruction sera en charge de récupérer l’argument de l’instruction, ce qui permettra d’instancier une instruction de type ByteArgInstruction ou ShortArgInstruction. Par exemple :

public static Instruction sipush(short value) {
  return new ShortArgInstruction(0x11, 1, 0, value);
}

public static Instruction ldc(byte indexInCP) {
  return new ByteArgInstruction(0x12, 1, 0, indexInCP);
}

public static Instruction ldc_w(short indexInCP) {
  return new ShortArgInstruction(0x13, 1, 0, indexInCP);
}

public static Instruction dstore(byte indexInLV) {
  return new ByteArgInstruction(0x39, -2, indexInLV + 1, indexInLV);
}

Source

Nous verrons dans la partie Disassembler comment générer les maps d’association OPCODE_TO_METAINSTRUCTION et MNEMONIC_TO_METAINSTRUCTION.

MethodBuilder

La dernière étape – qui nous permettra de créer un fichier .class simplement en utilisant les instructions ayant des arguments – est de rajouter les méthodes correspondantes à la classes MethodBuilder. Néanmoins, contrairement aux méthodes existantes permettant d’ajouter des instructions sans arguments, ces nouvelles méthodes apporteront une plus-value à notre builder, en prenant en paramètres des valeurs identiques à celles d’un fichier .pjb et non des index du pool de constantes. Par exemple :

methodBuilder.ldc("Hello World")

De plus, seule la méthode ldc() sera disponible (il n’y aura donc pas de méthode ldc_w() ou ldc2_w()) avec différentes signatures permettant l’utilisation de tous les types de constantes. Il reviendra donc aux différentes méthodes ldc(), de créer les bonnes constantes dans le pool de constantes et potentiellement d’utiliser l’instruction ldc_w lorsque nécessaire (Si l’index dans le pool de constantes dépasse 255, la valeur maximum d’un octet).

public MethodBuilder sipush(short value) {
  this.code.addInstruction(Instructions.sipush(value));
  return this;
}

public MethodBuilder ldc(String value) {
  final int utf8Index = this.classFileBuilder
                            .getClassFile().addConstantUTF8(value);
  final int indexInCP = this.classFileBuilder
                             .getClassFile().addConstantString(utf8Index);

  this.internalLdc(indexInCP);

  return this;
}

public MethodBuilder ldc(double value) {
  final int indexInCP = this.classFileBuilder
                            .getClassFile().addConstantDouble(value);
  this.code.addInstruction(Instructions.ldc2_w((short) indexInCP));
  return this;
}

private void internalLdc(final int indexInCP) {
  if (indexInCP > 255) { // 255 = Byte.MAX_UNSIGNED_VALUE
    this.code.addInstruction(Instructions.ldc_w((short) indexInCP));
  } else {
    this.code.addInstruction(Instructions.ldc((byte) indexInCP));
  }
}

public MethodBuilder dstore(byte indexInLV) {
  this.code.addInstruction(Instructions.dstore(indexInLV));
  return this;
}

Source

A noter que l’utilisation des types byte et short en paramètres de certaines méthodes a pour objectif d’indiquer à l’utilisateur le type de valeur utilisable avec l’instruction.

Cela nous aura pris un peu de temps, mais nous avons passé en revue l’ensemble des modifications nécessaires à l’ajout d’instructions ayant des arguments.

A noter que dans les prochains articles, nous n’étudierons pas en détail l’ajout d’une instruction à PJBA.

Il ne reste donc plus qu’à ajouter les tests unitaires pour chacune des instructions dans la classe Classes créée un peu plus tôt. Néanmoins, cette fois nous allons générer deux fichiers .class (AllInstructionsWithoutDummiesInCP et AllInstructionsWithDummiesInCP), qui auront chacun une classe de test quasiment identique (AllInstructionsWithoutDummiesInCPTest et AllInstructionsWithDummiesInCPTest). Le premier ressemblera à celui que nous utilisions jusqu’à présent (AssemblerTest) et le second permettra de tester l’assemblage d’un fichier .class dont le pool de constantes à une taille supérieures à 256, ce qui implique l’utilisation d’index ayant une taille de deux octets.

@Test
public void assemble0() throws Exception {
  final String className = "org/isk/jvmhardcore/pjba/"
                         + "AllInstructionsWithoutDummiesInCP";

  final ClassFile classFile = this.buildClass(className, false);
  org.isk.jvmhardcore.pjba.Assembling.createFile(classFile);
}

@Test
public void assemble1() throws Exception {
  final String className = "org/isk/jvmhardcore/pjba/"
                         + "AllInstructionsWithDummiesInCP";

  final ClassFile classFile = this.buildClass(className, true);
  org.isk.jvmhardcore.pjba.Assembling.createFile(classFile);
}

private ClassFile buildClass(final String fullyQualifiedName,
                             final boolean withDummies) {
  final ClassFileBuilder builder = new ClassFileBuilder(fullyQualifiedName);

  if (withDummies) {
    this.addDummyConstants(builder);
  }

  this.buildNop(builder);
  this.buildAConstNull(builder);
  this.buildIConstM1(builder);
  // ...
  this.buildLdc(builder);
  if (withDummies) {
    this.buildLdcW(builder);
  }
  this.buildLdc2W(builder);

  //...
}

Source

Disassembler

Intéressons-nous à présent à l’opération de désassemblage (Disassembler).

Bien que la classe Disassembler compile toujours elle ne fonctionnera pas avec des fichiers .class contenant des instructions ayant des arguments. Actuellement, nous lisons un opcode et récupérons une instruction :

final int opcode = this.readUnsignedByte();
final Instruction instruction = Instructions.getInstruction(opcode);

Or nous n’avons pas ajouté les nouvelles instructions aux maps d’association OPCODE_TO_METAINSTRUCTION et MNEMONIC_TO_METAINSTRUCTION, puisque les instructions ayant besoin d’arguments ne peuvent être des constantes. Il nous faut alors trouver un autre mécanisme pour résoudre ce problème, qui peut être en définitive résumé de la manière suivante : “A partir d’un opcode (ou d’une mnémonique) nous souhaitons récupérer une MetaInstruction qui nous permettra de créer une instruction en appelant une méthode à laquelle nous pourrons potentiellement passer des paramètres dans le case des instructions ayant des arguments”. Pour essayer de trouver une solution partons du code de la méthode readInstructions() de la classe Disassembler. Au lieu de récupérer une Instruction, nous souhaitons récupérer une MetaInstruction :

final MetaInstruction metaInstruction = Instructions.getMetaInstruction(opcode);

Puis en fonction du type de l’instruction (sans argument ou avec un argument de type byte ou de type short) appeler une méthode permettant de construire une instruction. Ceci peut être traduit de la façon suivante :

si metaInstruction représente une instruction de type NoArgInstruction
    instruction = metaInstruction.buildInstruction()
si metaInstruction représente une instruction de type ByteArgInstruction
    byte b = this.readByte()
    instruction = metaInstruction.buildInstruction(b)
si metaInstruction représente une instruction de type ShortArgInstruction
    short s = this.readShort()
    instruction = metaInstruction.buildInstruction(s)

La question est maintenant de savoir à quoi correspondent les trois conditions. Le type de l’instruction pourrait être un champ de la classe MetaInstruction :

if (metaInstruction.getInstructionType() == NoArgInstruction.class)
    // ...

Mais ce code implique que la classe MetaInstruction implémente nos trois méthodes buildInstruction(). Il serait probablement préférable de tester directement le type de la MetaInstruction :

if (metaInstruction instanceof NoArgMetaInstruction) {
  instruction = ((NoArgMetaInstruction) metaInstruction).buildInstruction();
} else if (metaInstruction instanceof ByteArgMetaInstruction) {
  final byte b = this.readByte();
  instruction = ((ByteArgMetaInstruction) metaInstruction).buildInstruction(b);
} else if (metaInstruction instanceof ShortArgMetaInstruction) {
  final short s = this.readShort();
  instruction = ((ShortArgMetaInstruction) metaInstruction).buildInstruction(s);
}

Source

Nous devons donc rajouter trois nouvelles classes (NoArgMetaInstruction, ByteArgMetaInstruction et ShortArgMetaInstruction). Mais avant de nous intéresser à leur implémentation nous devons voir comment les méthodes buildInstruction() vont pouvoir instancier une Instruction liée à une MetaInstruction.

Une solution extrêmement simple semble d’utiliser le patron de conception fabrique, qui permet d’appeler une méthode retournant l’objet que nous souhaitons créer.

Nous allons créer deux interfaces (nous n’avons pas besoin d’instancier les instructions de type NoArgInstruction) :

public interface ByteArgInstructionFactory {
  Instruction buildInstruction(byte b);
}

Source

public interface ShortArgInstructionFactory {
  Instruction buildInstruction(short s);
}

Source

Ceci répond à notre question d’implémentation des méthodes buildInstruction(), mais nous avons encore repoussé le problème. Ce qui ne nous empêche pas de voir l’implémentation des MetaInstruction. La classe MetaInstruction est simplifiée (le champ instruction a disparu) :

public class MetaInstruction {

  final private String mnemonic;
  protected int opcode;

  public MetaInstruction(final String mnemonic) {
    this.mnemonic = mnemonic;
  }

  // Getters
}

Les trois classes liées aux trois types d’instructions n’ont rien d’extraordinaire.

NoArgMetaInstruction :

public class NoArgMetaInstruction extends MetaInstruction {

  private Instruction instruction;

  public NoArgMetaInstruction(final String mnemonic, final Instruction instruction) {
    super(mnemonic);

    this.instruction = instruction;
    this.opcode = instruction.getOpcode();
  }

  public Instruction buildInstruction() {
    return instruction;
  }
}

ByteArgMetaInstruction :

public class ByteArgMetaInstruction extends MetaInstruction {
  final private static byte BYTE_ZERO = 0;

  private ByteArgInstructionFactory instructionBuilder;

  public ByteArgMetaInstruction(final String mnemonic,
                                final ByteArgInstructionFactory instructionBuilder) {
    super(mnemonic);

    this.instructionBuilder = instructionBuilder;
    this.opcode = instructionBuilder.buildInstruction(BYTE_ZERO).getOpcode();
  }

  public Instruction buildInstruction(byte b) {
    return this.instructionBuilder.buildInstruction(b);
  }
}

La classe ShortArgMetaInstruction est quasiment identique. Les seules différences viennent des mots “byte” qui doivent être remplacés par “short”.

En définitive nous avons ajouté plusieurs classes, mais le code est toujours très simple. Il ne nous reste plus qu’à implémenter les interfaces XxxInstructionFactory. Sachant que nous avons déjà les méthodes nous permettant de construire toutes les instructions, ce sera toujours aussi simple, bien que nous allons devoir utiliser des classes anonymes. Heureusement, dans quelque mois, grâce Java 8 nous pourrons utiliser des lambdas, puisque nos fabriques (factories) sont des interfaces fonctionnelles.

list.add(new NoArgMetaInstruction("dconst_1", Instructions.DCONST_1));

list.add(new ByteArgMetaInstruction("bipush", new ByteArgInstructionFactory() {

  @Override
  public Instruction buildInstruction(byte b) {
    return Instructions.bipush(b);
  }
}));

list.add(new ShortArgMetaInstruction("sipush", new ShortArgInstructionFactory() {

  @Override
  public Instruction buildInstruction(short s) {
    return Instructions.sipush(s);
  }
}));

Pour conclure cette partie, notons que les tests mis en place dans l’article précédent nous permettent de tester le désassembleur sans aucune modification du code. Pour rappel, lors de la construction du projet, une étape génère des fichiers .class (Assembling et Classes) – dont les deux fichiers contenant toutes les instructions de PJBA, l’une désassemble tous ces fichiers et les régénère (Disassembling) et l’autre exécute les tests unitaires en ayant dans son classpath les fichiers régénérés.

Tout ce que nous avons fait dans cette partie n’étant pas lié directement à l’assembleur, pour éviter de surcharger la classe Instructions, nous devons déplacer le code propre aux MetaInstructions dans une classe MetaInstructions. Nous devons aussi réorganiser un petit peu l’organisation des classes.

org.isk.jvmhardcore.pjba.instruction
|  +- factory
|  |  +- ByteArgInstructionFactory.java
|  |  +- ShortArgInstructionFactory.java
|  +- meta
|  |  +- ByteArgMetaInstruction.java
|  |  +- MetaInstruction.java
|  |  +- MetaInstructions.java
|  |  +- NoArgMetaInstruction.java
|  |  +- ShortArgMetaInstruction.java
|  +- ByteArgInstruction.java
|  +- Instructions.java
|  +- NoArgInstruction.java
|  +- ShortArgInstruction.java

Dumpers

Ajouter des instructions ayant des arguments introduit une nouvelle problématique, au niveau de nos dumpers, relative à l’affichage des arguments. La classe PjbDumper doit afficher des valeurs et la classe HexDumper des valeurs ou des index du pool de constantes tout en faisant la distinction entre les deux. Et pour ce faire nous avons besoin de connaître le type des arguments. Par exemple pour l’instruction ldc nous devons savoir que l’argument est un index du pool des constantes pouvant pointer vers plusieurs types de constantes, pour que nous puissions afficher ldc # à l’aide de la classe HexDumper et ldc <Chaîne de caractères> à l’aide de la classe PjbDumper. En revanche, le choix a été fait d’afficher un index dans les variables locales dans le symbole dièse. Par exemple, quel que soit le dumper nous afficherons toujours iload 5.

De fait, nous pouvons utiliser les types d’arguments suivants, sous la forme d’une énumération :

public static enum ArgsType {
  NONE,
  BYTE_VALUE, // => 1 byte
  SHORT_VALUE, // => 1 short
  IFS_CONSTANT, // int, float et String => 1 byte
  W_IFS_CONSTANT, // int, float et String => 1 short
  LD_CONSTANT // long et double => 1 short
}

Source

Ce qui implique la modification du constructeur de nos classes MetaInstruction pour pouvoir ajouter le type des arguments :

public MetaInstruction(final String mnemonic,
                       final ArgsType argsType) {
  // ...
}

public ByteArgMetaInstruction(final String mnemonic,
                              final ArgsType argsType,
                              final ByteArgInstructionFactory instructionBuilder) {
  super(mnemonic, argsType);

  // ...
}

Et de fait la création de la liste temporaire permettant la création de la liste servant à générer les maps d’association.

list.add(new NoArgMetaInstruction("dconst_1", ArgsType.NONE, /* ... */ ));
list.add(new ByteArgMetaInstruction("bipush", ArgsType.BYTE_VALUE, /* ... */ ));
list.add(new ShortArgMetaInstruction("sipush", ArgsType.SHORT_VALUE, /* ... */ ));
list.add(new ByteArgMetaInstruction("ldc", ArgsType.IFS_CONSTANT, /* ... */ ));
list.add(new ShortArgMetaInstruction("ldc_w", ArgsType.W_IFS_CONSTANT, /* ... */ ));
list.add(new ShortArgMetaInstruction("ldc2_w", ArgsType.LD_CONSTANT, /* ... */ ));

Source

HexDumper

Avec ce nouveau mécanisme, les modifications de la classe HexDumper sont assez simples. La méthode visitOpcode() récupère la MetaInstruction liée à l’opcode. Cette MetaInstruction est ensuite utilisée par les méthodes visitInstructionByte() et visitInstructionShort() pour afficher les données de manière adaptée.

private MetaInstruction metaInstruction;

@Override
public void visitOpcode(int opcode) {
  this.metaInstruction = Instructions.getMetaInstruction(opcode);
  this.pjb.append(this.getHexAndAddByte()).append("\t");
  this.pjb.append("      ").append(this.metaInstruction.getMnemonic());

  if (this.metaInstruction.getArgsType() == ArgsType.NONE) {
    this.pjb.append("\n");
  }
}

@Override
public void visitInstructionByte(int value) {
  final ArgsType type = this.metaInstruction.getArgsType();
  switch (type) {
    case BYTE_VALUE:
      this.pjb.append(" ").append(value).append("\n");
      break;
    case IFS_CONSTANT:
      this.pjb.append(" #").append(BytecodeUtils.unsign((byte)value)).append("\n");
      break;
    default:
      throw new RuntimeException("Incorrect type: " + type
                               + " for the value: " + value);
  }
}

@Override
public void visitInstructionShort(int value) {
  final ArgsType type = this.metaInstruction.getArgsType();
  switch (type) {
    case SHORT_VALUE:
      this.pjb.append(" ").append(value).append("\n");
      break;
    case W_IFS_CONSTANT:
    case LD_CONSTANT:
      this.pjb.append(" #").append(BytecodeUtils.unsign((short)value)).append("\n");
      break;
    default:
      throw new RuntimeException("Incorrect type: " + type
                               + " for the value: " + value);
  }
}

Source

A noter que la classe BytecodeUtils contient trois méthodes unsignXxx() permettant de convertir un nombre signé en nombre non signé.

PjbDumper

Les modifications de la classe PjbDumper ne sont – quant à elles – pas plus compliquées. Nous avons uniquement besoin d’une méthode supplémentaire nous permettant l’affichage des arguments de type IFS_CONSTANT, W_IFS_CONSTANT et LD_CONSTANT qui sont des index du pool de constantes, mais pour lesquels nous souhaitons afficher la valeur de la constante et non son index.

public static String getPrintableConstant(int index, ClassFile classFile) {
  final ConstantPoolEntry constant = classFile.getConstant(index);

  switch (constant.tag) {
    case 3:
      final Constant.Integer cInteger = (Constant.Integer) constant;
      return String.valueOf(cInteger.integer);
    case 4:
      final Constant.Float cFloat = (Constant.Float) constant;
      return String.valueOf(cFloat.floatValue);
    case 5:
      final Constant.Long cLong = (Constant.Long) constant;
      return String.valueOf(cLong.longValue);
    case 6:
      final Constant.Double cDouble = (Constant.Double) constant;
      return String.valueOf(cDouble.doubleValue);
    case 8:
      final Constant.String cString = (Constant.String) constant;
      final int utf8Index = cString.utf8Index;
      final String value = ((Constant.UTF8) classFile.getConstant(utf8Index)).value;
      return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
    default:
      return null;
  }
}

Source

Mais il nous manque une dernière chose. Un fichier .pjb ne permettant pas l’utilisation de la mnémonique ldc_w, nous avons besoin de faire évoluer une dernière fois les classes MetaInstruction en rajoutant un constructeur qui, en plus des paramètres des autres constructeurs, prendra une mnémonique tel qu’elle doit être affichée dans un fichier .pjb :

public class MetaInstruction {

  public MetaInstruction(final String mnemonic,
                         final ArgsType argsType) {
    this(mnemonic, mnemonic, argsType);
  }

  public MetaInstruction(final String mnemonic,
                         final String pjbMnemonic,
                         final ArgsType type) {
    // ...
    this.pjbMnemonic = pjbMnemonic;
    // ...
  }
}

Source

list.add(new ShortArgMetaInstruction("ldc_w", "ldc", ArgsType.W_IFS_CONSTANT, /* ... */ ));

Source

A présent, il ne nous reste plus qu’à adapter la classe PjbDumper pour refléter toutes les nouveautés. La méthode visitOpcode() étant similaire à celle de la classe HexDumper, elle n’a pas été reprise.

// Valeur assignée dans la méthode visitOpcode()
private MetaInstruction metaInstruction;

@Override
public void visitInstructionByte(int value) {
  Object printableValue = null;

  final ArgsType type = this.metaInstruction.getArgsType();
  switch (type) {
    case BYTE_VALUE:
      printableValue = value;
      break;
    case IFS_CONSTANT:
      final byte unsignedValue = BytecodeUtils.unsign((byte)value);
      printableValue = StringValues.getPrintableConstant(unsignedValue,
                                                         this.classFile);
      break;
    default:
      throw new RuntimeException("Incorrect type: " + type
                               + " for the value: " + value);
  }

  this.pjb.append(" ").append(printableValue).append("\n");
}

@Override
public void visitInstructionShort(int value) {
  Object printableValue = null;

  final ArgsType type = this.metaInstruction.getArgsType();
  switch (type) {
    case SHORT_VALUE:
      printableValue = value;
      break;
    case W_IFS_CONSTANT:
    case LD_CONSTANT:
      final short unsignedValue = BytecodeUtils.unsign((short)value);
      printableValue = StringValues.getPrintableConstant(printableValue,
                                                         this.classFile);
      break;
    default:
      throw new RuntimeException("Incorrect type: " + type
                               + " for the value: " + value);
  }

  this.pjb.append(" ").append(printableValue).append("\n");
}

Source

A noter que la dernière partie de cet article sera consacrée aux tests de la classe PjbDumper.

L’analyseur syntaxique de PJBA

Branche part14_03

Le fonctionnement et la création d’un analyseur syntaxique ont été détaillés dans l’article “Mon premier analyseur syntaxique” (Part 7 et Part 8). De fait, nous n’y reviendrons pas, ni ne détaillerons comment implémenter la grammaire dans notre analyseur syntaxique. En revanche, nous nous attarderons sur les classes PjbaParser et PjbaTokenizer.

Tout le code de l’analyseur syntaxique est présent dans le package org.isk.jvmhardcore.pjba.parser et le coeur, que nous avons étudié dans les parties 7 et 8, dans le sous-package org.isk.jvmhardcore.pjba.parser.core. Le code spécifique à l’analyseur PJB se résume donc à cinq classes :

  • Symbols : représentant tous les symboles d’une grammaire
  • EventType : représentant les symboles terminaux d’une grammaire, à l’exception de ceux non utiles pour la création de notre arbre syntaxique
  • Productions : représentant la description d’un symbole
  • PjbTokenizer : permettant de récupérer les valeurs liées aux types d’événements
  • PjbParser : permettant de construire un graphe d’objet ClassFile à partir d’un fichier .pjb

La grammaire suivante permet – en plus de ce que faisait l’analyseur présenté dans l’article Part 2 – de créer plusieurs classes et méthodes dans un seul fichier .pjb et d’indiquer des modificateurs pour les classes et les méthodes.

classes = class {class}
class = ws classStartIdentifier ws classModifiers
        className methods classEndIdentifier ws
methods = {method}
method = ws methodStartIdentifier ws methodsModifiers methodName ws
         methodSignature methodContent methodEndIdentifier ws
className = identifier {identifierSeparator identifier}
methodName = identifier
methodSignature = parametersStart parametersDescriptor parametersEnd returnDescriptor
parametersDescriptor = descriptor {descriptor}
returnDescriptor = descriptor | 'V'
methodContent = {ws instruction ws}
instruction = mnemonic mws {arg mws}
identifier = asciiJavaLetter {asciiJavaLetter | digit}
mnemonic = ? known mnemonic ?
arg = ? authorized argument for mnemonic ?
classModifiers = {classModifier ws}
methodsModifiers = {methodsModifier ws}
classStartIdentifier = '.class'
classEndIdentifier = '.endclass'
methodStartIdentifier = '.method'
methodEndIdentifier = '.endMethod'
classModifier = 'public' | 'final' | 'abstract' | 'interface' | 'super'
methodsModifier = 'public' | 'protected' | 'private' | 'static' | 'final' |
                  'strictfp' | 'synchronized' | 'abstract'  | 'native'
descriptor = 'B' | 'C' | 'D' | 'F' | 'I' | 'J' | 'S' | 'Z' |
             '[' ['descriptor | 'L' className ';'
parametersStart = '('
parametersEnd = ')'
comment = singlelineComment | multilineComment
singlelineComment = ('@' | ';' | '#') ? All characters but LF and EOF ? (LF | EOF)
multilineComment = '/*' ? All characters but '*/'? '*/'
asciiJavaLetter = ? a-z | A-Z | '_' | '$' ?
digit = ? 0-9 ?
identifierSeparator = '/'
mws = TAB | SPACE | NEW_LINE
ws = ? {mws | comment | BLANK} ?

Cette grammaire nous permet de créer des fichiers .pjb de la forme suivante :

.class public org/isk/pjb/MultiOne
  .method public static add(II)I
    iload_0
    iload_1
    iadd
    ireturn
  .methodend

  .method public static sub(II)I
    iload_0
    iload_1
    isub
    ireturn
  .methodend
.classend

.class public org/isk/pjb/MultiTwo
  .method public static ishl(II)I
    iload_0
    iload_1
    ishl
    ireturn
  .methodend

  .method public static d2i(D)I
    dload_0
    d2i
    ireturn
  .methodend
.classend

Source

PjbTokenizer

La classe PjbTokenizer contient des méthodes pouvant être divisées en quatre catégories :

  • Les méthodes getXxx() utilisées par la classe PjbParser, permettant de récupérer des valeurs
  • Les méthodes checkXxx() aussi utilisées par la classe PjbParser, permettant de consommer des caractères en vérifiant qu’un token est bien présent (par exemple .classend)
  • Les méthodes isXxx() utilisées par les classes de type Productions, permettant de savoir quel est le symbole à venir (par exemple doit-on analyser une autre méthode ou s’attendre à la fin d’une classe ?)
  • Les méthodes consumeXxx() utilisées par les classes de type Productions et PjbParser, permettant de consommer des caractères inutiles, tels que les espaces ou les commentaires.

Hormis les méthodes getIfsConstant() et getLdConstant() permettant de récupérer le paramètre des instructions ldc et ldc2_w dont le type peut être multiple, toutes les autres sont assez simples à comprendre.

A noter que contrairement à notre analyseur d’expressions mathématiques, celui de PJBA permet d’écrire des nombres à virgule ayant la même forme qu’en java (par exemple 1.4e-24). En revanche, il n’est nipas possible d’utiliser les notations introduites en Java 7 telles que 18_543 ou 0b00100001.

PjbParser

La classe PjbParser est quant à elle extrêmement simple. Le graphe d’objets est construit en utilisant les classes ClassFileBuilder et MethodBuilder dans le but de ne pas dupliquer la logique de construction des objets.

  • La méthode initProductionTable() associe les symboles aux productions.
  • La méthode parse() contient un switch/case de tous les événements et retourne une liste de ClassFile.
  • La méthode processInstruction(), comme son nom l’indique, reconstitue une instruction pour obtenir un objet Java.

Voyons un peu plus en détail la méthode processInstruction(). Cette méthode est exécutée suite à un événement EventType.INSTRUCTION.

  1. Nous récupérons une MetaInstruction à partir d’une mnémonique venant d’être analysée. Si nous ne trouvons pas de MetaInstruction associée à la mnémonique alors une exception est levée.

  2. Nous utilisons le type de l’argument (MetaInstruction.ArgsType) pour déterminer quel type d’instruction nous souhaitons construire. Pour les types NONE, BYTE_VALUE et SHORT_VALUE nous utilisons la méthode buildInstruction(). En revanche pour les types IFS_CONSTANT et LD_CONSTANT, nous appelons directement la méthodes ldc() de l’instance courante du MethodBuilder. De cette manière, les méthodes getIfsConstant() et getLdConstant() retournent le type précis de l’argument (String, Integer, Long, Float ou Double), ce qui nous permet de le convertir pour appeler la bonne méthode ldc(). Par exemple :

if (ifs instanceof Integer) {
  this.methodBuilder.ldc((int) ifs);
}

Outre l’ajout de l’analyseur syntaxique, nous avons modifié les objets ClassFile et Method, ainsi que ClassFileBuilder et MethodBuilder pour permettre l’utilisation de modificateurs.

La classe PjbDumper a aussi été modifiée pour générer ces modificateurs dans un fichier .pjb.

Tester PjbDumper

Pour tester la classe PjbDumper la solution la plus efficace est d’utiliser notre analyseur syntaxique sur la chaîne de caractères dumpée (par la classe PjbDumper), pour ensuite réassembler le graphe d’objets ClassFile en un fichier .class, et finalement exécuter les tests unitaires en ayant dans le classpath ces classes assemblées. De plus, nous souhaitons avoir le plus de cas possibles. Bien qu’être exhaustif est impossible, le minimum est d’avoir un fichier .pjb comparable à la classe construite par la classe Classes. Néanmoins, créer de multiples graphes d’objets est assez rébarbatif et nous souhaitons éviter d’avoir à créer de nouvelles étapes dans notre processus de construction, puisque pour rappel, nous exécutons déjà deux fois les tests unitaires : une fois avec les fichiers assemblés et une autre avec les fichiers désassemblés et réassemblés. Nous allons donc faire un choix discutable, nous brancher sur la phase de désassemblage :

public void disassemble(final File file) throws Exception {
  // Désassemble
  final InputStream inputStream = new FileInputStream(file);
  final DataInput dataInput = new DataInputStream(inputStream);
  final Disassembler disassambler = new Disassembler(dataInput);
  final ClassFile classFile = disassambler.disassemble();

  // Crée une chaîne de caractères au format .pjb
  final PjbDumper pjbDumper = new PjbDumper(classFile);
  final String dump = pjbDumper.dump();

  // Analyse la chaîne de caractères
  final InputStream is = new ByteArrayInputStream(dump.getBytes("UTF-8"));
  final PjbParser parser = new PjbParser(file.getCanonicalPath(), is);
  final List<ClassFile> classFiles = parser.parse();

  // Génère les fichiers .class
  for (ClassFile cf : classFiles) {
    this.createFile(cf);
  }
}

Source

What’s next ?

Dans l’article suivant, nous allons reprendre notre étude des instructions de la JVM, en commençant par des instructions liées aux variables locales et aux opérations arithmétiques que nous avions mises de côté.