15.1 Stack-Based and Register-Based Bytecode
Native machine code commits a program to one instruction set. Bytecode instead targets a virtual instruction set implemented by an interpreter, JIT compiler, or both. This extra layer can make programs portable, compact, easy to inspect, and quick to start. It also gives the runtime control over dynamic types, garbage collection, exceptions, sandboxing, and profiling.
A bytecode format is an engineering contract. It defines opcodes, operand encoding, value representation, control-flow targets, function metadata, constant pools, exception tables, and versioning. Once files are distributed or cached, changing that contract requires compatibility planning just like changing a hardware ISA. Two common instruction models are stack-based and register-based bytecode.
Stack machines make operands implicit
A stack instruction consumes values from the top of an operand stack and pushes results. The expression (a + b) * c might become:
load_local 0 ; stack: [a]
load_local 1 ; stack: [a, b]
add ; stack: [a+b]
load_local 2 ; stack: [a+b, c]
mul ; stack: [(a+b)*c]
returnadd needs no register operands because its inputs and output positions are implicit. That makes individual instructions small, simplifies code generation, and works naturally with expression-tree traversal. JVM bytecode, WebAssembly's validation model, and many compact language VMs use stack semantics, although their type systems and binary encodings differ.
The compiler must track stack height and value types at every program point. All control-flow predecessors entering one block must agree on the expected stack state, or the bytecode is malformed. A verifier can simulate stack effects without running arbitrary values: load adds one slot, binary add removes two and adds one, and return consumes the declared result. It also checks underflow, maximum depth, operand types, branch targets, and exception-handler entries.
Register machines name data flow explicitly
A register-based VM uses numbered virtual registers or frame slots:
load r0, local0
load r1, local1
add r2, r0, r1
load r3, local2
mul r4, r2, r3
return r4This form needs fewer instructions for many expressions because values remain named rather than being repeatedly pushed, popped, duplicated, or swapped. It resembles three-address IR, which can simplify translation to native machine code and make def-use relationships explicit. The price is wider instructions: add must encode a destination and two sources. Compact formats may use 8-bit register indices for common functions and a wide prefix or alternate opcode when a function needs more.
Some designs use two-address operations such as add r0, r1, overwriting one input. Others use three-address operations. “Registers” are usually not physical CPU registers; they are indexed locations in a VM frame. An interpreter may store them in an array, while a JIT later maps hot values to real registers.
Encoding, control flow, and format evolution
Opcode frequency matters. Common operations deserve short encodings; rare operations can afford longer forms. Operands may be fixed-width, variable-length integers, or indices into a constant pool. Fixed widths decode quickly and permit direct instruction indexing, while variable widths reduce size but make boundary discovery and random access harder. A wide prefix saves common-case bytes but creates another decoder path to test.
Branches can encode byte offsets, instruction indices, or labels resolved by the assembler. The emitter usually performs a layout pass, calculates displacements, and widens short branches that no longer fit. Exception handlers need protected ranges and target stack/frame states. Debug tables map bytecode offsets back to source locations. Garbage-collected runtimes may need stack maps identifying which frame slots contain references at safepoints.
Neither stack nor register bytecode wins everywhere. Measure serialized size, decode complexity, instruction count, dispatch count, verifier cost, interpreter speed, JIT translation cost, and debugging quality on representative workloads. Hybrid designs are common: an external stack format may be decoded into internal register-like instructions, or frequently adjacent opcodes may be fused into superinstructions. The correct design is the one whose invariants are explicit, verifiable, evolvable, and aligned with the runtime that will execute it.