JVM Hardcore – Part 15 – Bytecode – Variables locales et Maths, le retour
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
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);
}
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 typexstore
:..., v -> ...
- Si
wide
est associée aux instructions du typexload
:... -> v
- Si
wide
est associée à l’instructioniinc
:... -> ...
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
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);
}
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
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);
}
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;
}
}
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);
}
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);
}
}));
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);
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;
}
}
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;
}
}
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);
}
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);
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;
}
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);
}
}
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);
}
}));
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;
}
}
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 MetaInstruction
s 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;
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.