Opcode Reference

This is the normative specification of Ember's instruction set. Every opcode is listed with its operands, the stack before, the stack after, the errors it can raise, and the lab that introduces it.

Keep this page open while you write Labs 9–11. When the implementation and this page disagree, one of them is a bug — and Lab 9's stack_effect test exists to make that disagreement a test failure rather than a mystery.


Conventions

[..., a, b]  →  [..., c]      `b` is the TOP of the stack. Operands are popped
                              right to left, so `b` was pushed last.

base                          the current frame's base: the value-stack index of
                              its slot 0. GET_LOCAL s reads stack[base + s].

ip                            the index of the NEXT instruction. All jump
                              operands are ABSOLUTE indices into `code`, not
                              relative offsets. See "Why absolute" below.

k:u16                         an index into the enclosing Chunk's constant pool.
s:u8                          a local slot number, relative to base.
u:u8                          an upvalue index into the current closure.
p:u16                         an index into the enclosing Chunk's `protos`.
t:u32                         an absolute instruction index (a jump target).
255                           in an argument/result count, means "all of them"
                              — the multiple-return sentinel. See Lab 17.

Every opcode may raise ErrorKind::Limit if the instruction budget is exhausted, because the check lives in the fetch position rather than in any individual opcode. That is not repeated in the per-opcode error columns.


The Representation

#![allow(unused)]
fn main() {
// src/bytecode.rs
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Op {
    LoadConst(u16), LoadInt(i32), LoadNil, LoadTrue, LoadFalse,
    GetLocal(u8), SetLocal(u8), Pop(u8),
    GetGlobal(u16), SetGlobal(u16),
    GetUpval(u8), SetUpval(u8), CloseUpvals(u8),
    Add, Sub, Mul, Div, IDiv, Mod, Pow, Concat,
    Neg, Not, Len,
    Eq, Ne, Lt, Le, Gt, Ge,
    Jump(u32), JumpIfFalse(u32), JumpIfFalseKeep(u32), JumpIfTrueKeep(u32),
    Call(u8, u8), Return(u8), Closure(u16),
    NewTable, GetIndex, SetIndex, GetField(u16), SetField(u16),
    SetList(u16, u32), SelfField(u16),
    ForPrep(u32), ForLoop(u32),
    Vararg(u8),
}
const _: () = assert!(std::mem::size_of::<Op>() == 8);
}
#![allow(unused)]
fn main() {
pub struct Chunk {
    pub code:      Vec<Op>,
    pub constants: Vec<Value>,     // deduplicated; strings interned
    pub lines:     Vec<Span>,      // ONE PER INSTRUCTION — code.len() == lines.len()
    pub protos:    Vec<Rc<Proto>>, // nested functions, compiled recursively
}

pub struct Proto {
    pub name:        Option<String>,
    pub nparams:     u8,
    pub is_vararg:   bool,
    pub max_stack:   u16,              // computed by the compiler; the VM reserves it
    pub chunk:       Chunk,
    pub upvals:      Vec<UpvalDesc>,   // Lab 14
    pub local_names: Vec<LocalDebug>,  // DEBUG ONLY — never read during execution
}
}

Note: Op is a Rust enum with typed operands, not a packed byte stream. That is a decision with a chapter: it costs 8 bytes per instruction where Lua uses 4, and it buys exhaustive matching, no decode step, and no class of "the operand width was wrong" bug. Section 7 measures the cost; capstone work may change it.

lines has exactly one entry per instruction. That invariant is asserted in Chunk::emit, because a drifting line table produces error messages that point at the wrong source and there is no other way to notice.


Why Jump Targets Are Absolute

Lua and most production VMs use relative offsets: the operand is added to the current ip. Relative jumps are position-independent (a chunk can be relocated or spliced) and fit in a smaller field.

Ember uses absolute targets, for one reason: JUMP 0016 in a disassembly listing is a target you can find with your eyes, and JUMP +7 is one you have to compute. Since jump patching is the single most off-by-one-prone thing in Section 3, making the artifact readable is worth the four bytes.

Write this in ADR-002's consequences. If you later want relocatable chunks — for a bytecode cache, or for splicing in a REPL — this is the decision to revisit.


Group 1: Constants and Literals

OpcodeOperandsStack beforeStack afterErrorsLab
LOAD_CONSTk:u16[...][..., constants[k]]—9
LOAD_INTn:i32[...][..., Integer(n)]—10
LOAD_NIL—[...][..., Nil]—9
LOAD_TRUE—[...][..., Boolean(true)]—9
LOAD_FALSE—[...][..., Boolean(false)]—9

Why LOAD_INT exists. LOAD_CONST costs a constant-pool slot and an indirection. Integers that fit in i32 — which is nearly all of them in real code — go straight in the instruction. Lua 5.4 added OP_LOADI for exactly this reason (rg -n 'OP_LOADI' lopcodes.h). Integers outside i32, and all floats and strings, use the pool.

Why LOAD_NIL/TRUE/FALSE rather than three constants. They are the three most common literals in any program, and giving them opcodes keeps them out of the pool entirely. Measure it in Section 7 if you want to know whether it mattered.


Group 2: Locals and the Stack

OpcodeOperandsStack beforeStack afterErrorsLab
GET_LOCALs:u8[...][..., stack[base+s]]—10
SET_LOCALs:u8[..., v][...]—10
POPn:u8[..., v₁..vₙ][...]—10

GET_LOCAL is the payoff of the whole section. It is one addition and one array load. In the tree walker the same read was a string hash plus a walk up the scope chain — measure both.

SET_LOCAL pops. An assignment statement leaves nothing behind. local x = 1 compiles to LOAD_INT 1; SET_LOCAL 0, and the net stack effect is zero — except that slot 0 is now live, and the compiler knows it because it allocated the slot. The stack and the local slots are the same array; slots occupy the bottom of the frame and temporaries sit above them.

POP n closes a scope. Leaving a block pops every local it declared, in one instruction.

Warning: The u8 slot number caps a function at 256 locals. That is a real limit, it is the same order as Lua's 200 (LUAI_MAXVARS), and the compiler must produce a clean compile error when a function exceeds it — never a truncated slot number. Test it with a generated function declaring 300 locals.


Group 3: Globals

OpcodeOperandsStack beforeStack afterErrorsLab
GET_GLOBALk:u16[...][..., globals[constants[k]]]— (missing → Nil)10
SET_GLOBALk:u16[..., v][...]—10

constants[k] must be a string. The validator enforces it, so the VM does not have to check.

A global access is a hash lookup, forever. It cannot be resolved to a slot, because the globals table can change between any two instructions. This is why Lua programmers write local sin = math.sin above a hot loop, and it is the motivating example for inline caches.


Group 4: Upvalues (Lab 14)

OpcodeOperandsStack beforeStack afterErrorsLab
GET_UPVALu:u8[...][..., *upvals[u]]—14
SET_UPVALu:u8[..., v][...]—14
CLOSE_UPVALSs:u8[...][...]—14

CLOSE_UPVALS s closes every open upvalue pointing at stack[base+s] or above: the value is copied out of the dying stack slot into a heap cell. It is emitted when a block ends and when a function returns. Section 4 explains what "closing" means and why it is the thing that makes make_counter() work.


Group 5: Arithmetic and Unary Operators

OpcodeOperandsStack beforeStack afterErrorsLab
ADD SUB MUL—[..., a, b][..., c]Runtime: non-number operand9
DIV POW—[..., a, b][..., Float]Runtime: non-number operand9
IDIV MOD—[..., a, b][..., c]Runtime: non-number; integer divisor 09
CONCAT—[..., a, b][..., Str]Runtime: operand not string/number10
NEG—[..., a][..., b]Runtime: non-number9
NOT—[..., a][..., Boolean]— (never errors)10
LEN—[..., a][..., Integer]Runtime: not a string or table10

These opcodes call the same helpers as the tree walker. Op::Add dispatches to the identical arith::add(a, b) function that eval_expr used. That is deliberate and it has a consequence you must understand: differential testing cannot find a bug in shared code. If arith::mod_ gets Lua's sign rule wrong, both backends are wrong together and the comparison passes. See the reference-implementation chapter's blind spot.

NOT never errors because every value has a truthiness. That single row is worth noticing: it is the only unary operator with no error column, and the reason is that truthiness is total.


Group 6: Comparison

OpcodeOperandsStack beforeStack afterErrorsLab
EQ NE—[..., a, b][..., Boolean]— (never errors)10
LT LE GT GE—[..., a, b][..., Boolean]Runtime: incomparable types10

Why GT and GE exist, when Lua has neither. Lua compiles a > b as b < a — it swaps the operands at compile time and reuses OP_LT. Two fewer opcodes, for free.

Ember cannot do that. Swapping operands in the compiler means emitting the code for b before the code for a, which reverses evaluation order — and Ember guarantees left-to-right operand evaluation while Lua does not. So Ember emits a, b, GT.

That is the cleanest example in the curriculum of an early semantic decision charging rent in a later subsystem. It cost two opcodes. Write it in docs/learning/05-bytecode.md, and note the alternative: a SWAP instruction would also work, and would cost one opcode plus one dispatch per comparison.

EQ/NE never error because equality across mismatched types is defined as false, not as a type error. Same shape of observation as NOT.


Group 7: Jumps

OpcodeOperandsStack beforeStack afterErrorsLab
JUMPt:u32[...][...]—10
JUMP_IF_FALSEt:u32[..., c][...]—10
JUMP_IF_FALSE_KEEPt:u32[..., c][..., c]—10
JUMP_IF_TRUE_KEEPt:u32[..., c][..., c]—10

All four set ip = t when they take the jump. The KEEP variants do not pop in either case; they exist so that and and or can leave an operand as the result.

a and b:

0000  <code for a>
....  JUMP_IF_FALSE_KEEP  0xxx     ; a is falsy → a IS the result; jump with it on the stack
....  POP 1                        ; a was truthy → discard it
....  <code for b>                 ; b is the result
0xxx: ...

a or b is the mirror image with JUMP_IF_TRUE_KEEP. Both leave exactly one value, on both paths. That balance is what the stack-effect test in Lab 9 checks.

Note: Lua uses OP_TESTSET/OP_TEST plus a jump for the same job, and CPython uses JUMP_IF_FALSE_OR_POP. Three names for one idea. Look at all three (python3 -c "import dis; dis.dis(lambda a,b: a and b)") and notice they all need a conditional pop, which is the actual difficulty.


Group 8: Calls, Returns, and Closures

OpcodeOperandsStack beforeStack afterErrorsLab
CALLargc:u8, nres:u8[..., f, a₁..a_argc][..., r₁..r_nres]Runtime: not callable; Limit: call depth11
RETURNn:u8[..., v₁..vₙ]frame popped; results moved to the caller—11
CLOSUREp:u16[...][..., Closure]—14

CALL in detail. The callee sits below its arguments, at stack.len() - argc - 1. The VM pushes a CallFrame { proto, base: that index + 1, ip: 0 }, so the callee's slot 0 is its first argument — no copying, no allocation. Missing arguments are filled with Nil up to nparams; extras stay on the stack below base + nparams and are reachable only through VARARG.

nres == 255 means "all results", used when the call is the last expression in a list. Anything else adjusts: pad with Nil, or discard. That adjustment is the multiple-return rule implemented in one place.

RETURN n. Moves the top n values down to base - 1 (over the callee slot), truncates the stack, pops the frame, and — from Lab 14 — emits an implicit CLOSE_UPVALS 0 first. n == 255 means "everything above the marker", for return f().

CLOSURE p builds a closure from protos[p], capturing upvalues per the proto's upvals descriptor list. Lua encodes the capture list as pseudo-instructions after OP_CLOSURE; Ember puts it in the Proto, which is one fewer special case in the disassembler and the validator. Divergence, deliberate, documented.


Group 9: Tables (Lab 13)

OpcodeOperandsStack beforeStack afterErrorsLab
NEW_TABLE—[...][..., Table]Limit: memory budget13
GET_INDEX—[..., t, k][..., v]Runtime: t not indexable; NaN/nil key13
SET_INDEX—[..., t, k, v][...]Runtime: as above; Limit: memory13
GET_FIELDk:u16[..., t][..., v]Runtime: t not indexable13
SET_FIELDk:u16[..., t, v][...]Runtime: as above; Limit: memory13
SET_LISTn:u16, off:u32[..., t, v₁..vₙ][..., t]Limit: memory13
SELF_FIELDk:u16[..., t][..., t.k, t]Runtime: t not indexable13

GET_FIELD versus GET_INDEX. t.name has a constant key, known at compile time, so it gets its own opcode with the key in the instruction. t[expr] needs the key computed at run time. Identical semantics, different information available to the compiler — and GET_FIELD is the site that Section 7's inline cache attaches to, because a constant key is what makes a cache possible.

SELF_FIELD implements t:m(a) = t.m(t, a) while evaluating t once. Without it the compiler would need a temporary slot, or would evaluate t twice — which is observably wrong when t is a call.

SET_LIST n, off fills t[off+1] .. t[off+n] from the stack, for array constructors. The off operand exists so a 10,000-element constructor can be emitted in batches rather than needing 10,000 stack slots at once.

Generic for (for k, v in pairs(t)) needs no new opcodes: it lowers to a call, a nil test, and jumps. That is worth noticing — a language feature that looks like it needs VM support and does not.


Group 10: The Numeric for

OpcodeOperandsStack beforeStack afterErrorsLab
FOR_PREPt:u32[..., init, limit, step][..., i, limit, step, count]Runtime: non-number control value; zero step10
FOR_LOOPt:u32[..., i, limit, step, count][..., i', limit, step, count-1] or popped—10

These two exist for one reason: the numeric for overflow trap. FOR_PREP validates the three control values, converts them all to float if any is a float, computes the iteration count as an unsigned quantity, and jumps to t (the loop end) if the count is zero. FOR_LOOP decrements the count, and if it is still positive advances the induction variable and jumps back to t (the loop body).

Counting down means for i = math.maxinteger - 1, math.maxinteger do terminates, where while i <= limit { i += step } loops forever. Lua 5.4 does the same thing; read forprep and forloop in lvm.c (rg -n 'forprep|forloop|OP_FORLOOP' lvm.c).

The four hidden values live in the frame's slots, and the visible loop variable is a separate slot, copied fresh on each iteration — which is what makes closures created in a loop capture distinct variables.


Group 11: Varargs (Lab 17)

OpcodeOperandsStack beforeStack afterErrorsLab
VARARGn:u8[...][..., v₁..vₙ]—17

n == 255 pushes all of them. Extras beyond the declared parameters were left on the stack by CALL, below base; VARARG copies them up.


Bytecode Validation

A Chunk that did not come from your compiler — from a cache, a file, or a fuzzer — must be validated before execution. The validator is a single pass and it is what makes Section 5's bytecode cache safe.

#![allow(unused)]
fn main() {
pub fn validate(chunk: &Chunk) -> Result<()> {
    // 1. code.len() == lines.len()
    // 2. every LoadConst/GetGlobal/SetGlobal/GetField/SetField/SetList/SelfField
    //    constant index < constants.len()
    // 3. every GetGlobal/SetGlobal/GetField/... constant is a STRING
    // 4. every jump target <= code.len()   (== is legal: jump to the end)
    // 5. every Closure proto index < protos.len()
    // 6. every GetLocal/SetLocal slot < proto.max_stack
    // 7. every GetUpval/SetUpval index < proto.upvals.len()
    // 8. the last instruction is a Return
    // 9. abstract-interpret the stack depth: it never goes negative, it agrees
    //    at every join point, and it ends at 0. This is what the JVM's and
    //    WebAssembly's verifiers do, and it is the check that turns a stack
    //    machine's simplicity into a security property.
    Ok(())
}
}

Note: Rule 9 is only tractable because Ember is a stack machine. Stack depth at every instruction is statically knowable, which is precisely why WebAssembly chose a stack machine for a format designed to be shipped over the network and validated in milliseconds. Register machines make this much harder. That is a real point in the stack machine's favour that has nothing to do with speed — see Stack vs Register.


The Stack-Effect Test

The table on this page is a specification, so test against it:

#![allow(unused)]
fn main() {
/// The documented stack effect of every opcode, as (pops, pushes).
/// `None` means "variable" — CALL, RETURN, VARARG, SET_LIST.
fn documented_effect(op: Op) -> Option<(usize, usize)> {
    use Op::*;
    Some(match op {
        LoadConst(_) | LoadInt(_) | LoadNil | LoadTrue | LoadFalse => (0, 1),
        GetLocal(_) | GetGlobal(_) | GetUpval(_) | NewTable => (0, 1),
        SetLocal(_) | SetGlobal(_) | SetUpval(_) => (1, 0),
        Pop(n) => (n as usize, 0),
        Add | Sub | Mul | Div | IDiv | Mod | Pow | Concat
            | Eq | Ne | Lt | Le | Gt | Ge | GetIndex => (2, 1),
        Neg | Not | Len | GetField(_) => (1, 1),
        SetIndex => (3, 0),
        SetField(_) => (2, 0),
        SelfField(_) => (1, 2),
        Jump(_) | CloseUpvals(_) => (0, 0),
        JumpIfFalse(_) => (1, 0),
        JumpIfFalseKeep(_) | JumpIfTrueKeep(_) => (0, 0),
        Closure(_) => (0, 1),
        Call(..) | Return(_) | Vararg(_) | SetList(..) | ForPrep(_) | ForLoop(_) => return None,
    })
}

#[test]
fn implementation_matches_the_documented_stack_effect() {
    // For each fixed-effect opcode, run it on a synthetic stack and assert the
    // depth changes by exactly (pushes - pops). This is the test that keeps
    // the opcode reference honest.
    for op in every_fixed_effect_op() {
        let (pops, pushes) = documented_effect(op).unwrap();
        let before = 8;
        let after = run_one_instruction(op, before);
        assert_eq!(after as i64, before as i64 - pops as i64 + pushes as i64,
                   "{op:?} does not match its documented stack effect");
    }
}
}

Comparison With Real Instruction Sets

EmberLua 5.4CPython 3.12JVMWebAssembly 1.0
Machine modelstackregisterstackstackstack
Opcode count~4483~120202~180
Instruction size8 B (Rust enum)4 B packed2 B + inline caches1–n BLEB128 varint
Jump targetsabsoluterelative (sBx)relativerelativestructured (no raw jumps)
Typed operandsyes (enum)packed A/B/C fieldsoparg bytetyped by opcodetyped, validated
Verificationvalidator passnone (trusts its compiler)nonefull verifierfull verifier
Superinstructionsno (§7)some (ADDI, MMBIN)yes, specialized (PEP 659)nono
# Look at three of these yourself, right now — it takes five minutes.
luac -l -l /tmp/t.lua
python3 -c "import dis; dis.dis(compile(open('/tmp/t.py').read(), 't', 'exec'))"
javap -c YourClass.class

What to notice. Every one of these is a stack machine except Lua. Wasm and the JVM both ship a verifier, and both chose a stack machine partly to make verification cheap. CPython's opcodes have grown inline cache slots between them since 3.11. Lua's are packed into 32 bits with fixed A/B/C fields, which is why its operand widths are so tight (B and C are 8 or 9 bits).


Validation / Self-check

  1. What does [..., a, b] → [..., c] say about which operand was pushed first?
  2. Why are jump targets absolute in Ember and relative in Lua? What would make you switch?
  3. Why does LOAD_INT exist alongside LOAD_CONST?
  4. Why does SET_LOCAL pop, and what is the net stack effect of local x = 1?
  5. Lua has no GT opcode. Why does Ember need one, and what earlier decision caused that?
  6. Why do NOT and EQ have no error column when LEN and LT do?
  7. Trace the stack through JUMP_IF_FALSE_KEEP + POP for a and b, on both paths. Show the depth is equal at the join.
  8. Where does the callee sit relative to its arguments during CALL, and why does that make calls allocation-free?
  9. Why does the numeric for need FOR_PREP/FOR_LOOP rather than a comparison and a jump?
  10. Which validator rule is only tractable because Ember is a stack machine, and which two production formats chose a stack machine partly for that reason?

Next: Instruction Set Design.