14 KiB
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/destslots), 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-basedjmp <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
- Push arguments in reverse order (last argument first), so argument 0 ends up closest to
bpr. call <function>.- After return, pop/discard each pushed argument slot (commonly
pop zerofor 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
funcprologue (or its manual expansion) at entry.- ensure all the registers that you intend to access in the function body are saved!
- Access arguments via
ldw bpr, <dest>, 8 + 4*n. - If returning a value, the convention (per
dsa.mdand 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 aftercallreturns. returnepilogue.
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:
rg0–rgfare callee-saved, therefore the callee should- push any registers they intend to write to onto the stack in the function prologue
- pop those registers back in reverse order in the function epilogue
Accis always volatile — never rely on its value surviving any instruction, pseudo-op expansion, or function call.Spr/Bprare managed by thefunc/returnconvention 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 |