GNO support fixed.

This commit is contained in:
Scott Duensing 2026-07-07 21:11:28 -05:00
parent 8d30c06605
commit dc152e575f
20 changed files with 941 additions and 105 deletions

55
demos/gnoConcurA.c Normal file
View file

@ -0,0 +1,55 @@
// Concurrency test, program A of a pair (run "gnoconcura & gnoconcurb" under
// gsh). Proves two SEPARATE programs run CONCURRENTLY under GNO's preemptive
// scheduler via a shared-memory rendezvous that can ONLY complete if the two
// interleave: A announces itself (flagA), then spins waiting for B's flag. If A
// ran to completion before B ever started, it would time out (0xDEAD). Both
// reaching their rendezvous marker (A=0xAAAA, B=0xBBBB) means the scheduler
// actually time-sliced them. Each also does real work (sumTo) to prove it is
// not a trivial spin. Bank-2 markers are absolute physical RAM, shared across
// processes -- that is what makes the cross-process rendezvous observable.
//
// 0x025040 0001 flagA: A is alive
// 0x025044 AAAA A saw B's flag (rendezvous) AND sumTo(100)==5050; else DEAD
#include <stdint.h>
#define M(a) (*(volatile uint16_t *)(a))
#define FLAG_A 0x025040UL
#define FLAG_B 0x025042UL
#define RES_A 0x025044UL
#define LIMIT 100000000UL
static uint16_t sumTo(uint16_t n) {
uint16_t acc;
uint16_t i;
acc = 0;
for (i = 1; i <= n; i++) {
acc = acc + i;
}
return acc;
}
int main(void) {
unsigned long spin;
uint16_t saw;
M(RES_A) = 0xDEAD;
M(FLAG_A) = 0x0001; // announce A
saw = 0;
for (spin = 0; spin < LIMIT; spin++) {
if (M(FLAG_B) == 0x0001) {
saw = 1;
break;
}
}
if (saw == 1 && sumTo(100) == 5050) {
M(RES_A) = 0xAAAA; // rendezvous + real work OK
}
M(0x025048UL) = 0xE0AA; // A reached end (distinguishes timeout from crash)
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

49
demos/gnoConcurB.c Normal file
View file

@ -0,0 +1,49 @@
// Concurrency test, program B of a pair (see gnoConcurA.c). B announces its
// flag, spins waiting for A's, then does real work. Both markers set means the
// two SEPARATE programs genuinely time-sliced under GNO's preemptive scheduler.
//
// 0x025042 0001 flagB: B is alive
// 0x025046 BBBB B saw A's flag (rendezvous) AND sumTo(100)==5050; else DEAD
#include <stdint.h>
#define M(a) (*(volatile uint16_t *)(a))
#define FLAG_A 0x025040UL
#define FLAG_B 0x025042UL
#define RES_B 0x025046UL
#define LIMIT 100000000UL
static uint16_t sumTo(uint16_t n) {
uint16_t acc;
uint16_t i;
acc = 0;
for (i = 1; i <= n; i++) {
acc = acc + i;
}
return acc;
}
int main(void) {
unsigned long spin;
uint16_t saw;
M(RES_B) = 0xDEAD;
M(FLAG_B) = 0x0001; // announce B
saw = 0;
for (spin = 0; spin < LIMIT; spin++) {
if (M(FLAG_A) == 0x0001) {
saw = 1;
break;
}
}
if (saw == 1 && sumTo(100) == 5050) {
M(RES_B) = 0xBBBB; // rendezvous + real work OK
}
M(0x02504AUL) = 0xE0BB; // B reached end (distinguishes timeout from crash)
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

84
demos/gnoConcurFnptr.c Normal file
View file

@ -0,0 +1,84 @@
// The ultimate concurrency proof: parent and forked child BOTH dispatch function
// pointers WHILE preemptively interleaved. They ping-pong a shared turn word
// (proving they time-slice), and on every one of its turns each thread makes an
// indirect call through its own volatile function pointer -- the parent inc's an
// accumulator, the child dec's one. This only produces the right accumulators
// (parent 0->ROUNDS, child 1000->1000-ROUNDS) if the reentrant __jsl_indir reads
// each thread's OWN per-thread DP target: with the old shared self-modified
// operand the two threads' indirect dispatch aliased and wild-jumped.
//
// 0x025070 ROUNDS*2 (0x0014) final turn value (ping-pong completed)
// 0x025072 5050 parent did ROUNDS fnptr inc's concurrently (acc==ROUNDS)
// 0x025074 CCCC child did ROUNDS fnptr dec's concurrently (acc==1000-ROUNDS)
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
#define M(a) (*(volatile uint16_t *)(a))
#define TURN 0x025070UL
#define RES_P 0x025072UL
#define RES_C 0x025074UL
#define ROUNDS 10
static uint16_t incf(uint16_t x) {
return x + 1;
}
static uint16_t decf(uint16_t x) {
return x - 1;
}
static uint16_t (*volatile fpP)(uint16_t) = incf;
static uint16_t (*volatile fpC)(uint16_t) = decf;
static void child(void) {
uint16_t k;
uint16_t acc;
acc = 1000;
for (k = 0; k < ROUNDS; k++) {
while ((M(TURN) & 1) == 0) {
}
acc = fpC(acc); // indirect call, concurrent with parent
M(TURN) = M(TURN) + 1;
}
if (acc == (uint16_t)(1000 - ROUNDS)) {
M(RES_C) = 0xCCCC;
}
_exit(0);
}
int main(void) {
uint16_t k;
uint16_t acc;
int pid;
int st;
M(TURN) = 0x0000;
M(RES_P) = 0xDEAD;
M(RES_C) = 0xDEAD;
acc = 0;
pid = fork((void *)child);
if (pid > 0) {
for (k = 0; k < ROUNDS; k++) {
while ((M(TURN) & 1) == 1) {
}
acc = fpP(acc); // indirect call, concurrent with child
M(TURN) = M(TURN) + 1;
}
if (acc == ROUNDS) {
M(RES_P) = 0x5050;
}
wait(&st);
}
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

66
demos/gnoConcurFork.c Normal file
View file

@ -0,0 +1,66 @@
// Definitive preemptive-concurrency test via fork() ping-pong. A forked GNO
// thread (child) and the parent alternate ownership of a shared "turn" word in
// bank 2: the parent advances it on EVEN turns, the child on ODD. Each must
// WAIT for the other to hand over the turn, ROUNDS times. This completes ONLY
// if GNO preemptively time-slices the two threads -- if they ran sequentially,
// whichever went first would spin forever at round 2 waiting for a turn the
// other can't give. No program-load latency and no gsh job control to muddy it
// (unlike the two-separate-programs path): fork is in-process and immediate.
//
// The child stays within the leaf-only fork contract: a register loop + absolute
// bank-2 loads/stores + _exit (no calls, libcalls, fn-pointers, or &global).
//
// 0x025050 ROUNDS*2 (0x0014) final turn value if the ping-pong completed
// 0x025052 5050 parent finished all ROUNDS turns (else DEAD = stuck/no sched)
// 0x025054 CCCC child finished all ROUNDS turns (else DEAD = never scheduled)
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
#define M(a) (*(volatile uint16_t *)(a))
#define TURN 0x025050UL
#define RES_P 0x025052UL
#define RES_C 0x025054UL
#define ROUNDS 10
// Child (leaf): take the ODD turns. Wait until TURN is odd, then bump it even.
static void child(void) {
uint16_t k;
for (k = 0; k < ROUNDS; k++) {
while ((M(TURN) & 1) == 0) {
}
M(TURN) = M(TURN) + 1;
}
M(RES_C) = 0xCCCC;
_exit(0);
}
int main(void) {
uint16_t k;
int pid;
int st;
M(TURN) = 0x0000; // even -> parent goes first
M(RES_P) = 0xDEAD;
M(RES_C) = 0xDEAD;
pid = fork((void *)child);
if (pid <= 0) {
M(RES_P) = 0xF00D; // fork failed outright
} else {
for (k = 0; k < ROUNDS; k++) {
while ((M(TURN) & 1) == 1) {
}
M(TURN) = M(TURN) + 1;
}
M(RES_P) = 0x5050; // parent completed its turns
wait(&st);
}
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

63
demos/gnoForkFnptr.c Normal file
View file

@ -0,0 +1,63 @@
// Pin down the LAST forked-child blocker: an indirect call (function pointer).
// clang routes indirect calls through __jsl_indir, whose target slot is selected
// by a single global self-modifying operand __jsl_indir_op that crt0 patches to
// the MAIN thread's DP+$B8. A forked child stores its target to its OWN (fresh)
// DP+$B8, but __jsl_indir still reads through the parent's operand -> the child's
// indirect call dispatches through the WRONG direct page. gFp is volatile so the
// call is a genuine indirect dispatch, not a devirtualized direct call.
//
// 0x025060 0001 child entered
// 0x025062 BEEF child is ABOUT to make the indirect call (crash localizer)
// 0x025064 0001 gFp(41)==42 (indirect call returned correctly)
// 0x025066 E0DD child reached end (no crash/wild-jump)
// 0x025068 5050 parent reaped the child
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
#define M(a) (*(volatile uint16_t *)(a))
#define BAD 0xDEAD
static uint16_t addOne(uint16_t x) {
return x + 1;
}
static uint16_t (*volatile gFp)(uint16_t) = addOne;
static void child(void) {
uint16_t r;
M(0x025060UL) = 0x0001;
M(0x025062UL) = 0xBEEF; // reached the indirect-call site
r = gFp(41); // indirect dispatch via __jsl_indir
if (r == 42) {
M(0x025064UL) = 0x0001;
}
M(0x025066UL) = 0xE0DD; // survived the indirect call
_exit(0);
}
int main(void) {
int pid;
int st;
M(0x025060UL) = BAD;
M(0x025062UL) = BAD;
M(0x025064UL) = BAD;
M(0x025066UL) = BAD;
M(0x025068UL) = BAD;
pid = fork((void *)child);
if (pid > 0) {
wait(&st);
M(0x025068UL) = 0x5050;
}
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

57
demos/gnoForkGlobal.c Normal file
View file

@ -0,0 +1,57 @@
// Confirm the &global blocker for forked children: forming a 32-bit &global
// pointer reads DP $BE for the bank byte, and a forked child's fresh DP has $BE
// uninitialized (crt0 sets it only for the main thread). So a child that takes
// the address of a global and dereferences it through a real pointer should read
// from the WRONG bank -> garbage (or crash), unless/until a fork thunk sets $BE.
// Passing &gCanary through a noinline fn forces a genuine 32-bit pointer (not a
// near/absolute access the compiler could fold).
//
// 0x025060 0001 child entered
// 0x025062 xxxx value read back through &gCanary (0x1234 == $BE correct)
// 0x025064 E0DD child reached end (no crash)
// 0x025066 5050 parent reaped the child
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
#define M(a) (*(volatile uint16_t *)(a))
#define BAD 0xDEAD
static volatile uint16_t gCanary = 0x1234;
static uint16_t __attribute__((noinline)) deref16(volatile uint16_t *p) {
return *p; // deref a full 32-bit pointer
}
static void child(void) {
uint16_t v;
M(0x025060UL) = 0x0001;
v = deref16((volatile uint16_t *)&gCanary); // &global -> uses $BE bank byte
M(0x025062UL) = v; // 0x1234 if $BE right; else garbage
M(0x025064UL) = 0xE0DD;
_exit(0);
}
int main(void) {
int pid;
int st;
M(0x025060UL) = BAD;
M(0x025062UL) = BAD;
M(0x025064UL) = BAD;
M(0x025066UL) = BAD;
pid = fork((void *)child);
if (pid > 0) {
wait(&st);
M(0x025066UL) = 0x5050;
}
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

70
demos/gnoForkRace.c Normal file
View file

@ -0,0 +1,70 @@
// Stress the fork stash race (fix for the rapid-double-fork window). The parent
// forks FOUR children back-to-back, each a DIFFERENT function that writes its own
// signature to its own slot. Without the fork mutex + consumption handshake, a
// second fork() would overwrite __gnoPendingChildFn before the first child read
// it, so a child would run the WRONG entry -- a slot would be missing or double-
// written. With the handshake, fork() blocks until each child has consumed the
// stash, so every child runs its intended function.
//
// 0x025090 AAAA childA ran (else DEAD -> A got the wrong entry)
// 0x025092 BBBB childB ran
// 0x025094 CCCC childC ran
// 0x025096 DDDD childD ran
// 0x025098 E0DD parent reaped all four
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
#define M(a) (*(volatile uint16_t *)(a))
static void childA(void) {
M(0x025090UL) = 0xAAAA;
_exit(0);
}
static void childB(void) {
M(0x025092UL) = 0xBBBB;
_exit(0);
}
static void childC(void) {
M(0x025094UL) = 0xCCCC;
_exit(0);
}
static void childD(void) {
M(0x025096UL) = 0xDDDD;
_exit(0);
}
int main(void) {
int st;
M(0x025090UL) = 0xDEAD;
M(0x025092UL) = 0xDEAD;
M(0x025094UL) = 0xDEAD;
M(0x025096UL) = 0xDEAD;
M(0x025098UL) = 0xDEAD;
fork((void *)childA); // back-to-back: stresses the stash window
fork((void *)childB);
fork((void *)childC);
fork((void *)childD);
wait(&st);
wait(&st);
wait(&st);
wait(&st);
M(0x025098UL) = 0xE0DD;
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

63
demos/gnoForkWork.c Normal file
View file

@ -0,0 +1,63 @@
// Map a forked child's REAL capabilities on a fresh DP (concurrency step 2).
// The leaf-only contract forbids "i32/i64 libcalls" -- but libcalls use the
// child's OWN $E0-$FF DP scratch (write-before-read), which a fresh page
// provides, so a child that CALLS A FUNCTION doing an i32 multiply (a real
// __mulsi3 libcall) may already work without any DP-scratch setup. This probe
// tests exactly that: a non-leaf child that JSLs a helper which does a genuine
// 32-bit multiply. It deliberately does NOT touch &global (needs $BE) or a
// function pointer (needs $B8 / the __jsl_indir operand) -- those are tested
// separately, since they can wild-jump. Reading a global's VALUE (gA/gB) is
// plain absolute/DBR addressing, not a 32-bit &global pointer.
//
// 0x025060 0001 child entered
// 0x025062 0001 mul32(gA,gB) == 300000 (function call + i32 libcall worked)
// 0x025064 E0DD child reached end (no crash)
// 0x025066 5050 parent reaped the child
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
#define M(a) (*(volatile uint16_t *)(a))
#define BAD 0xDEAD
static volatile uint32_t gA = 100000;
static volatile uint32_t gB = 3;
static uint32_t __attribute__((noinline)) mul32(uint32_t x, uint32_t y) {
return x * y; // real __mulsi3 libcall (uses $E0-$FF)
}
static void child(void) {
uint32_t r;
M(0x025060UL) = 0x0001; // child entered on its fresh DP
r = mul32(gA, gB); // fn-call + i32 libcall
if (r == 300000UL) {
M(0x025062UL) = 0x0001;
}
M(0x025064UL) = 0xE0DD; // reached end -> no crash
_exit(0);
}
int main(void) {
int pid;
int st;
M(0x025060UL) = BAD;
M(0x025062UL) = BAD;
M(0x025064UL) = BAD;
M(0x025066UL) = BAD;
pid = fork((void *)child);
if (pid > 0) {
wait(&st);
M(0x025066UL) = 0x5050;
}
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

79
demos/gnoHeapConcur.c Normal file
View file

@ -0,0 +1,79 @@
// Stress concurrent malloc/free (fix: the __heapLock semaphore mutex). The
// parent and a forked child both hammer the SHARED heap -- each repeatedly
// malloc()s a block, fills it with a thread-unique pattern, holds it briefly (to
// invite preemption), verifies the pattern survived, and free()s it. If the
// free-list critical section were not serialized, the two threads' interleaved
// malloc/free could hand the same block to both (or corrupt the list), so a
// pattern check would fail or the walk would crash. Both loops completing with
// intact patterns shows the heap is concurrency-safe.
//
// 0x025082 AAAA parent's alloc loop finished with every pattern intact
// 0x025084 CCCC child's alloc loop finished with every pattern intact
// 0x025086 E0DD parent reaped the child
#include <stdint.h>
extern int fork(void *subr);
extern int wait(int *status);
extern __attribute__((noreturn)) void _exit(int status);
extern void *malloc(unsigned long n);
extern void free(void *p);
#define M(a) (*(volatile uint16_t *)(a))
#define ITERS 120
static uint16_t allocLoop(uint16_t tag) {
volatile uint16_t *p;
uint16_t ok;
int i;
int j;
ok = 1;
for (i = 0; i < ITERS; i++) {
p = (volatile uint16_t *)malloc(32);
if (p == 0) {
ok = 0;
break;
}
for (j = 0; j < 16; j++) {
p[j] = (uint16_t)(tag + j);
}
for (volatile int s = 0; s < 40; s++) { // hold the block; invite a task swap
}
for (j = 0; j < 16; j++) {
if (p[j] != (uint16_t)(tag + j)) {
ok = 0;
}
}
free((void *)p);
}
return ok;
}
static void child(void) {
uint16_t ok;
ok = allocLoop(0xC000);
M(0x025084UL) = ok ? 0xCCCC : 0xDEAD;
_exit(0);
}
int main(void) {
uint16_t ok;
int st;
M(0x025082UL) = 0xDEAD;
M(0x025084UL) = 0xDEAD;
M(0x025086UL) = 0xDEAD;
fork((void *)child);
ok = allocLoop(0xA000); // parent hammers the heap concurrently
M(0x025082UL) = ok ? 0xAAAA : 0xDEAD;
wait(&st);
M(0x025086UL) = 0xE0DD;
for (volatile unsigned long j = 0; j < 300000UL; j++) {
}
return 0;
}

Binary file not shown.

View file

@ -22,6 +22,9 @@ extern unsigned long Kalarm10(void * a0, void * a1); // 0x4203
extern int Ksigpause(void * a0, void * a1); // 0x2103
extern unsigned long Ksigsetmask(void * a0, void * a1); // 0x1B03
extern unsigned long Ksigblock(void * a0, void * a1); // 0x1C03
extern int Kscreate(int a0, void * a1); // 0x0F03
extern int Kswait(int a0, void * a1); // 0x0D03
extern int Kssignal(int a0, void * a1); // 0x0E03
extern int Kdup(int a0, void * a1); // 0x2203
extern int Kdup2(int a0, int a1, void * a2); // 0x2303
extern int Kpipe(void * a0, void * a1); // 0x2403

View file

@ -60,15 +60,10 @@ __start:
phk
plb
; Point the libgcc __jsl_indir trampoline's JMP-(abs) operand at
; (DP + $B8) so indirect dispatch reads its target from our private DP
; page instead of bank-0 $00B8. Identical to crt0Gsos.s and REQUIRED
; now that DP is non-zero (the trampoline operand defaults to $00B8).
rep #0x30
tdc
clc
adc #0xb8
sta __jsl_indir_op
; No __jsl_indir operand patch: the trampoline reads its indirect target
; DP-relative ($B8/$BA), so it works on any thread's per-process DP with no
; self-modified global operand (which used to alias across concurrent
; threads -- see the reentrant __jsl_indir in libgcc.s).
; Persistent "current data bank" byte at DP $BE = PBR (our bank).
; See crt0.s comment (codegen reads DP $BE for the bank half of

View file

@ -47,17 +47,11 @@ __start:
; with C++ ctors crashed in the init_array indirect-dispatch loop
; for exactly that reason.
;
; The fix: keep DP at the Loader's allocation (no `tcd #0` here),
; AND patch `__jsl_indir_op` (the operand of the JMP (abs) inside
; libgcc's __jsl_indir trampoline) to (DP + $B8) so the trampoline
; reads the indirect target from the right bank-0 slot. See
; libgcc.s commentary on the SMC anchor. Every codegen DP slot
; now lands in Loader-reserved memory that no system code touches.
rep #0x30
tdc
clc
adc #0xb8
sta __jsl_indir_op
; The fix: keep DP at the Loader's allocation (no `tcd #0` here).
; libgcc's __jsl_indir trampoline reads its indirect target DP-relative
; ($B8/$BA), so NO operand patch is needed (it used to self-modify a
; global operand to DP+$B8, which was not concurrency-safe). Every
; codegen DP slot lands in Loader-reserved memory no system code touches.
; Persistent "current data bank" byte at DP $BE. Set to PBR
; (= our load bank) so the LDAptr/STAptr/STBptr inserters'

View file

@ -154,6 +154,30 @@ __gnoCaptureDP:
rtl
; __gnoForkThunk -- fork() child entry shim. fork() passes THIS as the spawn
; target, not the user's function directly. GNO's createProc starts the child
; here with DBR=PBR=program bank and a FRESH direct page whose scratch bytes are
; uninitialized. We seed the one per-thread scratch byte codegen depends on --
; $BE, the bank half of &global 32-bit pointers (crt0 sets it for the main thread;
; a fresh child page holds garbage) -- then hand off to __gnoChildStart (libcGno.c),
; which reads the stashed child entry, signals the parent's fork() handshake, and
; runs the child. ($B8/$BA indirect target + $E0-$FF libcall scratch are written-
; before-read per use, so a fresh page needs no init; the reentrant __jsl_indir
; reads THIS child's own DP.) __gnoChildStart _exit()s the child; the tail _exit
; is belt-and-suspenders in case it ever returns.
.globl __gnoForkThunk
__gnoForkThunk:
sep #0x20
phk
pla ; A = PBR (program bank)
sta 0xbe ; DP $BE = program bank (per-thread &global bank byte)
stz 0xbf ; pad so 16-bit `lda $BE` reads $00:PBR
rep #0x30
jsl __gnoChildStart ; C: read stash, signal parent handshake, run child
lda #0
jsl _exit
.data
.globl __gnoProcDP
__gnoProcDP:

View file

@ -321,6 +321,72 @@ Ksigblock:
plx ; result hi -> X
rtl
; Kscreate(i16, i32) -> i16
; toolset 3, func 0x0F03
.section .text.Kscreate,"ax"
.globl Kscreate
Kscreate:
; --- stash arg0 (in A) ---
sta 0xE0
; --- result space (2 bytes) ---
pea 0
; --- arg0 ---
lda 0xE0
pha
; --- arg1 (4B) ---
lda 10, s
pha
lda 10, s
pha
ldx #0x0F03
jsl 0xe10008
pla ; result lo -> A
rtl
; Kswait(i16, i32) -> i16
; toolset 3, func 0x0D03
.section .text.Kswait,"ax"
.globl Kswait
Kswait:
; --- stash arg0 (in A) ---
sta 0xE0
; --- result space (2 bytes) ---
pea 0
; --- arg0 ---
lda 0xE0
pha
; --- arg1 (4B) ---
lda 10, s
pha
lda 10, s
pha
ldx #0x0D03
jsl 0xe10008
pla ; result lo -> A
rtl
; Kssignal(i16, i32) -> i16
; toolset 3, func 0x0E03
.section .text.Kssignal,"ax"
.globl Kssignal
Kssignal:
; --- stash arg0 (in A) ---
sta 0xE0
; --- result space (2 bytes) ---
pea 0
; --- arg0 ---
lda 0xE0
pha
; --- arg1 (4B) ---
lda 10, s
pha
lda 10, s
pha
ldx #0x0E03
jsl 0xe10008
pla ; result lo -> A
rtl
; Kdup(i16, i32) -> i16
; toolset 3, func 0x2203
.section .text.Kdup,"ax"

View file

@ -623,6 +623,12 @@ void __heapInitMM(unsigned short userID) {
heapSetWindow(base, base + want);
}
// Heap serialization hooks -- weak no-ops (libgcc.s) for single-threaded
// runtimes; libcGno.c overrides them with a GNO binary-semaphore mutex so
// malloc/free are safe across preemptive threads.
extern void __heapLock(void);
extern void __heapUnlock(void);
void *malloc(size_t n0) {
mallocInitOnce();
// Heap ceiling is ~32KB so anything > 0x7FF0 is unsatisfiable.
@ -639,7 +645,10 @@ void *malloc(size_t n0) {
n = (u16)((n + 1) & ~(u16)1);
if (n < (u16)(FREE_NODE_SZ - HDR_SZ))
n = (u16)(FREE_NODE_SZ - HDR_SZ);
// First-fit on free list.
// First-fit on free list. __heapLock/__heapUnlock serialize this free-list
// critical section against concurrent threads (a GNO semaphore mutex under
// libcGno.c; a no-op single-threaded).
__heapLock();
FreeBlk **link = &freeList;
FreeBlk *cur = freeList;
while (cur) {
@ -655,6 +664,7 @@ void *malloc(size_t n0) {
} else {
*link = cur->next;
}
__heapUnlock();
return (char *)cur + HDR_SZ;
}
link = &cur->next;
@ -677,14 +687,19 @@ void *malloc(size_t n0) {
// and every p share a bank and heapEnd >= p -- so it is a small
// non-negative remaining-byte count. Covered by smoke check #176.
char *p = bumpPtr;
if ((size_t)(HDR_SZ + n) > (size_t)(heapEnd - p)) return (void *)0;
if ((size_t)(HDR_SZ + n) > (size_t)(heapEnd - p)) {
__heapUnlock();
return (void *)0;
}
*(u16 *)p = (u16)n;
bumpPtr = p + HDR_SZ + n;
__heapUnlock();
return p + HDR_SZ;
}
void free(void *p) {
if (!p) return;
__heapLock();
FreeBlk *blk = (FreeBlk *)((char *)p - HDR_SZ);
blk->next = freeList;
freeList = blk;
@ -731,6 +746,7 @@ void free(void *p) {
a = a->next;
}
}
__heapUnlock();
}
void *calloc(size_t nmemb, size_t size) {

View file

@ -306,28 +306,118 @@ static int gnoToPosixErrno(int e) {
// fork: GNO thread-spawn -- NOT Unix copy-fork. `subr` is the 24-bit address of
// a function that runs in the CHILD on a fresh stack + direct page; the parent
// gets the child pid, the child never returns to the call site. GNO's createProc
// (kern/gno/sys.c) starts the child at subr with DBR=PBR=subr's bank (= our
// program bank, since &func is loader-relocated) but a FRESH direct page -- so the
// crt0-initialized DP scratch ($B8 __jsl_indir vector, $BE &global bank byte,
// $E0-$FF libcall scratch) is ABSENT. `subr` must therefore point at a LEAF that
// uses only absolute-long addressing and ends in _exit()/exec*(): no function
// pointers, no i32/i64 libcalls, no &global 32-bit pointers, and it must not
// return. (A future __gnoForkThunk could relax this, but the single shared
// __jsl_indir_op operand caps how far an intra-image child can go regardless.)
// a function that runs in the CHILD as a concurrent thread on a fresh stack +
// direct page; the parent gets the child pid. GNO's createProc (kern/gno/sys.c)
// starts the child with DBR=PBR=program bank but a FRESH direct page whose codegen
// scratch is uninitialized. We spawn __gnoForkThunk (gnoGsos.s) instead of subr
// directly: it seeds the child's per-thread $BE (the &global bank byte crt0 sets
// for the main thread) and then dispatches to subr. Together with the reentrant
// __jsl_indir (per-thread indirect-call dispatch, no shared self-modified
// operand), a forked child is now a FULL-CAPABILITY thread -- function calls,
// i32/i64 libcalls, &global pointers, function pointers, and syscalls all work,
// concurrently with the parent. ($E0-$FF libcall scratch and $B8/$BA per-call
// indirect target are written-before-read, so a fresh page needs no init for
// them.) The child may return (the thunk _exit(0)s it) or _exit/exec itself.
//
// `subr` is typed void* (matching GNO's own `int fork(void *subr)` and Kfork's
// arg type), NOT a function-pointer type: the callee-side lowering of a
// function-pointer PARAMETER currently drops the value (it saves only the low
// word and never reloads it, so Kfork sees subr==0), whereas the ptr32 data
// path carries the full 24-bit address. Callers pass (void *)&fn.
// Not yet concurrency-safe: the malloc heap is shared with the parent and its
// allocator is not serialized, so a child doing heavy allocation must not race
// the parent's; and __gnoPendingChildFn is a single global, so forking a second
// child before the first is scheduled would race (GNO programs fork one at a time).
//
// `subr` is typed void* (matching GNO's own `int fork(void *subr)`), NOT a
// function-pointer type: the callee-side lowering of a function-pointer PARAMETER
// currently drops the value (saves only the low word, never reloads it), whereas
// the ptr32 data path carries the full 24-bit address. Callers pass (void *)&fn.
void *__gnoPendingChildFn; // subr for the current child; read by __gnoChildStart
extern void __gnoForkThunk(void);
// Fork serialization + stash handoff. __forkMutex serializes concurrent fork()
// calls; __forkDone lets the child signal that it has read __gnoPendingChildFn, so
// the parent can release the mutex and let the next fork proceed. Without it a
// rapid second fork could overwrite the stash before the first child read it.
// Both are GNO binary semaphores, lazily created on the first fork (the program
// is single-threaded until the first child spawns).
// Concurrency state, all lazily set up by the FIRST fork() (which runs while the
// program is still single-threaded). __gnoThreaded gates both the fork
// serialization AND the heap lock: before any fork there is only one thread, so
// malloc/free need no locking and pay nothing -- no semaphore syscall.
static int __forkMutex = 0; // serialize concurrent fork() calls
static int __forkDone = 0; // child -> parent "stash consumed" handshake
static int __heapSem = 0; // malloc/free free-list mutex (see __heapLock)
static int __gnoThreaded = 0; // 0 until the first fork(); gates all of the above
int fork(void *subr) {
int err = 0;
int r = Kfork(subr, &err);
if (r < 0) errno = gnoToPosixErrno(err);
int r;
if (__gnoThreaded == 0) {
// First fork(): lazily create the concurrency semaphores while the
// program is still single-threaded. All three gate concurrency
// safety (fork serialization + heap locking), so commit to threaded
// mode ONLY if every create succeeds -- latching __gnoThreaded on a
// failed create would silently disable every guard. On failure (e.g.
// the system semaphore table is exhausted) K* returns -1 with err set;
// fail the fork with errno and stay single-threaded so malloc/free
// keep their safe single-threaded early-out.
__forkMutex = Kscreate(1, &err); // serialize forks
__forkDone = Kscreate(0, &err); // fork handshake
__heapSem = Kscreate(1, &err); // heap mutex
if (__forkMutex < 0 || __forkDone < 0 || __heapSem < 0) {
errno = gnoToPosixErrno(err);
return -1;
}
__gnoThreaded = 1; // enable locking from here on
}
Kswait(__forkMutex, &err); // serialize against other fork()s
__gnoPendingChildFn = subr;
r = Kfork((void *)__gnoForkThunk, &err);
if (r < 0) {
Kssignal(__forkMutex, &err);
errno = gnoToPosixErrno(err);
return r;
}
Kswait(__forkDone, &err); // block until the child consumes the stash
Kssignal(__forkMutex, &err); // release; the next fork may proceed
return r;
}
// __gnoChildStart: runs on the CHILD thread, called by __gnoForkThunk (gnoGsos.s)
// after it seeds the child's per-thread $BE. Reads the stashed child entry,
// signals the parent's fork() that the stash is consumed (so the next fork may
// proceed), then runs the child. The indirect call goes through the reentrant
// __jsl_indir on this child's OWN direct page, so the child is a full-capability
// concurrent thread. If the child returns, _exit(0).
void __gnoChildStart(void) {
void (*fn)(void);
int err = 0;
fn = (void (*)(void))__gnoPendingChildFn;
Kssignal(__forkDone, &err); // stash consumed -> unblock parent fork()
fn(); // run the real child concurrently
_exit(0);
}
// __heapLock / __heapUnlock: GNO override of the weak no-ops in libgcc.s. The
// binary-semaphore mutex __heapSem (created by the first fork) serializes
// malloc/free's free-list critical section across preemptive threads. Until the
// first fork the program is single-threaded, so both are a cheap early-out -- a
// single-threaded GNO program pays no semaphore syscall per malloc/free.
void __heapLock(void) {
int err = 0;
if (__gnoThreaded == 0) {
return;
}
Kswait(__heapSem, &err);
}
void __heapUnlock(void) {
int err = 0;
if (__gnoThreaded == 0) {
return;
}
Kssignal(__heapSem, &err);
}
// wait: block until a child terminates; *status receives the 16-bit union-wait

View file

@ -22,56 +22,44 @@
.text
; --------------------------------------------------------------------
; Indirect-call trampoline. An indirect call (function pointer) stores
; the target's 16-bit address to __indirTarget before JSL'ing here.
; This routine does a JMP indirect through that variable: control
; transfers to the target with the original caller's JSL frame still
; on the stack, so target's RTL returns to the original caller (one
; frame, no double-RTL).
; Indirect-call trampoline (REENTRANT). An indirect call (function
; pointer) stores its 24-bit target -- 16-bit offset to __indirTarget
; ($B8/$B9) and bank byte to $BA -- then JSLs here. __jsl_indir reads
; that target DP-RELATIVE and RTL-jumps to it with the original caller's
; JSL frame still on the stack, so the target's own RTL returns straight
; back to the caller (one frame, no double-RTL). The instruction-level
; idiom is documented at the __jsl_indir label below.
;
; Caller emit sequence in W65816ISelLowering::LowerCall:
; sta __indirTarget ; store ptr (must precede any A clobber for args)
; ... arg pushes ...
; sta __indirTarget ; store ptr offset (must precede any A clobber for args)
; ...store target bank to $BA, arg pushes...
; jsl __jsl_indir
;
; __indirTarget is a DP-relative slot at offset $B8. Callers store the
; target's 16-bit address with `sta $B8` (DP-form, writes to (DP+$B8)
; in bank 0). The trampoline below is a JMP (abs) whose operand has
; been patched by the crt0 variant at startup to point at the runtime
; DP+$B8 anchor; the JSL → JMP → target flow then takes a single CPU
; instruction past the JSL, so A and X (which hold arg0 for the 65816
; C ABI) survive the indirection unmodified.
; No self-modified operand: the target is read from the caller's OWN
; direct page ($B8/$B9 offset + $BA bank), and DP is per-thread -- GNO
; context-switches it every task swap, and both the GS/OS Loader and GNO
; keep DP at a non-zero, process-private page (never bank-0 zero page).
; So each thread dispatches through ITS OWN target, and a fork()ed child's
; function pointer no longer wild-jumps through the parent thread's page.
; (An earlier form JMP'd through a single global operand that crt0 self-
; modified to the main thread's DP+$B8; it aliased under preemptive multi-
; tasking and is gone -- there is no __jsl_indir_op now, and the crt0
; variants no longer patch anything.)
;
; Why the SMC dispatch instead of a fixed `jmp ($00B8)`:
; Under bare-metal (crt0.s) the runtime leaves DP=0, so the patched
; operand stays $00B8 and the dispatch is byte-identical to the legacy
; fixed form. Under the GS/OS Loader (crt0Gsos.s) AND GNO/ME
; (crt0Gno.s) we keep DP at a non-zero value -- the Loader-allocated
; page under GS/OS, GNO's per-process page under GNO -- and patch the
; operand to (DP + $B8). That keeps every codegen scratch slot (the
; indirect-call anchor at $B8, the PBR stash at $BE, the libcall
; scratch at $E0..$FF) in reserved / process-private bank-0 memory
; instead of on top of system zero page that ROM IRQ handlers and
; GS/OS dispatcher state freely scribble (and, under GNO's preemptive
; multitasking, that a concurrent second copy of ours would alias).
; Forcing DP=0 was making clang-built S16 apps with C++ ctors crash
; during the init_array indirect-dispatch loop for exactly that reason.
; Keeping DP non-zero also parks every codegen scratch slot (indirect-call
; anchor $B8, PBR stash $BE, libcall scratch $E0..$FF) in reserved /
; process-private bank-0 memory instead of on system zero page that ROM
; IRQ handlers and GS/OS dispatcher state freely scribble. Forcing DP=0
; used to crash clang-built S16 apps with C++ ctors in the init_array
; indirect-dispatch loop for exactly that reason. DP is always in bank 0
; on the 65816, so the $B8/$BA reads resolve to bank 0 regardless of DP --
; bare-metal (crt0.s) keeps DP=0 and the same reads land at $00B8/$00BA,
; needing no patch.
;
; Constraint preserved: the long-indirect anchor must be in bank 0 (the
; 65816 JML [abs] opcode 0xDC reads its 24-bit PC vector from bank-0
; unconditionally). DP is always in bank 0 on the 65816, so
; (DP+$B8) is automatically a bank-0 address. The fetched 24-bit
; pointer (offset $B8-$B9 + bank $BA, both stored by clang at the call
; site) is honored BANK-EXPLICIT, so a cross-bank function pointer --
; e.g. &func of a function the linker placed in a different segment/
; bank, redirected through a seg1 JML trampoline -- lands in the
; correct bank. When bank==PBR (single-segment) this is byte-for-byte
; equivalent to the old 0x6C/16-bit dispatch.
;
; SMC patch site: crt0 variants compute (DP + $B8) at startup with
; tdc ; clc ; adc #0xb8 ; sta __jsl_indir_op
; The default operand below is $00B8 so a program that links without
; patching (e.g. an asm-only test) still works under DP=0.
; The fetched pointer is honored BANK-EXPLICIT via the $BA bank byte, so a
; cross-bank function pointer -- e.g. &func the linker placed in a
; different segment/bank and redirected through a seg1 JML trampoline --
; lands in the correct bank.
; --------------------------------------------------------------------
.globl __indirTarget
__indirTarget = 0xb8
@ -79,19 +67,26 @@ __indirTarget = 0xb8
.text
.globl __jsl_indir
__jsl_indir:
.byte 0xdc ; JML [abs] opcode -- 24-bit LONG indirect. Reads a
; 24-bit pointer (offset $B8-$B9 + bank $BA) from
; bank-0:operand and jumps BANK-EXPLICIT, so a
; cross-bank function pointer lands in the right bank.
; (Was 0x6C JMP(abs), 16-bit/PBR-local; clang already
; stores the pointer's bank byte to $BA, so this just
; honors it. Identical to the old form when bank==PBR.)
.globl __jsl_indir_op
__jsl_indir_op:
.byte 0xb8, 0x00 ; operand: bank-0 address of the target slot.
; Patched at startup by crt0 to (DP + $B8). The
; default $00B8 is correct only under bare-metal
; DP=0; GS/OS and GNO patch it to their non-zero DP.
; REENTRANT dispatch: read the 24-bit target DP-RELATIVE (offset $B8-$B9,
; bank $BA -- both stored by clang at the call site) and RTL to it, with NO
; self-modified global operand. DP is per-thread (context-switched by GNO
; every task swap; process-private under GS/OS), so each caller reads ITS
; OWN target -- a fork()ed child dispatching a function pointer no longer
; wild-jumps through the parent thread's page. Preserves A/X/Y (C-ABI arg
; regs) and the caller's JSL frame (the target's RTL returns to the JSL
; site). $BC is a per-thread DP temp to free A; assumes the 16-bit native
; call state (M=0/X=0) every clang indirect call + the ctor loop use. RTL
; adds 1 to the pulled 16-bit PC, hence offset-1.
sta 0xbc ; save arg0 (A) to a per-thread DP temp
sep #0x20 ; 8-bit A for the 1-byte bank push
lda 0xba ; target bank
pha ; push PBR
rep #0x20 ; back to 16-bit A
lda 0xb8 ; target offset (16-bit)
dec a ; offset-1 (RTL adds 1 to the pulled PC)
pha ; push PCH:PCL
lda 0xbc ; restore arg0 (A)
rtl ; -> bank:offset; caller's RTL frame remains below
; --------------------------------------------------------------------
; __run_cxa_atexit — weak no-op fallback.
@ -105,6 +100,21 @@ __jsl_indir_op:
__run_cxa_atexit:
rtl
; --------------------------------------------------------------------
; __heapLock / __heapUnlock -- weak no-op heap-serialization hooks.
;
; malloc()/free() (libc.c) bracket their free-list critical sections with these.
; Single-threaded runtimes (bare-metal crt0.s, GS/OS crt0Gsos.s) keep the no-op:
; there are no concurrent threads to serialize. libcGno.c overrides both with a
; GNO binary-semaphore mutex so malloc/free are safe across preemptive threads.
; --------------------------------------------------------------------
.weak __heapLock
__heapLock:
rtl
.weak __heapUnlock
__heapUnlock:
rtl
; --------------------------------------------------------------------
; __srandInitFromTime — weak no-op fallback.
;

View file

@ -48,6 +48,11 @@ SYSCALLS = [
(0x1B03, "Ksigsetmask", 4, [4, 4]), # (mask, *errno) -> longword
(0x1C03, "Ksigblock", 4, [4, 4]), # (mask, *errno) -> longword
# ---- semaphores (binary-mutex + handshake for thread-safe malloc/fork) ----
(0x0F03, "Kscreate", 2, [2, 4]), # (count, *errno) -> sem id
(0x0D03, "Kswait", 2, [2, 4]), # (sem, *errno) -> int (P / lock)
(0x0E03, "Kssignal", 2, [2, 4]), # (sem, *errno) -> int (V / unlock)
# ---- File descriptors ----
(0x2203, "Kdup", 2, [2, 4]), # (oldfd, *errno) -> int
(0x2303, "Kdup2", 2, [2, 2, 4]), # (oldfd, newfd, *errno) -> int

View file

@ -6765,15 +6765,16 @@ EOF
"${binPhc2%.bin}".seg*.bin "${relPhc2}".seg*.reloc
fi
# crt0Gsos indirect-dispatch regression: ensure the __jsl_indir SMC patch
# (`tdc; clc; adc #$B8; sta __jsl_indir_op` in crt0Gsos.s) lets a clang-
# built S16 app survive an init_array-style indirect call under the
# real GS/OS Loader. Before the fix, crt0Gsos forced DP=0 and the
# indirect dispatcher hard-coded $00B8 — but bank-0 zero page is
# scribbled by ROM IRQ handlers + GS/OS dispatcher state, so the first
# indirect call (C++ static ctor, etc.) raced and crashed. The probe
# below uses a function-pointer table to force the codegen indirect
# path through __jsl_indir. Marker $70 = 0x0B = twice(7) + neg(3).
# crt0Gsos indirect-dispatch regression: ensure a clang-built S16 app
# survives an init_array-style indirect call under the real GS/OS Loader.
# crt0Gsos keeps DP at the Loader's non-zero page and __jsl_indir reads
# its 24-bit target DP-RELATIVE ($B8/$BA), so the indirect anchor stays in
# the Loader-reserved page. An earlier form forced DP=0, putting the
# anchor at $00B8 on bank-0 zero page -- scribbled by ROM IRQ handlers +
# GS/OS dispatcher state -- so the first indirect call (C++ static ctor,
# etc.) raced and crashed. The probe below uses a function-pointer table
# to force the codegen indirect path through __jsl_indir. Marker
# $70 = 0x0B = twice(7) + neg(3).
if [ "${SMOKE_SKIP_GSOSINDIR:-0}" != "1" ] \
&& [ -x "$CLANG" ] && [ -x "$CADIUS" ] && [ -f "$SYSDISK" ] \
&& command -v mame >/dev/null 2>&1; then
@ -7038,6 +7039,52 @@ else
}
log "OK: gnoForkWaitProbe fork/wait + _exit status (WEXITSTATUS=42) green under GNO"
# Phase 5.3d.2 CONCURRENCY: a forked child is a full-capability concurrent
# thread, not a leaf. Three guards:
# - gnoConcurFork: parent+child ping-pong a shared turn word ROUNDS times
# each (0x025050=0x14) -- only completes if GNO preemptively time-slices
# them; sequential execution would deadlock. Proves preemptive multitasking.
# - gnoForkGlobal: a child dereferences a &global 32-bit pointer (reads
# 0x1234); needs __gnoForkThunk to seed the child's per-thread $BE bank
# byte -- a fresh fork DP has garbage there.
# - gnoConcurFnptr: parent AND child dispatch function pointers WHILE
# interleaved (0x025072=5050 + 0x025074=CCCC). Guards the reentrant
# __jsl_indir: the old shared self-modified operand made a child's indirect
# call wild-jump through the parent's direct page.
# - gnoForkRace: parent forks FOUR distinct children back-to-back; each runs
# its intended function only if the fork mutex + consumption handshake
# close the __gnoPendingChildFn stash race.
# - gnoHeapConcur: parent + child both hammer malloc/free on the shared heap
# 120x each with pattern checks -- passes only if __heapLock (GNO semaphore
# mutex) serializes the free-list critical section.
for concSpec in \
"gnoConcurFork|preemptive ping-pong|0x025050=0014 0x025052=5050 0x025054=CCCC" \
"gnoForkGlobal|child &global via thunk \$BE|0x025062=1234 0x025064=E0DD 0x025066=5050" \
"gnoConcurFnptr|concurrent fnptr dispatch|0x025070=0014 0x025072=5050 0x025074=CCCC" \
"gnoForkRace|rapid multi-fork stash handoff|0x025090=AAAA 0x025092=BBBB 0x025094=CCCC 0x025096=DDDD 0x025098=E0DD" \
"gnoHeapConcur|concurrent malloc/free heap mutex|0x025082=AAAA 0x025084=CCCC 0x025086=E0DD" \
; do
concProbe="${concSpec%%|*}"
concRest="${concSpec#*|}"
concDesc="${concRest%%|*}"
concChecks="${concRest##*|}"
concArgs=""
for concChk in $concChecks; do
concArgs="$concArgs --check $concChk"
done
log "check: $concProbe ($concDesc) under GNO"
bash "$PROJECT_ROOT/demos/buildGno.sh" "$concProbe" >/tmp/gnoConcBuildOut 2>&1 || {
cat /tmp/gnoConcBuildOut >&2
die "buildGno.sh $concProbe failed"
}
bash "$PROJECT_ROOT/scripts/runInGno.sh" "$PROJECT_ROOT/demos/$concProbe.omf" $concArgs \
>/tmp/gnoConcRunOut 2>&1 || {
cat /tmp/gnoConcRunOut >&2
die "$concProbe failed: GNO concurrency regression ($concDesc)"
}
log "OK: $concProbe ($concDesc) green under GNO"
done
# Phase 5.3e..5.3k POSIX syscall surface (punch-list items 4-12): the GNO
# kernel toolset-$03 wrappers (waitpid/stat/isatty/uid/gid/job-control/env/
# fd layer/exec/signals) now wired into libc via libcGno.c. Each probe