MicroQuickJS โ€” Data Flow

An interactive walkthrough of how JS source becomes execution

~10 KB RAM ~100 KB ROM ES5 subset embedded systems
0
How MicroQuickJS Works
Click any stage below to explore it in detail
1. JS_NewContext
โ†’
2. JS_Parse
โ†’
3. Bytecode
โ†’
4. JS_Run (VM)
โ†’
5. GC
โ†’
6. C API
๐Ÿ“ JS Source Code
Text: var x = 1 + 2;
The engine accepts raw JavaScript source as a UTF-8 string. Only a strict ES5 subset is supported โ€” no with, no holey arrays, no direct eval. Tap Parse stage to see how it's compiled.
โ†“ JS_NewContext()
๐Ÿง  JSContext + Memory Buffer
A single flat buffer: uint8_t mem_buf[8192]
No malloc(). The caller provides a fixed-size buffer. The engine, GC, object heap, and atom table all live inside it. The stdlib is in ROM โ€” zero RAM cost to instantiate.
โ†“ JS_Parse()
๐Ÿ”„ One-Pass Compilation
No AST. Bounded C-stack. Direct bytecode emission.
Unlike QuickJS (which builds an AST and runs multiple optimization passes), MicroQuickJS emits bytecode in a single pass. The parser avoids recursion, keeping C-stack usage bounded โ€” important on embedded systems with small stacks.
โ†“
๐Ÿ“ฆ Bytecode Object (JSValue)
A tagged pointer to the compiled function object
JS_Parse() returns a JSValue pointing to the top-level function. It can be saved to a .bin file with JS_PrepareBytecode(), or executed immediately.
โ†“ JS_Run() or save to ROM
โš™๏ธ Stack-Based VM
Executes opcodes. JS stack โ‰  C stack.
The VM maintains its own value stack in the heap. Each opcode pops and pushes JSValue words. The C call stack is not consumed by JS frames โ€” crucial for deeply nested JS on embedded MCUs.
โ†“ GC on each allocation
๐Ÿ—‘๏ธ Tracing + Compacting GC
Objects can move. Use JSGCRef to root values.
Unlike QuickJS's reference counting, MicroQuickJS uses a tracing GC with compaction. This eliminates fragmentation and keeps objects small (no refcount field). The tradeoff: pointers can change, so C code must use JSGCRef instead of raw JSValue.
1
Context Initialization
The engine lives entirely in a caller-supplied buffer
uint8_t mem_buf[65536];   // 64 KB is plenty
JSContext *ctx;

ctx = JS_NewContext(mem_buf, sizeof(mem_buf), &js_stdlib);
JS_SetLogFunc(ctx, js_log_func);   // optional logging hook

// ... do work ...

JS_FreeContext(ctx);   // only needed to run C finalizers
free(mem_buf);         // or just let it go out of scope
Memory layout inside mem_buf 64 KB example
0x0000
JSContext header & GC metadata
~128 bytes
0x0080
Atom table (interned strings)
variable
โ†“
JS objects & arrays (heap grows โ†’)
dynamic
ยทยทยท
free space
0xFFFF
VM value stack (grows โ†)
variable
stdlib is in ROM
The &js_stdlib argument is a pointer to a read-only C structure generated at compile time by mquickjs_build.c. Instantiation allocates almost no RAM โ€” only a handful of objects need to be created in the heap.
No malloc / free / printf
MicroQuickJS has zero dependency on the C standard library heap. The engine uses only the buffer you provide. This makes it safe for bare-metal MCUs without an OS.
Minimum footprint: JS_NewContext(buf, 10240, ...) โ€” 10 KB is enough to run many programs, including the Mandelbrot demo.
2
Parsing & Compilation
One pass. No AST. Bounded stack.
// Parse JS source โ†’ bytecode object
JSValue bytecode = JS_Parse(ctx,
    "var x = 1 + 2; print(x);",
    25,          // length
    "script.js", // filename (for error messages)
    0            // flags
);

if (JS_IsException(bytecode)) {
    // syntax error โ€” dump and exit
    JSValue err = JS_GetException(ctx);
    JS_PrintValueF(ctx, err, JS_DUMP_LONG);
}
๐Ÿ”ค Lexer (tokenizer)
Converts UTF-8 text to a stream of tokens
Tokens: keywords (var, if, โ€ฆ), identifiers, literals, operators. String keys are interned as atoms โ€” each unique string gets a small integer ID. This makes property lookup O(1) with integer comparison.
โ†“ tokens
๐ŸŒฒ Parser (non-recursive)
Parses expressions and statements; avoids C-stack recursion
QuickJS uses a recursive descent parser that consumes C stack. MicroQuickJS rewrites this to be iterative โ€” it keeps an explicit operator stack. This bounds C-stack depth to a fixed small amount regardless of JS nesting depth.
โ†“ emit instructions
โšก Code Generator
Emits bytecode directly โ€” no intermediate AST
While QuickJS builds a full AST and then runs multiple optimization passes, MicroQuickJS emits bytecode inline as it parses. Small local optimizations (constant folding, branch peephole) are applied on the fly. The result: less RAM during compilation.
โ†“ compress line info
๐Ÿ“ Exp-Golomb line/column encoding
Compressed debug info uses only a few bytes per instruction
Line and column numbers are stored as deltas using exponential-Golomb codes. Pass --no-column to strip column info and save a few more bytes.
Atom table โ€” string interning
  • Every property key (like "x", "length") is interned to a small integer atom.
  • Atoms are stored in the same memory buffer โ€” no separate hash map.
  • Bytecode references atoms through an indirect table, so the bytecode stays relocatable.
Try it mentally: var x = 1 + 2; becomes roughly:
push_value  1
push_value  2
add
put_loc     0   ; local slot for 'x'
3
Bytecode
Save to ROM / file, or run immediately
Two paths after JS_Parse()
โ–ถ Run directly
JS_Run(ctx, bytecode)
Fastest path. Bytecode stays in heap.
๐Ÿ’พ Save to .bin
JS_PrepareBytecode()
Serialize โ†’ flash โ†’ load from ROM
// Compile and save:
./mqjs -o mandelbrot.bin tests/mandelbrot.js

// Run saved bytecode:
./mqjs -b mandelbrot.bin

// C API equivalent:
JS_IsBytecode(buf, buf_len);      // detect .bin files
JS_RelocateBytecode(ctx, buf, len); // patch addresses
JSValue fn = JS_LoadBytecode(ctx, buf);
JS_Run(ctx, fn);
Bytecode file format
  • JSBytecodeHeader โ€” magic, version, word size, atom count
  • Atom table โ€” all interned strings
  • Function objects โ€” opcodes, constants, closures (recursive)
  • Endian and word-size aware: use -m32 to cross-compile 64โ†’32 bit
ROM execution: After JS_RelocateBytecode2(ctx, hdr, data, len, 0, FALSE) patches all addresses to zero-based, the .bin can be flashed into MCU flash memory and executed without copying to RAM.
Security warning
Bytecode is not validated before execution. Only load bytecode from trusted sources โ€” malformed bytecode can crash or compromise the VM.
4
VM Execution
Stack machine. JSValue = one CPU word.
JSValue bit layout โ€” tap to explore
Stack simulation โ€” var x = 1 + 2; print(x);
Value Stack (top โ†’ bottom)
stack empty
BYTECODE
Key VM properties
  • No CPU stack usage for JS frames โ€” the JS value stack lives in the heap buffer. Deep JS recursion won't overflow the MCU's C stack.
  • JS_StackCheck(ctx, n) โ€” must be called before pushing n values; triggers GC if needed and returns an error if out of memory.
  • call / call_method / call_constructor โ€” arguments and function are pushed on the value stack before calling.
5
Tracing + Compacting GC
Objects can move. Root them with JSGCRef.
Unlike QuickJS (reference counting), MicroQuickJS uses a tracing GC that periodically scans all live objects and compacts them. No per-object refcount field โ†’ smaller objects. No cycles problem.
GC phases โ€” tap to step through
Objects allocated. Some are unreachable (red).
โ†’
// JSGCRef roots a JSValue across GC/allocation
JSGCRef obj_ref;
JSValue *obj = JS_PushGCRef(ctx, &obj_ref);

*obj = JS_NewObject(ctx);       // may trigger GC
if (JS_IsException(*obj)) goto fail;

JS_SetPropertyStr(ctx, *obj, "x", JS_NewInt32(ctx, 42));
// *obj still valid: JSGCRef was updated when obj moved

JS_PopGCRef(ctx, &obj_ref);  // unroot when done
The golden rule of MicroQuickJS C code
  • Never store a JSValue in a C local between two API calls that might allocate.
  • Use JS_PushGCRef (stack-based, O(1)) or JS_AddGCRef (list-based, any order) to keep a pointer that the GC will update.
  • On PC, compile with -DDEBUG_GC โ€” it forces objects to move on every allocation, catching stale JSValues immediately.
No JS_FreeValue()
Unlike QuickJS, you never need to call JS_FreeValue(). The GC owns all memory. JS_FreeContext() is only needed to run C finalizers (e.g., to free() opaque C data you allocated).
6
C API โ€” Extending the Engine
Custom classes, C functions, opaque data
๐Ÿ—๏ธ Define a C class
JS_CLASS_USER + N โ€” your custom object type
#define JS_CLASS_RECTANGLE (JS_CLASS_USER + 0)

typedef struct { int x, y; } RectangleData;
Each user class has a unique class ID. Objects created with JS_NewObjectClassUser(ctx, CLASS_ID) hold an opaque void * that you manage.
โ†“ register constructor + methods
๐Ÿ”Œ Register C functions
Via JSCFunctionDef table in JSStdLib
JSValue js_rect_get_x(JSContext *ctx,
    JSValue *this_val, int argc, JSValue *argv)
{
    RectangleData *d = JS_GetOpaque(ctx, *this_val);
    return JS_NewInt32(ctx, d->x);
}
โ†“ provide finalizer
๐Ÿงน Finalizer callback
Called when GC collects the object
static void js_rect_finalizer(
    JSContext *ctx, void *opaque)
{
    free((RectangleData *)opaque);
}
// No JS functions can be called from here
The finalizer frees C-side resources. It must not call any JS API functions.
โ†“ call JS from C
๐Ÿ“ž Call a JS function from C
Push args and function, then JS_Call()
JS_StackCheck(ctx, 3);    // need 3 slots
JS_PushArg(ctx, argv[1]); // arg
JS_PushArg(ctx, argv[0]); // function
JS_PushArg(ctx, JS_NULL); // this
JSValue ret = JS_Call(ctx, 1); // 1 arg
C closures โ€” passing parameters to C functions
// Register a C function that carries a JSValue parameter:
return JS_NewCFunctionParams(ctx, JS_CFUNCTION_MY_FUNC, params);

// The C handler receives it as the last argument:
JSValue my_handler(JSContext *ctx, JSValue *this_val,
    int argc, JSValue *argv, JSValue params) { ... }
This lets you bind configuration into a function without a separate object.
Full example flow
Define RectangleData
โ†’
js_rectangle_constructor
โ†’
JS_SetOpaque
โ†’
JS_GetOpaque in getter
โ†’
finalizer calls free()
See example.c for the full working implementation including Rectangle and FilledRectangle.
Tip: C functions are stored as a single JSValue word (not a full object) when they have no extra properties โ€” no overhead for the hundreds of stdlib functions.