aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-24 14:15:46 +0200
committeruser <user@clank>2026-09-24 14:15:46 +0200
commit50a56b48f22cd31868403f1fde10cf8367344e2e (patch)
tree57c606182378dde67ce63d115ea07f98cc96b709
parentusr: xxd - hex dump a file or stdin (diff)
downloadgbos-50a56b48f22cd31868403f1fde10cf8367344e2e.tar.gz
gbos-50a56b48f22cd31868403f1fde10cf8367344e2e.tar.xz
gbos-50a56b48f22cd31868403f1fde10cf8367344e2e.zip
fs: mount the builtins at /bin
The commands only existed in the kernel's program table, so the only way to find out what the machine could do was to already know (or read the source). They are now a directory in the tree: /# ls / /# xxd -l 16 /bin/uname bin 0000: cd 6b 43 06 00 0e 00 f7 .kC..... readme 0008: 18 fe 47 0e 03 f7 c9 21 ..G....! /# ls -l /bin | head -2 - 1 16384 worker /bin is synthetic, not disk: dir_find resolves "bin" in the root to BIN_INO and names inside it through the NameTable, so `ls /bin` lists exactly what the shell can run - one table, one truth, no second list to keep in sync. The root listing carries "bin" as a synthetic first entry so it is discoverable by walking the tree. A program image is inode BIN_FILE|progid: it stats as a 16 KiB file (a program IS its ROM bank) and reads through rom_getb, which maps that bank, takes the byte, and maps the CALLER'S text bank back before returning - the caller is itself executing from $4000, so leaving the wrong bank mapped would return into another program's code. Verified byte-for-byte against the host's view of the blob. The tree is read-only: create/remove/write inside /bin all fail. xxd grows -l (stop after N bytes), because a program image is 2048 dump lines - more than a pipe's temp file can hold, let alone the screen. Two traps worth remembering, both found the hard way here: `and BIN_FILE` destroys A, so the inode must be reloaded before inode_ptr (missing it once made ls -l stat inode 0 and call every file an empty non-directory); and ls now copies its directory argument to the stack first, because stat()'s request block is a static living at $A000 - on top of the command line it was reading.
-rw-r--r--README.md4
-rw-r--r--docs/internals.md11
-rw-r--r--include/gbos.inc7
-rw-r--r--src/fs.asm237
-rw-r--r--src/syscall.asm2
-rw-r--r--usr/ls.c20
-rw-r--r--usr/xxd.c22
7 files changed, 293 insertions, 10 deletions
diff --git a/README.md b/README.md
index 0dffb18..e828915 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,10 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
directories, per-process cwd — survives power-off (files up to 3 KiB).
`ls -l` and `df` report sizes and free space; `poweroff` is the only safe
way to stop the machine (the battery save is written on a clean exit).
+- **`/bin` in the tree**: the builtins are not files on the disk, but they
+ show up as one anyway — `ls /bin` enumerates the kernel's own command
+ table, `cd /bin` works, and `xxd -l 16 /bin/uname` dumps the program's ROM
+ bank. Read-only, since it is ROM.
- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~45
tools ship in `usr/` (`cat`, `wc`, `head`, `tail`, `grep`, `cp`, `mv`,
`tee`, `more`, `ls`, `df`, `xxd`, `sleep`, `uptime`, …) — `help` lists them all on
diff --git a/docs/internals.md b/docs/internals.md
index f9c5899..f630025 100644
--- a/docs/internals.md
+++ b/docs/internals.md
@@ -241,6 +241,17 @@ blocks 4.. data blocks
- **`sys_stat` / `sys_fsstat`** back `ls -l` and `df`. The syscall trap needs
HL for its dispatch table, so a call taking both a path and an output
buffer passes a request block, the shape SYS_NET already uses.
+- **`/bin` is synthetic.** It is not on the disk: `dir_find` resolves "bin" in
+ the root to `BIN_INO`, and names inside it through the kernel's own
+ NameTable, so `ls /bin` shows exactly what the shell can run - one table,
+ one truth. A program image is inode `BIN_FILE|progid`, `stat`s as a 16 KiB
+ file (a program IS its ROM bank) and reads through `rom_getb`, which maps
+ that bank, takes the byte, and maps the CALLER'S text bank back before
+ returning - the caller is itself running from `$4000`. The whole tree is
+ read-only: creating, removing or writing inside `/bin` fails.
+ NB every `and BIN_FILE` test destroys A, so the inode has to be reloaded
+ before `inode_ptr`; forgetting that once made `ls -l` stat inode 0 and
+ report every file as an empty non-directory.
- **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths
(absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`,
inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt.
diff --git a/include/gbos.inc b/include/gbos.inc
index 1af3fc8..32418b6 100644
--- a/include/gbos.inc
+++ b/include/gbos.inc
@@ -235,6 +235,13 @@ DEF BLK_DATA0 EQU 4 ; first data block
DEF FS_VERSION EQU 3 ; bumped: dirs now carry "."/".." entries
DEF NINODES EQU 32
DEF ROOT_INO EQU 1
+; /bin is synthetic: a directory made of the kernel's program table rather
+; than of disk blocks, so the builtins can be found (ls /bin), stat'd and read
+; (xxd /bin/echo) like files. Both values are outside the real inode range
+; (1..NINODES), so anything holding an inode number can tell them apart.
+DEF BIN_INO EQU $F0 ; the /bin directory itself
+DEF BIN_FILE EQU $80 ; BIN_FILE|progid = one program image
+DEF BIN_SIZE EQU $4000 ; a program is exactly its 16 KiB ROM bank
DEF NDIRECT EQU 12 ; direct block pointers per inode -> files <= 3 KiB
; (4..15: the whole tail of the 16-byte inode. It
; was 8, and bytes 12-15 were unused - but nothing
diff --git a/src/fs.asm b/src/fs.asm
index 30dfdd6..5019015 100644
--- a/src/fs.asm
+++ b/src/fs.asm
@@ -242,6 +242,31 @@ dir_write:
; dir_find - dir inode in wFsDir, name in wFsNameBuf -> A = inode (0 if none)
; -----------------------------------------------------------------------------
dir_find::
+ ; /bin is synthetic. Inside it, names come from the kernel's NameTable;
+ ; in the root, the name "bin" resolves to it. Everything else is disk.
+ ld a, [wFsDir]
+ cp BIN_INO
+ jr z, .inbin
+ cp ROOT_INO
+ jr nz, .disk
+ ld hl, .binname
+ ld de, wFsNameBuf
+ call fs_streq
+ jr nz, .disk
+ ld a, BIN_INO
+ ret
+.inbin
+ ld de, wFsNameBuf
+ call sys_lookup ; A = program id, or $FF
+ cp $FF
+ jr z, .nobin
+ or BIN_FILE ; -> the synthetic inode for that program
+ ret
+.nobin
+ xor a
+ ret
+.binname: db "bin", 0
+.disk
call dir_load
ld c, 0
.l
@@ -264,10 +289,27 @@ dir_find::
ld a, [hl]
ret
+; fs_streq(HL, DE) -> Z if the two NUL-terminated strings match.
+fs_streq:
+ ld a, [de]
+ cp [hl]
+ ret nz
+ or a
+ ret z
+ inc hl
+ inc de
+ jr fs_streq
+
; -----------------------------------------------------------------------------
; dir_add - dir in wFsDir, child in wFsInode, name in wFsNameBuf -> A = 1/0
; -----------------------------------------------------------------------------
dir_add::
+ ld a, [wFsDir] ; /bin is read-only: it is ROM
+ cp BIN_INO
+ jr nz, .notbin
+ xor a
+ ret
+.notbin
call dir_load
ld c, 0
.l
@@ -676,10 +718,18 @@ sys_open::
call copy_path ; path -> wFsPath
call resolve_parent ; A = parent dir inode, wFsNameBuf = final name
or a
- jr z, .fail
+ jp z, .fail
ld [wFsDir], a
call dir_find ; A = target inode or 0
ld [wFsInode], a
+ cp BIN_INO
+ jp z, .isdir ; /bin: a directory, not a file
+ and BIN_FILE
+ jp nz, .romfile ; a program image: read-only, no inode on disk
+ ld a, [wFsDir]
+ cp BIN_INO
+ jp z, .fail ; creating inside /bin: it is ROM
+ ld a, [wFsInode]
or a
jr z, .notdir ; doesn't exist yet: no type to check
call inode_ptr
@@ -728,6 +778,11 @@ sys_open::
ld a, [wFsMode]
cp O_APPEND
jr z, .allocof ; append: keep the data, seek to the end below
+.romfile
+ ld a, [wFsMode]
+ or a
+ jr nz, .fail ; O_WRITE / O_APPEND on a program image
+ jp .allocof
.trunc
ld a, [wFsInode]
call file_truncate
@@ -850,6 +905,9 @@ sys_getb::
ld e, a
ld a, [hl]
ld d, a ; DE = pos
+ ld a, [wFsInode]
+ and BIN_FILE
+ jp nz, rom_getb ; /bin/<prog>: the bytes are in ROM, not on disk
; pos < size ?
ld a, [wFsInode]
call inode_ptr
@@ -911,6 +969,125 @@ sys_getb::
scf
ret
+binname: db "bin", 0
+
+; list_copy_name - HL = NUL-terminated name -> copy it to the caller's buffer
+; (wFsOFPtr) and return A = 1. Shared by the /bin and root-"bin" list paths.
+list_copy_name:
+ ld a, [wFsOFPtr]
+ ld e, a
+ ld a, [wFsOFPtr+1]
+ ld d, a
+ ld b, 15
+.cp
+ ld a, [hl+]
+ ld [de], a
+ inc de
+ or a
+ jr z, .done
+ dec b
+ jr nz, .cp
+ xor a
+ ld [de], a
+.done
+ ld a, 1
+ ret
+
+; bin_list - the index-th name from the kernel's NameTable, so `ls /bin` shows
+; exactly what the shell can run: one table, one truth.
+bin_list:
+ ld hl, NameTable
+ ld a, [wFsSlot]
+ ld c, a ; entries still to skip
+.scan
+ ld a, [hl]
+ or a
+ jr z, .none ; end of table: no such index
+ ld a, c
+ or a
+ jp z, list_copy_name ; this is the one
+ dec c
+.skip
+ ld a, [hl+]
+ or a
+ jr nz, .skip ; past the name
+ inc hl ; past the program id
+ jr .scan
+.none
+ xor a
+ ret
+
+; =============================================================================
+; rom_getb - read one byte of a /bin program image. DE = position, wFsInode =
+; BIN_FILE|progid, wFsOFPtr = the open-file entry. Returns like sys_getb:
+; A = byte with CF clear, or CF set at EOF.
+;
+; A program image IS its 16 KiB ROM bank, so this maps that bank into
+; $4000-$7FFF, takes the byte, and maps the CALLER'S text bank back before
+; returning - the caller is itself running from $4000, so leaving the wrong
+; bank mapped would return into another program's code.
+; =============================================================================
+rom_getb:
+ ld a, d ; pos >= BIN_SIZE ($4000) -> EOF
+ cp HIGH(BIN_SIZE)
+ jr c, .inrange
+ scf
+ ret
+.inrange
+ push de
+ ld a, [wFsInode]
+ and $7F ; program id
+ add a
+ add a ; *4: ProgramTable entry = bank(2) + entry(2)
+ ld hl, ProgramTable
+ add l
+ ld l, a
+ ld a, h
+ adc 0
+ ld h, a
+ ld a, [hl+]
+ ld b, a ; bank lo
+ ld a, [hl]
+ ld c, a ; bank hi
+ ld a, b
+ ld [MBC5_ROMB_LO], a
+ ld a, c
+ ld [MBC5_ROMB_HI], a
+ pop de
+ push de
+ ld a, d
+ add $40 ; HL = $4000 + pos
+ ld h, a
+ ld l, e
+ ld a, [hl]
+ ld [wFsByte], a
+ ; put the caller's own text bank back
+ ld a, [wCurProc]
+ add PROC_ROMB
+ ld l, a
+ ld a, [wCurProc+1]
+ adc 0
+ ld h, a
+ ld a, [hl+]
+ ld [MBC5_ROMB_LO], a
+ ld a, [hl]
+ ld [MBC5_ROMB_HI], a
+ pop de
+ inc de ; pos++
+ ld a, [wFsOFPtr]
+ add OF_POS
+ ld l, a
+ ld a, [wFsOFPtr+1]
+ adc 0
+ ld h, a
+ ld a, e
+ ld [hl+], a
+ ld a, d
+ ld [hl], a
+ ld a, [wFsByte]
+ and a ; CF = 0
+ ret
+
; =============================================================================
; sys_putb(B = fd, E = byte)
; =============================================================================
@@ -1030,6 +1207,21 @@ sys_list::
ld [wFsSlot], a ; wanted index
ld a, [wListDir]
ld [wFsDir], a
+ cp BIN_INO
+ jp z, bin_list ; /bin comes from the program table
+ cp ROOT_INO
+ jr nz, .ondisk
+ ; the root carries a synthetic "bin" as its first entry, so the builtins
+ ; can be found by walking the tree instead of by knowing they are there
+ ld a, [wFsSlot]
+ or a
+ jr nz, .shift
+ ld hl, binname
+ jp list_copy_name
+.shift
+ dec a
+ ld [wFsSlot], a ; the disk entries follow it
+.ondisk
call dir_load
ld c, 0 ; entry cursor
ld b, 0 ; used counter
@@ -1175,10 +1367,16 @@ sys_chdir::
or a
jr z, .fail
ld [wFsInode], a
+ cp BIN_INO
+ jr z, .setcwd ; /bin: a directory, just not one on disk
+ and BIN_FILE
+ jr nz, .fail ; a program image is not a directory
+ ld a, [wFsInode]
call inode_ptr
ld a, [hl] ; I_TYPE
cp IT_DIR
jr nz, .fail
+.setcwd
ld a, [wCurProc]
add PROC_CWD
ld l, a
@@ -1217,6 +1415,11 @@ sys_stat::
or a
jr z, .fail
ld [wFsInode], a
+ cp BIN_INO
+ jr z, .statbindir
+ and BIN_FILE
+ jr nz, .statbinfile
+ ld a, [wFsInode] ; the AND above clobbered it
call inode_ptr ; HL = &inode: type, nlink, size lo, size hi
ld a, [wFsStatReq]
add SR_TYPE
@@ -1235,6 +1438,32 @@ sys_stat::
ld [de], a ; SR_INO follows SR_SIZE
xor a
ret
+.statbindir
+ ld hl, .bindir
+ jr .statfixed
+.statbinfile
+ ld hl, .binfile
+.statfixed
+ ; type, nlink, size lo, size hi from a fixed template, then the inode
+ ld a, [wFsStatReq]
+ add SR_TYPE
+ ld e, a
+ ld a, [wFsStatReq+1]
+ adc 0
+ ld d, a
+ ld b, 4
+.cpfx
+ ld a, [hl+]
+ ld [de], a
+ inc de
+ dec b
+ jr nz, .cpfx
+ ld a, [wFsInode]
+ ld [de], a
+ xor a
+ ret
+.bindir: db IT_DIR, 1, 0, 0
+.binfile: db IT_FILE, 1, LOW(BIN_SIZE), HIGH(BIN_SIZE)
.fail
ld a, $FF
ret
@@ -1297,10 +1526,16 @@ sys_opendir::
or a
jr z, .fail
ld [wFsInode], a
+ cp BIN_INO
+ jr z, .setdir ; /bin: listed from the program table
+ and BIN_FILE
+ jr nz, .fail
+ ld a, [wFsInode]
call inode_ptr
ld a, [hl]
cp IT_DIR
jr nz, .fail
+.setdir
ld a, [wFsInode]
ld [wListDir], a
xor a
diff --git a/src/syscall.asm b/src/syscall.asm
index 9e5ca33..b548328 100644
--- a/src/syscall.asm
+++ b/src/syscall.asm
@@ -203,7 +203,7 @@ sys_setout:
; -----------------------------------------------------------------------------
; sys_lookup(DE = name) -> A = program id, or $FF if not a known command.
; -----------------------------------------------------------------------------
-sys_lookup:
+sys_lookup::
ld h, d
ld l, e ; HL = query name
ld de, NameTable
diff --git a/usr/ls.c b/usr/ls.c
index 5a26717..2c2a922 100644
--- a/usr/ls.c
+++ b/usr/ls.c
@@ -21,15 +21,25 @@ void main(void) {
struct gstat st;
unsigned char argc = argv_parse(argv, 4);
unsigned char lng = hasflag(argv, argc, 'l');
- unsigned char i, j, k;
- char *dir = 0;
+ char dir[32];
+ unsigned char i, j, k, havedir = 0;
+ /* Copy the directory argument onto the stack before anything else: argv
+ points into the command line at $A000, and gstat()'s request block is a
+ static - which lives at $A000 too. The first stat would otherwise eat
+ the argument, and every later entry would stat a garbage path. */
for (i = 0; i < argc; i++)
if (argv[i][0] != '-') {
- dir = argv[i];
+ for (j = 0; argv[i][j] && j < 31; j++) dir[j] = argv[i][j];
+ dir[j] = 0;
+ havedir = 1;
break;
}
- if (opendir(dir ? dir : ".")) {
+ if (!havedir) {
+ dir[0] = '.';
+ dir[1] = 0;
+ }
+ if (opendir(dir)) {
puts("ls: no such directory");
nl();
sexit(1);
@@ -44,7 +54,7 @@ void main(void) {
}
/* stat wants a path, and flist only hands back the leaf name */
j = 0;
- if (dir) {
+ if (havedir && !(dir[0] == '.' && dir[1] == 0)) {
for (k = 0; dir[k] && j < 30; k++) path[j++] = dir[k];
if (j && path[j - 1] != '/') path[j++] = '/';
}
diff --git a/usr/xxd.c b/usr/xxd.c
index 8e255ae..421c9c6 100644
--- a/usr/xxd.c
+++ b/usr/xxd.c
@@ -6,10 +6,22 @@
* line and make the dump unreadable. -c overrides it anyway.
*
* There is no seek syscall, so this streams from the start - which is also
- * why there is no -s (skip). */
+ * why there is no -s (skip). -l stops after N bytes, which is how you look at
+ * a program image: /bin/<prog> is a whole 16 KiB ROM bank, and dumping all of
+ * it is 2048 lines (and more than a pipe's temp file can hold). */
#define MAXC 16
+/* atou() is 8-bit; -l wants up to 16 KiB */
+static unsigned int atou16(const char *s) {
+ unsigned int n = 0;
+ while (*s >= '0' && *s <= '9') {
+ n = n * 10 + (unsigned char)(*s - '0');
+ s++;
+ }
+ return n;
+}
+
static char hexd(unsigned char v) { return (char)(v < 10 ? '0' + v : 'a' + (v - 10)); }
static void hex2(unsigned char v) {
putc(hexd((unsigned char)(v >> 4)));
@@ -25,6 +37,8 @@ void main(void) {
unsigned char buf[MAXC];
unsigned char argc = argv_parse(argv, 8);
char *cv = optval(argv, argc, 'c');
+ char *lv = optval(argv, argc, 'l');
+ unsigned int limit = 0; /* 0 = no limit */
unsigned char cols = 8, fd = NOFD, n = 0, i;
unsigned int off = 0;
char *fname = 0;
@@ -34,9 +48,10 @@ void main(void) {
cols = atou(cv);
if (cols == 0 || cols > MAXC) cols = 8;
}
+ if (lv) limit = atou16(lv);
for (i = 0; i < argc; i++) {
if (argv[i][0] == '-') continue;
- if (cv == argv[i]) continue;
+ if (cv == argv[i] || lv == argv[i]) continue;
fname = argv[i];
break;
}
@@ -55,7 +70,8 @@ void main(void) {
}
for (;;) {
- if (fd == NOFD) {
+ if (limit && off + n >= limit) ch = -1; /* -l: stop after N bytes */
+ else if (fd == NOFD) {
char c = readc();
ch = (c == EOF) ? -1 : c;
} else ch = fgetc(fd);