DGCC -- a C-subset compiler for the Data General Nova/Eclipse

DGCC compiles a small, self-hosting-friendly subset of C into Data General Nova/Eclipse 16-bit assembler. It's built as four small, separate programs that pass text through a pipeline, plus a hand-written runtime support library for the operations the hardware has no instruction for:

SOURCE.C -> [cpp] -> SOURCE.I -> [nparse] -> SOURCE.NIR -> [ncodegen] -> SOURCE.ASM -> [nopt, optional] -> SOURCE.ASM
Program Role
src/cpp.c preprocessor (#define, #include "..."/<...> with -I search paths, conditionals, #line/#error/#pragma, __FILE__/__LINE__/__DATE__/__TIME__)
src/nparse.c lexer + parser + IR emitter
src/ncodegen.c IR -> Nova/Eclipse assembler
src/nopt.c optional peephole optimizer over the generated assembler
rtlib/rtlib.asm runtime helpers: multiply, divide, modulo, variable shifts, xor, packed-byte load/store

Please read docs/DESIGN.md's opening section before relying on this for anything real: it has never run on actual Data General hardware, and no real DG assembler or simulator was available while building it. Correctness was instead checked with a purpose-built instruction-level simulator, tools/nova_sim.py, which genuinely executes the generated code rather than just re-reading it -- but that is still not the same thing as real hardware. See DESIGN.md for exactly what that does and doesn't prove, and for the full list of real bugs that process caught along the way.

What it supports

int, char, void, single-level pointers, one-dimensional arrays (including string literals and array initializers, with a constant- expression size, not just a bare literal), typedef, static/extern, if/else/while/do-while/for/switch/case/default (constant-integer cases, real fall-through)/break/continue/return, the full operator set including compound assignment, ++/--, and the ternary operator ?:, casts, sizeof(type), and #asm/#a <rest of line> for embedding one line of raw target assembler text verbatim (anywhere a statement can appear, or at file scope) -- see DESIGN.md's "Inline assembly". A declaration may appear at the start of any { } block, not just a function's outer one -- though see DESIGN.md's "Interim block scoping": it's hoisted into one flat per-function frame, not a real nested scope, so the same name can't be reused across sibling blocks the way real C scoping would allow. No struct/union/enum/unsigned/long/float/comma-operator/multi- dimensional arrays/function pointers. Full details, plus several smaller documented simplifications (signed-shift behavior, cast semantics, a narrow parenthesized-lvalue-assignment gap, and so on), are in docs/DESIGN.md. The grammar itself is in docs/GRAMMAR.md.

There is no standard library -- no string.h/stdio.h/ctype.h. All I/O goes through four primitives (open/close/read-char/write-char) declared in src/portio.h, which a real deployment would implement in hand-written Nova I/O routines. src/hostio.c implements them on top of ANSI stdio.h purely so the tools can be built and tested with gcc on an ordinary machine.

Building

You need gcc (or another C89 compiler) and Python 3 (only for the test suite's simulator, not for the compiler itself). There is no real Nova hardware or assembler involved anywhere in this build -- "building DGCC" means compiling its four C sources as ordinary host programs:

make            # builds build/cpp, build/nparse, build/ncodegen, build/nopt
make test       # builds, then runs tests/run_tests.py (see below)
make clean

Compiling a program

./build/cpp        myprog.c    myprog.i
./build/nparse      myprog.i   myprog.nir
./build/ncodegen    myprog.nir myprog.asm
./build/nopt        myprog.asm myprog.opt.asm   # optional

cpp also accepts -g (emit #L location markers nparse.c uses for accurate diagnostics across #include/#line boundaries) and any number of -I dir / -Idir flags (a search path for #include "...", tried after the current directory, and the only thing #include <...> searches), in any order, before the two filenames:

./build/cpp -g -Iinclude -I ../shared/include myprog.c myprog.i

myprog.opt.asm (or myprog.asm, if you skipped nopt) is Nova/Eclipse assembler text. Turning it into something a real machine could run would still need a real DG assembler and rtlib.asm linked in for any program using *, /, %, <<, >>, ^, or char arrays/pointers -- neither of which this project has access to, so that last step is untested (see DESIGN.md).

build.sh runs the four steps above for a single file in one command: ./build.sh myprog.c produces myprog.opt.asm.

Inspecting NIR

tools/nir_dump.py reads a .nir file and prints it as an indented, AST-like tree -- the same idea as a real compiler's -ast-dump flag, adapted to the fact that NIR itself has no tree structure on disk (it's a flat stack-machine instruction stream, the same shape JVM bytecode or a Forth word's definition has). It reconstructs the tree by simulating the operand stack: every opcode that pops N values and pushes one becomes a node whose children are what it popped.

python3 tools/nir_dump.py myprog.nir

src/nirdump.c is the same tool, ported into DGCC's own C subset -- self-hosting, like cpp.c/nparse.c/ncodegen.c/nopt.c, and built into build/nirdump alongside them:

./build/nirdump myprog.nir myprog.nir.txt

The two are meant to produce byte-for-byte identical output (checked directly in tests/run_tests.py, against cpp.c's and nparse.c's own real, large, self-compiled NIR); the C version has no colored-output option, though, since portio.h's I/O abstraction has no terminal detection primitive, on purpose, since it also has to make sense on a real DG target.

Expressions, loads/stores, calls, and increment/decrement all reconstruct completely. Control flow (LABEL/JMP/JZ/JNZ) prints as flat milestone lines in source order rather than being decompiled back into if/while/switch shapes -- matching jump patterns back to source constructs reliably is a much harder problem than a stack simulation, and this tool would rather show the real, flat opcodes than guess at a nesting that might be wrong. A conditional jump's own tested expression still prints as a full tree, since that part is just an ordinary stack pop.

Testing

python3 tests/run_tests.py

This builds the four tools, checks cpp.c's own text output and exit codes for its preprocessor-only features (comments, the built-in __FILE__/__LINE__/__DATE__/__TIME__ macros, #line, #error, #pragma, -I search paths, and #include <...>), then compiles every tests/*.c file through the real pipeline (both with and without nopt) and actually executes the resulting assembler with tools/nova_sim.py, checking results against hand-computed expected values -- 1233 checks as of this writing, all passing, covering function calls and stack frames, global arrays and string-literal initializers, byte pointers and pointer arithmetic, typedef, for/while/do- while/break/continue, switch/case/default (including fall-through, a switch inside a loop, and a switch nested inside a switch), every arithmetic/bitwise/comparison operator (including the runtime-library-backed *, /, %, <<, >>, ^), pre/post ++/ --, the ternary operator, block-scoped declarations hoisted out of nested blocks, extern function and variable linkage, and #asm/#a inline assembly (including that nopt's optimizations never touch a hand-written line, checked by requiring identical results whether or not nopt ran). It also confirms the optimizer changes nothing about the results, only the size, of every one of those programs. As its final step, it also actually compiles cpp.c and nparse.c's own sources through the real pipeline, checks that every stage succeeds, and (since that step's own NIR output is large and varied) confirms tools/nir_dump.py -- and src/nirdump.c's compiled output, checked directly against it for byte-for-byte equality -- recognize every opcode it contains. See "Self-hosting" below.

Self-hosting

cpp.c, nparse.c, ncodegen.c, nopt.c, nirdump.c, util.c, and hostio.c are all written using only the language subset DGCC itself implements (see DESIGN.md for the exact discipline: no heap, fixed-size tables, no struct, and so on, plus an interim, hoisting-based approach to block scoping). tests/run_tests.py actually compiles cpp.c's, nparse.c's, and nirdump.c's own sources through the complete pipeline as its final step, and as of this writing all three succeed at every stage -- including nparse.c's own several-hundred-line switch/do-while implementation, which is itself now part of what gets self-compiled on every test run. That's real, checkable evidence of self-hosting readiness, but it has an honest, specific scope: it proves these files are within the language subset and that ncodegen can turn them into assembler text without error. It does not prove the resulting assembly would actually link and run correctly as a working compiler on real hardware -- there's no real linker or hardware/simulator target in this environment capable of running an entire multi-program toolchain's worth of Nova assembly, so full self-compilation-and-execution remains undemonstrated. See DESIGN.md's "Self-compiling dgcc's own sources" for the complete account, including several real gaps this process found and fixed across two rounds of adding new syntax (a stray switch statement and an array-of-pointers in the sources themselves, a missing constant-expression array-size feature, a string-literal table that was too small, a capture-nesting bug that briefly duplicated generated code, and a couple of ordering/naming bugs of its own) -- and, notably, that nirdump.c had none of these: written from scratch after all of that hard-won discipline was already established, it compiled cleanly through the real pipeline on the first attempt. cpp.c and nparse.c also now use switch/do-while themselves in a few well-justified places -- see DESIGN.md's "Using the new statements in dgcc's own sources" for exactly which, and why most candidate sites were deliberately left alone.