Files
cell/docs/spec/c-runtime.md
2026-02-23 18:08:13 -06:00

13 KiB

title, description
title description
C Runtime for Native Code Minimum C runtime surface for QBE-generated native code

Overview

QBE-generated native code calls into a C runtime for anything that touches the heap, dispatches dynamically, or requires GC awareness. The design principle: native code handles control flow and integer math directly; everything else is a runtime call.

This document defines the runtime boundary — what must be in C, what QBE handles inline, and how to organize the C code to serve both the mcode interpreter and native code cleanly.

The Boundary

What native code does inline (no C calls)

These operations compile to straight QBE instructions with no runtime involvement:

  • Integer arithmetic: add, sub, mul on NaN-boxed ints (shift right 1, operate, shift left 1)
  • Integer comparisons: extract int with shift, compare, produce tagged bool
  • Control flow: jumps, branches, labels, function entry/exit
  • Slot access: load/store to frame slots via %fp + offset
  • NaN-box tagging: integer tagging (n << 1), bool constants (0x03/0x23), null (0x07)
  • Type tests: JS_IsInt (LSB check), JS_IsNumber, JS_IsText, JS_IsNull — these are bit tests on the value, no heap access needed

What requires a C call

Anything that:

  1. Allocates (arrays, records, strings, frames, function objects)
  2. Touches the heap (property get/set, array indexing, closure access)
  3. Dispatches on type at runtime (dynamic load/store, polymorphic arithmetic)
  4. Calls user functions (frame setup, argument passing, invocation)
  5. Does string operations (concatenation, comparison, conversion)

Runtime Functions

Tier 1: Essential (must exist for any program to run)

These are called by virtually every QBE program.

Intrinsic Lookup

// Look up a built-in function by name. Called once per intrinsic per callsite.
JSValue cell_rt_get_intrinsic(JSContext *ctx, const char *name);

Maps name → C function pointer wrapped in JSValue. This is the primary entry point for all built-in functions (print, text, length, is_array, etc). The native code never calls intrinsics directly — it always goes through get_intrinsicframeinvoke.

Function Calls

// Allocate a call frame with space for nr_args arguments.
JSValue cell_rt_frame(JSContext *ctx, JSValue fn, int nr_args);

// Set argument idx in the frame.
void cell_rt_setarg(JSValue frame, int idx, JSValue val);

// Execute the function. Returns the result.
JSValue cell_rt_invoke(JSContext *ctx, JSValue frame);

This is the universal calling convention. Every function call — user functions, intrinsics, methods — goes through frame/setarg/invoke. The frame allocates a JSFrameRegister on the GC heap, setarg fills slots, invoke dispatches.

Tail call variants:

JSValue cell_rt_goframe(JSContext *ctx, JSValue fn, int nr_args);
void    cell_rt_goinvoke(JSContext *ctx, JSValue frame);

Same as frame/invoke but reuse the caller's stack position.

Tier 2: Property Access (needed by any program using records or arrays)

// Record field by constant name.
JSValue cell_rt_load_field(JSContext *ctx, JSValue obj, const char *name);
void    cell_rt_store_field(JSContext *ctx, JSValue obj, JSValue val, const char *name);

// Array element by integer index.
JSValue cell_rt_load_index(JSContext *ctx, JSValue obj, JSValue idx);
void    cell_rt_store_index(JSContext *ctx, JSValue obj, JSValue idx, JSValue val);

// Dynamic — type of key unknown at compile time.
JSValue cell_rt_load_dynamic(JSContext *ctx, JSValue obj, JSValue key);
void    cell_rt_store_dynamic(JSContext *ctx, JSValue obj, JSValue key, JSValue val);

The typed variants (load_field/load_index) skip the key-type dispatch that load_dynamic must do. When parse and fold provide type information, QBE emit selects the typed variant and the streamline optimizer can narrow dynamic → typed.

Implementation: These are thin wrappers around existing JS_GetPropertyStr/JS_GetPropertyNumber/JS_GetProperty and their Set counterparts.

Tier 3: Closures (needed by programs with nested functions)

// Walk depth levels up the frame chain, read slot.
JSValue cell_rt_get_closure(JSContext *ctx, JSValue fp, int depth, int slot);

// Walk depth levels up, write slot.
void    cell_rt_put_closure(JSContext *ctx, JSValue fp, JSValue val, int depth, int slot);

Closure variables live in outer frames. depth is how many caller links to follow; slot is the register index in that frame.

Tier 4: Object Construction (needed by programs creating arrays/records/functions)

// Create a function object from a compiled function index.
// The native code loader must maintain a function table.
JSValue cell_rt_make_function(JSContext *ctx, int fn_id);

Array and record literals are currently compiled as intrinsic calls (array(...), direct {...} construction) which go through the frame/invoke path. A future optimization could add:

// Fast paths (optional, not yet needed)
JSValue cell_rt_new_array(JSContext *ctx, int len);
JSValue cell_rt_new_record(JSContext *ctx);

Tier 5: Collection Operations

// a[] = val (push) and var v = a[] (pop)
void    cell_rt_push(JSContext *ctx, JSValue arr, JSValue val);
JSValue cell_rt_pop(JSContext *ctx, JSValue arr);

Tier 6: Error Handling

// Trigger disruption. Jumps to the disrupt handler or unwinds.
void cell_rt_disrupt(JSContext *ctx);

Tier 7: Miscellaneous

JSValue cell_rt_delete(JSContext *ctx, JSValue obj, JSValue key);
JSValue cell_rt_typeof(JSContext *ctx, JSValue val);

Tier 8: String and Float Helpers (called from QBE inline code, not from qbe_emit)

These are called from the QBE IL that qbe.cm generates inline for arithmetic and comparison operations. They're not cell_rt_ prefixed — they're lower-level:

// Float arithmetic (when operands aren't both ints)
JSValue qbe_float_add(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_float_sub(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_float_mul(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_float_div(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_float_mod(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_float_pow(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_float_neg(JSContext *ctx, JSValue v);
JSValue qbe_float_inc(JSContext *ctx, JSValue v);
JSValue qbe_float_dec(JSContext *ctx, JSValue v);

// Float comparison (returns C int 0/1 for QBE branching)
int qbe_float_cmp(JSContext *ctx, int op, JSValue a, JSValue b);

// Bitwise ops on non-int values (convert to int32 first)
JSValue qbe_bnot(JSContext *ctx, JSValue v);
JSValue qbe_bitwise_and(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_bitwise_or(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_bitwise_xor(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_shift_shl(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_shift_sar(JSContext *ctx, JSValue a, JSValue b);
JSValue qbe_shift_shr(JSContext *ctx, JSValue a, JSValue b);

// String operations
JSValue JS_ConcatString(JSContext *ctx, JSValue a, JSValue b);
int     js_string_compare_value(JSContext *ctx, JSValue a, JSValue b, int eq_only);
JSValue JS_NewString(JSContext *ctx, const char *str);
JSValue __JS_NewFloat64(JSContext *ctx, double d);
int     JS_ToBool(JSContext *ctx, JSValue v);

// String/number type tests (inline-able but currently calls)
int     JS_IsText(JSValue v);
int     JS_IsNumber(JSValue v);

// Tolerant equality (== on mixed types)
JSValue cell_rt_eq_tol(JSContext *ctx, JSValue a, JSValue b);
JSValue cell_rt_ne_tol(JSContext *ctx, JSValue a, JSValue b);

// Text ordering comparisons
JSValue cell_rt_lt_text(JSContext *ctx, JSValue a, JSValue b);
JSValue cell_rt_le_text(JSContext *ctx, JSValue a, JSValue b);
JSValue cell_rt_gt_text(JSContext *ctx, JSValue a, JSValue b);
JSValue cell_rt_ge_text(JSContext *ctx, JSValue a, JSValue b);

What Exists vs What Needs Writing

Already exists (in qbe_helpers.c)

All qbe_float_*, qbe_bnot, qbe_bitwise_*, qbe_shift_*, qbe_to_bool — these are implemented and working.

Already exists (in runtime.c / quickjs.c) but not yet wrapped

The underlying operations exist but aren't exposed with the cell_rt_ names:

Runtime function Underlying implementation
cell_rt_load_field JS_GetPropertyStr(ctx, obj, name)
cell_rt_load_index JS_GetPropertyNumber(ctx, obj, JS_VALUE_GET_INT(idx))
cell_rt_load_dynamic JS_GetProperty(ctx, obj, key)
cell_rt_store_field JS_SetPropertyStr(ctx, obj, name, val)
cell_rt_store_index JS_SetPropertyNumber(ctx, obj, JS_VALUE_GET_INT(idx), val)
cell_rt_store_dynamic JS_SetProperty(ctx, obj, key, val)
cell_rt_delete JS_DeleteProperty(ctx, obj, key)
cell_rt_push JS_ArrayPush(ctx, &arr, val)
cell_rt_pop JS_ArrayPop(ctx, arr)
cell_rt_typeof type tag switch → JS_NewString
cell_rt_disrupt JS_Throw(ctx, ...)
cell_rt_eq_tol / cell_rt_ne_tol comparison logic in mcode.c eq_tol/ne_tol handler
cell_rt_lt_text etc. js_string_compare_value + wrap result

Needs new code

Runtime function What's needed
cell_rt_get_intrinsic Look up intrinsic by name string, return JSValue function. Currently scattered across js_cell_intrinsic_get and the mcode handler. Needs a clean single entry point.
cell_rt_frame Allocate JSFrameRegister, set function slot, set argc. Exists in mcode.c frame handler but not as a callable function.
cell_rt_setarg Write to frame slot. Trivial: frame->slots[idx + 1] = val (slot 0 is this).
cell_rt_invoke Call the function in the frame. Needs to dispatch: native C function vs mach bytecode vs mcode. This is the critical piece — it must handle all function types.
cell_rt_goframe / cell_rt_goinvoke Tail call variants. Similar to frame/invoke but reuse caller frame.
cell_rt_make_function Create function object from index. Needs a function table (populated by the native loader).
cell_rt_get_closure / cell_rt_put_closure Walk frame chain. Exists inline in mcode.c get/put handlers.
source/
  cell_runtime.c    — NEW: all cell_rt_* functions (the native code API)
  qbe_helpers.c     — existing: float/bitwise/shift helpers for inline QBE
  runtime.c         — existing: JS_GetProperty, JS_SetProperty, etc.
  quickjs.c         — existing: core VM, GC, value representation
  mcode.c           — existing: mcode interpreter (can delegate to cell_runtime.c)

cell_runtime.c is the single file that defines the native code contract. It should:

  1. Include pit_internal.h for access to value representation and heap types
  2. Export all cell_rt_* functions with C linkage (no static)
  3. Keep each function thin — delegate to existing JS_* functions where possible
  4. Handle GC safety: after any allocation (frame, string, array), callers' frames may have moved

Implementation Priority

Phase 1 — Get "hello world" running natively:

  • cell_rt_get_intrinsic (to find print and text)
  • cell_rt_frame, cell_rt_setarg, cell_rt_invoke (to call them)
  • A loader that takes QBE output → assembles → links → calls cell_main

Phase 2 — Variables and arithmetic:

  • All property access (load_field, load_index, store_*, load_dynamic)
  • cell_rt_make_function, cell_rt_get_closure, cell_rt_put_closure

Phase 3 — Full language:

  • cell_rt_push, cell_rt_pop, cell_rt_delete, cell_rt_typeof
  • cell_rt_disrupt
  • cell_rt_goframe, cell_rt_goinvoke
  • Text comparison wrappers (cell_rt_lt_text, etc.)
  • Tolerant equality (cell_rt_eq_tol, cell_rt_ne_tol)

Calling Convention

All cell_rt_* functions follow the same pattern:

  • First argument is always JSContext *ctx
  • Values are passed/returned as JSValue (64-bit, by value)
  • Frame pointers are JSValue (tagged pointer to JSFrameRegister)
  • String names are const char * (pointer to data section label)
  • Integer constants (slot indices, arg counts) are int / long

Native code maintains %ctx (JSContext) and %fp (current frame pointer) as persistent values across the function body. All slot reads/writes go through %fp + offset.

What Should NOT Be in the C Runtime

These are handled entirely by QBE-generated code:

  • Integer arithmetic and comparisons — bit operations on NaN-boxed values
  • Control flow — branches, loops, labels, jumps
  • Boolean logicand/or/not on tagged values
  • Constant loading — integer constants are immediate, strings are data labels
  • Type guard branches — the is_int/is_text/is_null checks are inline bit tests; the branch to the float or text path is just a QBE jnz

The qbe.cm macros already handle all of this. The arithmetic path looks like:

check both ints? → yes → inline int add → done
                 → no  → call qbe_float_add (or JS_ConcatString for text)

The C runtime is only called on the slow paths (float, text, dynamic dispatch). The fast path (integer arithmetic, comparisons, branching) is fully native.