Add full compiler toolchain, libc, examples and reference docs

First substantive commit: the entire Sprinter C compiler tree on top of
the bare README+gitignore initial commit.

What's in here:
  bin/sprinter-cc        — driver script invoking SDCC + linker + mkexe
  libc/                  — Sprinter-specific libc layer over ESTEX/BIOS
                           (conio, gfx, io, mem, stdio + headers)
  runtime/               — crt0 variants (default/small/banked/minimal)
                           + heap + bank trampolines
  toolchain/             — mkexe (SprintEXE packer, C + tests)
  examples/              — 30 demo programs (gfx, file I/O, env, time, …)
  lib/Makefile           — builds the libc archive (sprinter.lib)
  docs/                  — converted Sprinter manuals + asm reference samples
  third_party/           — solid-c reference compiler dump + sdcc setup script
  release_docs/          — packaging / release notes

gitignore overhaul:
  • Drop dangerous blanket patterns: *.asm (would hide docs/samples/*.asm)
    and *.exe (case-insensitive match was hiding third_party/solid-c/*.EXE
    on macOS APFS).  Replaced with examples/*/*.{asm,exe,…} and lib/*.lib.
  • Restore tracking of toolchain/mkexe/tests/{one,big}.bin — those are
    INPUT fixtures, not build outputs.
  • Collapse the duplicated SDCC/C/Sdcc sections into one section per
    concern (build outputs / vendored / OS-junk).
  • Add .sprinter-cc-*/, build/ (catches lib/build/ too), .claude/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:13:21 +03:00
parent f542608b3f
commit c71e249a4e
404 changed files with 75155 additions and 58 deletions
+70
View File
@@ -0,0 +1,70 @@
;; ----------------------------------------------------------------------
;; bank.s — Sprinter banked-call trampolines for SDCC __banked functions.
;;
;; ABI (verified empirically with SDCC 4.5, see memory/sdcc_banking.md):
;; On entry to ___sdcc_bcall_ehl:
;; E = bank id (1..15), HL = target address (low 16 bits).
;; Args already on stack, pushed by the caller right-to-left.
;; Stack picture once the trampoline finishes set-up and jp(hl) fires:
;; SP+0..1 = bjump return (callee's "ret address")
;; SP+2 = saved physical page (1 byte)
;; SP+3..4 = bcall caller return
;; SP+5.. = args ← __banked callees read from here
;;
;; The 3-byte spacer between callee ret and args is the contract SDCC bakes
;; into its codegen — touching it breaks all __banked calls.
;; ----------------------------------------------------------------------
.module bank
.globl _bank_pages
.globl ___sdcc_bcall_ehl
.globl ___sdcc_bjump_ehl
;; Choose between W3 (HUGE mode, default) and W1 (BIG mode, sprinter-cc
;; prepends `BANK_W1 = 1`). Keep in sync with crt0_banked.s.
.ifdef BANK_W1
BANK_PORT = 0xA2
.else
BANK_PORT = 0xE2
.endif
;; HOME area — always mapped, so the trampoline is reachable from any bank.
.area _CODE
___sdcc_bcall_ehl::
;; The 3-byte spacer between bcall-ret and args (1-byte saved page
;; + 2-byte bjump return address) is baked into SDCC's __banked
;; codegen — callees access their args at fixed offsets from SP.
;; We MUST keep that contract intact.
;;
;; The old `pop af; out (n), a` restore sequence clobbered A,
;; dropping uint8_t return values. We use the `OUT (C), r` form
;; (port via C, byte via B) so A is preserved. BC is scratch in
;; SDCC __sdcccall(1), so clobbering it here is harmless.
in a, (#BANK_PORT) ; A = current bank-window page
push af ; push A:F (2 bytes)
inc sp ; keep only the A byte (1-byte spacer)
call ___sdcc_bjump_ehl
;; Callee returned. A may hold a uint8_t return value.
dec sp ; widen back to 2 bytes
pop bc ; B = saved page (C = whatever F was)
ld c, #BANK_PORT ; C = bank-window select port
out (c), b ; OUT (port), B — A and F untouched
ret ; back to bcall caller
___sdcc_bjump_ehl::
push hl ; preserve target address
ld d, #0 ; D:E = 0:bank_id
ld hl, #_bank_pages
add hl, de ; HL = &_bank_pages[bank_id]
ld a, (hl) ; A = physical page for this bank
pop hl ; restore target address
out (#BANK_PORT), a ; map bank into banking window
jp (hl) ; tail-jump to callee
;; Per-bank physical page table. Index = bank id (1-based; [0] is unused).
;; Filled at startup by crt0_banked.s.
.area _DATA
_bank_pages::
.ds 16
+358
View File
@@ -0,0 +1,358 @@
;; ----------------------------------------------------------------------
;; crt0.s — Sprinter ESTEX C runtime startup, with argv parsing.
;;
;; What's special vs crt0_minimal.s:
;; After gsinit and before calling main, we read the ESTEX command-line
;; from the startup prefix (IX+0 = length, IX+1.. = ASCIIZ bytes) and
;; tokenize it in place into a static argv[] table. argc → HL, argv → DE
;; when we then `call _main`, which matches SDCC's __sdcccall(1) two-arg
;; ABI. Programs declared as `int main(void)` simply ignore the input
;; registers — no harm done — so this is the default crt0.
;;
;; Entry contract (per ESTEX EXEC):
;; IX -> startup prefix:
;; IX-3 = open file handle (only if EXE header.loader > 0)
;; IX-2 = memory block id (released automatically on EXIT)
;; IX-1 = current process level
;; IX+0 = command-line length (1 byte)
;; IX+1 = command-line bytes (ASCIIZ, up to 127 chars)
;;
;; Memory map: see compiler_approach memory file.
;; ----------------------------------------------------------------------
.module crt0
.globl _main
;; Linker-emitted symbols (resolved at link time).
.globl s__INITIALIZER
.globl l__INITIALIZER
.globl s__INITIALIZED
.globl s__BSS
.globl l__BSS
;; Tunables — match the constants in argv parsing below.
ARGV_MAX_ARGS = 16 ; including argv[0]
ARGV_BUF_BYTES = 128 ; cmdline buffer size (ESTEX max + 1)
;; ___sdcc_heap_end (upper bound of the malloc free-list) lives in a
;; separate per-program file (runtime/heap_top.s) so it can be regenerated
;; with a custom value without re-assembling crt0. The default is set in
;; that file (0xBB00); sprinter-cc may emit a different value when
;; `--stack-size N` is given.
;; =========================================================================
;; AREA ORDERING — emitted up-front so the linker walks them in this order.
;; =========================================================================
.area _HOME
.area _CODE
.area _INITIALIZER
.area _GSINIT
.area _GSFINAL
.area _DATA
.area _INITIALIZED
.area _BSEG
.area _BSS
.area _HEAP
;; =========================================================================
;; Entry point — first instruction in _CODE, hence at --code-loc (0x4100).
;; =========================================================================
.area _CODE
_start::
ld sp, #0xBFFE
ld (_estex_startup_ix), ix ; save IX prefix pointer
call gsinit
.ifdef DEBUG_RT
;; tiny mode never self-allocates a W2 page (CODE+DATA both live in W2,
;; which DSS itself maps for us). Publish 0 to the diagnostic flag —
;; must run AFTER gsinit since BSS is zeroed there and we want a
;; deterministic value visible to user code from main() onward.
xor a
ld (_w2_self_allocated), a
.endif
;; Parse the ESTEX command-line into argv[]; populates _argc, _argv.
call parse_argv
;; Replace argv[0] (empty string placeholder) with the basename of the
;; running .EXE. Safe to skip if ESTEX APPINFO fails.
call get_progname
;; Load argc/argv per SDCC __sdcccall(1): arg1 → HL, arg2 → DE.
ld hl, (_argc)
ld de, (_argv)
call _main
;; SDCC's int return → DE. Low byte is the exit code.
ld a, e
ld b, a
ld c, #0x41 ; ESTEX EXIT
rst #0x10
;; Should not return; halt loop just in case.
1$: halt
jr 1$
;; =========================================================================
;; parse_argv — tokenize ESTEX cmdline into argv[].
;;
;; The prefix: (IX+0) = length; (IX+1...) = ASCIIZ bytes.
;; We copy up to ARGV_BUF_BYTES-1 chars into our own buffer (so we can
;; overwrite separators with NUL), strip leading whitespace (DSS quirk),
;; then walk through tokens. argv[0] is set to an empty string because
;; ESTEX doesn't pass the program name in the prefix.
;; =========================================================================
.area _CODE
parse_argv::
;; Copy cmdline body into argv_buf, NUL-terminate.
ld hl, (_estex_startup_ix)
ld a, (hl) ; A = cmdline length
cp #ARGV_BUF_BYTES
jr c, len_ok
ld a, #ARGV_BUF_BYTES-1
len_ok:
ld c, a
ld b, #0 ; BC = number of bytes to copy
inc hl ; HL = body start (= IX+1)
ld de, #argv_buf
ld a, b
or a, c
jr Z, after_copy
ldir ; DE = one past last copied byte
after_copy:
xor a
ld (de), a ; NUL-terminate
;; Skip leading whitespace.
ld hl, #argv_buf
strip_ws:
ld a, (hl)
or a
jr Z, no_args
cp #0x20 ; ' '
jr Z, strip_more
cp #0x09 ; tab
jr nz, have_first_token
strip_more:
inc hl
jr strip_ws
no_args:
;; Only argv[0] = "" — no real arguments.
ld hl, #empty_str
ld (argv_array), hl
ld hl, #0
ld (argv_array+2), hl ; argv[1] = NULL
ld hl, #1
ld (_argc), hl
ld hl, #argv_array
ld (_argv), hl
ret
have_first_token:
;; HL points at start of first real argument.
;; argv[0] = empty string (placeholder for program name).
ld de, #empty_str
ld (argv_array), de
;; B = current argc (start at 1). Use B because HL/DE are busy.
ld b, #1
token_loop:
;; Bound check: argc < ARGV_MAX_ARGS
ld a, b
cp #ARGV_MAX_ARGS
jr nc, tokens_done
;; argv[argc] = HL. Compute slot address = argv_array + argc*2.
push hl
ld a, b
add a, a ; A = argc * 2
ld e, a
ld d, #0
ld hl, #argv_array
add hl, de ; HL = &argv[argc]
pop de ; DE = current token pointer
ld (hl), e
inc hl
ld (hl), d
ex de, hl ; HL = token pointer again
inc b ; argc++
;; Advance past token (non-whitespace).
walk_token:
ld a, (hl)
or a
jr Z, tokens_done
cp #0x20
jr Z, end_of_token
cp #0x09
jr Z, end_of_token
inc hl
jr walk_token
end_of_token:
;; Replace separator with NUL.
xor a
ld (hl), a
inc hl
;; Skip extra whitespace.
skip_ws:
ld a, (hl)
or a
jr Z, tokens_done
cp #0x20
jr Z, skip_more
cp #0x09
jr Z, skip_more
jr token_loop
skip_more:
inc hl
jr skip_ws
tokens_done:
;; argv[argc] = NULL terminator.
ld a, b
add a, a
ld e, a
ld d, #0
ld hl, #argv_array
add hl, de
ld (hl), #0
inc hl
ld (hl), #0
;; Publish argc (16-bit, zero-extended from B).
ld a, b
ld (_argc), a
xor a
ld (_argc+1), a
;; Publish argv = &argv_array[0]
ld hl, #argv_array
ld (_argv), hl
ret
;; The empty string used as argv[0] placeholder — lives in code.
empty_str:
.db 0
;; =========================================================================
;; get_progname — fetch the running program's full path via ESTEX APPINFO
;; (subfn 2, $47) and set argv[0] to the basename portion.
;;
;; ESTEX APPINFO: A=err, C=$47, B=subfn, HL=buf → A=err, CF=1 on error.
;; subfn 2 = full app path, e.g. "A:\PROGRAMS\ARGV_TES.EXE"
;;
;; On error or when no separator is found, we leave argv[0] as the empty
;; string placeholder that parse_argv set.
;; =========================================================================
.area _CODE
get_progname::
push ix ; ESTEX clobbers IX
ld hl, #progname_buf
ld b, #2 ; subfn 2 = app_path
ld c, #0x47
rst #0x10
pop ix
jr c, gpn_skip ; APPINFO failed → keep empty argv[0]
;; First make sure the buffer is null-terminated (it should already be).
;; Then scan forward to find the end-of-string, then scan backwards to
;; the last directory separator ('\' or ':').
ld hl, #progname_buf
gpn_find_end:
ld a, (hl)
or a, a
jr Z, gpn_at_end
inc hl
jr gpn_find_end
gpn_at_end:
;; HL points at the NUL. Walk backwards looking for '\' or ':'. Stop
;; if we reach the start of the buffer — then the whole path is the
;; basename.
ld de, #progname_buf
gpn_scan_back:
ld a, h
cp a, d
jr nz, gpn_dec
ld a, l
cp a, e
jr Z, gpn_set ; reached buffer start
gpn_dec:
dec hl
ld a, (hl)
cp #0x5C ; backslash
jr Z, gpn_after_sep
cp #0x3A ; colon (drive separator)
jr Z, gpn_after_sep
jr gpn_scan_back
gpn_after_sep:
inc hl ; skip past the separator
gpn_set:
ld (argv_array), hl ; argv[0] = basename
gpn_skip:
ret
;; =========================================================================
;; Runtime data (HOME's _DATA / _BSS)
;; =========================================================================
.area _DATA
_estex_startup_ix::
.ds 2
_argc::
.ds 2
_argv::
.ds 2
.ifdef DEBUG_RT
;; Runtime diagnostic: 0 = no extra W2 page was self-allocated by crt0
;; (DSS gave us what we needed); 1 = crt0 had to allocate W2 itself.
;; Only present when sprinter-cc is invoked with --debug.
_w2_self_allocated::
.ds 1
.endif
.area _BSS
argv_buf:
.ds ARGV_BUF_BYTES
argv_array:
.ds (ARGV_MAX_ARGS + 1) * 2 ; +1 for trailing NULL pointer
progname_buf:
.ds 128 ; ESTEX APPINFO app_path target
;; =========================================================================
;; gsinit — copy _INITIALIZER -> _INITIALIZED, then zero _BSS.
;; =========================================================================
.area _GSINIT
gsinit::
ld bc, #l__INITIALIZER
ld a, b
or a, c
jr Z, gsinit_bss
ld de, #s__INITIALIZED
ld hl, #s__INITIALIZER
ldir
gsinit_bss:
ld bc, #l__BSS
ld a, b
or a, c
jr Z, gsinit_done
ld hl, #s__BSS
ld (hl), #0
dec bc
ld a, b
or a, c
jr Z, gsinit_done
ld d, h
ld e, l
inc de
ldir
gsinit_done:
.area _GSFINAL
ret
+459
View File
@@ -0,0 +1,459 @@
;; ----------------------------------------------------------------------
;; crt0_banked.s — Sprinter ESTEX C runtime startup with bank loading.
;;
;; Same entry contract as crt0.s, plus the EXE was packaged with
;; `loader > 0` so the file handle survives in (IX-3).
;;
;; Sequence:
;; 1. Boot stack in W1 (CODE area). We can't yet touch W2 because
;; small images (<16 KB) get only the W1 page from DSS — W2 reads
;; as 0xFF and ignores writes. This mirrors crt0_small.s.
;; 2. Detect via IN A,(#0xC2) whether DSS already mapped W2. If not,
;; ESTEX $3D GETMEM + $3A SETWIN2 maps a fresh page (BIOS $C4 + OUT
;; would require SP in W2, which we don't have yet).
;; 3. SP = 0xBFFE (now W2 is real RAM)
;; 4. Stash file handle + ESTEX block id from prefix
;; 5. GETMEM(_n_banks) → block id, then for each bank i in 1..n:
;; BIOS_EMM_GETPAGE → phys page → _bank_pages[i]
;; OUT (#0xE2), phys ; map into W3
;; ESTEX READ 16384 bytes → 0xC000 ; load bank from file
;; 6. CLOSE file
;; 7. Standard gsinit + call _main + ESTEX EXIT
;;
;; _n_banks MUST be defined as `const uint8_t n_banks = N;` — we read it
;; before gsinit, so a plain initialized `uint8_t` would still hold 0
;; (the initializer hasn't been copied yet) and bank loading would be
;; silently skipped, leaving window 3 mapped to a garbage page.
;; For zero banks, link crt0.s instead.
;; ----------------------------------------------------------------------
.module crt0_banked
.globl _main
.globl _n_banks
.globl _bank_pages
.globl s__INITIALIZER
.globl l__INITIALIZER
.globl s__INITIALIZED
.globl s__BSS
.globl l__BSS
;; argv parsing tunables — mirror crt0.s.
ARGV_MAX_ARGS = 16
ARGV_BUF_BYTES = 128
BOOT_STACK_BYTES = 32 ; enough for a few ESTEX RST 10h calls
;; ----- Banking-window parameters ----------------------------------------
;; Choose between W3 (default = HUGE mode) and W1 (BIG mode, prepended
;; BANK_W1 = 1 at assembly time by sprinter-cc). These constants steer
;; the bank-loading IO port and the address where each bank lives in
;; the program's virtual memory once mapped in.
.ifdef BANK_W1
BANK_PORT = 0xA2 ; W1 page register
BANK_LOAD_ADDR = 0x4000 ; banks at 0x4000-0x7FFF when mapped
.else
BANK_PORT = 0xE2 ; W3 page register
BANK_LOAD_ADDR = 0xC000 ; banks at 0xC000-0xFFFF when mapped
.endif
;; ----- Area declaration order ---------------------------------------------
.area _HOME
.area _CODE
.area _INITIALIZER
.area _GSINIT
.area _GSFINAL
.area _DATA
.area _INITIALIZED
.area _BSEG
.area _BSS
.area _HEAP
;; =========================================================================
;; Entry point — first instruction in _CODE, hence at --code-loc (0x4100).
;; =========================================================================
.area _CODE
_start::
;; ----- Step 1: switch to W1 boot stack (W2 may be unmapped). -------
ld sp, #boot_stack_top
push ix ; stash IX in W1 (W2 not yet safe)
;; ----- Step 2: detect / allocate W2. ------------------------------
;; Port 0xC2 = W2 page register. 0xFF means DSS hasn't mapped W2
;; (program ≤ 16 KB). Any other value means W2 is real RAM and we
;; must NOT replace it (DSS may have stored bytes there).
in a, (#0xC2)
cp #0xFF
jr nz, w2_already_mapped
;; ESTEX $3D GETMEM — alloc 1 page; A = block id, CF = err
ld b, #1
ld c, #0x3D
rst #0x10
jp c, abort_fault
;; ESTEX $3A SETWIN2 — A = block id, B = page idx → map into W2
ld b, #0
ld c, #0x3A
rst #0x10
jp c, abort_fault
.ifdef DEBUG_RT
ld a, #1 ; flag: we self-allocated W2
jr w2_join
.endif
w2_already_mapped:
.ifdef DEBUG_RT
xor a ; flag: DSS already gave us W2
w2_join:
.endif
pop ix ; restore IX
ld sp, #0xBFFE ; canonical stack top (W2)
.ifdef DEBUG_RT
push af ; preserve flag across gsinit/bank load
.endif
;; Stash startup prefix info from IX (asxxxx syntax: disp(ix)).
ld a, -3 (ix)
ld (_estex_file_handle), a
ld a, -2 (ix)
ld (_estex_block_id), a
ld (_estex_startup_ix), ix
;; ---- Allocate _n_banks pages via ESTEX GETMEM ----
ld a, (_n_banks)
or a, a
jr Z, skip_bank_load ; nothing to allocate
ld b, a
ld c, #0x3D ; ESTEX GETMEM
rst #0x10
jp c, abort_fault ; CF=1 → out of memory
ld (_bank_block_id), a ; remember block id
;; ---- For each bank i in 0..n-1 ----
ld b, #0 ; B = page index inside block
load_bank_loop:
ld a, (_n_banks)
cp b
jr Z, load_bank_done ; B == n_banks → finished
;; --- Get physical page for slot B from BIOS EMM ---
push bc ; save loop counter
ld a, (_bank_block_id)
ld c, #0xC4 ; BIOS EMM_GETPAGE
rst #0x08
pop bc
jp c, abort_fault
;; A = physical page. Store in _bank_pages[B+1].
push af ; save A (the phys page)
push bc
ld c, b
inc c ; C = B+1 (1-based index)
ld b, #0
ld hl, #_bank_pages
add hl, bc
pop bc
pop af
ld (hl), a ; _bank_pages[B+1] = phys
;; Map this page into the banking window (W1 for BIG, W3 for HUGE).
out (#BANK_PORT), a
;; ESTEX READ: A=handle, HL=buf, DE=count, C=0x13
push bc ; save loop counter across RST
ld a, (_estex_file_handle)
ld hl, #BANK_LOAD_ADDR
ld de, #0x4000 ; 16384 bytes (one full bank)
ld c, #0x13 ; ESTEX READ
rst #0x10
pop bc
jp c, abort_fault
inc b
jr load_bank_loop
load_bank_done:
;; Close the executable file — banks are in RAM now.
ld a, (_estex_file_handle)
ld c, #0x12 ; ESTEX CLOSE
rst #0x10
;; Ignore CF from close.
skip_bank_load:
;; ---- Standard SDCC init path ----
call gsinit
.ifdef DEBUG_RT
pop af
ld (_w2_self_allocated), a
.endif
;; Parse cmdline into argv[] (BSS now zeroed by gsinit), then fix
;; argv[0] to the .EXE basename via APPINFO. Both routines fail
;; gracefully (argv[0] stays empty if APPINFO returns CF=1).
call parse_argv
call get_progname
;; SDCC __sdcccall(1): main(argc, argv) → arg1=HL, arg2=DE.
ld hl, (_argc)
ld de, (_argv)
call _main
;; main returned: int return is in DE per SDCC 4.5 __sdcccall(1).
ld a, e
jr exit_with_a
_exit::
;; Userspace _exit(int code) — code in HL (single-arg ABI).
ld a, l
exit_with_a:
ld b, a
ld c, #0x41 ; ESTEX EXIT
rst #0x10
1$: halt
jr 1$
abort_fault:
;; Bail out with a sentinel exit code so failures are visible in the
;; emulator / shell.
ld b, #0xFE
ld c, #0x41
rst #0x10
1$: halt
jr 1$
;; =========================================================================
;; parse_argv / get_progname — identical copy of crt0.s. When we build
;; libsprinter.lib these two routines will be factored into argv.s and
;; linked once for all crt0 variants.
;; =========================================================================
.area _CODE
parse_argv::
ld hl, (_estex_startup_ix)
ld a, (hl)
cp #ARGV_BUF_BYTES
jr c, len_ok
ld a, #ARGV_BUF_BYTES-1
len_ok:
ld c, a
ld b, #0
inc hl
ld de, #argv_buf
ld a, b
or a, c
jr Z, after_copy
ldir
after_copy:
xor a
ld (de), a
ld hl, #argv_buf
strip_ws:
ld a, (hl)
or a
jr Z, no_args
cp #0x20
jr Z, strip_more
cp #0x09
jr nz, have_first_token
strip_more:
inc hl
jr strip_ws
no_args:
ld hl, #empty_str
ld (argv_array), hl
ld hl, #0
ld (argv_array+2), hl
ld hl, #1
ld (_argc), hl
ld hl, #argv_array
ld (_argv), hl
ret
have_first_token:
ld de, #empty_str
ld (argv_array), de
ld b, #1
token_loop:
ld a, b
cp #ARGV_MAX_ARGS
jr nc, tokens_done
push hl
ld a, b
add a, a
ld e, a
ld d, #0
ld hl, #argv_array
add hl, de
pop de
ld (hl), e
inc hl
ld (hl), d
ex de, hl
inc b
walk_token:
ld a, (hl)
or a
jr Z, tokens_done
cp #0x20
jr Z, end_of_token
cp #0x09
jr Z, end_of_token
inc hl
jr walk_token
end_of_token:
xor a
ld (hl), a
inc hl
skip_ws:
ld a, (hl)
or a
jr Z, tokens_done
cp #0x20
jr Z, skip_more
cp #0x09
jr Z, skip_more
jr token_loop
skip_more:
inc hl
jr skip_ws
tokens_done:
ld a, b
add a, a
ld e, a
ld d, #0
ld hl, #argv_array
add hl, de
ld (hl), #0
inc hl
ld (hl), #0
ld a, b
ld (_argc), a
xor a
ld (_argc+1), a
ld hl, #argv_array
ld (_argv), hl
ret
empty_str:
.db 0
;; =========================================================================
;; Boot stack — reserved in _CODE (W1, real RAM). Used only during the
;; first few instructions of _start, before W2 is known-mapped. After we
;; switch SP to 0xBFFE this memory is just dead bytes in HOME.
;; =========================================================================
boot_stack:
.ds BOOT_STACK_BYTES
boot_stack_top:
get_progname::
push ix
ld hl, #progname_buf
ld b, #2
ld c, #0x47
rst #0x10
pop ix
jr c, gpn_skip
ld hl, #progname_buf
gpn_find_end:
ld a, (hl)
or a, a
jr Z, gpn_at_end
inc hl
jr gpn_find_end
gpn_at_end:
ld de, #progname_buf
gpn_scan_back:
ld a, h
cp a, d
jr nz, gpn_dec
ld a, l
cp a, e
jr Z, gpn_set
gpn_dec:
dec hl
ld a, (hl)
cp #0x5C
jr Z, gpn_after_sep
cp #0x3A
jr Z, gpn_after_sep
jr gpn_scan_back
gpn_after_sep:
inc hl
gpn_set:
ld (argv_array), hl
gpn_skip:
ret
;; =========================================================================
;; Runtime data
;; =========================================================================
.area _DATA
_estex_startup_ix::
.ds 2
_estex_file_handle::
.ds 1
_estex_block_id::
.ds 1
_bank_block_id::
.ds 1
_argc::
.ds 2
_argv::
.ds 2
.area _BSS
argv_buf:
.ds ARGV_BUF_BYTES
argv_array:
.ds (ARGV_MAX_ARGS + 1) * 2
progname_buf:
.ds 128
.ifdef DEBUG_RT
;; Runtime diagnostic — same semantics as in crt0.s / crt0_small.s.
_w2_self_allocated::
.ds 1
.endif
;; =========================================================================
;; gsinit — same as crt0.s (copy INITIALIZER, zero BSS)
;; =========================================================================
.area _GSINIT
gsinit::
ld bc, #l__INITIALIZER
ld a, b
or a, c
jr Z, gsinit_bss
ld de, #s__INITIALIZED
ld hl, #s__INITIALIZER
ldir
gsinit_bss:
ld bc, #l__BSS
ld a, b
or a, c
jr Z, gsinit_done
ld hl, #s__BSS
ld (hl), #0
dec bc
ld a, b
or a, c
jr Z, gsinit_done
ld d, h
ld e, l
inc de
ldir
gsinit_done:
.area _GSFINAL
ret
+110
View File
@@ -0,0 +1,110 @@
;; ----------------------------------------------------------------------
;; crt0_minimal.s — Sprinter ESTEX C runtime startup, no argv parsing.
;;
;; This is the opt-out variant for programs where every byte counts.
;; The DEFAULT runtime is runtime/crt0.s which also parses argv from the
;; ESTEX command line and passes argc/argv to main().
;;
;; To use this instead of the default:
;; crt0.rel: $(PROJ_ROOT)/runtime/crt0_minimal.s
;; $(SDASZ80) -o $@ $<
;;
;; Otherwise identical to crt0.s — saves IX, calls gsinit, calls main
;; with no arguments, then EXIT via ESTEX $41.
;; ----------------------------------------------------------------------
.module crt0_minimal
.globl _main
;; Linker-emitted symbols for areas (resolved at link time).
.globl s__INITIALIZER
.globl l__INITIALIZER
.globl s__INITIALIZED
.globl s__BSS
.globl l__BSS
;; =========================================================================
;; AREA ORDERING — emitted up-front so the linker walks them in this order.
;; =========================================================================
.area _HOME
.area _CODE
.area _INITIALIZER
.area _GSINIT
.area _GSFINAL
.area _DATA
.area _INITIALIZED
.area _BSEG
.area _BSS
.area _HEAP
;; =========================================================================
;; Entry point — first instruction in _CODE, hence at --code-loc (0x4100).
;; =========================================================================
.area _CODE
_start::
;; ESTEX already set SP from the EXE header, but make it explicit.
ld sp, #0xBFFE
;; Save startup prefix (IX) for argv/exit-code helpers.
ld (_estex_startup_ix), ix
;; Run SDCC global initialisers (concatenated into _GSINIT).
call gsinit
;; Hand off to user main.
call _main
;; SDCC 4.5 __sdcccall(1): int return is in DE, so low byte of main()'s
;; return is in E. (See memory/sdcc_z80_abi.md.)
;; main returned — terminate via ESTEX EXIT directly. We do NOT
;; expose a _exit symbol; programs that need exit() / atexit() must
;; link libc/io/atexit.c.
ld a, e
ld b, a
ld c, #0x41 ; ESTEX EXIT
rst #0x10
1$: halt
jr 1$
;; =========================================================================
;; Runtime data
;; =========================================================================
.area _DATA
_estex_startup_ix::
.ds 2
;; =========================================================================
;; gsinit — copy _INITIALIZER -> _INITIALIZED, then zero _BSS.
;; SDCC appends per-unit init code between this label and the ret in _GSFINAL.
;; =========================================================================
.area _GSINIT
gsinit::
;; --- Copy _INITIALIZER -> _INITIALIZED if non-empty ---
ld bc, #l__INITIALIZER
ld a, b
or a, c
jr Z, gsinit_bss
ld de, #s__INITIALIZED
ld hl, #s__INITIALIZER
ldir
gsinit_bss:
;; --- Zero _BSS if non-empty ---
ld bc, #l__BSS
ld a, b
or a, c
jr Z, gsinit_done
ld hl, #s__BSS
ld (hl), #0
dec bc
ld a, b
or a, c
jr Z, gsinit_done
ld d, h
ld e, l
inc de
ldir
gsinit_done:
.area _GSFINAL
ret
+395
View File
@@ -0,0 +1,395 @@
;; ----------------------------------------------------------------------
;; crt0_small.s — Sprinter ESTEX C runtime startup for SMALL memory mode.
;;
;; Layout (small mode):
;; CODE at 0x4100 (W1)
;; DATA chained after code by the linker (W1, possibly spills into W2)
;; STACK at 0xBFFE (top of W2, valid after W2 page is mapped)
;; HEAP from end-of-BSS up to ___sdcc_heap_end (default 0xBB00)
;;
;; Size auto-detect:
;; For programs < ~14 KB, DSS allocates exactly ONE 16 KB page (W1). W2
;; is "page #FF" (read=0xFF, writes ignored) and we must allocate &
;; map a page ourselves. For programs that already span > 16 KB (the
;; linker emitted bytes above 0x7FFF), DSS allocates TWO pages on its
;; own — W2 already RAM — and we MUST NOT call SETWIN2, or we'd swap
;; the DSS-loaded code page out of W2.
;;
;; We auto-detect by writing a test byte at 0xBFFF (well above stack
;; pushes) and reading back. Match → W2 is RAM, skip allocation.
;; Mismatch → W2 is page #FF, allocate via ESTEX $3D + $3A.
;;
;; Chicken-and-egg:
;; DSS sets SP from the EXE header (0xBFFE) — in unmapped W2 for small
;; programs. Pushes silently fail; first `call` returns to 0xFFFF and
;; crashes. Boot stack lives in HOME (W1, real RAM) until W2 is ready.
;;
;; Why ESTEX $3A SETWIN2 not BIOS $C4 + OUT (0xC2):
;; BIOS calls require SP in W2 (0x8000..0xBFFF) — exactly what we don't
;; have yet. SETWIN2 is ESTEX, works with W1 stack.
;;
;; Entry contract — same as crt0.s.
;; ----------------------------------------------------------------------
.module crt0_small
.globl _main
;; Linker-emitted symbols (resolved at link time).
.globl s__INITIALIZER
.globl l__INITIALIZER
.globl s__INITIALIZED
.globl s__BSS
.globl l__BSS
;; Tunables — mirror crt0.s.
ARGV_MAX_ARGS = 16
ARGV_BUF_BYTES = 128
BOOT_STACK_BYTES = 32 ; enough for 2-3 nested RST 10h/RST 8 calls
;; =========================================================================
;; AREA ORDERING.
;; =========================================================================
.area _HOME
.area _CODE
.area _INITIALIZER
.area _GSINIT
.area _GSFINAL
.area _DATA
.area _INITIALIZED
.area _BSEG
.area _BSS
.area _HEAP
;; =========================================================================
;; Entry point — must be FIRST in _CODE so it lands at --code-loc (0x4100).
;; W2 is unmapped at this instant — no `call` allowed until we point SP at
;; boot_stack_top (in W1, declared just below).
;; =========================================================================
.area _CODE
_start::
;; Switch to the boot stack in W1. The DSS-set SP=0xBFFE may point
;; into unmapped W2 — pushing there silently fails and the first call
;; would return to 0xFFFF. Boot stack lives in HOME (W1, real RAM).
ld sp, #boot_stack_top
;; The DSS startup-prefix pointer is in IX. Stash it on the boot
;; stack — _estex_startup_ix is in W2 which may not be RAM yet.
push ix
;; ----- Step 1: detect whether W2 is already mapped. -----------------
;; Port 0xC2 is the W2 page register (0x82/A2/C2/E2 = W0/W1/W2/W3).
;; Reading it returns the page number currently selected for W2; page
;; 0xFF is the "unmapped" sentinel (read=0xFF / write-ignored). A
;; non-0xFF value means DSS already allocated and mapped a real RAM
;; page for W2 — this happens for programs whose HOME image extends
;; past 0x7FFF (DSS allocates as many pages as the image size demands).
in a, (#0xC2)
cp #0xFF
jr nz, w2_already_mapped
;; ----- Step 2a: W2 unmapped → allocate one page and map it. --------
;; ESTEX $3D GETMEM: B = npages → A = block id, CF = err.
ld b, #1
ld c, #0x3D
rst #0x10
jr c, w2_alloc_fail
;; A = block id of the freshly-allocated 1-page block.
;; ESTEX $3A SETWIN2: A = block id, B = page idx → maps to W2. This
;; is an ESTEX (RST 10h) call, not BIOS (RST 8) — BIOS would require
;; SP in W2, which is exactly what we don't have yet.
ld b, #0 ; page 0 of the 1-page block
ld c, #0x3A
rst #0x10
jr c, w2_alloc_fail
.ifdef DEBUG_RT
ld a, #1 ; flag: we self-allocated W2
jr w2_join
.endif
w2_already_mapped:
.ifdef DEBUG_RT
xor a ; flag: DSS already gave us W2
w2_join:
.endif
;; ----- Step 3: switch to the canonical SP at the top of W2. --------
;; Pop IX back from the boot stack first.
pop ix
ld sp, #0xBFFE
.ifdef DEBUG_RT
;; Save the self-alloc flag across gsinit (which clobbers BC/DE/HL and
;; zeroes BSS). SP is in W2 now, push is safe.
push af
.endif
;; ----- Step 5: save IX prefix in its canonical W2 slot. -------------
ld (_estex_startup_ix), ix
;; ----- Step 6: standard gsinit + argv flow (identical to crt0.s). --
call gsinit
.ifdef DEBUG_RT
pop af
ld (_w2_self_allocated), a
.endif
call parse_argv
call get_progname
ld hl, (_argc)
ld de, (_argv)
call _main
;; SDCC int return in DE. Low byte = exit code.
ld a, e
ld b, a
ld c, #0x41 ; ESTEX EXIT
rst #0x10
1$: halt
jr 1$
;; -------------------------------------------------------------------------
;; W2 allocation failed — there's not much we can do without DATA/BSS.
;; Try to abort via ESTEX EXIT with a non-zero code (no return).
;; -------------------------------------------------------------------------
w2_alloc_fail:
ld b, #0xFE ; arbitrary failure code
ld c, #0x41 ; ESTEX EXIT
rst #0x10
2$: halt
jr 2$
;; =========================================================================
;; Boot stack — reserved in HOME (W1, real RAM). Used only during the
;; first few instructions of _start, before W2 is mapped. After we switch
;; SP to 0xBFFE the boot stack memory is just dead bytes in HOME.
;; =========================================================================
boot_stack:
.ds BOOT_STACK_BYTES
boot_stack_top:
;; =========================================================================
;; parse_argv / get_progname / runtime data / gsinit — same as crt0.s.
;; Kept inline rather than factored out to keep crt0 build self-contained.
;; =========================================================================
.area _CODE
parse_argv::
ld hl, (_estex_startup_ix)
ld a, (hl)
cp #ARGV_BUF_BYTES
jr c, len_ok
ld a, #ARGV_BUF_BYTES-1
len_ok:
ld c, a
ld b, #0
inc hl
ld de, #argv_buf
ld a, b
or a, c
jr Z, after_copy
ldir
after_copy:
xor a
ld (de), a
ld hl, #argv_buf
strip_ws:
ld a, (hl)
or a
jr Z, no_args
cp #0x20
jr Z, strip_more
cp #0x09
jr nz, have_first_token
strip_more:
inc hl
jr strip_ws
no_args:
ld hl, #empty_str
ld (argv_array), hl
ld hl, #0
ld (argv_array+2), hl
ld hl, #1
ld (_argc), hl
ld hl, #argv_array
ld (_argv), hl
ret
have_first_token:
ld de, #empty_str
ld (argv_array), de
ld b, #1
token_loop:
ld a, b
cp #ARGV_MAX_ARGS
jr nc, tokens_done
push hl
ld a, b
add a, a
ld e, a
ld d, #0
ld hl, #argv_array
add hl, de
pop de
ld (hl), e
inc hl
ld (hl), d
ex de, hl
inc b
walk_token:
ld a, (hl)
or a
jr Z, tokens_done
cp #0x20
jr Z, end_of_token
cp #0x09
jr Z, end_of_token
inc hl
jr walk_token
end_of_token:
xor a
ld (hl), a
inc hl
skip_ws:
ld a, (hl)
or a
jr Z, tokens_done
cp #0x20
jr Z, skip_more
cp #0x09
jr Z, skip_more
jr token_loop
skip_more:
inc hl
jr skip_ws
tokens_done:
ld a, b
add a, a
ld e, a
ld d, #0
ld hl, #argv_array
add hl, de
ld (hl), #0
inc hl
ld (hl), #0
ld a, b
ld (_argc), a
xor a
ld (_argc+1), a
ld hl, #argv_array
ld (_argv), hl
ret
empty_str:
.db 0
.area _CODE
get_progname::
push ix
ld hl, #progname_buf
ld b, #2
ld c, #0x47
rst #0x10
pop ix
jr c, gpn_skip
ld hl, #progname_buf
gpn_find_end:
ld a, (hl)
or a, a
jr Z, gpn_at_end
inc hl
jr gpn_find_end
gpn_at_end:
ld de, #progname_buf
gpn_scan_back:
ld a, h
cp a, d
jr nz, gpn_dec
ld a, l
cp a, e
jr Z, gpn_set
gpn_dec:
dec hl
ld a, (hl)
cp #0x5C
jr Z, gpn_after_sep
cp #0x3A
jr Z, gpn_after_sep
jr gpn_scan_back
gpn_after_sep:
inc hl
gpn_set:
ld (argv_array), hl
gpn_skip:
ret
;; =========================================================================
;; Runtime data — same layout as crt0.s. Lives in DATA/BSS which the
;; linker places in W2 (--data-loc 0x8000).
;; =========================================================================
.area _DATA
_estex_startup_ix::
.ds 2
_argc::
.ds 2
_argv::
.ds 2
.area _BSS
argv_buf:
.ds ARGV_BUF_BYTES
argv_array:
.ds (ARGV_MAX_ARGS + 1) * 2
progname_buf:
.ds 128
.ifdef DEBUG_RT
;; Runtime diagnostic: 0 = DSS already mapped W2 for us (program > 16 KB,
;; or some other case); 1 = crt0 had to allocate and map W2 itself via
;; ESTEX $3D + $3A. Only present when sprinter-cc is invoked with --debug.
_w2_self_allocated::
.ds 1
.endif
;; =========================================================================
;; gsinit — copy _INITIALIZER -> _INITIALIZED, then zero _BSS. Identical
;; to crt0.s; runs AFTER W2 is mapped so writes to _INITIALIZED/_BSS land.
;; =========================================================================
.area _GSINIT
gsinit::
ld bc, #l__INITIALIZER
ld a, b
or a, c
jr Z, gsinit_bss
ld de, #s__INITIALIZED
ld hl, #s__INITIALIZER
ldir
gsinit_bss:
ld bc, #l__BSS
ld a, b
or a, c
jr Z, gsinit_done
ld hl, #s__BSS
ld (hl), #0
dec bc
ld a, b
or a, c
jr Z, gsinit_done
ld d, h
ld e, l
inc de
ldir
gsinit_done:
.area _GSFINAL
ret
+48
View File
@@ -0,0 +1,48 @@
;; ----------------------------------------------------------------------
;; heap.s — Sprinter heap definition. Replaces SDCC's z80.lib heap.rel
;; with a DYNAMIC heap that auto-sizes to whatever space is left between
;; end-of-BSS and a fixed upper bound near the top of W2.
;;
;; Old design reserved a fixed `.ds 14000` after BSS, which overflowed
;; the stack at 0xBFFE in tiny mode and crashed once allocations crossed
;; that boundary. Even a static 8 KB would fail once CODE+DATA grew.
;;
;; New design:
;; ___sdcc_heap = label at start of _HEAP area (= end of _BSS,
;; placed by the linker)
;; ___sdcc_heap_end = absolute equate at HEAP_TOP (= 0xBB00 default).
;; malloc uses `&___sdcc_heap_end` as the upper
;; bound of the free list.
;;
;; Heap size = HEAP_TOP - end_of_BSS. Grows when code shrinks, shrinks
;; when code grows. Falls to 0 (malloc returns NULL) if BSS grows past
;; HEAP_TOP — safe, no crash.
;;
;; The gap (BSS_end..HEAP_TOP) is real RAM that simply isn't tracked by
;; the linker — it doesn't need to be, since nothing else is placed in
;; that range (the area-ordering in crt0.s ends with _HEAP).
;;
;; Reserve 0xBB00..0xBFFE (= 1278 bytes) for the stack — enough for
;; deep printf and most recursion. Override with -DHEAP_TOP=0xNNNN.
;; ----------------------------------------------------------------------
.module sprinter_heap
.globl ___sdcc_heap_init
;; NOTE: ___sdcc_heap_end is defined in crt0.s (per-program crt0).
;; We need it as an absolute equate (since malloc takes &symbol),
;; and the value must be configurable per program via -DHEAP_TOP=N
;; (or its derivative --stack-size). Putting it in crt0 means the
;; library can stay HEAP_TOP-agnostic.
;; gsinit hook — calls into z80.lib's malloc.rel to wire up the
;; free-list at startup (after _BSS is zeroed).
.area _GSINIT
call ___sdcc_heap_init
;; Heap start label — _HEAP follows _BSS in the area-ordering chain,
;; so ___sdcc_heap = first byte after BSS. No bytes reserved here;
;; the heap memory is the gap to HEAP_TOP (defined in crt0).
.area _HEAP
___sdcc_heap::
+22
View File
@@ -0,0 +1,22 @@
;; ----------------------------------------------------------------------
;; heap_top.s — defines the absolute upper bound of the heap.
;;
;; This file is built PER-PROGRAM (not bundled into sprinter.lib) so the
;; value of ___sdcc_heap_end can be overridden without re-assembling the
;; library or the user's crt0. sprinter-cc may regenerate this file in
;; its working dir with a custom value when `--stack-size N` is passed;
;; otherwise the default below applies.
;;
;; malloc takes &___sdcc_heap_end as the upper bound of the free-list,
;; so the symbol's address (== the equate value) IS the heap ceiling.
;;
;; 0xBFFE — stack top (init SP in crt0)
;; 0xBB00 — heap top (default) → ~1278 bytes reserved for the stack
;; ... — heap grows up to here from end-of-BSS
;; end-of-BSS — heap start
;; ----------------------------------------------------------------------
.module sprinter_heap_top
___sdcc_heap_end = 0xBB00
.globl ___sdcc_heap_end