Clause
← All documentation

Generated-code ABI (revision 21)

Private contract between compiler output and the runtime. It is project-internal C++23, not BEAM-compatible and not a general FFI. Objects and runtime must come from the same build; older descriptor revisions are rejected before use.

Headers: v1.hpp (term/context/function types), term.hpp (immediate integer codec), status.hpp, builtins.hpp, startup.hpp (program startup).

Terms

A term is one unsigned target-pointer-width word (32 or 64 bits). Layouts derive from the configured LLVM target, so cross-target code uses target widths.

Functions and symbols

Entry signature (native C calling convention, no C linkage required):

TermWord function(ProcessContext *context, const TermWord *arguments);

Arguments are a borrowed, word-aligned array in source order (null at arity 0). The context is live and passed unchanged through calls. A returned word is usable only after checking the failure channel.

Symbols: clausev1_<hex module>_<hex function>_<arity>, lowercase hex of UTF-8 bytes, canonical decimal arity; reversible and host-independent. Exported entries are external, others internal.

Module registration

Each module emits clausev1_<hex module>__0.descriptor and .register. The descriptor holds ABI version, term width, export table (name, arity, host entry and the FrameDescriptor dynamic calls enter), atom spellings (UTF-8 pointer/size pairs), native record descriptors (module, name and field atom slots, export flag; native records) in an external <prefix>.records table that other modules of a batch reference, fun descriptors in a private <prefix>.funs table (funs), the encoded module literals and the literal cache (areas), the writable atom and fun caches generated code reads inline, and the code_convention of its bodies. Registration calls CLAUSE_register_module_v4(Runtime*), which validates version/width and all exports, interns atoms, builds a frozen registry and publishes it with the code image in one transaction. Duplicate modules never replace code; any failure publishes nothing (already interned atoms stay in the bounded table).

Failure channel (revision 2)

Errors are not encoded in term bits. After every non-tail generated call the caller checks the channel before using the result or evaluating the next argument: it reads the failed word of the context's FrameState, which the runtime keeps equal to CLAUSE_call_failed_v2(context). On failure the callee returns an invalid zero word.

OutcomeTransport
Pattern mismatch, guard rejectionContinuation to next candidate; channel untouched
Exhausted clauseserror:function_clause; error:{case_clause, Value} with owned payload for a case; error:if_clause; error:{try_clause, Value} for a try's of clauses; error:{else_clause, Value} for a maybe's else clauses
Body match failureerror:{badmatch, Value} with owned payload
Comprehensionserror:{bad_generator, Tail}, error:{bad_filter, Value}, error:{bad_generators, Inputs} (ErrorReason 16-18); a strict generator's rejection is {badmatch, Element}
Record access, bad arguments, arithmetic, mapsbadrecord, badarg, badarith, badmap/badkey
Native record field missingErrorReason::badfield (20), payload {{Module, Name}, Field}
Calling a value (F(Args))ErrorReason::badfun (22, payload the value), badarity (23, payload {Fun, Args}), undef (24), recorded by CLAUSE_apply_v1 (funs)
Dynamic calls (M:F(Args), apply/2,3, fun M:F/A with variables)badarg for a non-atom module or function, an improper argument list or an invalid arity; undef when no module of the program exports the function; badfun/badarity as above (funs)
External native construction without a valueErrorReason::novalue (21), payload {{Module, Name}, Field}
Integer result past the size limitService outcome ValueOutcome::system_limit (3): a guard rejects, a body raises error:system_limit (ErrorReason 19)
Invalid lazy left operand{badarg, Value}
Infrastructure (OOM, limits, ownership, internal)CallError::runtime_failure with exact Status
erlang:error/1,2,3, exit/1, throw/1, erlang:raise/3raised_error/raised_exit/raised_throw: class from the ID, owned payload is the whole reason
erlang:halt/0,1CallError::halted with halt_status (and slogan)

Reasons are typed IDs recorded by CLAUSE_raise_v2; the three raised_* IDs select class exit or throw (otherwise error) and carry any term as the reason. error/2,3 raise through CLAUSE_error_v1(context, reason, args), which also keeps a list args for the top stack frame. First failure wins; nested invocations share the channel. GeneratedInvocation is the host scope: it checks pending failures before entry and after return, copies result or error, and clears only at the outermost exit (also on C++ exceptions). No exception crosses generated entries. Raw entry callers must open a GeneratedInvocation; normal hosts use ResolvedFunction::call.

catch Expr redirects every failure check and raise inside Expr to a handler block that calls CLAUSE_catch_v1(context, slot). For a pending Erlang exception it writes the catch value to the root slot and clears the channel: the thrown term, {'EXIT', Reason} for an exit, or {'EXIT', {Reason, []}} for an error (typed reasons become their OTP terms such as {badmatch, V}; the stack is described below). Halts and infrastructure failures stay pending, and the handler's own check continues to the enclosing handler or function exit. Bindings made inside Expr are unsafe afterwards, so the join only merges the value.

try Body of ... catch ... end protects only Body the same way. Its handler calls CLAUSE_exception_v2(context, class_slot, reason_slot, stack_slot), which writes the class atom (error, exit or throw), the reason and the stack trace term to root slots and clears the channel (halts and infrastructure failures stay pending as for catch). Catch clauses then match Class:Reason with ordinary patterns and guards; an omitted class matches throw, and a named stack variable binds the stack term. When none matches, CLAUSE_reraise_v2(context, class, reason, stack) records the exception again with a raised_* reason and the same stack, which reports and catches exactly like the original. of clauses select on the body value and raise {try_clause, Value} (ErrorReason::try_clause = 14); exceptions inside of clauses and handlers go to the enclosing handler.

maybe needs no service: each ?= is an ordinary match whose mismatch edge leaves the body for the maybe's exit with the unmatched (already rooted) value. Without else that value is the result; otherwise else clauses select on it like case clauses and raise {else_clause, Value} (ErrorReason::else_clause = 15) when none matches.

Comprehensions use the existing services with a few operations: the accumulated elements are reversed by the construction reverse (ContainerConstruction::reverse = 2, values {List, Tail}), which builds the result in one heap reservation; list generators test and read cons cells inline; a binary comprehension joins its pieces with BitOperation::concat ({List}) and a map comprehension builds its map with MapOperation::from_list ({Pairs}, later keys win). Map generators take MapOperation::entries (11, {Map}) once: a cell with the keys and values at words 1 + 2i and 2 + 2i in canonical key order (a classic map itself, or a tuple-shaped copy of an off-heap map's entries that only generated code reads) and read each entry inline; key_at/value_at ({Map, Position}) remain for other callers. A zip's remaining input is shown with MapOperation::iterator, OTP's {K, V, Next} chain ending in none. MapOperation::update_owned (10) takes the operands of update for a map update whose source the compiler proved unread afterwards: an off-heap source map is updated in place and its stub left consumed (empty). Bitstring generators use ordinary pattern extraction plus a final binary/all segment for the rest.

try ... after A end adds a second protection around the body and all of and catch clauses. On the normal path A runs after the selected value is rooted and its value is discarded. The after handler takes the exception with CLAUSE_exception_v2, runs a second copy of A and re-raises with CLAUSE_reraise_v2; an exception or failure inside A leaves through the enclosing handler instead, replacing the original. Halts and infrastructure failures skip A. Root slots belong to the function frame, so every path releases them at the function exit.

Stack traces

Each root frame names its generated function with a private abi::v1::FrameDescriptor (module descriptor, module and function name atom slots, arity). When an Erlang exception is recorded, the channel copies the innermost 8 named frames (BEAM's default backtrace_depth); the term [{Module, Function, Arity, []}, ...] is built only when a handler, catch or report asks for it. The top frame shows the error/2,3 argument list instead of the arity when that argument is a list.

erlang:raise(Class, Reason, Stack) (CLAUSE_reraise_v2) accepts the stacks BEAM accepts: a proper list of {M, F, A} (completed with a [] location) or {M, F, A, Location} with atom M, F and a list Location, cut to 8 entries; the stack is then kept as given and frames are no longer captured. An invalid class or stack records nothing and the call evaluates to badarg, as in OTP. erlang:get_stacktrace/0 is rejected with OTP 29's "removed" lint text.

Differences from OTP, all visible only in the stack term:

Frames and transfers

Generated functions run on explicit frames (execution model; services in frames.hpp). Each function has a <symbol>.frame descriptor (FrameDescriptor: module descriptor, module and function atom slots, arity, body code, slot count, term slot count; external for exported functions) and an internal <symbol>.body of type abi::v1::Code, void(void *context, TermWord x0, ...) with abi::v1::register_arguments term parameters; with a Clang-built runtime on x86-64 and AArch64 the bodies and every transfer use the preserve_none convention, as abi::v1::Code does there: bodies never return normally, so they save no callee-saved registers; registration rejects a module whose code_convention (0 none, 1 C, 2 preserve_none) is not the runtime's. An exported <symbol> keeps the TermWord(Context *, const TermWord *) signature as a host entry calling CLAUSE_invoke_v1(context, frame, arguments).

Runtime services

Generated code calls checked C++ services: CLAUSE_exact_v1 (exact equality), CLAUSE_immediate_v1 (immediate predicates/queries), CLAUSE_construct_v1, CLAUSE_inspect_v1 (for other consumers; generated code tests and reads tuple and cons cells inline), CLAUSE_integer_v1, CLAUSE_float_v1, CLAUSE_map_v1, CLAUSE_bits_v1, CLAUSE_record_v1 (native record make/get/update/match/test under a RecordCheck; outcomes bad_record, bad_field, no_match), CLAUSE_make_fun_v1 (build a fun of a FunDescriptor). Each returns success, semantic error (badarg/badarith/...) or infrastructure failure and writes output only on success. CLAUSE_display_v1 (output.hpp) prints one erlang:display/1 line and yields true; it has no semantic error. CLAUSE_halt_v1 never succeeds: it records a halt request (CallError::halted with the exit status) or badarg, so the caller unwinds. Linker spellings follow the target's Itanium or Microsoft C++ mangling.

Inline forms (codegen/lowering_inline.cpp): type tests (is_atom, is_integer, is_number, is_boolean, is_tuple, is_list, is_bitstring, is_float, is_map, is_pid, is_port, is_reference, is_function/1, native is_record/1, is_binary/1 by the bit length word) read tags and, after a boxed tag test, the header kind (abi::v1::HeaderKind); they never call a service. Comparisons of two immediates (==, /=) or two small integers (<, =<, >, >=), element/2, tuple_size/1, bit_size/1 and byte_size/1 (from the bit length word, while the size is a small integer), hd/1, tl/1, the boolean operators, band, bor, bxor, bsr, bsl (while the result stays small) and small div/rem run inline; self/0 reads FrameState::self. They run inline where their operands allow and call CLAUSE_immediate_v1 otherwise, which keeps every error and bignum case. setelement/3 of a small index within a boxed tuple copies the tuple into the heap block's free words and replaces the slot inline; otherwise, or without room, it enters the bridge builtin.

CLAUSE_builtin_frame_v1(context, builtin) (builtins.hpp) returns the FrameDescriptor of the production builtin with index builtin in abi::v1::bridge_builtins (append-only); generated code enters it like a function with the arguments in the registers, and errors and failures go to the checked channel (builtins). A FrameDescriptor with a null body is a builtin: entering it runs the builtin on the registers and returns into the caller, or suspends the process at the builtin's continuation when it trapped (portions).

A direct builtin (BuiltinCall::direct: pure conversions such as integer_to_list/1, atom_to_list/1, list_to_tuple/1, setelement/3, phash2/1) never traps, waits or acts on another process. Generated code calls it with CLAUSE_builtin_call_v1(context, builtin, arguments), which runs its body at once and returns the result, with no frame transfer, no resume point and no entry safepoint; it spends one reduction, and a failure goes to the checked channel as a service's does. A quick builtin (BuiltinCall::quick: length/1, binary_to_list/1, list_to_binary/1, iolist_to_binary/1) is called the same way first; its bounded fast path returns 0 when it declines (longer inputs, other shapes, every error, and always while calls are traced), and the generated code then enters the builtin's frame as for any bridge builtin.

abi::v1::dispatch_builtin calls host-registered builtins by module/function bytes, argument array and arity, returning a Status; output is written only on success.

StatusValueMeaning
ok0Success
not_implemented1Known deferred feature, reported once
invalid_argument2Invalid input
diagnostic_failure3Report delivery failed
out_of_memory4Allocation failed, state rolled back
busy5Live contexts or running dispatch
wrong_owner6Object belongs to another runtime/context
resource_limit7Cap or budget exhausted
stopped8Owner shut down
abi_mismatch9Version or term width differs
internal_error10Unexpected failure contained
unknown_builtin11Signature not registered or catalogued
erlang_error12Structured Erlang error recorded
output_failure13Standard output rejected a write

Revisions

RevisionChange
1Initial descriptors and entries
2Checked failure channel
3Atom spellings and slots in descriptors
4Mandatory generated root scopes
5Explicit process frames and transfers
6Native record descriptors in module descriptors, CLAUSE_record_v1
7Fun descriptors in module descriptors, CLAUSE_make_fun_v1, CLAUSE_apply_v1
8Export descriptors name their FrameDescriptor; dynamic call services CLAUSE_call_v1, CLAUSE_apply_list_v1, CLAUSE_call_list_v1, CLAUSE_make_external_fun_v1
9Every context starts with abi::v1::FrameState; inline transfers
10Leading X registers passed as code parameters
11Fixed words of false and true
12Inline heap fields heap_top/heap_limit in FrameState
13Fixed undefined, ok, error; ModuleDescriptor::atom_cache and FrameState::runtime
14Module literal areas: ModuleDescriptor::literals, literal_words
15FrameState::self, the context's pid word
16Code uses preserve_none on x86-64 and AArch64 with a Clang-built runtime; ModuleDescriptor::code_convention
17Six X registers as Code parameters
18sub_binary cells (HeaderKind::sub_binary, sub_*_word) and inline Rest extraction
19Append end and limit words in refc_binary cells (refc_append_*_word, 8 payload words) and inline appends
20MapOperation::entries and inline map generators
21ModuleDescriptor::fun_cache and inline closures
22Direct builtins: BuiltinName::direct, CLAUSE_builtin_call_v1
23Quick builtins: BuiltinName::call (BuiltinCall), 0 from CLAUSE_builtin_call_v1 declines
24RecordDescriptor::definition (the field list as External Term Format bytes, for records:get_definition/2) and ModuleDescriptor::features (atom slots of the enabled features, for erl_features:used/1)
25Encoded module literals and the literal heap: ModuleDescriptor::literals holds encoded bytes (literal_bytes), literal_cache, literal_count; CLAUSE_literal_v1