Skip to content

Commodore 900 Coherent Z8001 platform/arch#50

Open
MichalPleban wants to merge 67 commits into
PortableCC:masterfrom
MichalPleban:z8001
Open

Commodore 900 Coherent Z8001 platform/arch#50
MichalPleban wants to merge 67 commits into
PortableCC:masterfrom
MichalPleban:z8001

Conversation

@MichalPleban

Copy link
Copy Markdown

This pull request adds Z8001 segmented CPU support to pcc, with a concrete platform supporting it: Commodore 900 running Coherent 0.7.3. Per platform conventions, all pointers are always 32-bit; short mode with 16-bit pointers is not implemented at the moment.

The generated assembly code can be assembled with the original Mark Williams assembler. The code was tested on a Commodore 900 emulator and compared to assembly generated by the original MWC C compiler; with -O1, pcc now generates code that is approximately 2.5% smaller than the original. The Coherent kernel and userland utilities were compiled and verified.

Related repositories:

https://github.com/MichalPleban/commodore-900-crossdev
Coherent assembler and linker ported to gcc, necessary for pcc to produce binaries.

https://github.com/MichalPleban/commodore-900-coherent
Coherent source code, compilable under this pcc Z8001 branch

https://github.com/MichalPleban/commodore-900-emulator
Commodore 900 emulator

Michal Pleban and others added 30 commits June 30, 2026 23:29
New pass-2 backend in arch/z8001/ targeting the Coherent ABI on the
Zilog Z8001 (segmented, 16-bit, big-endian, 32-bit segmented pointers):

  macdefs.h  storage sizes, register model (word class A r0-r12,
             pair class B rr0..rr10), frame/stack macros
  code.c     prologue data directives, Coherent segment directives
             (.shri/.strn/.prvd/.bssd), .byte string emission, .file
             suppression
  local.c    pass-1 clocal, trailing-underscore name mangling, ctype
  local2.c   prologue/epilogue, register naming, COLORMAP, adrput/conput
  order.c    instruction ordering and OREG formation
  table.c    instruction selection table

OS/ABI config in os/coherent/ccconfig.h (include/library paths,
crts0.o startup, trailing-underscore convention).

Build wiring: configure.ac/configure recognise the coherent OS and
config.sub the z8001 CPU, so --target=z8001-coherent selects the
backend.

Verified end to end: a Hello World program compiles through the cross
PCC to Z8001 assembly that the native Coherent as/ld assemble and link
into a working executable, at both -O0 and -O1 (SSA).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Fixes six code-generation bugs, each verified against the Coherent
assembler/linker sources and the reference-compiler output.  With these,
the full C test surface (integer/long arithmetic, div/mod, shifts,
bitwise, pointers, control flow, switch, globals/static/arrays/strings,
char parameters, and structs by pointer and by value) compiles, links
with the native as/ld, and runs correctly on a Commodore 900.

- local.c: emit ".comm name,size" with a comma; the assembler's S_COMM
  handler requires it (getid then "if (getnb()!=',') qerr()").

- local2.c/table.c: address frame objects in the stack segment.  Set
  the frame pointer r13 to the frame bottom and define a per-function
  equate "L<n> = SS|framesize", referencing every slot as
  "L<n>+off(r13)" (matching the reference compiler).  Taking the address
  of a local (&x) uses the same equate so the resulting pointer carries
  the stack segment SS.

- local2.c/table.c: byte operations must name the low-byte register
  "rlN"; a plain "rN" selects the high byte "rhN".  extsb/exts keep the
  word name; unsigned char zero-extension uses word "and rN,$0xff".

- table.c: compare against zero with cp/cpl, not test/testl.  TEST
  computes "dst OR 0", leaving the P/V flag as parity rather than signed
  overflow, so the signed jr lt/ge conditions misfire at the 0 boundary.

- local2.c: struct-by-value argument copy builds the destination pointer
  as (source segment):r15 rather than copying rr14, whose segment word
  is not a usable data-segment value.

- local2.c: a pair-register base with a displacement is Based addressing,
  written "rrN(disp)" (base pair first); "disp(rrN)" is parsed as Indexed
  mode and uses the pair as a word index.  Fixes p->member and p[i].

Also update the copyright year to 2026.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Three fixes, all verified on real Coherent hardware (Commodore 900):

- Variable (register-count) shifts: sll/sra/srl and friends take only
  an immediate count, so register counts now use the dynamic shift
  instructions sdl/sda/sdll/sdal.  Their count is signed (positive =
  left), so right shifts negate a copy of the count into a scratch
  register first.

- Addressing-mode legality (from the Coherent as sources): the based
  mode rrN(disp) exists only for ld/ldl/ldb/lda.  New special shapes
  SNBA (OREG a non-load insn can encode: frame X mode, or pair base
  with zero displacement) and SFRAME (frame only, for the synthesized
  long ops that touch both pair halves) replace plain SOREG in every
  non-load rule.  Removed rules for forms the hardware lacks entirely:
  mem-destination add/sub/and/or/xor (dst must be a register),
  cpl with any memory operand, cp/cpb mem-vs-reg, and ldl mem,#imm
  (long constants now store through a pair register).

- The Coherent ABI is word-aligned throughout (struct stat places the
  long st_size at offset 14), so ALLONG/ALPOINT/ALDOUBLE/ALFLOAT/
  ALLONGLONG drop from 32 to 16.  ALLONG=32 also broke our own calling
  convention for mixed int/long argument lists: the caller pushes
  arguments contiguously while pass 1 padded the callee's arg offsets.

Co-Authored-By: Claude Fable 5 <[email protected]>
All verified on real Coherent hardware (Commodore 900): strret 23/23,
chartest 8/8, edgetest 40/40.

Struct/union return by value uses a hidden-pointer ABI (invented here;
the native Coherent K&R compiler has no struct-by-value):

- The caller reserves a buffer in its frame for EACH struct-returning
  call (offsets assigned in myreader() and carried on the call node via
  the previously unused ATTR_P2_TARGET pass2-attribute hook) and pushes
  its address last, so it lands in the first-argument slot.  Distinct
  per-call buffers are required: pass 2 pre-evaluates call-containing
  arguments into pointer temps before pushing anything, so with
  f(g(), h()) both results are live at once.
- The callee (bfcode) shifts the declared argument offsets one pointer
  up and saves the hidden pointer in a temp; clocal(FORCE) returns the
  value's address in rr0; efcode copies the value through the hidden
  pointer and returns that pointer (the caller's live buffer) in rr0.
- Call results can coalesce with the precolored rr0, bypassing the
  clregs mask, and rr0 cannot be a memory base in any mode: NEVER(RR0)
  on all pair-result call rules forces the result out of rr0 right
  after every call.  Removed stale STCALL rules that predate the ABI
  (they pushed no hidden pointer at all).

Byte-register constraints: byte instructions can only name the halves
of r0-r7, so BYTEL/BYTER/BYTET needs (NOLEFT/NORIGHT/NEVER of r8-r12)
confine char values at every ldb/cpb site; under pressure they now
spill instead of hitting the blput() comperr.  Unsigned char loads are
rewritten read-first (ldb then "and $0xff") - the old clr-first form
could clobber its own address base.

Bitfields: clocal's same-size SCONV folds no longer retype a FLD child.
A FLD's signedness picks sra vs srl in the generic rmfldops lowering,
so folding stref's SCONV(INT) wrapper made unsigned fields read back
sign-extended.

Also: memory-destination assignments gained INAREG/INBREG+RDEST forms
(e.g. "return ++staticvar" previously could not match), and member
access directly on a call result (f().y) restores the struct size
descriptor that pprop loses, in myp2tree.

Co-Authored-By: Claude Fable 5 <[email protected]>
The NaN patterns (nLDOUBLE et al.) hold only the meaningful
representation bytes, which may be fewer than sizeof(long double) -
e.g. a 10-byte x87 pattern in a 16-byte long double - so copying
sizeof(long double) reads past the array.  Bound the copy like the
VALX macro already does, via a zero-filled temporary.

Found via -Warray-bounds while cross-building for z8001-coherent.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ime)

The Z8001 in the Commodore 900 has no FPU; lower all floating point to
the libc soft-FP runtime, following the native compiler's convention
exactly (discovered from libc/crt/{dadd,dcmp,dmul,ddiv,dtoi,itod,dtof,
ftod}.s and the compiler-emitted call sites in factor.s/modf.s):

  - a double travels in rq0 (r0 = sign+exponent, big-endian) and is
    pushed "pushl rr2; pushl rr0" (8 bytes, caller pops)
  - dradd/drsub/drmul/drdiv(da,db): both by value, result in rq0
  - drcmp(da,db): three-way result in r1, then compared against 0
  - diflt/duflt/dlflt/dvflt: int/unsigned/long/ulong -> double
  - ifix/ufix -> r1, lfix/vfix -> rr0: double -> integer
  - dfpack/fdpack: float <-> double, register-based (rr0/rq0)
  - negation inline: "xor <hiword>,$-32768" (sign-bit flip)
  - float is raw bits in a pair; float arithmetic computed in double

Implementation:
  - new register class C: quads rq0/rq4/rq8 (i386 pairs-of-pairs
    pattern); RETREG(double)=rq0, 3-class COLORMAP, quad rmove,
    prologue saves for quad-clobbered callee-saved words
  - fixfloatops() in myreader() (m68k fixcalls pattern) rewrites FP
    arithmetic, comparisons, and int<->fp SCONVs into helper CALLs;
    float ops convert operands to double and round the result back
  - table: dfpack/fdpack SCONV rules (NLEFT/NRES pin rr0/rq0), quad
    CALL/ASSIGN/OPLTYPE/UMUL/FUNARG/UMINUS rules; TFLOAT added to the
    pair rules; zzzcode ZD/ZE/ZP/ZW + quadmem() (ldm for frame/name
    memory - ldm has no BA mode - and two ldl for pair bases, loading
    the base pair's own half last since OREG bases have no regw)
  - ninval() emits FP constants as big-endian .word pairs (the generic
    inval assumes SZINT-sized soft_toush chunks; they are uint32_t)
  - emit ".globl _dtoa_" from ejobcode() when a unit uses FP so the
    linker pulls libc's real _dtefg printf formatter instead of the
    "No floating point!" dummy in sdtoa.o

Fixes found along the way:
  - myp2tree's FCON symtab left the real sap field as arena garbage
    (the "#define sap sss" compat macro hijacked the initializer);
    locctr's attr_find then chased a wild pointer.  memset the struct.
  - SCONV widening rules only listed same-signedness results; e.g.
    "u == 40000" (a long compare on 16-bit int) had no unsigned->long
    rule.  Result masks broadened to TLNG on all six.
  - PCONV word->pointer was backwards ("ld A1,AL; clr U1" put the
    value in the segment word and cleared the offset); zero-extend
    templates printed the pair name in clr (worked only because as
    encodes "clr rr2" as r2).  Both now name the word explicitly (ZM).

Hardware-verified on the Commodore 900: ztests/floattest.c, all 65
tests pass plus printf %f.

Co-Authored-By: Claude Fable 5 <[email protected]>
Taking the address of a scalar auto or parameter only compiled under
-xtemps (where locals become TEMPs, and &TEMP is legal), and never for
doubles (cisreg(DOUBLE)=0 keeps them out of temps): clocal rewrote
scalar locals straight into OREGs, and buildtree's ADDROF accepts only
UMUL/TEMP/NAME ("unacceptable operand of &").  Exposed by scanf-style
code: sscanf("%lf", &d).

Fix per the arch/i86 idiom: clocal now builds the *(r13+off) structure
reference for ALL autos and parameters, scalars included.  &x then
cancels ADDROF(UMUL) in buildtree, and pass 2's canon folds ordinary
accesses back into the same frame OREGs as before - the generated
assembly for the entire existing test suite is byte-identical.  The
big-endian char-parameter byte correction (value in the low byte of
the pushed word slot) moves from per-access in clocal to a one-time
soffset adjustment in bfcode, so &charparam also yields the true byte
address.

Also revert the automatic ".globl _dtoa_" emission from ejobcode:
pulling libc's real _dtefg printf formatter is link-time policy (the
native "cc -f" flag), not the code generator's business - emitting it
per FP-using unit would drag the formatter into programs that never
print and would tie generated code to one libc's member layout.  Link
FP-printing programs with "ld -u _dtoa_" (or -Wl,-u,_dtoa_ via the
driver).

Hardware-verified on the Commodore 900: floattest 69/69 (adds sscanf
%lf/%f/%d interop and &double/&float/&int arguments), printf %f via
the -u link flag.

Co-Authored-By: Claude Fable 5 <[email protected]>
Two front-end issues exposed by compiling real K&R sources (the
Coherent userland) with the z8001 backend:

- olddecl() copies stype/sdf/sap from an old-style parameter's real
  declaration but not the struct descriptor (sss), so any K&R
  struct or struct-pointer parameter kept ss == NULL and died with
  "compiler error: strmemb" on first member access (or in bfcode's
  -xtemps parameter copy via suemeq).  Prototype-style definitions
  take defid2's enter path, which does copy it.

- sizeof, offsetof, and pointer-difference results were hardwired to
  INTPTR, which is LONG on targets whose pointers are wider than int.
  A K&R libc declares sizes and counts as plain int/unsigned
  (qsort "unsigned nel, width", malloc "unsigned size"), so passing
  a widened sizeof to an unprototyped function misaligns the callee's
  argument walk.  New SIZET and PTRDIFFT macros default to INTPTR
  (no change for existing targets) and may be overridden in macdefs.h;
  pointer subtraction still computes pointer-sized and only narrows
  the final result.

Co-Authored-By: Claude Fable 5 <[email protected]>
Compiling the Coherent userland sources (echo.c, wc.c, ls.c) against
the native compiler's golden .s output found five backend bugs the
synthetic suite never touched; with them fixed, all three programs
produce output identical to the native binaries on the C900 (ls -l
matches byte for byte, wc matches on files and piped stdin), and the
seven synthetic suites still pass.

- The &frame-object rules (ZF) used SANY for the left operand, which
  also direct-matches NAME/OREG pointers and stole global-pointer
  arithmetic (char *gp; gp++) from the addl/subl rules, emitting
  frame-lda garbage.  New SR13 special shape matches only REG r13.

- sizeof/ptrdiff: SIZET=UNSIGNED, PTRDIFFT=INT (see the ccom commit);
  the K&R libc reads sizes as 16-bit words, so qsort/malloc/read
  calls pushed 4-byte longs where the callee walks 2-byte ints.

- Statics were emitted as .comm, but Coherent commons are linker
  global: two modules with same-numbered L<n> static labels would be
  merged into shared storage.  defzero now reserves .bssd storage
  under the label (".even; L<n>: .blkb size") like the native
  compiler; only real commons keep .comm.

- TEMPREG is now carried by r0-r5 only.  Listing rr0-rr4/rq0/rq4 in
  tempregs[] was redundant (AssignColors excludes overlapped colors
  via aliasmap from the r0-r5 edges) and the rq4 entry made every
  call exclude callee-saved r6/r7/rr6 from values live across it, so
  register locals in wc-sized functions all spilled to the frame.

- The long zero-assign rule offered INBREG+RDEST with an SNAME
  destination, but its ZQ template clears memory and leaves no
  register holding the value, so chained assignments
  (chars = words = lines = 0) reclaimed an uninitialized pair.
  Split: SBREG keeps INBREG+RDEST, memory dest is FOREFF-only.

- Frame equates are now numeric ("L<n>=0|total", E_ASEG) instead of
  symbolic ("L<n>=SS|total", E_SEG): the shipped Coherent as drops
  the bit-15 long-form flag when emitting E_SEG addresses with
  offsets outside 0..255 (outsof() sets it on the wrong expr), so
  the CPU decodes a short form and executes the offset word as an
  instruction.  Any frame over ~250 bytes read garbage argc/argv
  through the broken form - the echo(1) segfault.  MWC's factory
  binaries were built with an assembler without this bug; a numeric
  segment takes the correct E_ASEG path and encodes identically at
  runtime (crts0.s pins SS = 0).

Co-Authored-By: Claude Fable 5 <[email protected]>
Z8001 byte instructions can only name the halves of r0-r7. This was
enforced by NOLEFT/NORIGHT/NEVER constraints on the byte rules; NEVER
is clobber semantics (addalledges), so every char access gave r8-r12
interference edges to ALL live values, starving cross-call values out
of rr8/rr10 in char-heavy code (every register local in wc spilled).

Make chars their own register class D instead (the arch/i86 model):
rl0-rl7, the low bytes of r0-r7, indices 25-32.  The hardware
restriction is now structural (ROVERLAP/aliasmap wiring), and the
constraints are gone.  Only low-byte registers exist - a char in a
word register lives in its low byte everywhere in this ABI.

Conversions exploit word ops (ld/extsb/and/neg/push) being legal on
any register: they operate on the byte register's CONTAINING word.
New zzzcode escapes ZG/ZH print it, ZK/ZI emit the conversion moves
and elide them when NSL sharing makes source and destination coincide,
fusing char->int back to the native "ldb rlN,mem; extsb rN" idiom.
No fixed registers, no NEEDS constraints in char handling.

Details: PCLASS/gclass(char)=class D; RETREG(CHAR)=rl1 with INDREG
call rules (shape matching is by PCLASS of node type); DECRA/ENCRA
widened to 6-bit fields for MAXREGS 33; byte regs carry no TEMPREG
(call-site r0-r5 edges reach rl0-rl5 via aliasmap); firstsavereg
treats rl6/rl7 as r6/r7; rmove emits ldb for class D; COLORMAP gains
the D terms; UMUL/OPLTYPE char loads are a bare ldb with promotion as
a separate (usually fused) SCONV, preserving read-before-write on
aliased bases.

Verified in the C900 emulator: all 7 test suites pass (262 tests);
echo, wc and ls output byte-identical to the native binaries.  wc
main's frame accesses drop 44 -> 2 (argc r9, argv rr10, fp rr6 across
calls, matching native allocation); entry/exit dead moves decreased.

Co-Authored-By: Claude Fable 5 <[email protected]>
clocal rewrites "return v" into ASSIGN(REG-rr0, v); for a reg-reg
assign, insnwalk moveadds the right-side temp with the precolored rr0
node, and Briggs coalescing bypasses the clregs selectable mask (the
same bug class as the CALL-result coalesce fixed earlier with NEVER
edges on the call rules).  A pair value that is both dereferenced and
returned then printed as the forbidden (rr0) indirect base, found in
real programs: tail nextline "ldb rl2,(rr0)" and sort ralloc
"ldl rr2,(rr0)".

Fix: NEEDS(NORIGHT(RR0)) on the pair reg <- reg/mem ASSIGN rule.  The
conflict edge blocks only that coalesce, so such returns emit an
explicit "ldl rr0,rrN" move, exactly like the native compiler.

Co-Authored-By: Claude Fable 5 <[email protected]>
The front end only applies the default argument promotion for
FLOAT->DOUBLE on unprototyped calls (params.c oldarg()); char
arguments were pushed as their containing word, whose high byte is
undefined under the class-D byte register model.  A K&R callee that
declares the parameter as int reads the whole word and gets garbage:
found via tail's skbl() doing fstat(fileno(infp), &stbuf), where the
FILE _fd char field was pushed as "ldb rl0,rr2(23); push r0".

Fix: funcode() wraps CHAR/UCHAR arguments in SCONV to INT/UNSIGNED
before building the FUNARG, so the push is a properly extended word
("ldb rl0,...; extsb r0; push r0"), exactly like the native compiler.
short/ushort are already full 16-bit words on this target and are
left alone.

Co-Authored-By: Claude Fable 5 <[email protected]>
chkpun's pointer-type walk assumed that when the left type reaches a
function type the right side is one too, and dereferenced its dimfun
pointer for the prototype check.  A non-function right operand (e.g.
int (*f)() = (char *)0, common in K&R code where NULL is defined as
((char *)0)) carries no dimfun at all -> NULL dereference, reported as
"major internal compiler error".  Break out to the ordinary illegal
pointer combination warning instead.  Found compiling Coherent
learn.c; repro in ztests/real/e46.c.

Co-Authored-By: Claude Fable 5 <[email protected]>
All four appear in Coherent 0.7.3 userland sources whose shipped .s
goldens prove the native K&R compiler accepted them:

- external declarations with no type specifier at all default to int
  with a warning (tar.c "unixbug = 0;", scat.c "puts1(), puts2();"):
  new notype_init_dcl_list under external_def.  Adds one benign
  shift/reduce conflict (declarator . C_ATTRIBUTE at external scope,
  resolves to shift).
- a scalar initializer for an array initializes element 0 as if
  braced, with a warning (tar.c "char tapedev[10] = '\0';").
- "return;" in a function returning a value is a warning, not an
  error - the constraint is C99, C89 allows it (mv.c NOTREACHED).
- mismatched pointer types in ?: warn ("illegal pointer combination")
  and take the left type instead of erroring (diff3.c
  "mat ? NULL : fp13" with NULL = ((char *)0)).

Repros in ztests/real/e47-e50.c.

Co-Authored-By: Claude Fable 5 <[email protected]>
optim() unconditionally rewrites PLUS/MINUS with a zero right side to
the bare left operand.  When the left side is the frame register that
turns an address computation into a naked word register - fine on
flat-memory targets, wrong on segmented ones where materializing a
frame address must also supply the segment.  Targets can now override
OPTIM_KEEPZERO(p) (default 0) in macdefs.h to keep the operation.

Co-Authored-By: Claude Fable 5 <[email protected]>
Two fixes from the _examples/cmd mass sweep (80/86 now compile clean
through cpp/ccom/audit/shapediff/host-as/host-ld):

- OPTIM_KEEPZERO: &arr[N] where the array end lands at frame offset
  exactly 0 was identity-folded to bare r13 - a 16-bit word with no
  segment - and compared against a pointer pair, emitting the illegal
  "cpl rr8,r13" (tee.c "fpp >= &fp[NUFILE]").  Keeping the PLUS lets
  the ZF rule emit the normal lda+mask pair.  Repro ztests/real/e44.c.

- clocal SCONV: an explicit (long)ptr cast feeding arithmetic left an
  SCONV(ptr->long) that pass2 has no rule for ("Cannot generate code
  op SCONV", units.c/load.c/uload.c).  Same-size no-op fold now also
  covers pointer sources; conversions TO pointers were already folded
  in the PCONV case.  Repro ztests/real/e45.c.

Co-Authored-By: Claude Fable 5 <[email protected]>
putw()'s return value is not portable: POSIX returns 0 on success but
MinGW's _putw returns the value written, so any nonzero word made
pr_wr() report a spurious write error.  Check the stream error flag
instead, matching how pr_rd() detects failure.

Co-Authored-By: Claude Fable 5 <[email protected]>
The xssa-only heuristic in findops() that demotes a directly-matched
left operand to SRREG (preferring 2-op insns after SSA) tested
n2osh(q->lshape) & INREGS.  For SPECIAL shapes the low bits are a
shape number, not register-class bits, so any special shape whose
number intersects INREGS misfired: z8001's SR13 (SPECIAL|8, 8 =
INCREG) made the &frameobj lda rule demote its own REG r13 operand
into a C-class register, for which no rule exists -> "Cannot generate
code op REG" ICE on any &param/&local at -xssa; where a fallback rule
existed it instead silently emitted ldl rrN,r13 (word frame reg into
a pointer pair, segment half garbage).  SCCON (SPECIAL|3) and friends
alias INAREG the same way on other targets.

Guard the demotion with (n2osh(q->lshape) & SPECIAL) == 0; correct
for both the int shape encoding and NEWSHAPES SPECON.  xssa-gated,
so non-ssa output is unchanged (verified byte-identical on the 7
z8001 test suites and 6 real programs at -O0).  Repro: take the
address of a parameter with -xtemps -xdeljumps -xssa.

Co-Authored-By: Claude Fable 5 <[email protected]>
FUNARG went through a scratch register for everything but registers
(ld A1,AL / push (rr14),A1), where native pushes most operands
directly: push src accepts IM/DA/X/IR (as machine.c S_PUSH; word
immediates get the dedicated PUSHI encoding) and the corpus uses
`push (rr14), $1` over a thousand times.  Add direct-push rules for
word SCON/SNAME/SNBA and long/ptr/float SNAME/SNBA; only BA OREGs
(pair base + displacement) keep the register detour, and long
constants keep the ldl path - the assembler special-cases IM for
PUSHW only, pushl-immediate does not assemble.

Also split the FUNARG TDBL "SNAME|SFRAME" rule in two: OR-ing a
special shape with SNAME builds a value tshape()/special() can never
match, so the combined rule was dead and doubles always bounced
through a quad register.  Special shapes must appear alone in a rule.

Verified in the C900 emulator against both a rebuilt -O0 and a new
-O1 pcc-built libc (95/95 libc sources compile at -O1): 7 test suites
x {-O0,-O1} all pass (524 ok), echo/wc/tail/sort/ls byte-identical to
native /bin, factor byte-identical to the -O0 reference build.

Co-Authored-By: Claude Fable 5 <[email protected]>
TEST performs "dst OR 0" and sets only Z and S, leaving P/V as
parity, so the ordered conditions (S xor V) cannot use it - but
EQ/NE read only the Z flag, which TEST sets correctly.  Add EQ/NE
rules emitting test/testl/testb on register, SNAME and SNBA shapes,
placed before the generic OPLOG cp/cpl rules (equality compares
match both at the same level and the first table entry wins).

test takes dst = R/IR/DA/X (as S_CLR class), so unlike cpl it works
directly on memory: testl mem absorbs an ldl+cpl pair and testb mem
a ldb/extsb/cp triple.  Native cc uses these heavily (943 test /
561 testl / 274 testb across the golden corpus); we previously
emitted none.

Co-Authored-By: Claude Fable 5 <[email protected]>
The front end promotes both sides of a compare to int, hiding the
byte operand behind an SCONV so pass 2 could never use testb/cpb:
"if (c)" compiled to ldb/extsb/cp where native emits one testb, and
"c == 'x'" to ldb/extsb/cp $imm where native emits cpb mem,$imm.

For EQ/NE the promotion is value-preserving in both directions
whenever the constant fits the char's value range (CHAR -128..127,
UCHAR 0..255), so clocal strips the SCONV and retypes the constant
to the char type.  Ordered compares are NOT narrowed: an unsigned
char would also need the operator flipped to the unsigned
condition.  Bitfield (FLD) sources are skipped - their SCONV
wrapper controls the extraction.

The narrowed forms cpb mem,$imm and cpb rlN,$imm are corpus-novel
but verified legal from the assembler source: S_CP with immediate
source accepts IR/DA/X dests via the CPI encoding, and byte
immediates to register dests are replicated into both halves by
outxx exactly as CPB Rbd,#data requires.

Co-Authored-By: Claude Fable 5 <[email protected]>
UMUL operands never reached the SNBA special shape: special() only
matched already-folded OREGs, but pointer-temp dereferences are
still UMUL(TEMP) at instruction-selection time (canon's oreg2 folds
only real-REG bases).  So "if (*p)" loaded the value into a
register before testing it instead of using the memory form.

Teach special() SNBA to accept UMUL of a bare pair register/temp
as SROREG: offstar evaluates the address into a pair and gencode's
canon folds UMUL(REG) into a zero-displacement OREG, which prints
as (rrN) = IR mode, legal in every SNBA consumer (test/testl/testb,
cp/cpb immediate-compare, ldb/ld $imm stores, clr, inc/dec,
add/sub/and/or/xor sources, push/pushl).  PLUS/MINUS(base,con)
addresses are rejected: they would fold to a displaced pair base =
BA mode, which these instructions cannot encode.

Besides the truth tests (testl (rrN) absorbs an ldl, testb (rrN) a
ldb) this also delivers the indirect push family for free:
FUNARG of *p now emits pushl (rr14),(rrN) like native instead of
the ldl detour, and dereferenced arithmetic operands fuse
(sub r1,(rr2)).

Co-Authored-By: Claude Fable 5 <[email protected]>
New special shape SLDK matches a nameless ICON in 0..15, the exact
range of the ldk instruction (as S_LDK rejects anything else).  An
OPLTYPE rule and an ASSIGN reg<-const rule use it ahead of the
generic ld $imm rules, halving those loads from 4 to 2 bytes; the
goldens use ldk for every such constant.

ldk sets no flags, so the ASSIGN rule deliberately claims no
FORCC/RESCC - a compare-context assign falls back to the generic
ld rule.

Co-Authored-By: Claude Fable 5 <[email protected]>
Under the Coherent segmented model no object crosses a segment
boundary, so valid pointer arithmetic can never carry out of the
16-bit offset half of a pair - the same assumption native cc makes
(user-approved).  New TPOINT-only PLUS/MINUS rules ahead of the
generic pair rules emit inc/dec UL,$1..16 (2 bytes, new special
SP16) and add/sub UL,$imm (4 bytes, new special SPCON limited to
word immediates without symbol relocations) instead of the 6-byte
addl/subl, leaving the segment word untouched.  upput already
prints the low word for every operand flavour.

Plain longs are excluded: for a real 32-bit integer the carry is
arithmetic, so TLONG/TULONG keep addl/subl.

Memory RMW forms (gp++ as inc gp_+2,$1 via FINDMOPS) use SNAME and
SFRAME shapes only - the low word of a zero-displacement pair-base
OREG would be BA mode, which inc cannot encode; and there are no
SPCON memory forms since add/sub take register destinations only.

Co-Authored-By: Claude Fable 5 <[email protected]>
clrb: ASSIGN char <- SZERO rules on SDREG/SNAME/SNBA, placed before the
generic SCON assigns.  clrb (as S_CLR, dst R/IR/DA/X) sets no flags, so
like ldk the rules carry no FORCC/RESCC and compare-context assigns fall
back to the generic ldb.

andb: char-typed AND rules (SDREG dst; SDREG/SNAME/SCON and SNBA src)
for the char-mask truth tests that clocal narrows back to byte width.
andb is S_RSRC (dst register, src R/IM/IR/DA/X); a byte immediate is
replicated into both halves by as, the cpb Rb,IM encoding path.  RESCC
is consumed by eq/ne only (Z correct, P/V parity - same as testb),
which the narrowing guarantees by construction.

subb: uchar->int zero-extension now emits the 2-byte native idiom
"subb rhN,rhN" via the new ZU zzzcode when the result word has an
addressable high byte (r0-r7), falling back to the 4-byte word
"and A1,$0xff" for r8-r12.

Native golden counts: clrb 124, andb 72 (all mask truth tests feeding
eq/ne), subb 90 (all rh self-subtract zero-extends).

Co-Authored-By: Claude Fable 5 <[email protected]>
Extend the session-14 EQ/NE char narrowing to the ordered compares and
to AND-mask truth tests, unlocking the cpb/andb rules:

Ordered compares vs a nameless ICON: sign-extension preserves signed
order, so a signed char keeps LT/LE/GT/GE (constant must fit -128..127).
Zero-extension puts both sides in 0..255 where the signed word compare
equals the UNSIGNED byte compare, so uchar flips the operator to
ULT/ULE/UGT/UGE (constant 0..255); an already-unsigned compare keeps
its operator.  A sign-extended char under an unsigned word compare has
no byte equivalent and is left alone.

Mask truth tests: EQ/NE(AND(SCONV(char->int), ICON m), ICON k) with m,k
both 0..255 - the mask clears every promoted high bit regardless of how
the char extended, so equality is decided by the low byte alone.  Strip
the SCONV and retype the AND to the char type; pass 2 then emits
"andb rlN,$m" whose Z flag feeds the eq/ne branch directly.

Constant-to-the-right canonicalization on narrowed compares: the parser
builds a>b / a<=b as b<a / b>=a (cgram.y eve C_GT/C_LE swap), leaving
the constant on the LEFT where pass 2 has no imm-compare rule and would
materialize it into a byte register.  Swapping the (side-effect-free)
constant back reverses the ordered operator; EQ/NE are symmetric.
"if (gc > 'z')" is now "cpb gc_,$122; jr le" like native instead of
"ldb rl0,$122; cpb rl0,gc_; jr ge".

Verified: 7-suite -O0 diffs are pure byte-width substitutions (misctest
range checks 8->2 insns); sweepO1 106 status-identical (100 OK); O1
build 13/13; emulator 7 suites x {O0,O1} ALL TESTS PASSED (262 ok
each); echo/wc/tail/sort -O1 byte-identical vs native /bin.  Gap vs
native goldens 49253 -> 49068 insns (1.086x -> 1.082x); we now emit
clrb 324 / andb 122 / subb 110 / cpb 416.  Repro ztests/real/e55.c.

Co-Authored-By: Claude Fable 5 <[email protected]>
PICKCOLOR: colfind's fallback takes the lowest set bit of okColors,
which allocates callee-save registers bottom-up.  Targets whose
prologue saves one contiguous register block (cost set by the lowest
callee-save register used) want the opposite order, so let the target
pick the color from the mask.  Move-related recommendations still take
precedence; without the macro the ffs behavior is unchanged.

SPILLSHADOW: a permreg shadow whose own register was taken by a value
would color into ANOTHER callee-save register, emitting entry/exit
reg-reg moves and dragging that register into the save set.  The old
bottom-up order only avoided this by accident: values collided with
the lowest shadows, whose okColors then came up empty and forced the
spill path (nsavregs -> prologue save, zero moves).  With a target-
directed color order that accident no longer happens, so let the
target declare that a shadow losing its register must always spill;
the existing ONLYPERM rewrite then marks it in nsavregs and recolors.
Both hooks are inert for targets that do not define the macros.

Co-Authored-By: Claude Fable 5 <[email protected]>
Define PICKCOLOR/SPILLSHADOW (new regs.c hooks) and add pickcolor():
caller-saved registers keep their lowest-first order, callee-saved
ones are preferred TOP-DOWN - words r12..r6, pairs rr10,rr8,rr6,
quads rq0,rq8,rq4 (rq4 overlaps r6/r7), bytes rl7 before rl6.  The
prologue saves one contiguous ldm block from the lowest used callee
register through r13, so its stack/cycle cost is set by that lowest
register alone; picking from the top keeps the block minimal.
SPILLSHADOW makes a displaced permreg shadow spill into that ldm
block (free) instead of parking in another callee register behind
entry/exit moves.

Effect on the 73-file golden sweep: prologue starts were 690x r6
(everything saved r6-r13); now r8:213, r6:184, rr10:152, r9:91,
r7:50 - the common case matches native's "ldm r8,$6" or better.
Insn gap 48854 vs 49068 (1.082x -> 1.078x): the -214 are eliminated
entry/exit shadow moves.  7-suite -O0 diffs are pure register renames
plus smaller ldm/frames (insn count identical); sweepO1 106
status-identical (100 OK); O1 build 13/13; emulator 7 suites x
{O0,O1} ALL TESTS PASSED (262 ok each); echo/wc/tail/sort -O1
byte-identical vs native /bin.  Repro ztests/real/e56.c
(ldm r6,$8 -> ldm r9,$5, no moves).

Co-Authored-By: Claude Fable 5 <[email protected]>
Two mip changes serving condition-code correctness on targets whose
instructions set only a subset of the flags:

1. geninsn's compare-vs-zero elision (computing the compare's child
   with FORCC so the child insn's own flags feed the branch) fired for
   ALL ten compare ops on any FLO/DIV/SIMP/SHF child with a FORCC
   rule.  That is only correct when the child instruction sets every
   flag the branch condition reads; on machines where e.g. the word
   logical ops leave the overflow flag untouched, an ordered signed
   branch after an elided compare reads a stale flag.  New target
   hook CCOKFORCOMP(compareop, childop) vetoes such pairs; the
   default (1) keeps the historic behavior for all targets.

2. findops returned ffs(cookie & visit & INREGS)-1 as the matched
   node's result class.  For a rule visited ONLY for its condition
   codes (visit FORCC, no result register - e.g. pdp11's "bit" test
   rules, which exist exactly for the elision path above) the mask is
   empty and the return value ffs(0)-1 = -1 collides with FFAIL, so
   the caller treats the successful match as a failure and the rule
   can never be selected.  Return 0 for such rules (the established
   no-result value, as for FOREFF) and accept 0 in the elision check.
Wrong-code fix: the word logical instructions AND/OR/XOR leave P/V
unaffected (manual: "P: AND -unaffected", same for OR/XOR; the byte
forms set it to parity), yet their table rules claim FORCC, so an
ordered signed compare against zero elided onto them read a stale V:
"if ((x & -2) < 0)" compiled to "and r0,$-2; jr ge" where ge = S xor V
inverts whenever the last V-setting insn left V=1.  Only LT/GE were
reachable (the parser swaps GT/LE operands, leaving the constant on
the left where the elision cannot fire); no site exists in the sweep
corpus, so this never bit at runtime.

Define CCOKFORCOMP (the new mip hook) to allow the elision for EQ/NE
always (they read Z alone, correct after every FORCC rule we have)
and for PLUS/MINUS children under any compare (add/sub/inc/dec set V
arithmetically, which is exactly the signed compare against zero).
Ordered compares on logical results now emit their cp.

Repro ztests/real/e57.c.  7-suite -O0 output unchanged by the gate
itself; all suites pass in the emulator at -O0 and -O1.
Michal Pleban and others added 20 commits July 7, 2026 22:31
x |= (1<<k) and x &= ~(1<<k) with a constant bit went through 4-byte
or/and-immediate (plus a load/store pair for memory operands).  set
and res are 2 bytes, take dst R/IR/DA/X like bit (as S_BIT immediate
path), and affect NO flags (manual 3-125/3-144) - so the rules claim
neither FORCC nor RESCC and flag consumers like "if (x &= ~8)" fall
through to the or/and rules, whose flags are real.

The set mask is the existing SPOW2 special (ZJ prints the bit
number); the res mask arrives as an ICON with every bit of its
type's width set EXCEPT one, in either sign representation (~8 on a
word is -9 or 65527) - new SNPOW2 special, printed by the new ZY
escape.  The SNAME/SNBA memory forms fire through findmops ("set
gw_,$3", "res (rr2),$1"), absorbing the load/store.

65 sites fire across the sweep at -O1 (native: 17, registers only).
Corpus-novel memory shapes set/res DA/IR/X,IM whitelisted; audit.py
extends the bit-number range check to set/setb/res/resb.
…C#10)

&garr[i] built the address with "ldl rrN,$garr_; add rN+1,rIDX".
lda's Indexed mode encodes the whole thing in one instruction,
"lda rrN,garr_(rIDX)" - the native compiler's own idiom (336 corpus
uses).  New PLUS(SCON ptr, SAREG word) rule with the ZX escape.

X mode CANNOT encode index r0 (an index field of 0 decodes as DA -
as machine.c outxx UP4), and a nameless address constant has no DA/X
spelling: ZX emits the old ldl+add pair for those two cases, which is
why this is a table rule and not a peephole.  A1 never overlaps the
index register (plain NBREG, no sharing), so the fallback's ldl
cannot clobber the index before the add reads it.

240 lda X sites fire across the sweep at -O1; 93 sites keep the
fallback because the allocator's lowest-first preference put the
index in r0 - steering those is possible future allocator work.
ASSIGN(TEMP t, expr) ; CBRANCH(relop(TEMP t, 0)) adjacent in the
interpass list becomes CBRANCH(relop(ASSIGN(TEMP t, expr), 0)) - an
ASSIGN yields its stored value, so this is exactly the tree pass1
builds for "if ((t = expr))".  When expr is a read-modify-write of t
the compare-vs-zero elision then branches on the operation's own
flags: "dec r5,$1 ; jr ne" instead of dec; test; jr (gated per-op by
CCOKFORCOMP - "and" plus an ordered compare keeps its test/jr pl).

Runs from myoptim, i.e. after deljumps/SSA/DCE and just before
register allocation: SSA splits exactly this kind of tree back into
the two-statement form (pass1's own "if ((t = expr))" trees
included), so fusing any earlier is undone at -O1.  Integer and
pointer temps only: float compares are rewritten to fcomp helper
calls and must keep their canonical shape.
sll rN,$1 is 4 bytes; add rN,rN is 2 and is the native idiom (116
corpus uses; native emits sll $1 only 3 times).  Same for the pair:
slll rrN,$1 -> addl rrN,rrN.  add sets all flags arithmetically for
the doubled value, unlike sll (V untouched), so the word rule may
claim FORCC/RESCC; the CCOKFORCOMP gate limits elided compares on an
LS child to EQ/NE anyway.  44 word + 3 pair sites across the sweep.
The basic blocks built during optimize() are REUSED by the register
allocator when xtemps is set (Build() only fakes a single block for
xtemps == 0), and LivenessAnalysis walks each block from bb->last
back to bb->first in the CIRCULAR interpass list.  fusecmp unlinked
the fused-away ASSIGN without looking at the blocks, so whenever that
ASSIGN was a block's first element (the first statement after a
label: ar.c update(), "p = namep[i]" at the top of the for body) the
backward walk never met bb->first and ccom spun forever.

Patch any bb->first/bb->last naming the removed element before the
DLIST_REMOVE, under the same xtemps||xssa condition optimize() uses
to build blocks at all.  Repro ztests/real/e66.c (hangs only with
preceding functions shifting the block layout; ar.c at any
optimization level was the corpus trigger).
Complete the c2-derived deljumps with its missing tail-merge half,
present until now only as #ifdef notyet stubs.  comjump() merges the
identical statement tails of two jumps to the same label; xjump()
cross-jumps a statement that is identical ahead of a jump and ahead
of the jump's target.  Both operate on the dlnod work list and the
interpass list in step (codemove precedent).

Statement equality (dltcmp) is deliberately strict, unlike the
loose-by-design treecmp() in match.c: n_type must agree on every
node, ops carrying state outside the common node fields (STASG,
STARG, STCALL, USTCALL, STCLR, FLD, XASM, XARG) never merge, and
unknown leaves compare unequal, since a false match here is wrong
code, not a missed optimization.

Tail merging is enabled only for the pre-regalloc deljumps runs
(new file-static tailmerge flag, set around the two calls in
optimize()): after ngenregs the trees carry n_reg/n_su and two
structurally equal trees may still use different scratch registers.
Both gated runs happen before bblocks_build, so no basic-block
boundary patching is needed.

New labels come from getlab2() and their interpasses from tmpalloc,
which is why deljumps skips its markset/markfree rollback when tail
merging is enabled - the label interpasses must survive the pass
(add_labels allocates the same way); the work list then simply lives
until the function's arena is freed.

Corpus effect (Coherent cmd, 73 files vs native cc): 46947 -> 46260
instructions, gap 1.036x -> 1.020x; login 1.21 -> 1.19, mv 1.14 ->
1.12, tar 1.10 -> 1.09.  The 7 synthetic suites are byte-identical
at -O0 (no duplicated tails).  Repro ztests/real/e67.c.
At -O0 the fused countdown tree ASSIGN(t, op(t, e)) under a
compare-vs-zero branches on the operation's own flags ("dec r5,$1 ;
jr ne").  At -O1 SSA renames the two halves of the same tree to
different temps, and after removephi the loop reads

	ASSIGN(s, x)				entry copy
    L:	CBRANCH(rel(ASSIGN(d, op(s, e)), 0))
	... body uses d ...
	ASSIGN(s, d)				back-edge copy
	GOTO L

so findmops' treecmp(d, s) can never match and the elision is lost;
the register allocator does coalesce d and s into one register, but
geninsn has already emitted the separate test by then (the 45
dec;test;jr + 8 sub + 3 and corpus sites, session 18 reality check).

New myoptim pass rmwrename: when the whole-function interpass list
proves this exact structure - d defined only by the RMW, s used only
by the RMW, s defined by exactly two plain top-level temp-to-temp
copies, one before the RMW sourcing neither temp and one after it
reading d - rename s to d.  The entry copy then initializes d, the
back-edge copy becomes a self-copy and is deleted (basic-block
boundaries patched, fusecmp precedent), and the RMW reads and writes
one temp again, so the elision fires.  The two-copy requirement pins
the removephi phi-lowering shape in which every path back to the RMW
passes one of the consistently-renamed copies; it rejects aliasing
shapes like "while (n = m - 1)" where the source stays live, and
do-while shapes where the copy precedes the branch.  Gated on xssa
(only SSA produces split temps, and the argument leans on removephi's
copy-on-every-edge invariant).  Runs after fusecmp, which rebuilds
the fused tree for pairs SSA split completely apart.

Repro ztests/real/e68.c (countdown, -=, &=, body-use, live-out, plus
aliasing / do-while / two-backedge controls).
The ZX rule (PLUS(SCON ptr, SAREG word) -> "lda A1,sym+off(rIDX)")
falls back to ldl+add whenever the index register colors to r0,
because an X-mode index field of 0 decodes as DA.  pickcolor's
caller-saved-lowest-first preference put the index temp in r0 at 93
of the 334 corpus sites (session 18).

NORIGHT(R0) in the rule's needs adds one interference edge between
the matched node's right operand and r0 (insnwalk cNOR ->
addedge_r), which is the targeted constraint: only temps actually
used as an lda X index lose r0, everything else keeps the existing
preference order.  This is the light needs form - unlike
NLEFT/NRIGHT/NEVER it does not addalledges the physical register.
On Windows the text-mode stdout turns every line ending into CRLF.
The Coherent assembler does not skip CR (its getnb treats it as an
ordinary character), so every line of a driver-produced .s file was
a syntax error.  "wb" is a no-op on POSIX systems.
Driver changes, generic side:

- find_file() tests access() on bare tool names, which never match
  Windows executables; retry with .exe appended (_WIN32).
- Append the directory the driver executable lives in to progdirs/
  crtdirs/libdirs (_WIN32, GetModuleFileName), after -B directories,
  so one folder can hold the tools, crts0.o and libc.a.
- DEFLIBS entries without a leading dash are resolved through the
  library search directories via strap() instead of being passed
  through, for linkers that have no usable -l/-L.

Coherent ld constraints (option table read from the linker source:
-L means LARGE MEMORY MODEL, -l hardcodes /lib and /usr/lib, entry
is the first object's start):

- never emit -L or -g to ld (os_coherent guards),
- do not pass -e STARTLABEL: crts0.s's "start" is not .globl; the
  linker enters at the first object, which is crts0.o,
- ccconfig.h: CRT0 is the bare "crts0.o" resolved through crtdirs,
  DEFLIBS { "libc.a" } resolved through libdirs, DEFLIBDIRS empty,
  and PCC_EARLY_AS_ARGS passes -g to the assembler (undefined
  symbols become external references; native cc relies on this too).

Diagnosis and the as/CRLF legs were verified piecewise (session 19);
the supported regression route stays the cpp|ccom pipeline in
ztests/compile.sh, which this does not affect.
Add a backend-usable pass2 operator SWDISP (a UTYPE; MAXOP 58->59),
matched through finduni, for targets that lower a switch to a single
computed-dispatch instruction whose scratch registers must be reserved
by the register allocator (the Z8001 cpir idiom).

cfg_build: a computed GOTO whose child is SWDISP now takes its
successor edges from a per-dispatch, null-terminated label list carried
on the GOTO node (n_name), instead of the function-global ip_labels.
This keeps the CFG precise per switch: otherwise a function with
several dispatches makes every case body a successor of every dispatch,
so each case body becomes a false multi-predecessor merge and SSA
removephi floods the shared dispatch point with phi copies (large
spills).  A genuine "goto *p" (no SWDISP) still conservatively assumes
any address-taken label in ip_labels.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Native Coherent cc dispatches a scattered (typically ASCII-code) switch
not with a compare ladder but with one cpir that linearly searches a
compact .word case-value table, then indexes a parallel .long target
table and jumps through it - eight instructions regardless of the case
count.

The idiom cannot be built from pass1 trees (there is no C-level
"search", and cpir's fixed register roles need pass2 allocation), so
mygenswitch() only MARKS an eligible switch (word-sized value, at least
5 cases) with /SW... comment interpasses carrying the case table.
myreader() rewrites the marks into GOTO(SWDISP(value)) plus the value/
target tables - emitted as one opaque IP_ASM blob so no collectable
DEFLAB is created for a label referenced only textually - and reserves
the search pair + count register through the SWDISP table rule's NEEDS.
The case and default labels are registered in ip_labels (kept alive so
deljumps inuse never drops a case body reached only by the jp) and also
carried on the GOTO node for precise cfg_build edges.

Corpus -240 instructions at -O1; several switch programs now match or
beat native (col 928 vs 941, lc 684 vs 699, ls 1077 vs 1100).  Runtime-
verified in the emulator: sort and tr are byte-identical to native, and
od's format switch dispatches the octal and -c cases correctly.

Known limitation: a very large switch inside a loop with variables live
across it (deroff, the sole corpus case) makes SSA removephi fan out
phi copies at the single dispatch point (+43 insns).  The opaque jump
table cannot be retargeted through copy-pads, so de-concentrating those
copies would need a retargetable table plus removephi support; left as
backlog.  Switch size is not the trigger - sort's 90-case switch is
fine.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add a backend-usable pass2 operator BCLR (a UTYPE; MAXOP 59->60) for
targets that fold a run of per-element zero stores into one block-clear
instruction (the Z8001 overlapping-ldirb idiom).  Registered in the dope
table (UTYPE) and matched through finduni.

isuseless(): BCLR writes memory - it is not a dead expression statement,
so it must never be dropped by deluseless.  List it with STASG.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
An ANSI partial initializer of an auto aggregate ("char buf[128] =
"exec ";") zero-fills the uninitialized tail; pass1 endinit->clearbf
emits one clrb per byte (login 123, tar 101 = 224 insns at 2 sites).
Native K&R cc omits the fill, but ANSI requires it - so keep it, emit
it compactly.

myreader()'s clrfill() folds a run of consecutive single-byte zero
stores to a frame slot into a compact clear:
  - >= 15 bytes: one BCLR node -> the Z8000 overlapping-ldirb idiom
    (clear byte 0, then propagate it forward with a single ldirb),
    ~7 insns regardless of length.  The BCLR result pair doubles as
    the ldirb dst (DECRA packs the result + A1 count + A2 src pair);
    the byte count rides in n_rval (n_lval aliases n_left = the child).
  - 2..14 bytes: merge each word-aligned byte pair into one word clear
    (r13 is even, so an even tree offset is a word-aligned frame addr).

Corpus -210 insns at -O1 (login 989->873, tar 2568->2474; both far
closer to native).  Runtime-verified in the emulator: the char and int
tails are zeroed correctly (e71 "FILL OK") and tar produces a byte-
identical archive to native /bin/tar.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The cpir dispatch stored case targets as .long (32-bit segmented)
addresses, so despite fewer instructions it was LARGER than the compare
ladder in bytes (tables + dispatch overhead: +1780 corpus).  Two changes
make it a real size win:

  - The target table is now a .word 16-bit OFFSET table, not .long
    addresses.  Every case body shares the dispatch's code segment, so
    the matched target offset is loaded into the search pair's low word
    (its high/segment word - which cpir leaves untouched - still points
    at this segment) and jp jumps through the pair.  This halves the
    target table (4N->2N bytes) and drops the index-scaling add.

  - mygenswitch now SIZE-gates: cpir (~28 + 4N bytes) is used only when
    it beats the ladder (~6N bytes), i.e. for the larger switches
    (N >= ~14).  Smaller switches keep the compare ladder, which is
    itself smaller than native's cpir.

Real code+data size vs native improves 0.9844x -> 0.9776x (-1186 bytes;
sort +118->+54, pr +88->+26, col/lc/ls and the rest back below native).
Runtime-verified: e72's 16-case switch dispatches every case and the
default correctly through the .word offset table (emulator "SW OK");
sweep 100 OK, status byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Four pre-ANSI constructs that PCC otherwise rejects as errors were being
accepted unconditionally on this branch to build Coherent's K&R sources.
Gate them behind a new -ftraditional language flag (default off, so strict
conformance is the default) plumbed like -ffreestanding:

  - external declarations with no type specifier (default int)
  - missing return value in a non-void function
  - scalar initializer for an array (first element)
  - mismatched pointer types in ?:

Each becomes a warning only with -ftraditional; without it they are hard
errors as before. The chkpun() function-pointer null-deref fix stays
unconditional -- it is a crash fix, not a language relaxation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
CRLF handling now lives in the assembler: the Windows z8001-coherent-as
was patched to accept CR, so the compiler no longer needs to force binary
output. Restore the original w freopen on the named-output-file path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
A shift with a char/uchar count (e.g. Coherent's restor.c
"lb >> shifts[il]" where shifts[] is char[]) failed with
"Cannot generate code ... op >>".

buildtree() promotes the value being shifted but only *demotes* an
oversized count (long -> int); a sub-int count is left unchanged. The
Z8001 dynamic-shift instructions (sdl/sda/sdll/sdal) take the count in
a word register, so every register-count table rule requires a TWORD
right operand and a byte count matches nothing.

Widen a CHAR/UCHAR shift count to int in clocal (SHORT/USHORT are
already word-sized here). Built with block()/direct retype rather than
makety() so this shared file compiles under both ccom and cxxcom, whose
makety() prototypes differ.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The per-function frame-base equate that lets pass2 address stack slots
as "L<n>+off(r13)" was hardcoded to segment 0 (L<n>=0|total).  That is
only correct when the stack segment SS is 0 -- true for user programs
(crts0.s pins SS=0x0000, a single flat segment) but NOT for segmented
code: the kernel's md.s sets SS=0x3F3F (system stack in segment 0x3F).

With 0|total, every callee in the kernel read its arguments and autos
out of segment 0 (ROM) instead of the stack, so e.g. setarena() got a
garbage arena pointer and the boot wedged before printing its banner.

Emit the equate with the external SS symbol (L<n>=SS|total) so frame/arg
addressing is correct in both ABIs.  The symbolic segment is E_SEG; this
relies on the assembler's E_SEG long-form emission being correct, which
it now is (the outsof() bit-15 drop that motivated the numeric workaround
has been fixed in the Coherent as).

Verified: with SS|total the PCC-built kernel boots past setarena/alloc
to its banner (and, with the companion build fixes, to an interactive
shell); the userland corpus is unaffected since crts0 keeps SS=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Two -O1 code-generation improvements for the Z8001 backend plus the one
machine-independent hook they need.

djnz/dbjnz loop fusion (cbfuse):
- New TARGET_CBRANCH_FUSE hook in emit() (mip/reader.c, default no-op;
  prototype in mip/pass2.h) lets a target collapse an RESCC compare-and-
  branch into a single instruction.
- arch/z8001 defines it as cbfuse(): a fused countdown branch
  CBRANCH(NE(ASSIGN(reg d, dec-by-1), 0)) with a word counter and a
  BACKWARD target becomes one "djnz r,L" instead of "dec r,$1; jr ne,L".
  Backward-only (a forward one would grow to dec;jp), tracked via a
  per-function emitted-label list (deflab/prologue).  The decrement is
  accepted as MINUS(r,1) or PLUS(r,-1).
- 7 corpus sites convert (dd,lc,ls,sum,tar); the -O1 sweep stays 100 OK
  status-identical and sum's output is byte-identical to native.  An
  out-of-range djnz relies on the Coherent assembler relaxing it to
  dec;jp nz; that assembler change lives outside this tree.

Dead-source RMW compare elision (rmwrenamesd):
- Recovers the read-modify-write shape for a straight-line
  "if ((x op= y))" whose source temp dies at the op.  SSA splits the
  stored and read halves apart (d != s) with no removephi copies, so
  findmops' treecmp fails and a redundant test is emitted.  When s is
  single-def / single-use - so by SSA its definition dominates the RMW -
  it renames s to d, restoring the in-place op and eliding the compare.
  EQ/NE only (the Z flag is the only one every op sets correctly).
- A live-source variant that inserts an explicit copy was attempted and
  reverted (it destabilised the SSA register allocator); see the NOTE in
  local2.c.

Verified end-to-end with ztests/e73.c in the emulator.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@ragge0

ragge0 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Whee, impressive job!
It may take some time before I can start to look at this, there are many things affected.

@ragge0 ragge0 self-assigned this Jul 11, 2026
@ragge0 ragge0 added the enhancement New feature or request label Jul 11, 2026
Michal Pleban and others added 7 commits July 12, 2026 00:46
The ZX escape (address constant + word index, i.e. &global[index]) emitted
its lda / ldl+add without the `and rN,$0x7F00` (32512) segment-normalising
mask that the ZF (&local) and ZM (lda-of-name) paths already apply.  That
left the reserved bit of the segment byte set, so a variable-indexed &arr[i]
pointer compared unequal to the same address materialised as a constant.

Symptom: the Coherent kernel's sleep/wakeup hash queue (linkq[]) was
initialised with constant-form self-pointers but probed via &linkq[hash(e)]
(lda), so wakeup() spun forever on an untouched bucket, hanging boot on real
hardware right after loading /etc/init.

Normalise the high (segment) word after forming the pointer, matching the
other lda sites.  ZX was the only lda emitter in the backend missing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Three size wins for long/pointer codegen, verified on the -O1 sweep (no
regressions) and at runtime in the c900 emulator (ZRT PASS at -O0 and -O1):

- register long-zero: ASSIGN SBREG<-SZERO emits "subl AL,AL" (2 bytes)
  instead of the two-clr ZQ (4 bytes); memory ZQ variants keep clr.

- small long/ptr constant: new SLCON shape + Z0 escape splits a numeric
  long constant into per-word clr/ldk/ld when that is no larger than the
  6-byte "ldl #imm32", else emits the plain ldl.  New ASSIGN and OPLTYPE
  rules feed it; symbolic constants fall through to the generic ldl.

- in-place RMW for a body-live loop induction variable: rmwrenameloop()
  is the mirror of rmwrename() -- here the destination temp is single-use,
  so renaming it onto the source and deleting the adjacent back-edge copy
  makes "t += 1" emit "addl rr10,$1" in place instead of
  "ldl rr2,rr10; addl rr2,$1; ldl rr10,rr2".  Rename+delete only (no
  mid-stream insert), xssa-gated, guarded by single def/use of the
  destination temp and literal adjacency of the "s = d" copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add SP16 (2..16) arms to the word PLUS and MINUS rules, emitting the
2-byte "inc/dec AL,$k" in place of the 4-byte "add/sub AL,$k".  The $1
case still matches the SONE/SMONE rules above (byte-identical output), so
the new arms only cover 2..16; symbolic and out-of-range constants fall
through to the generic add/sub, which these must precede.

inc/dec set S/Z/V but not carry; this is sound because the RESCC
compare-elision after PLUS/MINUS relies only on S/Z/V (the CCOKFORCOMP
signed-compare-vs-zero path), and the pre-existing inc/dec $1 rules
already carry RESCC through that same gate.

Sweep -O1 status-identical (no regressions), -196 bytes across 103
programs (29 shrank, 0 grew); runtime self-check ZINC PASS in the
emulator including the flag-sensitive compare/unsigned-wrap cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Frameless fast-path in prologue()/eoftn(): when a function saves no
callee-saved GPR (firstsave==R13), has no frame slots (fsize==0, which
also rules out spills), and never references r13 as a frame base
(usesfp() walks the interpass body for OREG/REG(FPREG)), emit no equate,
no allocation, no r13 save, and no "ld r13,r15" -- epilogue is a bare
"ret un".  Kills the whole frame on leaf/no-arg helpers (usage, etc).

For the remaining total==2 && nsave==1 case (only r13 saved, no autos,
but r13 used for stack-arg access), combine allocate+store into a single
"push (rr14),r13" (2B vs 4B) and restore+reclaim into "pop r13,(rr14)".

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Generalises the frameless work: r13 is now saved and set up as a frame
pointer (ld r13,r15 + SS| equate) ONLY when the function needs one --
has autos/spills (fsize>0) or addresses a frame slot / stack arg through
r13 (usesfp()).  When it doesn't, the callee-saved run stops at the top
used r6..r12 (new lastsavereg()) and r13 is never touched; the caller's
r13 is preserved by not clobbering it.  So a function that uses e.g.
rr10 but not r13 no longer saves/restores r13 or emits the equate.

Adds a pushl/popl combine for an aligned callee-saved pair (rr6/rr8/rr10,
no autos): "pushl (rr14),rrN" / "popl rrN,(rr14)" (2B) replaces
dec+ldm / ldm+inc (6B), mirroring the existing single-reg push/pop.

Verified: sweepO1 status-identical to RESULTS_sess22.tsv; corpus 263 fns
now skip r13 setup, 38 new pair combines; code+data -612B vs prior. pwd
main_ (uses rr10, r13 dead) -> pushl/body/popl/ret, byte-identical to
/bin/pwd in the emulator. popl RR,IR is legal (assembles + emulator
StackPopL) but corpus-novel -> whitelisted in the sweep scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
argcse() in myreader hoists an address or wide constant passed to a run of
adjacent calls into one callee-saved register instead of rematerialising it
per call.  Handles &global/&auto (ADDROF), symbolic address constants
(ldl $sym), and long/pointer numeric constants (ldl $imm32); generalised to
multi-argument calls with longest-run selection and overlapping runs.  Runs
before canon/regalloc (only safe point to insert IP nodes); restricted to
call-invariant, expensive-to-recompute operands so the hoist is always safe.

-324B text across the -O1 corpus (36 programs: 30 smaller, 6 larger from
2-use ICON cases that force a register save), 100 OK status-identical, audit
clean, no novel instructions.  pr/sort/scat (addresses) and m4 (numeric
const) verified byte-identical to native /bin in the c900 emulator.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
A call's caller-cleanup "inc r15,$K" immediately followed by the next
call's first argument push of exactly K bytes is a wasteful SP round-trip
(free K, re-allocate K).  Collapse it to a single in-place store: drop the
inc and rewrite the push as "ld/ldl (rr14),reg".  SP is unchanged, the
value is re-provided from the (preserved) register, and any following pushes
proceed normally -- so no dec is needed and it stays correct even if the
first callee overwrote its own argument slot.  Only a register push source
qualifies (a memory/immediate push has no store-to-memory form); an
intervening label blocks the collapse, so basic-block boundaries are honored.

Implemented as a whole-output text peephole: pass-2 assembly is printf'd
straight to stdout with no instruction buffer, so bjobcode() captures the
compilation's stdout to a temp file and ejobcode() streams it back through a
one-line-lookback filter.  arch/z8001/code.c only -- no shared MIP changes,
and output stays direct (no peephole) if capture cannot be set up.

-98B text across the -O1 corpus (48 sites); sweep 100 OK status-identical;
sort/scat/m4/pr verified byte-identical to native /bin in the c900 emulator.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants