uc80 C compiler for z80 CP/M

uc80 C compiler for z80 CP/M
The Mandelbrot set, rendered on a Feersum Microbeast

Recently I covered the Minz retro-compiler which made some grand claims about code size and performance, but wasn't quite able to back them up. Not yet at any rate, but it's exciting technology that I'll keep my eye on.

Meanwhile, I stumbled across uc80 which bills itself as:

"a C compiler for the z80 processor and CP/M. It optimises for small code size, and it made smaller binaries than z88dk in all 47 tests of a public test suite.

z80 on CP/M eh? That's right in my wheelhouse! Let's give it a go.

😡
The project I'm about to discuss includes significant contributions from AI. If this enrages you, press STOP, then press EJECT.

Installation

Oddly, the uc80 is written in Python, but let's not hold that against it. I've learned that the best way to sandbox Python and stop it running amok on your system is to use uv. First set up an environment and activate it:

uv venv
source .env/bin/activate

then install uc80 and its dependency um8. um8 is a clean room implementation of a Linuxc version of the classic Microsoft MACRO-80 assembler, including the linker, cross-reference tool and library archiver. It also includes a disassembler ud80 (I'm not sure if that was also in the original, or if that's a modern addition). um8 is quite a major accomplishment in its own right, and uc8 uses the assembler and linker as its backend.

uv pip install uc80
uv pip install um80

Taking it for a spin

That's it for setup. Next I copied the hello.c file from uc8's example directory:

/* Hello World in C */

void puts(char *s);

int main(void) {
    puts("Hello, World!");
    return 0;
}

Nothing too taxing, but I've seen other compilers fail on less.

To compile this to a z80 .com is fairly straightforward:

# set an env var to locate the C library, runtime library, and crt0
LIB=$(uc80 --print-lib-dir)

# compile C to assembler
uc80 hello.c -o hello.mac

# assemble it and produce a relocatable object file
um80 hello.mac -o hello.rel

# link it against runtime libs
ul80 hello.rel $LIB/libc.lib $LIB/runtime.lib -o hello.com

.mac is what they call assembler files hereabouts, and here's what it generated:

 C24 Compiler Output - hello
; Target: Z80
; Generated by uc80

        .z80


; Embedded startup code (crt0)
; crt0.mac - C Runtime Startup for CP/M
; uc80 C Compiler
;
; This is the startup code that:
; 1. Sets up the stack from BDOS
; 2. Zeros BSS (COMMON segment - uninitialized static storage)
; 3. Calls main()
; 4. Returns exit code to CP/M



        PUBLIC  __start

        EXTRN   __bss_start
        EXTRN   __bss_end

; CP/M program entry point (at 0100H after linking)
        EXTRN   _puts
        PUBLIC  _main
        PUBLIC  __printf_format_table
        PUBLIC  __printf_long_table
        PUBLIC  __printf_ll_table
        EXTRN   _puts
__start:
        ; Get stack pointer from BDOS (address 6 contains top of TPA)
        LD      HL,(6)
        LD      SP,HL

        ; Zero BSS region (COMMON segment)
        ; C standard requires static storage to be zero-initialized
        LD      HL,__bss_end
        LD      DE,__bss_start
        OR      A
        SBC     HL,DE           ; HL = bss size
        LD      B,H
        LD      C,L
        LD      A,B
        OR      C
        JR      Z,__nobss       ; skip if no BSS
        LD      HL,__bss_start
        LD      (HL),0
        DEC     BC              ; size - 1 (first byte already set)
        LD      A,B
        OR      C
        JR      Z,__nobss       ; skip LDIR if only 1 byte
        LD      D,H
        LD      E,L
        INC     DE              ; DE = bss_start + 1
        LDIR                    ; fill with zeros
__nobss:

        ; Call main - return value left in HL (16-bit), DEHL (32-bit), or
        ; __tmp64 (64-bit) depending on the main() return type width.
        CALL    _main

        ; Return to CP/M via warm boot.  CP/M has no standard register
        ; convention for passing a program exit status, so the return
        ; value from main() is discarded here regardless of its width.
        JP      0




; Function main
_main:
        push    IX
        ld      IX,0
        add     IX,SP
        ld      HL,@STR1
        push    HL
        call    _puts
        pop     DE
        ld      HL,0
@main_ret:
        ld      SP,IX
        pop     IX
        ret


; Printf format dispatch tables (#pragma printf)

__printf_format_table:
        db      0               ; sentinel

__printf_long_table:
        db      0               ; sentinel

__printf_ll_table:
        db      0               ; sentinel
__printf_ll_entry:
        ret

; Implicit external functions

        DSEG
@STR1:
        db      'Hello, World!',0

        end%

Nothing too startling. It's pretty verbose and relatively easy to follow. Because we compiled a single file, uc80 assumed we want "whole program mode" and it automatically included the crt0.lib that it's bundled with. We see a small part of this library doing our BSS init ("zero BSS region") and because the compiler does tree-shaking that strips out unused code, that's pretty much the only footprint.

The .rel file is a relocatable object file, and contains little of interest. I used the included ud80 tool to disassemble the final hello.com binary, and didn't find any nasty surprises. The final executable is 191 bytes (0 padded up to the CP/M record size). That's not bad at all. But does it work? Let's try it on the MicroBeast!

Hello World C program running on the MicroBeast

By Jove, it works! I've been disappointed so often by this sort of thing that I was genuinely surprised by this result. I flipped through the rest of the examples, but they were a bit uninspiring to be honest: mostly unit tests drafted in to serve as example code. Yawnsville.

Kicking the tyres

I decided to give this compiler a much more serious test - the classic Mandebrot set. In ASCII, on a vt52 terminal. And because the compiler claims full float48, I implemented the iteration in floating point arithmetic. Here's the code:

/* mandel.c - interactive ASCII Mandelbrot explorer for CP/M 2.2 on Z80.
 *
 */
#pragma printf float

#include <stdio.h>

#define COLS       80
#define PLOT_ROWS  22           /* rows 0..21: the picture */
#define HELP_ROW   22
#define STATUS_ROW 23
#define ASPECT     2.0f         /

#define ESC   27
#define CTRL_C 3

/* Home view */
#define HOME_X   (-0.75f)
#define HOME_Y   0.0f
#define HOME_W   3.5f
#define HOME_IT  32

#define MIN_W    5e-4f
#define MAX_W    16.0f
#define MIN_IT   8
#define MAX_IT   1024

static const char ramp[] = " .,-~:;=!*#$%";
#define NRAMP  ((int)sizeof ramp - 1)
#define INSIDE '@'

/* ------------------------------------------------------------------ */
/* Console I/O via BDOS 6.  The asm blocks reach these through their     */
/* assembler names (_bdos_e, _bdos_a).                                   */

volatile unsigned char bdos_e;
volatile unsigned char bdos_a;

static unsigned char bdos6(unsigned char e)
{
    bdos_e = e;
    asm("\tPUSH\tIX\n"
        "\tPUSH\tIY\n"
        "\tLD\tA,(_bdos_e)\n"
        "\tLD\tE,A\n"
        "\tLD\tC,6\n"
        "\tCALL\t5\n"
        "\tLD\t(_bdos_a),A\n"
        "\tPOP\tIY\n"
        "\tPOP\tIX\n");
    return bdos_a;
}

static void out(char c)          { bdos6((unsigned char)c); }
static void outs(const char *s)  { while (*s) out(*s++); }

/* Returns the pending key, or 0 if none. */
static int poll_key(void)        { return bdos6(0xFF); }

static int wait_key(void)
{
    int k;
    while ((k = poll_key()) == 0);
    return k;
}

static int wait_key_briefly(void)
{
    for (unsigned n = 0; n < 2000; n++) {
        int k = poll_key();
        if (k)
            return k;
    }
    return 0;
}

/* VT52 */
static void clear_screen(void) { out(ESC); out('H'); out(ESC); out('J'); }
static void clear_eol(void)    { out(ESC); out('K'); }
static void goto_rc(int r, int c)
{
    out(ESC); out('Y'); out((char)(32 + r)); out((char)(32 + c));
}

static float view_x, view_y, view_w;
static int   max_it;
static int   cycle_palette;
static int   pending_key;       
static char  line[COLS];

static void reset_view(void)
{
    view_x = HOME_X;
    view_y = HOME_Y;
    view_w = HOME_W;
    max_it = HOME_IT;
}

static int iterate(float cr, float ci)
{
    float ci2 = ci * ci;

    float xq = cr - 0.25f;
    float q = xq * xq + ci2;
    if (q * (q + xq) <= 0.25f * ci2) return max_it;
    float xb = cr + 1.0f;
    if (xb * xb + ci2 <= 0.0625f) return max_it;

    float x = cr, y = ci, x2 = cr * cr, y2 = ci2;
    int n = 0;
    while (n < max_it && x2 + y2 <= 4.0f) {
        y = (x + x) * y + ci;
        x = x2 - y2 + cr;
        x2 = x * x;
        y2 = y * y;
        n++;
    }
    return n;
}

static char shade(int n)
{
    if (n >= max_it) return INSIDE;
    if (cycle_palette) return ramp[n % NRAMP];
    return ramp[(long)n * NRAMP / max_it];
}

static void status(const char *msg)
{
    char buf[COLS];
    snprintf(buf, sizeof buf, "x %.6f  y %.6f  w %.3e  it %d  %s",
             (double)view_x, (double)view_y, (double)view_w, max_it, msg);
    buf[COLS - 1] = '\0';       
    goto_rc(STATUS_ROW, 0);
    outs(buf);
    clear_eol();
}

static void help(void)
{
    goto_rc(HELP_ROW, 0);
    outs("pan:arrows/hjkl  zoom:+ -  sector:1-9  iter:< >  p:palette  r:reset  q:quit");
    clear_eol();
}

static int render(void)
{
    float dx = view_w / COLS;
    float dy = dx * ASPECT;
    int mirror = (view_y == 0.0f);
    int rows = mirror ? PLOT_ROWS / 2 : PLOT_ROWS;

    status("drawing");
    for (int r = 0; r < rows; r++) {
        float ci = view_y - ((float)r - PLOT_ROWS / 2 + 0.5f) * dy;
        goto_rc(r, 0);
        for (int c = 0; c < COLS; c++) {
            if ((c & 7) == 0) {
                int k = poll_key();
                if (k) {
                    pending_key = k;
                    return 0;
                }
            }
            float cr = view_x + ((float)c - COLS / 2 + 0.5f) * dx;
            line[c] = shade(iterate(cr, ci));
            out(line[c]);
        }
        if (mirror) {
            goto_rc(PLOT_ROWS - 1 - r, 0);
            for (int c = 0; c < COLS; c++)
                out(line[c]);
        }
    }
    status("ready");
    return 1;
}

static int read_command(void)
{
    int k;
    if (pending_key) {
        k = pending_key;
        pending_key = 0;
    } else {
        k = wait_key();
    }
    if (k != ESC) return k;

    k = wait_key_briefly();
    if (k == '[' || k == 'O')
        k = wait_key_briefly();
    switch (k) {
    case 'A': return 'k';
    case 'B': return 'j';
    case 'C': return 'l';
    case 'D': return 'h';
    }
    return 0;
}


static int zoom(float factor)
{
    float w = view_w / factor;
    if (w < MIN_W) {
        status("precision limit");
        return 0;
    }
    if (w > MAX_W) w = MAX_W;
    view_w = w;
    return 1;
}

int main(void)
{
    int dirty = 1;

    reset_view();
    clear_screen();
    help();

    for (;;) {
        if (dirty) render();
        dirty = 1;

        float height = view_w / COLS * ASPECT * PLOT_ROWS;
        int k = read_command();
        switch (k) {
        case 'h': case 'a': case 'H': case 'A':
            view_x -= view_w / 4; break;
        case 'l': case 'd': case 'L': case 'D':
            view_x += view_w / 4; break;
        case 'k': case 'w': case 'K': case 'W':
            view_y += height / 4; break;
        case 'j': case 's': case 'J': case 'S':
            view_y -= height / 4; break;
        case '+': case '=':
            dirty = zoom(2.0f); break;
        case '-': case '_':
            dirty = zoom(0.5f); break;
        case '1': case '2': case '3':
        case '4': case '5': case '6':
        case '7': case '8': case '9': {
            int s = k - '1';                  
            float old_w = view_w;
            dirty = zoom(3.0f);
            if (dirty) {
                view_x += (float)(s % 3 - 1) * old_w / 3;
                view_y += (float)(s / 3 - 1) * height / 3;
            }
            break;
        }
        case '<': case ',':
            if (max_it > MIN_IT) max_it /= 2;
            break;
        case '>': case '.':
            if (max_it < MAX_IT) max_it *= 2;
            break;
        case 'p': case 'P':
            cycle_palette = !cycle_palette; break;
        case 'r': case 'R':
            reset_view(); break;
        case ' ':
            break;
        case 'q': case 'Q': case CTRL_C:
            clear_screen();
            return 0;
        default:
            dirty = 0;               
            break;
        }
    }
}

This is a bit long-winded but conceptually simple. The actual fractal calculation will look familiar to anyone who's plotted a Mandelbrot set in any language. The rest of it is boiler plate to manage the vt52 output, including some nifty inline assembler to let me make CP/M BDOS calls from C. This was surprisingly easy to achieve.

With bated breath I compiled this up using exactly the same sequence as before, and tried to run it on the 'Beast:

Genuinely gobsmacked. It's not going to win any performance awards, taking a full 27 seconds to generate that screen, but the fact that it's working at all (and indeed, on the first attempt) is astonishing. The final binary weighs in at 17 Kbytes, and most of that is maths library routines.

How much of it is really implemented

I would estimate 90% coverage of C89/90/99/23/24. The bits that are missing are unlikely to be missed by anyone (complex numbers, multibyte/wide character functions, threads and atomics). But we have got bools, designated initialisers, binary literals, static_assert, and an almost complete stdlib. It has benchmark tests against three different compiler test suites with (I estimate) 95% passing, and the fails are "environmental, not codegen bugs:" - there's certainly enough here to do some serious work.

There are some pretty spectacular claims about comparitive binary sizes too:

Other goodies

As well as single precision floating point, you get 16, 32, 48 and 64 bit arithmetic libraries. The 48 bit maths (and I suspect also the floating point) routines were swiped from z88dk, but that's no bad thing; they have been extensively battle tested there.

We get rudimentary inline assembler support where the calling convention is just passing around global variables by their address, which is simple but effective. The IX register is used as the C frame pointer and must be preserved. It looks like there will be future support for operand register and clobber lists, but it's not there yet.

Another interesting feature is configurable integer sizes: by default an int is 16 bits, a long is 32 bits and a long long is 64 bits, but these are all configurable via command line flags to the compiler.

There's some clever stuff going on to optimise printf() calls that currently seems to generate a lot of redundant code: pretty much the opposite of the intended effect. Can't say I'm going to be using `printf()` much in this sort of environment, to be honest.

🤔
In fact, according to a deeply buried doc, this seems to be a known bug: using the printf() library pulls in all the floating point maths routines, regardless of whether you used %f anywhere or not. That's why I had to #pragma printf - you have to deliberately opt in. Not an issue in my Mandelbrot viewer, as I was using floats anyway, but if you're not you'll pay a 2,379 byte penalty!

Wrapping up

I'm very impressed with uc80's abilities, and I might try it for some larger scale projects. I particularly want to try a multiple .c file project, where each piece is compiled separately and they're all linked at the end.

Even more interestingly, uc80 could be an important step in my long-held dream of running Zig on the z80: if I can get the zig compiler to emit C code, and uc80 can compile that C code, job done! A bit long-winded, but significantly less depressing than the misery that comes from trying to understand the LLVM z80 code.

Comments