AntBASIC Inline ARM64 Assembler

AntBASIC has a built-in assembler, in the BBC BASIC tradition — but it targets ARM64 (A64), the native instruction set of the machine it runs on (DeMon's CM5). Assembly is written in [ … ] blocks inside a BASIC program; labels and BASIC variables share one namespace, so you assemble a routine and CALL it as if its label were a variable. A line-numbered BASIC that hand-assembles native code is the platform's ethos at full volume.

Status: design. Nothing here is built. This page fixes the model — delimiters, the accumulating cursor, the label-is-a-variable principle, CALL/USR, the executable-memory reality, and the v1 instruction subset — so it can be implemented against a settled shape. Overview + the language it lives in: AntBASIC.

Back to AntBASIC · the basic library: antos_basic / luau_basic.

Companion: this page is the ARM64 assembler ([ … ]), which runs on DeMon. AntBASIC also has a RISC-V assembler ({ … }) for the FPGA-side processor — see antbasic_asm_riscv. The block model, accumulating cursor, and label-is-a-variable principle below are shared by both; that page covers only what differs.


The assembly block

Assembly lives between [ and ], inside an ordinary BASIC program. It can span many lines or sit on one:

100 [
110   .double           \ a label
120   ADD X0, X0, X0
130   RET
140 ]
100 [ MUL X0, X0, X0 ]   \ single-line block

A backslash \ begins an assembler comment to end of line (BASIC's REM doesn't apply inside a block). Mnemonics and register names fold case-insensitively, like the rest of AntBASIC.

Everything outside the brackets is normal BASIC; the brackets just switch the tokeniser into assembler mode for their span. Because it is all one program, you can loop, compute addresses, and drive assembly with BASIC — the classic trick of assembling a table with a FOR loop around a [ … ] block works.


Assembly accumulates — the cursor

The one big simplification over classic BBC BASIC: you do not manage the program counter line by line. The assembler keeps a running assembly cursor for the current execution area, and every [ … ] block simply continues from wherever the last one left off. P% reflects the cursor; it advances automatically as instructions are laid down.

You only touch the cursor when a directive says so:

Directive Effect
P% = addr set the assembly (run) address — start a new area, or assemble to a fixed location
O% = addr set the write offset separately from P% (assemble code destined to run elsewhere)
ALIGN [n] advance the cursor to an n-byte boundary (default 4 — one instruction)
OPT n pass / listing / error behaviour (see Two passes)
SAVE "file" write the current execution area to a file — see Saving assembled code
DEFER assemble this block at run time, not in the pre-pass — see runtime assembly

DIM code% n reserves an execution area of n bytes and points the cursor at it. With no explicit P%, assembly flows into the current area in order — "add code, it goes after the last code" — which is the common case and needs no ceremony.


Labels and variables are one namespace

This is the heart of it, and it falls straight out of AntBASIC's single shared variable space: there is no separate symbol table — the BASIC variables are the symbols.

  • A label .name is a BASIC variable the assembler sets to the current assembly address. After the block assembles, name is an ordinary numeric variable holding that address.
  • A BASIC variable used as an operand supplies its current value at assemble time: MOV X0, #count% assembles the current value of count% as an immediate; LDR X1, buffer uses the address in buffer.

So the bridge in both directions is just variable access:

10 DIM code% 256
20 [
30   .square
40   MUL X0, X0, X0
50   RET
60 ]
70 PRINT ~square          : REM the label is now a variable — prints the address
80 CALL square            : REM ...and you can call it

Because labels are variables, forward references, address arithmetic (square + 8), and passing a routine's address to another routine all work with no special mechanism — it is all just BASIC's number space.


Two passes and forward references

A forward branch (B .later before .later exists) needs the label's address, so the assembler makes two passes over each block: pass one lays out instructions and fixes label addresses; pass two emits final code with references resolved. This is automatic. OPT controls the details BBC-style — whether pass two lists the assembly, and whether an unresolved label is an error or a warning — but the accumulating-cursor model means you do not write the manual FOR pass = 0 TO 2 … NEXT wrapper classic BBC BASIC required. Both passes run in the RUN pre-pass, before the program executes — so the machine code (and, for { }, its delivery to the FPGA) is ready before the first line, and a runtime CALL / USR just invokes it.


Saving assembled code

A SAVE "file" directive, written inside a block, writes the current execution area to a file. Because the save is a directive rather than a console verb, one source listing can emit many artifacts — each area serialises itself where it sits:

10 P% = &8000
20 [ SAVE "loader.arm"        \ this ARM64 area -> a Linux ELF
30   .entry
40   ...
50 ]
60 P% = &2000
70 { SAVE "kernel.ee"          \ a second area -> an EE executable
80   ...
90 }

The format follows the file extension (the same extension-implies-format rule LOAD uses):

Extension Output
.arm / ELF Linux AArch64 executable, from an [ ] area
.ee EE (RV64) executable — header + absolute image, from a { } area
.ezx eZX executable — header + absolute image, from a { } area; the same RV64 EE code as .ee, headed for the eZX machine
.bin raw image, no header — cartridge ROMs, chipset upload blobs, anything that just wants the bytes

The directive needs no other arguments in the common case: the area already knows its target (ARM, or EE/eZX with narrow/wide), its absolute load address (P%), its entry point (a label), and its length, so SAVE has everything it needs to write the right header. An explicit SAVE "file", start, length form captures a sub-span or a raw slice when you want one. SAVE is the basic.build primitive surfaced as an assembler directive — the console's build script calls the same primitive.

Executables are build outputs, not programs — LOAD does not read them back.


CALL and USR — into native code and back

Two verbs transfer control to assembled code:

  • CALL addr — call the routine at addr (a label/variable). Runs it, returns to BASIC.
  • USR addr — call and return a value (from X0 by convention), usable in an expression: n = USR square.

The calling convention (which registers carry BASIC arguments in and results out) is documented with the implementation; the intent is the natural AArch64 one — integer arguments in X0…, result in X0 — with BASIC's integer variables marshalled to/from registers. A routine that follows the AArch64 procedure-call standard can also be called by, and can call, other native code and AntOS itself.


The executable-arena reality (not the 1980s)

On a bare 6502 or ARM2 you DIMmed a buffer and jumped to it. On DeMon's CM5 under Linux you cannot — memory is W^X (a page is writable or executable, never both), so a plain data buffer is not runnable.

AntBASIC handles this in the engine: an assembler execution area is backed by an executable arena — memory mmap'd for execution (or made executable with mprotect after assembly, and the instruction cache flushed, which AArch64 requires before running freshly written code). DIM code% n for assembly reserves from this arena rather than the ordinary BASIC workspace. The upshot for the programmer is nothing to do; the upshot for the doc is that "assemble and CALL" is real, not a buffer-and-pray.


Crash isolation

Hand-assembled native code can fault — a wild LDR, a bad RET — and that is retro-honest; the machine lets you do it. But because AntBASIC programs can run in isolated contexts on worker threads, a faulting CALL should bring down only its own context, not DeMon or the console. The same context isolation that lets a BASIC program SPAWN another in parallel is what contains a bad CALL to the program that made it. (A CALL from the main console context is the one case that can still take the console down — assemble carefully at Ready..)


The v1 instruction subset

Full A64 is enormous. v1 targets a useful core rather than the whole ISA:

  • Integer data processing — MOV, ADD/SUB, MUL, AND/ORR/EOR, shifts, CMP.
  • Loads and stores — LDR/STR and byte/half variants, register and immediate offsets.
  • Branches — B, BL, RET, conditional B.cond, CBZ/CBNZ.
  • Immediates and address materialisation — MOV/MOVZ/MOVK, ADR/ADRP for label addresses.

Floating-point and SIMD (V/Q registers, NEON) are a later addition, as are system-register and hardware-poking helpers that tie into the chipset bridge idea. The assembler is a native-core component of the basic library, so extending the mnemonic set is engine work behind a stable BASIC surface.


Where to go next

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.