Chapter 7

Reading the Disassembly

Bloom's bytecode is an assembly language

The bytecode VM executes a flat array of bytes. That byte array is a kind of machine code, and like any machine code it has a human-readable form: an assembly listing. The tool that turns the raw bytes back into that listing is a disassembler.

Reading the disassembly is one of the best ways to understand what the compiler actually does with your code — how an if becomes a conditional jump, how a loop jumps backward, how a constant gets stored in a pool.

Try it yourself Run bloom disasm yourfile.blm from the command line to print the full listing for any program.

A Worked Example

Here is a small recursive Fibonacci function and the program that calls it:

fib.blm
fn fib(n) {
  if (n < 2) { return n }
  return fib(n - 1) + fib(n - 2)
}
print(fib(10))

Running bloom disasm fib.blm produces:

bloom disasm fib.blm
== main ==
  0000  LOAD_GLOBAL  0  ; = "fib"
  0003  CONST  1  ; = 10
  0006  CALL  1
  0008  CALL_NATIVE  60 1  ; = print()
  0012  POP
  0013  HALT

== function fib(s0) ==  (locals: 1)
  0000  LOAD_LOCAL  s0
  0002  CONST  0  ; = 2
  0005  LT
  0006  JUMP_IF_FALSE  7  ; -> 0016
  0009  POP
  0010  LOAD_LOCAL  s0
  0012  RETURN
  0013  JUMP  1  ; -> 0017
  0016  POP
  0017  LOAD_GLOBAL  1  ; = "fib"
  0020  LOAD_LOCAL  s0
  0022  CONST  2  ; = 1
  0025  SUB
  0026  CALL  1
  0028  LOAD_GLOBAL  1  ; = "fib"
  0031  LOAD_LOCAL  s0
  0033  CONST  0  ; = 2
  0036  SUB
  0037  CALL  1
  0039  ADD
  0040  RETURN
  0041  RETURN_NIL

Anatomy of a Line

Every instruction line follows the same shape:

  0006  JUMP_IF_FALSE  7  ; -> 0016
  ^^^^  ^^^^^^^^^^^^^  ^     ^^^^^^^
  offset  opcode    operand  annotation

Walking Through It

The constant pool

Numbers and strings aren't stored inline in the bytecode. They live in a per-chunk constant pool, and instructions refer to them by index. CONST 1 means "push the constant at index 1" — the disassembler looks it up and tells you that's 10.

Locals vs. globals

Inside fib, the parameter n is local slot 0, so reading it is LOAD_LOCAL s0 — a direct array index, the fast path. At the top level, fib itself is a global, so calling it starts with LOAD_GLOBAL 0, whose name ("fib") is resolved from the constant pool.

How an if becomes a jump

There is no if instruction. The condition n < 2 compiles to LOAD_LOCAL s0, CONST 2, LT, leaving a boolean on the stack. Then JUMP_IF_FALSE at offset 0006 skips the then branch when that boolean is false. Its target, -> 0016, is exactly where the else path begins — the disassembler computes the absolute landing site from the relative offset stored in the bytecode, so you don't have to do the arithmetic.

Calls

User functions use CALL n, where n is the argument count; the callee was pushed first, then its arguments. Built-ins like print use CALL_NATIVE id n, dispatched directly through the native table — which is why print shows up as CALL_NATIVE 60 1 rather than a regular call.

Returns

Notice the trailing RETURN_NIL at offset 0041. The compiler always appends an implicit "return nothing" so a function that falls off the end still returns cleanly — even when, as here, every real path already hit a RETURN.

Loops Jump Backward

A for loop shows the other direction of control flow. The LOOP instruction jumps backward to re-test the loop, while ITER_NEXT jumps forward to the exit when the iterator is exhausted:

for i in 0..3 { print(i) }
  0019  RANGE
  0020  ITER_INIT
  ...
  0026  ITER_NEXT  17  ; -> 0046   (forward, to the exit)
  ...
  0043  LOOP  22  ; -> 0024        (backward, to the test)
  0046  POP

Because the disassembler resolves both into absolute offsets, you can read the loop's shape at a glance: the body sits between the ITER_NEXT test and the LOOP that closes it.

Superinstructions

For speed the compiler sometimes fuses common pairs into a single superinstruction. If you see opcodes like ADD_LOCAL_CONST or LOAD_LOCAL_CONST_LT_JUMP in a listing, that's one instruction doing what would otherwise be two or three. The disassembler decodes each one's operands the same way the VM does, so the listing always stays in sync.

In the Source Code

The disassembler lives in src/tools/disassembler.ts: