Where the bytes go in a Z80 C compiler

uc80 compiles C for the Z80 and CP/M and produces smaller binaries than z88dk on every test of a public suite. Most of the difference comes from one decision about where local variables live — and from being honest about which part of that comparison is meaningful.

A CP/M program has 64 kilobytes of address space, minus the operating system, minus your data. Code size is not a vanity metric there; it is the difference between a program that loads and one that does not. So uc80, a C compiler targeting the Z80 and CP/M, optimizes for size ahead of speed.

AI-built. I directed this work; I did not write the code. How that works.

The published comparison is against z88dk with the SDCC backend at -SO3 --max-allocs-per-node10000, over the Fujitsu compiler test suite: uc80 is smaller on 47 of 47 tests, 170,496 bytes against 369,644 in aggregate.

Before that number does any work, it deserves to be taken apart, because part of it is real and part of it is an artifact.

Which half of that number is meaningful

Two of the sample results:

Programuc80z88dkRatio
hello world (puts)2565,1725%
printf("%d")4,6087,69660%
integer math5,2487,94866%

The 5% is barely a compiler result. A hello-world binary is almost entirely runtime, and the two projects disagree about what a CP/M runtime should contain — z88dk's default target pulls in machinery uc80 simply does not have. Quoting that ratio as though it measured code generation would be dishonest.

The 60–66% figures are the ones that mean something. Those programs are large enough that generated code dominates the runtime, and a third of the bytes are still gone.

Both numbers reproduce. On this machine, from a stock pip install uc80:

$ printf 'int main(void){return 0;}' > min.c
$ uc80 min.c -o min.mac && um80 min.mac -o min.rel
$ ul80 min.rel $LIB/libc.lib $LIB/runtime.lib -o min.com
$ stat -c%s min.com
128

128 bytes for a complete CP/M executable, 256 for one that prints a string.

Where the bytes actually go

The largest single win is a decision about local variables.

The conventional way to compile a function on the Z80 is the way you would on any machine with an index register: push IX, point it at the stack, address locals as (IX+n). It is correct, it is re-entrant, and on this processor it is expensive. Every access to a 16-bit local costs two indexed instructions:

    ld  (IX+4),L        ; DD 75 04 — 3 bytes, 19 T-states
    ld  (IX+5),H        ; DD 74 05 — 3 bytes, 19 T-states

Six bytes and 38 T-states to store one int. The same store to a fixed address is one instruction:

    ld  (??AUTO+0),HL   ; 22 nn nn — 3 bytes, 16 T-states

Half the bytes, and it happens on every local access in the program.

The catch is obvious: a static slot is not re-entrant. A recursive function, or two functions live at the same time, would trample each other. So uc80 builds the call graph across the whole program, works out which functions can never be simultaneously live, and lets those functions share the same storage. Functions that genuinely need a stack frame still get one.

You can watch it happen. Two functions, neither calling the other:

int f(int a) { int x = a * 2; return x + 1; }
int g(int a) { int y = a * 3; return y + 2; }
int main(void) { return f(1) + g(2); }

Both locals compile to the same address, and the program reserves two bytes total for all of its automatic storage:

_f:  ...  ld  (??AUTO+0),HL
_g:  ...  ld  (??AUTO+0),HL

??AUTO:
     ds  2

Note what did not change: parameters still arrive on the stack and are read through IX. Only the locals were moved. That is a deliberately narrow optimization — it takes the frequent, cheap-to-analyze case and leaves the calling convention alone.

This is the same trick ucow, my Cowgol compiler, uses for the same reason, and it is an old idea: it is roughly what FORTRAN did before anyone needed recursion. The Z80 makes it worth reviving because the gap between "address a fixed location" and "address a stack slot" is unusually wide on this instruction set.

The rest of it

Nothing else is a single large win; the remainder is accumulation.

Whole-program mode. Compiling every translation unit in one invocation is what makes the call-graph analysis above possible at all, and it enables dead function elimination across file boundaries. Separate compilation still works and produces correct, larger code.

Interprocedural constant propagation, then AST-level simplification and strength reduction, before any instruction selection happens.

Assembly-level dead code elimination, which catches what the earlier passes could not see because it was created by instruction selection.

Peephole optimizationupeepz80 shortens jp to jr where the displacement fits, folds counted loops into djnz, rewrites call/ret pairs as jp, and removes dead stores. Each rule saves one or two bytes and none of them is interesting alone.

A libc that was written for this target rather than ported to it. On the minimal-binary end of the table that is most of the difference, which is exactly why the minimal-binary comparison should not be quoted as a compiler result.

The part worth stealing

The reusable idea is not the static-locals trick, which only pays off on architectures with this particular cost structure. It is that the trick was findable at all.

Size was the objective, so it was measured, on somebody else's test suite, with the competing compiler's optimizer turned up. That produces a number that can go down, that can be checked, and that makes it obvious which changes were worth keeping. "Optimizes for small code size" is a claim about intent. 47 of 47 on a public suite is a claim about outcomes, and only one of those is falsifiable.

There is a reason I am insistent about this. I did not write uc80; I directed AI to write it. That makes a suite of 47 independently-published tests less a scorecard than a steering wheel — it is the thing that tells you whether the last change was an improvement, when you are not going to review the generated Z80 by eye and could not reliably judge it if you did. Take the benchmark away and you are choosing between code-generation strategies on vibes, which at this layer means choosing badly and not finding out for months.

The corollary is that the benchmark has to be one you cannot quietly adjust. Nobody is fooled by a compiler that wins on its author's own tests.

uc80 shares its C23 frontend with uc386, which targets i386 and MS-DOS, through uc_core — the frontend and AST optimizer are common, and only the backend differs.