Source: src/codegen/ — mod.rs, driver_support.rs, main_emission.rs, class_methods.rs, function_variants.rs, interface_wrappers.rs, callables.rs, reflection.rs, prescan.rs, program_usage.rs, program_usage/, expr.rs, expr/, stmt.rs, stmt/, functions/, builtins/, runtime/, ffi.rs, abi/, platform/, context.rs, data_section.rs, emit.rs
The code generator (codegen) is the heart of the compiler. It takes the checked AST after the optimizer’s local simplification passes and produces native assembly text for the selected target — the actual instructions the CPU will execute.
elephc now supports more than one backend. AArch64 is still the clearest reference path in the codebase and in this document, while Linux x86_64 is also a supported backend that goes through the same high-level lowering pipeline.
Most snippets below use AArch64 because the instruction forms are compact and the surrounding docs already explain them in detail. When a section talks about target-specific ABI or runtime behavior, it calls out Linux x86_64 explicitly.
For an introduction to AArch64, see Introduction to ARM64 Assembly.
Overview
The codegen walks the AST and emits assembly for each node. In the CLI, the main output is now the user program assembly; the shared runtime helpers are usually assembled separately and reused from the runtime object cache. The user-facing .s file still has this structure:
.global _main
.align 2
; --- user-defined functions ---
_fn_factorial:
...
ret
; --- class methods ---
_method_Point_move:
...
ret
; --- main program ---
_main:
; prologue (stack frame setup)
; global argc/argv initialization
; program statements
; epilogue (exit syscall)
; --- deferred closures emitted after _main ---
_closure_1:
...
ret
; --- data section ---
.data
_str_0: .ascii "hello"
_float_0: .quad 0x400921FB54442D18
; --- source markers used by --source-map ---
; @src line=12 col=5
Trait composition does not add a separate runtime dispatch layer. Traits are flattened into each concrete class during type checking, then inheritance metadata is layered on top. Codegen still emits _method_Class_method / _static_Class_method labels, but instance calls now use vtable slots keyed by class_id so child overrides work through inherited methods.
The exact directives and symbol decoration vary by target. The example above is intentionally AArch64-flavored, but the same structural phases apply on Linux x86_64.
When you call the library-style codegen::generate(...) entry point, elephc still exposes both pieces explicitly as (user_asm, runtime_asm). The CLI path uses generate_user_asm(...) plus the runtime-object cache so repeated compiles do not have to reassemble the same shared runtime text every time.
The Emitter
File: src/codegen/emit.rs
The Emitter is a simple string buffer with helper methods:
| Method | Output |
|---|---|
instruction("mov x0, #42") | mov x0, #42\n (indented) |
label("_main") | _main:\n |
comment("load variable") | ; load variable\n |
raw(".global _main") | .global _main\n (no indent) |
blank() | \n |
All assembly is built as text, then written to the .s file.
Statement emission also injects source markers of the form @src line=<N> col=<M>. They are ignored by the assembler as comments, but the CLI can later scan them to build a simple source-map sidecar file when --source-map is enabled.
Runtime split, cache, and source maps
The compiler’s codegen/runtime handoff now has three distinct artifacts:
- User assembly — emitted from the checked AST into the per-build
.sfile - Runtime object — assembled from the shared runtime once and cached under
~/.cache/elephc/(orXDG_CACHE_HOME) using the compiler version, target, heap size, and generated runtime assembly hash in the filename - Optional source map — a JSON sidecar generated from
@srcmarkers embedded in the user assembly comments
This means normal CLI builds no longer concatenate the runtime text into every output assembly file before assembling. Instead, they:
- prepare or reuse the cached runtime object
- assemble only the user
.sfile intofile.o - link
file.oagainst the cached runtime object
The source-map file is intentionally simple. Today it stores a list of (asm_line, php_line, php_col) entries so tools and humans can correlate generated assembly back to the original PHP statements without needing full DWARF debug info.
The optimizer intentionally stays at the AST level. By the time codegen runs, constant expressions and some dead control-flow have already been removed, but codegen still sees a normal checked program shape rather than a target-specific IR. Assembly-level peephole cleanup is future work.
The Context
File: src/codegen/context.rs
The Context tracks state during code generation:
pub struct Context {
pub variables: HashMap<String, VarInfo>, // variable → type + stack offset
pub stack_offset: usize, // next available stack slot
pub loop_stack: Vec<LoopLabels>, // for break/continue
pub return_label: Option<String>, // for early returns
pub functions: HashMap<String, FunctionSig>,
pub function_variant_groups: HashSet<String>, // include-loaded function dispatchers
pub deferred_closures: Vec<DeferredClosure>, // closures emitted after current function
pub deferred_fiber_wrappers: Vec<DeferredFiberWrapper>,
pub deferred_callback_wrappers: Vec<DeferredCallbackWrapper>,
pub constants: HashMap<String, (ExprKind, PhpType)>, // compile-time constants
pub global_vars: HashSet<String>, // globals active in current scope
pub static_vars: HashSet<String>, // statics active in current scope
pub ref_params: HashSet<String>, // pass-by-reference params
pub local_ref_cell_flags: HashMap<String, LocalRefCellFlag>, // compiler-created ref cells
pub in_main: bool, // whether we're compiling top-level code
pub all_global_var_names: HashSet<String>,
pub all_static_vars: HashMap<(String, String), PhpType>,
pub closure_sigs: HashMap<String, FunctionSig>,
pub callable_param_sigs: HashMap<(String, String), FunctionSig>,
pub closure_captures: HashMap<String, Vec<(String, PhpType, bool)>>,
pub first_class_callable_targets: HashMap<String, CallableTarget>,
pub variable_fcc_label: HashMap<String, String>,
pub classes: HashMap<String, ClassInfo>,
pub interfaces: HashMap<String, InterfaceInfo>,
pub traits: HashSet<String>,
pub enums: HashMap<String, EnumInfo>,
pub packed_classes: HashMap<String, PackedClassInfo>,
pub current_class: Option<String>,
pub extern_functions: HashMap<String, ExternFunctionSig>,
pub extern_classes: HashMap<String, ExternClassInfo>,
pub extern_globals: HashMap<String, PhpType>,
pub return_type: PhpType,
pub activation_prev_offset: Option<usize>,
pub activation_cleanup_offset: Option<usize>,
pub activation_frame_base_offset: Option<usize>,
pub pending_action_offset: Option<usize>,
pub pending_target_offset: Option<usize>,
pub nested_concat_offset_offset: Option<usize>,
pub pending_return_value_offset: Option<usize>,
pub try_slot_offsets: Vec<usize>,
pub next_try_slot_idx: usize,
pub finally_stack: Vec<FinallyContext>,
}
Each variable has a VarInfo:
pub struct VarInfo {
pub ty: PhpType, // current runtime storage type
pub static_ty: PhpType, // declared/static type retained for checks and calls
pub stack_offset: usize, // offset from frame pointer (x29)
pub ownership: HeapOwnership, // NonHeap / Owned / Borrowed / MaybeOwned
pub epilogue_cleanup_safe: bool, // false for locals populated through still-ambiguous control-flow/alias paths
}
HeapOwnership is a codegen-only ownership lattice used for heap-backed values flowing through stack slots:
NonHeap— integers, floats, bools, null, resources, raw pointersOwned— this slot definitely owns the current heap-backed valueBorrowed— this slot currently aliases heap storage owned elsewhereMaybeOwned— control flow merged heap-backed paths with different ownership states
The lattice is now threaded through the main local-variable paths. Function epilogues re-enable cleanup only for slots classified as Owned and still marked epilogue_cleanup_safe; locals coming from still-ambiguous control-flow or aliasing paths are intentionally skipped. Special aliases such as $this, by-reference params, globals, and statics are explicitly kept out of epilogue cleanup because the current frame does not own their storage. Builtins that duplicate containers now also dispatch to dedicated _refcounted runtime helpers when their element/value types are heap-backed, so nested array/hash/object/string payloads are retained before the new container becomes an owner.
The exception-related fields let codegen thread try / catch / finally through non-local control flow. Function and _main frames publish activation records into the runtime cleanup stack, pre-allocate handler slots for setjmp buffers, and use finally_stack plus the pending_* slots to defer return, break, and continue until the innermost finally body has run.
Label generation
ctx.next_label("while") produces _while_1, _while_2, etc. A global atomic counter ensures labels never collide across functions or compilation units.
The Data Section
File: src/codegen/data_section.rs
String literals and float constants are stored in the .data section:
pub struct DataSection {
entries: Vec<(String, Vec<u8>)>, // string label → bytes
float_entries: Vec<(String, u64)>, // float label → bit pattern
counter: usize, // next unique label suffix
dedup: HashMap<Vec<u8>, String>, // avoid duplicate strings
float_dedup: HashMap<u64, String>, // avoid duplicate floats
}
When the codegen encounters "hello", it calls data.add_string(b"hello") which returns a label (_str_0) and length (5). Identical strings are deduplicated — two "hello" literals share the same label.
Floats are stored as their raw 64-bit IEEE 754 bit patterns (.quad directive).
Expression codegen
Files: src/codegen/expr.rs, src/codegen/expr/
emit_expr() takes an expression node and emits code that leaves the result in the standard registers. The top-level expr.rs file now mainly dispatches into focused helpers under expr/ such as scalars.rs, variables.rs, binops.rs, arrays.rs, compare/, calls/, and objects/.
| Type | Result location |
|---|---|
Int / Bool / Void / Resource | x0 |
Float | d0 |
Str | x1 (pointer), x2 (length) |
Array / AssocArray / Iterable | x0 (heap pointer) |
Mixed | x0 (pointer to boxed mixed cell) |
Object | x0 (heap pointer) |
Callable / Pointer | x0 |
Buffer / Packed | x0 (heap pointer) |
Union | x0 (same as Mixed — boxed runtime-tagged payload) |
Expression AST dispatch coverage
The expression dispatcher is intentionally thin. It routes each ExprKind
variant into one of the focused lowering paths below:
| Variants | Lowering path |
|---|---|
StringLiteral, IntLiteral, FloatLiteral, BoolLiteral, Null, Negate, Not, BitNot, Cast, Print, ErrorSuppress | Scalar, coercion, stdout, and diagnostics helpers |
Variable, This, PreIncrement, PostIncrement, PreDecrement, PostDecrement, Assignment | Variable load/store and assignment-expression helpers |
BinaryOp, InstanceOf, NullCoalesce, Pipe, Ternary, ShortTernary, Throw | Operator, comparison, call-pipe, branch, and exception-aware expression helpers |
ArrayLiteral, ArrayLiteralAssoc, ArrayAccess, Spread, Match | Indexed-array, associative-array, unpacking, string-indexing, and match-expression helpers |
FunctionCall, NamedArg, ClosureCall, ExprCall, Closure, FirstClassCallable | Shared call-argument planner, closure wrappers, and callable dispatch helpers |
ConstRef, ClassConstant, ScopedConstantAccess, MagicConstant | Compile-time constant and class-constant loading. MagicConstant should already be lowered by the frontend before codegen. |
NewObject, NewScopedObject, PropertyAccess, DynamicPropertyAccess, NullsafePropertyAccess, NullsafeDynamicPropertyAccess, StaticPropertyAccess, MethodCall, NullsafeMethodCall, StaticMethodCall | Object allocation, property/member access, nullsafe chain lowering, vtable dispatch, and late-static-binding helpers |
PtrCast, BufferNew, Yield, YieldFrom | Pointer/buffer extensions and generator state-machine lowering |
Literals
42 → mov x0, #42
3.14 → adrp x9, _float_0@PAGE / add x9, ... / ldr d0, [x9]
"hello" → adrp x1, _str_0@PAGE / add x1, ... / mov x2, #5
true → mov x0, #1
null → movz x0, #0xFFFE / movk x0, ... (load null sentinel)
Large integers (> 65535 or negative) use movz + movk sequences. See ARM64 Instruction Reference.
The push/pop pattern for binary operations
Binary operations like $a + $b need both operands in registers simultaneously, but emit_expr uses the same registers for every expression. The solution: push the left result onto the stack, evaluate the right, then pop the left back.
$a + $b
; Step 1: evaluate left ($a)
ldur x0, [x29, #-8] ; x0 = $a
; Step 2: push left onto stack
str x0, [sp, #-16]! ; save x0 to stack, decrement sp
; Step 3: evaluate right ($b)
ldur x0, [x29, #-16] ; x0 = $b (overwrites left!)
; Step 4: pop left back into a different register
ldr x1, [sp], #16 ; restore left into x1, increment sp
; Step 5: operate
add x0, x1, x0 ; x0 = left + right
For strings (which use two registers), the push saves both x1 and x2, and the pop restores them to x3 and x4.
For floats, the push/pop uses d0/d1:
str d0, [sp, #-16]! ; push left float
; ... evaluate right → d0 ...
ldr d1, [sp], #16 ; pop left float into d1
fadd d0, d1, d0 ; d0 = left + right
Comparison operators
Comparisons use cmp (integer) or fcmp (float) followed by cset:
$x > 5
; ... push $x, evaluate 5 ...
cmp x1, x0 ; compare left with right
cset x0, gt ; x0 = 1 if greater, 0 otherwise
The result is always x0 with value 0 or 1 (PhpType::Bool).
Short-circuit logical operators
&&, ||, and, and or use short-circuit evaluation — the right side isn’t evaluated if the left determines the result. xor is also a logical operator, but it evaluates both operands because exclusive OR needs both truthiness values.
$a && $b
; evaluate $a
cmp x0, #0
b.eq _sc_end_1 ; if $a is falsy, skip $b entirely (result = 0)
; evaluate $b
cmp x0, #0
cset x0, ne ; result = whether $b is truthy
_sc_end_1:
String concatenation
The . operator calls the runtime’s __rt_concat:
"hello" . " world"
; push left string (x1, x2)
; evaluate right string → x1, x2
; pop left → x3, x4
; call concat
mov x3, ... ; left ptr
mov x4, ... ; left len
bl __rt_concat ; result → x1 (ptr), x2 (len)
See The Runtime for how __rt_concat works.
Bitwise operations
The bitwise operators (&, |, ^, ~, <<, >>) operate on integers and emit single ARM64 instructions:
$a & $b → and x0, x1, x0 // bitwise AND
$a | $b → orr x0, x1, x0 // bitwise OR
$a ^ $b → eor x0, x1, x0 // bitwise XOR
$a << $b → lsl x0, x1, x0 // logical shift left
$a >> $b → asr x0, x1, x0 // arithmetic shift right (preserves sign)
~$a → mvn x0, x0 // bitwise complement (one's complement)
Like other binary operations, bitwise ops use the push/pop pattern — evaluate left, push, evaluate right, pop left, apply operation.
Spaceship operator
The spaceship operator (<=>) returns -1, 0, or 1 depending on the comparison result. It uses conditional select instructions:
$a <=> $b
; ... push $a, evaluate $b ...
cmp x1, x0 ; compare left with right
cset x0, gt ; x0 = 1 if left > right, else 0
csinv x0, x0, xzr, ge ; if left < right: x0 = ~0 = -1 (all ones)
csinv (conditional select invert) inverts xzr (the zero register) to produce -1 when the condition is not met.
For floats, fcmp replaces cmp, but the same cset/csinv pattern applies.
Array union
When both operands of + are arrays, codegen routes the expression to PHP array-union lowering instead of numeric addition. Indexed arrays call __rt_array_union, which clones the left operand and appends only the right-side numeric suffix whose keys are missing from the left. Associative arrays call __rt_hash_union, which clones the left hash, walks the right hash in insertion order, and inserts only keys that are absent from the clone. Mixed indexed/associative operands return a hash result: __rt_array_hash_union maps left indexed positions into integer hash keys before merging the right hash, while __rt_hash_array_union clones the left hash and probes right indexed positions as integer keys.
Null coalescing operator
The ?? operator returns the left operand if it is non-null, otherwise the right:
$x ?? "default"
; evaluate $x
; compare with null sentinel (0x7FFFFFFFFFFFFFFE)
b.ne _nc_done_1 ; if not null, keep left value
; evaluate "default" ; otherwise, use right side
_nc_done_1:
The null check compares the value against the null sentinel. The operator is right-associative ($a ?? $b ?? $c = $a ?? ($b ?? $c)).
Null coalescing assignment is parsed as $x = $x ?? expr, but assignment lowering recognizes that exact shape and emits a conditional store:
$x ??= "default";
The generated code loads $x, branches past the assignment when it is non-null, and evaluates/stores the right-hand side only on the null path. This preserves PHP’s ??= short-circuit behavior and avoids rewriting an already-owned heap value back into the same local slot.
Pipe operator
PHP 8.5 value |> callable lowers through src/codegen/expr/calls/pipe.rs.
emit_expr() first stores the left-hand value into a hidden local slot so the
left side is observably evaluated before the callable target. It then builds a
synthetic one-argument call using that hidden local as the single positional
argument.
The pipe lowering delegates to the existing call paths whenever possible:
first-class function targets become FunctionCall, first-class static method
targets become StaticMethodCall, first-class instance method targets become
MethodCall, local callable variables become ClosureCall, and other callable
expressions become ExprCall. Argument planning, ABI materialization,
ownership, and diagnostics therefore stay aligned with ordinary calls.
Error-control operator
The @ operator lowers to a scoped runtime diagnostic-suppression pair:
- Call
__rt_diag_push_suppression - Evaluate the operand normally
- Preserve the operand result in the appropriate ABI result shape
- Call
__rt_diag_pop_suppression - Restore the operand result
The exception handler frame also snapshots the current suppression depth before setjmp() and restores it after a longjmp() into catch dispatch. That prevents a thrown expression inside @ from leaking warning suppression into later code.
Nullsafe operator
The ?-> operator lowers nullable receivers through the boxed mixed path used by nullable and union storage. Codegen flattens postfix chains that contain a nullsafe segment, evaluates the base once, and branches to a shared boxed-null result when a nullsafe receiver is null. That branch skips the rest of the chain, including later ordinary -> segments, array indexes, method arguments, and callable arguments. If an ordinary segment later receives a real null value from the non-short-circuited path, it still follows PHP’s warning or fatal behavior.
Type coercions
When types need to match (e.g., int + float), the codegen inserts conversion instructions:
scvtf d0, x0 ; convert signed integer (x0) → double (d0)
fcvtzs x0, d0 ; convert double (d0) → signed integer (x0)
The . (concat) operator also coerces non-strings:
Int→ calls__rt_itoato get a stringFloat→ calls__rt_ftoaBool true→ string “1”Bool false/Null→ empty string (length 0)
Constant references
const MAX = 100;
echo MAX;
Constants declared with const or define() are resolved at compile time. When the codegen encounters a ConstRef, it looks up the constant’s value and emits it as a literal — mov x0, #100 for an integer, or loads a string label from the data section. define() call sites still emit a per-constant runtime seen flag so the call returns true only for the first runtime definition and returns false with a suppressible warning on duplicate attempts.
Enum cases reuse the same idea, but through enum metadata instead of scalar constants: parser output uses ExprKind::ScopedConstantAccess for Color::Red, and codegen detects enum receivers to load the canonical enum-case symbol emitted in runtime data. Helper builtins such as Enum::from() / Enum::tryFrom() lower through the checker/codegen enum tables carried in Context.
Pointer values and casts
Pointer expressions are carried in x0 as plain 64-bit addresses:
ptr($var)computes the address of a stack or global slot and returns it inx0ptr_null()loads the zero addressptr_cast<T>($p)only changes the static type tag seen by the checker, so codegen emits the inner expression and leaves the address unchanged- Pointer printing routes through
__rt_ptoa, which formats the address as a0x...string before writing
Buffer allocation and packed hot-path access
buffer_new<T>(len) lowers directly from ExprKind::BufferNew: codegen evaluates the element count, loads the checked element stride from the type metadata, and calls __rt_buffer_new. The resulting pointer in x0 references a contiguous [length][stride][payload...] block rather than a PHP array/hash structure.
When T is a scalar POD type, reads and writes use direct address arithmetic from the buffer base plus index * stride. When T is a packed class, codegen combines the buffer element stride with the field offset from packed_classes metadata and emits direct typed loads/stores into the packed payload.
Function calls
my_func($a, $b, $c)
- Evaluate each argument and push results onto the stack
- Pop arguments into the correct ABI registers (
x0-x7for ints,d0-d7for floats, two registers per string) - If a heap-backed argument is being borrowed from an existing owner (for example a local variable or container read), retain it before passing it to the callee
bl _fn_my_func— branch with link (saves return address)- Result is in
x0/d0/x1+x2depending on return type
Named-argument calls split evaluation order from ABI order. src/codegen/expr/calls/args.rs evaluates source arguments left-to-right, stores any out-of-order values in temporary slots, validates spread prefixes after later named expressions have run, then materializes the final parameter list in ABI order. Spread prefixes before named arguments are evaluated once; multiple prefix spreads are combined before runtime length/overwrite checks, and too-short positional spreads for required parameters fail instead of reading beyond the array payload. Runtime associative-array spreads are dynamic named providers: they look up string keys by parameter name, fall back to numeric keys for positional slots, and let the per-parameter missing/default branch decide whether a required value is present. Built-in and extern named calls use the same source-order pre-evaluation step before their normalized positional emitters run; mutating built-ins mark their target parameter as ref-like so pre-evaluation does not redirect writes into a temporary. Extern calls preserve PHP source evaluation order first and only then load C ABI registers.
Closure codegen
Anonymous functions and arrow functions
Closures (function($x) { ... }) and arrow functions (fn($x) => ...) are compiled as separate labeled functions, similar to user-defined functions. The key difference is deferred emission — the closure body is not emitted inline. Instead:
-
At the closure expression site: the codegen generates a unique label (e.g.,
_closure_1) and loads its address intox0usingadrp+add. The address is then stored in the variable’s stack slot as aCallable(8-byte function pointer). -
The body is deferred: the closure’s parameter list, body statements, captured variables, and label are pushed onto
ctx.deferred_closures. This avoids emitting function code in the middle of the current function’s instruction stream. -
After
_main: all deferred closures are emitted as standalone labeled functions (prologue, body, epilogue), just like user-defined functions.
use captures
Closures can capture variables from the enclosing scope via use ($var1, $var2):
$greeting = "Hello";
$fn = function($name) use ($greeting) {
echo $greeting . " " . $name;
};
Only explicit use (...) captures are stored in the AST and forwarded as hidden closure arguments. Arrow functions are still parsed as closures, but they use is_arrow = true with an empty captures list.
The AST stores captured variable names in the captures field of the Closure expression. At the call site, captured variables are passed as extra arguments after the explicit arguments:
- At the closure expression site: the captured variable names and types are recorded in
ctx.closure_capturesalongside the deferred closure. - At the call site (
$fn("World")): the codegen looks up the captured variables, evaluates them from the caller’s scope, and passes them as additional arguments after the explicit ones. - In the closure body: the captured values arrive as extra parameters and are stored in local stack slots, making them accessible like regular local variables.
This means captures are passed by value — modifying a captured variable inside the closure does not affect the outer scope (matching PHP semantics).
Closure calls
When a closure variable is called ($fn(1, 2)), the codegen:
- Evaluates each argument and pushes results onto the stack
- Loads the closure function address from the variable’s stack slot into
x9 - Pushes
x9temporarily while popping arguments into ABI registers - Pops
x9back and callsblr x9— an indirect branch through a register
blr (Branch with Link to Register) is like bl but the target address comes from a register rather than a label. This is what makes closures work — the compiler doesn’t know at compile time which function will be called, so it uses an indirect jump.
Closures as callback arguments
Built-in functions like array_map, array_filter, array_reduce, array_walk, usort, uksort, and uasort accept callback values. The callback function pointer is passed in a register (like any other Callable argument) and the runtime routine calls it via blr.
For captured closures passed through callback runtimes such as array_map, array_filter, array_reduce, array_walk, usort, uksort, and uasort, codegen builds a temporary callback environment containing the original closure pointer plus its hidden use (...) values. The runtime passes that environment to a generated callback wrapper, and the wrapper re-materializes the original visible arguments plus hidden captures before calling the closure. call_user_func() and call_user_func_array() do not need a runtime loop, so they append the hidden capture arguments directly at the indirect call site. When call_user_func_array() targets a by-reference callback and receives a literal argument array, codegen passes frame-slot addresses for variable elements in by-reference positions instead of loading array payload values.
First-class callable wrappers reuse this hidden argument path when the callable target carries context. $obj->method(...) records the receiver as a hidden capture; non-local receiver expressions are evaluated once into a hidden temporary before wrapper creation. static::method(...) records the forwarded called-class id, or $this in an instance method, so late static binding is preserved for direct callable calls and for callback paths that forward an environment.
Generator codegen
Files: src/codegen/functions/generator/, src/codegen/runtime/generators/, src/codegen/expr/objects/dispatch/vtable.rs
A function or closure body that contains yield does not emit as an ordinary function body. Codegen emits two symbols:
_fn_<name>— a wrapper that allocates a heapGeneratorFrame, stamps it as the built-inGeneratorobject, copies supported scalar parameters/captures into frame slots, zeroes local slots, and returns the frame pointer._fn_<name>__resume— a state-machine entry point. State0enters the body; each yield gets a numbered resume label. At a yield, the resume function boxes the key/value into Mixed cells, replaces the frame’s last key/value slots, stores the next state index, and returns to the caller.
Generator closures reuse the same path as ordinary deferred closures, but their hidden use (...) captures are copied into the generator frame alongside visible parameters. yield from stores the active inner generator in the frame’s delegated_iter slot and resumes it through the same __rt_gen_* runtime helpers used by user-visible Generator methods.
The generated Generator object has a custom payload layout rather than ordinary PHP properties. Method dispatch for current, key, valid, next, rewind, send, throw, and getReturn is intercepted before vtable lookup and routed directly to __rt_gen_*. Both AArch64 and Linux x86_64 follow the same high-level state-machine model; the wrapper, resume dispatcher, and runtime helper emitters select target-specific instruction sequences internally.
Fiber codegen
Files: src/codegen/expr/objects/allocation.rs, src/codegen/expr/objects/dispatch/, src/codegen/expr/objects/fiber_wrapper.rs, src/codegen/functions/fiber_wrapper.rs, src/codegen/runtime/fibers/
Fiber is a built-in class, but codegen does not lower it through the ordinary object constructor and method-dispatch path. new Fiber($callable) is intercepted and delegated to __rt_fiber_construct, which allocates the larger runtime-managed Fiber object, creates its guarded native stack, stores the original callable pointer, and records the generated wrapper label that adapts Fiber start values to the callback ABI.
Each accepted Fiber callback gets a deferred entry wrapper emitted next to deferred closure bodies. The wrapper runs on the Fiber stack, reloads boxed start() values from start_args[0..6], unboxes them to the callback’s declared parameter types, appends any preloaded closure captures from reserved Fiber-owned slots, calls the original closure/function pointer with normal ABI materialization, and boxes the terminal return value back to mixed.
Instance and static Fiber methods are also intercepted:
$fiber->start(...)spills up to seven boxedmixedstart arguments into the Fiber object before calling__rt_fiber_start; it respectsuser_arg_maxso closure captures stored in trailing slots are not overwritten.$fiber->resume($value),$fiber->throw($exception),$fiber->getReturn(), and the state predicates branch directly to their__rt_fiber_*runtime helpers.Fiber::suspend($value)andFiber::getCurrent()lower to runtime helper calls instead of ordinary static method dispatch.
Both AArch64 and Linux x86_64 use the same high-level lowering. The final register moves, temporary-stack layout, direct/indirect calls, and frame setup go through the ABI module so the Fiber wrapper follows each target’s calling convention rather than hardcoding ARM64 register names in shared code.
Associative array codegen
Associative arrays use a hash table stored on the heap. The codegen differs from indexed arrays at every level:
Literal creation
$m = ["name" => "Alice", "age" => "30"];
- Call
__rt_hash_newwith initial capacity and value type tag →x0= hash table pointer - For each key-value pair: evaluate key (string →
x1/x2), evaluate value, call__rt_hash_set
Access
$m["name"]
- Save hash table pointer on stack
- Evaluate key expression →
x1/x2(string) - Call
__rt_hash_get→x0= found (0/1),x1= value_lo,x2= value_hi,x3= per-entry value tag - Move result to standard registers based on value type; if the static result is
Mixed, box the payload into a heap cell first
Functions on associative arrays
Builtin functions like array_key_exists, in_array, array_keys, array_values dispatch on the array type at compile time:
PhpType::Array→ use indexed runtime routines (e.g., bounds check, linear scan)PhpType::AssocArray→ use hash table routines (e.g.,__rt_hash_get,__rt_hash_iter_next)
foreach over associative arrays
When foreach iterates a PhpType::AssocArray, the lowering differs from indexed arrays:
- Save the hash pointer and an iteration cursor on the stack (
0means “start from header.head”) - Call
__rt_hash_iter_next - If
x0 == -1, exit the loop - Otherwise save the returned cursor, store
x1/x2into the optional key variable, and storex3/x4/x5into the value variable according to the inferred element type;Mixedloop variables reuse or allocate boxed mixed cells as needed - Emit the loop body, then branch back to the iterator call
This preserves PHP-style insertion order because __rt_hash_iter_next walks the hash table’s linked insertion-order chain rather than scanning physical buckets.
See The Runtime for details on hash table routines and Memory Model for the hash table memory layout.
String indexing codegen
The same ArrayAccess AST node also covers string indexing such as $str[1] or $str[-1]. In src/codegen/expr/arrays.rs, emit_array_access() checks for PhpType::Str and lowers the operation inline:
- Save the string pointer/length while evaluating the index expression
- Adjust negative indices relative to the end of the string
- Clamp offsets below
-lento the start and offsets past the end to the end - Advance the string pointer to the selected byte
- Return either a one-character string (
x1+x2 = 1) or an empty string when the offset is out of bounds
So the behavior is slice-like, but it does not call substr() or a dedicated runtime helper.
Statement codegen
Files: src/codegen/stmt.rs, src/codegen/stmt/
emit_stmt() is similarly split across focused helpers under stmt/: assignment/storage logic, array statements, include-once guards, and control-flow lowering (branching, foreach, loops) now live outside the thin top-level dispatcher. stmt/includes.rs emits the .comm flag and branch sequence used by resolver-generated IncludeOnceMark and IncludeOnceGuard nodes, plus the active-variant store used when an include point loads a hidden function implementation. Small shared statement-side policies such as borrowed-result retention, local-slot ownership updates, static-init guards, and indexed-array metadata stamping now sit in stmt/helpers.rs instead of bloating stmt.rs itself. Storage lowering is now split too: stmt/storage.rs is just a boundary, with storage/locals.rs handling ordinary global/static symbol access and storage/extern_globals.rs owning extern-global load/store conventions. Assignment lowering is also split one level deeper: stmt/assignments/locals.rs handles plain local/global/ref writes, while stmt/assignments/properties.rs now orchestrates property writes across properties/target.rs, magic_set.rs, and storage.rs. Array-index writes follow the same pattern now: stmt/arrays/assign.rs is just a dispatcher, while stmt/arrays/assign/buffer.rs and assoc.rs isolate the non-indexed-container paths, and stmt/arrays/assign/indexed.rs now orchestrates the indexed-array write across indexed/prepare.rs, normalize.rs, store.rs, and extend.rs. Branching lowering now follows that same shape too: stmt/control_flow/branching.rs is just a boundary, while branching/if_stmt.rs and branching/switch_stmt.rs own the distinct lowering paths. Exception lowering follows the same structure: stmt/control_flow/exceptions.rs orchestrates the high-level try/catch/finally flow, while exceptions/handlers.rs, catches.rs, and finally.rs own the lower-level handler stack, catch matching, and pending-action/finally dispatch mechanics. Loop lowering is split too: stmt/control_flow/loops.rs is now just a boundary, with loops/iterative.rs handling for/while/do...while and loops/exits.rs owning break/continue/return. foreach lowering now follows the same pattern: stmt/control_flow/foreach.rs dispatches between foreach/indexed.rs, foreach/assoc.rs, and foreach/iterator.rs for arrays, hashes, Iterator, IteratorAggregate, and object-backed iterable values.
Statement AST dispatch coverage
The statement dispatcher maps StmtKind variants to storage, control-flow,
declaration, include, or extension paths:
| Variants | Lowering path |
|---|---|
Echo, ExprStmt, Throw, Synthetic | Direct statement helpers, expression dispatch, exception throw, or already-lowered statement sequences |
Assign, TypedAssign, ArrayAssign, NestedArrayAssign, ArrayPush, ListUnpack, PropertyAssign, StaticPropertyAssign, PropertyArrayPush, PropertyArrayAssign, StaticPropertyArrayPush, StaticPropertyArrayAssign | Local/global/static storage, array storage, destructuring, and property storage helpers |
If, IfDef, While, DoWhile, For, Foreach, Switch, Try, Break, Continue, Return | Branching, compile-time conditional lowering, loops, foreach dispatch, switch lowering, exception/finally control flow, loop exits, and return epilogues |
Include, IncludeOnceMark, IncludeOnceGuard, FunctionVariantGroup, FunctionVariantMark | Resolver-produced include guards and include-loaded function variant activation |
NamespaceDecl, NamespaceBlock, UseDecl, ConstDecl | Mostly frontend/name-resolution artifacts; constants remain available through the codegen context |
FunctionDecl, ClassDecl, EnumDecl, InterfaceDecl, TraitDecl, PackedClassDecl | Deferred function/method emission and metadata-driven class, enum, interface, trait, and packed-record setup |
Global, StaticVar | Symbol-backed local aliases and per-function static storage |
ExternFunctionDecl, ExternClassDecl, ExternGlobalDecl | Registration-only at statement emission; expression/call lowering uses the collected FFI metadata |
Echo and print
echo $x;
echo "a", "b";
$status = print $x;
- Evaluate each
echoexpression in source order → result in registers - Check for null/false (skip printing if so — matches PHP behavior where
echo falseprints nothing) - Call
emit_write_stdout()from the ABI module
print expressions reuse the same stdout helper, then write integer 1 into
the expression result register so the value can be assigned, concatenated, or
passed into another expression.
Assignment
$x = expr;
- Evaluate expression
- If the result is a borrowed heap value, retain it before the local slot becomes a new owner
- Release the previous owned heap value from
$xwhen overwriting a heap-backed slot emit_store()— write result to$x’s stack slot and classify the local slot asOwnedfor heap-backed types
Typed local declarations such as int $x = 42; or buffer<int> $xs = buffer_new<int>(8); share the same storage path after the checker has resolved StmtKind::TypedAssign into a concrete PhpType.
Constant declaration
const MAX = 100;
ConstDecl registers a compile-time constant. The value is stored in the codegen context and substituted directly wherever the constant is referenced via ConstRef. No runtime storage or stack allocation is needed.
Global variables
$x = 10;
function inc() {
global $x;
$x++;
}
The global statement inside a function declares that a variable refers to global storage rather than a local stack slot. The codegen uses BSS-allocated storage (_gvar_NAME, 16 bytes each) for global variables:
- At
global $x;: the variable is marked as global in the context. The current value is loaded from_gvar_xinto the local stack slot. The local view is tracked as a borrowed alias of the BSS-backed owner. - On assignment to a global variable: the codegen writes to the BSS storage (
_gvar_x) viaadrp/add/strinstead of (or in addition to) the local stack slot. - In
_main: when the main scope assigns to a variable that any function declares asglobal, the value is also written to_gvar_NAMEso that functions can read it.
Extern declarations
ExternFunctionDecl, ExternClassDecl, and ExternGlobalDecl are registration-only statements during codegen. Their metadata has already been collected by the type checker and copied into Context, so emit_stmt() treats the declarations themselves as no-ops while later expression codegen uses the recorded FFI data.
Extern globals are loaded through GOT-relative addressing (adrp ...@GOTPAGE / ldr ...@GOTPAGEOFF) instead of ordinary stack or BSS slots.
Static variables
function counter() {
static $count = 0;
$count++;
echo $count;
}
Static variables persist their value across function calls. Each static variable gets two BSS slots:
_static_FUNC_VAR(16 bytes) — stores the persisted value_static_FUNC_VAR_init(8 bytes) — initialization flag (0 = not yet initialized)
The codegen for static $count = 0;:
- Check the init flag — if already initialized, skip to loading the persisted value
- If not initialized: evaluate the init expression, store to the BSS slot, set the init flag to 1
- Load the persisted value into the local stack slot
That per-call local slot is tracked as Borrowed; the persisted static storage remains the long-lived owner.
At function epilogue, variables marked as static are written back to their BSS storage.
Static properties
Static properties use one global 16-byte storage slot per effective declaring class property:
_static_prop_CLASS_PROPstores the current value payload- inherited static properties point back to the declaring class slot until a subclass redeclares the property
- redeclared static properties get a separate subclass slot
At program startup, _main evaluates static property defaults and stores them into these slots before user statements run. Reads such as ClassName::$count load directly from the resolved symbol, and assignments store the new result back to the same symbol after type coercion and previous-value release for heap-backed values. static::$count uses the forwarded called-class id (or $this in instance methods) to select a redeclared descendant slot at runtime; if that late-bound slot is private and inaccessible from the current method scope, generated code emits a fatal private-static-property diagnostic.
List unpacking
[$a, $b, $c] = [10, 20, 30];
Simple local positional destructuring remains a ListUnpack statement. The codegen:
- Evaluates the right-hand side expression (an array)
- Saves the array pointer on the stack
- For each variable in the list: loads the element at the corresponding index from the array, stores it into the variable’s stack slot, and marks heap-backed elements as borrowed aliases of the source container
Richer PHP destructuring patterns are lowered by the parser into ordinary synthetic assignments before checking and codegen. Skipped entries simply emit no assignment, keyed entries become array reads with the given key, nested patterns bind a hidden temporary for the nested source array, and non-local targets reuse the same assignment emitters as $arr[$i] = ..., $arr[] = ..., $obj->prop = ..., and static-property writes.
If / Elseif / Else
if ($cond1) { body1 } elseif ($cond2) { body2 } else { body3 }
; evaluate $cond1
cmp x0, #0
b.eq _elseif_1 ; skip to next branch if falsy
; body1
b _end_if_1 ; done — skip all remaining branches
_elseif_1:
; evaluate $cond2
cmp x0, #0
b.eq _else_1
; body2
b _end_if_1
_else_1:
; body3
_end_if_1:
While loop
while ($cond) { body }
_while_1: ; ← continue jumps here
; evaluate $cond
cmp x0, #0
b.eq _end_while_1 ; exit if falsy ← break jumps here
; body
b _while_1 ; loop back
_end_while_1:
For loop
for ($i = 0; $i < 10; $i++) { body }
; emit init ($i = 0)
_for_1:
; evaluate condition ($i < 10)
cmp x0, #0
b.eq _end_for_1
; body
_for_cont_1: ; ← continue jumps here
; emit update ($i++)
b _for_1
_end_for_1: ; ← break jumps here
Foreach
foreach ($arr as $v) { body }
For indexed arrays:
- Save array pointer, length, and index counter on the stack (3 × 16-byte slots)
- Loop: load element at current index, unbox through the runtime
value_typetag when the static element type isMixed, store to$v, and classify heap-backed loop variables as borrowed aliases of the iterated container - Branch back to condition check
- Cleanup: deallocate the 48 bytes
For associative arrays, see Associative array codegen: the loop stores a hash pointer plus cursor, then advances with __rt_hash_iter_next.
For Iterator objects, codegen parks the receiver in a 16-byte stack slot, dispatches rewind(), then drives the loop through valid(), key(), current(), and next(). Keys and values are boxed into Mixed because the concrete runtime payload can vary per iterator implementation. IteratorAggregate values dispatch getIterator() first, then reuse the same iterator loop path. Values typed as iterable branch through runtime heap-kind and interface metadata so arrays, direct Iterator objects, and aggregate-backed objects select the correct lowering.
Before the first valid() call, foreach target slots are normalized to boxed Mixed. That keeps empty iterators compatible with PHP: existing target variables keep a valid mixed cell, fresh loop variables remain null-like, and receiver aliases stay live until loop cleanup.
Break / Continue
break emits a b (unconditional jump) to the selected loop/switch end label.
continue emits a b to the selected continue label (the condition check for
while, the update for for, or the switch end label for PHP-style
continue inside switch).
The loop_stack in the Context tracks labels for nested loops and switches.
Multi-level forms such as break 2; and continue 2; index back through that
stack. Each LoopLabels entry also carries an sp_adjust field so multi-level
exits and returns can undo any skipped switch-subject temporary stack slots
before jumping to the selected target or shared function epilogue. If the exit
crosses a finally, codegen records the selected target and runs the active
finally chain before resuming the branch.
The type checker rejects break / continue that would jump out of a
finally body, so codegen only has to route legal exits from protected try or
catch bodies through finally_stack.
Exceptions and finally
Exception lowering lives in src/codegen/stmt/control_flow/exceptions.rs. The basic strategy is:
- Evaluate the thrown object and publish it to
_exc_value - Call
__rt_throw_current, which unwinds activation records andlongjmps into the nearest handler - For
try, emit a_setjmpresume point plus a linked handler record in_exc_handler_top - Test each catch target by class id or interface id through
__rt_exception_matches - Route
return,break,continue, and rethrow throughfinally_stackso every enclosingfinallyruns before control leaves the protected region. The checker rejectsbreak/continuethat would originate inside afinallyand target an outer loop/switch.
This means finally is part of ordinary control-flow lowering, not a separate runtime pass. The runtime only unwinds frames and chooses the landing pad; the compiler-generated labels still decide whether execution resumes in a matching catch, in a finally, or in an outer handler.
Switch
switch ($x) {
case 1: echo "one"; break;
case 2: echo "two"; break;
default: echo "other"; break;
}
- Evaluate the subject expression once and push the result onto the stack
- For each case: pop subject, evaluate case value, compare (
cmp+b.nefor integers,bl __rt_str_eqfor strings) - If match: emit case body, which may contain
break(jump to end label) or fall through to next case - Default case: emit body unconditionally
- End label after all cases
The switch uses the loop stack so that break inside a case body jumps to the switch end label rather than an enclosing loop.
Match expression
Match is an expression (returns a value), not a statement. It uses strict comparison (===) and has no fall-through:
$result = match($x) {
1 => "one",
2 => "two",
default => "other",
};
- Evaluate subject, push onto stack
- For each arm: compare subject with each pattern in the arm’s pattern list
- If any pattern matches: evaluate the arm’s result expression, jump to end
- Default arm: evaluate result unconditionally
- Result is left in standard registers (
x0,d0, orx1/x2)
Class codegen
Object allocation (new ClassName(...))
When the codegen encounters a NewObject expression:
- Calculate object size:
8 + (num_properties × 16) + dyn_props_slot— 8 bytes for the class ID, 16 bytes per property across the full inherited layout, plus one optional 8-byte slot for the dynamic-property hash pointer when the class carries#[\AllowDynamicProperties] - Allocate heap memory: call
__rt_heap_allocwith the calculated size - Zero-initialize: clear all property slots to zero
- Store class ID: write the class identifier at offset 0
- Apply defaults: for properties with default values, evaluate and store them at their fixed offsets
- Call constructor: if the class exposes
__construct, pass the new object pointer asx0($this) followed by the constructor arguments, then branch to the implementation label recorded in class metadata (which may come from an inherited constructor)
Classes declared with the PHP 8.2 #[\AllowDynamicProperties] attribute reserve a trailing per-object hash slot so undeclared property writes/reads can be routed through a runtime side table instead of failing at compile time.
The result is the object pointer in x0.
Attribute reflection objects
new ReflectionClass(...), new ReflectionMethod(...), and new ReflectionProperty(...) are intercepted by src/codegen/expr/objects/reflection.rs instead of relying on ordinary user-defined constructor bodies. The type checker has already forced their class/member arguments to compile-time strings after normal call-argument planning, so codegen can look up the target ClassInfo directly and populate the private __attrs slot with a freshly built array<ReflectionAttribute>.
src/codegen/reflection.rs owns the shared materialization path. It allocates each synthetic ReflectionAttribute, writes the resolved __name, builds the array<mixed> __args payload from supported literal attribute arguments, and stores a deterministic __factory id. ReflectionAttribute::newInstance() is then generated in src/codegen/class_methods.rs as a branch table over those factory ids; each branch constructs the real attribute class with the captured literal args, and the fallback returns null when no defined attribute class can be materialized.
The _class_attribute_* runtime data tables still emit class-level attribute metadata from the same ClassInfo fields, but the supported Reflection owner constructors are compile-time materialized and do not perform runtime name lookups for classes, methods, or properties.
Type checks ($obj instanceof ClassName)
ExprKind::InstanceOf evaluates the left-hand side exactly once, materializes the target class or interface id from emitted metadata, and returns a boolean in x0. Direct object values call __rt_exception_matches, the same metadata matcher used by exception catch lowering, so inherited classes and implemented interfaces are handled through the same parent-id and class-interface tables.
For named targets, when the left-hand side is lowered as Mixed or Union, codegen calls __rt_mixed_instanceof instead. That helper unwraps nested mixed boxes, returns false for scalar, array, null, and unknown payload tags, and only forwards object payloads into __rt_exception_matches. This keeps nullable and union object checks PHP-compatible without treating the boxed mixed cell itself as an object pointer.
Named targets are resolved before codegen. Named classes/interfaces become concrete metadata ids, self and parent resolve in the current lexical class context, and static uses the forwarded called-class id for late static binding. Dynamic targets are evaluated and validated after the left-hand side is evaluated; string targets are resolved through emitted case-insensitive class/interface name metadata, object targets load the target object’s runtime class id, invalid target payloads branch to a fatal runtime diagnostic, and non-object left-hand payloads become false after that validation step.
Property access ($obj->prop)
Property access usually uses fixed offsets computed at compile time from ClassInfo.property_offsets:
; $obj->prop where prop resolved to offset 24
ldur x0, [x29, #-offset] ; load object pointer
ldur x0, [x0, #24] ; load property at resolved inherited offset
If the property does not exist but the class exposes __get($name), codegen materializes the property name as a string literal, pushes it as an argument, and dispatches the instance method through the normal object-call path. The returned value then flows back through the ordinary result registers based on the inferred return type.
For property assignment ($obj->prop = value), the value is evaluated first, then stored at the resolved inherited offset. If the property is missing but the class exposes __set($name, $value), codegen boxes the value as Mixed, materializes the property name, and dispatches __set instead of emitting a direct store.
Property-array writes use the same fixed-offset property resolution first, then delegate to the ordinary array storage paths for the nested container. $obj->items[] = $value lowers through PropertyArrayPush, and $obj->items[$key] = $value lowers through PropertyArrayAssign; both require a concrete array/assoc-array property rather than a magic __set fallback.
Method call ($obj->method(args))
- Evaluate the object expression to get the pointer in
x0 - Push the object pointer onto the stack
- Evaluate and push all arguments
- Pop arguments into ABI registers, with the object pointer as the first argument (
x0) - Load the object’s
class_id, fetch the class vtable pointer from_class_vtable_ptrs, load the method slot, andblrto the resolved implementation - Result is in the standard registers based on return type
Inside the method body, $this is the first parameter and lives in the function’s first stack slot.
Private instance methods are the exception: they do not get vtable slots, so calls resolved to a private method of the current lexical class use a direct _method_Class_method branch instead of virtual dispatch.
Static method call (ClassName::method(args))
Static methods are called like regular functions, but with the label _static_ClassName_methodName. No object pointer is passed:
bl _static_Point_origin ; call static method
; result in x0 (object pointer)
self::method() is handled as a direct call against the current lexical class. If it resolves to an instance method, codegen loads the implicit $this receiver and branches directly to the resolved _method_Class_method label. parent::method() works the same way against the immediate parent class. For static targets, codegen now also threads a hidden “called class id” argument through static method bodies: named ClassName::method() calls pin that id to the named class, while self:: and parent:: forward the current called class. static::method() then uses that forwarded class id to load the target from a per-class static-method table at runtime.
The ABI module
Files: src/codegen/abi/mod.rs, src/codegen/abi/
Centralizes register conventions so they’re consistent everywhere:
Large offset addressing
ARM64’s stur/ldur instructions only support 9-bit signed immediates (offsets up to 255). Functions with many local variables can exceed this limit. The ABI module handles this transparently via store_at_offset() and load_at_offset():
- Offsets <= 255: single
stur/ldurinstruction (fast path) - Offsets 256-4095: two-instruction sequence —
sub x9, x29, #offsetto compute the address in a scratch register, thenstr/ldrthrough that register
This means all codegen that accesses stack variables goes through the ABI helpers rather than emitting stur/ldur directly, so large stack frames work automatically. The same boundary now also owns indirect [*ptr] loads/stores used by by-reference params and mutation-heavy expression paths, so x86_64-specific memory syntax does not leak back into expr.rs.
emit_frame_slot_address() complements those helpers when codegen needs the address of a local slot itself rather than the value stored there. By-reference calls, ptr($var), and exception-frame bookkeeping now all reuse that helper instead of open-coding frame-slot address math.
Frame and return-value helpers
The abi/ module now centralizes the frame-management primitives used by both _main and ordinary functions:
emit_frame_prologue()/emit_frame_restore()— shared stack-frame setup and teardownemit_cleanup_callback_prologue()/emit_cleanup_callback_epilogue()— tiny helper frames used by exception cleanup callbacksemit_preserve_return_value()/emit_restore_return_value()— spill/reload of scalar, float, and string returns across epilogue side effects orfinallydispatch
That moves prologue/epilogue mechanics out of the higher-level walkers and makes the ABI layer responsible for more than just local-slot addressing.
Incoming argument lowering
Incoming parameter decoding now goes through IncomingArgCursor plus emit_store_incoming_param().
The cursor tracks:
- current integer argument register index
- current floating-point argument register index
- when argument passing has overflowed to the caller stack
- the caller-stack byte offset for subsequent spilled parameters
Those helpers now understand both the AArch64 calling convention and the Linux x86_64 SysV AMD64 target. Function codegen delegates incoming-parameter lowering to the ABI layer instead of open-coding register names or caller-stack offsets inline.
Outgoing call argument lowering
Outgoing calls now use ABI-owned helpers as well:
build_outgoing_arg_assignments_for_target()decides whether each argument lands in an integer register, a floating-point register, or overflows onto the caller-visible stack area for the selected targetmaterialize_outgoing_args()rewrites the temporary pushed-argument stack into the final ABI layout expected at the call site
That logic is shared by ordinary function calls, indirect/callable dispatch, object/method calls, constructor/static dispatch, and helpers such as call_user_func_array(). The assignment/materialization rules now cover both AArch64 and Linux x86_64 SysV layout, so the call ABI policy lives in one place instead of being duplicated across several dispatch paths.
The same module now also owns a thin layer of call-site and temporary-stack primitives used by higher-level walkers:
emit_call_label()/emit_call_reg()emit direct and indirect calls for the current targetemit_push_reg(),emit_pop_reg(),emit_push_float_reg(),emit_pop_float_reg(),emit_push_reg_pair(),emit_pop_reg_pair(), andemit_push_result_value()manage the temporary argument stack without hardcoding ARM64 push/pop forms in each call pathemit_reserve_temporary_stack(),emit_temporary_stack_address(), andemit_load_temporary_stack_slot()now also back the FFI extern-call path, where borrowed C-string temporaries are tracked and released after the foreign call returnsemit_release_temporary_stack()andemit_store_zero_to_local_slot()centralize target-specific stack cleanup and zero-initialization detailsemit_store_process_args_to_globals(),emit_enable_heap_debug_flag(),emit_copy_frame_pointer(), andemit_exit()cover the_mainbootstrap/teardown path without hardcoding process-entry registers or exit sequences in the higher-level driver
That keeps target-specific ABI work focused inside abi/ instead of scattering call, blr, add sp, rsp, or zero-register assumptions across function, closure, callable, and method dispatch code.
The same abi/ layer now also owns symbol-slot plumbing for compiler-managed globals such as _gvar_*, _static_*, _exc_*, _global_*, and the high-frequency runtime symbols used by string builders, heap bookkeeping, and GC state such as _concat_off, _heap_*, and _gc_*: computing symbol addresses, moving result registers into symbol storage, loading symbol storage back into result registers, and copying local frame slots into symbol-backed storage during epilogues. Extern globals now use the same boundary too, so GOT/GOTPCREL address materialization lives in abi/ instead of being open-coded separately in expression and statement lowering.
emit_store(emitter, type, offset)
Stores the current result to a stack variable. Uses store_at_offset() internally to handle large offsets:
| Type | What it stores |
|---|---|
Int / Bool / Resource | stur x0, [x29, #-offset] (or 2-insn sequence for large offsets) |
Float | stur d0, [x29, #-offset] |
Str | bl __rt_str_persist, then stur x1, [x29, #-offset] + stur x2, [x29, #-(offset-8)] |
Array / AssocArray / Iterable | stur x0, [x29, #-offset] |
Mixed | stur x0, [x29, #-offset] |
Object | stur x0, [x29, #-offset] |
Callable / Pointer | stur x0, [x29, #-offset] |
Buffer / Packed / Union | stur x0, [x29, #-offset] |
emit_load(emitter, type, offset)
Loads a stack variable into result registers (inverse of store). Uses load_at_offset() internally.
emit_write_stdout(emitter, type)
Emits code to print a value to stdout:
| Type | How it prints |
|---|---|
Str | mov x0, #1 / mov x16, #4 / svc #0x80 (direct syscall) |
Int | bl __rt_itoa → then write |
Float | bl __rt_ftoa → then write |
Bool | true prints “1”, false prints nothing |
Pointer | bl __rt_ptoa → then write |
Mixed | bl __rt_mixed_write_stdout → inspect boxed runtime tag, then write |
Void/Array/AssocArray/Callable/Object | Prints nothing |
For Linux x86_64, the same write path now follows the SysV ABI and a broad native runtime slice rather than AArch64-specific helper sequences. String results use the Linux syscall register layout, integer and float echo go through x86_64 __rt_itoa / __rt_ftoa, _main initializes $argc / $argv only when needed, and the bootstrap runtime now covers a wide set of array, string, math, filesystem, FFI, enum, exception, GC, and mixed-value helpers without leaking AArch64-only assumptions back into the higher-level walkers.
That same bootstrap system slice now also includes x86_64-native time() / microtime(true) through libc gettimeofday(), target-aware php_uname() through libc uname(), plus package-version lowering for phpversion() and constant-string lowering for sys_get_temp_dir() via the shared symbol-address ABI helpers instead of ARM64-only adrp / add_lo12 sequences.
The x86_64 math surface is broader now too: the libc-backed float builtin family (sin, cos, tan, asin, acos, atan, sinh, cosh, tanh, exp, log, log2, log10, atan2, hypot, pow) and the pure float helpers (sqrt, pi, deg2rad, rad2deg, min, max) all use SysV floating-point registers plus the shared temporary-stack ABI helpers instead of raw AArch64 d0 / scvtf / str d0 lowering. The same applies to the ** operator in expression codegen, which now routes through the x86_64 pow() libc call path with the right floating argument order. The scalar random helpers (rand(), mt_rand(), random_int()) also live on that target-aware ABI path now, so their [min, max] range materialization no longer emits raw AArch64 stack spills on Linux x86_64. Comparator-driven indexed-array sorting is on that same path too: usort(), uasort(), and uksort() now resolve callback addresses through the shared symbol/stack ABI helpers and dispatch through an x86_64 __rt_usort bubble-sort runtime instead of hard-coded ARM64 adrp / blr sequences.
Function codegen
Files: src/codegen/functions/mod.rs, src/codegen/functions/
emit_function()
Compiles a user-defined function:
- Collect local variables — scan the function body to find all variables and their types
- Calculate stack frame size — 16-byte aligned, includes space for all locals
- Emit prologue — call the shared ABI frame helper
- Store parameters — lower incoming arguments through the ABI helpers into stack slots, marking by-value heap params as
Ownedand by-reference params as borrowed aliases of the caller’s storage - Emit body — all statements
- Emit epilogue — preserve return registers, save static locals back to BSS through the shared ABI storage helpers, clean up only
Owned+epilogue_cleanup_safeheap locals, then call the shared ABI frame-restore helper andret
Pass by reference
function increment(&$val) {
$val++;
}
When a parameter is declared with &, the codegen passes the stack address of the argument instead of its value:
- At the call site: the address of the argument’s stack slot is computed (
sub x_n, x29, #offset) and passed in the argument register. - In the function prologue: the address is stored in the parameter’s stack slot (it holds a pointer, not a value).
- On reads: the codegen dereferences the pointer (
ldr x0, [x0]) to get the actual value. - On writes: the codegen stores through the pointer (
str x0, [addr]), modifying the caller’s variable directly.
The context tracks which parameters are pass-by-reference via ctx.ref_params.
Variadic parameters and spread operator
function sum(...$nums) { /* $nums is an array */ }
sum(1, 2, 3);
sum(...$arr); // spread
Variadic functions: The last parameter can be prefixed with ... to collect all remaining arguments into an array. At the call site, the codegen:
- Passes regular (non-variadic) arguments normally via registers
- Uses the shared helpers in
src/codegen/expr/calls/args.rsto prepare normalized/defaulted argument lists, lower pass-by-reference slots, handle spread-into-named parameters, and build the trailing variadic array when needed - Passes the array pointer as the last argument register
Spread operator (...$arr): When calling a function with ...$arr, the array is unpacked into positional parameters. For function f($a, ...$rest), f(...[1, 2, 3]) passes 1 to $a and collects [2, 3] into $rest. Associative-array spreads map string keys to named arguments, keep numeric keys positional, and collapse duplicate static string keys to the last value before planning. Variable AssocArray spreads before named arguments can satisfy later parameters by string key at runtime, so codegen skips fixed prefix length checks for that dynamic provider and emits per-parameter lookup/default handling instead. In array literals, the spread operator uses __rt_array_merge_into to append all elements from the spread array into the target array.
Default parameter values
Functions and closures support default parameter values:
function greet($name, $greeting = "Hello") { ... }
When a call site omits an argument that has a default value, the codegen fills in the default. At the call site, the compiler checks how many arguments were actually passed and, for each missing parameter with a default, evaluates the default expression and places it in the appropriate argument register. This is handled at compile time — no runtime checks are needed.
collect_local_vars()
Pre-scans the function body AST to find every variable that will be used. This is necessary because stack space must be allocated in the prologue, before any code runs.
It walks the statement tree before code emission and handles the major local-binding forms recursively (Assign, control-flow blocks, For/Foreach, ListUnpack, Global, StaticVar, and related cases). The exact match is implementation-driven in the functions/ module, so this list is illustrative rather than exhaustive.
Main program codegen
File: src/codegen/mod.rs
The generate() function orchestrates everything:
- Emit user functions — scan AST for
FunctionDecl, emit each one - Emit class methods — constructor, instance methods, and static methods use their own labels
- Emit
_main:- Prologue (stack frame for global variables)
- Save
argcandargvfrom OS (they arrive inx0andx1) - Build
$argvarray via__rt_build_argvruntime call - Register the main activation record so exceptions can unwind through top-level code too
- Emit all non-function statements
- Epilogue: clean up owned locals, unregister the activation record, then
exit(0)
- Emit deferred closures — closure bodies recorded during earlier expression codegen
- Emit runtime routines — all
__rt_*helper functions - Emit data section — string and float literals
- Emit runtime data / BSS — global buffers, globals, statics, and lookup tables
On Linux x86_64, the current minimal runtime slice now also includes the refcounted indexed-array helper family used by GC-sensitive array transforms such as array_merge(), array_slice(), array_splice(), array_pad(), array_chunk(), array_diff(), array_intersect(), array_combine(), array_reverse(), and array_unique(). The simple sort family that can piggyback on those indexed-array helpers is on the same path now too: asort() / arsort(), ksort() / krsort(), and natsort() / natcasesort() all dispatch through target-aware x86_64 runtime labels instead of hard-coded ARM64 branches.
That x86_64 slice now also covers the copy-on-write and GC accounting paths for indexed and associative arrays: shallow clone / ensure-unique helpers, owned-hash insertion during clone, heap alloc/free GC counters, indexed-array deep-free, and the x86_64 header-stamping paths needed so nested array writes keep their runtime value-type tags intact.
The x86_64 runtime is no longer limited to the earlier malloc / free bootstrap wrappers in those paths. __rt_heap_alloc and __rt_heap_free now mirror the real heap model closely enough to reuse small bins, split and coalesce free-list blocks, trim the bump pointer when the heap tail becomes free again, and drive _gc_live / _gc_peak / _gc_allocs / _gc_frees accounting directly from the allocator. The minimal x86_64 runtime now also emits __rt_gc_mark_reachable and __rt_gc_collect_cycles, so retained arrays, hashes, objects, and boxed mixed values can participate in cycle collection instead of relying only on acyclic decref teardown. That allocator slice now includes the heap-debug/runtime-observability helpers too: __rt_heap_debug_fail, __rt_heap_debug_check_live, __rt_heap_debug_validate_free_list, __rt_heap_debug_report, __rt_heap_kind, and the x86_64 __rt_hash_may_have_cyclic_values path used to skip pointless collector runs for scalar-only hashes.
The same minimal x86_64 runtime now also carries the first string-search / compare slice: __rt_strpos, __rt_strrpos, __rt_strcmp, __rt_strcasecmp, __rt_str_starts_with, __rt_str_ends_with, __rt_strtolower, __rt_strrev, __rt_wordwrap, __rt_str_split, __rt_str_pad, __rt_str_replace, __rt_str_ireplace, __rt_substr_replace, __rt_sprintf, __rt_number_format, and __rt_sscanf, with the matching builtin lowering for strpos(), strrpos(), strcmp(), strcasecmp(), str_contains(), str_starts_with(), str_ends_with(), strstr(), ord(), substr(), substr_replace(), strtolower(), strrev(), wordwrap(), str_split(), str_pad(), str_replace(), str_ireplace(), sprintf(), printf(), number_format(), and sscanf(). That keeps this family on the SysV ABI path instead of falling back to ARM64-only stp/ldp lowering, and the same ABI conversion helpers now also cover settype() when it rewrites locals across int / float / string / bool on Linux x86_64.
The remaining inline array/string accessors are on that same path now too: x86_64 string indexing via ArrayAccess ($str[$i], including negative offsets) and statement-side indexed-array list unpacking no longer emit raw AArch64 ldr / stp snippets. They now restore temporaries through the shared ABI helpers and use native SysV register pairs / stack slots instead.