Initial commit: Schunix as it stands before the SchOS rename

An x86-64 operating system built from xv6, now largely rewritten: SMP,
a copy-on-write root filesystem (SchFS), PCI/USB/virtio drivers, a TTF
rasteriser, and a Linux-numbered syscall ABI.

No history before this point -- the tree was never under version control.
This commit exists so the rename that follows has something to undo to.
This commit is contained in:
Nexoniarz 2026-08-09 13:47:01 +02:00
commit d0f7acf9eb
275 changed files with 51417 additions and 0 deletions

623
kernel/fs/schfs/kmount.c Normal file
View file

@ -0,0 +1,623 @@
// SPDX-License-Identifier: Apache-2.0
//
// SchFS in the kernel: block device, mount, and the transaction bracket.
//
// The filesystem code itself (btree.c, space.c, vol.c, obj.c, objw.c) is the
// same portable C the host tools compile, so nothing here re-implements the
// format. This file is only the three things that cannot be shared: a block
// device backed by the buffer cache, the boot-time mount, and the rule about
// when a group of changes becomes durable.
//
// The running root filesystem is still the v1 code in kernel/fs/fs.c, so a
// SchFS volume that fails to mount costs nothing — the system boots exactly as
// before and says so on the console.
//
// ---------------------------------------------------------------------------
// Transactions
//
// Every entry point below that changes anything is one transaction group: it
// takes the volume lock, does its work, and commits before returning. That
// makes an operation durable the moment it returns, and — because a commit is a
// single uberblock write that either lands or does not — indivisible against a
// crash. There is no journal to replay and nothing to undo, because none of the
// work is reachable until that one block is written.
//
// The cost is one uberblock write per operation. Batching several operations
// into a group would amortise it, and the structure here is deliberately shaped
// so that becomes a change to where commit is called rather than a redesign;
// but a filesystem that loses the last few operations on a crash is a much
// worse default than a slow one.
#include "types.h"
#include "defs.h"
#include "param.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "fs.h"
#include "buf.h"
#include "schfs_int.h"
// A SchFS block is 4096 bytes and a buffer-cache block is BSIZE (1024), so each
// filesystem block spans SCHFS_BPB consecutive buffers. Keeping the buffer
// cache at its own block size avoids disturbing the v1 filesystem, the log and
// the IDE driver, all of which assume BSIZE.
#define SCHFS_BPB (SCHFS_BLKSIZE / BSIZE)
struct schfs_kdev {
uint dev;
};
static struct schfs_kdev kdev;
static struct schfs_vol kvol;
static int kmounted;
// Block buffers, allocated once at mount.
//
// Nothing in SchFS may put a block on the stack: a cursor needs a whole
// SCHFS_BLKSIZE buffer and the kernel stack is one 4096-byte page (KSTACKSIZE),
// so a single such local overflows it. kalloc returns exactly one page, which
// is exactly one SchFS block, so each of these fits with nothing to spare.
//
// kcurblk and kdatablk serve the read paths; kctx* are the three the volume
// keeps for mutations. They are separate pages because a write path runs a
// cursor of its own inside the volume's buffers while the caller's cursor is
// still positioned in kcurblk.
static char *kcurblk;
static char *kdatablk;
static char *kctxa, *kctxb, *kctxc;
// Those buffers are shared by every caller, so every entry point below holds
// this lock for the whole operation. A sleeplock rather than a spinlock because
// bread() sleeps waiting on the disk.
//
// It also serialises transactions, which is not incidental: a group being built
// rewrites its own blocks in place, so two operations interleaving inside one
// group would each see the other's half-finished tree.
static struct sleeplock schfs_lock;
// Seconds, for inode timestamps. The kernel has no wall clock wired up, so this
// matches what fs.c does for the v1 filesystem rather than inventing a second,
// differently-wrong answer.
static uint64_t
know(void)
{
return (uint64_t)uptime();
}
// Start and finish one transaction. `changed` says whether anything was
// written, so a read does not pay for an uberblock write.
static void
ktx_begin(void)
{
acquiresleep(&schfs_lock);
}
static int
ktx_end(int changed, int r)
{
if (changed && r >= 0) {
int c = schfs_vol_commit(&kvol);
if (c != 0)
r = c;
}
releasesleep(&schfs_lock);
return r;
}
static int
kd_read(void *ctx, uint64_t blk, void *out)
{
struct schfs_kdev *d = ctx;
if (blk == 0)
return -1;
// The buffer cache addresses blocks with a uint, which caps the volume long
// before the on-disk format does. Refusing out here beats truncating and
// reading some unrelated sector, which would surface as corruption.
if (blk > 0xffffffffULL / SCHFS_BPB)
return -1;
for (int i = 0; i < SCHFS_BPB; i++) {
struct buf *b = bread(d->dev, (uint)(blk * SCHFS_BPB + i));
memmove((char *)out + i * BSIZE, b->data, BSIZE);
brelse(b);
}
return 0;
}
static int
kd_write(void *ctx, uint64_t blk, const void *in)
{
struct schfs_kdev *d = ctx;
if (blk == 0)
return -1;
if (blk > 0xffffffffULL / SCHFS_BPB)
return -1;
// Straight through the buffer cache, with no log. The xv6 log exists to make
// multi-block updates atomic; copy-on-write makes them atomic by never
// modifying a block the committed tree can reach, so logging every write
// would double the I/O to re-solve a problem the format has already solved.
//
// bread before the overwrite because the cache has no "give me this block
// without reading it" entry point. It costs a read the first time a block is
// touched and nothing afterwards.
for (int i = 0; i < SCHFS_BPB; i++) {
struct buf *b = bread(d->dev, (uint)(blk * SCHFS_BPB + i));
memmove(b->data, (const char *)in + i * BSIZE, BSIZE);
bwrite(b);
brelse(b);
}
return 0;
}
// Allocation goes through the volume's space manager. These take no ctx of
// their own because a schfs_bdev has one ctx shared by every hook and the block
// I/O above needs it for the device number.
static uint64_t
kd_alloc(void *ctx)
{
(void)ctx;
return schfs_vol_alloc(&kvol);
}
static uint64_t
kd_alloc_run(void *ctx, uint64_t n)
{
(void)ctx;
return schfs_vol_alloc_run(&kvol, n);
}
static void
kd_free(void *ctx, uint64_t b)
{
(void)ctx;
schfs_vol_free(&kvol, b);
}
// Pages for the allocation bitmap: one per 32768 blocks of volume, taken once
// at mount and never given back.
static void *
kd_palloc(void)
{
return kalloc();
}
// Try to mount a SchFS volume from `dev`. Never panics: a disk that is not
// SchFS, or is an unreadable SchFS, simply leaves the volume unmounted.
void
schfs_kmount(uint dev)
{
if (kmounted)
return;
initsleeplock(&schfs_lock, "schfs");
if (kcurblk == 0) kcurblk = kalloc();
if (kdatablk == 0) kdatablk = kalloc();
if (kctxa == 0) kctxa = kalloc();
if (kctxb == 0) kctxb = kalloc();
if (kctxc == 0) kctxc = kalloc();
if (kcurblk == 0 || kdatablk == 0 ||
kctxa == 0 || kctxb == 0 || kctxc == 0) {
cprintf("schfs: no memory for block buffers\n");
return;
}
kdev.dev = dev;
struct schfs_bdev bd;
bd.ctx = &kdev;
bd.bread = kd_read;
bd.bwrite = kd_write;
bd.balloc = kd_alloc;
bd.bfree = kd_free;
bd.balloc_run = kd_alloc_run;
struct schfs_ctx cx;
cx.a = kctxa;
cx.b = kctxb;
cx.c = kctxc;
// The uberblock scan doubles as the probe: no valid uberblock means this is
// simply not a SchFS volume, which is not an error worth reporting loudly.
// Opening with a page allocator also builds the allocation bitmap, by walking
// the tree — the only mount-time cost SchFS has, and what buys it a mount
// that cannot disagree with the filesystem it just adopted.
int r = schfs_vol_open(&kvol, &bd, &cx, kd_palloc);
if (r == -1)
return;
if (r == -4) {
cprintf("schfs: device %d is larger than %d GB and cannot be mapped\n",
dev, (int)(SCHFS_MAX_BLOCKS * SCHFS_BLKSIZE / (1024 * 1024 * 1024)));
return;
}
if (r == -5) {
cprintf("schfs: no memory for the allocation map of device %d\n", dev);
return;
}
if (r != 0) {
cprintf("schfs: device %d has a SchFS uberblock but an unreadable tree\n",
dev);
return;
}
kmounted = 1;
cprintf("schfs: mounted '%s' on device %d, %d MB, generation %d\n",
kvol.label, dev,
(int)(kvol.total_blocks * SCHFS_BLKSIZE / (1024 * 1024)),
(int)kvol.generation);
}
int
schfs_kmounted(void)
{
return kmounted;
}
// Resolve a path on the mounted volume. Returns the object id, or 0.
uint64_t
schfs_klookup(const char *path)
{
if (!kmounted)
return 0;
acquiresleep(&schfs_lock);
struct schfs_cursor c;
schfs_cursor_init(&c, &kvol.tree, kcurblk);
uint64_t r = schfs_path_lookup(&kvol, &c, path);
releasesleep(&schfs_lock);
return r;
}
int
schfs_kstat(uint64_t ino, struct schfs_inode *out)
{
if (!kmounted)
return -1;
acquiresleep(&schfs_lock);
struct schfs_cursor c;
schfs_cursor_init(&c, &kvol.tree, kcurblk);
int r = schfs_inode_get(&kvol, &c, ino, out);
releasesleep(&schfs_lock);
return r;
}
long
schfs_kread(uint64_t ino, uint64_t off, void *buf, unsigned n)
{
if (!kmounted)
return -1;
acquiresleep(&schfs_lock);
struct schfs_cursor c;
schfs_cursor_init(&c, &kvol.tree, kcurblk);
long r = schfs_file_read(&kvol, &c, ino, off, buf, n, kdatablk);
releasesleep(&schfs_lock);
return r;
}
int
schfs_kreaddir(uint64_t dir,
int (*fn)(uint64_t, uint16_t, const char *, unsigned, void *),
void *arg)
{
if (!kmounted)
return -1;
// NOTE: fn must not call back into schfs_k* -- the lock is not recursive and
// the cursor buffer is shared. Callers that need per-entry detail collect the
// entries first, then look them up after the walk.
acquiresleep(&schfs_lock);
struct schfs_cursor c;
schfs_cursor_init(&c, &kvol.tree, kcurblk);
int r = schfs_dir_iterate(&kvol, &c, dir, fn, arg);
releasesleep(&schfs_lock);
return r;
}
struct schfs_vol *
schfs_kvol(void)
{
return kmounted ? &kvol : 0;
}
/* ------------------------------------------------------------------ */
/* Write side */
/* */
/* Each of these is one transaction: everything it does is invisible */
/* until it commits, and a crash part way through leaves the volume at */
/* the state before the call. That is the entire failure story — there */
/* is no partial state to detect and nothing to roll back. */
/* ------------------------------------------------------------------ */
int
schfs_kcreate(uint64_t dir, const char *name, unsigned namelen, uint16_t type,
uint32_t mode, uint16_t major, uint16_t minor, uint64_t *out)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_create(&kvol, dir, name, namelen, type, mode, major, minor,
know(), out);
return ktx_end(1, r);
}
long
schfs_kwrite(uint64_t ino, uint64_t off, const void *buf, unsigned n)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
long r = schfs_file_write(&kvol, ino, off, buf, n, kdatablk);
// ktx_end speaks in error codes, and a write speaks in byte counts. Passing
// the count straight through told it the transaction had failed with status
// 17, which it duly reported -- and a short write is a panic one layer up.
int e = ktx_end(1, r < 0 ? (int)r : 0);
return e == 0 ? r : (long)e;
}
int
schfs_ktruncate(uint64_t ino, uint64_t size)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_file_truncate(&kvol, ino, size, kdatablk);
return ktx_end(1, r);
}
int
schfs_kunlink(uint64_t dir, const char *name, unsigned namelen)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_unlink(&kvol, dir, name, namelen);
return ktx_end(1, r);
}
int
schfs_klink(uint64_t dir, const char *name, unsigned namelen, uint64_t ino)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_link(&kvol, dir, name, namelen, ino);
return ktx_end(1, r);
}
int
schfs_krename(uint64_t olddir, const char *oldname, unsigned oldlen,
uint64_t newdir, const char *newname, unsigned newlen)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_rename(&kvol, olddir, oldname, oldlen,
newdir, newname, newlen);
return ktx_end(1, r);
}
// Write back an inode record the caller has modified — the one way to change
// mode, ownership or timestamps. The caller reads it with schfs_kstat, edits
// the struct, and hands it back.
int
schfs_ksetattr(uint64_t ino, const struct schfs_inode *in)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_inode_put(&kvol, ino, in);
return ktx_end(1, r);
}
// Flush without changing anything. Nothing needs this yet — every operation
// above commits already — but sync(2) has to mean something.
int
schfs_ksync(void)
{
if (!kmounted || kvol.readonly)
return 0;
ktx_begin();
return ktx_end(1, 0);
}
/* ------------------------------------------------------------------ */
/* Pieces the VFS needs */
/* */
/* The VFS keeps its own link count and decides when an object dies, */
/* so it needs the directory entry and the object as separate */
/* operations rather than the combined create/unlink above. Two layers */
/* counting links independently is exactly how a filesystem loses a */
/* file that something still has open. */
/* ------------------------------------------------------------------ */
// Resolve one name inside a directory. 0 if there is no such entry.
uint64_t
schfs_klookup_at(uint64_t dir, const char *name, unsigned namelen)
{
if (!kmounted)
return 0;
acquiresleep(&schfs_lock);
struct schfs_cursor c;
schfs_cursor_init(&c, &kvol.tree, kcurblk);
uint64_t ino = 0;
if (schfs_dir_lookup(&kvol, &c, dir, name, namelen, &ino, 0) != 0)
ino = 0;
releasesleep(&schfs_lock);
return ino;
}
// 1 if the directory holds no entries, 0 if it does, -1 on error.
int
schfs_kdirempty(uint64_t dir)
{
if (!kmounted)
return -1;
acquiresleep(&schfs_lock);
int r = schfs_dir_empty(&kvol, dir);
releasesleep(&schfs_lock);
return r == -2 ? -1 : r;
}
// An object with no name and no links yet. Nothing can reach it until the
// caller links it, and nothing reaches the disk until the transaction commits,
// so a caller that gives up in between leaves nothing behind.
int
schfs_kalloc_object(uint16_t type, uint32_t mode, uint64_t *out)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
uint64_t ino = schfs_alloc_objectid(&kvol);
struct schfs_inode in;
memset(&in, 0, sizeof(in));
in.type = type;
in.mode = mode;
in.nlink = 0;
in.atime = in.mtime = in.ctime = know();
in.gen = kvol.tree.generation;
int r = schfs_inode_put(&kvol, ino, &in);
if (r == 0 && out)
*out = ino;
return ktx_end(1, r);
}
int
schfs_kdiradd(uint64_t dir, const char *name, unsigned namelen, uint64_t ino,
uint16_t type)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_dir_add(&kvol, dir, name, namelen, ino, type);
return ktx_end(1, r);
}
// Remove one directory entry, leaving the object alone. The VFS decrements the
// link count itself and calls schfs_kfree when it reaches zero.
int
schfs_kdirdel(uint64_t dir, const char *name, unsigned namelen)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_dir_del(&kvol, dir, name, namelen);
return ktx_end(1, r);
}
// Release an object's data, checksums and inode record. Called from iput once
// the last name is gone and nothing has it open.
int
schfs_kfree(uint64_t ino)
{
if (!kmounted || kvol.readonly)
return -1;
ktx_begin();
int r = schfs_object_free(&kvol, ino);
return ktx_end(1, r);
}
uint64_t
schfs_kroot(void)
{
return kmounted ? kvol.root_objectid : 0;
}
/* ------------------------------------------------------------------ */
/* /proc/schfs */
/* */
/* Phase 2 has no VFS integration -- SchFS files are not reachable */
/* through open(2) yet -- so this is how the mount is observable from */
/* userland at all. It reports the volume and lists the root directory, */
/* which is enough to confirm from inside a running Schunix that the */
/* kernel really did parse an image mkschfs wrote. */
/* ------------------------------------------------------------------ */
static void
kp_puts(char *buf, int cap, int *len, const char *s)
{
while (*s && *len < cap - 1)
buf[(*len)++] = *s++;
}
static void
kp_putu(char *buf, int cap, int *len, uint64 v)
{
char tmp[24];
int i = 0;
if (v == 0) { if (*len < cap - 1) buf[(*len)++] = '0'; return; }
while (v && i < (int)sizeof(tmp)) { tmp[i++] = '0' + (v % 10); v /= 10; }
while (i--) { if (*len < cap - 1) buf[(*len)++] = tmp[i]; }
}
struct kp_ctx {
char *buf;
int cap;
int *len;
int count;
};
static int
kp_entry(uint64_t ino, uint16_t type, const char *name, unsigned namelen,
void *arg)
{
struct kp_ctx *c = arg;
c->count++;
// Only the first handful, and no per-entry stat: schfs_kreaddir holds the
// lock across the walk, so calling schfs_kstat from here would deadlock.
if (c->count > 16)
return 0;
kp_puts(c->buf, c->cap, c->len, " ");
kp_puts(c->buf, c->cap, c->len,
type == SCHFS_T_DIR ? "dir " :
type == SCHFS_T_SYMLINK ? "symlink " :
type == SCHFS_T_DEV ? "dev " : "file ");
for (unsigned i = 0; i < namelen && *c->len < c->cap - 1; i++)
c->buf[(*c->len)++] = name[i];
kp_puts(c->buf, c->cap, c->len, " #");
kp_putu(c->buf, c->cap, c->len, ino);
kp_puts(c->buf, c->cap, c->len, "\n");
return 0;
}
int
schfs_kinfo(char *buf, int cap)
{
int len = 0;
if (!kmounted) {
kp_puts(buf, cap, &len, "no SchFS volume mounted\n");
return len;
}
kp_puts(buf, cap, &len, "label: ");
kp_puts(buf, cap, &len, kvol.label);
kp_puts(buf, cap, &len, "\nblocksize: ");
kp_putu(buf, cap, &len, SCHFS_BLKSIZE);
kp_puts(buf, cap, &len, "\ntotal_blocks: ");
kp_putu(buf, cap, &len, kvol.total_blocks);
kp_puts(buf, cap, &len, "\nfree_blocks: ");
kp_putu(buf, cap, &len, kvol.free_blocks);
kp_puts(buf, cap, &len, "\ngeneration: ");
kp_putu(buf, cap, &len, kvol.generation);
kp_puts(buf, cap, &len, "\ntree_root: ");
kp_putu(buf, cap, &len, kvol.tree.root);
kp_puts(buf, cap, &len, "\nroot_objectid: ");
kp_putu(buf, cap, &len, kvol.root_objectid);
kp_puts(buf, cap, &len, "\n\nroot directory:\n");
struct kp_ctx c = { buf, cap, &len, 0 };
if (schfs_kreaddir(kvol.root_objectid, kp_entry, &c) != 0) {
kp_puts(buf, cap, &len, " (directory read failed)\n");
return len;
}
if (c.count > 16) {
kp_puts(buf, cap, &len, " ... ");
kp_putu(buf, cap, &len, (uint64)(c.count - 16));
kp_puts(buf, cap, &len, " more\n");
}
return len;
}