Skip to main content

Command Palette

Search for a command to run...

Command-line tools for the assembler

Updated
7 min read
Command-line tools for the assembler

ASM80 lives in the browser, and for most people that's fine. But if you want to run the assembler from a Makefile, a CI pipeline, or a script that also compiles C code for a Z80, clicking "Build" in a browser tab isn't going to cut it. That's what asm80-cli is for.

One npm install gives you four commands:

npm install -g asm80-cli

After that, you have asm80, asm80-link, asm80-ar, and asm80-run in your PATH. They share the same assembler core as the browser IDE — same syntax, same directives, same output. No surprises. The code that turns your mnemonics into bytes is literally the same JavaScript module, just invoked from Node instead of a <script> tag.

asm80

The assembler. Give it a source file and it produces Intel HEX (or Motorola S-Record for 6800/6809). The CPU is detected from the file extension — .z80 for Z80, .a80 for 8080, .a65 for 6502, and so on — so there's no flag for that. You just write:

asm80 hello.z80

and get hello.hex. If you want a relocatable object module instead (for linking later), pass --module:

asm80 --module mylib.a80

This produces mylib.obj80. Note the extension: it's .obj plus a CPU suffix, i.e. .objz80, .obj80, .obj65, .obj09. The linker needs to know what it's linking. Add --no-lst if you don't care about the listing file. The listing is mostly useful for debugging — it shows you the generated bytes next to each source line, so you can spot when a relative jump is out of range or an operand got encoded differently than you expected.

The linker takes a YAML recipe with a .lnk extension in the same format the ASM80 IDE uses and produces a single .hex file from multiple object modules. A typical .lnk looks like this:

segments:
  CSEG: '0x0000'
  DSEG: '0x8000'
vars:
  BIOS_PRINT: '0x5'
modules:
  - crt0.objz80
  - main.objz80
library:
  - z80-runtime.objz80
entrypoint: __start

The segments: section tells the linker where to place code and data. CSEG is the code segment, DSEG is for initialized data, the addresses are absolute, so you're laying out your binary exactly as it will sit in memory. If your ROM starts at 0x0000 and your RAM at 0x8000, those are the numbers you put here.

The vars: section injects symbols at link time, which is handy for BIOS entry points or hardware addresses you don't want hardcoded in your source. Your assembly code can reference BIOS_PRINT with an EXTERN directive, and the linker fills in the actual value. No magic numbers scattered across files.

Modules listed under library: only contribute code that's actually referenced, same as a static library in a C toolchain. If your runtime library defines 30 routines and your program calls two of them, only those two end up in the final hex. Modules listed under modules:, on the other hand, are always included in full — that's where your main code and startup routines go.

The entrypoint: field names the symbol where execution begins. The linker makes sure this address ends up as the first thing in the output, so the CPU starts running the right code after reset.

asm80-link project.lnk

asm80-ar

If you're building a library from several object modules, asm80-ar packages them together using a .lbr recipe:

name: mylib
version: 0.1.0
modules:
  - mathops.obj80
  - stringutils.obj80

The output extension depends on the CPU: .lib80 for 8080, .libz80 for Z80, .lib65 for 6502, .lib09 for 6809 etc. This mirrors the .obj convention, everything is tagged with what CPU it belongs to, so you can't accidentally link Z80 code into a 6502 project. (You'd be amazed how often that comes up.)

asm80-ar mylib.lbr

The result is a single file you can reference in any .lnk recipe under the library: key. Build the library once, use it in every project that needs it.

asm80-run

It's a command-line emulator. Hand it a source file or a .hex and it assembles (if needed), loads, and runs:

asm80-run hello.z80

Serial output goes to stdout, so you can pipe it, diff it, test it. The -T flag sets a tick limit, which is useful for automated tests where you don't want a runaway loop eating your CI minutes:

asm80-run -T 100000 hello.z80

For hardware configuration (memory layout, serial port addresses, peripherals) it reads .emu files, the same format the IDE uses. If you've already set up your project in the browser, the same config works here. Here's what a real .emu file looks like - this one comes from the IC80 test suite:

cpu: z80
memory:
  rom:
    from: '0x0000'
    to: '0x7FFF'
  ram:
    from: '0x8000'
    to: '0xFFFF'
serial:
  type: 6850
  mapped: port
  control: '0xDE'
  data: '0xDF'

The cpu: field tells the emulator which CPU to spin up: z80, 8080, 6502, 6809, ... Everything else follows from that choice: the instruction decoder, the register set, the interrupt model.

The memory: block defines the address space layout. rom: marks the region that gets loaded from the hex file and is read-only at runtime. Writes to ROM addresses are silently ignored, just like on real hardware. ram: marks the read-write region. In this config, the lower 32K is ROM and the upper 32K is RAM, which is a classic split for small Z80 systems. The emulator doesn't care about gaps, if you only define ROM from 0x0000 to 0x3FFF and RAM from 0xC000 to 0xFFFF, everything in between is unmapped and reads back as 0xFF.

The serial: section is where it gets useful for testing. type: 6850 tells the emulator to simulate a Motorola 6850 ACIA, the most common serial chip in homebrew 8-bit designs. The mapped: port field means the ACIA is accessed through I/O port instructions (IN/OUT on Z80) rather than memory-mapped I/O. The control: and data: fields give the port addresses: 0xDE for the status/control register and 0xDF for the data register. When your code writes a byte to port 0xDF, it shows up on stdout. When it reads the status register at 0xDE, the emulator reports whether there's input waiting. This is exactly how real 6850 hardware works — the emulator just replaces the physical UART with a pipe to your terminal.

So when asm80-run executes your program and the code sends characters out through the emulated 6850, those characters land in your shell. That's what makes it possible to write tests that check actual program output: assemble, link, run, capture stdout, compare.

All four in action: testing a C compiler

Here's where it gets concrete. IC80 is a C compiler I'm working on that targets 8-bit CPUs. Its test suite (run-test.ps1) exercises the entire pipeline with all four tools:

  1. IC80 compiles a .c file to Z80 assembly

  2. asm80 assembles the .z80 source and the runtime (crt0.z80, libc.z80) into .objz80 modules

  3. The script generates a .lnk recipe and asm80-link links everything into a .hex

  4. asm80-run loads the hex, runs the emulator with a tick limit, and the script checks the serial output against expected results

The tick limit matters here. Each test case gets 100,000 ticks, enough to run any reasonable C test program to completion, but short enough that an infinite loop bails out in milliseconds instead of hanging the build. If the output doesn't match, the test fails. If the emulator hits the tick limit before the program terminates, the test also fails.

The whole thing runs in a few seconds. No browser, no GUI, no clicking. Just a script that compiles C, assembles Z80, links, runs, and tells you if the output is correct. That's the kind of thing asm80-cli was built for.