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
-m32to 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
JSValuein a C local between two API calls that might allocate. - Use
JS_PushGCRef(stack-based, O(1)) orJS_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 JSStdLibJSValue 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 hereThe 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()
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.