eZX Spectrum — Assembler
Reference for ezxasm, the eZX-RV32 assembler. For the CPU and the custom extensions see cpu, ezx_xcrisp, ezx_xmath, ezx_xstack, ezx_xlate, ezx_xctx, and ezx_xez. For the hardware platform see spec.
Overview
ezxasm is a standard RV32 assembler with the eZX custom extensions added — Xcrisp (compiler-friendly memory and ALU fusion), Xmath (fused MAC, saturating arithmetic, min/max, distance heuristics, multi-precision carry, plus single-precision FP approximations, BAM trigonometry, 2D/3D vector bundles, animation easing, and quaternion math when F is present), Xstack (BSRAM-backed hardware stack with multi-register push/pop), Xlate (per-register load/store translators), Xctx (hardware context pool), and Xez (Z80-ergonomics with ezflags flag model). Standard F (single-precision floating-point) is also supported with full RV-F instructions and the f0–f31 register file. Source files are conventional RISC-V assembly — same directives, same lexical conventions, same operand syntax. Where ezxasm differs from a stock RV32 assembler is in the custom instruction families and in the eZX-specific section layout and reset vector conventions.
Register naming follows the standard RISC-V ABI throughout — for integer: zero, ra, sp, gp, tp, t0–t6, s0–s11, a0–a7, fp (alias for s0). For floating-point: ft0–ft11, fs0–fs11, fa0–fa7. Numeric forms x0–x31 and f0–f31 are accepted but the ABI names are preferred.
The output is a RISC-V ELF object file compatible with standard linkers; the eZX toolchain ships a configured lld for final linking, but binutils ld works equally well with the appropriate linker script.
Source-level compatibility with earlier eZX drafts
Earlier eZX drafts had ADDC/SUBC/RL/RR/RLC/RRC as native Xez instructions and PUSHM/POPM for multi-register push/pop. The Ant64 EE extension set now covers those patterns (in Xmath G12, Zbb, and Xstack respectively), so the eZX has moved them upstream. ezxasm accepts the old Xez mnemonics and lowers them to the new homes:
| Source mnemonic | Lowers to | Encoding home |
|---|---|---|
ADDC / SUBC |
ADDC / SUBC (Xmath G12) |
Xmath |
RL / RR |
ROLC / RORC (Xmath G12) |
Xmath |
RLC / RRC |
rol / ror (Zbb) |
standard Zbb |
PUSHM / POPM |
PUSH / POP (Xstack) |
Xstack |
SCF / CCF |
csrrsi / csrrci on xcarry |
direct CSR op |
Source written for older eZX drafts assembles correctly with no edits. The bytes emitted differ; the semantics match. See ezx_xez §9 for the full table.
Lexical structure
Comments
Two comment styles:
; line comment to end of line
// also a line comment
/* block comment */
Identifiers
[A-Za-z_.$][A-Za-z0-9_.$]* — starts with letter, underscore, period, or dollar; continues with the same characters or digits. Identifiers are case-sensitive.
Numeric literals
| Form | Example |
|---|---|
| Decimal | 42, -17 |
| Hexadecimal | 0x1F, 0xff, $1F (eZX historical form) |
| Binary | 0b1010, %1010 (eZX historical form) |
| Octal | 0o755 |
| Character | 'A', '\n', '\t', '\0' |
The $ prefix and % prefix are accepted alongside the standard C-style forms because they're traditional in Z80 source and many eZX users will arrive with that habit.
String literals
.asciz "Hello, world\n"
.ascii "no terminator"
Standard C escape sequences: \n, \r, \t, \\, \", \', \0, \xNN.
Whitespace
Whitespace separates tokens. Line breaks end statements. A backslash at the end of a line continues the statement onto the next line.
Source layout
Statements
One statement per line. A statement is either:
- A label — identifier followed by colon
- A directive — starts with a period
- An instruction — mnemonic followed by operands
- A combination — label followed by an instruction or directive on the same line
my_label:
addi a0, a0, 1
ret
loop: lb.pi a1, (a0) ; label and instruction on one line
dbnz a2, loop
Labels
A label is an identifier ending in :. Labels mark addresses in the current section.
Local labels are integers ending in :. They can be referenced by Nf (forward) or Nb (backward):
1: lb.pi a1, (a0)
cmpi a1, 0
bz 2f ; branch forward to 2:
addi a3, a3, 1
j 1b ; branch backward to 1:
2: ret
Local labels are useful inside macros and tight loops where named labels would clutter the namespace.
Sections
.text ; code section
.data ; initialised data
.rodata ; read-only data
.bss ; uninitialised data
.section .name, "axrw" ; explicit section with attributes
Section attributes follow the GNU as convention. Common attributes: a (allocated), x (executable), r (readable), w (writable), M (mergeable), S (strings).
Default starting section is .text.
Alignment
.align n ; align to 2^n bytes
.balign n ; align to n bytes (byte alignment)
.p2align n ; align to 2^n bytes (explicit power-of-two)
.align 4 aligns to a 16-byte boundary. The leading bytes are filled with zeros in data sections and with nop in text sections.
Registers
The standard RISC-V ABI register names are the canonical form:
| Name | Number | Role |
|---|---|---|
zero |
x0 |
Hardwired zero |
ra |
x1 |
Return address |
sp |
x2 |
Stack pointer |
gp |
x3 |
Global pointer |
tp |
x4 |
Thread pointer |
t0–t2 |
x5–x7 |
Temporaries (caller-saved) |
s0 / fp |
x8 |
Saved register / frame pointer |
s1 |
x9 |
Saved register |
a0–a7 |
x10–x17 |
Arguments / return values |
s2–s11 |
x18–x27 |
Saved registers |
t3–t6 |
x28–x31 |
Temporaries (caller-saved) |
Numeric forms x0 through x31 are also accepted but the ABI names are strongly preferred. Mixing forms in the same file works but reads inconsistently.
ezflags is accessed via CSR operations:
csrr a0, ezflags ; read ezflags into a0
csrw ezflags, a0 ; write a0 into ezflags
csrrw a0, ezflags, a1 ; atomic swap
The assembler recognises ezflags as a CSR name (mapped to CSR address 0x809).
Directives
Data definition
| Directive | Effect |
|---|---|
.byte v[, v...] |
Emit one or more bytes |
.half v[, v...] |
Emit one or more 16-bit halfwords (little-endian) |
.word v[, v...] |
Emit one or more 32-bit words |
.ascii "..." |
Emit string bytes, no terminator |
.asciz "..." |
Emit string bytes plus null terminator |
.string "..." |
Synonym for .asciz |
.skip n[, fill] |
Reserve n bytes, filled with fill (default 0) |
.fill n, size, fill |
Emit n repetitions of fill at the given size |
.org addr[, fill] |
Pad/move to absolute offset within section |
Symbol attributes
| Directive | Effect |
|---|---|
.global sym |
Mark sym as externally visible |
.local sym |
Mark sym as section-local (default for non-.global) |
.extern sym |
Declare external symbol (informational; not strictly required) |
.weak sym |
Weak binding — overridable by strong definition elsewhere |
.equ sym, expr |
Define sym as a constant expression |
.set sym, expr |
Synonym for .equ, allows redefinition |
.type sym, @function |
Set symbol type (function or object) |
.size sym, expr |
Set symbol size for debug/profiling |
Source organisation
| Directive | Effect |
|---|---|
.include "file" |
Include the named file at this point |
.file "name" |
Set source file name (debug info) |
.line n |
Set source line number (debug info) |
.loc f l c |
DWARF location — file, line, column |
.macro name args... |
Begin macro definition |
.endm |
End macro definition |
Conditional assembly
| Directive | Effect |
|---|---|
.if expr |
Begin conditional block (assembled if expr non-zero) |
.elif expr |
Else-if |
.else |
Else |
.endif |
End conditional |
.ifdef sym |
Conditional on symbol defined |
.ifndef sym |
Conditional on symbol not defined |
Operand syntax
Immediates
A bare expression is an immediate. Expressions support +, -, *, /, %, &, |, ^, ~, <<, >>, parentheses, and references to defined symbols and labels.
.equ SCREEN_BASE, 0x80000000
.equ SCREEN_WIDTH, 256
addi t0, zero, SCREEN_WIDTH * 3
Memory operands
Standard RV32 form: imm(rs) where imm is the displacement and rs is the base register.
lw t0, 0(sp)
sw a0, -4(s0)
lb t1, OFFSET_NAME(a2)
For Xcrisp auto-index loads and stores, the displacement is implicit (always the element size in v0.1) and the syntax uses an immediate offset in parentheses on the mnemonic suffix:
lb.pi a1, (a0) ; load byte from [a0], a0 += 1
sw.pd a2, (sp) ; store word at [sp], sp -= 4
Symbol references with relocations
| Form | Meaning |
|---|---|
sym |
Address of sym (resolved by linker) |
%hi(sym) |
High 20 bits of sym (for lui) |
%lo(sym) |
Low 12 bits of sym (for addi, lw, etc.) |
%pcrel_hi(sym) |
PC-relative high 20 bits |
%pcrel_lo(label) |
PC-relative low 12 bits (paired by label) |
Standard RISC-V relocation modifiers. Most code uses the pseudo-instructions la and call rather than emitting these directly.
Pseudo-instructions
Standard RISC-V pseudo-instructions are recognised:
| Pseudo | Expands to |
|---|---|
nop |
addi zero, zero, 0 |
li rd, imm |
One or two instructions to load imm (addi, or lui + addi) |
la rd, sym |
auipc rd, %pcrel_hi(sym) + addi rd, rd, %pcrel_lo(...) |
mv rd, rs |
addi rd, rs, 0 |
not rd, rs |
xori rd, rs, -1 |
neg rd, rs |
sub rd, zero, rs (no-flags form — see also Xez NEG) |
negw rd, rs |
not applicable on RV32 |
seqz rd, rs |
sltiu rd, rs, 1 |
snez rd, rs |
sltu rd, zero, rs |
sltz rd, rs |
slt rd, rs, zero |
sgtz rd, rs |
slt rd, zero, rs |
beqz rs, label |
beq rs, zero, label |
bnez rs, label |
bne rs, zero, label |
bgez rs, label |
bge rs, zero, label |
bltz rs, label |
blt rs, zero, label |
blez rs, label |
bge zero, rs, label |
bgtz rs, label |
blt zero, rs, label |
bgt rs, rt, label |
blt rt, rs, label |
ble rs, rt, label |
bge rt, rs, label |
bgtu rs, rt, label |
bltu rt, rs, label |
bleu rs, rt, label |
bgeu rt, rs, label |
j label |
jal zero, label |
jr rs |
jalr zero, 0(rs) |
ret |
jalr zero, 0(ra) |
call sym |
auipc ra, %pcrel_hi(sym) + jalr ra, %pcrel_lo(...) |
tail sym |
auipc t1, %pcrel_hi(sym) + jalr zero, t1, %pcrel_lo(...) |
csrr rd, csr |
csrrs rd, csr, zero |
csrw csr, rs |
csrrw zero, csr, rs |
csrs csr, rs |
csrrs zero, csr, rs |
csrc csr, rs |
csrrc zero, csr, rs |
eZX-specific pseudo-instructions
| Pseudo | Expands to |
|---|---|
ezflags.clear |
csrw ezflags, zero |
ezflags.read rd |
csrr rd, ezflags |
xcarry.clear |
csrrci x0, xcarry, 1 |
xcarry.set |
csrrsi x0, xcarry, 1 |
xcarry.read rd |
csrr rd, xcarry (rd[0] = carry bit) |
XLATE_RD rd, slot |
csrrc x0, xlate_rd_n, mask + csrrs x0, xlate_rd_n, value (clear & set the rd field in the appropriate xlate_rd_* CSR) |
XLATE_WR rd, slot |
as above on the xlate_wr_* family |
XLATE_OFF_RD rd |
XLATE_RD rd, IDENT (slot 0) |
XLATE_OFF_WR rd |
XLATE_WR rd, IDENT |
wait label |
Embed a wait-for-event marker (chipset-dependent; future) |
The XLATE_RD / XLATE_WR pseudos select the right CSR (xlate_rd_0/1/2/3 based on which group the register falls in) and clear-then-set the 4-bit field for the register inside that CSR. Slot operands accept the mnemonic forms (IDENT, NSWAP8, BREV8, BSWAP16, BSWAP32, HSWAP32, BREV16, BREV32) or numeric 0–15; using a reserved slot number (5, 7, 8, 11, 12–15) emits a warning at assembly time and the runtime trap fires only if a memory op actually references the register.
Note that the Xcrisp, Xmath, Xstack, Xctx, and Xez instructions are not pseudo-instructions — they have their own opcode encodings in the custom space (see the relevant extension doc). The assembler emits real bytes for them. The pseudos listed above are CSR-manipulation conveniences.
Xez instruction syntax
Xez instructions follow the same general syntactic pattern as standard RV32 — mnemonic, comma-separated operands. A few category-specific notes:
Block operations
bmov.b rs, rd, rc ; rs = source, rd = destination, rc = count
bmov.h rs, rd, rc
bmov.w rs, rd, rc
bmovr.b rs, rd, rc ; reverse direction
bcmp.b rs, rd, rc ; compare; rc decrements; exits on mismatch
bcmpr.b rs, rd, rc
bfill.b rs, rd, rc ; fill rd[0..rc] with low byte of rs
Source register comes before destination — the same order as the RV32 ABI for memcpy-style functions, and the same order as Z80 LDIR (HL = source, DE = destination, BC = count).
Auto-index loads and stores
lb.pi rd, (rs) ; load byte, post-increment
lbu.pi rd, (rs) ; load byte unsigned, post-increment
lh.pi rd, (rs) ; load halfword
lhu.pi rd, (rs) ; load halfword unsigned
lw.pi rd, (rs) ; load word
lb.pd rd, (rs) ; load byte, post-decrement
; ... and so on for .pd variants
sb.pi rs, (rd) ; store byte, post-increment
sh.pi rs, (rd) ; store halfword
sw.pi rs, (rd) ; store word
; ... and so on for .pd variants
The displacement is implicit — always the element size — so there is no imm(rs) form. The address register is bare-(rs).
Decrement-and-branch
dbnz rs, label ; rs -= 1; branch if not zero
dbz rs, label ; rs -= 1; branch if zero
label is a PC-relative branch target with the same range as standard B-type branches (±4KB).
Push/pop multiple
Two equivalent syntaxes — register list (preferred) or explicit hex mask:
pushm {ra, s0, s1, s2}
popm {ra, s0, s1, s2}
; equivalent — explicit mask
pushm 0x00040E02
Register-list form accepts:
- Single registers:
ra,s0,a0, etc. - Ranges:
s0-s3,a0-a3,t0-t6 - Mixed:
{ra, s0-s4, a0}
Order within the braces doesn't matter — the assembler computes the mask, and the hardware pushes/pops in ascending register order regardless of source-text order.
Bit operations on memory
bsetm 3, (a0) ; set bit 3 of mem[a0]
bclrm 7, (a0) ; clear bit 7 of mem[a0]
btglm 0, (sp) ; toggle bit 0 of mem[sp]
btstm 5, (a1) ; test bit 5 of mem[a1] → ezflags.Z
Bit immediate is 0–7 (byte target only in v1).
Flag-setting ALU operations
addf rd, rs1, rs2 ; rd = rs1 + rs2; ezflags updated
addfi rd, rs1, imm12 ; rd = rs1 + imm; ezflags updated
subf rd, rs1, rs2 ; rd = rs1 - rs2; ezflags updated
addc rd, rs1, rs2 ; rd = rs1 + rs2 + ezflags.C
subc rd, rs1, rs2 ; rd = rs1 - rs2 - ezflags.C
andf rd, rs1, rs2
andfi rd, rs1, imm12
orf rd, rs1, rs2
orfi rd, rs1, imm12
xorf rd, rs1, rs2
xorfi rd, rs1, imm12
cmp rs1, rs2 ; flags from rs1 - rs2, no write
cmpi rs1, imm12 ; flags from rs1 - imm, no write
negf rd, rs ; flag-updating negate
Conditional branches on ezflags
bz label ; branch if ezflags.Z = 1
bnz label ; branch if ezflags.Z = 0
bc label ; branch if ezflags.C = 1
bnc label
bs label ; branch if ezflags.S = 1
bns label
bv label ; branch if ezflags.V = 1
bnv label
bpe label ; branch if ezflags.P = 1
bpo label
Rotates through carry
rlc rd, rs ; rotate left circular; updates Z, C, S
rrc rd, rs ; rotate right circular
rl rd, rs ; rotate left through carry
rr rd, rs ; rotate right through carry
slaf rd, rs, imm5 ; shift left arithmetic with flags
sraf rd, rs, imm5 ; shift right arithmetic with flags
srlf rd, rs, imm5 ; shift right logical with flags
Convenience
neg rd, rs ; negate, no flags (use negf for flag form)
cpl rd, rs ; one's complement (bitwise NOT)
ex rd1, rd2 ; swap rd1 and rd2 in one instruction
swapn rd, rs ; swap nibbles of low byte
mirror rd, rs ; reverse bit order of low byte
Macros
.macro push_pair r1, r2
addi sp, sp, -8
sw \r1, 0(sp)
sw \r2, 4(sp)
.endm
.macro pop_pair r1, r2
lw \r1, 0(sp)
lw \r2, 4(sp)
addi sp, sp, 8
.endm
push_pair a0, a1 ; expands inline
...
pop_pair a0, a1
Macro arguments are referenced with backslash-prefix: \arg_name. Numeric arguments inside macros are usually local labels — use \@ to get the macro instance number for uniqueness:
.macro retry_loop n
\@_start:
addi a0, a0, 1
cmpi a0, \n
bnz \@_start
.endm
Listing format
The default listing format is:
Address Encoding Source
00001000 fe010113 addi sp, sp, -32
00001004 00112e23 sw ra, 28(sp)
00001008 00812c23 sw s0, 24(sp)
0000100c 02010413 addi s0, sp, 32
00001010 [TBD encoding] bmov.b a0, a1, a2
Standard RV32 instructions show their full 32-bit encoding. Xez instructions show the encoded form once the encoding spec is finalised; until then, the listing shows [TBD encoding] and the assembler emits an internal placeholder that will be patched when the encoding is locked.
Listings are produced with the -l flag and written to source.lst by default.
Worked example — copy and bit-count
A small program demonstrating Xez idioms — copy a buffer and count the set bits as we go:
;
; copy_count: copy n bytes from src to dst, returning bits-set count
;
; arguments
; a0 = src
; a1 = dst
; a2 = byte count
;
; returns
; a0 = count of bits set in the copied data
;
.text
.global copy_count
copy_count:
; save callee-saved registers we'll use
pushm {ra, s0, s1, s2}
mv s0, a0 ; src
mv s1, a1 ; dst
mv s2, a2 ; count
li a0, 0 ; bit count accumulator
beqz s2, 2f ; empty buffer? done
1: lb.pi t0, (s0) ; load byte, advance src
sb.pi t0, (s1) ; store byte, advance dst
; count bits set in t0
cpop t1, t0 ; Zbb population count
add a0, a0, t1
dbnz s2, 1b ; decrement count, loop
2: popm {ra, s0, s1, s2}
ret
Notes:
pushm/popmcollapse the prologue and epilogue to one instruction eachlb.pi/sb.piavoid the explicit address-increment that a stock RV32 version would need after each load and storedbnzis the entire loop control — decrement and branch in one instructioncpopis the standard Zbb popcount — already at our disposal because the eZX-RV32 includes Zbb- The Z80 lineage is in the shape — load, store, count, decrement-and-loop — and the modern foundations are in the rest
Toolchain
ezxasm is one piece of the eZX toolchain:
- ezxasm — assembler (this document)
- ezxld — linker (LLD configured for eZX)
- ezxcc — C/C++ compiler (Clang configured for eZX-RV32 with Xez intrinsics)
- ezxdbg — debugger frontend (talks to DeMon's host debug protocol)
Standard binutils (riscv32-unknown-elf-as, riscv32-unknown-elf-ld) can also assemble and link eZX code; the binutils path lacks Xez instruction support without a patch but produces correct output for any RV32IMC + Zbb + Zbs code that doesn't use Xez. The eZX-specific tools add Xez awareness and ship with the appropriate linker scripts.
Differences from stock GAS
For developers familiar with GNU as for RISC-V, the user-facing differences are small:
- Xez mnemonics are recognised (see Xez instruction syntax above)
$and%numeric prefixes are accepted alongside0xand0bcsrr/csrwrecogniseezflagsas a CSR name- The default linker script targets the eZX memory map (text at
$0001_0000in DDR3 by default; reset vector and DeMon-shared regions reserved) - Listing output annotates Xez instructions appropriately
ezxasm aims for source-level compatibility with GAS for all standard RV32 constructs. A .S source file written for GNU as should assemble unchanged under ezxasm, with the caveat that any inline Xez instructions need the corresponding GAS patch on the binutils side.
Status and open questions
- Xez encoding placeholders — until the encoding spec lands, the assembler emits internal placeholders for Xez opcodes. Object files produced now will need re-assembling once the encoding is finalised.
- Compressed Xez forms — when added to the ISA, they will be selected automatically by the assembler the same way the C extension is handled today.
- Inline relocations for chipset registers — symbol forms like
%chipreg(name)for chipset register addresses may be added to make register access more readable; not yet defined. - Debug info — DWARF emission is on the roadmap; the
.file/.line/.locdirectives are accepted but produce limited output in v1. - Macro library — a standard
eZX.inclibrary of macros (string ops, screen handling, common chipset patterns) is anticipated but not yet specified.