1. Bytecode as Data
  2. Examples
    1. Inspecting what the compiler generates
    2. Counting opcodes
    3. Detecting tail-call optimization
    4. Hand-writing bytecode
    5. Patching a function
  3. VM Instruction Set
    1. Stack Operations
    2. Constants & Literals
    3. Variables & Scope
    4. Arithmetic
    5. Comparison
    6. Control Flow
    7. Exception Handling
    8. Functions & Closures
  4. Operand Encoding
  5. Design Notes

Beerlang compiles everything to bytecode before execution — REPL input, scripts, and macro expansions all go through the same path. This page is for hackers who want to see what the compiler produces, manipulate it from beerlang itself, and understand the instruction set underneath.

Bytecode as Data

disasm and asm are native functions that bridge the gap between running code and beerlang data structures. A function is not a black box — you can pull it apart, inspect it, transform it, and reassemble it.

(defn add [a b] (+ a b))

(disasm add)
;=> {:arity 2
;    :constants [+]
;    :code [[:ENTER 1]
;           [:LOAD_SELF] [:STORE_LOCAL 2]
;           [:LOAD_LOCAL 0]
;           [:LOAD_LOCAL 1]
;           [:LOAD_VAR 0]        ; load + from constants[0]
;           [:TAIL_CALL 2]
;           [:RETURN]]}

The map has three keys:

asm turns a map back into a callable function:

(def sq (asm (disasm (fn [x] (* x x)))))
(sq 9)   ;=> 81

Examples

Inspecting what the compiler generates

The compiler makes choices you might not expect. Here's what a simple if looks like:

(disasm (fn [x] (if (> x 0) :pos :neg)))
;=> {:arity 1
;    :constants [>]
;    :code [[:ENTER 1] [:LOAD_SELF] [:STORE_LOCAL 1]
;           [:LOAD_LOCAL 0]
;           [:PUSH_INT 0]
;           [:LOAD_VAR 0]          ; load >
;           [:CALL 2]
;           [:JUMP_IF_FALSE :L0]   ; branch on result — does NOT pop the test value
;           [:POP]                 ; pop test value (true branch)
;           [:PUSH_CONST 1]        ; :pos
;           [:JUMP :L1]
;           [:LABEL :L0]
;           [:POP]                 ; pop test value (false branch)
;           [:PUSH_CONST 2]        ; :neg
;           [:LABEL :L1]
;           [:RETURN]]}

JUMP_IF_FALSE peeks at the stack without consuming it — the branches each start with a POP.

And here's the same function with tail-call optimization visible:

(defn fact [n]
  (if (< n 2) 1 (* n (fact (- n 1)))))

(disasm fact)
;=> {:arity 1
;    :constants [< - *]
;    :code [[:ENTER 1] [:LOAD_SELF] [:STORE_LOCAL 1]
;           [:LOAD_LOCAL 0] [:PUSH_INT 2] [:LOAD_VAR 0] [:CALL 2]
;           [:JUMP_IF_FALSE :L0]
;           [:POP] [:PUSH_INT 1] [:JUMP :L1]
;           [:LABEL :L0]
;           [:POP]
;           [:LOAD_LOCAL 0]
;           [:LOAD_LOCAL 0] [:PUSH_INT 1] [:LOAD_VAR 1] [:CALL 2]
;           [:LOAD_LOCAL 1] [:CALL 1]   ; recursive call — (fact (- n 1))
;           [:LOAD_VAR 2] [:TAIL_CALL 2] ; tail call for the outer * — reuses frame
;           [:LABEL :L1]
;           [:RETURN]]}

Closures capture values at call time. LOAD_CLOSURE reads from the closed-over environment:

(defn make-adder [n] (fn [x] (+ n x)))
(def add5 (make-adder 5))

(disasm add5)
;=> {:arity 1
;    :constants [+]
;    :code [[:ENTER 0]
;           [:LOAD_CLOSURE 0]    ; load captured n
;           [:LOAD_LOCAL 0]      ; load x
;           [:LOAD_VAR 0]        ; load +
;           [:TAIL_CALL 2]
;           [:RETURN]]}

Counting opcodes

Since :code is just a sequence of vectors, any standard sequence operation works on it:

(defn opcode-freq [f]
  (reduce (fn [acc instr]
            (let [op (first instr)]
              (assoc acc op (inc (get acc op 0)))))
          {}
          (:code (disasm f))))

(opcode-freq fact)
;=> {:ENTER 1, :LOAD_SELF 1, :STORE_LOCAL 1, :LOAD_LOCAL 4,
;    :PUSH_INT 3, :LOAD_VAR 3, :CALL 3, :JUMP_IF_FALSE 1,
;    :POP 2, :JUMP 1, :TAIL_CALL 1, :LABEL 2, :RETURN 1}

Detecting tail-call optimization

The compiler emits TAIL_CALL instead of CALL + RETURN at tail positions. You can check whether it fired:

(defn tail-optimized? [f]
  (some #(= (first %) :TAIL_CALL) (:code (disasm f))))

(tail-optimized? (fn loop [n] (if (= n 0) :done (loop (- n 1)))))
;=> true

(tail-optimized? (fn [n] (* n (+ n 1))))
;=> nil

Hand-writing bytecode

You can construct functions from scratch without the compiler. Here's an identity function assembled by hand:

;; (fn [x] x) — load arg 0 and return it
(def my-identity
  (asm {:arity 1
        :constants []
        :code [[:ENTER 0]
               [:LOAD_LOCAL 0]
               [:RETURN]]}))

(my-identity 42)   ;=> 42
(my-identity :ok)  ;=> :ok

And a function that always returns the same constant:

(def always-pi
  (asm {:arity 0
        :constants [3.141592653589793]
        :code [[:ENTER 0]
               [:PUSH_CONST 0]
               [:RETURN]]}))

(always-pi)   ;=> 3.14159...

Patching a function

disasm + asm lets you surgically replace parts of a function. Here's a simple constant-folding patch — swap one immediate integer for another:

(defn patch-int [f from to]
  (let [d (disasm f)
        patched (into [] (map (fn [instr]
                                (if (= instr [:PUSH_INT from])
                                  [:PUSH_INT to]
                                  instr))
                              (:code d)))]
    (asm (assoc d :code patched))))

(defn add-ten [x] (+ x 10))

(def add-twenty (patch-int add-ten 10 20))
(add-twenty 5)   ;=> 25

VM Instruction Set

The VM uses single-byte opcodes with little-endian operands. All currently implemented instructions are listed below.

Stack Operations

OpcodeInstructionStack effectDescription
0x00NOPNo operation
0x01POPa →Discard top of stack
0x02DUPa → a aDuplicate top
0x03SWAPa b → b aSwap top two
0x04OVERa b → a b aCopy second to top

Constants & Literals

OpcodeInstructionOperandDescription
0x10PUSH_NILPush nil
0x11PUSH_TRUEPush true
0x12PUSH_FALSEPush false
0x13PUSH_CONSTu32 indexPush constants[index]
0x14PUSH_INTi64 valuePush 64-bit integer literal

Variables & Scope

OpcodeInstructionOperandDescription
0x20LOAD_VARu16 const_idxLoad from namespace var (symbol at constants[const_idx])
0x21STORE_VARu16 const_idxStore top-of-stack to namespace var
0x22LOAD_LOCALu16 slotLoad local variable slot
0x23STORE_LOCALu16 slotStore to local variable slot
0x24LOAD_CLOSUREu16 indexLoad from the closure's captured environment
0x25LOAD_SELFPush the currently-executing function (enables named fn self-recursion)

Arithmetic

OpcodeInstructionStack effectDescription
0x30ADDa b → (a+b)
0x31SUBa b → (a-b)
0x32MULa b → (a*b)
0x33DIVa b → (a/b)
0x35NEGa → -aNegate
0x36INCa → (a+1)Increment
0x37DECa → (a-1)Decrement

mod and rem are native functions, not opcodes.

Comparison

OpcodeInstructionStack effectDescription
0x40EQa b → boolStructural equality (cross-type, [1 2] = '(1 2))
0x42LTa b → boolLess than
0x44GTa b → boolGreater than

<= and >= are native functions.

Control Flow

OpcodeInstructionOperandDescription
0x60JUMPi32 offsetUnconditional relative jump
0x61JUMP_IF_FALSEi32 offsetJump if top is false/nilpeeks, does not pop
0x63CALLu16 n_argsCall function with n args
0x64TAIL_CALLu16 n_argsTail call — reuses current frame
0x65RETURNReturn from function
0x67ENTERu16 n_localsFunction prologue — allocate local variable slots
0x6FHALTStop VM execution

JUMP_IF_FALSE peeks at the stack — the value remains and must be explicitly POP'd by whichever branch runs.

Exception Handling

OpcodeInstructionOperandDescription
0x70PUSH_HANDLERu32 catch_pcPush an exception handler; catch_pc is the absolute PC of the catch block
0x71POP_HANDLERRemove the top handler (normal exit from try block)
0x72THROWPop a map value and throw it as an exception
0x73LOAD_EXCEPTIONPush the current exception onto the stack (inside catch)

Only hash maps can be thrown. (throw {:type :oops :msg "..."}).

Functions & Closures

OpcodeInstructionOperandsDescription
0x80MAKE_CLOSUREu32 code_offset, u16 n_locals, u16 n_closed, u16 arity, u16 name_idxCreate a closure. n_closed captured values are popped from the stack.

Operand Encoding

All multi-byte operands are little-endian:

NotationSizeNotes
u162 bytesUnsigned, e.g. slot index, arg count
u324 bytesUnsigned, e.g. constant pool index
i324 bytesSigned relative jump offset
i648 bytesSigned integer literal for PUSH_INT

Jump offsets are relative to the byte after the full instruction (i.e. after the opcode + operand bytes).


Design Notes

Why stack-based? The instruction set is deliberately simple — it fits in L2/L3 cache alongside the code it's executing. Stack machines avoid register allocation entirely, which keeps the compiler straightforward.

Why so few opcodes? Everything else is a native function. mod, rem, <=, >=, collection operations — they're all Beerlang functions called through CALL/TAIL_CALL. Opcodes are only warranted when measurements prove a critical hot path needs them.

TAIL_CALL vs CALL — The compiler detects tail position automatically. A call in tail position emits TAIL_CALL instead of CALL + RETURN, reusing the current stack frame. This makes tail-recursive functions use constant stack space, and you can verify it fired with disasm.


Copyright © 2026 Beerlang Contributors
Powered by Cryogen
Theme by KingMob