I am writing a java class file parsing & serialization library. As part of that i needed to implement a structure for the JVM bytecode instructions as well as come up with a way to parse & serialize these in an easy & generic way.
There are over 200+ instructions defined in the JVM bytecode spec and they have variable size due to operands. Due to my philosophy of how to write parsers (listed below as requirements for my solution) this turned out to be a bit of a challenge to deal with.
I looked up several open source implementations of JVM bytecode instructions and other types of instructions, but it seems basically everyone i could find solved it in a way I'd consider bad.
I had 3 key requirements for my solution.
Avoid blatant memory waste: One of the most common ways i saw other projects implement their instruction structure was by either simply having a vector of U32s or a union. I do not want to just store every operand as the largest type however as I imagine it wastes quite a bit of memory even if it may technically be equally fast or faster, due to the way the CPU handles passing these values under the hood anyway.
Fully parse the binary data into a real struct representation: The other most common way I saw instructions implemented was by simply not dealing with parsing operands into a ""real"" structure at all. Basically a lot of implementations would instead represent "instructions" as a single U32 offset into the classfile, which they'd keep loaded in memory. I do not want to keep the classfile or any part of it loaded after memory with structures that point back into it. I want everything to be parsed into real structures you'd interface with just like if it was an actual structure you had in your code.
Avoid code repetition: The way a lot of the instructions dealt with the 200+ opcodes was by simply repeating the exact same line 200+ times. I definitely do not like this especially because you'd not only have to repeat those 200+ lines in one place, but in many places, such as the name function (GetOpcodeNames), enum, various constructors, serializer, parser, etc.
Anyway, here's my code which I think solves all these problems (but you may be able to find other problems with):
#include <vector>
#include <variant>
#include <cstdint>
#include <cassert>
#include <iostream>
#include <fstream>
#include <tuple>
using U8 = std::uint8_t;
using U16 = std::uint16_t;
using U32 = std::uint32_t;
using S8 = std::int8_t;
using S16 = std::int16_t;
using S32 = std::int32_t;
//macro that lets caller generate code for each JVM defined instruction, by passing their own macro (_OP).
//For each instruction the instruction name, value, and thereafter operand types, are passed as arguments.
//see example use in the OPCODE enum.
#define ITERATE_OPCODES( _OP ) \
_OP(NOP, 0x00) \
_OP(RET, 0x01) \
_OP(BIPUSH, 0x03, U8) \
_OP(TABLESWITCH, 0x04, U16, U16)
//...repeat for all 200+ instructions
enum class OPCODE : U8
{
#define _ENUM_ENTRY(OP, VAL, ...) OP = VAL,
ITERATE_OPCODES( _ENUM_ENTRY )
#undef _ENUM_ENTRY
};
//highlight why i like the macro use here. Notice what i can do in 11 lines which would otherwise be 200+
const char* GetOpcodeName(OPCODE op)
{
switch(op)
{
#define _CASE_ENTRY(OP, ...) case OPCODE::OP: return #OP;
ITERATE_OPCODES( _CASE_ENTRY )
#undef _CASE_ENTRY
};
assert(false);
return "";
}
struct Instruction
{
OPCODE Opcode;
using OperandT = std::variant<U8, U16, U32, S8, S16, S32>;
std::vector<OperandT> Operands;
template<typename... Args>
Instruction(OPCODE opcode, Args&&... args): Opcode{opcode}
{
//TODO (looking for help on how to achieve this): validate operand types match corresponding operand type as defined in ITERATE_OPCODES, at compile time
//e.g. for BIPUSH the variadic argument should have 1 argument in total and it should be of type U8
(Operands.push_back(std::forward<Args>(args)), ...);
}
//stream parser for Instruction
//TODO: theres some code duplication with the above constructor here
//ideally i'd like to just read the opcode & operands then call the delegate constructor, to fix this.
//However i cannot do this as the data is not constant.
Instruction(std::istream& stream)
{
OPCODE opcode;
stream.read(reinterpret_cast<char*>(&opcode), sizeof(opcode));
switch(opcode)
{
#define _CASE_ENTRY(OP, VAL, ...) case OPCODE::OP: \
{ \
std::tuple<__VA_ARGS__> operands; \
\
std::apply([&](auto&&... args) \
{ \
(stream.read(reinterpret_cast<char*>(&args), sizeof(args)), ...); \
(Operands.push_back( args ), ...); \
}, operands); \
\
}break;
ITERATE_OPCODES( _CASE_ENTRY )
#undef _CASE_ENTRY
default: throw std::runtime_error{"unknown opcode encountered."};
}
Opcode = opcode;
}
};
//helper template function for writing
template <typename T>
void Write(std::ostream& stream, const T& t)
{
stream.write(reinterpret_cast<const char*>(&t), sizeof(T));
}
//stream serializer for Instruction
std::ostream& operator<<(std::ostream& stream, const Instruction& instr)
{
Write(stream, instr.Opcode);
for(const Instruction::OperandT& operand : instr.Operands)
{
if(std::holds_alternative<U8>(operand))
Write(stream, std::get<U8>(operand));
else if(std::holds_alternative<U16>(operand))
Write(stream, std::get<U16>(operand));
else if(std::holds_alternative<U32>(operand))
Write(stream, std::get<U32>(operand));
else if(std::holds_alternative<S8>(operand))
Write(stream, std::get<S8>(operand));
else if(std::holds_alternative<S16>(operand))
Write(stream, std::get<S16>(operand));
else if(std::holds_alternative<S32>(operand))
Write(stream, std::get<S32>(operand));
else
throw std::runtime_error{"serialize encountered unknown operand type."};
}
return stream;
}
In short: instructions use tagged unions (variants) for operands to avoid the memory waste problem, and I utilize macros to get around the code repetition problem. The parser constructor is a bit gnarly but basically manages to extract the operand type information into a tuple from __VA_ARGS__
, read them using std::apply
, and then pack them into the Operands
vector with the correct type.
(If you know the JVM bytecode spec you'll notice there are some details that aren't handled, such as wide instructions or the more complex operands; I removed the code that handles that to keep it simple for reviewers to just review the base structure).
Quick rebuttal about the macros: I can already tell this is the thing people are going to scream at me. "UGLY MACROS UGLY MACROS!!!". Yes I understand macros are ugly but it seems to be my only solution here due to C++17's lack of reflection. If the choice is between repeating the same statement 200+ x 5 times or use a macro to just generate this thing, then isn't this the ONE place where using macros are acceptable? If at all macros are acceptable in any scenario wouldn't it be here?
#undef ITERATE_OPCODES
once we're done with it! \$\endgroup\$