used AI to generate documentation. verified all documentation is correct and accurately represents the functionality of the emulator

This commit is contained in:
2026-07-16 20:29:51 +01:00
parent a4d42bdad6
commit a306575cdd
6 changed files with 752 additions and 149 deletions
+321
View File
@@ -0,0 +1,321 @@
# 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.
```asm
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`
```asm
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`):
```asm
dw DISPLAY: 0x30000
dw COL0: 0xFFFFFFFF
dw COL_IDX: 0
```
Example (from `bf.dsa`):
```asm
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:
```asm
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 `jmp`s 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).
```asm
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`:
```asm
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``rgf` 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):
```asm
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`):
```asm
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 |
| | | |
+261
View File
@@ -0,0 +1,261 @@
# DSA Instruction Set Architecture Specification
**Status:** Draft, grounded directly in `common::isa::instructions` and `common::isa::instructions::encode`.
**Audience:** Emulator/interpreter authors, kernel writers, toolchain developers.
---
## 1. Architecture Overview
| Feature | Detail |
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Word size | 32 bits |
| Instruction width | 32 bits, fixed (one word per instruction) |
| Instruction endianness | Little-endian in memory |
| Addressing mode | Base register + signed 16-bit offset for all loads/stores/jumps |
| Stack direction | Downward — `push` decrements the stack pointer, `stw`s, then continues; `pop` reads then increments |
| Program counter | Updated by jump/call/ret instructions; not directly writable by ordinary instructions |
| Condition handling | **No flags register.** Comparisons (`ieq`/`ine`/`ilt`/`ile`/`igt`/`ige`) write a 1-or-0 result into an explicit destination register, which conditional jumps (`jez`/`jnz`) then test directly. There is no implicit zero/carry flag propagated between instructions. |
> **Correction to earlier drafts:** some prior notes describe a `sts` status/flags register and carry-flag-driven branches (`jic`/`jnc` "jump if carry"). The current implementation has no flags register at all — `jic`/`jnc` exist as opcodes but their semantics are whatever the interpreter defines for them (not yet confirmed in this document); all conditional control flow actually exercised by the assembler/emulator today goes through `jez`/`jnz` testing an explicit register produced by a comparison instruction.
---
## 2. Register File
Confirmed from the `Register` enum used throughout `Instruction`'s builders and the disassembler:
| Register | Role |
|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Rg0``RgF` | 16 general-purpose registers. Caller-saved (not preserved across calls unless explicitly saved). |
| `Acc` | Scratch register used by pseudo-instruction expansions (address computation for label-based load/store/jump). Volatile — never preserved across any instruction that touches it. |
| `Spr` | Stack pointer. |
| `Bpr` | Base/frame pointer. |
| `Ret` | Return-address register, written internally by `call`/read by `ret`. |
| `Zero` | Hardwired to 0. Writes are discarded. |
| `Null` | Sentinel used internally by the assembler/disassembler for "unused/unencodable" register fields. Not a real writable register — referencing it in assembly should be treated as invalid. |
Registers not independently confirmed against source in this conversation but described in earlier architecture notes (present pending confirmation against the actual `Register` enum): `Idr` (interrupt descriptor table base), `Mmr` (memory-map register), `Sts` (status), `Cir` (current instruction register), `Pcx` (program counter). Treat these as provisional until checked against `common::isa::register`.
---
## 3. Instruction Encoding
### R-type
```
| 31-26 | 25-21 | 20-16 | 15-11 | 10-6 | 5-0 |
| opcode | src1 | dest | src2 | misc | shamt |
```
Built by `Instruction::build_r(opcode, src1, dest, src2, misc, shamt)`:
```
(opcode << 26) | (src1 << 21) | (dest << 16) | (src2 << 11) | (misc << 6) | (shamt & 0x3F)
```
### I-type
```
| 31-26 | 25-21 | 20-16 | 15-0 |
| opcode | src | dest | imm16 |
```
Built by `Instruction::build_i(opcode, src, dest, imm)`:
```
(opcode << 26) | (src << 21) | (dest << 16) | imm
```
### No-arg
```
| 31-26 | 25-0 |
| opcode | must be zero |
```
Built by `Instruction::build_noarg(opcode)`: `opcode << 26`.
Decoding accessors (confirmed from `decode.rs`):
- `opcode()` = bits 3126
- `src1_checked()` = bits 2521 → `Register`
- `dest_checked()` = bits 2016 → `Register`
- `src2_checked()` = bits 1511 → `Register`
- `misc_checked()` = bits 106 → `Register`
- `shamt()` = bits 50 → `u8`
- `imm16()` = bits 150 → `u16`
Register fields decode via `Register::from_u8`, falling back to `Register::Null` on an unrecognized 5-bit value (never a hard decode failure).
---
## 4. Opcode Table
Authoritative values from `common::isa::instructions::Opcode`:
| Value | Mnemonic | Type | Category |
| ----- | -------- | ----- | -------------- |
| 0x00 | NOP | noarg | Control |
| 0x01 | MOV | R | Move |
| 0x02 | CMOV | R | Move |
| 0x03 | LDB | I | Load |
| 0x04 | LDBS | I | Load (signed) |
| 0x05 | LDH | I | Load |
| 0x06 | LDHS | I | Load (signed) |
| 0x07 | LDW | I | Load |
| 0x08 | STB | I | Store |
| 0x09 | STH | I | Store |
| 0x0A | STW | I | Store |
| 0x0B | LLI | I | Immediate load |
| 0x0C | LUI | I | Immediate load |
| 0x0D | IEQ | R | Comparison |
| 0x0E | INE | R | Comparison |
| 0x0F | ILT | R | Comparison |
| 0x10 | ILE | R | Comparison |
| 0x11 | IGT | R | Comparison |
| 0x12 | IGE | R | Comparison |
| 0x13 | JMP | I | Jump |
| 0x14 | JEZ | I | Jump |
| 0x15 | JNZ | I | Jump |
| 0x16 | JIC | I | Jump |
| 0x17 | JNC | I | Jump |
| 0x18 | AND | R | Bitwise |
| 0x19 | NAND | R | Bitwise |
| 0x1A | OR | R | Bitwise |
| 0x1B | NOR | R | Bitwise |
| 0x1C | XOR | R | Bitwise |
| 0x1D | XNOR | R | Bitwise |
| 0x1E | NOT | R | Bitwise |
| 0x1F | ADD | R | Arithmetic |
| 0x20 | SUB | R | Arithmetic |
| 0x21 | SHL | R | Shift |
| 0x22 | SHR | R | Shift |
| 0x23 | ADDI | I | Arithmetic |
| 0x24 | SUBI | I | Arithmetic |
| 0x25 | PUSH | R | Stack |
| 0x26 | POP | R | Stack |
| 0x27 | CALL | I | Control flow |
| 0x28 | RET | noarg | Control flow |
| 0x29 | INT | I | System |
| 0x2A | IRET | noarg | System |
| 0x2B | ACS | R | Atomic |
| 0x2C | HLT | noarg | System |
`Opcode::from_u8` accepts values `0..=0x2B` (i.e. `<= Hlt as u8`); anything higher decodes as an invalid instruction. There is no `ACS`/atomic-compare-swap opcode in the current enum — if one is planned, it is not yet implemented and should not be assumed present by tooling.
---
## 5. Instruction Semantics
All arithmetic wraps modulo 2³² unless noted.
### 5.1 Move
| Mnemonic | Fields | Effect |
|------------------------|----------------------------------|--------------------------------------------|
| `mov(src, dest)` | R: src1=src, dest=dest | `dest ← src` |
| `cmov(src, dest, cmp)` | R: src1=src, dest=dest, src2=cmp | If `cmp ≠ 0`, `dest ← src`; else unchanged |
### 5.2 Load
| Mnemonic | Fields | Effect |
|---------------------------|--------|-------------------------------------------------------------------------|
| `ldb(src, dest, offset)` | I | `dest ← zero_extend(byte at [src + sext(offset)])` |
| `ldbs(src, dest, offset)` | I | Same, sign-extended |
| `ldh(src, dest, offset)` | I | `dest ← zero_extend(halfword at [src + sext(offset)])` (2-byte aligned) |
| `ldhs(src, dest, offset)` | I | Same, sign-extended |
| `ldw(src, dest, offset)` | I | `dest ← word at [src + sext(offset)]` (4-byte aligned) |
### 5.3 Store
| Mnemonic | Fields | Effect |
|--------------------------|--------|---------------------------------------------------------|
| `stb(src, dest, offset)` | I | `[dest + sext(offset)] ← src & 0xFF` |
| `sth(src, dest, offset)` | I | `[dest + sext(offset)] ← src & 0xFFFF` (2-byte aligned) |
| `stw(src, dest, offset)` | I | `[dest + sext(offset)] ← src` (4-byte aligned) |
Note the field naming convention: for stores, the instruction's `dest` field holds the **base address** register, and `src` holds the **value** being written — this mirrors the load encoding's field layout even though the semantic roles are reversed. This matters when hand-assembling or reading raw encodings.
### 5.4 Immediate Load
| Mnemonic | Fields | Effect |
|--------------------|-------------|---------------------------------------------|
| `lli(dest, imm16)` | I: src=Zero | `dest ← imm16` (zero-extended into 32 bits) |
| `lui(dest, imm16)` | I: src=Zero | `dest ← (imm16 << 16) \| (dest & 0xFFFF)` |
To build a full 32-bit constant: `lli` first (sets the low half, clearing the register), then `lui` (sets the high half, preserving the low half `lli` just wrote). `Instruction::load_imm32(dest, imm)` produces this pair directly.
### 5.5 Comparison
All of the form `(sr1, sr2, dest)`, R-type, `dest ← (condition) ? 1 : 0`:
| Mnemonic | Condition |
|----------|-----------------------|
| `ieq` | `sr1 == sr2` |
| `ine` | `sr1 != sr2` |
| `ilt` | `sr1 < sr2` (signed) |
| `ile` | `sr1 <= sr2` (signed) |
| `igt` | `sr1 > sr2` (signed) |
| `ige` | `sr1 >= sr2` (signed) |
### 5.6 Jump / Branch
| Mnemonic | Fields | Effect |
|--------------------------|------------------------|-------------------------------------------------------------------|
| `jmp(addr, offset)` | I: src=Zero, dest=addr | `PCX ← addr + sext(offset)`, unconditional |
| `jez(cmp, addr, offset)` | I: src=cmp, dest=addr | If `cmp == 0`: `PCX ← addr + sext(offset)` |
| `jnz(cmp, addr, offset)` | I: src=cmp, dest=addr | If `cmp != 0`: `PCX ← addr + sext(offset)` |
| `jic(addr, offset)` | I: src=Zero, dest=addr | Encoded; semantics not exercised by the current assembler/codegen |
| `jnc(addr, offset)` | I: src=Zero, dest=addr | Encoded; semantics not exercised by the current assembler/codegen |
**Absolute vs. relative:** when `addr` is `Zero`, the effective target is just `offset` — this is how the assembler encodes absolute jumps to resolved symbol addresses today. Any other `addr` register makes the jump base-relative to that register's runtime value (e.g. an instruction-pointer-relative jump, as used internally by interpreters written in DSA assembly, such as the Brainfuck interpreter example). Because `offset` is a 16-bit field, **statically resolved absolute jump targets are currently capped at 0xFFFF** — a real constraint worth being aware of as programs grow past 64KB of combined text.
### 5.7 Bitwise
All `(sr1, sr2, dest)` R-type except `not`:
| Mnemonic | Effect |
|------------------|------------------------|
| `and` | `dest ← sr1 & sr2` |
| `nand` | `dest ← !(sr1 & sr2)` |
| `or` | `dest ← sr1 \| sr2` |
| `nor` | `dest ← !(sr1 \| sr2)` |
| `xor` | `dest ← sr1 ^ sr2` |
| `xnor` | `dest ← !(sr1 ^ sr2)` |
| `not(src, dest)` | `dest ← !src` |
### 5.8 Arithmetic
| Mnemonic | Fields | Effect |
|--------------------------|--------|----------------------------|
| `add(sr1, sr2, dest)` | R | `dest ← sr1 + sr2` |
| `sub(sr1, sr2, dest)` | R | `dest ← sr1 - sr2` |
| `addi(sr1, dest, imm16)` | I | `dest ← sr1 + sext(imm16)` |
| `subi(sr1, dest, imm16)` | I | `dest ← sr1 - sext(imm16)` |
### 5.9 Shift
| Mnemonic | Fields | Effect |
|----------------------------------|---------------------------------------------------|--------------------------------------------|
| `shl(src, rshamt, dest, ishamt)` | R: src1=src, dest=dest, src2=rshamt, shamt=ishamt | `dest ← src << (rshamt + ishamt)` |
| `shr(src, rshamt, dest, ishamt)` | R | `dest ← src >> (rshamt + ishamt)`, logical |
`ishamt` is a literal encoded in the instruction (6 bits, asserted `< 64` at build time); `rshamt` is an additional runtime register value added to it. In practice, current codegen only ever populates the literal (`ishamt`) form with `rshamt = Zero`.
### 5.10 Stack
| Mnemonic | Fields | Effect |
|-------------|-------------|------------------------------|
| `push(reg)` | R: src1=reg | `SPR ← SPR - 4; [SPR] ← reg` |
| `pop(reg)` | R: dest=reg | `reg ← [SPR]; SPR ← SPR + 4` |
### 5.11 Function Call / Return
| Mnemonic | Fields | Effect |
|----------------------|------------------------|----------------------------------------------------|
| `call(addr, offset)` | I: src=Zero, dest=addr | Pushes return context; `PCX ← addr + sext(offset)` |
| `ret()` | noarg | Pops return context; `PCX ← saved return address` |
Exact push/pop mechanics of `call`/`ret` (whether the return address goes through the explicit stack or the dedicated `Ret` register) are implemented in the interpreter and not independently re-derived here — confirm against the emulator's execution code if implementing a second interpreter.
### 5.12 System
| Mnemonic | Fields | Effect |
|-------------|-----------------------------------------|-------------------------|
| `int(code)` | I: imm16 holds `code` (asserted `< 64`) | Software interrupt |
| `irt()` | noarg | Return from interrupt |
| `hlt()` | R, all-zero operands | Halts instruction fetch |
| `nop()` | noarg | No effect |
Exact interrupt dispatch mechanics (IDT layout, privilege transition) are not covered here — this is architecture the emulator's interrupt controller module defines, not something re-derived from the instruction builders alone.
### 5.13 Data Pseudo-word
`Instruction::data(value)` constructs a raw `u32` word with no opcode semantics — used internally by the assembler pipeline as a placeholder for data words prior to the current data/text section split. Should not appear in the TEXT section of a well-formed program under the current `DsoBinary`/`DseExecutable` pipeline (see the DSE Executable spec) — data belongs exclusively in the DATA section now.
---
## 6. Known Gaps / Open Questions
- `jic`/`jnc` are encoded but no confirmed semantics have been exercised in this codebase — treat as reserved/unimplemented until the interpreter's handling is confirmed.
- The full `Register` enum (specifically any privileged/system registers) has not been independently confirmed in this conversation — the table in §2 lists only registers seen in actual builder/disassembler code.
- Interrupt/IDT layout and `call`/`ret`'s exact stack mechanics live in the interpreter, not the instruction encoder, and should be documented separately once confirmed against that source.
+166
View File
@@ -0,0 +1,166 @@
# DSE Executable Format Specification (v1)
**Status:** Implemented — matches `DseHeader`/`SymbolEntry`/`DseExecutable` as built in the `assembler`
crate's format module.
**Scope:** Single-module, fully-linked executables produced by assembling one `.dsa` program (with its `include`s already concatenated in). Does not yet cover the planned `DsoBinary` (relocatableobject/`.o`-equivalent) format or a real multi-unit linker — see §7.
---
## 1. Design Goals
- Minimal, purpose-built for DSA — not an ELF-compatible or ELF-inspired-to-the-letter format; borrows
only ELF's useful idea of offset+size pairs in the header rather than assuming fixed contiguous
section layout.
- Separate DATA and TEXT payloads, addressed independently — no jump-over-data hack required to skip
past a data blob embedded in the instruction stream.
- Symbol table sufficient for post-hoc disassembly/debugging (name, resolved address, section,
binding), even though the current single-file assembler doesn't need cross-file linking yet.
---
## 2. File Layout
All multi-byte fields are little-endian. On-disk section order is:
```
[ Header ] [ Symbol Table ] [ String Table ] [ Data ] [ Text ]
```
**This on-disk order is not the same as the runtime memory layout** — see §5. Do not assume file byte
offsets correspond to runtime addresses.
### 2.1 Header
Magic bytes `"DSAX"` (4 bytes) precede the struct fields below. Total header size on disk is **48
bytes** (4-byte magic + 44 bytes of fields).
| Field | Type | Meaning |
|-------|------|---------|
| `version` | `u16` | Format version. Currently `1`. |
| `flags` | `u16` | Reserved, currently `0`. |
| `entry_point` | `u32` | Absolute runtime address of the program's entry point, assuming TEXT is loaded at address 0 (see §5). |
| `symbol_table_offset` | `u32` | Byte offset from start of file to the first `SymbolEntry`. |
| `symbol_table_size` | `u32` | **Count** of symbol entries (not byte size). |
| `string_table_offset` | `u32` | Byte offset to the string table. |
| `string_table_size` | `u32` | **Byte length** of the string table. |
| `data_offset` | `u32` | Byte offset to the DATA section. |
| `data_size` | `u32` | **Byte length** of the DATA section. |
| `text_offset` | `u32` | Byte offset to the TEXT section. |
| `text_size` | `u32` | **Count** of 32-bit words in TEXT (not byte size). |
### 2.2 Symbol Table
An array of `symbol_table_size` fixed-size entries, each **12 bytes**:
| Field | Type | Meaning |
|-------|------|---------|
| `name_offset` | `u32` | Byte offset into the string table where this symbol's name begins. |
| `value` | `u32` | Resolved runtime address (see §5 for the base-address convention this assumes). |
| `section` | `u8` | `0` = absolute, `1` = data, `2` = text, any other value = undefined/unknown. |
| `binding` | `u8` | `0` = local, `1` = global, `2` = weak. |
| `_pad` | `u16` | Reserved, currently unused; always `0`. |
### 2.3 String Table
Raw bytes: a concatenation of symbol names, each null-terminated, referenced by `name_offset` into
this buffer. No length prefix — read to the next `0x00` byte from the given offset.
### 2.4 Data Section
Raw bytes — whatever `db`/`dh`/`dw`/`resb`/`resh`/`resw` directives produced, concatenated in program
order. **Not guaranteed to be internally word-aligned at every sub-item boundary** — a string literal
can leave the next data item starting at an arbitrary byte offset within the section. Consumers that
walk this section looking for structure (disassemblers, memory viewers) must scan byte-by-byte, not
assume 4-byte-aligned entries throughout.
### 2.5 Text Section
`text_size` raw 32-bit words, each a fully-encoded `Instruction` per the ISA spec. No padding between
words; word `i` begins at `text_offset + 4*i` on disk.
---
## 3. Symbol Resolution Convention
Symbol addresses (`SymbolEntry.value`, and the header's `entry_point`) are computed under a fixed
runtime-layout assumption, **not** relative to anything about the file's on-disk layout:
```
data_base = text_len_words * 4 // where text_len_words = TEXT section's word count
```
- A TEXT-section symbol's `value` is its offset within TEXT (i.e. assumes TEXT is loaded starting at
runtime address 0).
- A DATA-section symbol's `value` is `data_base + <offset within DATA>`.
This is a v1 stopgap, not a general relocation scheme — see §7.
---
## 4. On-Disk Offset Computation (Writer Responsibilities)
When constructing a `DseExecutable`, offsets must be derived from the **actual serialized size** of
each preceding section — not hand-counted constants. The header magic (4 bytes) and each
`SymbolEntry`'s true packed size (12 bytes, not a naively-summed field-width guess) have both been
sources of real, silent file corruption during development, each shifting every subsequent offset by a
fixed amount and making downstream sections unreadable at their claimed positions. Compute:
```
symbol_table_offset = header_size // 48
string_table_offset = symbol_table_offset + (symbol_count * 12)
data_offset = string_table_offset + string_table.len()
text_offset = data_offset + data.len()
```
A unit test that round-trips a `DseExecutable` through `write()` then `read()` and asserts structural
equality is the recommended guard against this class of bug recurring — this is far more reliable than
re-deriving constants by hand whenever a struct's fields change.
---
## 5. Loading Into Runtime Memory
**The on-disk file layout and the runtime memory image are different things.** Loading a `.dse` file
means:
1. Parse the header and extract `data` and `text` as separate in-memory byte/word buffers (via
`DseExecutable::load`/`read`).
2. Copy `text` into memory starting at whatever base address the loader chooses (`text_base`,
conventionally `0` for the current single-program, single-load-address model).
3. Copy `data` into memory immediately following, at `text_base + text.len() * 4`.
4. Set the program counter to `text_base + header.entry_point`.
**Important:** copying the *raw file bytes* wholesale into memory and treating a file offset (e.g. `header.text_offset`) as if it were a runtime address is incorrect — the header/symtab/strtab/data sections sitting ahead of TEXT *in the file* have no relationship to what sits ahead of TEXT *in the runtime memory image the assembler assumed* (which is nothing — TEXT is assumed to start at address 0). The loader must unpack `data`/`text` into the layout described above, not memory-map the file as-is.
If `text_base != 0`, every address already baked into instruction words and the symbol table (computed under the `text_base == 0` assumption at assembly time) will be wrong at runtime, since nothing currently patches them for a different load address. Until relocations exist (§7), load at `text_base = 0`.
---
## 6. Reading and Writing (Reference Implementation Shape)
```rust
struct DseExecutable {
header: DseHeader,
symbols: Vec<SymbolEntry>,
string_table: Vec<u8>,
data: Vec<u8>,
text: Vec<u32>,
}
```
- `DseExecutable::load(path)` / `::read(reader)` — parses the header, then seeks to each
`*_offset` field in turn to read the symbol table, string table, data, and text sections. Sections are addressed by absolute offset, not read sequentially by assumed contiguous layout, so this must seek rather than just read-through.
- `DseExecutable::save(path)` / `::write(writer)` — writes header, then symtab, then strtab, then data, then text, in that fixed order, with offsets computed per §4.
- `DseExecutable::to_memory_image()` — produces the runtime image described in §5 (TEXT bytes followed immediately by DATA bytes), ready to be copied into emulator memory at `text_base`.
---
## 7. Known Limitations / Planned Extensions
- **No relocations.** All addresses assume a single fixed load address (TEXT at 0). Loading at any other base, or supporting a multi-program/OS environment with per-process load addresses, requires adding a relocation table — additive to this format (new optional header fields, `0`/absent in
existing files) rather than a breaking change.
- **Single compilation unit only.** The current assembler concatenates all `include`d files into one node list before producing a `DseExecutable` — there is no independent per-file compilation or linking step yet. The planned `DsoBinary` format (a `.o`-equivalent, carrying local symbols + unresolved imports + a relocation table per compiled unit) plus a dedicated linker step will decouple "compile a module" from "produce a runnable program," enabling per-file caching and parallel builds. `DseExecutable` is intended to remain the final, fully-linked output format even after that change — only the path that produces it will change.
- **No debug info section.** Symbol names/addresses are present, but there's no line-number or
source-mapping table. Worth considering if source-level debugging in the egui debugger becomes a priority.
- **`flags` field is currently unused** — reserved for future use (e.g. marking an executable as
requiring a specific kernel/loader feature).