Documentation
pdp11-c0 — design
This explains what pdp11-c0 is, where it sits in the compiler, how a translation unit becomes the intermediate stream c1 reads, and the two things the port had to get right: keeping this pre-1977 C compilable on a 64-bit…
pdp11-c0 — design
This explains what pdp11-c0 is, where it sits in the compiler, how a
translation unit becomes the intermediate stream c1 reads, and the two things
the port had to get right: keeping this pre-1977 C compilable on a 64-bit host,
and keeping its output byte-for-byte what the 1981 compiler produced. For how to
run it, see the user guide; for the line-by-line porting
account see porting.md.
The sources are c00.c (driver, lexer, expression parser), c01.c (tree
building and conversions), c02.c (statements and externals), c03.c
(declarations), c04.c (tree walk and emission), c05.c (the tables), and
c0.h (the shared structs and #defines). It is the 2.9BSD c0, SCCS 2.x,
near byte-identical upstream to 2.8's.
1. What it is, and where it sits
pdp11-c0 is pass 1 of Dennis Ritchie's two-pass PDP-11 C compiler. The
full compile is
cpp -> c0 -> c1 [ -> c2 ] -> as -> ld
— the preprocessor, then this pass, then c1 the code generator, then the
optional c2 peephole optimiser (cc -O), then the assembler and linker.
c0 reads already-preprocessed C and does the front-end work: lexing,
parsing declarations / statements / expressions, building the symbol table and
the expression trees, and writing an intermediate token/operator stream that
c1 turns into PDP-11 assembly. It does not emit assembly — that is c1's
job. cc drives the chain and calls c0 with three temp-file arguments (see the
user guide).
The 16-bit PDP-11 target is baked into c0.h: int, short and pointer are all
2 bytes (SZINT/SZPTR), NBPW = 16. Those object-machine constants are
unchanged by the port (§5).
2. The six files
c00.c—main, the lexer (symbol, backed by thectabcharacter classes;getnum,getcc,mapch,putstr), the keyword hash, andtree()— the classic bottom-up, priority-driven expression parser, which shifts operands onto a stack and callsbuild()inc01.cto reduce.c01.c—build(): takes the top operands off the stack, inserts the required type conversions (driven bycvtab/cvntab), folds constant sub-expressions (fold), and makes a new node. Also the node allocators (block/cblock/fblock/nblock/gblock) anderror().c02.c—extdef()(one top-level external at a time) andstatement()(the keyword-dispatched grammar:if/while/for/do/switch/goto/return/break/continue, blocks, labels), pluscfunc(function bodies) andcinit/strinit(initialisers).c03.c— declarations:getkeywords,declare/decl1,getype(the declarator recursion → pointer/array/function types),strdec(struct/union/ enum),align(member and bit-field layout), and the push-down (pushdecl).c04.c—treeout/rcexpr/cbranch/branch/labelwalk a finished tree and emit it, the type arithmetic (incref/decref,length/plength), andoutcode()— the one function that writes the byte stream.c05.c— pure data:opdope[](per-operator priority + flag bits),cvtab/cvntab(the conversion matrix and its operator mapping), andctab[](the 128-entry character-class table the lexer indexes).
3. From source to stream
main() loops extdef() until EOF. Each external is either a data definition —
whose initialiser is walked by cinit/strinit and emitted as DATA/BSS/
SSPACE items — or a function, handed to cfunc(), which frames the body
(PROG/SAVE/SETREG/RETRN/SETSTK) and calls statement() for it.
Names live in one open-addressed table, hshtab[HSHSIZ] (800 entries). lookup()
hashes the 8-char name (NCPS) and linear-probes; keyword slots are pre-marked so
a name that collides with a keyword is checked against kwtab first (findkw).
Block scope is handled by push-down: an inner declaration of an outer name saves
the outer entry through hpdown (pushdecl), and blkend() restores or discards
it and rehashes retained names.
Expressions are the heart of it. tree() reads tokens, using opdope
priorities to decide shift-vs-reduce, and calls build() to reduce. build() is
where the type rules live: it disarrays arrays and takes the address of functions
(disarray/chkfun), consults cvtab[lintyp(t1)][lintyp(t2)] for each operand's
conversion and applies it (convert → an ITOF/ITOL/… node), folds constant
operands (fold, so array bounds and case values reduce to a CON), and builds
the operator node. A finished tree is handed to rcexpr()/treeout() (c04),
which post-order-walks it into outcode().
4. The intermediate stream — the c0 → c1 interface
outcode(fmt, ...) writes a flat, host-independent stream. The code stream goes
to temp1; strings and data go to temp2 (selected by the global strflg),
which c1 reads after temp1. The format codes:
| code | bytes | meaning |
|---|---|---|
B |
value, 0376 |
an operator/opcode byte + marker |
N |
lo, hi | a 16-bit little-endian word |
L |
4 bytes | a 32-bit long (high word first, each word little-endian) |
S |
_name\0 |
a symbol name (≤ NCPS, _-prefixed if non-empty) |
F |
string\0 |
a float-constant string |
0/1 |
word | the constant 0 / 1 |
The opcodes are the #defines in c0.h (SYMDEF, PROG, RLABEL, SAVE,
CON, RFORCE, EXPR, RETRN, EOFC, …). Because the stream is defined in
bytes and 16-bit words, it is identical on the PDP-11 and on the host — only
the programs at each end differ. This is the contract the whole port is measured
against (§7): the bytes must match what native c0 emitted — which is why the
L code reproduces native's high-word-first long order, not the host's.
5. Authentic 1970s C on a 64-bit host
c0's own source is pre-1977 C, and the hazard is the C it is, not the C it
compiles. Target semantics are untouched (still 2-byte ints and pointers); only
the compiler's own word assumptions widened. On the PDP-11 an int and a pointer
were both 16 bits and freely interchanged — on LP64 they are 8 and 4 bytes, so
every pointer-in-int had to be found.
- The tree nodes are a union superset. Old C reached any variant tail
(
cnode/lnode/fnode) through a barestruct tnode *, relying on the sharedop/type/subsp/strpprefix.tnodeis made a superset whose anonymous union holds{tr1,tr2} | value | lvalue | cstr(built with-fms-extensions); the prefix still aliases the variant structs exactly, so atnode *and acnode */lnode */fnode *alias the same fields. - The parameter list carries a real pointer. It was chained through the
int hoffsetfield;hshtabgained an honesthpnextpointer so the link is not truncated on LP64. - The pointer/int register-reuse idioms were made honest. Where the original
reused one variable as both an
intand a node pointer, the port splits them (build'sSIZEOFuses the sparep3) or fixes the type:statement()'so1, which holds a label number or a token, is now an honestint; the spots reusing ahshtab *slot as anlnode/tree node are explicit casts. outcode()uses<stdarg.h>. The original walked its arguments as PDP-11 stack words (along= two words), invalid on a register-argument ABI; arguments are now gathered by type, theLcode replacing the old two-Nlong walk. The on-disk bytes are unchanged.- The node arena is
malloc, notsbrk. Rawsbrkcollides with the host libcmallocthatc0's own stdio uses;maintakes one 16 MB region,gblockbump-allocates through it (never less than a fulltnode, zeroed), reset per function viafuncbase = curbase.
One subtle bug was a dangling one: cmst[], the operand stack tree() builds
via the global cp, was a tree()-local array — but callers keep writing through
cp after tree() returns. On the PDP-11's stack that survived; on the host
the reused frame's writes smash the stack. cmst is now file-scope (BSS).
6. The syntax-error NULL guards
On the PDP-11, reading address 0 returned a harmless 0, so following a NULL tree
pointer on an error path merely produced nonsense; a 64-bit host faults. The
paths where tree() returns 0 (a diagnosed syntax error) are now guarded, so a
bad input yields a diagnostic and a clean non-zero exit, not a crash:
conexp()returnst ? t->value : 0instead of dereferencing;dogoto()returns early whentree()gave 0, rather than building on it;doret()skips the return-value conversion on a 0 tree (still branching toretlab);chkfun()/disarray()pass a NULL argument through untouched — the empty arglist of a no-argument callg()flows through them as 0.
Relatedly, a SEQNC (comma-expression) node now copies the right operand's
subsp/strp, so a struct- or array-typed sequence does not later hand
length()/treeout() a NULL strp.
7. Testing
The output is held to the strongest standard available: byte-identity with the compiler that actually built 2.xBSD.
tests/c0/return42.shparsesint main(){return 42;}throughcpp+c0and checks the emitted stream by opcode —SYMDEF _main,RLABEL _main, the constant 42 asCON/INT/0x2a,RETRN— decoding theodhex directly.- The
ccend-to-end suite (tests/cc/*) compiles, assembles, links and runs real programs through the whole chain, so a change that still parses but emits a wrong tree is caught downstream. - The native-compiler oracle (
oracle/run.sh,oracle/cc-corpus.sh) is the decisive check: it feeds the same input to ourc0|c1|c2and to the original 1981 2.8/2.9BSD binaries underapsimand compares — thec2stage byte-exact, thec1stage by assembling both outputs and comparing the.o. This is where "byte-identical to native" is proven (cc-corpus 91/91).
8. For a maintainer
- The intermediate stream is the contract. Any change must keep the
temp1/temp2bytes identical to nativec0; the oracle proves it — run it, don't eyeball the tree. - Do not reorder the node prefix.
op/type/subsp/strpmust stay first and identical acrosstnode/cnode/lnode/fnode; the union superset and-fms-extensionsare load-bearing, not stylistic. - Keep the arena on
malloc. Reintroducingsbrkcorrupts the host heapc0's stdio shares; the fixed region and per-function reset are deliberate. - Keep the NULL guards on the error paths. They turn a host segfault back into the diagnostic the PDP-11 would have limped through.
MENLO_OVLYmust stay defined (viawhoami.h).c0unconditionally emitsSETSTKas-maxauto+STAUTO, and onlyc1's MENLO stack formula matches it; with it undefined the two passes disagree on the frame and a callee clobbers a saved register. The-Voverlay path it enables stays dormant (ccpasses only-P).