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:

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:

StateMeaning
wvthe 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
cmpwhether a comparison result is valid here
dead, unkreachability, 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:

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.

Runtime, src/rt/cryn_rt.c

A few hundred lines, no hidden magic:

PieceNotes
contextone _Thread_local CrCtx per thread: value stack, focus history (1024), input and output streams, file table (256), spawn list (256)
value stackgrowable array of Val
heapmalloc 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
atomicsrelaxed load/store helpers per width, acquire release exchange helpers per width
conversionssaturating float to integer for every width
outputshortest round trip float printing by retrying %g precisions
fileshandles 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
threadsspawn 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:

  1. Lexer: add the keyword and token kind, and to tok_kind_name.
  2. AST: add a node kind and any operand fields.
  3. Parser: add the case, prefer reusing existing argument words over new ones.
  4. IR: add an opcode, keep it typed and simple; update irop_name.
  5. Checker: define the abstract state transition exactly (wv, focus, stack, cmp) and emit IR; think about label merges, this is where bugs hide.
  6. Backend: emit C, update the tracked cur type if wv's type changes.
  7. Runtime: add helpers in src/rt/ only if inline C cannot do it.
  8. Tests: a passing program with expected output, at least one program in tests/errors/, and expected diagnostics.
  9. 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.