aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 16:02:55 +0200
committeruser <user@clank>2026-09-22 16:02:55 +0200
commit43e43b6dcec29e2b008d20135f5d02523b62fbcf (patch)
treed647acd07596e290c70a9884c1dc5d8c20a31067
parentnet: three TCP receive bugs - caller corruption, data loss, early EOF (diff)
downloadgbos-43e43b6dcec29e2b008d20135f5d02523b62fbcf.tar.gz
gbos-43e43b6dcec29e2b008d20135f5d02523b62fbcf.tar.xz
gbos-43e43b6dcec29e2b008d20135f5d02523b62fbcf.zip
usr: grep, more, cp, nc - and make pollcon pump the link
The shell has had pipes for a while with nothing to pipe *through*, and an 18-row screen with no way to stop output scrolling off it. - grep [-vin] PAT [file]: substring match (no regex), stdin or a file, grep(1)'s exit convention. The other end of the pipe, at last. - more [file]: pages 17 lines at a time, --More-- bar in white-on-blue, any key pages, q quits. Counts *screen* rows, so wrapped lines pay their real cost. Keys come from pollin/pollcon, not stdin, so `cat big | more` still has a keyboard. - cp SRC DST: the fs has rm/mkdir but no way to duplicate a file. mv is this plus rm (there is no rename). Rejects SRC == DST, which would otherwise truncate the source via O_WRITE before reading a byte. - nc [-s] HOST PORT: raw TCP. Interactive (START sends the line with a CRLF, /q quits) or -s to pump stdin and print the reply with its own wall-clock idle timeout, because the kernel's blocking recv counts pumps, not seconds. Generalizes what chat/irc hardcode: `nc towel.blinkenlights.nl 23` works. sys_pollcon now pumps the link before draining the console ring. The ring is filled *by* net_pump, so a program that never touches the network polled a ring nothing would ever fill: host-injected keys (gbtype/gbdemo) hung `more` forever, and the Game Boy stopped answering pings for as long as it ran. KGetc already pumps for this reason. New tools take banks 35-38. Locals over statics in all four: a program's _DATA starts at $A000, where the shell leaves the command line.
-rw-r--r--Makefile3
-rw-r--r--README.md15
-rw-r--r--include/gbos.inc4
-rw-r--r--src/net.asm11
-rw-r--r--src/programs.asm28
-rw-r--r--usr/cp.c62
-rw-r--r--usr/grep.c99
-rw-r--r--usr/more.c83
-rw-r--r--usr/nc.c197
9 files changed, 493 insertions, 9 deletions
diff --git a/Makefile b/Makefile
index 19de12e..f4214d8 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,8 @@ $(BUILD)/%.o: src/%.asm | $(BUILD)
# $(BUILD)/usr and INCBIN'd by programs.asm
PROGS := chello echo true false uname pid cat wc head args \
ls save rm sh ps kill spin blk mkdir count ptest necho \
- wget chat netd ping nslookup dhcp irc uptime ansi
+ wget chat netd ping nslookup dhcp irc uptime ansi \
+ grep more cp nc
CBLOBS := $(patsubst %,$(BUILD)/usr/%.bin,$(PROGS))
$(BUILD)/programs.o: $(CBLOBS)
diff --git a/README.md b/README.md
index 6d0061a..95bc362 100644
--- a/README.md
+++ b/README.md
@@ -17,12 +17,15 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
background jobs (`spin &`), `ps`, `kill` — round-robin cooperative
scheduling across banked memory with an HRAM context switch.
- **A shell** (written in C, running *on* the GB) with I/O redirection
- (`>` `<`) and pipes (`|`), argument passing, and a cwd-aware prompt.
+ (`>` `<`) and pipes (`|`), argument passing, and a cwd-aware prompt — with
+ `grep` to put on the other end of the pipe, and `more` to page anything
+ longer than the 18-row screen.
- **Networking, for real**: the kernel owns SLIP over the link port, IPv4,
ICMP, UDP and TCP; userland gets BSD-ish sockets via one syscall. On top:
`dhcp` (boot-time lease), `ping`, `nslookup` (real DNS), `wget` (HTTP),
- `chat`, and a colorized **IRC client** (`irc HOST [NICK]`, `/join` and
- friends, SELECT for the keyboard).
+ `nc` (netcat: `nc HOST PORT` interactively, or `nc -s HOST PORT < file` to
+ pipe a request in and print the reply), `chat`, and a colorized **IRC
+ client** (`irc HOST [NICK]`, `/join` and friends, SELECT for the keyboard).
- **A network to plug into**: `tools/gbhub` is a virtual switch + DHCP server +
NAT gateway — spawn a fleet of Game Boys that ping *each other* and reach
the real internet; `tools/gbjoin` attaches an interactive GB to it, and
@@ -31,9 +34,9 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
colors) + an **on-screen keyboard** on the window layer (SELECT toggles it).
- **Persistent filesystem** on battery-backed cart RAM: inodes, nested
directories, per-process cwd — survives power-off.
-- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~30
- tools ship in `usr/` (`cat`, `wc`, `head`, `ls`, `uptime`, …). Adding a
- program is one `.c` file + a table entry.
+- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~35
+ tools ship in `usr/` (`cat`, `wc`, `head`, `grep`, `cp`, `more`, `ls`,
+ `uptime`, …). Adding a program is one `.c` file + a table entry.
## Quick start
diff --git a/include/gbos.inc b/include/gbos.inc
index cbd2670..9099bc9 100644
--- a/include/gbos.inc
+++ b/include/gbos.inc
@@ -292,5 +292,9 @@ DEF PROG_DHCP EQU 29
DEF PROG_IRC EQU 30
DEF PROG_UPTIME EQU 31
DEF PROG_ANSI EQU 32
+DEF PROG_GREP EQU 33
+DEF PROG_MORE EQU 34
+DEF PROG_CP EQU 35
+DEF PROG_NC EQU 36
ENDC
diff --git a/src/net.asm b/src/net.asm
index 8309242..a5f0210 100644
--- a/src/net.asm
+++ b/src/net.asm
@@ -56,9 +56,16 @@ sys_srecv_nb::
; sys_pollcon() -> A = a queued console-ring byte (link-injected input,
; e.g. gbhub 'type'), or 0 if none. Non-blocking twin of KGetc's con_pop
-; path: programs that own their main loop (irc) poll this alongside the OSK.
-; The ring fills during net pumps, which such programs do constantly.
+; path: programs that own their main loop (irc, nc, more) poll this alongside
+; the OSK.
+;
+; Pump the link first. The ring is filled BY net_pump, so a caller that never
+; touches the network (a pager, a game) would otherwise poll a ring nothing
+; ever fills - host-injected keys (gbtype/gbdemo) would hang it forever, and
+; the Game Boy would stop answering pings for as long as it ran. KGetc already
+; pumps for exactly this reason; pollcon now matches it.
sys_pollcon::
+ call net_pump
call con_pop
ret nc ; got a byte
xor a
diff --git a/src/programs.asm b/src/programs.asm
index c6228f3..a3bd680 100644
--- a/src/programs.asm
+++ b/src/programs.asm
@@ -139,6 +139,18 @@ ProgUptime:
SECTION "prog_ansi", ROMX[$4000], BANK[34]
ProgAnsi:
INCBIN "build/usr/ansi.bin"
+SECTION "prog_grep", ROMX[$4000], BANK[35]
+ProgGrep:
+ INCBIN "build/usr/grep.bin"
+SECTION "prog_more", ROMX[$4000], BANK[36]
+ProgMore:
+ INCBIN "build/usr/more.bin"
+SECTION "prog_cp", ROMX[$4000], BANK[37]
+ProgCp:
+ INCBIN "build/usr/cp.bin"
+SECTION "prog_nc", ROMX[$4000], BANK[38]
+ProgNc:
+ INCBIN "build/usr/nc.bin"
; -----------------------------------------------------------------------------
; PROG_SH (bank 3) - the shell, now written in C (usr/sh.c): parses >/</| and
@@ -247,6 +259,14 @@ ProgramTable::
dw ProgUptime
db LOW(BANK(ProgAnsi)), HIGH(BANK(ProgAnsi))
dw ProgAnsi
+ db LOW(BANK(ProgGrep)), HIGH(BANK(ProgGrep))
+ dw ProgGrep
+ db LOW(BANK(ProgMore)), HIGH(BANK(ProgMore))
+ dw ProgMore
+ db LOW(BANK(ProgCp)), HIGH(BANK(ProgCp))
+ dw ProgCp
+ db LOW(BANK(ProgNc)), HIGH(BANK(ProgNc))
+ dw ProgNc
ProgramTableEnd::
; -----------------------------------------------------------------------------
@@ -320,4 +340,12 @@ NameTable::
db PROG_UPTIME
db "ansi", 0
db PROG_ANSI
+ db "grep", 0
+ db PROG_GREP
+ db "more", 0
+ db PROG_MORE
+ db "cp", 0
+ db PROG_CP
+ db "nc", 0
+ db PROG_NC
db 0
diff --git a/usr/cp.c b/usr/cp.c
new file mode 100644
index 0000000..61a1e0b
--- /dev/null
+++ b/usr/cp.c
@@ -0,0 +1,62 @@
+#include "gbos.h"
+/* cp SRC DST: copy a file. There is no rename syscall, so `mv` is this plus
+ * rm - and O_WRITE truncates (see fs.asm sys_open .trunc), which is exactly
+ * why the SRC == DST case below has to be rejected before opening DST. */
+
+static unsigned char streq(const char *a, const char *b) {
+ while (*a && *a == *b) {
+ a++;
+ b++;
+ }
+ return (unsigned char)(*a == *b);
+}
+
+void main(void) {
+ char *argv[4];
+ unsigned char argc = argv_parse(argv, 4);
+ unsigned char in, out;
+ int ch;
+
+ if (argc < 2) {
+ puts("usage: cp SRC DST");
+ nl();
+ sexit(2);
+ }
+ if (streq(argv[0], argv[1])) {
+ puts("cp: SRC and DST are the same file");
+ nl();
+ sexit(2);
+ }
+
+ in = open(argv[0], O_READ);
+ if (in == EISDIR) {
+ puts("cp: SRC is a directory");
+ nl();
+ sexit(2);
+ }
+ if (in == NOFD) {
+ puts("cp: no such file: ");
+ puts(argv[0]);
+ nl();
+ sexit(2);
+ }
+
+ out = open(argv[1], O_WRITE); /* creates, or truncates an existing file */
+ if (out == EISDIR) {
+ puts("cp: DST is a directory");
+ nl();
+ close(in);
+ sexit(2);
+ }
+ if (out == NOFD) {
+ puts("cp: cannot create ");
+ puts(argv[1]);
+ nl();
+ close(in);
+ sexit(2); /* out of inodes/blocks, or a bad path */
+ }
+
+ while ((ch = fgetc(in)) >= 0) fputc(out, (char)ch);
+ close(in);
+ close(out); /* flushes the inode */
+}
diff --git a/usr/grep.c b/usr/grep.c
new file mode 100644
index 0000000..3d2b325
--- /dev/null
+++ b/usr/grep.c
@@ -0,0 +1,99 @@
+#include "gbos.h"
+/* grep [-v][-i][-n] PAT [file]: print the lines of a file (or stdin) that
+ * contain the substring PAT. Plain substring matching, no regex - '.' and '*'
+ * are literal characters. -v inverts the match, -i folds case, -n prefixes
+ * each hit with its line number.
+ *
+ * NB: no statics here. A program's _DATA area starts at $A000, which is also
+ * where the shell leaves the command line (usr/sh.c ARGV0), so statics would
+ * eat argv. Locals live on the task stack below $BFFF and are always safe. */
+
+#define MAXLINE 80 /* two terminal rows; longer lines are clipped */
+
+static char fold(char c, unsigned char ic) {
+ if (ic && c >= 'A' && c <= 'Z') return (char)(c + 32);
+ return c;
+}
+
+/* is pat[] present anywhere in line[0..len)? */
+static unsigned char contains(char *line, unsigned char len, char *pat, unsigned char ic) {
+ unsigned char i, j;
+ if (!pat[0]) return 1; /* empty pattern matches all */
+ for (i = 0; i < len; i++) {
+ for (j = 0;; j++) {
+ if (!pat[j]) return 1; /* ran out of pattern: a hit */
+ if (i + j >= len) break; /* ran out of line: no hit here */
+ if (fold(line[i + j], ic) != fold(pat[j], ic)) break;
+ }
+ }
+ return 0;
+}
+
+void main(void) {
+ char *argv[8];
+ char line[MAXLINE];
+ unsigned char argc = argv_parse(argv, 8);
+ unsigned char inv = hasflag(argv, argc, 'v') ? 1 : 0;
+ unsigned char ic = hasflag(argv, argc, 'i') ? 1 : 0;
+ unsigned char num = hasflag(argv, argc, 'n') ? 1 : 0;
+ unsigned char i, len = 0, hits = 0, fd = NOFD;
+ unsigned int lineno = 0;
+ char *pat = 0, *fname = 0;
+ int ch;
+
+ /* first non-flag word is the pattern, second (if any) is the file */
+ for (i = 0; i < argc; i++) {
+ if (argv[i][0] == '-') continue;
+ if (!pat) pat = argv[i];
+ else {
+ fname = argv[i];
+ break;
+ }
+ }
+ if (!pat) {
+ puts("usage: grep [-vin] PAT [file]");
+ nl();
+ sexit(2);
+ }
+
+ if (fname) {
+ fd = open(fname, O_READ);
+ if (fd == EISDIR) {
+ puts("grep: is a directory");
+ nl();
+ sexit(2);
+ }
+ if (fd == NOFD) {
+ puts("grep: no such file");
+ nl();
+ sexit(2);
+ }
+ }
+
+ for (;;) {
+ if (fd == NOFD) {
+ char c = readc();
+ ch = (c == EOF) ? -1 : c;
+ } else ch = fgetc(fd);
+ if (ch == '\r') continue; /* CRLF: the LF ends the line */
+ if (ch >= 0 && ch != '\n') { /* ordinary character */
+ if (len < MAXLINE) line[len++] = (char)ch;
+ continue;
+ }
+ if (ch < 0 && len == 0) break; /* EOF, nothing pending */
+ lineno++;
+ if (contains(line, len, pat, ic) != inv) {
+ if (num) {
+ putu(lineno);
+ putc(':');
+ }
+ writes(line, len); /* one batched render */
+ nl();
+ hits = 1;
+ }
+ len = 0;
+ if (ch < 0) break; /* EOF after a final partial line */
+ }
+ if (fd != NOFD) close(fd);
+ sexit(hits ? 0 : 1); /* grep(1) exit convention */
+}
diff --git a/usr/more.c b/usr/more.c
new file mode 100644
index 0000000..6f6afa7
--- /dev/null
+++ b/usr/more.c
@@ -0,0 +1,83 @@
+#include "gbos.h"
+/* more [file]: page a file (or stdin) one screenful at a time.
+ *
+ * The terminal is 40x18, so anything longer than a screen scrolls off before
+ * you can read it (try `ls` on a full disk, or `cat` on a wget dump). This
+ * prints 17 lines, then paints a --More-- bar and waits for a keypress:
+ * SELECT opens the on-screen keyboard, any key pages forward, 'q' quits.
+ *
+ * Keys come from pollin()/pollcon(), NOT stdin - so `cat file | more` still
+ * has a keyboard even though its stdin is the pipe. (pollcon also means the
+ * host can drive it over the link with tools/gbtype.) */
+
+#define PAGE 17 /* TROWS - 1: leave a row for the bar */
+#define TCOLS 40
+
+static void sgr(unsigned char n) { /* emit ESC [ n m */
+ putc(27);
+ putc('[');
+ putu(n);
+ putc('m');
+}
+
+/* show the bar, block until a key arrives; 0 = the user asked to quit */
+static unsigned char waitkey(void) {
+ unsigned char c;
+ sgr(44);
+ sgr(37); /* white on blue */
+ puts("--More--");
+ sgr(0);
+ for (;;) {
+ c = pollin(); /* on-screen keyboard */
+ if (!c) c = pollcon(); /* link-injected console bytes */
+ if (c) break;
+ yield(); /* cooperative: let other procs run */
+ }
+ puts("\r \r"); /* wipe the bar, stay on the same row */
+ return (c == 'q' || c == 'Q') ? 0 : 1;
+}
+
+void main(void) {
+ char *argv[4];
+ unsigned char argc = argv_parse(argv, 4);
+ unsigned char fd = NOFD, rows = 0, col = 0;
+ int ch;
+
+ if (argc > 0 && argv[0][0] != '-') {
+ fd = open(argv[0], O_READ);
+ if (fd == EISDIR) {
+ puts("more: is a directory");
+ nl();
+ sexit(2);
+ }
+ if (fd == NOFD) {
+ puts("more: no such file");
+ nl();
+ sexit(2);
+ }
+ }
+
+ for (;;) {
+ if (fd == NOFD) {
+ char c = readc();
+ ch = (c == EOF) ? -1 : c;
+ } else ch = fgetc(fd);
+ if (ch < 0) break;
+ putc((char)ch);
+
+ /* count screen rows, not input lines: a 100-char line wraps to three */
+ if (ch == '\n') {
+ rows++;
+ col = 0;
+ } else if (ch != '\r' && ++col == TCOLS) {
+ rows++;
+ col = 0;
+ }
+
+ if (rows >= PAGE) {
+ if (!waitkey()) break;
+ rows = 0;
+ }
+ }
+ if (fd != NOFD) close(fd);
+}
diff --git a/usr/nc.c b/usr/nc.c
new file mode 100644
index 0000000..023bedb
--- /dev/null
+++ b/usr/nc.c
@@ -0,0 +1,197 @@
+#include "resolve.h"
+/* nc [-s] HOST PORT - raw TCP, the way netcat does it. The kernel owns
+ * SLIP/IP/TCP; this is just a pump between the socket and the terminal.
+ *
+ * nc towel.blinkenlights.nl 23 telnet-ish: ASCIImation on a Game Boy
+ * nc 10.0.0.1 6667 talk to anything line-oriented
+ * echo GET / HTTP/1.0 > r (blank line needed by most servers)
+ * nc -s example.com 80 < r -s: send stdin, print the reply, exit
+ *
+ * Interactive mode: SELECT opens the on-screen keyboard, typing echoes
+ * locally, START sends the line with a CRLF. The line "/q" quits without
+ * being sent (there is no ^C here); a remote EOF quits too.
+ *
+ * Everything lives on the stack on purpose: resolve.h's DNS buffer is a
+ * static, so it sits at $A000 - right on top of the command line the shell
+ * left for us. Copy argv before the first DNS call, exactly like ping.c. */
+
+#define BUFSZ 208 /* one full segment: the most the kernel buffers */
+#define LINESZ 64
+
+static unsigned char split(char *s, char **tok, unsigned char max) {
+ unsigned char n = 0;
+ for (;;) {
+ while (*s == ' ') *s++ = 0;
+ if (!*s) break;
+ if (n < max) tok[n++] = s;
+ while (*s && *s != ' ') s++;
+ }
+ return n;
+}
+
+/* atou() is 8-bit; ports are not */
+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;
+}
+
+void main(void) {
+ char args[LINESZ]; /* stack copy of the command line */
+ char line[LINESZ];
+ unsigned char buf[BUFSZ];
+ unsigned char dst[4];
+ char *tok[4];
+ char *host = 0, *portstr = 0;
+ unsigned int port;
+ unsigned char ntok, i, n, c, sock, sflag = 0, llen = 0, idle = 0;
+
+ { /* copy args off $A000 before resolve()'s statics land on them */
+ char *a = getargs();
+ for (i = 0; a[i] && i < LINESZ - 1; i++) args[i] = a[i];
+ args[i] = 0;
+ }
+
+ ntok = split(args, tok, 4);
+ for (i = 0; i < ntok; i++) {
+ if (tok[i][0] == '-') {
+ if (tok[i][1] == 's') sflag = 1;
+ continue;
+ }
+ if (!host) host = tok[i];
+ else if (!portstr) portstr = tok[i];
+ }
+ if (!host || !portstr) {
+ puts("usage: nc [-s] HOST PORT");
+ nl();
+ puts(" -s: send stdin, print reply, exit");
+ nl();
+ sexit(2);
+ }
+ port = atou16(portstr);
+ if (port == 0) {
+ puts("nc: bad port");
+ nl();
+ sexit(2);
+ }
+
+ if (!resolve(host, dst)) {
+ puts("nc: cannot resolve ");
+ puts(host);
+ nl();
+ sexit(1);
+ }
+
+ sock = net_socket(SOCK_TCP);
+ if (sock == 0xFF) {
+ puts("nc: no socket");
+ nl();
+ sexit(1);
+ }
+ /* vary the local port per run (DIV noise + pid): a fixed one can collide
+ with a half-dead connection still open at the server (see irc.c) */
+ net_bind(sock, 42000 + ((unsigned int)*(volatile unsigned char *)0xFF04 << 2) + getpid());
+
+ puts("-!- connecting ");
+ for (i = 0; i < 4; i++) {
+ putu(dst[i]);
+ if (i < 3) putc('.');
+ }
+ putc(':');
+ putu(port);
+ nl();
+ if (net_connect(sock, dst, port) == 0xFF) {
+ puts("nc: connect failed");
+ nl();
+ net_close(sock);
+ sexit(1);
+ }
+
+ if (sflag) { /* batch mode: stdin -> socket -> term */
+ n = 0;
+ for (;;) {
+ char ch = readc(); /* blocks forever on a console stdin! */
+ if (ch == EOF) break;
+ buf[n++] = (unsigned char)ch;
+ if (n == BUFSZ) {
+ net_send(sock, buf, n);
+ n = 0;
+ }
+ }
+ if (n) net_send(sock, buf, n);
+ /* Own the timeout out here (like ping): the kernel's blocking recv
+ counts pumps, not seconds, so a peer that just goes quiet would
+ wedge us for minutes instead of ending the transfer. */
+ idle = 0;
+ for (;;) {
+ n = net_recv_nb(sock, buf, BUFSZ);
+ if (n == 0) {
+ nl();
+ puts("[eof]");
+ nl();
+ break;
+ }
+ if (n == 0xFE || n == 0xFF) {
+ if (++idle >= 80) {
+ nl();
+ puts("[timeout]");
+ nl();
+ break;
+ }
+ msleep(50); /* ~4s of silence ends it */
+ continue;
+ }
+ writes((char *)buf, n);
+ idle = 0;
+ }
+ net_close(sock);
+ sexit(0);
+ }
+
+ puts("-!- connected (SELECT = keyboard, /q quits)");
+ nl();
+ for (;;) {
+ n = net_recv_nb(sock, buf, BUFSZ);
+ if (n == 0) {
+ puts("-!- closed by peer");
+ nl();
+ break;
+ }
+ if (n != 0xFE && n != 0xFF) writes((char *)buf, n); /* batched render */
+
+ /* poll input every pass, even mid-flood, or a chatty peer starves the
+ keyboard and dropped keystrokes look like a broken OSK (irc.c) */
+ c = pollin();
+ if (!c) c = pollcon();
+ if (c == '\n') {
+ nl();
+ if (llen == 2 && line[0] == '/' && line[1] == 'q') break;
+ line[llen] = '\r';
+ line[llen + 1] = '\n';
+ net_send(sock, line, (unsigned char)(llen + 2));
+ llen = 0;
+ } else if (c == 8) {
+ if (llen) {
+ llen--;
+ putc(8);
+ }
+ } else if (c) {
+ if (llen < LINESZ - 2) {
+ line[llen++] = (char)c;
+ putc((char)c);
+ }
+ }
+
+ if (n == 0xFE || n == 0xFF) { /* nothing arrived: back off a little */
+ if (!c && ++idle >= 4) {
+ msleep(20);
+ idle = 4;
+ }
+ } else idle = 0;
+ }
+ net_close(sock);
+ sexit(0);
+}