Skip to main content

uplm80 - PL/M-80 Compiler

PyPI version Tests Pylint License: GPL v3

A modern PL/M-80 compiler targeting Zilog Z80 assembly language.

PL/M-80 was the primary systems programming language for CP/M and other 8080/Z80 operating systems. This compiler can rebuild original CP/M utilities from their PL/M source code.

Repository: https://github.com/avwohl/uplm80

Features

  • Full PL/M-80 language support
  • Targets Z80 instruction
  • Multi-file compilation with cross-module optimization
  • Multiple optimization passes (peephole, post-assembly tail merging)
  • Generates relocatable object files compatible with standard CP/M linkers
  • Produces code competitive with the original Digital Research compiler

Code Quality

Compiled output is comparable to the original Digital Research PL/M-80 compiler:

Program DR PL/M-80 uplm80 Difference
PIP.COM 7424 bytes 7127 bytes -4.0%

Installation

Quick install from PyPI:

pip install uplm80 um80 upeepz80

Platform-specific guides:

Or install from source:

git clone https://github.com/avwohl/uplm80.git
cd uplm80
pip install -e .

Usage

Compile PL/M-80 to Assembly

uplm80 input.plm -o output.mac

Or run as a module:

python -m uplm80.compiler input.plm -o output.mac

Options:

  • -m cpm, -m bare or -m mpm - Runtime mode (default: cpm)
    • cpm: For new PL/M programs, maximum stack under BDOS
    • bare: Original Digital Research compatible (jump to start-3)
    • mpm: MP/M II relocatable modules (.PRL, .RSP, .SPR)
  • -o output.mac - Output file name
  • -O 0|1|2|3 - Optimization level (default: 2)
  • -D SYMBOL - Define conditional compilation symbol (can be repeated)

Multi-File Compilation

Compile multiple source files together for optimal cross-module optimization:

uplm80 main.plm helper.plm library.plm -o output.mac

When multiple files are provided:

  • All files are parsed together before code generation
  • A unified call graph is built across all modules
  • Procedures that are never active at the same time share storage for their parameters and for the locals that may share it (??AUTO, see Procedure locals), across module boundaries
  • Each module keeps its own name space, as if it were compiled alone and linked: a name that is not PUBLIC or EXTERNAL is qualified with the module's name in the output (LIB?HELPER), and PUBLIC and EXTERNAL names bind across the modules (see docs/multi_file_compilation.md)
  • A single combined output file is generated

This produces better code than compiling files separately, as the compiler can share local variable storage between procedures in different modules that never call each other.

Use your preferred Z80 assembler and linker. Example with um80/ul80:

um80 output.mac                              # Assemble to .rel
ul80 -o program.com output.rel               # Link to CP/M .com

The module carries the runtime routines it uses (see Runtime Library); link with it only what defines the names it declares EXTERNAL.

Language Reference

PL/M-80 is a typed systems programming language with:

  • Data types: BYTE (8-bit), ADDRESS (16-bit)
  • Variables: Scalars, arrays, structures, BASED variables (pointers)
  • Control flow: DO/END, DO WHILE, DO CASE, IF/THEN/ELSE
  • Procedures: With parameters, local variables, recursion
  • Built-in functions: HIGH, LOW, DOUBLE, SHL, SHR, ROL, ROR, etc.
  • I/O: INPUT, OUTPUT for port access

Example:

hello: DO;
    DECLARE message DATA ('Hello, World!$');
    DECLARE i BYTE;

    print: PROCEDURE(addr) PUBLIC;
        DECLARE addr ADDRESS;
        /* CP/M BDOS print string */
        CALL mon1(9, addr);
    END print;

    CALL print(.message);
END hello;

See examples/hellocpm.plm for a complete working example. A drop-in Makefile that drives the full uplm80 → um80 → ul80 pipeline (with optional ud80/ux80 disassembly targets) is available at docs/example.Makefile — contributed by Martin Homuth-Rosemann (@Ho-Ro, issue #5).

For more on CP/M BDOS usage, see docs/BDOS_REFERENCE.md.

Conditional Compilation

PL/M-80 v4.0 added conditional compilation. The directives are control lines — a leading $ at the left margin (column 1), exactly like $INCLUDE and $TITLE — so the same source can target different configurations (e.g., CP/M 2.2 vs CP/M 3, single-user vs MP/M). No enabling directive is required.

Directives

Directive Description
$SET (NAME) Define a symbol
$RESET (NAME) Undefine a symbol
$IF NAME Compile following code if NAME is defined
$ELSEIF NAME Else-if branch
$ELSE Else branch
$ENDIF End conditional block
$COND / $NOCOND Listing controls only (accepted as no-ops)

A comment-wrapped form (/** $if NAME **/) is also accepted for CP/M-3-style sources that embed the same directives in comment syntax.

Example

$set (CPM3)
DECLARE
$if CPM3
    VERSION LITERALLY '30H',
$else
    VERSION LITERALLY '22H',
$endif
    MAXFILES BYTE;

Command Line

Symbols can also be defined from the command line:

uplm80 pip.plm -D CPM3 -D MPM -o pip.mac

Runtime Library

A module carries the runtime routines it uses at the end of its code (uplm80/runtime.py):

Routine Description
??mul16 16-bit multiply, HL = HL * DE
??div16, ??mod16 16-bit divide and remainder, as DRI's PL/M-80 computes them
??mul8 8-bit multiply
??move Block memory move (MOVE)
??subde 16-bit subtract, HL = HL - DE
??jpde A CALL through an address (CALL q, Programming Manual 8.2.1)
??inp, ??outp INPUT and OUTPUT of a port that is not a constant

Names in the Output

A module-level name is its own name in the assembly, and a procedure's names are @proc$name, but for the parameters and locals that share storage in ??AUTO (see Procedure locals). A static parameter is also named by an EQU, ?@proc$name, which costs nothing: upeepz80 drops the store of an argument at a procedure's entry when nothing else names its storage, and a pointer from the parameter before it can reach it. Where PL/M-80 would let two declarations meet in one assembler name - a label in each of two DO blocks, a procedure in each of two blocks, a LITERALLY in each of two procedures, a procedure or label in a DO block named like a local or a parameter of the procedure around it - one is renamed NAME?2 (no PL/M-80 identifier has a ?): a LITERALLY before a label, a label before a procedure, a procedure before a variable. A name the assembler reads as a register or an operator (A, HL, EQ, NUL, ...) is @NAME. uplm80/names.py binds every name to the declaration PL/M-80 means and checks every GOTO against the Programming Manual (9.3): out of a procedure only to a label at the outer level of the main program module.

Runtime Modes

The first 100H bytes of a CP/M program's memory are reserved by the operating system (zero page, default FCB, default DMA buffer). All CP/M .COM programs load at address 100H, so a CP/M binary's contents start at offset 0 of the file — the linker takes care of relocating to 100H. PL/M source files should not declare 100H: themselves; doing so causes the assembler to emit a cseg org 100H, which the linker then honors by padding the binary with 256 zero bytes from 0–FFH. See the bare mode notes below for the one situation where a leading address constant is meaningful.

CP/M Mode (default: -m cpm)

The mode to use for new PL/M-80 programs. The compiler emits a small entry preamble that takes maximum stack space under BDOS and returns cleanly to CP/M:

  • The compiler emits the entry code at the start of the .com image — do not write 0100H: in your source. The linker (ul80) defaults to origin 100H, which is what CP/M wants.

  • Entry preamble (auto-generated):

    ld   hl,(6)       ; load BDOS base from address 6
    ld   sp,hl        ; stack grows down from just below BDOS
    call MAIN         ; run your main procedure
    jp   0            ; warm-boot return to CP/M when MAIN returns
    
  • Stack: maximum available — everything between program end and BDOS.

  • Requires CP/M stubs in your runtime: MON1, MON2, MON3, BOOT.

  • System variables: BDISK, MAXB, FCB, BUFF, IOBYTE.

Bare Metal Mode (-m bare)

The mode required to rebuild original Digital Research utilities (PIP.PLM, ED.PLM, etc.) byte-compatibly. These programs follow the Intel PL/M-80 convention of jumping to start − 3 to skip over a local stack area:

  • Entry preamble (auto-generated):

    ld   sp,??STACK   ; a 64-byte stack in the data segment (see Memory Layout)
    jp   MAIN         ; jump (not call) into MAIN
    
  • The program controls its own exit — no automatic warm boot. Original DR utilities reboot or chain by writing to memory directly.

  • Custom entry points: because the entry preamble lives in the first few bytes of the image, original programs sometimes prepend a DECLARE … DATA(...) block to forge a different jump (see PIP.PLM, which fakes a JMP table at page 1). In bare mode the leading address constant (e.g. 0100H: or 0200H:) is meaningful — it sets the assembler org for the bare image.

  • Compatible with original Intel/DR PL/M-80 sources.

MP/M Mode (-m mpm)

For MP/M II page-relocatable modules. MP/M gives each process a memory segment and puts its page zero at the segment's base, so the page-zero addresses a program uses have to be relocated when it loads — and only a resolved symbol reference reaches a .PRL relocation bitmap.

  • Page-zero references are emitted as externals: ??BDOS (0005H), ??MAXB (0006H) and ??BOOT (0000H). Link with a small module that defines them at those addresses, using ul80 --prl (transient, linked at 100H) or ul80 --spr (system page, linked at 0); ul80 marks resolved page-zero symbol references for relocation.

  • Entry preamble (auto-generated), one three-byte instruction as DRI's PL/M-80 emitted — DRI's sources enter themselves by a jump to .start-3:

    ld   sp,??STACK   ; 512-byte stack carried in the image, before the variables
    
  • AT(.MEMORY) storage lies past the image; give the .PRL the memory it needs with ul80 --extra, as DRI did with GENMOD's third argument.

Memory Layout

A module is laid out the way Intel's PL/M-80 lays a program out, with the variables last:

  • Code segment (cseg): a BARE or MP/M module's own DATA first (DRI's programs begin with the jump byte data (0c3h) they enter themselves by), then the entry code, the procedures and the runtime routines, then every constant: string literals, .(...) lists, DATA declared in a procedure and, in CP/M mode, the module's own DATA, which there must not come before the entry code at 100H.
  • Data segment (dseg): ??AUTO, the procedures' shared locals; then the stack of BARE and MP/M modes (??STACK); then the variables, in the order the source declares them, a procedure's static locals among them.

ul80 places every module's data segment after all the code segments, including those of the runtime modules linked after it, so nothing follows a program's last variable and .MEMORY (the linker's __END__) is one past it. DRI's programs rely on that: MP/M II's SUB.PLM and MSPL.PLM use everything from their last variable up to MAXB as a buffer.

Procedure locals

PL/M-80 allocates a procedure's variables statically (Programming Manual, 8.1.7): a local keeps its value from one call to the next, and a program may count on it - a first-time flag, a running count. uplm80 saves memory by overlaying the storage of procedures that are never active at the same time, ??AUTO, but only for what no call can see the old value of:

  • Parameters, which every call assigns, unless a rule below makes one static.
  • A local that every call assigns before anything reads it. The compiler follows each procedure's statements, GOTOs included, and counts a call of a nested procedure that names the local as a read of it at the call; a use of a BASED variable reads its base. An array or structure is assigned once every element has been, through constant subscripts.

Every other local is static, @proc$name among the variables:

  • one that may be read before it is assigned;
  • one whose address is taken (.x, in a statement, an AT or an INITIAL) - a parameter too;
  • one named by a procedure nested in its own whose address is taken, or by anything that procedure calls: a call through the address can come when the procedure the local belongs to is not active;
  • one reached outside its bounds through a subscript: a scalar with any subscript but (0), an array (or an array member of a structure) with a constant subscript past its end, and a one-element array with any subscript but a constant 0. A subscript is a constant when it folds to one - a(1+1), a(-1) (which is a(255)), a(LAST(a)) - and it is decided the same way at every optimization level;
  • one declared with a static local in a factored declaration (6.2.4 makes those contiguous).

A local with INITIAL or PUBLIC is static in any case; a REENTRANT procedure's are on the stack.

DRI lays out what a procedure's text declares in the order it declares it: its parameters and locals, and among them the parameters and locals of the procedures nested in it and the variables of its DO blocks, where the text has them (MP/M II's SUBMIT has FILLRBUFF's ssbp at 0E7AH, the parameter of PUTRBUFF, declared next, at 0E7BH, and reading, which FILLRBUFF declares after PUTRBUFF, at 0E7CH). uplm80 keeps that order - the static locals among the variables in the order of the text, the others in the procedure's frame in ??AUTO, the parameters first, as the PROCEDURE statement lists them - wherever a program can tell:

  • every local declared after one whose address is taken, or which is reached outside its bounds, is static too: a pointer run on from it reaches them;
  • an array or structure subscripted by anything but a constant may run on into the locals declared after it, so from the first such array or structure on, a procedure's locals are all static or all in ??AUTO: if any of them is static (an INITIAL one included), all of them are. A read through such a subscript counts as a read of every local it can run on into;
  • a frame in ??AUTO holds only its procedure's own locals, so where a procedure with parameters or locals, or a DO block with variables, is declared after the local that either of those starts from, what comes after that local is static, the nested procedure's storage too, in the order of the text.

A subscript or pointer that runs backwards, before the variable, is not covered, nor is one that runs past a procedure's last local, or past a module-level variable into the procedures declared after it.

The analysis is in uplm80/local_storage.py.

Two procedures' frames in ??AUTO overlap only if the procedures are never active at the same time, which the call graph decides. It has the calls the program's text does not name as well: a CALL through an address (8.2.1) may call any procedure whose address is taken; an EXTERNAL procedure may call back any PUBLIC procedure and any whose address is taken (a call of MON1 or MON2 with a constant function is a call of the BDOS, which calls nothing back); and an INTERRUPT procedure, and everything it calls, may run while any procedure is active, so their frames overlap no other.

Project Structure

uplm80/
├── compiler.py      # Main compiler driver / CLI
├── frontend.py      # plox-driven lexer + LR parse → AST
├── preprocess.py    # PL/M preprocessor ($INCLUDE, $if, LITERALLY, ...)
├── ast_nodes.py     # AST definitions
├── ast_optimizer.py # AST-level optimizations
├── codegen.py       # Z80 code generator
├── local_storage.py # Which procedure locals may share ??AUTO
├── names.py         # Which declaration each name means; GOTO rules; assembler names
├── runtime.py       # Runtime helpers
├── symbols.py       # Symbol table
├── errors.py        # Diagnostic exception types
└── data/            # Pre-built plox grammar bundle (plm_full.json)

Peephole optimization is provided by the external upeepz80 package; the front-end is generated from plox grammars (plm_pre + plm_full) and loaded at import time from the JSON bundle in data/.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.

  • 80un - Unpacker for the CP/M archive and compression formats LBR, ARC, squeeze, crunch, and CrLZH.
  • cpmdroid - Z80/CP/M emulator for Android phones and tablets. It emulates the RomWBW HBIOS interface and a VT100 terminal.
  • cpmemu - Z80/CP/M emulator for Linux and Windows, with Z80 and 8080 CPU cores. It translates the BDOS and BIOS calls of CP/M 2.2 programs to the host file system.
  • ioscpm - Z80/CP/M emulator for iOS and macOS. It emulates the RomWBW HBIOS interface and runs CP/M 2.2 and CP/M 3.
  • learn-ada-z80 - Collection of more than 90 Ada example programs for uada80, the Ada compiler for the Z80 processor and CP/M.
  • mbasic - Python interpreter for MBASIC 5.21, the Microsoft BASIC-80 for CP/M. Two compiler backends compile the programs to CP/M .COM files or to JavaScript.
  • mbasic2025 - Reconstruction of the lost source code of MBASIC 5.21, the Microsoft BASIC-80 for CP/M. The MACRO-80 source code assembles to a binary that matches mbasic.com byte for byte.
  • mbasicc - C++17 interpreter for MBASIC 5.21, the Microsoft BASIC-80 for CP/M. It runs on Linux and macOS.
  • mbasicc_web - Web browser interpreter for MBASIC 5.21, the Microsoft BASIC-80 for CP/M. Emscripten compiles the mbasicc interpreter to WebAssembly.
  • mpm2 - Z80 emulator for MP/M II, the multi-user CP/M operating system. Users connect over SSH, and SFTP clients transfer files.
  • romwbw_emu - Hardware-level Z80/CP/M emulator for Linux and macOS. It emulates the RomWBW HBIOS interface and switches banks in 512 KB of ROM and 512 KB of RAM.
  • scelbal - Floating-point BASIC interpreter for the 8080 processor and CP/M. A translator converts the original 8008 source code to 8080 source code.
  • uada80 - Ada compiler for the Z80 processor and CP/M 2.2. It compiles a subset of Ada 2012 to CP/M .COM files.
  • uc80 - C compiler for the Z80 processor and CP/M. It optimizes for small code size.
  • ucow - Cowgol compiler for the Z80 processor and CP/M. It runs on Linux in Python.
  • um80_and_friends - Linux toolchain that is compatible with Microsoft MACRO-80. It has an assembler, a linker, a librarian, and a disassembler.
  • upeepz80 - Peephole optimizer for Z80 compilers that write lowercase Z80 assembly language. It shortens jumps to jr, builds djnz loops, and removes dead stores.
  • uplox - LR(1) and GLR parser generator. It writes a lexer DFA, an LR parser, and a typed automatic AST, and uplm80 uses it to parse PL/M-80.
  • z80cpmw - Z80/CP/M emulator for Windows. It emulates the RomWBW HBIOS interface and boots CP/M from disk images.

Release files for uplm80 0.3.7

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for uplm80 0.3.7
File Size Uploaded
uplm80-0.3.7.tar.gz 295.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for uplm80 0.3.7
File Interpreter ABI Platform
uplm80-0.3.7-py3-none-any.whl Python 3 none any Details

Total release size: 512.6 kB

Release files / uplm80-0.3.7.tar.gz

Download URL uplm80-0.3.7.tar.gz
Size 295.6 kB
Tags Source
SHA-256 checksum
How to use checksums
115cf1564fcc4eff4c6a84854fb0eb50238297607e24459e12b02e1acddca6b4
BLAKE2b-256 checksum
How to use checksums
b32e6becd28157ca2b4bd9f7c146146c9ceb187a837afe3fab23f72a1da1e043
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / uplm80-0.3.7-py3-none-any.whl

Download URL uplm80-0.3.7-py3-none-any.whl
Size 217.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c34b32ae629c2880ab161062f6608553de8c75d8e2d77ae37d0e7d7fb813f555
BLAKE2b-256 checksum
How to use checksums
ed02670555f166ff347b0cb398b0efb4c3b9839e6260c96cba5e1b850710f87f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page