software/compiler — y1cc, a C cross-compiler for the YACC1¶
y1cc.py (Python 3, no dependencies) compiles a small C subset to YACC1 assembly for software/assembler
(RC/asm with yacc1.def). Written 2026-09-22; the first C compiler the machine has had. Since 2026-09-24 it has a
twin in C, c/y1cc.c (below), written in the subset itself and producing the same assembly byte for byte: the first
step towards a compiler that runs on the machine. The same day that twin was split into nine programs, c/cc1_lex.c
.. c/cc9_final.c ("The multi-pass compiler", below), each of which fits the Y1/OS program area with its tables and
its stack, and which together still produce the same assembly. y1cc.py stays the reference and the bootstrap. The front end (lexer,
parser, the C subset) is the one of the P8X compiler p8x/compiler/p8cc.py, so P8X C programs port with their
source unchanged as far as the subset goes; the back end is new, written for what the YACC1 actually has.
python3 software/compiler/y1cc.py prog.c -o prog.asm # for the machine (monitor: G3000 calls main)
python3 software/compiler/y1cc.py prog.c -o prog.asm --vector # for the monitor as burned in 2021 (G = BRVR)
python3 software/compiler/y1cc.py prog.c -o prog.asm --org 0x5000 --os # a Y1/OS program (os/Makefile): console via the OS
python3 software/compiler/y1cc.py prog.c -o prog.asm --boot # for the emulator, stand-alone
python3 software/compiler/y1cc.py prog.c -o prog.asm --xisa # + LDZ/STZ/ADDIW/SHL16 (2026-09-24 microcode), below
python3 software/compiler/y1cc.py prog.c -o prog.asm --org 0x5000 --os --stack 0xCFFF # main on its own stack (below)
cd <dir with rcasm.rc + yacc1.def> && ../software/assembler/asm prog -d=yacc1 > prog.lst # -> prog.img (Intel hex)
software/emulator/emulator -x -f prog.img # runs it, exits at HALT (--boot images)
python3 tests/compiler/run.py # the test suite (make cc-test)
make -C software/compiler/c && software/compiler/c/y1cc prog.c -o prog.asm --boot # the C twin: same options, same output
software/compiler/c/y1ccp prog.c -o prog.asm --boot # the multi-pass compiler (cc1..cc9): the same again
python3 tests/compiler/twin.py [--chain] # y1cc.py vs the C twin (vs the passes) over the corpus
python3 tests/compiler/passes.py # each pass against the Y1/OS program area
The C subset¶
| types | int (16-bit unsigned, as in p8cc), char (8-bit unsigned), pointers, arrays T a[N], struct/union (by pointer or member: no by-value struct params, returns or assignment); unsigned, const, static, void accepted |
| top level | struct/union definitions, function definitions and prototypes, globals with constant initializers (numbers, strings, {lists}, &var / array addresses, [] length inferred) |
| statements | {} decl (with initializer, several per line) if/else while for(e;e;e) switch/case/default break continue return expr ; |
| expressions | = += -= *= /= %= &= \|= ^= <<= >>= ++ -- (pre/post) ?: \|\| && \| ^ & == != < > <= >= << >> + - * / % unary - ! ~ & * a[i] s.m p->m f(args) sizeof |
| preprocessor | #define NAME value (integer or char), #include "file" (textual, each file once, searched beside the source then in lib/); a directive is its line, where a /* */ comment is one space and may go on over newlines (the directive then ends at the newline after it) and a // comment ends it, as in C (2026-09-25: until then a comment going on from a directive's line had its next lines lexed as code; tests/compiler/dircomment.c, dircomline.c) |
| builtins | putchar(c) getchar() puts(s) (console), peek(a) poke(a,v) peekw(a) pokew(a,v) (memory), inp(port) outp(port,v) (I/O ports, constant 0..15), halt(), bios(addr, r7, acc) (JSR a monitor routine with R7 and ACC set; returns ACC), call(addr) (JSRUR a computed address, returns its R3), argstr() (the command tail at $0F40), sys(n, a, b, c) and funcaddr(f) (the Y1/OS syscall interface, below) |
| library | lib/y1lib.c: putstr putnum puthex puthex2 strlen strcmp strcpy memset — #include "y1lib.c"; unused functions cost nothing (dead-function elimination) |
| recursion | direct and mutual (2026-09-24): a call inside a recursive cycle saves and restores the callee's static frame on the stack (below). Not main; not the address of a local passed into the cycle |
| not there | signed arithmetic, long/float, function pointers, goto, bit fields, do ... while, #if/#ifdef (ignored, like every directive but #define/#include) |
Console I/O: on the emulator putchar is OUTA P2 and getchar is INP P2 (returns 0 at end of input); on the
machine they call the monitor's BIOS vectors charout ($FFC4) and uartin ($FFE8). The runtime chooses at run
time with BRDEV, which never branches on the emulator and always branches on the hardware, so one image serves
both. puts appends \n (10) only.
--os (2026-09-23) compiles a Y1/OS program: os/Makefile builds the OS itself and every /BIN command with
it. putchar (and puts, which loops over it) then stores the byte in SYSARG0 ($0F06, a big-endian word, high byte
0) and JSRURs the Y1/OS syscall CONOUT (the word at SYSTAB + 38 = $0F3A); getchar JSRURs CONIN (SYSTAB + 34) and
returns the low byte of SYSRES, or 0 when CONIN says 65535 (end of input, Ctrl-D, the end of a < file): the
same end-of-input byte as INP P2 on the emulator. Both keep R3 and R4 (rt_puts walks its string in R3); the OS
handler is compiled code and clobbers R5-R7, ACC and TMP, which nothing keeps across a call. This is what lets the
shell redirect a program's output (>, >>, |) and input (<) with no change to its source (os/README.md).
Without --os the console runtime is the one above, byte for byte (checked 2026-09-23: every tests/compiler
program and the bench sources compile to identical assembly). An --os program must run under Y1/OS: from the
bare monitor its first putchar would jump through an empty SYSTAB slot.
How the generated code works (the YACC1-specific part)¶
The YACC1 has an 8-bit accumulator ACC and an 8-bit TMP, eight 16-bit registers R0-R7 (R0 = PC, R1 = SP), byte
loads/stores through any register (LDAVR/STAVR), absolute 16-bit register loads/stores (LDR/STR, 3 bytes),
and big-endian words in memory. There is no 16-bit ALU and no indexed addressing, which drove every choice below.
- R3 is the expression accumulator: every expression leaves its 16-bit value in R3 (chars zero-extended).
R4 is the second operand, R5-R7 are runtime scratch (R7 also carries the string for the monitor's
stringout). R2 is never touched: on the hardwareLDA/STA/LDT/STT/LDR/STRuse R2 as the hidden operand-address register (docs/isa/MICROCODE-REVIEW-NOTES.mdL-9); the emulator uses a ninth register for that, so a program that relied on R2 would only fail on the real machine. - Static frames. A global is a labelled word or byte; a function's parameters and locals are
labelled slots of their own (
main_i: DS 2). Loading or storing a scalar is one 3-byteLDR R3,label/STR R3,label; a frame-relative access would have cost a 16-bit add per variable (about 12 bytes) because the ISA has no(Rn+d)addressing. A char scalar occupies a 2-byte slot whose high byte is kept zero, so it loads with oneLDRtoo; char arrays and struct members are true bytes. A function's slots are consecutiveDSlines: its frame, one block of bytes. - Recursion (2026-09-24) keeps the static frames and saves them. The call graph's reachability tells which
functions can reach themselves (a recursive cycle, a strongly connected component:
fact, oris_even/is_odd). A call from a function to a callee that can reach back to it is a call inside a cycle; only those change: the caller pushes the callee's whole frame (parameters and locals) on the R1 stack, stores the arguments into the slots as usual,JSRs, and pops the frame back afterwards. So every activation finds its own values in the slots while it runs, all accesses stay oneLDR/STR, and a function outside every cycle compiles exactly as before (tests/compiler/diffcheck.pyproves it on the whole corpus). Why the callee's frame: whatever the callee (or anything it calls) changes in any frame of the cycle is put back by the call that changed it, so after any call inside the cycle every frame of the cycle is as it was. A function outside the cycle needs nothing: it cannot be active further up the stack (it would then reach the cycle and be part of it). - Cost per call inside a cycle: frames of up to 8 bytes are copied inline,
LDR R4,f+k / PUSHR R4before andPOPR R4 / STR R4,f+kafter (10 bytes of code and about 123 microcode steps per frame word, an odd byte throughLDA/PUSH,POP/STA); a bigger frame goes through the runtime pairrt_fsave/rt_frest(MVIW R5,frame / MVIW R6,bytes / JSR, 9 bytes each side, about 107 steps per byte each way). Example (2026-09-24,--boot):fib(15)recursive is 1,973 calls, 48,388 instructions and about 980,000 microcode steps (~500 steps a call); the iterative loop is 541 instructions and about 9,000 steps; the recursive function is 14 bytes larger (two call sites × 10 bytes of save/restore, less code elsewhere). Stack: 2 bytes of return address + the frame per level. The stack is the monitor's $0C00-$0EFF (768 bytes, the handle buffers of Y1/OS end at $0BFF), so keep deep recursion to small frames; nothing checks for overflow. - Self-calls and arguments. In
f(a, b)called fromfthe arguments are stored into the caller's own parameter slots; an argument whose slot a later argument still reads (hanoi(n - 1, from, via, to)storesto's slot before readingto) waits on the stack like a parked argument. The return value comes back in R3, which the restore does not touch. - Rules.
maincannot be recursive (it clears the BSS on entry; compile error). The address of a local of a recursive function (&x, a local array, a member of a local struct) cannot be passed as an argument to a call inside its cycle: the callee's activation of the same function would reuse the slot (compile error "the address of local 'x' is passed to f()"). Passing it to a function outside the cycle is fine (fill(buf),strlen(buf)). Not detected: such an address stored in a variable and used after a call into the cycle, or returned. Local arrays and structs in recursive functions are allowed; they are saved and restored whole on every call inside the cycle (the cost above, per byte). A syscall handler must still notsys()itself (the call graph cannot see through SYSTAB). - Calls: the caller evaluates each argument into R3 and stores it straight into the callee's parameter slot,
then
JSR(inside a recursive cycle wrapped in the frame save/restore above). When a later argument's evaluation could itself run the callee (f(x, g())wheregcallsf) the earlier ones are parked on the stack meanwhile. The result comes back in R3;returnisRET. - Arithmetic is byte-wise through ACC/TMP.
+ & | ^are inline (MVRLA R4 / MVAT / MVRLA R3 / ADDT / MVARL R3 / ... ADDTC ..., thedo_add16idiom the monitor proved on the hardware; 8-10 bytes), constants fold intoADDI/ADDICimmediates,+1/-1areINCR/DECR.-is a runtime call (two's-complement add: the emulator and the hardware disagree about SUB's borrow, so it is never used).* / % << >>are runtime loops (rt_mul,rt_divmod,rt_shl,rt_shr); shifts by small constants,*2 *4 *8 *256,/2^k,%2^kare inline. - Comparisons in conditions branch straight on the comparator instructions (
BRLT/BRGT/BREQ/BRNEQcompare ACC with TMP): high bytes first, low bytes only if they are equal. Two char operands need one compare;x == 0ORs the two bytes. Unsigned, like p8cc's int. A relation used as a value becomes 0/1 through the same path. - The carry flag is used only inside an
ADDT/ADDTC(orADDI/ADDIC) pair with nothing but register moves between them, and inside aCSHL/CSHRpair after an explicit clear (LDAI 0 / CSHL). Plain shifts and subtracts never feed a following carry op, because the hardware loads the carry flip-flop on every shift and on SUB and the emulator does not (review item L-7). - Image layout:
ORG→ main → the other live functions → the runtime helpers actually used → initialised data and strings (DBas numbers: the assembler upper-cases every source line, soDB "text"would be shouted) → uninitialised variables (DS, kept last) → with--boota stub at $F000 (BR $F003 / MVIW R1,$0EFF / JSR f_main / HALT; the first branch presents an A15-high address, which releases the memory card's FORCE-ROM boot remap exactly as the monitor's first instruction does — without it every fetch stays inside $F000-$FFFF, seen on the microcode emulator). The monitor'sG AAAA(rebuilt 2026-09-22) isJSRUR R7, a call:G3000enters main and main's RET returns to the command loop.--vectoris the layout for the monitor as burned in 2021, whose G wasBRVR R7: on the hardware that is an indirect jump through the word at AAAA (the microcode reads[R7],[R7+1]into the branch register,docs/isa/steps.txt) that pushes no return address, so that image starts with a 2-byte vector to a stubJSR f_main / BR $F000(monitor restart). Both were run on the emulator against the respective monitor image (the 2021 one from the chip capture). The default--orgis $3000 because $1000-$1FFF is BASIC's token buffer, which the monitor's boot (and the restart after main) clears, and the monitor's T tests use $2000 as scratch; a program at $1000 lost its first byte before it ran (found on the emulator 2026-09-22). - Zero-initialised data (2026-09-22, found by the microcode emulator's $FF-filled RAM): main starts by clearing
every
DSslot betweenbss_startandbss_end(20 bytes of code), and a partially initialised array's tail is real zero bytes in the image, notDS. The interpreter's zeroed memory had hidden both. switch(2026-09-22): the case labels must be direct statements of the switch block. Dispatch is whichever is smaller: a compare chain (LDTI k / BREQper case when every case fits a byte, 5 bytes each; a two-level compare, 13 bytes, otherwise) or a jump table throughBRUR($AD, PC ← Rn): subtract the lowest case, range-check, index a table ofDWaddresses, load the word into R3,BRUR R3(about 49 bytes plus 2 per slot; holes go to default).--no-brurforbids the table, for the machine until its sequencer EEPROM holds the microcode with BRUR; the same test program passes both ways (switch.c/switchnb.c).- Syscalls (2026-09-23):
sys(n, a, b, c)is how a program reaches Y1/OS (os/README.md). The arguments (any of a, b, c may be left out) are evaluated into the parameter words SYSARG0..2 at $0F06/$0F08/$0F0A (STR R3,addr); an argument evaluated later that calls anything (it could run asys()of its own) makes the earlier ones wait on the stack, exactly as a user call's arguments do. Then the entry wordSYSTAB + 2n($0F14 + 2n, n = 0..21; for a constant 22..31 the word atSYSTAB2 + 2n, $4FC0 + 2n, the OS's 32-entry table since 2026-09-25) is loaded into R7 (LDR R7,addrfor a constant n; a computed n is shifted, added and dereferenced) andJSRUR R7calls the handler; the result word SYSRES ($0F0C) comes back in R3 as an int.funcaddr(f)is the address of functionfas an int (MVIW R3,f_label): the OS installs its handlers withpokew(SYSTAB + 2 * n, funcaddr(h_open)). A function named infuncaddr()is an entry point: it and whatever it calls are kept in the image even when nothing calls them directly (the dead-function pass starts frommainand every such function). The call-graph check cannot see through the table: an OS handler must notsys()itself.tests/compiler/syscall.cinstalls its own handlers and calls them, constant and computed numbers, 1..4 arguments, nestedsys()in arguments. Constant folding got a fix the same day: the operator table was an eager dictionary that evaluateda // bfor every fold, so any constant expression with a zero right operand (SYSTAB + 2 * SYS_OPEN) crashed the compiler. --stack ADDR(2026-09-25):mainmoves the program onto a stack of its own. Its first lines (after the--xisapage load, before the BSS clear) areMOVRR R1,R5 / MVIW R1,ADDR / PUSHR R5: the caller's SP is the first word on the new stack (ADDR-1..ADDR); everyreturninmain, and its end whether or not the last statement returned (4 dead bytes then, the price of an unconditional rule the passes can follow), isPOPR R5 / MOVRR R5,R1 / RET, so the RET pops the caller's return address from the caller's own stack and the shell or monitor finds its stack as it left it. R3 (the result) is untouched. Everything else runs on the new stack: return addresses, pushed operands, parked arguments, recursion's frame saves, and the Y1/OS syscall handlers (they run on the caller's stack). Why: the monitor's stack is 768 bytes ($0C00-$0EFF, the handle buffers below it), shared with the shell and the syscalls, and the compiler's parser alone needs 1.2K on the corpus; a Y1/OS program owns $5000-$CFFF, so--stack 0xCFFFputs its stack at the top of its own area, above its tables. The option was chosen over the OS giving every program the area's top (a/BINcommand whose data reached $CFFF would collide with it). Nothing checks for overflow at run time; the instruction-level emulator's-S(program watch) reports the lowest SP a--stackprogram reached, whichtests/native/run.pycompares with each pass's last byte of data. Without the option the output is byte-identical (diffcheck.py);tests/compiler/stack.c(// y1cc: --stack 0xC7FF: the saved SP, 40 levels of recursion on the new stack, an early return) runs on both emulators,twin.py/--chaincover it, andtwinfuzz.pyadds--stackto every fifth random program.- Never emitted:
BR16Z BR16NZ BRNC(no microcode),BRVR(not needed yet),LDZ STZ ADDIW SHL16without--xisa, negative numbers (the assembler silently drops the sign), labels over 29 characters (crash the assembler), or two labels differing only in case (the assembler folds case; the compiler mangles and uniquifies).
--xisa: the page, ADDIW and SHL16 (2026-09-24)¶
Four instruction families were added to the microcode on 2026-09-24 (docs/programming/ISA-REFERENCE.md section 4a)
to shrink exactly what this compiler emits most: LDZ Rn,d / STZ Rn,d (2 bytes: the word at R6.hi:d),
ADDIW Rn,w (3 bytes: Rn += w) and SHL16 Rn (1 byte: Rn <<= 1). --xisa uses them. It is opt-in until the
machine's sequencer EEPROM holds that microcode and tests/bench has passed on it (BACKLOG.md); without it the
output is byte-identical to before (tests/compiler/diffcheck.py: 126 identical + 4 errors).
- The page. Every uninitialised 2-byte global and every 2-byte parameter or local is a candidate; so is the whole
frame of a function in a recursive cycle (1..256 bytes:
frame_saveandrt_fsaveneed it contiguous). A candidate's weight is how many times its variables are named in the live functions' bodies (every identifier of the tree, resolved asvinfowould), its density the weight per word; the densest go first (ties: the earlier in BSS order) while they fit in 256 bytes. They are laid out first in the BSS:zpad: DS (256-(zpad).0)&255(beforebss_start, so main's BSS clear does not clear the padding; it needs the 2026-09-24 assembler, which resolves the label in pass 1), thenzpage:, then theirDSlines in BSS order, then the rest. A load or store of one of them isLDZ R3,(main_i).0instead ofLDR R3,main_i(the low byte of an address in the aligned page is its offset), and a recursive frame in the page is saved withLDZ R4,(f_a+2).0/STZ. The peephole's store/reload rules apply toSTZ/LDZpairs as toSTR/LDR. - R6 is the page register and is reserved:
mainand everyfuncaddr()function (an entry from outside: the OS's syscall handlers) start withMVIW R6,zpage, and R6 is set again after everything that leaves compiled code, because the ROM and Y1/OS use R6 freely: afterbios()'sJSR,call()'s andsys()'sJSRUR, insidert_putc/rt_getcafter the ROM or OS call, and at the end ofrt_fsave/rt_frest(whose callers load R6 with the byte count).rt_divmodkeeps its remainder in R5 instead of R6. A program with nothing in the page (no 2-byte variable named anywhere) gets none of this: noMVIW R6, nozpad. - ADDIW / SHL16:
add_const's 8-byteMVRLA/ADDI/MVARL/MVRHA/ADDIC/MVARH(and the 4-byte high-byte-only form) becomesADDIW R3,k(a label expression too:ADDIW R3,g_table), the same for R4 inload_address_r4;shl1's 8 bytes becomeSHL16 R3, in compiled code and inrt_mulandrt_shl. Both leave ACC and the carry as the old sequences did (ACC = the high byte, carry = out of bit 15), but nothing the compiler emits reads the carry after them (rt_divmod's double shift still does it the long way until the machine has confirmed SHL16's carry). - Sizes (2026-09-24): the nine compiler passes, compiled as Y1/OS programs (
tests/compiler/passes.py [--xisa]):
| pass | code | code --xisa | image --xisa | total --xisa | free --xisa (default) |
|---|---|---|---|---|---|
| cc1 lex | 12,434 | 10,148 | 11,591 | 29,637 | 3,131 (1,030) |
| cc2 parse | 16,893 | 14,325 | 15,021 | 28,039 | 4,729 (2,244) |
| cc3 decl | 8,961 | 6,585 | 7,562 | 19,909 | 12,859 (10,601) |
| cc4 calls | 5,809 | 4,529 | 4,959 | 17,335 | 15,433 (14,314) |
| cc5 layout | 8,908 | 7,069 | 7,609 | 24,767 | 8,001 (6,233) |
| cc6 stmt | 11,921 | 9,302 | 9,963 | 29,795 | 2,973 (375) |
| cc7 sema | 16,974 | 13,106 | 13,546 | 28,376 | 4,392 (546) |
| cc8 emit | 25,197 | 21,979 | 23,406 | 27,593 | 5,175 (2,103) |
| cc9 final | 16,714 | 14,841 | 16,771 | 30,745 | 2,023 (275) |
| all nine | 123,811 | 101,884 (-17.7 %) |
In the nine with --xisa 9,115 of the 13,063 word loads and stores are LDZ/STZ, with 1,278 ADDIW and 954
SHL16. The /BIN commands (make -C os sizes, XISA=1): 87,235 bytes of images → 75,998 (-12.9 %); the
smallest grow by up to 2 bytes (hello: the MVIW R6,zpage costs more than its two variables save) and the
data stays the same. The C OS y1os.c: code 13,747 → 11,905. Test programs (code + data, --boot): -9 % to -18 %.
- Speed (microcode emulator, clocks): ADDIW is 46 steps against 72, SHL16 35 against 90, LDZ/STZ 30/31
like LDR/STR (they save a byte, not time). Whole programs: bench/sort -5.0 %, arrays -5.5 %, fib/sieve/
structs/rcalc -1 to -1.5 %, arith/rfact -0.5 to -0.7 %, strings/control +0.5 to +0.7 % (the R6 reload after
every character printed, and main's larger BSS clear).
- Tests: tests/compiler/run.py --xisa and tests/ucemu/run.py --xisa (every test, both emulators, 0 bus
fights); twin.py --xisa (also --16, --chain, --chain16) and twinfuzz.py --xisa: y1cc.py, y1cc.c and the
passes agree; XISA=1 python3 tests/os/run.py (Y1/OS with every /BIN program built with --xisa, 20/20;
make -C os XISA=1 builds that disk); tests/compiler/xisa.c (// y1cc: --xisa) is a test of its own and a
tests/bench program for the machine.
- In the passes: cc6 counts the identifiers while it copies each expression tree, plans the page after the last
function and writes W.zp (a flag byte and a bit per variable); cc8 writes ADDIW R4 and the M_ZP reloads;
cc9 prints LDZ/STZ for page variables, the page's DS lines first and the runtime variants
(lib/y1ccrt.txt: @NAME+x/-x sections, % lines only when there is a page); cc4 keeps a funcaddr() root's
live value at 1 even when another function reaches it.
Tests¶
tests/compiler/*.c with the expected output beside each (.out, .in for stdin, .err for an expected
compile error, // y1cc: flags on a line for per-test compiler flags); tests/compiler/run.py compiles, assembles, runs each on emulator -x and diffs. --oracle
regenerates the .out files with the HOST C compiler through host_shim.h (int = unsigned short, unsigned
char), so the expectations are independent of this compiler; tests marked no-oracle (peek/poke, struct layout,
byte order) carry hand-written expectations. 16 programs, 16/16 on 2026-09-23 (~3 s; syscall.c joined that day);
21 since 2026-09-24 with the recursion tests: rfact.c (fact, fib, Ackermann with a recursive call in an argument,
depth 50 with int and char locals), rmutual.c (even/odd, a three-function cycle, Hofstadter F/M), rlocals.c
(local arrays and structs in a recursive function through rt_fsave, an odd-sized frame, Hanoi, a pointer to a
local handed to a non-recursive helper), rcalc.c (a recursive-descent expression evaluator over a string), and
the compile errors recurse.c (address of a local into the cycle) and rmain.c (recursive main); all pass on both
emulators (tests/ucemu/run.py); 22 with adjstr.c (an error test, from the twin work below); 23 with xisa.c
(2026-09-24, // y1cc: --xisa, section "--xisa"). make check runs them.
tests/compiler/diffcheck.py [--base REV] is the differential proof for a compiler change: it compiles the whole
corpus (tests/compiler/corpus.py: the compiler tests with --boot, plain, --os and --no-brur, three
--vector builds, the bench sources, os/y1os.c, every /BIN command, tests/os/*.c, and since the twin
c/target.c — 121 compiles on 2026-09-24; 130 with the nine passes of the multi-pass compiler as Y1/OS programs,
c/target/*.c, the same day) with an old y1cc.py from git and the working one and diffs the assembly
(the header's timestamp masked). Against c847a97 (the last compiler without recursion): 100 identical, 17 that only
the new one compiles (the recursion tests and y1cc.c), 3 expected errors, 1 that crashed the old one (adjstr.c),
0 different. tests/compiler/twin.py is the same kind of proof between y1cc.py and its C twin (below).
The same images also run under the monitor on the emulator (emulator -m -f prog.img, then G3000): the program's
output appears after GO ADDRESS: and the monitor's banner follows when main returns (hello and fib tried 2026-09-22).
Sizes on 2026-09-22 (code + data + runtime, bytes): hello 111, io 722, sieve 862, chars 893, calls 948, globals 966, fib 1045, structs 1398, arrays 1446, control 2356, arith 2339.
y1cc.c — the C twin (2026-09-24)¶
c/y1cc.c is y1cc.py rewritten in C, function by function (the same names where C allows), so that a change to
one carries over to the other. It is written in the intersection of C89 and the y1cc subset, so it builds three
ways:
make -C software/compiler/c # ./y1cc on the Mac: cc -std=c89 -Wall -Wextra -pedantic, no warnings
# (plus ./y1cc16, the 16-bit check build, below)
software/compiler/c/y1cc prog.c -o prog.asm [--org N] [--boot] [--vector] [--no-brur] [--os] [-l]
make -C software/compiler/c target # y1cc.py compiles y1cc.c as a Y1/OS program; its size (below)
python3 tests/compiler/twin.py [--16] # the twin test (make check, make cc-test)
| file | what |
|---|---|
c/y1cc.c |
the compiler, 3,122 lines: lexer, parser, AST, code generator, peephole, runtime text, driver (y1cc_main) |
c/io.h |
the host interface: everything outside y1cc.c goes through these twelve functions (five more for the passes, below) |
c/host_io.c |
io.h on the Mac (stdio, getcwd, main(argc, argv)), plain host C |
c/target_io.c |
io.h on Y1/OS over os/lib_fs.c (the file syscalls), in the subset; run by the native compiler's passes since 2026-09-25, buffered (READN, WRITE) the same day, with c/target_rd.c or c/target_inc.c (the reads); the three output sections, which only y1cc.c uses, are in c/target_sec.c (so the passes do not carry its buffers) |
c/host.c, c/target.c |
the two translation units: limits + (target I/O) + #include "y1cc.c" |
c/limits_host.h, c/limits_y1.h |
the table sizes (#define numbers only: y1cc's preprocessor has no #if) |
c/host16.c |
the check build y1cc16: #define int unsigned short and -funsigned-char |
The I/O interface (c/io.h): io_argc(), io_arg(i, buf, max) (the command line); io_open(path),
io_getc(h) (0..255, 256 at the end), io_close(h) (source files); io_find(name, from, out, max) (an
#include: beside the including file, then the library directory, with a canonical path so each file is included
once); io_create(path), io_put(section, c), io_finish() (the output in three sections — code, data, uninitialised
data — concatenated at the end; the host writes the file only when the compile succeeded, as y1cc.py does);
io_out(c) (the -l summary), io_fail(msg) (message, exit status 1), io_date(buf) (the header's timestamp). All
plain ints and char buffers, no negative numbers. On the host the library directory is $Y1CC_LIB, else ../lib
beside the executable (= software/compiler/lib, where y1cc.py looks). On Y1/OS (target_io.c) the command line is
the argstr() tail, #include falls back to /LIB, code goes straight into the output file while data and BSS wait
in RAM (Y1/OS has one write handle), there is no clock (the date is 0000-00-00 00:00) and io_fail HALTs (no exit
syscall yet).
Rules it keeps (so it means the same compiled by cc and by y1cc): int is 16-bit unsigned on the YACC1 and
32-bit signed on the Mac, so every value stays in 0..65535 — no negative numbers or -1 sentinels ("none" is 0, the
end of a file is 256), wrapping arithmetic is masked (& 65535, products through mul16), no loop runs to 65535;
char is unsigned there and signed here, so bytes read back from char arrays are masked with & 255; no casts, no
long, no function pointers (y1cc.py's lambdas became mode numbers), no goto, no do ... while, no struct at all
(parallel arrays), no adjacent string literals, no #if; every local declared at the top of its function (y1cc does
not see declarations inside a switch body); every function defined or prototyped before use (the prototype block
at the top). Recursion is y1cc's (since 2026-09-24): the parser and the code generator are recursive, and no address
of a local ever goes into a recursive call — every buffer that crosses a call is a global, and functions that return
two or more values leave them in globals (fv, tb/tp, sl_*, sm_*...), as their callers read them at once.
y1cc16 (host16.c: int = unsigned short, unsigned char, the same trick tests/compiler/host_shim.h
plays for the test oracle) runs the whole corpus through the YACC1's integer types on the Mac: arithmetic is still
promoted to the host's int, so it does not model every 16-bit wrap, but it found a real bug on its first run —
for (i = k; i <= 65535; i++) (emitting up to three DECRs) never ends with a 16-bit int.
How it differs inside (the output does not): y1cc.py lexes the whole file and keeps every line of code until
the end; y1cc.c streams both. The lexer reads through a 4-byte lookahead per open file (an #include pushes a
file) and the parser looks at most 2 tokens ahead and 1 back; the peephole pass keeps only the lines that a later
line can still change — a run of BR lines, which a following label can delete (the four rules delete or replace
only the NEW line otherwise, so the rewrite system is confluent and the streaming result equals y1cc.py's
repeat-until-no-change passes). The whole program's AST is kept (as in y1cc.py: the call graph, dead functions and
recursion need every body before any code is generated) and the nodes made while generating a function are freed
after it. Generated labels (Lend12) are a number plus a prefix remembered per function; the labels derived from
names (g_x, f_main, main_i) are the only ones checked for case-folded uniqueness (they always contain _, the
generated ones never do). Where the two can still disagree, on invalid or odd input only: with several errors in a
program y1cc.c may report a different one first (it lexes as it parses; and with bad funcaddr()s in several
functions it reports them in function order, where y1cc.py takes the order of the first definitions, and in one
function the first in the text where y1cc.py puts a malformed one first - the multi-pass compiler, below, follows
y1cc.py in all of these); source must be ASCII (Python decodes UTF-8, y1cc.c sees bytes); a #define value over
65535 is masked at once; *x of a non-pointer does not go to pointer depth -1; a function defined twice (below).
Limits (limits_host.h, each overflow is a clean "y1cc: too many ... (NAME)" error): 4,000 names (32,000
bytes), 2,000 distinct string literals (32,000 bytes, 1,024 per literal), 40,000 AST nodes, 3,000 variables, 512
functions, 64 structs with 512 members, 4,000 derived labels (40,000 bytes), 2,000 generated labels per function,
8 levels of #include and 64 files, 64 nested loops, 512 cases per switch. Enough for the corpus and for y1cc.c
itself. limits_y1.h is a small illustrative set for the Y1/OS build (about 41K of tables): a native compiler will
size its tables per pass.
The twin test tests/compiler/twin.py compiles the whole corpus (tests/compiler/corpus.py, 121 compiles on
2026-09-24: the compiler tests in four option sets, three --vector builds, the bench sources, os/y1os.c, every
/BIN command, tests/os programs, and c/target.c — y1cc.c compiling itself) with both compilers and requires
identical assembly (the header's timestamp masked), identical -l summaries and identical error messages for the
expected-error tests. 2026-09-24: 117 programs identical, 4 identical errors, 0 different, with y1cc and with
y1cc16; 126 and 4 once the nine passes joined the corpus. It runs in make check and make cc-test; --chain and
--chain16 run the same comparison against the multi-pass compiler (below). tests/compiler/twinfuzz.py [N] [--seed S] adds random
programs (every operator, type and statement shape of the subset mixed, recursion, switch tables, struct members,
pointer arithmetic, initialisers, --boot/--os/--no-brur/--vector) and a fixed list of 60 invalid programs
whose error messages must match: 2026-09-24, seeds 1-3, 1,400 random programs identical (or the same error) and 60 of
60 error messages, 0 different. Writing the twin found two y1cc.py crashes, now fixed in y1cc.py: a string literal
right after an expression (puts("a" "b"), a Python TypeError; now the ordinary "expected ')'" error,
tests/compiler/adjstr.err) and 0x without digits (a ValueError; now "bad hex constant").
y1cc.c compiled by y1cc.py (make target, the proof that it is in the subset): no errors, 45,173 lines of
assembly, code 75,445 + data 6,900 = an 82,345-byte image (plus about 41K of tables with limits_y1.h), 2.5
times the 32K program area ($5000-$CFFF) for the image alone and over the 64K address space; so the assembler cannot
take it (every label past $FFFF is an error; its label table held 1,000 labels until 2026-09-24 and crashed on this
one's 3,817 - it holds 8,191 now and says so when full), and the bytes
are counted from the assembly with yacc1.def's instruction lengths — a count checked against the assembler's
"Object Code" on all 116 corpus programs that assemble (exact on every one). Where the code goes: the code generator
56,040 bytes (gen_call 4,484, gen_bin 2,821, gen_program 2,793, gen_expr 2,257, walk 2,043, gen_stmt
1,988, const_data 1,729, type_of 1,513, gen_cond 1,404), the parser 8,749, the lexer 6,549, the runtime text
(emit_runtime) 2,257, the Y1/OS I/O with lib_fs.c 1,603; the data is mostly message and instruction strings.
Recursion costs about 4,100 bytes of it (124 call sites through rt_fsave/rt_frest, 190 inline word saves). That is
about 24 bytes of code per line of C.
The road to native (BACKLOG "C compiler"; where it stands in "The multi-pass compiler", below):
1. Self-compile on the host — done: y1cc.c compiling target.c gives the same assembly as y1cc.py (the twin
corpus), so the C compiler already compiles itself, on the Mac.
2. Split into passes that each fit the 32K area with their tables — done 2026-09-24: nine passes, below.
3. A bigger stack — measured, placed, not built: below, "The stack".
4. Y1/OS support: an exit syscall (io_fail and io_done HALT today), files over 64K, a way to run nine
programs in a row, /LIB/y1ccrt.txt on the disk: below, "What is left for native".
5. The on-target assembler (BACKLOG wave 3): the compiler emits assembly text; the machine needs an assembler
(the host assembler's label table now holds 8,191, enough for any single pass) before a program compiled on it can run.
Done 2026-09-25: /BIN/ASM (os/commands/asm.c, docs/programming/ASSEMBLER.md section 10) is byte-identical to
the host assembler on the whole corpus, and its symbol table holds cc8's 1,326 labels; on the emulator it has
assembled pass cc4 (--xisa, 58,585 bytes, the one pass under 64K) under Y1/OS (tests/asm/target.py).
2026-09-26: /BIN/ASM is the same assembler hand-written in YACC1 assembly (os/commands-asm/asm.asm, about five
times faster, a 20,292-byte label table); asm.c, its specification, is /BIN/ASMC.
6. Native self-host — done 2026-09-25: under Y1/OS on the emulator the native compiler and assembler rebuild all
nine passes, /BIN/ASM and /BIN/CC byte-identical to the host builds, and the rebuilt tools do it again
identically (below, "Self-host").
The multi-pass compiler (2026-09-24)¶
y1cc.c compiled by y1cc.py is an 82K image; the Y1/OS program area is 32K ($5000-$CFFF) for a program's image, its
uninitialised data (the tables, and every function's static frame) and its stack. So the compiler is split into
nine programs run one after the other, each passing the next its work in files. Each is written in the y1cc subset
(the rules of y1cc.c, above: C89 on the Mac with -Wall -Wextra -pedantic clean, a 16-bit check build, compiled by
y1cc.py), each is y1cc.c's code for its part of the work - the same functions under the same names wherever the part
is the same - and the nine chained produce y1cc.py's assembly byte for byte.
make -C software/compiler/c # cc1..cc9, cc1_16..cc9_16, the drivers y1ccp, y1ccp16
software/compiler/c/y1ccp prog.c -o prog.asm [--org N] [--boot] [--vector] [--no-brur] [--os] [-l] # as y1cc.py
Y1CCP_KEEP=dir software/compiler/c/y1ccp prog.c ... # the intermediate files kept in dir (dir/w.*)
python3 tests/compiler/twin.py --chain [--16 | --chain16] # the corpus: y1cc.py against the chain
python3 tests/compiler/twinfuzz.py 500 --seed 1 --chain # random programs and the error list, likewise
python3 tests/compiler/passes.py [-v] # the sizes, the stack, the capacity (below)
y1ccp (c/y1ccp.c, host C) takes y1cc.py's command line, makes a temporary directory W, runs cc1 W <the
command line>, then cc2 W ... cc9 W, stops at the first pass that fails (that pass printed the message) and
removes the files. On Y1/OS (2026-09-25) /BIN/CC does the same with the syscall EXEC: "Native", below.
The passes¶
| pass | y1cc.c's part | reads | writes |
|---|---|---|---|
cc1_lex.c |
y1cc_main's options, the preprocessor (#define, #include), the lexer |
the source, its includes | W.opt options, W.tok tokens, W.nam names, W.lit string literals |
cc2_parse.c |
the parser, constant folding | W.tok |
W.ast one record per top-level declaration, W.typ types |
cc3_decl.c |
gen_program to the check for main(): structs, the function table, the globals, their data |
W.ast (3 readings), W.typ, W.nam, W.lit |
W.dat the global data (a stream), W.s1 symbols |
cc4_calls.c |
build_reach (main must not be recursive), the funcaddr() roots, the live functions |
W.ast (2), W.s1, W.nam |
W.cg live functions + the reach matrix |
cc5_layout.c |
the labels (ulabel) of the globals, functions, parameters and locals, layout_func, the frames |
W.ast (2), W.s1, W.cg, W.typ, W.nam |
W.sym symbol tables, W.lab the labels' owners |
cc6_stmt.c |
compile_func, gen_stmt, gen_switch's case labels: every expression becomes a hole |
W.ast (2: main first), W.sym |
W.st a stream: labels, branches, holes |
cc7_sema.c |
the analysis gen_* asks of every node: fold, type_of, type_lval, vinfo, struct_member, sizeof, the call graph walks, escapes |
W.st, W.sym, W.cg, W.lit |
W.se the stream, every hole's tree annotated |
cc8_emit.c |
gen_expr, gen_assign, gen_bin, gen_cond, gen_call...: each hole's code |
W.se, W.sym |
W.em the stream, instruction records |
cc9_final.c |
the text: labels numbered, the macros (add_const, branch_rel, frame_save, switch_table...) expanded, the peephole, strings at first use, the runtime (from lib/y1ccrt.txt), the three sections |
W.opt, W.dat, W.em, W.lab, W.nam, W.lit, W.sym |
the .asm file, the -l line |
Shared source: pdefs.h (every number: token and node kinds, record codes, mnemonics, macros...), pcommon.c
(strings, errors, the file primitives: bytes, words, strings, whole table columns), pnames.c (the names' text),
past.c (reading W.ast), plabel.c (a label's text from its owner); the I/O is io.h as for y1cc.c, grown by
io_wopen/io_wput/io_wclose (one file written at a time, as Y1/OS allows), io_done and io_lib, and on
2026-09-25 io_wputs (a string) and io_skip (bytes read past), for the buffered Y1/OS layer. The builds:
hostp.c / host16p.c (a pass on the Mac and its 16-bit check build, -DPASS="cc1_lex.c"), plim_host.h (the
Mac's table sizes), ylim/NAME.h and target/NAME.c (each pass's Y1/OS table sizes and its Y1/OS translation unit,
as target.c is y1cc.c's), stackprobe.c (passes.py's stack probe), lib/y1ccrt.txt (the runtime helpers' text).
Each file's header comment gives its formats in full; the ideas that make the split exact:
- The whole-program work never loads a function body. cc2 writes into each function's record the calls it makes
(in the order y1cc.c's
walk()meets them) and its declarations (incollect_decls()order), so the call graph (cc4) and the layout (cc5) read short lists; only cc6 loads a body, one function at a time. - Holes. The statement pass allocates its labels and writes the branches around each expression, and in the
expression's place a hole holding the expression's tree (renumbered 1..n: the biggest in the corpus has 31
nodes). cc7 adds to every node what the code generator will ask about it, cc8 replaces the hole by the code. A
generated label is a symbol (cc6's from 1, cc8's from 32768) announced by an
R_ALLOCrecord at the moment y1cc.c'slbl()would have run; cc9 numbers them in stream order, soLend12gets the same 12. - Errors come out in y1cc.py's order. y1cc.py lexes the whole source first (so cc1 stops at a lexer error, as
it does), then parses, then generates each function's statements and expressions in one walk: a statement error
(a
breakoutside a loop) comes after any error in an expression before it. So cc6 writes a statement error into its stream and stops (cc8 raises it once the expressions before it are done), and cc7 never stops at an error: it records it with the attribute ("poisoned": code and names) and cc8 raises it when its code generator asks for that attribute, which is when y1cc.py would have failed. The fuzzer's error list (60 programs), every random program that fails, and a list of programs with two errors in different passes give the same message as y1cc.py - better than y1cc.c, which lexes as it parses and can report a parse error before a lexer error further on. - No label text is kept. A label is y1cc.c's
ulabel(want): the want sanitised, cut to 29 characters, or to 24 and_nafter n labels equal to it ignoring case. cc5 remembers each label's owner and n and makes the text again when it compares (plabel.c); cc9 does the same when it prints. The globals' labels moved from the declarations to cc5, in the same order. - Fixed sequences are macros. cc8 writes
M_ADDK k,M_BREL rel label ...,M_FSAVE var bytes,M_SWITCH ...and cc9 expands them with y1cc.c's code (and allocates their labels then, in the same order). - Addresses keep y1cc.c's text: a label and its
+kterms as written (g_s+2+4, notg_s+6), a constant index times the element size as a 32-bit number (g_a+131070fora[-1]of an int array, as y1cc.py prints it). - Column files. The symbol tables go to and from the files a column at a time (
warr/rarr), and each pass reads only the columns it needs: a statement per field costs y1cc's code model far more.
Found on the way: y1cc.c's switch_table counts v from lo to hi, which never ends in 16 bits when hi is 65535;
cc9 counts the table's entries instead. Known differences from y1cc.py, both on programs y1cc.py cannot compile
into working assembly, none in the corpus or produced by the fuzzer: an object over 65,535 bytes (int a[40000];
the address space is 64K) - y1cc.py prints its size whole (DS 80000, sizeof 80000), the passes, written for
16-bit ints, mod 65536 (14464), as y1cc16 does (y1cc.c on the Mac prints it whole); and a function defined twice -
y1cc.py lays out both definitions and compiles the last one twice under the second label (f_f_1: twice: the
assembler rejects it), y1cc.c and the passes compile it once as f_f. (Better: y1cc.py reports the redefinition as
an error, like a global declared twice - BACKLOG.)
Sizes against the program area¶
tests/compiler/passes.py compiles each pass with y1cc.py as a Y1/OS program (c/target/NAME.c: its Y1/OS table
sizes c/ylim/NAME.h, the Y1/OS I/O layer c/target_io.c, the pass), assembles it with the host assembler (0
errors; the label count), counts code and data (checked against the assembler's Object Code) and the uninitialised
data (tables, static frames), and measures the stack (below). 2026-09-24, in bytes:
| pass | code | data | image | tables | stack | total | free of 32,768 | labels |
|---|---|---|---|---|---|---|---|---|
| cc1 lex | 12,434 | 1,443 | 13,877 | 17,771 | 90 | 31,738 | 1,030 | 880 |
| cc2 parse | 16,893 | 696 | 17,589 | 11,733 | 1,202 | 30,524 | 2,244 | 967 |
| cc3 decl | 8,961 | 977 | 9,938 | 12,143 | 86 | 22,167 | 10,601 | 528 |
| cc4 calls | 5,809 | 430 | 6,239 | 12,131 | 84 | 18,454 | 14,314 | 382 |
| cc5 layout | 8,908 | 540 | 9,448 | 17,003 | 84 | 26,535 | 6,233 | 578 |
| cc6 stmt | 11,921 | 661 | 12,582 | 19,219 | 592 | 32,393 | 375 | 663 |
| cc7 sema | 16,974 | 440 | 17,414 | 14,613 | 195 | 32,222 | 546 | 910 |
| cc8 emit | 25,197 | 1,427 | 26,624 | 3,617 | 424 | 30,665 | 2,103 | 1,326 |
| cc9 final | 16,714 | 1,930 | 18,644 | 13,749 | 100 | 32,493 | 275 | 1,178 |
(2026-09-25, built as the native compiler is, with --stack 0xCFFF, and with target_io.c's upper-case /LIB names:
free cc1 717, cc2 2,231, cc3 10,588, cc4 14,301, cc5 6,220, cc6 201, cc7 525, cc8 2,082, cc9 211; the stack
prologue and epilogue cost cc6 135 bytes of code, the other passes 11.)
(Updated after --xisa, same day: cc6 grew by the page planning (2.9K of code, 3.3K of tables), cc9 kept its room
because five of its macros became cc8's instructions; the table before it had cc6 at 26,206 and cc9 at 244 free. The
same passes compiled with --xisa are in the section "--xisa" above: 101,884 bytes of code instead of 123,811.)
(2026-09-25, the passes as the native compiler is built - --xisa, --stack 0xCFFF - after the buffered I/O and
the speed work, passes.py: code, image, tables, free: cc1 12,134, 13,733, 18,631, 314; cc2 14,892, 15,630, 12,271,
3,663; cc3 7,115, 8,134, 12,771, 11,775; cc4 5,090, 5,562, 14,034, 13,086; cc5 7,481, 8,063, 18,110, 6,509; cc6
9,886, 10,611, 20,347, 1,214; cc7 13,614, 14,106, 17,152, 1,313; cc8 22,470, 23,949, 4,026, 4,365; cc9 15,927,
17,899, 14,302, 463. Before them cc1 had 1,417 free, cc9 1,539, cc6 1,991, cc7 2,257. The buffers take 128-256 bytes a
pass; VARS_MAX went from 380 to 400 in cc5-cc7 and cc9, because the passes' own sources (they compile themselves)
now have 384 variables in cc8's. With --xisa the tables start on a page boundary, so a pass's room moves in steps
of 256 as its image crosses one: cc9's image is 21 bytes below the next, cc1's 91.)
Every pass fits. The nine together are 123,811 bytes of code against y1cc.c's 75,445: each carries the shared
utilities and file code, and the work split across passes costs its intermediate records. What made them fit: the
runtime helpers' text moved into lib/y1ccrt.txt (about 3K out of cc9); the call graph and the layout read the call
and declaration lists instead of bodies (they had to hold whole functions); the labels made again from their owners
(cc5 and cc9 each held all the label text, 5-8K for the bigger programs); the symbol and tree files a column at a
time (1.5K of code each in cc7 and cc8); cc1 passing on only the names that become tokens (the pass sources intern
about 700 names, about 360 of them #defines that later passes never see); cc7's name lookups hashed instead of an
array per name.
The table sizes (c/ylim/; c/plim_host.h has the Mac's, far larger) are set so that the whole corpus except
y1cc.c, and the nine passes' own sources, compile: 760 names in cc1 (5,400 bytes of text, #defines included), 400
identifiers after it (2,600 bytes), 170 functions, 380 variables (128 globals), 160 string literals (1,800 bytes),
920 AST nodes in one function (the biggest function of the passes, in cc8, has 856), 40 nodes in one expression
(the corpus has 31), 200 generated labels in one function, 64 cases in a switch. passes.py builds the passes on the
Mac with exactly these sizes (only the path pool is larger: Mac paths are absolute) and compiles the corpus through
them: 125 identical to y1cc.py, 4 identical errors, and y1cc.c the one program that does not fit (833 names,
130K of source). So the chain can compile itself: each of the nine passes, compiled by the nine, is identical to
what y1cc.py makes of it.
The stack¶
y1cc gives every local a fixed address; the stack holds only return addresses, pushed operands and parked
arguments, and the frames saved around a call inside a recursive cycle. passes.py measures how deep that goes:
it reads, in each pass's y1cc assembly, the bytes each function has on the stack at each of its calls (return
address included; a runtime helper, the Y1/OS I/O layer and a syscall are counted there, a syscall with an allowance
of 64 bytes for the OS, a ROM routine 16), builds the pass on the Mac with -finstrument-functions
(c/stackprobe.c), and replays the YACC1 stack on every call while the corpus and the passes' sources compile.
The deepest points are the "stack" column. They grow with nesting: about 144 bytes per level of parentheses in cc2
(its recursive descent: ((((1)))) 12 levels deep takes cc2 to 2,004 bytes), 62 per level of operators in cc8
(x + (x + (...))), 28 in cc7, 16 in cc6; the other passes do not recurse on the input.
Where it goes (built 2026-09-25): at the top of each pass's own program area, growing down from $CFFF towards
its tables, which end at $5000 + image + tables; the "free" column is the room beyond the deepest point measured
(cc2: about 15 more levels of parentheses than the corpus uses, cc8 about 45 levels of operators). Not the
monitor's $0C00-$0EFF: that 768 bytes is shared with the shell that runs the program and with every syscall, and
cc2 alone needs 1,202 on the corpus. y1cc's --stack ADDR ("How the generated code works"
above) was chosen (2026-09-25) over Y1/OS giving every program the area's top: os/Makefile passes builds each pass with --stack 0xCFFF
(build/cc/NAME.bin), and passes.py measures that build. On the emulator, tests/native/run.py runs the passes
under Y1/OS with the program watch (emulator -S) and checks that each one's deepest point stays above its last
byte of data (below, "Native").
What was left for native (all done 2026-09-25)¶
- Files over 64K (done: Y1/OS positions and lengths are 24 bits, files up to 16M). 111 of the corpus's 125
compiles kept every intermediate file and the output under 64K; y1os.c (
W.se174K, its assembly 152K), md.c, awk.c, grep.c, vi.c and the passes compiling themselves (their assembly 68-259K) did not. - Open files (done:
c/target_inc.c, the lexer's reads). Y1/OS has four handles, one of them the file being written, so cc1 can keep only three sources open, and the /BIN commands nest five (cat.c, lib_stdin.c, lib_globx.c, lib_fs.c, lib_abi.c): a virtual handle per file, the innermost three real; the outermost is closed and later opened again and SEEKed (the new syscall) to where it was. - Exit, and nine programs in a row (done: the syscalls EXIT and EXEC, SYSTAB2 for them;
/BIN/CC),/LIB/Y1CCRT.TXTon the disk (done), room on the disk for the intermediate files (cc8's source compiling itself: 740K; the emulators' card grows, a real card is far bigger), and the stack (done:--stack, above). - The passes run under Y1/OS, one by one and chained (below). Still open: the code size work (every byte y1cc saves
shrinks the passes too) - since 2026-09-25 the passes are built with
--xisa, because with the chaining and the lexer's include stack cc1, cc6 and cc9 no longer fit without it; they need the 2026-09-24 microcode on the machine.
Native: the compiler on the emulated YACC1 (2026-09-25)¶
cc /SRC/FIB.C -o FIB.ASM --org 0x5000 --os # under Y1/OS: y1cc's command line, the nine passes chained
asm FIB.ASM # /BIN/ASM: the program file FIB (asmc: the C build, the same)
run FIB
- On the disk (
make -C os):/BIN/CC(os/commands/cc.c), the passes/LIB/CC/CC1..CC9(make -C os passes:c/target/NAME.ccompiled with--org 0x5000 --os --stack 0xCFFF --xisa),/LIB/Y1CCRT.TXT(the runtime text cc9 reads) and/LIB/Y1LIB.C.#include "name"looks beside the including file, then in/LIBwith the name upper-cased (target_io.cio_lib), as the Makefiles put files on a disk. - The chain:
ccEXECs/LIB/CC/CC1withCCW+ its own command line; every pass ends (target_io.cmain, orio_doneafter a deferred error) by EXECing the next one, named in itstarget/NAME.c(io_next_pass), with the work prefixCCWas its whole command line; cc9 returns to the shell. A pass that finds an error prints it on the screen (lib_err.ceputs, so not into a>file) and EXITs with status 1, which ends the chain. The work filesCCW.opt..CCW.emstay in the current directory (the next compile replaces them; pack gets the space back). - The I/O layer:
c/target_io.c(every pass: the command line,/LIB, the output file, EXIT/EXEC) withc/target_rd.c(plain reads: passes 2-9) orc/target_inc.c(the lexer: a stack of virtual handles, the three innermost real, the others closed and SEEKed back to on the way out; the /BIN sources nest five#includes). Buffered since 2026-09-25 (a syscall per byte read or written was a third of a compile):io_getctakes the next byte from a buffer ofIO_RBbytes that the syscall READN fills (Y1/OS's 25th,os/README.md: up to n bytes, never past a sector);io_wputcollectsIO_WBbytes for one WRITE,io_wputsa whole line (cc9).IO_RBandIO_WBare in each pass'sylim/NAME.h(64 or 128; cc1 and cc9, the fullest, 64). One read buffer is enough: a pass reads one file at a time (target_rd.c: the buffer belongs to the handle read first; another one, only ever read on the way to an error message, goes byte by byte), and the lexer switches files only at an#includeand at an included file's end (target_inc.c: the file it leaves gives back the bytes it has not had and is SEEKed there when it is read again).io_skip(io.h, for the passes that skip function bodies inW.ast) moves through the buffer a block at a time. The files' bytes are the same as before. - First run, 2026-09-25: target_io.c, compiled since 2026-09-24 and never run, worked at once: the passes run
one by one with the shell's
runcompiled hello, fib, sieve, calls, globals, chars, structs, switch, rfact and stack byte-identically to y1cc.py (the header's date is0000-00-00 00:00: Y1/OS has no clock); then chained withcc, andcat.c(five nested#includes) likewise. hello takes 5.7M instructions, the others 23-39M, cat.c 92M; cc1 (the lexer, a syscall per source byte) and cc9 (the text, a syscall per output byte) take most of it. - The stack:
emulator -Sreports each pass's lowest SP; the deepest on the ten was cc2 at $CC8F (880 bytes, rfact's expressions), everything else under 250 bytes, and every pass kept at least 1,476 bytes between its stack and its last byte of data (cc1; 272 before the passes were built with--xisa). Since the buffered I/O and the speed work (2026-09-25) the least is 373 bytes (cc1), then cc9 518 (passes.py: 314 and 463 free, below). - The test (
tests/native/run.py, inmake checkandmake native-test): 27 compiles on the instruction-level emulator, 4 of them (hello, fib, echo, cat) also on the microcode emulator (--all-uc: all 27, 27 of 27 on 2026-09-25 in 1.53G instructions, 25.4G steps, 49.2G clocks: 13.7 hours at 1 MHz, 18 minutes of emulation): everytests/compilerprogram that compiles (with its own flags:--stack,--no-brur,--xisa), fib again with--xisa, the/BINcommands hello, echo, cat and wc, and the compiler's own pass 4 (c/target/calls.c: the native compiler compiling itself). Each is compiled byccin its source directory (the sources are on the disk under/Ras in the repository), assembled by/BIN/ASMand, when it can run under the OS, run with its output redirected to a file. The host then checks, 27 of 27 on 2026-09-25: the assembly is byte-identical to y1cc.py's (the date masked), the program file byte-identical to the host toolchain's (y1cc.py + the host assembler - img2bin; the four commands also to the Makefile's
/BINbuilds), and the output is the test's.out(or, for a command, what/BIN's own build prints in the same session). The deepest stack of any pass kept 1,474 bytes of room above its data (cc1); 373 since 2026-09-25's buffers (above). - How long (instructions on the instruction-level emulator; the microcode emulator counted 16.6 steps and 32.1
clocks an instruction on hello, fib, echo and cat before 2026-09-25's speed work, 16.7 and 32.4 after, so at 1 MHz
an instruction is about 32 us). Before and after the buffered I/O and the passes' hot spots (2026-09-25, the same
27 programs in the same session,
tests/native/run.py --int):
| program | compile before | after | assemble before | after | all | at 1 MHz before | after | compile alone |
|---|---|---|---|---|---|---|---|---|
| hello.c (9 lines) | 5.8M | 3.5M | 0.9M | 0.8M | 1.5x | 3 min 32 s | 2 min 20 s | 1.6x |
| fib.c | 28.1M | 10.9M | 5.8M | 5.5M | 2.1x | 18 min 09 s | 8 min 51 s | 2.6x |
echo.c (/BIN/ECHO) |
11.7M | 5.1M | 0.6M | 0.6M | 2.2x | 6 min 36 s | 3 min 05 s | 2.3x |
cat.c (5 nested #includes) |
92.4M | 31.3M | 13.6M | 12.9M | 2.4x | 56 min 40 s | 23 min 50 s | 3.0x |
| wc.c | 107.7M | 36.4M | 16.8M | 16.0M | 2.4x | 1 h 06 min | 28 min 18 s | 3.0x |
| xisa.c (the biggest test) | 132.9M | 50.0M | 28.4M | 27.3M | 2.1x | 1 h 26 min | 41 min 44 s | 2.7x |
pass 4 of the compiler (target/calls.c as of 2026-09-25 morning, 60K of assembly) |
216.8M | 72.6M | 35.2M | 33.5M | 2.4x | 2 h 14 min | 57 min | 3.0x |
| all 27 | 1,269M | 468M | 225M | 217M | 2.2x | 13 h 19 min | 6 h 09 min | 2.7x |
(Pass 4's row compiles the same source both times; the test compiles the tree's own calls.c, which the I/O work
itself grew: 78.8M + 35.7M now.) The microcode emulator's clocks agree: hello, fib, echo and cat with their runs
took 5.26G clocks before, 2.45G after (2.1x).
Where the time went, and what was done (tests/native/profile.py: the emulator's PC histogram, emulator -P,
turned into instructions per function of every pass, the assembler and the OS):
- The OS's byte path: GETC/PUTC were about 100 instructions a byte with the syscall, the handle checks and
the 24-bit position, a fifth of all the time and most of cc3-cc8's; now READN and WRITE copy a sector's bytes at
7 instructions each (os/README.md) and the passes' buffers cost about 30 (io_getc's fast path).
- cc9 made every code line's text three times, once per section, and dropped it twice: it now reads past an
instruction record in the data and BSS readings (r_skip). Its peephole parsed each line three times (now once:
the pending line's words are kept); bnum did two divisions a digit (now subtraction by powers of ten, pnum);
bcat/bchr/s_len indexed (now pointer walks, and the instruction builders append at the line's end, Ls);
lcand built a label with a bchr per character. cc9: 33.5M -> 9.2M on cat.c.
- cc1 kept the lookahead in an array indexed by the include depth (a third of the lexer's time; now three
variables, saved and restored at an #include), tried all 45 operators for every punctuation mark (now a chain
per first byte, op_chains), and hashed every name with a multiply and a division per character (now 5h + c
and a mask: the tables are 128/256 entries). cc1: 22.4M -> 5.1M on cat.c.
- cc2: tk/tv/is_op/is_kw call need_tok only when the token is not read yet (a third of the parser's
time); cc4: the reach matrix's closure walks row pointers (it multiplied by the row length per bit and per
byte); cc5: lhash as cc1's; rarr/warr and the skips through pointers and io_skip.
- /BIN/ASM: getln tests a character against ;, : and the quotes only when it is at most ; (5% of the
assembler). The assembler was then the biggest single step (30%): getln (a third of it: each source byte is
copied, upper-cased and parsed, about 50 instructions), hash, asmcmd, same; the assembly text itself
(cc9 writes 60-260K and the assembler reads it twice) is the design's cost.
2026-09-26: /BIN/ASM in YACC1 assembly (os/commands-asm/asm.asm, the same behaviour byte for byte; the C
build is /BIN/ASMC; docs/programming/ASSEMBLER.md section 10): the same 27 programs,
| | assemble | compile and assemble | at 1 MHz | the assembler's share |
|---|---|---|---|---|
| `/BIN/ASM` = asm.c (2026-09-25) | 216.5M | 687.4M | 6 h 11 min | 31% |
| `/BIN/ASM` = asm.asm (2026-09-26) | 46.5M | 517.4M | 4 h 39 min | 9% |
(cat.c: 12.9M -> 2.8M to assemble; pass 4: 35.7M -> 7.5M.) The compiler's passes are now 90% of a native build.
- Left: each pass's load by the ROM and
main's BSS clear (about 2M instructions a compile: half of hello's time), cc9's text building (mn_arg,Ls,bcat: about 20% of a compile), cc2's parser, cc7's and cc8's table and tree records (wi/ria byte at a time throughio_wput/io_getc).
Self-host: the toolchain rebuilds itself on the emulated YACC1 (2026-09-25)¶
Yes, the compiler compiles itself natively. tests/native/selfhost.py (make selfhost, and in make check:
about 30 seconds on the Mac) runs, under Y1/OS on the instruction-level emulator, with the toolchain's own sources on
the disk under /R as in the repository (the pass sources, c/target/, c/ylim/, os/lib_*.c, os/asm_optab.c,
os/commands/asm.c and cc.c, and since 2026-09-26 os/commands-asm/asm.asm with asmtab.inc; /LIB/Y1LIB.C and /LIB/Y1CCRT.TXT are on the OS disk):
- Stage 1, the native build: the host-built
/BIN/CC+/LIB/CC/CC1..CC9compile each of the nine passes (c/target/NAME.c,--org 0x5000 --os --stack 0xCFFF --xisaasos/Makefilebuilds them), the assembleros/commands/asm.cand the driveros/commands/cc.c(--org 0x5000 --os, the/BINflags), each in its source directory; the host-built/BIN/ASMassembles each into a program file. The host fetches the eleven assemblies and program files off the image and compares them withos/build(y1cc.py + the host assembler + img2bin). - Stage 2, the fixed point: a fresh OS disk with the natively built CC1..CC9, CC and ASM installed in place of the host builds, and the same eleven compiles and assemblies again.
Result, 2026-09-25, the first run: all eleven identical to the host builds in both stages (assembly byte for
byte with the header's date masked, program files byte for byte), so the natively built toolchain reproduces itself
exactly. Nothing had to change: every table held (cc1's names, cc8's 1,383 labels in /BIN/ASM's 17,088-byte pool,
asm.c's 117K of assembly), every pass kept its stack above its data (the least room: cc1 373 bytes, cc9 522, cc7
1,333, cc6 1,259), and the 24-bit files took the biggest (cc8's source: work files 514K, CCW.se 248K of them, and 235K of assembly).
| program | assembly | program file | compile | assemble | both | at 1 MHz |
|---|---|---|---|---|---|---|
| cc1 lex | 148K | 13,733 | 172.0M | 82.8M | 254.9M | 2 h 17 min |
| cc2 parse | 161K | 15,630 | 175.4M | 94.1M | 269.5M | 2 h 25 min |
| cc3 decl | 90K | 8,134 | 114.3M | 51.6M | 165.8M | 1 h 29 min |
| cc4 calls | 65K | 5,562 | 84.5M | 37.9M | 122.4M | 1 h 06 min |
| cc5 layout | 90K | 8,063 | 117.6M | 53.5M | 171.1M | 1 h 32 min |
| cc6 stmt | 115K | 10,611 | 134.6M | 65.8M | 200.4M | 1 h 48 min |
| cc7 sema | 157K | 14,106 | 172.1M | 90.4M | 262.5M | 2 h 21 min |
| cc8 emit | 230K | 23,949 | 261.0M | 129.7M | 390.8M | 3 h 31 min |
| cc9 final | 172K | 17,899 | 224.9M | 97.0M | 321.9M | 2 h 53 min |
| /BIN/ASM (asm.c) | 117K | 13,186 | 131.7M | 58.5M | 190.2M | 1 h 42 min |
| /BIN/CC (cc.c) | 5K | 498 | 13.1M | 2.5M | 15.6M | 8 min |
| the toolchain | 1.35M | 131,371 | 1,601M | 764M | 2,365M | 21 h 17 min |
(Instructions on the instruction-level emulator, emulator -S; the same in stage 2, whose tools are the same bytes.
At 1 MHz with 32.4 clocks an instruction, tests/native/run.py's ratio. The microcode emulator ran stage 1 as well
(selfhost.py --stage 1 --uc, 30 minutes on the Mac): all eleven identical, 2,378M instructions with the boot and
the shell, 40.9G steps (17.2 an instruction) and 79.3G clocks (33.4 an instruction): 22 hours at 1 MHz,
measured.) So on the machine one full rebuild of the toolchain by itself would take most of a day; the fixed-point
check doubles it. Where the time goes is as for the other native compiles
("How long", above): cc9's text and the lexer; /BIN/ASM was a third (getln, hash).
2026-09-26: /BIN/ASM in YACC1 assembly. The self-host now rebuilds twelve programs: the eleven above (asm.c's
build installed as /BIN/ASMC) and the hand-written assembler os/commands-asm/asm.asm, which the native /BIN/ASM
assembles in its own directory (its INCLUDE asmtab.inc); stage 2 installs the native builds of both assemblers,
and all twelve come out identical again: the fixed point holds with the assembly-language assembler. One stage:
| program | assemble with asm.c | with asm.asm | faster | both, at 1 MHz: before | after |
|---|---|---|---|---|---|
| cc1 lex | 82.8M | 16.6M | 5.0x | 2 h 17 min | 1 h 41 min |
| cc2 parse | 94.1M | 18.8M | 5.0x | 2 h 25 min | 1 h 44 min |
| cc3 decl | 51.6M | 10.1M | 5.1x | 1 h 29 min | 1 h 07 min |
| cc4 calls | 37.9M | 7.2M | 5.3x | 1 h 06 min | 50 min |
| cc5 layout | 53.5M | 10.2M | 5.2x | 1 h 32 min | 1 h 09 min |
| cc6 stmt | 65.8M | 12.8M | 5.1x | 1 h 48 min | 1 h 19 min |
| cc7 sema | 90.4M | 17.7M | 5.1x | 2 h 21 min | 1 h 42 min |
| cc8 emit | 129.7M | 26.4M | 4.9x | 3 h 31 min | 2 h 35 min |
| cc9 final | 97.0M | 19.9M | 4.9x | 2 h 53 min | 2 h 12 min |
asm.c (/BIN/ASMC) |
58.5M | 13.1M | 4.5x | 1 h 42 min | 1 h 18 min |
cc.c (/BIN/CC) |
2.5M | 0.6M | 4.1x | 8 min | 7 min |
| the eleven | 763.8M | 153.4M | 5.0x | 21 h 17 min | 15 h 47 min |
asm.asm (/BIN/ASM, assembled only) |
42.8M | 9.3M | 4.6x | 23 min | 5 min |
| the toolchain now | 15 h 52 min (1,764M instructions) |
(Compiling is unchanged, 1,601M; the assembler's share of a stage fell from 32% to 9%. At 1 MHz with 32.4 clocks an instruction; asm.asm itself runs at 29.4 clocks an instruction on the microcode emulator, so its hours are a little less than shown.)
- The disk: the P8XFS volume grows as the compiles write (the emulators' card model extends the image; the OS
has no size of its own, only the 16-bit free pointer: 32M at most). Each stage writes about 9,400 sectors (4.7M),
most of it work files the next compile replaces (
CCW.*: 514K for cc8's source); at the end 5,624 sectors are live (2.8M: the OS disk's 0.5M, the sources' 0.4M, the outputs and the last work files) and 5,747 reclaimable bypack. So a 1M card is too small; any real CF card (16M and up) holds a whole stage without packing. - What remains for the real machine: the CF interface for Y1/OS, the 2026-09-24 microcode (the passes use
--xisa), and a day of run time. y1cc.c, the single-program twin, still does not fit the passes' tables (833 names); it is not part of the native toolchain.
y1cc.c stays as the single-program C twin: it is what the passes were cut from, twin.py keeps it identical to
y1cc.py, and it is the quicker program to read. A change to y1cc.py now has two C counterparts to follow it; once
the passes run on the machine, y1cc.c can go.
Not done yet (BACKLOG "C compiler")¶
Running a compiled program on the real machine needs a way to load RAM (the monitor's E-command loader on the
backlog, or the bus tester with the CPU off); signed types; peephole
work (the code is straightforward, roughly 2-3x what hand assembly would be); the P8X-side libraries. (switch
done 2026-09-22, recursion 2026-09-24.)
Size against the P8X compiler¶
bench/sizecmp.sh compiles the same four programs (written in the subset both compilers accept) with p8cc + p8xasm
and with y1cc + asm and compares the binaries, uninitialised data included on both sides (2026-09-22):
| program | P8X bytes | YACC1 bytes | ratio |
|---|---|---|---|
| fib | 1075 | 813 | 0.76 |
| sieve | 760 | 667 | 0.88 |
| sort | 1114 | 896 | 0.80 |
| strings | 1030 | 782 | 0.76 |
The YACC1 binaries are 14-26% smaller for the same source (2026-09-22 evening, after main gained its 20-byte BSS clear).
The same four programs rewritten with everything y1cc accepts (bench/full/: the library's putnum, ++, +=,
?:, continue, pointer loops, char loop counters) come out only a little smaller — sieve 633, fib 769, strings
713, sort 865 bytes (1-6%) — because i++ and i = i + 1 are the same code; what saved bytes was char counters
(one-byte compares) and pointer walks. The size is in the code model, not the syntax.
The reasons are in the instruction sets rather than in
the compilers: y1cc keeps every scalar at a fixed address, so a load or store is one 3-byte LDR/STR and a
16-bit constant is one 3-byte MVIW, while p8cc's frame-relative LDW/STW (P3+d) and LDW __ax,#n (4-5 bytes)
plus its memory-word arithmetic helpers cost more per operation; the YACC1's register INCR/DECR and the
comparator branches are 1-3 bytes where the P8X needs a memory word op. Speed is another matter: a YACC1 step is
two clocks and an instruction 8-30 steps, so the emulator's instruction counts above translate to roughly 10x the
clock cycles of the same work on the P8X. Integer literals over 65535 are a compile error (int is 16-bit).