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:
Opis 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.
lineshas exactly one entry per instruction. That invariant is asserted inChunk::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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
LOAD_CONST | k:u16 | [...] | [..., constants[k]] | — | 9 |
LOAD_INT | n: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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
GET_LOCAL | s:u8 | [...] | [..., stack[base+s]] | — | 10 |
SET_LOCAL | s:u8 | [..., v] | [...] | — | 10 |
POP | n: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
u8slot 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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
GET_GLOBAL | k:u16 | [...] | [..., globals[constants[k]]] | — (missing → Nil) | 10 |
SET_GLOBAL | k: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)
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
GET_UPVAL | u:u8 | [...] | [..., *upvals[u]] | — | 14 |
SET_UPVAL | u:u8 | [..., v] | [...] | — | 14 |
CLOSE_UPVALS | s: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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
ADD SUB MUL | — | [..., a, b] | [..., c] | Runtime: non-number operand | 9 |
DIV POW | — | [..., a, b] | [..., Float] | Runtime: non-number operand | 9 |
IDIV MOD | — | [..., a, b] | [..., c] | Runtime: non-number; integer divisor 0 | 9 |
CONCAT | — | [..., a, b] | [..., Str] | Runtime: operand not string/number | 10 |
NEG | — | [..., a] | [..., b] | Runtime: non-number | 9 |
NOT | — | [..., a] | [..., Boolean] | — (never errors) | 10 |
LEN | — | [..., a] | [..., Integer] | Runtime: not a string or table | 10 |
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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
EQ NE | — | [..., a, b] | [..., Boolean] | — (never errors) | 10 |
LT LE GT GE | — | [..., a, b] | [..., Boolean] | Runtime: incomparable types | 10 |
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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
JUMP | t:u32 | [...] | [...] | — | 10 |
JUMP_IF_FALSE | t:u32 | [..., c] | [...] | — | 10 |
JUMP_IF_FALSE_KEEP | t:u32 | [..., c] | [..., c] | — | 10 |
JUMP_IF_TRUE_KEEP | t: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_TESTplus a jump for the same job, and CPython usesJUMP_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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
CALL | argc:u8, nres:u8 | [..., f, a₁..a_argc] | [..., r₁..r_nres] | Runtime: not callable; Limit: call depth | 11 |
RETURN | n:u8 | [..., v₁..vₙ] | frame popped; results moved to the caller | — | 11 |
CLOSURE | p: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)
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
NEW_TABLE | — | [...] | [..., Table] | Limit: memory budget | 13 |
GET_INDEX | — | [..., t, k] | [..., v] | Runtime: t not indexable; NaN/nil key | 13 |
SET_INDEX | — | [..., t, k, v] | [...] | Runtime: as above; Limit: memory | 13 |
GET_FIELD | k:u16 | [..., t] | [..., v] | Runtime: t not indexable | 13 |
SET_FIELD | k:u16 | [..., t, v] | [...] | Runtime: as above; Limit: memory | 13 |
SET_LIST | n:u16, off:u32 | [..., t, v₁..vₙ] | [..., t] | Limit: memory | 13 |
SELF_FIELD | k:u16 | [..., t] | [..., t.k, t] | Runtime: t not indexable | 13 |
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
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
FOR_PREP | t:u32 | [..., init, limit, step] | [..., i, limit, step, count] | Runtime: non-number control value; zero step | 10 |
FOR_LOOP | t: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)
| Opcode | Operands | Stack before | Stack after | Errors | Lab |
|---|---|---|---|---|---|
VARARG | n: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
| Ember | Lua 5.4 | CPython 3.12 | JVM | WebAssembly 1.0 | |
|---|---|---|---|---|---|
| Machine model | stack | register | stack | stack | stack |
| Opcode count | ~44 | 83 | ~120 | 202 | ~180 |
| Instruction size | 8 B (Rust enum) | 4 B packed | 2 B + inline caches | 1–n B | LEB128 varint |
| Jump targets | absolute | relative (sBx) | relative | relative | structured (no raw jumps) |
| Typed operands | yes (enum) | packed A/B/C fields | oparg byte | typed by opcode | typed, validated |
| Verification | validator pass | none (trusts its compiler) | none | full verifier | full verifier |
| Superinstructions | no (§7) | some (ADDI, MMBIN) | yes, specialized (PEP 659) | no | no |
# 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
- What does
[..., a, b] → [..., c]say about which operand was pushed first? - Why are jump targets absolute in Ember and relative in Lua? What would make you switch?
- Why does
LOAD_INTexist alongsideLOAD_CONST? - Why does
SET_LOCALpop, and what is the net stack effect oflocal x = 1? - Lua has no
GTopcode. Why does Ember need one, and what earlier decision caused that? - Why do
NOTandEQhave no error column whenLENandLTdo? - Trace the stack through
JUMP_IF_FALSE_KEEP+POPfora and b, on both paths. Show the depth is equal at the join. - Where does the callee sit relative to its arguments during
CALL, and why does that make calls allocation-free? - Why does the numeric
forneedFOR_PREP/FOR_LOOPrather than a comparison and a jump? - 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.