Files
DSA/doc/reference/dsa-assembly-spec.md
T

14 KiB
Raw Blame History

DSA Assembly Language Specification

Status: Draft, grounded in the current assembler crate (model.rs, expand.rs, resolver.rs, codegen.rs) and the example programs print.dsa, bf.dsa, animation.dsa, serial.dsa.

This describes the assembly language as the current pipeline actually implements it — pseudo-op expansion, symbol resolution, and argument order all reflect the real expand.rs/codegen.rs logic, not just the ISA's hardware semantics (see the companion ISA spec for those).


1. Source File Structure

A .dsa file is a sequence of directives, labels, and instructions. Comments start with // and run to end of line.

include print "./print.dsa"     // pulls in another module by relative path

dw display: 0x20000             // data declaration with a label
db message: "hello\0"           // string data

start:                          // a label — marks the address of the next node
    lli 0, rg0
    ...

1.1 Modules and include

include <name> "<relative-path>"

<name> becomes the namespace prefix used to reference symbols from that file — e.g. include print "./print.dsa" lets you call print::print, print::newline, etc. Each included file is compiled once per program even if reached via multiple include paths (deduplicated by canonicalized file path). Symbols are disambiguated internally by a hash of the defining file's path, not by name alone — two files can each define a label called start without collision, as long as they're referenced through their respective module namespace.

Known limitation: cross-module symbol references (module::symbol) resolve correctly today because the current pipeline concatenates all included files' nodes into one list before running symbol resolution — it is not yet a true separate-compilation linker. A planned DsoBinary intermediate format (see the compiler roadmap if there is one available) will make per-file compilation independent of the whole program; until then, changing any included file requires reassembling the whole program.


2. Data Declarations

Directive Unit size Meaning
db <label>: <value> 4 bytes/item internally (see note) Byte-oriented data — typically a string literal
dh <label>: <value> 2 bytes/item Halfword data
dw <label>: <value> 1 word/item Word data
resb <label>: <count> reserves count bytes, zeroed Uninitialized byte buffer
resh <label>: <count> reserves count halfwords, zeroed Uninitialized halfword buffer
resw <label>: <count> reserves count words, zeroed Uninitialized word buffer

Example (from animation.dsa):

dw DISPLAY: 0x30000
dw COL0: 0xFFFFFFFF
dw COL_IDX: 0

Example (from bf.dsa):

db program: "++++++++++...(brainfuck source)..."
db error: "Invalid Instruction!"
dw stack: 0x10000
resb data: 1024

String literals (db label: "text") are packed and null-terminated; the assembler pads between successive data items to keep each item's own internal alignment, but does not guarantee every subsequent item starts on a word boundary — a db string can leave the following item at an arbitrary byte offset. Tooling that walks the data section (disassemblers, debuggers) must scan byte-by-byte rather than assuming word-aligned entries throughout.

Every db/dh/dw/resb/resh/resw directive lowers, during pseudo-op expansion, into one or more internal Data pseudo-nodes (never real instructions) — these are split into the DATA section at codegen time and never appear as TEXT.


3. Labels and Symbols

A label (name:) attaches to the next node emitted — including nodes produced by pseudo-instruction expansion, so a label on a func/return/ldw <label>, reg line attaches to whatever real instruction that expands to first.

Known hazard: the current symbol table is a single flat namespace per module — a data label and a text label sharing the same name will silently collide, with the later-inserted definition winning. Always give data and code labels distinct names within a module (e.g. don't declare dw input: ... and also have a code label input: in the same file).


4. Instruction Syntax

Register-register instructions and hardware instructions take arguments in the order the ISA spec defines (see the ISA spec, §5, for exact field semantics per instruction). A few argument-order points worth calling out explicitly, since they're easy to get backwards:

  • Loads: ldw <base>, <dest>, <offset> — base address register first, destination second.
  • Stores: stw <src>, <base>, <offset> — value register first, base address register second. This mirrors the encoding field order (src/dest slots), even though the semantic roles for stores are reversed from loads.
  • Immediate load: lli <value>, <dest> / lui <value>, <dest> — value first, destination second.
  • Arithmetic/bitwise (register form): add <sr1>, <sr2>, <dest> — operands first, destination last.
  • Immediate arithmetic: addi <src>, <imm> [, <dest>] — if <dest> is omitted, it defaults to <src> (in-place update). Both two- and three-argument forms are valid.
  • Jumps: jmp <addr-reg>, <offset> at the hardware level, but see §5 for the far more common label-based jmp <label> pseudo-instruction, which is what you'll write in practice.

Offsets on load/store instructions are optional — if omitted, the assembler inserts an implicit 0. ldw bpr, rg0, 8 and ldw bpr, rg0 (offset 0) are both valid; stw rg8, COL_IDX (no offset) is likewise valid and equivalent to stw rg8, COL_IDX, 0.


5. Pseudo-Instructions

These expand into one or more real instructions during assembly (expand_pseudo_ops). Understanding the expansion matters because several of them clobber Acc.

5.1 lwi <value-or-label>, <dest>

lli <value>, <dest>
lui <value>, <dest>

Loads a full 32-bit immediate or a resolved label address into <dest>. This is the standard way to get a pointer to a data symbol into a register:

lwi program, rg0     // rg0 = address of `program`
lwi data, rg1        // rg1 = address of `data`

5.2 Label-based load: ldb/ldbs/ldh/ldhs/ldw <label>, <dest> [, offset]

lli <label>, <dest>
lui <label>, <dest>
<real-op> <dest>, <dest>, <offset>

Reads the label's address into <dest> via the lli/lui pair, then performs the actual load using that same register as its own base — i.e. ldw stack, bpr loads the value stored at the address stack, into bpr, not the address itself (contrast with lwi, which loads the address).

5.3 Label-based store: stb/sth/stw <src>, <label> [, offset]

lli <label>, acc
lui <label>, acc
<real-op> <src>, acc, <offset>

Stores <src>'s value to the memory location named by <label>. Clobbers Acc.

5.4 func (prologue)

push bpr
mov spr, bpr

Marks the start of a function — establishes a new stack frame by saving the caller's frame pointer and setting the new frame base. Attach func (or, per current usage, place the label directly on a line ending in : func) to the function's entry label.

5.5 return (epilogue)

mov bpr, spr
pop bpr
ret

Tears down the current frame and returns to the caller.

Style note from the example programs: print.dsa factors a shared epilogue into a single _end: label that every function jmps to rather than repeating return inline in each function body — this is a manual optimization on top of the pseudo-instruction, not a different pseudo-op; the underlying epilogue sequence is identical.

5.6 Two-argument arithmetic defaults

addi <src>, <imm> and subi <src>, <imm> (destination omitted) update <src> in place — equivalent to addi <src>, <imm>, <src>. Seen throughout the example programs, e.g. addi rg1, 1 in print.dsa.


6. Calling Convention

6.1 Stack Frame Layout

Higher addresses
   │  ... caller's frame ...
   ├───────────────┤
   │ Arg N          │
   │ ...            │
   │ Arg 1          │
   │ Arg 0          │  ← bpr + 8  (first argument)
   ├───────────────┤
   │ Return addr    │  ← pushed by `call`
   │ Old Bpr        │  ← pushed by `func` prologue; bpr now points here
   ├───────────────┤  ← spr, bpr after prologue
   │ Locals / saved │
   │ registers      │
   └───────────────┘
Lower addresses

Arguments are accessed at bpr + 8 + 4*n for the n-th argument (0-indexed) — confirmed directly from usage in print.dsa (ldw bpr, rg0, 8 retrieves the first/only argument) and dsa.md's stated convention (bpr+8 as the first-argument slot).

6.2 Caller Responsibilities

  1. Push arguments in reverse order (last argument first), so argument 0 ends up closest to bpr.
  2. call <function>.
  3. After return, pop/discard each pushed argument slot (commonly pop zero for slots you don't need the value of).
push rg2            // arg2 pushed first (will be furthest from bpr)
push rg1
push rg0            // arg0 pushed last (closest to bpr, at bpr+8)
call some_func
pop zero            // discard arg0 slot (or capture return value if the callee wrote one there)
pop rg1
pop rg2

6.3 Callee Responsibilities

  1. func prologue (or its manual expansion) at entry.
  2. ensure all the registers that you intend to access in the function body are saved!
  3. Access arguments via ldw bpr, <dest>, 8 + 4*n.
  4. If returning a value, the convention (per dsa.md and consistent with example usage) is to store it back into the caller's argument-0 slot (bpr + 8) before returning, so the caller can retrieve it by popping that slot after call returns.
  5. return epilogue.

Real example, from print.dsa:

print: func
    push rg0
    push rg1
    push rg2
    push rg3

    ldw bpr, rg0, 8      // arg0: string address
    ldw current, rg1     // load cursor position from the `current` data symbol

_print_loop:
    ldb rg0, rg2         // load next character
    ieq rg2, zero, rg3
    jnz rg3, _print_end  // stop at null terminator
    stb rg2, rg1

    addi rg0, 1
    addi rg1, 1
    jmp _print_loop

_print_end:
    stw rg1, current     // persist cursor position

    pop rg3
    pop rg2
    pop rg1
    pop rg0
    return

6.4 Register Preservation

  • Per the ISA spec's register conventions: rg0rgf are callee-saved, therefore the callee should
    1. push any registers they intend to write to onto the stack in the function prologue
    2. pop those registers back in reverse order in the function epilogue
  • Acc is always volatile — never rely on its value surviving any instruction, pseudo-op expansion, or function call.
  • Spr/Bpr are managed by the func/return convention and in most cases should not be manipulated directly except when implementing that convention itself (e.g. _init_stack: routines that set up the initial stack pointer before any function has been called).

7. Complete Worked Example

From print.dsa, showing data declarations, a label-based load without an explicit offset, and the full calling convention in a small, real function (simplified for the sake of this example):

dw display: 0x20000
dw current: 0x20000

print_byte: func
    push rg0
    push rg1

    ldw bpr, rg0, 8      // arg0 = byte to print
    ldw current, rg1     // rg1 = cursor position

    stb rg0, rg1
    addi rg1, 1

	stw rg1, current     // save the cursor position for the next caller

    pop rg1
    pop rg0
    return

Caller side (from bf.dsa):

db error: "Failed!"

printerr: func
	lwi error, rg2
	push rg2
	call print::print_byte
	pop zero
	return

8. Common Pitfalls

Symptom Cause Fix
Acc value unexpectedly changed A label-based store pseudo-op (§5.3) ran between your set and use of Acc Don't hold live state in Acc across any label-based store, or any pseudo-op expansion in general
Wrong value loaded from a "pointer" Confusing lwi <label>, reg (loads the address) with ldw <label>, reg (loads the value stored at that address) Double-check which one your access pattern actually needs
Symbol silently resolves to the wrong address A data label and a text label share the same name within one module Rename one of them — the assembler does not currently detect this collision
Jump target wraps or lands somewhere wrong in a large program Static jump-target immediates are 16-bit; combined TEXT beyond ~64KB can overflow that field Keep an eye on total program size until a wider addressing/relocation scheme lands
A function's argument reads garbage Arguments pushed in the wrong order, or the stack not balanced after the call Push last-to-first (reverse order), and ensure every pushed argument is popped exactly once after call returns