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.