Skip to content

Repository files navigation

ccforth

ccforth is a Forth to C compiler written in Go. It interprets compile-time Forth (immediate words, meta-programming) and emits flattened C11 code that is compiled with gcc or clang to produce standalone executables.

ccforth was written to explore the idea of having an interpreter and a compiler running simultaneously to enable meta-programming heavy Forth programs to be compiled into C. This is harder than it sounds because Forth cannot be ahead-of-time compiled by parsing alone; programs routinely execute code at compile time. The project was also an experiment in how far an AI agent could progress a compiler project - most of the initial implementation was done by Claude Opus 4.6. Go was used as the implementation language as it is low enough level to express Forth properly but easier to write than C. C was used as a compiler backend as C compilers optimize well and are available everywhere.

Features

  • Fast compiled output — generates optimised C11, compiled with gcc/clang at -O3. Typically 5–373x faster than Gforth on benchmarks.
  • Single binary, zero dependencies — download one executable and go. No runtime library, no install step, no Forth image files.
  • Self-contained executables — compiled programs are standalone native binaries with no runtime dependencies (the C runtime is embedded).
  • Interactive REPLccforth -i gives you a full interpreter with tab completion and line editing for prototyping and debugging.
  • Forth 2012 standard — passes the official Forth 2012 test suite with 0 errors across all implemented word sets.
  • Gforth compatible — implements { } locals, SLURP-FILE, S+, SYSTEM, ARGC/ARG, and other Gforth extensions so existing code ports easily.
  • Inline C escape hatchCODE / ;CODE lets you drop into raw C for performance-critical words or direct library calls.
  • Cross-platform — runs on Linux, macOS, and Windows.
  • 32-bit and 64-bit — 64-bit cells by default; the -32 flag generates 32-bit cell code for cross-compiling to ARM32, embedded targets, or 32-bit x86 (via -Cflags "-m32").
  • Readable generated Cccforth -c emits human-readable C with #line directives mapping back to your .fth source, so you can debug with gdb.
  • Floating-point — full Forth 2012 FLOATING and FLOATING EXT word sets including transcendentals.
  • Exception handlingCATCH / THROW and the TRY / ENDTRY extension, compiled via setjmp / longjmp.
  • Locals{: ... :} and Gforth { ... } syntax with typed specifiers, compiled to C local variables.

How it works

  1. .fth source is read by the Scanner (lexer), which tokenises it.
  2. The Interpreter executes immediate words and metaprogramming, building a dictionary of word bodies.
  3. The C emitter walks the finished dictionary and generates flattened C from the word bodies.
  4. The result is a self-contained output.c with the runtime inlined.
  5. gcc / clang compiles that C11 source into a standalone executable.

Forth source is processed by the interpreter, which executes immediate words (like IF, DO, :, ;) and allows the normal metaprogramming tricks Forth programs do. These build up an intermediate representation — a list of body items containing word references, literals, and branch instructions — stored in the dictionary. The C emitter then walks each colon definition's body and produces inlined C code using goto-based control flow. A small C runtime is embedded into the ccforth binary and inlined at the top of every generated C file, providing the data stack, return stack, floating-point stack, and file table. This means ccforth -c output is fully self-contained — you can compile it with just cc -std=c11 -O3 -march=native -flto -fwrapv -fno-strict-aliasing -lm output.c (-fwrapv and -fno-strict-aliasing are required for correctness).

Why a full interpreter?

A Forth compiler cannot just parse — it must execute. Forth programs routinely run code at compile time: immediate words like IF and DO build control-flow structures by manipulating the compiler state, CREATE/DOES> defines new defining words, [CHAR] and ['] evaluate at compile time and embed the result as a literal, and [IF]/[THEN] conditionally includes or excludes source code. Programs use these facilities to build lookup tables, define new control structures (e.g. CASE/ENDCASE), compute constants, and generate families of words from templates — all before the program "runs". A static compiler that only translates syntax would miss all of this. ccforth solves this by running a full Forth interpreter first: every line of source is executed, immediate words fire, the dictionary and memory fill up with the results, and only then does the C emitter walk the finished dictionary to produce output. The interpreter is the compiler's front end.

Key design choices

  • 64-bit cells (int64_t) by default — a cell must be big enough to hold a pointer and most platforms are 64-bit. A -32 flag generates 32-bit cell code for embedded and cross-compilation targets.
  • Parameterised C output — words with statically known stack effects are emitted as proper C functions with parameters and return values (e.g. cell_t word_FACT(cell_t n)), so the C compiler can register-allocate stack values. Words that cannot be analysed fall back to void(void) functions with global stack operations. Both forms are static inline, letting the C compiler optimise across word boundaries.
  • goto-based control flow — direct translation of Forth branch instructions. No structured if/while reconstruction is performed.
  • Real-pointer memory model — a single unsigned char[] array holds the Forth memory image (variables, strings, allotted data), initialised from a snapshot taken after interpretation. All addresses are real C pointers, so @ and ! work uniformly on both dictionary resident data and malloc'd buffers from ALLOCATE.
  • Peephole optimiser — operates on the body before C emission. Folds constants, eliminates identity operations and redundant stack shuffles. See the Performance section for details.
  • #line directives — generated C includes #line directives that map back to the original .fth source files with absolute paths. This means gdb can be used to debug crashes, though you'll want to pass -Cflags -g. Use -no-line-directives to disable this (useful when debugging the compiler itself).
  • Bootstrap from Forthcore.fth defines many standard words in Forth itself, as is traditional, and provides CODE words for I/O, file access, and diagnostics, keeping both the Go interpreter and the C runtime minimal. It is embedded in the binary for distribution.

Supported Forth words

For detailed per-word documentation including stack effects, see WORDS.md.

Stack: DUP DROP SWAP OVER ROT -ROT NIP TUCK ?DUP PICK DEPTH 2DUP 2DROP 2SWAP 2OVER

Arithmetic: + - * / MOD /MOD */ */MOD NEGATE ABS MIN MAX 1+ 1- 2* 2/

Double-cell arithmetic: D+ D- DNEGATE DABS D2* D2/ D< D0< D0= D= D>S DMAX DMIN M+ M* M*/ S>D UM* UM/MOD SM/REM FM/MOD

Bitwise: AND OR XOR INVERT LSHIFT RSHIFT

Comparison: = <> < > <= >= 0= 0< 0> 0<> U< U> TRUE FALSE

Memory: @ ! C@ C! +! 2@ 2! CELL+ CELLS CHAR+ CHARS ALIGN ALIGNED ALLOT FILL ERASE MOVE CMOVE CMOVE> PAD UNUSED

I/O: . U. U.R D. D.R .R CR EMIT SPACE SPACES TYPE ." .( KEY ACCEPT SOURCE >IN WORD PARSE PARSE-NAME REFILL

Pictured numeric output: <# # #S #> HOLD SIGN BASE DECIMAL

Return stack: >R R> R@ 2>R 2R> 2R@ N>R NR>

Control flow: IF ELSE THEN DO ?DO LOOP +LOOP I J LEAVE UNLOOP BEGIN AGAIN UNTIL WHILE REPEAT RECURSE EXIT CASE OF ENDOF ENDCASE AHEAD ?DUP-IF ?DUP-0=-IF

Strings: S" S\" C" COUNT CHAR [CHAR] COMPARE SEARCH SLITERAL /STRING -TRAILING BLANK S+

Defining words: : ; :NONAME CONSTANT VARIABLE VALUE FVALUE 2CONSTANT 2VARIABLE 2VALUE CREATE ALLOT , C, DOES> IMMEDIATE DEFER DEFER! DEFER@ IS ACTION-OF BUFFER: MARKER [: ;]

Compilation: STATE [ ] LITERAL 2LITERAL POSTPONE ]] [[ ' ['] EXECUTE COMPILE, HERE FIND >BODY >NUMBER TO SAVE-INPUT RESTORE-INPUT

Exception handling: CATCH THROW ABORT ABORT" TRY IFERROR RESTORE ENDTRY

File access: OPEN-FILE CREATE-FILE CLOSE-FILE READ-FILE WRITE-FILE DELETE-FILE FILE-POSITION FILE-SIZE REPOSITION-FILE RESIZE-FILE READ-LINE WRITE-LINE SOURCE-ID INCLUDE-FILE INCLUDED INCLUDE REQUIRED REQUIRE RENAME-FILE FILE-STATUS FLUSH-FILE R/O W/O R/W BIN SLURP-FID SLURP-FILE

Floating-point: F+ F- F* F/ FNEGATE FABS FMAX FMIN FLOOR FROUND FSQRT F0< F0= F< FDUP FDROP FSWAP FOVER FROT F@ F! D>F F>D FDEPTH >FLOAT REPRESENT FLITERAL F. FLOAT+ FLOATS FALIGN FALIGNED FVARIABLE FCONSTANT FSIN FCOS FTAN FASIN FACOS FATAN FATAN2 FSINCOS FSINH FCOSH FTANH FASINH FACOSH FATANH FEXP FEXPM1 FLN FLNP1 FLOG FALOG F** F>S S>F FTRUNC F= F~ SF! SF@ DF! DF@ PRECISION SET-PRECISION FS. FE. FVALUE

Facility: BEGIN-STRUCTURE END-STRUCTURE +FIELD FIELD: CFIELD: AT-XY PAGE KEY? EMIT? MS TIME&DATE UTIME EKEY EKEY? EKEY>CHAR EKEY>FKEY

Extended-Character (UTF-8): XC-SIZE X-SIZE XC@+ XC!+ XCHAR+ XCHAR- XEMIT XKEY XKEY? +X/STRING XC-WIDTH X-WIDTH EKEY>XCHAR -TRAILING-GARBAGE XHOLD X\STRING-

Search order: FORTH-WORDLIST GET-ORDER SET-ORDER GET-CURRENT SET-CURRENT WORDLIST SEARCH-WORDLIST DEFINITIONS FORTH ALSO ONLY PREVIOUS ORDER VOCABULARY

Locals: (LOCAL) {: ... :} { ... } TO

Memory allocation: ALLOCATE FREE RESIZE

Programming tools: .S ? DUMP WORDS [DEFINED] [UNDEFINED] [IF] [ELSE] [THEN] CODE ;CODE INTERPRETER AHEAD CS-PICK CS-ROLL N>R NR> SYNONYM TRAVERSE-WORDLIST NAME>STRING NAME>INTERPRET NAME>COMPILE

String extensions: SUBSTITUTE REPLACES UNESCAPE PLACE

System: ATEXIT BYE EVALUATE QUIT ENVIRONMENT? SYSTEM ARGC ARG NEXT-ARG SHIFT-ARGS GETENV SETENV

C interface: C-FUNCTION C-VALUE C-VARIABLE \C

Inline C with CODE

CODE and ;CODE let you define words with raw C code that is emitted verbatim into the compiled output. This is useful for performance-critical operations or calling C library functions directly.

Typed CODE words

CODE words can declare their stack signature on the first line using Gforth-compatible type codes. Parameters are named p0, p1, p2, ... (deepest first) and outputs use return:

CODE FAST-ADD  n n -- n
  return p0 + p1;
;CODE

CODE FILL  a n n --
  memset(p0, (int)p2, (size_t)p1);
;CODE

Type codes:

Code C type Stack Description
n cell_t data Integer cell
a void * data Address (auto-cast from cell_t)
u ucell_t data Unsigned cell
r double float Floating-point value

Typed CODE words generate a parameterised __p function that the optimiser calls directly — no stack spill/reload needed. A void(void) wrapper is also generated for backward compatibility (and tree-shaken if unused).

Multi-return CODE words

CODE words that return multiple values use pre-declared output variables r0, r1, r2, ... The emitter declares them with the correct C type from the signature and appends a return epilogue that constructs the struct. Do not use return or PUSH() — just assign the output variables:

CODE UM/MOD  n n n -- n n
  unsigned __int128 _n = ((unsigned __int128)(ucell_t)p1 << 64) | (ucell_t)p0;
  r0 = (cell_t)(ucell_t)(_n % (ucell_t)p2);
  r1 = (cell_t)(ucell_t)(_n / (ucell_t)p2);
;CODE

CODE FSINCOS  r -- r r
  r0 = sin(p0); r1 = cos(p0);
;CODE

CODE SDL-POLL-EVENT  -- n n
  SDL_Event _e;
  if (SDL_PollEvent(&_e)) {
    r0 = _e.button.button; r1 = _e.type;
  } else { r0 = 0; r1 = 0; }
;CODE

Multi-return typed CODE words generate __p functions with struct returns, so parameterised callers can call them directly without spill/reload.

Untyped CODE words

Without a type signature, CODE words use manual POP()/PUSH() macros as before:

CODE FAST-SQUARE
  { cell_t n = POP(); PUSH(n * n); }
;CODE

You can use the runtime macros (PUSH, POP, TOS, FPUSH, FPOP, etc.). Addresses on the stack are real C pointers — use the CADDR(a) macro (expands to (unsigned char*)(uintptr_t)(a)) to cast them for byte access.

Calling C library functions

C-FUNCTION declares an external C function with typed parameters, following Gforth's calling convention:

\C #include <math.h>
C-FUNCTION c-sqrt sqrt r -- r

: MAIN  2.0e0 c-sqrt F. CR ;   \ prints 1.41421

\C embeds a line of C code verbatim into the generated output (hoisted to the top alongside #include lines).

C-FUNCTION creates a typed CODE word that calls the named C function directly — no manual POP()/PUSH() needed.

C constants and variables

C-VALUE exposes a C constant, macro, or expression as a Forth word:

\C #include <SDL2/SDL.h>
C-VALUE SDL_INIT_VIDEO SDL_INIT_VIDEO -- n

C-VARIABLE exposes a C variable's address, like a Forth VARIABLE:

\C static int counter = 0;
C-VARIABLE my-counter counter
: MAIN  my-counter @  1+  my-counter ! ;

Both follow Gforth's C interface conventions.

#include hoisting

If the C code contains #include directives, they are hoisted to the top of the generated file and de-duplicated:

CODE C-SIN  r -- r
  #include <math.h>
  return sin(p0);
;CODE

Dual-mode definitions

If a Go primitive with the same name already exists, CODE adds the C body to it — the word works in both modes (Go primitive in the interpreter, inline C in the compiler). If a Forth colon definition exists, CODE creates a new entry that uses the C body for compiled mode and falls back to the Forth definition for interpretation. If neither exists, the CODE word only runs in compiled mode; calling it in the interpreter raises an error.

Use INTERPRETER with [IF]/[ELSE]/[THEN] to provide portable definitions that work in both modes when there is no Go primitive:

INTERPRETER [IF]
  : FAST-SQUARE  DUP * ;
[ELSE]
  CODE FAST-SQUARE  n -- n
    return p0 * p0;
  ;CODE
[THEN]

Words that need both an interpreter implementation and an optimised C implementation follow one of two patterns in core.fth:

  • Shadowed definitions: The Forth colon definition is placed first, then a CODE block inside INTERPRETER [IF] [ELSE] ... [THEN] shadows it. The CODE word detects the existing Forth definition and preserves it as an interpreter fallback while adding the C implementation for compiled output. Used by double-cell arithmetic, memory alignment, and similar words.
  • Guarded definitions: Words that only make sense in one mode use INTERPRETER [IF] ... [ELSE] ... [THEN] so each mode gets exactly one definition. Used by I/O, file-access, and floating-point I/O words where the interpreter has a Go primitive.

Reading the generated C

The generated C is designed to be readable. Use ccforth -c to emit it (add -no-line-directives to omit #line markers for easier reading).

Word names

Each Forth word becomes one or two static inline C functions. Words with statically analysable stack effects get a parameterised version (suffix __p) that takes stack inputs as parameters and returns outputs via cell_t or a struct, plus a void(void) wrapper for backward compatibility. Words that cannot be analysed get only the void(void) form. The name is formed by prefixing word_ and replacing special characters:

Character Replacement
- _sub_
+ _add_
* _mul_
/ _div_
! _store_
@ _fetch_
< _lt_
> _gt_
= _eq_
? _q_
. _dot_
" _quote_

Alphanumerics pass through unchanged. Any other character becomes _<codepoint>_. For example, FM/MODword_FM_div_MOD, 2>Rword_2_gt_R.

If a word name is unique in the dictionary, the function name is just the mangled name (e.g. word_MAIN). If the same name has been redefined (e.g. a Forth colon definition shadowed by a CODE word), the execution token is appended as a suffix to disambiguate (e.g. word_CR_348).

Local variable names

Local variables declared with {: ... :} or { ... } or (LOCAL) are emitted as C locals using their Forth names, prefixed by _local_ (or _flocal_ for float locals):

: DISTANCE {: F: x F: y -- F: r :}  x x F* y y F* F+ FSQRT ;

generates:

static inline void word_DISTANCE(void) {
    double _flocal_y;
    double _flocal_x;
    _flocal_y = FPOP();
    _flocal_x = FPOP();
    FPUSH(_flocal_x);
    FPUSH(_flocal_x);
    /* ... */
}

Buffer locals (W^, D^, F^, C^) get a _buf suffix on the backing array (e.g. _local_buf with _local_buf_buf[1] as storage). Double-cell locals occupy two slots; only the first slot carries the Forth name, the second uses a numeric fallback. If two locals would mangle to the same C name, a numeric suffix is appended (e.g. _local_x_1, _local_x_2).

Forth 2012 standard compliance

For a complete per-word reference organised by standard word set, see WORDS.md.

ccforth targets the Forth 2012 standard. The table below summarises which word sets are implemented. "Both" means words work in the interpreter and compile to C; "interpreter-only" means they work in the interpreter but have no C emission.

Word set Status Mode Notes
CORE Complete Both All 134 words
CORE EXT Complete Both All standard words
BLOCK Not implemented
DOUBLE Complete Both All 20 words
DOUBLE EXT Complete Both 2ROT 2VALUE DU<
EXCEPTION Complete Both CATCH/THROW via setjmp/longjmp
EXCEPTION EXT Partial Both TRY IFERROR RESTORE ENDTRY
FACILITY Complete Both All 3 words (AT-XY KEY? PAGE)
FACILITY EXT Partial Both Structure words, EKEY family, EMIT?, MS, TIME&DATE, UTIME; K-* constants not implemented
FILE Complete Both All 21 words; CODE words in core.fth use FILE* table
FILE EXT Partial Both FILE-STATUS FLUSH-FILE INCLUDE REFILL RENAME-FILE REQUIRED REQUIRE
FLOATING Complete Both 34 words
FLOATING EXT Complete Both Transcendentals, exp/log, F**, F~, SF!/SF@, DF!/DF@, PRECISION, SET-PRECISION, FS., FE., FVALUE
LOCALS Complete Both (LOCAL) and {: ... :} syntax
LOCALS EXT Partial Both Typed specifiers (W: D: F: C: W^ D^ F^ C^); no LOCALS|
MEMORY Complete Both Real malloc/free/realloc in compiled code; heap allocator in interpreter
SEARCH Complete Interpreter-only Dictionary restructured with wordlists
SEARCH EXT Partial Interpreter-only ALSO ONLY PREVIOUS ORDER
STRING Complete Both All 8 words
STRING EXT Partial Both SUBSTITUTE REPLACES UNESCAPE
XCHAR Complete Both UTF-8 encode/decode, XEMIT, XKEY
XCHAR EXT Complete Both +X/STRING, X-WIDTH, XHOLD, X\STRING-, -TRAILING-GARBAGE
TOOLS Partial Both .S ? DUMP WORDS
TOOLS EXT Partial Both [DEFINED] [UNDEFINED] [IF] [ELSE] [THEN] CODE ;CODE INTERPRETER AHEAD CS-PICK CS-ROLL N>R NR> SYNONYM TRAVERSE-WORDLIST NAME>STRING NAME>INTERPRET NAME>COMPILE

CORE word set (complete)

All 134 CORE words are implemented:

! # #> #S ' ( * */ */MOD + +! +LOOP , - . ." / /MOD 0< 0= 1+ 1- 2! 2* 2/ 2@ 2DROP 2DUP 2OVER 2SWAP : ; < <# = > >BODY >IN >NUMBER >R ?DUP @ ABORT ABORT" ABS ACCEPT ALIGN ALIGNED ALLOT AND BASE BEGIN BL C! C, C@ CELL+ CELLS CHAR CHAR+ CHARS CONSTANT COUNT CR CREATE DECIMAL DEPTH DO DOES> DROP DUP ELSE EMIT ENVIRONMENT? EVALUATE EXECUTE EXIT FILL FIND FM/MOD HERE HOLD I IF IMMEDIATE INVERT J KEY LEAVE LITERAL LOOP LSHIFT M* MAX MIN MOD MOVE NEGATE OR OVER POSTPONE QUIT R> R@ RECURSE REPEAT ROT RSHIFT S" S>D SIGN SM/REM SOURCE SPACE SPACES STATE SWAP THEN TYPE U. U< UM* UM/MOD UNLOOP UNTIL VARIABLE WHILE WITHIN WORD XOR [ ['] [CHAR] ]

CORE EXT words implemented

.( .R 0<> 0> <> ?DO :NONAME 2>R 2R> 2R@ AGAIN BUFFER: BYE C" CASE COMPILE, DEFER DEFER! DEFER@ ENDCASE ENDOF ERASE FALSE HEX HOLDS IS ACTION-OF MARKER NIP OF PAD PARSE PARSE-NAME PICK REFILL RESTORE-INPUT ROLL S\" SAVE-INPUT TO TRUE TUCK U.R U> UNUSED VALUE -ROT

Known deviations from the standard

Interpreter-only word sets. The following word sets work in the interpreter but cannot be compiled to C:

  • Search order — dictionary manipulation is inherently an interpreter-time operation.
  • INCLUDE-FILE / INCLUDED — loading Forth source at runtime is an interpreter operation.

CODE / ;CODE are repurposed for inline C rather than assembly language. When a CODE word is defined for a name that already has a Go primitive, the primitive is augmented with the C body — the word works in both modes. When a Forth colon definition exists, CODE shadows it for compilation while preserving the Forth definition for interpreter use. Otherwise, CODE words only execute in compiled mode. Use INTERPRETER [IF] to provide a Forth fallback for interpreter mode (see "Inline C with CODE" above).

INTERPRETER is a non-standard extension that pushes TRUE in interpreter mode and FALSE in compiler mode, enabling conditional definitions with [IF]/[ELSE]/[THEN].

Memory allocation uses malloc/free/realloc in compiled code, so ALLOCATE can allocate beyond the Forth memory size and FREE genuinely releases memory. In the interpreter, a heap allocator grows downward from the top of memory — separate from the dictionary space (HERE) — so ALLOCATE does not affect the data-space pointer. Compile-time allocations (e.g., 1024 ALLOCATE THROW CONSTANT BUF) are relocated to malloc'd pointers at program startup via an init function in the generated C.

Floating-point literals require an E or e exponent marker per the standard (e.g., 1E, 3.14E0, .5E-3). Plain decimal numbers like 3.14 are not recognised as floats.

Locals support the {: ... :} syntax with initialised and uninitialised locals. Multiple {: or { } blocks in a single word extend the same local frame (Gforth-compatible). LOCALS| is not implemented.

ENVIRONMENT? recognises a fixed set of queries and does not report all implementation limits.

Cell size is 64 bits (int64_t) by default, which is not the most common choice for Forth systems but is valid per the standard. The -32 flag generates 32-bit (int32_t) cells for cross-compilation targets.

QUIT clears the stacks and returns to the interpreter in interpreter mode. In compiled mode it calls exit(0).

Gforth compatibility

ccforth is not a Gforth clone, but it implements many Gforth extensions that make it straightforward to port Gforth programs. This section covers what works, what doesn't, and what to watch out for.

What works

Locals. The { ... } brace syntax works identically to Gforth, including typed specifiers (W: D: F: C: for stack-initialised locals, W^ D^ F^ C^ for buffer locals) and the | separator for uninitialised locals. Multiple { } blocks in a single word extend the same local frame, matching Gforth behaviour. The standard {: ... :} syntax is also supported.

Strings and files. SLURP-FID, SLURP-FILE, PLACE, and S+ work as in Gforth.

System interface. ARGC, ARG, NEXT-ARG, SHIFT-ARGS, GETENV, SETENV, SYSTEM, and UTIME all behave as in Gforth.

Structures. Both Gforth-style (STRUCT / END-STRUCT / FIELD) and Forth 2012-style (BEGIN-STRUCTURE / END-STRUCTURE / FIELD:) structure definitions are supported.

Control flow. ?DUP-IF, ?DUP-0=-IF, and the postpone-state brackets ]] / [[ work as in Gforth.

Exception allocation. EXCEPTION ( addr u -- n ) allocates a unique negative throw code, matching Gforth.

C interface. C-FUNCTION, C-VALUE, C-VARIABLE, and \C follow Gforth's calling convention for declaring external C functions, constants, and variables. Type codes n, a, u, r match Gforth's conventions. CODE words also accept Gforth-style type signatures on the first line.

Miscellaneous. VOCABULARY, CELL, <=, >=, 0<=, 0>=, and F= are all available.

Semantic differences

Topic ccforth Gforth
Cell size 64-bit (int64_t) Typically 64-bit on modern systems, 32-bit on older ones
Division Floored (matches Gforth) Floored
Float literals Require E/e exponent (3.14E0) Also accepts 3.14 in some configurations
CODE / ;CODE Inline C Inline assembly

The 64-bit cell size matches Gforth on modern 64-bit systems but differs from 32-bit Gforth installations. This affects CELL+, CELLS, and memory layout of packed data.

What doesn't work

  • LOCALS| — not implemented. Use { ... } or {: ... :} instead.
  • Search order in compiled codeVOCABULARY, DEFINITIONS, GET-ORDER, SET-ORDER, etc. work in the interpreter but cannot be compiled to C (dictionary manipulation is inherently an interpreter-time operation).
  • Runtime file loadingINCLUDE-FILE, INCLUDED, REQUIRE, REQUIRED are interpreter-only.
  • CODE differences — Gforth CODE defines words with inline assembly; ccforth CODE defines words with inline C. The syntax is the same but the body language is different. ccforth extends CODE with optional type signatures (CODE name n n -- n) that Gforth does not have.

Porting tips

  1. Replace LOCALS| with { }LOCALS| a b c | becomes { a b c } (note: stack order is reversed in { } syntax).
  2. Add exponent markers to float literals3.143.14E0.
  3. Guard platform-specific CODE words — use INTERPRETER [IF] ... [ELSE] CODE ... ;CODE [THEN] to provide Forth fallbacks for the interpreter.
  4. Test in the REPL firstccforth -i runs in pure interpreter mode, so you can verify word-level behaviour before compiling.

Installation

Download a pre-built binary from the GitHub releases page and put it on your PATH. Binaries are available for Linux, macOS, and Windows (amd64 and arm64).

Or install from source with Go:

go install github.com/ncw/ccforth/cmd/ccforth@latest

Or build from a checkout:

go build -o ccforth ./cmd/ccforth

You also need a C compiler (gcc or clang) on your PATH for compiling Forth programs. The -c flag emits C without invoking a compiler, so a C compiler is not required for that mode or for the interactive REPL.

Usage

# Compile a Forth program to an executable
ccforth -o hello testdata/end-to-end/hello.fth
./hello

# Emit C source only (don't invoke the C compiler)
ccforth -c testdata/end-to-end/factorial.fth

# Use a specific C compiler and entry word
ccforth -cc gcc -entry MAIN -o program source.fth

# Start an interactive REPL
ccforth -i

# Load source files then enter the REPL
ccforth -i mylib.fth

Flags

Flag Default Description
-i false Interactive REPL mode
-o a.out Output executable path
-c false Emit C only, don't compile
-cc cc C compiler to use
-Cflags Extra flags for the C compiler
-entry MAIN Entry word called from main()
-memsize 1048576 Forth memory size in bytes
-v false Print emitted C to stderr
-32 false Generate 32-bit cell C code (for cross-compilation)
-no-line-directives false Omit #line directives from generated C

Interactive mode

The -i flag starts an interactive REPL. Source files given on the command line are loaded first, then you get a prompt where you can type Forth interactively:

$ ccforth -i
ccforth interactive mode
Type `BYE' to exit
1 2 + . 3  ok
: SQUARE  DUP * ; ok
5 SQUARE . 25  ok
BYE

Features:

  • Tab completion of dictionary words (case-insensitive).
  • Line editing and history (persisted to ~/.ccforth_history).
  • BYE or Ctrl-D to exit.

The REPL runs in pure interpreter mode, so INTERPRETER pushes TRUE and CODE-only words are skipped during bootstrap. All Go primitives and Forth-defined words are available.

32-bit mode

The -32 flag generates C code with 32-bit cells (int32_t) instead of the default 64-bit cells. This is useful for embedded targets, retro computing, and cross-compilation to 32-bit architectures.

When -32 is active, the Go interpreter also runs with 32-bit cell semantics, so all compile-time constants (e.g. 0 INVERT 1 RSHIFT for MAX-INT) are computed correctly for the target. You provide the appropriate cross-compiler via -cc and any necessary flags via -Cflags:

# Cross-compile for ARM32
ccforth -32 -cc arm-linux-gnueabihf-gcc -Cflags "-static" -o hello hello.fth

# Compile 32-bit x86 on a 64-bit Linux host
ccforth -32 -Cflags "-m32" -o hello hello.fth

# Emit 32-bit C only (inspect generated code)
ccforth -32 -c hello.fth

32-bit x86 on a 64-bit host

The easiest way to produce a 32-bit executable on an x86-64 machine is to pass -m32 to the C compiler via -Cflags. You need the 32-bit C library and development headers installed:

# Debian / Ubuntu — install 32-bit toolchain
sudo apt install gcc-multilib

# Fedora / RHEL
sudo dnf install glibc-devel.i686 libgcc.i686

# Arch
sudo pacman -S lib32-glibc lib32-gcc-libs

Then compile and run as normal:

ccforth -32 -Cflags "-m32" -o hello hello.fth
./hello           # runs natively on x86-64 Linux
file hello        # ELF 32-bit LSB executable, Intel 80386 ...

On macOS, -m32 is no longer supported by Apple Clang (dropped in Xcode 10). Use a cross-compiler or Docker instead.

When -32 is set, -march=native is omitted from the default C compiler flags since it is incompatible with cross-compilation.

Limitations:

  • Values exceeding 2^31 are truncated to 32 bits at interpret time, which is correct Forth behavior for a 32-bit cell system.
  • User CODE words that assume 64-bit cells will need #ifdef CCFORTH_32BIT guards for portable operation.
  • FLOAT+ and FLOATS remain 8-byte (doubles are always 64-bit), while CELL+ and CELLS become 4-byte.

Examples

The examples/ directory contains complete programs demonstrating different ccforth features:

Example Description Dependencies
Ray tracer Renders 3 spheres on a checkerboard ground plane to a PPM image. Demonstrates floating-point math, typed locals, recursive algorithms, and compile-time data with CREATE/F,. None
Mandelbrot viewer Interactive Mandelbrot set explorer with click-to-zoom. Uses SDL2 for windowed rendering. SDL2
Mandelbrot terminal Animated Mandelbrot zoom in the terminal using ANSI 24-bit color and Unicode half-block characters. None
Breakout Classic breakout game with paddle, ball, and bricks. Uses SDL2 for rendering and input. SDL2
Game Boy Game Boy DMG emulator with CPU/ALU in pure Forth. Renders in the terminal using ANSI 256-color and Unicode half-blocks. Ported from ioccc-gameboy. None
Whetstone benchmark Classic double-precision floating-point benchmark. Compares C, ccforth compiled, and Gforth interpreted performance (C 1.0x, ccforth 1.4x, Gforth 41.9x). None
Dhrystone benchmark Classic integer benchmark (v1.1). Compares C, ccforth compiled, and Gforth interpreted performance (C 1.0x, ccforth 3.2x, Gforth 217.5x). None
Towers of Hanoi Animated terminal demo with rainbow-colored disks, smooth lift/slide/drop animation, and live statistics. Configurable 1–13 disks via command line. None

Quick start

# Ray tracer (no dependencies)
ccforth -o raytrace examples/raytrace/raytrace.fth
./raytrace > scene.ppm

# Mandelbrot terminal animation (no dependencies)
ccforth -o mandelterm examples/mandelbrot/mandelterm.fth
./mandelterm

# Mandelbrot SDL viewer (requires SDL2)
ccforth -Cflags "-lSDL2" -o mandelbrot examples/mandelbrot/mandelbrot.fth
./mandelbrot

# Breakout game (requires SDL2)
ccforth -Cflags "-lSDL2" -o breakout examples/breakout/breakout.fth
./breakout

# Game Boy emulator (no dependencies)
ccforth -Cflags "-Iexamples/gameboy" -o gameboy examples/gameboy/gameboy.fth
./gameboy rom.gb

Project structure

ccforth/
+-- cmd/ccforth/          CLI entry point
+-- pkg/
|   +-- scanner/          Lexer: source -> tokens
|   +-- dictionary/       Word headers, flags, lookup
|   +-- memory/           Linear byte-addressed memory model
|   +-- interpreter/      Outer interpreter, primitives, control flow
|   +-- compiler/         C emitter, peephole optimiser
|   +-- codegen/          Orchestrates complete C output
|   +-- forth/            Bootstrap Forth definitions (core.fth)
+-- runtime/              Embedded C runtime (runtime.c.src, embed.go)
+-- testdata/
|   +-- end-to-end/       Test Forth programs with expected output
|   +-- benchmarks/       Benchmark programs
|   +-- forth2012-test-suite/  Forth 2012 conformance test suite

Testing

go test ./...

Tests run at every level:

  • Unit tests for the scanner, memory, dictionary, interpreter, emitter, and peephole optimiser.
  • End-to-end compiler tests — each .fth file in testdata/end-to-end/ is compiled to C, built with gcc, run, and its stdout compared against the matching .txt file.
  • End-to-end interpreter tests — the same .fth files are loaded into the interpreter, MAIN is called, and output is verified.
  • Forth 2012 test suite — the official Forth 2012 test suite (testdata/forth2012-test-suite/) is run through the interpreter, validating standard compliance across all implemented word sets.

To add a new test, create testdata/end-to-end/mytest.fth (with a : MAIN ... ; word) and testdata/end-to-end/mytest.txt (with the expected output). The test harness discovers them automatically.

Forth 2012 test suite

The Forth 2012 test suite is included in the testdata/forth2012-test-suite/ directory and run as an integration test (TestForth2012Suite). It validates conformance to the Forth 2012 standard across the following word sets:

Word set Errors
Core 0
Core extension 0
Double 0
Exception 0
File-access 0
Locals 0
Memory-allocation 0
Programming-tools 0
Search-order 0
String 0
Block - (not implemented)

To run just the test suite:

go test -run TestForth2012Suite -v

The test parses the suite's error report and fails if any word set reports errors. Word sets marked - are not implemented and are excluded from the pass/fail check.

Performance

ccforth does not try to be a good optimising compiler. Instead, it generates simple, transparent C that a good optimising C compiler (gcc, clang) can reason about. The philosophy is: do the minimum work in the emitter to remove obstacles that prevent the C compiler from doing its job.

GCC and clang already know how to allocate registers, schedule instructions, unroll loops, and eliminate dead code. What they struggle with is aliasing: when values live in global arrays (dstack[], rstack[], fstack[]), the compiler cannot prove that a write through one pointer doesn't clobber a read through another, so it conservatively reloads values from memory on every access. The optimisations below focus on keeping hot values out of global arrays and reducing the number of operations the C compiler has to see through.

Every generated function is declared static inline, which lets the C compiler inline across word boundaries and optimise the resulting code as a single unit. The default compiler flags are -std=c11 -O3 -march=native -flto.

Peephole optimiser

Operates on the body IR (word references, literals, branches) before C emission. Patterns are matched in two- and three-item windows:

  • Constant foldingLIT 3 LIT 4 + becomes LIT 7. Works for +, -, *, AND, OR, XOR, LSHIFT, RSHIFT.
  • Identity eliminationLIT 0 +, LIT 1 *, LIT -1 AND, etc. are removed entirely.
  • Strength reductionLIT 1 + becomes 1+, LIT 2 * becomes 2*, LIT 2 / becomes 2/, DUP + becomes 2*, SWAP DROP becomes NIP.
  • Word combiningOVER OVER becomes 2DUP, DROP DROP becomes 2DROP.
  • SWAP eliminationSWAP before a commutative operation (+, *, AND, OR, XOR, =, <>) is removed, since the operand order doesn't matter.
  • Return-stack cleanup>R immediately followed by R> is eliminated (identity round-trip).

DO/LOOP to C local variables

This is the single most impactful optimisation. Standard Forth DO loops store their index and limit on the return stack — a global array (rstack[]). Every I, LOOP, and +LOOP reads and writes rstack[rsp-1] and rstack[rsp-2], which the C compiler cannot promote to registers because it can't prove that other code (function calls, pointer writes) doesn't alias the same memory.

The emitter rewrites eligible DO/LOOP constructs to use C local variables instead. With local variables, GCC promotes the loop counter and limit to registers. The inner loop of a DO/LOOP benchmark compiles to a handful of register-only instructions with no memory traffic.

Nested loops each get their own C locals (_loop0_*, _loop1_*, etc.). I refers to the innermost loop's local, J to the next outer loop. +LOOP uses the same locals with the standard boundary-crossing check. LEAVE becomes a bare goto (no rstack cleanup needed). UNLOOP before EXIT is a no-op since there are no rstack entries to remove.

Eligibility: in the traditional emitter, a loop falls back to rstack-based emission if its body contains >R, R>, R@, or UNLOOP. In the parameterised emitter, this restriction does not apply because >R/R> are handled via the virtual return stack (see below).

Comparison + branch fusion

When a comparison word is immediately followed by a conditional branch (IF, UNTIL, WHILE), the emitter produces a single C conditional instead of two separate operations:

// Before: 0= IF
TOS = (TOS == 0) ? -1 : 0;   // comparison → flag
if (POP() == 0) goto L;       // branch on flag

// After: fused
if (POP() != 0) goto L;       // one test, no intermediate flag

This eliminates the intermediate -1/0 Forth truth flag and the second conditional test. Covers all standard comparisons: 0=, 0<>, 0<, 0>, =, <>, <, >, <=, >=, U<, U>.

Literal + operation fusion

When a literal is immediately followed by an arithmetic or bitwise operation, the emitter produces a compound assignment instead of a push-then-operate sequence:

// Before: LIT 128 +
PUSH(128);
SECOND += TOS; sp--;

// After: fused
TOS += 128;

This avoids a stack push and the associated array write that GCC cannot always optimise away. Covers +, -, *, AND, OR, XOR.

Parameterised C emission (stack-to-locals)

The single most impactful optimisation. Instead of every word being void word(void) with all data flowing through the global dstack[] array, words with statically known stack effects are emitted as proper C functions where stack inputs become parameters and outputs become return values:

// Forth:  : FACT  DUP 1 > IF DUP 1- RECURSE * ELSE DROP 1 THEN ;
// Generated C:
static inline cell_t word_FACT__p(cell_t s0) {
    if (s0 > 1) {
        cell_t t0 = s0 - 1;
        cell_t t1 = word_FACT__p(t0);
        cell_t _m0 = s0 * t1;
        return _m0;
    } else {
        return 1;
    }
}

This lets GCC keep all values in registers instead of bouncing them through dstack[]. Stack shuffles like SWAP, ROT, OVER, and DUP become zero-cost renames in a virtual stack — no C code is emitted for them at all.

The same approach is applied to all three stacks:

  • Data stack — inputs become function parameters, outputs become return values (single cell_t or a struct for multiple outputs). Stack shuffles are pure name renames.
  • Return stack>R / R> / R@ transfer variable names between a virtual data stack and a virtual return stack. In compiled code the return stack is never used for return addresses (C handles those), so >R/R> are just temporary variable storage. Zero C code is emitted for them.
  • Float stackFDUP / FSWAP / FOVER / FROT are zero-cost name renames. F+ / F* etc. become double fN = a + b; with gcc-allocated registers. Float values are flushed to the global fstack[] at control flow boundaries (branches, loops, word calls) since the float stack does not participate in the branch merge logic.

A stack effect analyser walks each word's body IR to compute its (inputs, outputs) signature, handling branches (IF/ELSE/THEN), loops (DO/LOOP, BEGIN/WHILE), locals, VALUEs, and self-recursion (RECURSE). Words that cannot be statically analysed fall back to the traditional global-stack emitter. The following constructs prevent parameterisation:

Construct Reason
PICK Dynamic stack depth (index from data stack)
ROLL Dynamic stack depth (count from data stack)
EXECUTE Unknown callee stack effect
DEPTH Reads global sp which parameterised code doesn't maintain
FDEPTH Reads global fsp which parameterised code doesn't maintain
N>R / NR> Dynamic count from data stack
CATCH Modifies control flow via setjmp/longjmp
Mismatched EXIT depth EXIT leaves a different stack depth than the normal return path
CREATE ... DOES> DOES> body has unknown stack effect
Mismatched branch depths IF/ELSE paths leave different stack depths (e.g. ?DUP)
Untyped CODE words CODE words without a type signature or stack effect table entry

Note: ?DUP-IF and ?DUP-0=-IF are parameterisable — they compile to dedicated branch instructions (BranchQDupIf / BranchQDupNot) that the analyser handles natively. Only bare ?DUP (without IF) is unconvertible due to its internal branch mismatch.

Note: EXIT inside loops is allowed — the analyser verifies that all EXIT paths leave the same stack depth as the normal return. All typed CODE words (including multi-return like -- n n) generate parameterised __p functions. Multi-return words use output variables r0, r1, ... and return a struct. 92 of 101 CODE words in core.fth have typed signatures; the remaining 9 (PICK, ROLL, EXECUTE, CATCH, N>R, NR>, .S, ENVIRONMENT?, >FLOAT) are genuinely unconvertible due to dynamic stack access or variable-length effects.

Each parameterised word also gets a void(void) wrapper that pops inputs from the global stack, calls the parameterised version, and pushes outputs back. This ensures backward compatibility: old-style callers and function pointers (EXECUTE, CATCH) work unchanged. When a parameterised word calls an unconverted word, it spills its virtual stack to dstack[], makes the call, and reloads the results.

Tail call optimisation converts self-recursive calls at tail position into parameter reassignment plus goto, turning recursion into iteration with constant stack space.

What we deliberately don't do

  • Structured control flow reconstruction. Reconstructing if/while/for from flat goto would add complexity for marginal gain — GCC's control-flow analysis handles goto graphs well.

Benchmarks

A benchmark suite in testdata/benchmarks/ covers 12 categories. The table below compares three execution methods: ccforth compiled to C (via gcc -O3 -march=native -flto), the ccforth Go interpreter, and Gforth 0.7.3. Times are medians of 3 runs on an AMD Ryzen 7 PRO 5850U.

Benchmark Compiled Interpreter Gforth Gforth/Compiled Interpreter/Compiled
control-flow 655µs 1.34s 206ms 315x 2049x
floating-point 7ms 152ms 36ms 5x 21x
integer-arithmetic 660µs 274ms 38ms 57x 415x
locals-vs-stack 686µs 1.91s 256ms 373x 2787x
loop-comparison 665µs 374ms 46ms 69x 562x
matrix-multiply 358ms 84.81s 8.08s 23x 237x
memory-access 2ms 655ms 68ms 29x 279x
recursion 7ms 584ms 78ms 11x 80x
sieve 13ms 4.42s 503ms 39x 343x
stack-manipulation 655µs 340ms 49ms 74x 519x
string-memory 105ms 7.89s 756ms 7x 75x
word-call-return 2ms 827ms 135ms 56x 340x

The compiled output is typically 5–373x faster than Gforth and 21–2787x faster than the interpreter, depending on how well the benchmark's hot loop maps to C optimisation.

Run the benchmarks using Go's standard benchmark framework:

go test -bench BenchmarkCompiled -run ^$ -timeout 300s
go test -bench BenchmarkInterpreter -run ^$ -timeout 300s
go test -bench BenchmarkGforth -run ^$ -timeout 600s

Run a specific benchmark:

go test -bench BenchmarkCompiled/sieve -run ^$ -timeout 60s

Contributing

Contributions are welcome at https://github.com/ncw/ccforth. To get started:

  1. Fork the repository and create a feature branch.
  2. Make your changes. Run go test ./... and ensure all tests pass.
  3. If adding new Forth words, add both interpreter and C emitter support, and include a test program in testdata/end-to-end/.
  4. Open a pull request with a clear description of what you changed and why.

Areas where help is appreciated

  • Block word set — not currently implemented.
  • Float stack branch merging — the virtual float stack is flushed to the global fstack[] at every control flow boundary (IF, ELSE, BEGIN, DO, word calls). Float values that span across branches could instead participate in the same merge-variable logic used for the data stack, avoiding the flush/reload overhead. This would benefit words like F-ABS (x F0< IF x FNEGATE ELSE x THEN) where the result is currently bounced through the global array at the merge.
  • Inline small parameterised words at IR level — copy the body of small __p words into callers before C emission, enabling cross-word peephole optimisation and reducing function call overhead.
  • Expression folding — collapse chains of single-use temps into compound expressions (cell_t t0 = s0 + 1; cell_t t1 = t0 * 2;cell_t t1 = (s0 + 1) * 2;). This is cosmetic — gcc already does it — but produces cleaner generated C.
  • More peephole patterns and dead code elimination.

License

ccforth is licensed under the MIT License.

Output exception: The C source code generated by ccforth (including the embedded runtime) is not considered a derivative work of ccforth. You may use, distribute, and license the generated code and compiled binaries under any terms you choose, with no obligation to apply the MIT license or provide attribution to ccforth.

About

ccforth is a mostly Gforth compatible Forth 2012 compliant Forth-to-C compiler written in Go

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages