grassleaff/soisp
1
1
Fork
You've already forked soisp
0
soisp is a minimalist programming language built around a single instruction: sblq, a variant of subleq (SUBtract and branch if Less than or EQual to zero). All control flow and arithmetic emerge from this one rule, augmented by a compact set of negative-opcode extensions that handle I/O, pointer operations, data movement, and subroutine calls.
  • C 94.8%
  • Makefile 5.2%
2026年05月12日 11:16:31 -03:00
assets ADD: assets/ folder + change on the README.md 2026年04月22日 15:02:19 -03:00
ex ADD [v1.2.1]: optimize symbol resolution and execution loop 2026年05月12日 11:16:31 -03:00
man ADD [v1.1.1]: New GNU Assembler (GAS) backend to generate assembly code with -S flag + New examples + Flags now can be combined 2026年04月22日 19:41:20 -03:00
src ADD [v1.2.1]: optimize symbol resolution and execution loop 2026年05月12日 11:16:31 -03:00
.gitignore Initial source code commit 2026年04月22日 03:10:30 -03:00
LICENSE Initial commit 2026年04月22日 08:07:23 +02:00
Makefile ADD [v1.1.1]: New GNU Assembler (GAS) backend to generate assembly code with -S flag + New examples + Flags now can be combined 2026年04月22日 19:41:20 -03:00
README.md ADD [v1.2.1]: optimize symbol resolution and execution loop 2026年05月12日 11:16:31 -03:00

soisp

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

  1. Building
  2. Usage
  3. Language overview
  4. The sblq instruction
  5. Extended opcodes
  6. Assembler directives
  7. Subroutine convention
  8. Key idioms
  9. Examples
  10. Trace, debug, and assembly flags
  11. Memory model
  12. Project structure
  13. Limits
  14. 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:

  1. Each subroutine owns one return-address cell (e.g. ret_print: 0) declared in the data segment.

  2. The caller passes that cell as operand B of CALL:

    sblq -9 ret_print @print # memory[ret_print] = ip+3; ip = @print
    
  3. The subroutine ends with RET reading the same cell:

    sblq -10 ret_print 0 # ip = memory[ret_print]
    
  4. Multiple call sites work because each CALL overwrites ret_print with 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 with CALL / RET
  • test.sn - full feature showcase
  • cat.sn - echo stdin until EOF
  • two_calls.sn - call the same subroutine twice with different strings
  • buffer_copy.sn - copy a string through a writable buffer, then print it
  • bench.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 .quad data
  • exported symbols such as soisp_label_main and soisp_addr_main for 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