Important considerations when designing a new virtual ISA


Published:
Tags:LLVM

The shape of the ISA significantly shapes the implementation of the LLVM backend. Changes to the properties of the VM, such as how it interacts with the host, may require reengineering large parts of the backend. Getting the cornerstones of the architecture right is foundational to the implementation. All the points I will highlight in this post come back to the same question: what purpose does the VM fulfill, and how much pain can you endure or want to inflict?

For this post, I am making a few assumptions to keep the scope manageable: the VM is single-threaded, memory access is atomic, and exceptions are not yet supported. These topics will be covered in a follow-up post once I have built a prototype.

When building an LLVM backend for a virtual ISA, you have a unique opportunity: unlike hardware ISAs, you are not constrained by silicon limitations, fabrication costs, or power consumption. You can design an ISA that is perfectly tailored to your use case. However, this freedom comes with a cost — every decision you make will influence the complexity of your backend implementation.

VM Boundary

The boundary between the VM and the host is the most foundational decision, as it dictates large parts of the VM design and has significant implications for the LLVM backend.

Memory Access: Transparent vs. Isolated

Transparent memory access means the VM and host share the same virtual memory space. The VM has direct access to the host, and vice versa. Memory pointers can be passed across the boundary freely.

Isolated memory access means the VM and host have separate memory spaces. An address translation component handles pointer translation, and memory must be explicitly mapped into the VM.

Call Boundary: Transparent vs. Isolated

Transparent call boundary allows the VM to directly call functions in the host address space (such as malloc).

Isolated call boundary means the VM is running in isolation.

Context Location

Where is the VM context stored?

Memory Model

The memory model defines how the VM interacts with memory and its properties, which has significant implications for the generated code.

Endianness

There are generally three endianness types: little-endian, big-endian, and middle-endian. But more important is the question of whether the VM and the host share the same endianness. While differing endianness can improve robustness against reverse engineering, matching endianness simplifies the emulator and is necessary for transparent memory access. Choosing the archaic middle-endianness has the disadvantage that extending stores and truncating loads become more complex, for example when only reading or writing the lowest 16 bits from a 64-bit value.

Alignment Requirements

Are there special alignment requirements within the VM? If all instructions are multiples of 16 bits wide, for example, jumps would not need to encode the lowest bit and the jump distance doubles. The same applies to memory access instructions.

Against intuition, this is not dictated by the host architecture as this can be worked around in the ISA. However, if you decide on differing alignment requirements, you need to scrutinize each structure that may cross the VM boundary, even those invisible to the developer like vtables.

Alignment requirements influence the offset of fields within structures as padding is introduced or omitted. You can try this if you know what you are doing, but be prepared to spend a significant amount of time bridging the gap between VM and host memory.

Access Widths

The memory access granularity has multiple subtle implications for all parts of the VM. If the access width is larger than the smallest register width, you will either need to:

Generally, the simplest approach is to support the same access width as the host ISA. However, the access width also needs to be encoded in the instruction, either increasing instruction size or reducing the available offset bits.

Stack Direction and Type

How does the stack grow, and what is its initial state? For transparent calling, the stack direction and type must match the host ISA. While conventionally there are two directions and two types (so, four combinations in total), feel free to come up with a new one — it is a virtual ISA and does not need to be practical.

Register Architecture

The register set is another fundamental aspect that significantly affects code generation.

Register Width

For transparent memory access and calls, registers should be of equal width to the host registers. This allows seamless integration between VM and host code.

Number of Registers

Key considerations:

Subregisters

Do you need subregister access (e.g., accessing the lower 32 bits of a 64-bit register)?

Special Purpose Registers

Beyond general-purpose registers, consider:

Instruction Set Design

The instruction set defines the operations your VM can perform. Since this is a virtual ISA, you have more freedom than with hardware ISAs.

RISC vs. CISC

This is a classic trade-off between simplicity and expressiveness.

RISC (Reduced Instruction Set Computer)

CISC (Complex Instruction Set Computer)

For an LLVM backend, RISC-style ISAs are often easier to work with due to their regularity and simpler instruction patterns.

Instruction Width

Immediate Encoding

How are immediate values encoded in instructions? This also depends on the instruction. Extended immediates mostly make sense for dedicated load instructions; simple immediates may be used for arithmetic; shifted immediates for offsets into memory.

Special Operations

Consider what special operations your ISA needs to support efficiently:

But keep in mind that you will need to find the bits necessary to encode all these instructions and the fortitude to implement them all — bug-free (or at least strongly bug-reduced).

Conditional Execution

How are conditional branches and operations handled?

Key questions to consider:

Calling Convention

The calling convention defines how functions receive arguments, return values, and manage the stack.

Caller vs. Callee Saved Registers

Cleanup Responsibility

Who is responsible for cleaning up the stack after a function call? While the caller cleanup is more common and simpler to implement, the callee cleanup could help enable tail call optimizations and throw curve balls at the reverse engineer.

Return Address

Frame Pointer

Guidelines for Virtual ISAs

When designing a virtual ISA, remember:

  1. Virtual ISAs are not bound by the constraints of real hardware. You can make design choices that would be impractical for silicon.
  2. Threading could be built into the ISA (e.g., lockstepping, fork + join), but for now we are assuming a single-threaded VM.
  3. It does not need to be efficient or efficiently emulatable. Focus on simplicity and correctness first.
  4. May not be deterministic, especially if the VM interacts with external state.
  5. Could allow for external signals for preemption in the future, but for this initial design we are assuming no exceptions.

Conclusion

Designing a virtual ISA for an LLVM backend requires careful consideration of many interrelated factors. The key is to always tie each decision back to your VM’s purpose and accept the trade-offs that come with each choice.

As a bonus for reading this far, here are two ideas you may (or may not) want to try out:

I take no responsibility for any harm to you, loved ones, reverse engineers, or any other party as direct or indirect result from adhering to or deviating from any of these recommendations, even implied ones.

In this post, I have outlined the foundational decisions for a single-threaded VM with atomic memory access and no exception support. In a follow-up post, I will explore how to add threading, non-atomic memory access, and exception handling to the design.