| assets | ADD: assets/ folder + change on the README.md | |
| ex | ADD [v1.2.1]: optimize symbol resolution and execution loop | |
| man | ADD [v1.1.1]: New GNU Assembler (GAS) backend to generate assembly code with -S flag + New examples + Flags now can be combined | |
| src | ADD [v1.2.1]: optimize symbol resolution and execution loop | |
| .gitignore | Initial source code commit | |
| LICENSE | Initial commit | |
| Makefile | ADD [v1.1.1]: New GNU Assembler (GAS) backend to generate assembly code with -S flag + New examples + Flags now can be combined | |
| README.md | ADD [v1.2.1]: optimize symbol resolution and execution loop | |
soisp (Singular One Instruction Set Programming) is a minimalist programming language and interpreter built on the SUBLEQ model — a Turing-complete One Instruction Set Computer (OISC) where every computation reduces to a single three-operand operation: subtract and branch if less than or equal to zero.
A small set of negative opcodes extends the core for I/O, pointer dereferencing, data movement, and subroutine calls — enough to write real programs without self-modifying tricks, while keeping the architecture minimal by design.
$ si hello.sn
Hello, world!
Table of contents
- Building
- Usage
- Language overview
- The sblq instruction
- Extended opcodes
- Assembler directives
- Subroutine convention
- Key idioms
- Examples
- Trace, debug, and assembly flags
- Memory model
- Project structure
- Limits
- See also
Building
A small C/POSIX toolchain is enough to build the interpreter. The optional
-S output path targets Linux x86_64 with GNU as/gcc.
git clone https://codeberg.org/grassleaff/soisp
cd soisp
make
This produces src/si.
System-wide install (default prefix /usr/local):
sudo make install
Custom prefix:
sudo make install PREFIX=/opt
Uninstall:
sudo make uninstall
Clean build artefacts:
make clean
Usage
si [flags] program.sn
| Flag | Effect |
|---|---|
-t |
Trace — print every instruction to stderr as it executes |
-v |
Verbose — print the label table and data layout before running |
-S |
Emit assembly — print GNU as x86_64 assembly to stdout and exit |
Flags can be combined, for example -tv, -tSv, or -Svt.
Use -- to stop flag parsing before the source filename.
Program output goes to stdout; trace and diagnostic output always go to stderr, so they can be separated cleanly:
si -t program.sn 2>trace.log # program output on terminal, trace to file
si -t program.sn 2>&1 | less # interleaved, paged
si -S program.sn > program.s # emit native GNU as source
Language overview
A .sn source file is plain text divided into two logical segments:
┌──────────────────────────────────────┐
│ code segment │
│ sblq instructions (3 cells each) │
├──────────────────────────────────────┤
│ data segment │
│ labelled cells, strings, buffers │
└──────────────────────────────────────┘
The assembler builds the memory image in three phases:
| Phase | What it does |
|---|---|
| 1 | Count instructions; register @code: labels and .equ constants |
| 2A | Walk data declarations to resolve data-label addresses (enables forward references) |
| 2B | Load strings, values, and buffers into memory; collect the instruction list |
| 3 | Materialise instructions into the final memory image; optionally emit GNU as with -S |
In phase 3 the instruction list is written into the low cells of the flat
memory array (cells 0 through ×ばつ3 − 1), immediately followed by the
data segment.
The sblq instruction
Every instruction has exactly one mnemonic:
sblq A B C
A, B, and C are memory addresses (label names or integer literals).
When A is non-negative — standard subleq:
memory[B] -= memory[A]
if memory[B] <= 0:
ip = C
else:
ip += 3
When A is a negative literal — extended opcode (see next section).
Extended opcodes
All ten extended operations are triggered by a negative A operand.
Positive A values always perform standard subleq.
| A | Mnemonic | Operands | Operation |
|---|---|---|---|
| −1 | OUT |
B _ |
putchar(memory[B]) — direct output |
| −2 | IN |
B _ |
memory[B] = getchar() — read one char; stores 0 on EOF |
| −3 | OUTI |
B _ |
putchar(memory[memory[B]]) — indirect output |
| −4 | LOAD |
B C |
memory[B] = memory[memory[C]] — indirect load |
| −5 | JNZ |
B C |
if memory[B] != 0: ip = C — jump if not zero |
| −6 | HALT |
_ _ |
exit(0) — terminate cleanly |
| −7 | STORE |
B C |
memory[memory[C]] = memory[B] — indirect store |
| −8 | MOV |
B C |
memory[B] = memory[C] — direct copy |
| −9 | CALL |
B C |
memory[B] = ip+3; ip = C — subroutine call |
| −10 | RET |
B _ |
ip = memory[B] — return / indirect jump |
_ means the operand is present in the source but its value is ignored
at runtime (conventionally written 0).
LOAD vs STORE vs MOV
These three are the workhorses for pointer and data operations:
LOAD B C → B = *C (read through pointer stored at C)
STORE B C → *C = B (write through pointer stored at C)
MOV B C → B = C (direct cell-to-cell copy, no pointer)
CALL and RET
CALL B C → memory[B] = ip+3; ip = C
RET B → ip = memory[B]
CALL saves the return address into cell B and jumps to C.
RET reads B back and jumps there. See
Subroutine convention for the full pattern.
Assembler directives
.equ — symbolic constant
.equ NAME value
Defines a compile-time constant. No memory cell is allocated; every
use of NAME in source is substituted with value at assembly time.
.equ BUF_SIZE 64
.equ NEG_ONE -1
@name: — code label
Marks the instruction immediately following it. Resolved in pass 1; its value is the absolute cell address (always a multiple of 3).
@loop:
sblq -4 ch ptr
...
sblq zero zero @loop # reference by name
name: — data label
Marks a cell in the data segment. Resolved in pass 1.5; forward references within the data segment are legal.
ptr: buf # value = address of buf
ch: 0
buf: .res 32 # ptr is set before buf is declared — that's fine
name: value — single cell
Allocates one cell initialised to value (integer literal or label):
counter: 0
base: array # holds the address of array as its value
neg1: -1
name: .data "string\n0円" — string literal
Allocates one cell per character. Supported escapes:
\n, \t, 0円, \\, \".
greeting: .data "Hello!\n0円"
name: .data v1 v2 ... — value list
Allocates one cell per space-separated value:
table: .data 10 20 30 40
name: .res N — reserved buffer
Allocates N zero-initialised cells. N may be a literal or a .equ
constant. Used for arrays and I/O buffers.
.equ BUF 128
outbuf: .res BUF
Subroutine convention
soisp has no hardware stack. The convention is:
-
Each subroutine owns one return-address cell (e.g.
ret_print: 0) declared in the data segment. -
The caller passes that cell as operand
BofCALL:sblq -9 ret_print @print # memory[ret_print] = ip+3; ip = @print -
The subroutine ends with
RETreading the same cell:sblq -10 ret_print 0 # ip = memory[ret_print] -
Multiple call sites work because each
CALLoverwritesret_printwith the correct return address for that invocation. This is safe for any non-reentrant (non-recursive) subroutine.
Key idioms
Unconditional jump
sblq zero zero @label # zero -= zero = 0, always ≤ 0, always branches
zero is a data cell permanently holding 0. Using it as both A and B
guarantees the result is 0 ≤ 0, so the branch is always taken.
Pointer increment (ptr++)
sblq neg1 ptr @next # ptr -= (-1) => ptr += 1
@next: # falls through here; ptr is always > 0
neg1 is a data cell holding −1. Because ptr is a memory address
(always positive after incrementing from a valid base), ptr − (−1) > 0,
so the branch is never taken and execution falls through to @next.
Copying an address into a pointer cell
MOV copies the contents of a cell, not a literal value. To load the
address of a label into a pointer, use a helper cell whose value is that
address:
prompt_p: prompt # memory[prompt_p] = address of prompt
...
sblq -8 ps_ptr prompt_p # MOV: ps_ptr = memory[prompt_p] = addr(prompt)
String iteration pattern
@loop:
sblq -4 ch ptr # LOAD: ch = *ptr
sblq -5 ch @body # JNZ: ch != 0 -> process it
sblq zero zero @done # ch == 0 (NUL) -> end of string
@body:
... # use ch
sblq neg1 ptr @next # ptr++
@next:
sblq zero zero @loop
Examples
Hello, world — inline loop
msg: .data "Hello, world!\n0円"
ptr: msg # pointer to current character
ch: 0 # current character
neg1: -1
zero: 0
@loop:
sblq -4 ch ptr # LOAD: ch = *ptr
sblq -5 ch @print # JNZ: ch != 0 -> print
sblq zero zero @end
@print:
sblq -1 ch 0 # OUT: putchar(ch)
sblq neg1 ptr @next # ptr++
@next:
sblq zero zero @loop
@end:
sblq -6 0 0 # HALT
Ready-to-run files live in ex/:
hello.sn- minimal hello-world withCALL/RETtest.sn- full feature showcasecat.sn- echo stdin until EOFtwo_calls.sn- call the same subroutine twice with different stringsbuffer_copy.sn- copy a string through a writable buffer, then print itbench.sn- tight pure-subleq countdown benchmark
Hello, world — with subroutine
msg: .data "Hello, world!\n0円"
ptr: msg
ch: 0
neg1: -1
zero: 0
ret_ps: 0 # return-address cell owned by print_str
@main:
sblq -9 ret_ps @print_str # CALL print_str
sblq -6 0 0 # HALT
@print_str:
sblq -4 ch ptr # LOAD: ch = *ptr
sblq -5 ch @ps_out # JNZ: ch != 0 -> emit
sblq -10 ret_ps 0 # RET
@ps_out:
sblq -1 ch 0 # OUT: putchar(ch)
sblq neg1 ptr @ps_next # ptr++
@ps_next:
sblq zero zero @print_str # loop
Writing into a buffer with STORE
.equ BUF_SIZE 8
buf: .res BUF_SIZE # 8 zero cells
wptr: buf # write pointer
rptr: buf # read pointer
val_A: 65 # 'A'
val_nl: 10 # '\n'
val_0: 0 # NUL
ch: 0
neg1: -1
zero: 0
@main:
sblq -7 val_A wptr # STORE: buf[0] = 'A'
sblq neg1 wptr @w1 # wptr++
@w1:
sblq -7 val_nl wptr # STORE: buf[1] = '\n'
sblq neg1 wptr @w2
@w2:
sblq -7 val_0 wptr # STORE: buf[2] = NUL
@loop:
sblq -4 ch rptr # LOAD: ch = *rptr
sblq -5 ch @emit # JNZ: emit if != 0
sblq zero zero @done
@emit:
sblq -3 rptr 0 # OUTI: putchar(*rptr)
sblq neg1 rptr @next # rptr++
@next:
sblq zero zero @loop
@done:
sblq -6 0 0 # HALT
Trace, debug, and assembly flags
-S — emit GNU as assembly
Prints real x86_64 GNU as source to stdout and exits instead of
running the program in the built-in interpreter.
./src/si -S ex/hello.sn > hello.s
gcc hello.s -o hello
./hello
The generated assembly contains:
- a native runtime loop that implements the soisp/subleq machine semantics
- the fully materialised
memory[]image as.quaddata - exported symbols such as
soisp_label_mainandsoisp_addr_mainfor debug
Current target assumption: Linux x86_64 with GNU toolchain (as/gcc).
-t — instruction trace
Every instruction prints a line to stderr before executing:
(T)> ip=0000 CALL ret=3 -> mem[ret_ps(43)] call print_str(6) jump -> print_str(6)
(T)> ip=0006 LOAD *ptr(39) -> mem[ch(40)] = 72 'H' seq -> 9
(T)> ip=0009 JNZ mem[ch(40)] = 72 'H' != 0 jump -> ps_out(15)
(O)> ip=0015 OUT 'H' (72)
H
(T)> ip=0018 SBLQ mem[ptr(39)] -= mem[neg1(41)] : 24 -> 25 seq -> ps_next(21)
Line prefixes:
| Prefix | Meaning |
|---|---|
(T)> |
Instruction trace |
(O)> |
Output character emitted |
(H)> |
HALT reached |
Each trace line shows: ip, opcode mnemonic, a human-readable description
of the operation (with label names resolved), the flow direction (jump or
seq), and the next ip value.
-v — verbose layout
Prints the full label table and the initial data segment contents before execution starts:
-- labels --
name addr
---------------- ----
msg 24
ptr 39
ch 40
neg1 41
zero 42
ret_ps 43
main 0
print_str 6
...
-- data segment (starts at 44) --
addr label value
------ ---------------- -----
24 72 'H'
25 101 'e'
...
Useful for verifying that strings and pointer cells were laid out as expected.
Memory model
| Property | Value |
|---|---|
| Cell type | Signed 64-bit integer (long) |
| Address space | 65 536 cells (indices 0 – 65 535) |
| Code layout | Cells 0 to ×ばつ3 − 1 (N instructions ×ばつ 3 cells each) |
| Data layout | Cells ×ばつ3 onward, in declaration order |
| Initialisation | All cells zero before data segment is written |
| Code/data boundary | Maintained by the assembler; no hardware enforcement |
Code and data share a single flat address space. There is no stack, no heap, no registers outside the memory array.
Project structure
.
├── ex/
│ ├── hello.sn minimal hello-world with subroutine
│ ├── test.sn full feature showcase (all opcodes and directives)
│ ├── cat.sn echo stdin until EOF
│ ├── two_calls.sn repeated subroutine calls with different strings
│ ├── buffer_copy.sn string copy via LOAD/STORE/OUTI
│ └── bench.sn pure-subleq countdown benchmark
├── man/
│ ├── soisp.1 soisp language reference
│ ├── subleq.1 SUBLEQ computing model reference
│ └── oisc.7 OISC theory and variants
└── src/
├── main.c assembler phases + interpreter run loop
├── backend_gas.c GNU as x86_64 backend
├── backend_gas.h backend interface
├── opcodes.h extended opcode constants and documentation
├── options.c flag parsing, trace formatter, verbose printer
└── options.h Options struct and function declarations
Limits
| Resource | Limit |
|---|---|
| Memory cells | 65 536 |
| Labels (code + data) | 1 024 |
.equ constants |
512 |
| Instructions | up to 21 845 (65 535 ÷ 3) |
| String / buffer size | limited only by total memory |
License
MIT — Copyright (c) 2026 grassleaff.
See also
man soisp— interpreter and language referenceman subleq— SUBLEQ computing model, idioms, and theoryman 7 oisc— OISC theory, variants (SUBLEQ, RSSB, Cryptoleq, ...)- URISC: The Ultimate Reduced Instruction Set Computer — Mavaddat & Parhami (1988)
- Computer Architecture: A Minimalist Perspective — Gilreath & Laplante (2003)