aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-24 23:08:35 +0200
committeruser <user@clank>2026-09-24 23:08:35 +0200
commit1430515ad419f2fddeb0265337a0da043901411b (patch)
treee94c53cb7d59ca4826242b81afeb3de3c655d02e
parentusr: touch, tr, rev, cut, uniq (diff)
downloadgbos-1430515ad419f2fddeb0265337a0da043901411b.tar.gz
gbos-1430515ad419f2fddeb0265337a0da043901411b.tar.xz
gbos-1430515ad419f2fddeb0265337a0da043901411b.zip
fs: hard links - ln(1), and rm that removes a NAME
gbfs has carried I_NLINK since the beginning and nothing ever incremented it, so a file could only have one name. SYS_LINK adds a second directory entry pointing at the same inode: the data is stored once, and both names read it. That makes rm's job different. It now decrements the link count and only frees the inode and its blocks when the LAST name goes - removing one of two links used to free blocks the other still pointed at. Refused, with the reasons that matter here: linking a directory (it would make a cycle the tree walkers cannot survive), an existing name, and anything under /bin, which is ROM. The syscall takes a request block because the trap needs HL for its dispatch table - and ln copies both paths to the stack first, since that block is a static living at $A000, on top of the command line it is reading.
-rw-r--r--docs/internals.md9
-rw-r--r--include/gbos.inc14
-rw-r--r--src/fs.asm108
-rw-r--r--src/kdata.asm3
-rw-r--r--src/syscall.asm120
-rw-r--r--usr/gbos.h3
-rw-r--r--usr/libc.s20
-rw-r--r--usr/ln.c43
8 files changed, 317 insertions, 3 deletions
diff --git a/docs/internals.md b/docs/internals.md
index f630025..c9f721a 100644
--- a/docs/internals.md
+++ b/docs/internals.md
@@ -234,6 +234,10 @@ blocks 4.. data blocks
the damage once something else touched the neighbouring inode.
- **One-block data cache.** Two files open at once thrash it: `cp` of a 3 KiB
file alternates read/write blocks per byte and takes ~25s. Correct, slow.
+- **Hard links.** `ln` is a second directory entry pointing at one inode;
+ `I_NLINK` existed from the start and nothing ever incremented it. `rm`
+ therefore removes a NAME: it decrements the count and only frees the inode
+ and its blocks when the last link goes.
- **Open modes** are read / write / append. There is no seek syscall, so
`O_APPEND` (open at EOF) is the only way to add to a file - which is what
makes `>>`, and therefore building a multi-line script on the device,
@@ -289,6 +293,11 @@ gbos programs are written in **C**, compiled with **SDCC** (the `sm83` port):
Arguments: the shell leaves `"cmd\0args\0"` at `$A000`; the child inherits it
through `fork`, `getargs()` returns the raw string, `argv_parse()` tokenizes.
+**The wall clock.** An MBC5 cart has no RTC, so `SYS_SETTIME` stores a unix
+stamp plus the tick it was taken at, and `SYS_GETTIME` adds the elapsed ticks
+back (`ntpdate` gets the stamp from SNTP, `date` formats it). It does not
+survive a power cycle - which is what `/rc` is for.
+
**Scripts.** `sh` is the same program interactively and as a script runner: it
asks `isatty()` (SYS_ISATTY: is stdin the console?) and, when stdin is a file,
drops the prompt and the echo. EOF ends that shell - only pid 1 on the console
diff --git a/include/gbos.inc b/include/gbos.inc
index a51dd31..24756e1 100644
--- a/include/gbos.inc
+++ b/include/gbos.inc
@@ -171,7 +171,10 @@ DEF SYS_UPTIME EQU 36 ; copy the 64 Hz tick counter (4B LE) to [DE]
DEF SYS_STAT EQU 37 ; stat a path (DE=&statreq -> A=0/$FF)
DEF SYS_FSSTAT EQU 38 ; filesystem totals (DE=buf[4] -> A=0)
DEF SYS_ISATTY EQU 39 ; is stdin the console? (-> A=1/0)
-DEF SYS_MAX EQU 40
+DEF SYS_LINK EQU 40 ; hard-link a file (DE=&linkreq -> A=0/$FF)
+DEF SYS_SETTIME EQU 41 ; set the wall clock (DE=buf[4], LE epoch)
+DEF SYS_GETTIME EQU 42 ; read the wall clock (DE=buf[4] -> A=1 if set)
+DEF SYS_MAX EQU 43
; ---- SYS_STAT request block (see fs.asm sys_stat) ---------------------------
; The syscall trap needs HL for its dispatch table, so a call that takes both a
@@ -343,5 +346,14 @@ DEF PROG_TR EQU 48
DEF PROG_REV EQU 49
DEF PROG_CUT EQU 50
DEF PROG_UNIQ EQU 51
+DEF PROG_SORT EQU 52
+DEF PROG_CMP EQU 53
+DEF PROG_STRINGS EQU 54
+DEF PROG_DU EQU 55
+DEF PROG_TIME EQU 56
+DEF PROG_CLEAR EQU 57
+DEF PROG_LN EQU 58
+DEF PROG_NTPDATE EQU 59
+DEF PROG_DATE EQU 60
ENDC
diff --git a/src/fs.asm b/src/fs.asm
index 5019015..8aa6eef 100644
--- a/src/fs.asm
+++ b/src/fs.asm
@@ -1275,7 +1275,25 @@ sys_remove::
or a
jr z, .no
ld [wFsInode], a
- call file_truncate ; A = inode (frees data blocks)
+ ; Removing a NAME, not necessarily the file: drop the link count and only
+ ; free the inode and its blocks when the last link goes. Without this,
+ ; `rm` on one of two hard links would free blocks the other still uses.
+ ld a, [wFsInode]
+ call inode_ptr
+ ld a, l
+ add I_NLINK
+ ld l, a
+ ld a, h
+ adc 0
+ ld h, a
+ ld a, [hl]
+ or a
+ jr z, .lastlink ; 0 already (old file): treat as the last one
+ dec a
+ ld [hl], a
+ jr nz, .keep ; other names still point here
+.lastlink
+ call file_truncate_cur ; frees the data blocks
ld a, [wFsInode]
call inode_ptr
xor a
@@ -1286,10 +1304,98 @@ sys_remove::
call free_inode
xor a
ret
+.keep
+ ld a, [wFsInode]
+ call write_inode ; just the new link count
+ xor a
+ ret
.no
ld a, $FF
ret
+; file_truncate_cur - file_truncate for wFsInode (it takes the inode in A).
+file_truncate_cur:
+ ld a, [wFsInode]
+ jp file_truncate
+
+; =============================================================================
+; sys_link(DE = &linkreq) -> A = 0 ok / $FF failed
+; req[0..1] = existing path, req[2..3] = new path
+;
+; A hard link is just a second directory entry pointing at the same inode,
+; which gbfs was already able to express - I_NLINK existed and nothing ever
+; incremented it.
+; =============================================================================
+sys_link::
+ ld a, e
+ ld [wFsStatReq], a ; (shared scratch: the request pointer)
+ ld a, d
+ ld [wFsStatReq+1], a
+ ld h, d
+ ld l, e
+ ld a, [hl+]
+ ld e, a
+ ld a, [hl]
+ ld d, a ; DE = existing path
+ call copy_path
+ call resolve
+ or a
+ jp z, .fail
+ ld [wFsInode], a ; stash it first: the tests below clobber A
+ cp BIN_INO
+ jr z, .fail ; /bin is ROM: not linkable
+ and BIN_FILE
+ jr nz, .fail
+ ld a, [wFsInode]
+ call inode_ptr
+ ld a, [hl]
+ cp IT_FILE
+ jr nz, .fail ; directories: no (it would make a cycle)
+ ; now the new name
+ ld a, [wFsStatReq]
+ add 2
+ ld l, a
+ ld a, [wFsStatReq+1]
+ adc 0
+ ld h, a
+ ld a, [hl+]
+ ld e, a
+ ld a, [hl]
+ ld d, a ; DE = new path
+ push de
+ call copy_path
+ call resolve_parent ; A = parent dir, wFsNameBuf = new name
+ pop de
+ or a
+ jr z, .fail
+ cp BIN_INO
+ jr z, .fail
+ ld [wFsDir], a
+ call dir_find ; must not already exist
+ or a
+ jr nz, .fail
+ call dir_add ; wFsDir + wFsInode + wFsNameBuf
+ or a
+ jr z, .fail
+ ld a, [wFsInode] ; one more name points here
+ call inode_ptr
+ ld a, l
+ add I_NLINK
+ ld l, a
+ ld a, h
+ adc 0
+ ld h, a
+ ld a, [hl]
+ inc a
+ ld [hl], a
+ ld a, [wFsInode]
+ call write_inode
+ xor a
+ ret
+.fail
+ ld a, $FF
+ ret
+
; =============================================================================
; sys_mkdir(DE = path) -> A = 0 ok / $FF
; =============================================================================
diff --git a/src/kdata.asm b/src/kdata.asm
index b6c2b68..9322b4e 100644
--- a/src/kdata.asm
+++ b/src/kdata.asm
@@ -68,6 +68,9 @@ wBootA:: DS 1 ; A at entry: console model from the boot ROM
wBootB:: DS 1 ; B at entry: bit0 set = AGB (GBA in CGB mode)
wBootFsFresh::DS 1 ; fs_init formatted a blank disk this boot
wTicks:: DS 4 ; monotonic 64 Hz system tick (TimerISR), LE
+wEpoch:: DS 4 ; wall clock: unix seconds at wEpochTick (LE), 0 = unset
+wEpochTick:: DS 4 ; the tick wEpoch was taken at (LE)
+wTimeTmp:: DS 4 ; sys_gettime scratch (elapsed ticks -> seconds)
wTickTog:: DS 1 ; TimerISR fires at 128 Hz in double-speed mode;
; this toggle keeps wTicks counting at 64 Hz
wBatch:: DS 1 ; inside a sys_write: defer terminal tile rendering
diff --git a/src/syscall.asm b/src/syscall.asm
index b548328..a4719fc 100644
--- a/src/syscall.asm
+++ b/src/syscall.asm
@@ -74,6 +74,9 @@ SyscallTable:
dw sys_stat ; 37 STAT
dw sys_fsstat ; 38 FSSTAT
dw sys_isatty ; 39 ISATTY
+ dw sys_link ; 40 LINK
+ dw sys_settime ; 41 SETTIME
+ dw sys_gettime ; 42 GETTIME
; -----------------------------------------------------------------------------
sys_nosys:
@@ -335,6 +338,123 @@ sys_sleep:
ret
; -----------------------------------------------------------------------------
+; sys_settime(DE = buf[4]) - unix seconds, little-endian. There is no RTC on an
+; MBC5 cart, so the wall clock is an epoch stamp plus the tick it was taken at
+; (ntpdate(1) fetches the stamp over SNTP); sys_gettime adds the elapsed ticks
+; back. It does not survive a power cycle - run ntpdate from /rc.
+; -----------------------------------------------------------------------------
+sys_settime:
+ ld hl, wEpoch
+ ld b, 4
+.cp
+ ld a, [de]
+ ld [hl+], a
+ inc de
+ dec b
+ jr nz, .cp
+ ld hl, wTicks ; remember when this stamp was true
+ ld de, wEpochTick
+ ld b, 4
+.cpt
+ ld a, [hl+]
+ ld [de], a
+ inc de
+ dec b
+ jr nz, .cpt
+ xor a
+ ret
+
+; -----------------------------------------------------------------------------
+; sys_gettime(DE = buf[4]) -> A = 1 if the clock has been set, 0 if not.
+; buf = wEpoch + (wTicks - wEpochTick) / 64, little-endian.
+; -----------------------------------------------------------------------------
+sys_gettime:
+ push de
+ ; elapsed = wTicks - wEpochTick, byte-wise with borrow (32-bit)
+ ld hl, wTimeTmp
+ ld a, [wTicks+0]
+ ld b, a
+ ld a, [wEpochTick+0]
+ ld c, a
+ ld a, b
+ sub c
+ ld [hl+], a
+ ld a, [wTicks+1]
+ ld b, a
+ ld a, [wEpochTick+1]
+ ld c, a
+ ld a, b
+ sbc c
+ ld [hl+], a
+ ld a, [wTicks+2]
+ ld b, a
+ ld a, [wEpochTick+2]
+ ld c, a
+ ld a, b
+ sbc c
+ ld [hl+], a
+ ld a, [wTicks+3]
+ ld b, a
+ ld a, [wEpochTick+3]
+ ld c, a
+ ld a, b
+ sbc c
+ ld [hl], a
+ ; elapsed >>= 6 (ticks -> seconds), 32-bit, six single-bit shifts
+ ld b, 6
+.shift
+ ld hl, wTimeTmp+3
+ srl [hl]
+ dec hl
+ rr [hl]
+ dec hl
+ rr [hl]
+ dec hl
+ rr [hl]
+ dec b
+ jr nz, .shift
+ ; out = wEpoch + elapsed
+ pop de
+ ld hl, wEpoch
+ ld a, [hl+]
+ ld b, a
+ ld a, [wTimeTmp+0]
+ add b
+ ld [de], a
+ inc de
+ ld a, [hl+]
+ ld b, a
+ ld a, [wTimeTmp+1]
+ adc b
+ ld [de], a
+ inc de
+ ld a, [hl+]
+ ld b, a
+ ld a, [wTimeTmp+2]
+ adc b
+ ld [de], a
+ inc de
+ ld a, [hl]
+ ld b, a
+ ld a, [wTimeTmp+3]
+ adc b
+ ld [de], a
+ ; A = is the clock set? (wEpoch != 0)
+ ld a, [wEpoch+0]
+ ld b, a
+ ld a, [wEpoch+1]
+ or b
+ ld b, a
+ ld a, [wEpoch+2]
+ or b
+ ld b, a
+ ld a, [wEpoch+3]
+ or b
+ ret z
+ ld a, 1
+ ret
+
+; -----------------------------------------------------------------------------
; sys_isatty() -> A = 1 if this process's stdin is the console, 0 if it is a
; file or a pipe. The shell uses it to tell an interactive session from a
; script (`sh < file`): no prompt, no echo, and EOF is just the end.
diff --git a/usr/gbos.h b/usr/gbos.h
index 014aea4..e0df8bf 100644
--- a/usr/gbos.h
+++ b/usr/gbos.h
@@ -23,6 +23,9 @@ unsigned char sys_net(void *req); /* socket layer trap (see sock.h) */
unsigned char sys_stat(void *req); /* stat trap (see stat.h) */
unsigned char sys_fsstat(void *buf); /* free blocks/inodes (see stat.h) */
unsigned char isatty(void); /* 1 if stdin is the console */
+unsigned char sys_link(void *req); /* hard link trap (see usr/ln.c) */
+void settime(unsigned char buf[4]); /* wall clock: unix seconds, LE */
+unsigned char gettime(unsigned char buf[4]); /* -> 1 if the clock is set */
void gsleep(unsigned char units); /* delay units*(1/64)s (raw trap) */
void gticks(unsigned char buf[4]); /* 64 Hz uptime ticks, 32-bit LE */
void msleep(unsigned int ms); /* delay ~ms milliseconds */
diff --git a/usr/libc.s b/usr/libc.s
index de087b6..4d7063c 100644
--- a/usr/libc.s
+++ b/usr/libc.s
@@ -1,7 +1,7 @@
.module libc
.globl _writes, _putc, _puts, _nl, _strlen, _readc, _sexit, _getpid, _getargs
.globl _open, _close, _fgetc, _fputc, _flist, _fremove, _pipe, _ssend, _srecv, _srecv_nb, _pollin, _pollcon
- .globl _sys_net, _gsleep, _gticks, _sys_stat, _sys_fsstat, _isatty
+ .globl _sys_net, _gsleep, _gticks, _sys_stat, _sys_fsstat, _isatty, _sys_link, _settime, _gettime
;; SDCC sm83 sdcccall(1): 1st arg -> A (byte) or DE (pointer); ret A / BC.
;; rst $30 (the trap) clobbers BC/DE/HL; SDCC treats them caller-saved, so
;; wrappers need not preserve them - but we compute cleanly regardless.
@@ -336,3 +336,21 @@ _isatty:
ld c, #39 ; SYS_ISATTY
rst #0x30
ret
+
+ ;; unsigned char sys_link(void *req /*DE*/) -> A (see fs.asm sys_link)
+_sys_link:
+ ld c, #40 ; SYS_LINK
+ rst #0x30
+ ret
+
+ ;; void settime(unsigned char buf[4] /*DE*/) - set the wall clock
+_settime:
+ ld c, #41 ; SYS_SETTIME
+ rst #0x30
+ ret
+
+ ;; unsigned char gettime(unsigned char buf[4] /*DE*/) -> A = 1 if set
+_gettime:
+ ld c, #42 ; SYS_GETTIME
+ rst #0x30
+ ret
diff --git a/usr/ln.c b/usr/ln.c
new file mode 100644
index 0000000..6a98982
--- /dev/null
+++ b/usr/ln.c
@@ -0,0 +1,43 @@
+#include "gbos.h"
+/* ln TARGET NAME: give an existing file a second name.
+ *
+ * A real hard link, not a copy: both names share one inode, so the data is
+ * stored once and `rm` only frees it when the last name goes. gbfs always had
+ * the link count - nothing had ever incremented it.
+ *
+ * The syscall takes a request block (two paths, and the trap needs HL for its
+ * dispatch table), and that block is a static living at $A000 - so the paths
+ * are copied to the stack first, like every other program that has statics. */
+
+static unsigned char req[4];
+
+void main(void) {
+ char paths[64];
+ char *argv[4];
+ unsigned char argc = argv_parse(argv, 4);
+ unsigned char i, j = 0;
+ char *tgt, *name;
+
+ if (argc < 2) {
+ puts("usage: ln TARGET NAME");
+ nl();
+ sexit(2);
+ }
+ tgt = paths;
+ for (i = 0; argv[0][i] && j < 30; i++) paths[j++] = argv[0][i];
+ paths[j++] = 0;
+ name = paths + j;
+ for (i = 0; argv[1][i] && j < 62; i++) paths[j++] = argv[1][i];
+ paths[j] = 0;
+
+ req[0] = (unsigned char)(unsigned int)tgt;
+ req[1] = (unsigned char)(((unsigned int)tgt) >> 8);
+ req[2] = (unsigned char)(unsigned int)name;
+ req[3] = (unsigned char)(((unsigned int)name) >> 8);
+ if (sys_link(req) != 0) {
+ puts("ln: cannot link ");
+ puts(name);
+ nl();
+ sexit(1);
+ }
+}