Architecture
How the bootstrap compiler (version 0.3.0) turns Cryn source into a native executable, and how the pieces fit together. Read Learning Cryn first for the machine model; this page is about the implementation.
The pipeline
.cryn source
| lex.c tokens
| parse.c AST
| check.c semantic analysis: typed IR
| opt.c IR cleanup
| +-- be_c.c one self contained C11 file -> host cc -> executable
+ \-- be_x64.c x86-64 machine code -> ELF written directly -> executable
Each stage is a plain C file with no framework, passing plain data structures to the next. The semantic layer knows nothing about code generation: the C backend and the native backend consume the same IR.
Stage by stage
Lexer, lex.c
One keyword table maps words to token kinds. Hyphenated keywords (less-or-equal, greater-or-equal) are recognized by lookahead after the first word. Comments [# ... #] are skipped as they are lexed. Strings and character literals share one escape table. Newlines are just whitespace; line and column are tracked for diagnostics. The output is a flat token array ending with an EOF token.
Parser, parse.c
Recursive descent with no backtracking. Two places deserve attention:
- transformation parameters are
type namepairs until the first token that cannot start a type.addressonly opens a parameter when a type word follows it, soaddress mycellas the first command of a body is parsed as a command, not a parameter. makeis disambiguated by its first token:string,array, a type word, or a size (name or integer literal).
The output is a ProgramAst: world cells plus transformations, each body a flat array of command nodes.
Checker, check.c
This is the semantic heart. It is an abstract interpreter of the Cryn machine. For each transformation it tracks:
| State | Meaning |
|---|---|
wv | the static type of the working value; integer literals keep their exact value |
foc[] | the abstract focus stack; each entry is the frame root or a cell type |
stk[] | the shape of the value stack as a list of types |
cmp | whether a comparison result is valid here |
dead, unk | reachability, and reachability blocked on unknown label state |
Every jump merges the current state into the target label's stored state. Merges must agree exactly: same focus depth with equal cell types, same stack depth with joinable types, and a comparison state that is only valid if valid on every incoming path. Integer literals join by widening to the type they fit; two literals join to int64; joining a literal with a float is rejected because the two paths would carry different bit representations.
Because a label can be targeted from above while fallthrough is dead, the walk is repeated in settling rounds until no label gains or strengthens state, then one final emitting walk produces IR and reports unreachable code. A call to a transformation whose result is not yet inferred blocks the walk; an outer fixpoint re-runs transformations until all results settle, which is why recursion needs an explicit result <type>.
One representation subtlety the checker owes the backend: an integer literal working value holds integer bits in the 64 bit union slot, a float holds double bits. Whenever a literal pairs with a float operand, the checker emits an explicit conversion first (function coerce_intlit_float), so the backend never guesses.
IR, ir.h
50 typed, flat instructions per transformation (51 with seek). Groups: navigation (10), cells and memory (14), working value and stack (12), comparison and conversion (2), control flow (6), external world (11: input, output, output text, open, close, use, flush, seek, system, spawn, wait), atomics (1: exchange), plus labels. Operands are small integers (local or global index, label index, mode), an immediate, and interned type pointers. Because types are interned, pointer equality is type equality. The checker also folds a literal index it can prove in range into a check free literal move, so bounds checks that are statically provable never reach any backend.
Optimizer, opt.c
Deliberately small: drop code made unreachable by unconditional jumps, returns, and stops up to the next referenced label, then drop labels nothing targets. Heavy optimization is left to the host C compiler, which sees readable generated code.
C backend, be_c.c
One transformation becomes one static C function taking and returning a Val, a union of uint64_t, int64_t, double, and pointer. Frame cells become C locals, world cells become file scope statics. The focus is one local pointer F; the focus history lives in the context. Each IR instruction maps to one or two lines of C:
- labels become
L<fn>_<label>: ;, jumps becomegoto - every scalar load and store goes through relaxed atomic helpers (
crn_ld_u64and friends), which is how the language can define data races instead of suffering C undefined behavior exchangegoes through acquire release helpers (crn_xch_u64and friends), float types via bit copying- arithmetic is done at the resolved type with explicit casts, so overflow wraps exactly as documented
- the backend re-derives the working value's static type as it walks (
cur) to know representation details, for example whether to print a value as signed or unsigned
The runtime sources are embedded as strings (src/rt_src.h, generated by tools/embed) and emitted before the program, so the output is a single self contained .c file. main initializes the context and the world cells, runs cr_fn_main, and flushes.
--release drops the runtime bounds checks from the generated C (division diagnostics, allocation caps, and stack machinery checks remain); statically provable checks are already gone in every mode.
Native backend, be_x64.c
The same IR lowered straight to x86-64 machine code; the compiler writes an ELF executable itself. No host C compiler, linker, or CRT objects take part at program build time.
- Calling convention: arguments and the result travel in general purpose registers; each function keeps its machine state in callee saved registers:
r12the comparison answer,r13the focus,r14the per thread context,r15the working value,rbxscratch. The checker guarantees the value stack is empty and the focus history is cut back at every call boundary, so one context per thread (reached throughr14) is exact. - The runtime is emitted as machine code too: value stack with growth, focus history, heap blocks with headers, file handles, streams with
seek, threads, atomic exchange, saturating conversions, and shortest round trip float printing. Float formatting calls the samesnprintf/strtodpair as the C backend, so outputs are byte identical. - The ELF writer builds the program headers, dynamic symbol table, SysV hash, and
R_X86_64_GLOB_DATrelocations by hand. The program links dynamically against the host C library purely as the system call interface (stdio, malloc, pthreads, fmod). The dynamic linker path and libc SONAME are detected whencrynis built (override withCRYN_INTERP); musl and glibc_startconventions are both supported. spawn/waitsynchronize with alock incqcompletion counter plus joins, sowaitis an ordering guarantee exactly as the language promises.- Scope: Linux, System V ABI, x86-64. Everything else stays on the C backend.
Runtime, src/rt/cryn_rt.c
A few hundred lines, no hidden magic:
| Piece | Notes |
|---|---|
| context | one _Thread_local CrCtx per thread: value stack, focus history (1024), input and output streams, file table (256), spawn list (256) |
| value stack | growable array of Val |
| heap | malloc plus a 16 byte header (marker, byte size) and a registry of live block addresses; the registry, not the header, validates free and bounds checks, so stale addresses (double free, indexing a freed or shifted pointer) abort with a diagnostic instead of reading freed memory |
| atomics | relaxed load/store helpers per width, acquire release exchange helpers per width |
| conversions | saturating float to integer for every width |
| output | shortest round trip float printing by retrying %g precisions |
| files | handles are slot numbers 1 to 256; use routes the readable side to input and the writable side to output, leaving unsupported sides alone; seek positions handle streams at a byte offset |
| threads | spawn wraps a zero parameter reference in a pthread; the thread gets a fresh context; wait joins all |
Threading and memory model
Scalar loads and stores are lowered as relaxed atomics, so a race on an aligned cell of 8 bytes or less yields an unspecified value of the cell's type and never tears. Transform reference cells are 16 bytes and remain undefined under races. The only ordering points are wait (pthread join) and exchange (acquire release RMW), which is enough to build locks.
Building and the CLI
make compiles the compiler with any ISO C11 compiler. tools/embed turns the runtime sources into src/rt_src.h; the Makefile regenerates it whenever the runtime changes. The C backend shells out to the host C compiler ($CC, default cc) with -O2 -std=c11 -pthread. Commands: build, run, check, emit-c, emit-ir, help, version. build and run accept --release (no runtime bounds checks) and --native (x86-64 ELF written directly; no host C compiler involved). make test, make test-release, and make test-native run the whole suite through each path.
Both host compiler families are exercised in development and behave identically: GCC and Clang each build the compiler itself warning-free, compile the generated code through the backend, and pass the full test suite, the feature audit, and all tutorial examples with the same outputs (verified with GCC 14.2 and Clang 19.1 on x86-64 Linux, single threaded and with spawned contexts, including address and undefined behavior sanitizer runs of the memory, thread, and atomic tests).
Adding a word to the language
The repository rules (see the README) apply: at most 40 commands, prove the word cannot be expressed with existing ones, keep the compiler portable ISO C11, and isolate platform specifics in src/rt/.
The checklist for a new command:
- Lexer: add the keyword and token kind, and to
tok_kind_name. - AST: add a node kind and any operand fields.
- Parser: add the case, prefer reusing existing argument words over new ones.
- IR: add an opcode, keep it typed and simple; update
irop_name. - Checker: define the abstract state transition exactly (wv, focus, stack, cmp) and emit IR; think about label merges, this is where bugs hide.
- Backend: emit C, update the tracked
curtype if wv's type changes. - Runtime: add helpers in
src/rt/only if inline C cannot do it. - Tests: a passing program with expected output, at least one program in
tests/errors/, and expected diagnostics. - Docs: the Language Reference table row, the Learning Cryn page if beginners meet the new word, and the README if the runtime surface changed.
use, flush, and exchange (added in 0.2.0) and seek (added in 0.3.0) followed exactly this path and are small reference examples: one opcode each, one runtime helper each for use, flush, seek, and the exchange family. seek also shows the second backend rule: a new external world word needs a helper in the C runtime and a machine code helper in be_x64.c, with the same messages and semantics.