QSOE Systems

Release notes

QSOE 0.2

A screen, a keyboard, a writable filesystem — and two machines that can see each other's files.

September 2026 · succeeds 0.1 (June 2026) · Apache-2.0

0.1 was a system you watched over a serial cable. 0.2 is one you can sit at. A text console paints on a graphics card the firmware hands over, a USB keyboard types into it, the shell has pipelines and a two-panel file manager, and there is finally somewhere to write a file. Underneath, a second silicon target joined the first, three Ethernet drivers share one descriptor ring, and QSP — transparent distributed networking — lets one board open a path on another as if it were local.

Both variants moved together, as they are meant to. Everything in userspace is byte-for-byte identical on the two kernels; where QSOE/L lagged QSOE/N during the cycle, it is said so below rather than implied away.

QSOE/N booting on a SpacemiT K3 Pico-ITX: qrvfs mounted from NVMe, devn-dwmac link
                    up at 1000 Mb/s full duplex, the QSP station roster listing five stations, a USB
                    keyboard ready on slot 5, tmpfs mounts, and a login prompt.
QSOE/N on the SpacemiT K3 Pico-ITX — five stations on the roster, and /net/k3 is this machine.

The component set

ComponentWhat it is0.10.2
nq QSOE/N — the Skimmer microkernel and its taskman 0.170.26
lq QSOE/L — the seL4 taskman and its kernel seam 0.140.22
libc the shared C library and crt0 0.60.15
quser the shared userspace: shell, drivers, servers, utilities 0.50.14
mr-bml the bootloader (separate project, shipped alongside) 0.5.20.9

The umbrella records the tested set in component.list; make prepare checks out exactly these.

On this page

Highlights

Ten things that make 0.2 a different system to use than 0.1.

A console on the glass. devc-hficon paints a text console on a graphics controller the HFI firmware hands over — a GK208 on the Unmatched, the SoC display block on the K3 — and a login: prompt appears on the monitor. A read on that device defers rather than returning end-of-file, which is what makes a screen a terminal before any keyboard exists: a getty prints its banner once and waits, instead of looping forever on an EOF that was a lie about capability.

A USB keyboard types into it. devu-xhci finds an xHCI controller either on the PCI bus or in the device tree, enumerates a HID keyboard, and injects characters into the console's parked read. Hot-plug is rescanned while the system runs. Verified on hardware with a 1989 DEC keyboard through a PS/2 adapter.

QSP — transparent distributed networking. open("/net/node2/dev/ser1") opens the serial port on the other machine, and the resource manager over there is never told anything happened: it sees an ordinary local client. That is what makes every driver already written work across the net unmodified. The relay deliberately does not decode — exactly two message types are looked at, the open that names the resource and the close that releases it, and everything else crosses as bytes, so an application's own private protocol passes through unchanged.

A writable filesystem. fs-tmpfs is the first place in QSOE you can create a file. It reconstructs RFS — a late-1990s block-map-and-B-tree design — in fresh C, with its store as a directory of 2 MiB chunks mapped as blocks are first allocated: QSOE has no demand paging, so a 256 MiB mount must not cost 256 MiB of frames before a file exists. Names grow to 108 characters and the largest file to 259 MiB.

A second silicon target. QSOE/N boots on the SpacemiT K3 Pico-ITX — sixteen harts, pure AIA with no PLIC anywhere on the SoC, DRAM at 4 GiB, heterogeneous application and AI cores, and a firmware that runs management cores of its own. Most of that work was finding out which of the things Skimmer "knew" about a RISC-V board were facts and which were habits.

Ethernet, three controllers, one ring. devn-gem (Cadence GEM, FU740), devn-dwmac (DesignWare EQoS, K3 and VisionFive 2) and devn-virtio (QEMU) all sit behind libnetdev: one physically contiguous, uncached descriptor ring published to the client, free-running producer and consumer counters, doorbells as pulses. A frame is written once and read in place. The old read/write path was deleted rather than kept alongside, because two ways to move a frame means one of them rots.

Sessions, pipelines and job control. A machine with two consoles needs "my terminal" to mean something per-process, so taskman gained a refcounted session with a process group and a controlling terminal, and /dev/tty is answered from the asking process's session rather than from a global alias. On top of that: a | b without fork(), $(...) into a capture object, Ctrl-C that reaches the command and then the shell, and a ps that says which console a process is on and why a thread is blocked.

The two-panel file manager, two releases early. An orthodox Norton/Midnight-Commander-style manager is built into qsh — Ctrl-O enters the panels — with tagging, an F-key bar, a live command line, and qedit, a full-screen editor, behind F4. The roadmap had this at 0.4; the writable filesystem arriving early brought it forward.

A server can find out who is talking to it. Every client of every server used to be root — not as a policy, as a constant in the framework's open path. Both kernels carry the sender's identity now, each by the means it allows. QSOE/N reads the credentials at send time and delivers them with every message, so they are current: getty attaches the console as root, login drops privilege, and the shell inherits — a credential frozen at attach would have reported root for every keystroke of the session. seL4 gives a receiver a badge and nothing else, so QSOE/L stamps the identity into the connection badge at ConnectAttach, the one moment taskman can, minted send-only so the client cannot re-badge it. And the answer is acted on: fs-qrv checks the permission triads, so an ordinary user reading /etc/shadow is refused on both kernels.

su without setuid. On Unix, su is setuid-root because the credential authority is the kernel while the password database is userspace policy the kernel knows nothing about; the setuid bit exists to bridge that separation. QSOE has no separation to bridge. taskman owns the credential table and the password database, so su is an ordinary unprivileged program: it sends a name and a password, taskman checks them against its own cache and, if they match, changes the caller's credentials itself. The stored hash never leaves taskman — which is why the filesystem's permission check on /etc/shadow is a second line of defense rather than the only one — and QSOE never grows the setuid-plus-dynamic-loader combination behind Unix's ugliest privilege-escalation history. There is no setuid bit in this system, and nothing needed one.

The full change list

Everything that changed since 0.1, by area. Open the parts you want — or open them all and read it as one document.

Kernels and IPC

Skimmer, the seL4 seam, and the message primitives above both.

Skimmer — new capability

  • Wired platform interrupts, without a new syscall. QSOE_INTR_FLAGS_WIRED tells InterruptAttachThread that the number it was given is a raw controller source rather than a QSOE vector — which is what a device tree actually gives a driver.
  • The attach says which hart it bound the source to. The kernel has the hart in its hand at that moment, so returning it costs nothing, while asking afterwards costs a taskman round trip. devb-nvme aims its MSI-X table entry at the answer. The same change finally maintains irq_iid, which had been documented but never kept.
  • Zicbom cache maintenance in U-mode, where the bus is not coherent. Without it a userspace driver on the K3 cannot own a DMA descriptor at all.
  • SYS_MSG_READV and SYS_MSG_WRITEV — vectored, positional access to a parked sender's message. A relay must move a message across a wire in fragments without ever holding it whole. The slots were reserved when the syscall groups were laid out, so no numbering moved.
  • Sleepers park on their own hart's deadline queue. Each hart's comparator is armed for the earlier of its next tick and its earliest sleeper, so a 250 µs sleep is no longer rounded up to a whole scheduling tick. A thread is bound to its hart for life, so expiry is a look at the head of a list and the wake is local rather than an IPI.
  • A credential the kernel may hold but never authors. TM_PRIV_VSPACE_SET_CRED, under the same contract as the sub-opcode that stamps the pid: taskman owns the policy, the kernel holds a copy only so it can report a sender's identity to a receiver.
  • Every PCI root complex is found and published, not the first one. The K3 has more than one.
  • The video controller's handoff block is found by the kernel, and a hart the character generator runs on is never started. That is a kernel duty rather than a driver's: by the time a driver could look, the hart would already have been taken.
  • Heterogeneous cores are recorded, not hidden. The K3's eight X100 application harts and eight A100 AI harts are distinguished from the tree's own cpu-ai property, published as TAG_CPUS in the system map, and an auxiliary hart is refused nothing — placement is a policy decision, so drivers ask qsoe_sysmap_ist_runmask() instead of naming a hart.
  • Withheld harts are honored and announced. A cpu@ node whose status is not okay belongs to firmware and is neither started nor counted.
  • ConnectAttach to a non-local node answers ENETDOWN on both kernels. It used to be EINVAL on one and EHOSTUNREACH on the other, and neither was right: the host is not unreachable, there is no distributed-networking manager on this machine to reach it through.
  • SYS_CONNECT_CLIENT_INFO is deleted, not implemented. It had a number, a libc prototype and an assembly stub, and no entry in the kernel's dispatch table — so the first program to ask would have reached syscall_unimpl_panic() and taken the machine down. That emptiness was correct: a credential is not a kernel fact, so a kernel asked for one could only invent it. ConnectClientInfo is an ordinary library call over TM_REQ_GET_CRED now.
  • The kernel spinlock moved into a header of its own; five files stopped including the whole channel and message graph to reach a lock.

Skimmer — corrected

  • An interrupt attachment is torn down when its thread ends. Nothing removed an intr_table entry when its thread went away, so a driver thread that crashed left the kernel pointing at a thread that no longer existed, and the next fire panicked. That inverts the entire reason drivers live in userspace: any process allowed to attach a source could halt the machine by attaching one and exiting. Teardown now happens immediately before TDF_ZOMBIE is published, on the ending thread's own hart — which is not incidental, since an IMSIC enable bit lives in a CSR only that hart can reach.
  • A zombie thread could be scheduled and run. A thread destroyed by another had its VSpace dropped under the comment "it never runs again", but nothing took a merely RUNNABLE victim off the runqueue. Scheduled, it ran on the kernel boot page table and returned to U-mode at an address not mapped there. Reproduced on the Unmatched with a spawn loop and rapid Ctrl-C.
  • An rcvid stays positive for every generation a slot can reach. The per-slot generation was sixteen bits, so it reached bit 31 and an ordinary message arrived looking like an error — the resmgr skipped it, the kernel had already completed the rendezvous, and the sender stayed reply-blocked for good while the server went on serving everyone else, healthily and visibly. Not one message either: 32768 vanish before it wraps. Fifteen bits still gives ample ABA protection.
  • MsgReceive reports the sender's own lengths, not how much was delivered into the receiver's buffer. A relay that must move a message larger than it will ever hold needs to know how big the message is; the field that was supposed to say had been reporting the truncation instead.
  • A pulse is not a bad rcvid. The kernel sets rcvid to 0 for a pulse precisely because there is no sender parked behind it, and folding that in with rcvid < 0 printed bad rcvid=0 on every pulse that reached taskman — where the one thing that must not happen next is a reply.
  • A resource manager keys per-open state on the scoid, not on the client's coid. A coid is unique only inside the process that holds it, so two processes' fd 3 are the same number at the server and landed on one open control block. The kernel had been stamping the scoid into every message all along.
  • A U-mode fault with no usable crash stub now prints the thread, its VSpace and the installed satp before the panic. satp against v_root_pa is what separates "the space was torn down under a running thread" from "the thread is on a page table that is not its own" — and it is what found the zombie above.

QSOE/L — the seL4 seam

  • The vectored Msg* family is real. Built on two new taskman transfer directions — TM_MSG_XFER_READ and TM_MSG_XFER_WRITE — that reach a pending message positionally and leave the stash standing. They had been announcing stubs, and qsp relays through MsgReadv, so transparent distributed networking did not run on QSOE/L at all. It does now, both directions.
  • Every process gets a 64 KiB stack and a guard page. It was two pages, and qsh ran 128 bytes past the bottom while sourcing an init fragment — on this kernel a silent write into whatever happened to be below. The size is QSOE/N's, and it has to be: the same userspace runs on both kernels, so a program that works on one and dies on the other over its stack is exactly the divergence a shared userspace exists to prevent.
  • msglen was one word short — it counted the body and not the type word, so a server sizing a path from it read one byte too few, and every open() through the QSP relay came back ENAMETOOLONG.
  • dstmsglen reported the receiver's buffer, which is a number the receiver already knows. It rides bits 32..51 of the seL4 label now, plus one, so a stored zero keeps its own meaning: nothing was stated.
  • MsgReceive reported the sender's byte count as msglen, which is defined as what landed in the caller's buffer, clamped — so anything trusting the field read past its own buffer. srcmsglen carries the unclamped figure and always did.
  • The bulk-transfer stash was keyed on the client pid alone, and it names a buffer inside that client. Any process could name a client with a bulk receive in flight and push its own bytes into that client's reply buffer. The identity of the exchange is the (client, server) pair, not either half of it.
  • read() and write() chunked at 928 bytes — the raw message capacity — rather than TM_IO_MAX, which is what every resource manager sizes its buffers to. Thirty-two bytes more than the server had allocated.
  • A connection id is reported as a connection id. MsgReceive had been handing taskman's badge back as info->pid, with a comment still claiming the two were the same thing — silently mis-attributing every client a server looked at.
  • Every nonzero exit was reported as a signal. taskman stored a terminating process's exit code raw, and WTERMSIG(s) is s & 0x7f — so a child that exited 7 read as killed by signal 7, and WEXITSTATUS answered zero, while QSOE/N encoded the byte into bits 15..8. The same program reported different things on the two kernels.
  • The bulk bounce copy reaches any client VA — image frames and worker-thread stacks, not only mmap'd megapages.
  • ThreadJoin_r. The seam carried a reentrant ThreadDetach_r; join never got one, so the shared pthread_join would have found nothing to call — and a missing function here does not fail at load time, it resolves to zero and faults at pc 0.
  • A quiet boot. taskman mirrors seL4's untyped free-index arithmetic instead of probing it. There is no invocation that asks how much of an untyped is left, so the only way to find out was to retype and read the failure — and every probe printed "Untyped Retype: Insufficient memory" from the kernel, which had scrolled past every boot since May and looked exactly like the fault it is not. Boot, login and the full suite are silent now.
  • Device untypeds carve order-free where they can. A device UT's free index only advances, so mapping a high offset strands every lower one — and a driver must map a handoff block before it can learn where the array below it lives.
  • A _Static_assert that seL4_MsgMaxLength still equals QSOE_MSG_MAX_LENGTH. They are the same number by adoption rather than by construction.
Memory

Two processes can now share memory by name instead of by physical address — and memory a device writes can be asked for as uncached.

  • Publishable regions. TM_REQ_REGION_PUBLISH / MAP_REGION / REGION_REVOKE. The owner names a range by its virtual address and gets an id back; whoever may map it names the id and never an address. Until now the only way to hand memory across a process boundary was MAP_PHYS — authority by assertion, and on QSOE/L not available for RAM at all, for exactly the reason seL4 is chosen.

    Ownership is checked against the page table itself: every page of the range must be mapped in the caller's VSpace, and the whole run must be one contiguous physical range. An earlier attempt kept a ledger of everything taskman had handed out — a second copy of a truth the page table already holds, which had to be bounded and duly ran out: a USB host that allocates a page at a time filled all thirty-two entries before the login prompt. The mapping is the ownership.

    Skimmer satisfies the request by mapping the owner's frames by physical address; seL4 copies the owner's frame capabilities into the client's CSpace and maps them. The caller cannot tell.

  • Uncached memory, and contiguous runs of it. TM_REQ_ALLOC_PHYS takes a flags word and no longer stops at one page. A longer request is served as one physically contiguous run — a device walks the range itself, and pages that merely happen to be adjacent in the caller's address space are no use to it. The ceiling is 2 MiB, which is not arbitrary: QSOE/L serves this from one seL4 Mega_Page, so a driver staying under it runs unchanged on both kernels.
  • QSOE_ALLOC_UNCACHED maps the pages with Svpbmt's NC memory type — for memory a device writes behind the CPU's back in units smaller than a cache block, where no amount of cache maintenance is correct: a clean writes back a whole block, a block holds several descriptors, and it always carries a stale copy of somebody else's. On a hart without Svpbmt the request is refused, not quietly downgraded — handing back cached memory produces a driver that works until it does not, which is the failure the flag exists to end.
  • TM_REQ_MAP_FDT is retired. The device tree is well-known region QSOE_REGION_FDT now, published once at startup. The opcode and the region call differed only in naming a thing rather than an id; keeping both would have meant two mechanisms for one job, with the second being the one nobody maintained.
  • MAP_PHYS reaches device memory only on QSOE/L, deliberately: a process able to map RAM by address is a process able to map every other process.
Processes, sessions and signals

What "my terminal" means on a machine with two consoles, and what happens when something is killed.

Sessions and the controlling terminal

  • taskman gains a refcounted session — leader, process group, and the path of the controlling terminal. setsid() starts a session with no terminal; tcsetctty() names one, in that order because POSIX says so. The sharing is the whole point: a terminal claimed once in getty is seen by login, by the shell login spawns, and by everything the shell runs after that.
  • /dev/tty is answered from the asking process's session, not from a global alias. It cannot be a symlink and be correct — the name means "the terminal of whoever is asking", so one alias necessarily names the wrong device for half of a machine with two consoles. Before this, a login on the screen printed its password prompt down the serial line.
  • The terminal is stored as a path, not a descriptor. A QSOE fd is a raw coid straight onto the resource manager, so neither libc nor taskman can map one back to the name it was opened under — and the name is precisely what the pathmgr resolves. tcsetctty() is therefore named for what it does rather than borrowing a signature it cannot honor.
  • /proc reports the session, the process group and the terminal, and ps shows a TTY column with no flag asked for. On a machine with one console that column is nearly free; on a machine with two it is the first question anyone asks.
  • A pulse can be sent to a channel taskman holds no connection to, for group signal delivery: taskman learns a process's signal chid when that process registers one, but has never opened a connection to it, and should not have to mint one just to deliver an interrupt to a foreground process group.

Spawn

  • posix_spawn delivers its third argument. It never had. libc packed the environment onto the wire, the spawn handler unpacked it into a pointer array, and then dropped it, while the stack builder was fed a hardcoded empty envp. So every process on this system started with an empty environmentposix_spawn returned success, the child ran, and the variables were simply not there.
  • posix_spawn honors its file actions. With no fork() there is no child to run addopen / adddup2 / addclose in, so they are replayed into a description of the descriptor table the child will start with, and the spawn request carries that table over. An action with no meaning yet — chdir, fchdir — returns ENOSYS rather than being quietly skipped, and so does an unsupported attrp flag.
  • An inherited descriptor is the parent's open, not a new one. A resource manager keys per-open state on the id it issued, so a child given a fresh one arrives as a stranger holding a descriptor the server never opened — POSIX's shared file description turned into two unrelated ones.
  • On QSOE/L, child fd N is CSpace slot QSOE_CAP_FD_BASE + N, a reserved block of sixteen. A fixed convention rather than taskman-chosen slots, because then the child needs no table handed to it — only which fds it got, which it asks for once before main(). A CSpace cannot be read from inside.
  • A descriptor whose binding outlived its connection is not offered to a child.

Ending a process

  • A killed process actually stops running. A default-terminate signal was delivered on the process's signal thread, which called _Exit, which terminated only itself. The process was reaped on paper — waitpid returned, the parent got SIGCHLD — while every other thread kept running, still holding whatever it had claimed. A shell killed while parked reading a console left that console owned forever: the next getty opened the device, arrived as a different client, was refused EBUSY, exited, and was respawned — a loop that took the screen away permanently on behalf of a process that no longer existed.

    Three things were missing and all three are here. Cross-thread destroy accepts any BLOCKED target wherever it is parked, rather than only one parked in receive — which is why the thread that mattered, one parked in a read, could not be killed at all. The kernel frees the server's pending-sender slot for a dying client and says so with an unblock pulse carrying the rcvid. And ThreadDestroy(-1) means every other thread of the process, as it does on QNX.

  • An interrupt character reaches the command, not the shell. The terminal driver holds the foreground process group and signals it, which is where QNX puts it too — the driver is what sees the keystroke arrive, so nothing else is in a position to turn it into a signal.
  • Reserved system pulse codes are defined, and the synthetic side-channel coid window is gone — nothing ever allocated one, and the scoid does that job properly.

Credentials

  • struct _msg_info carries the sender's credentials on QSOE/N, read at send time and delivered with every message. Both halves of that are load-bearing. A server cannot ask who its client is: taskman's dispatch is a single thread, so while it is parked sending a request to a server it cannot answer that server — and taskman opens files on a filesystem in order to spawn. Asking hung the first spawn after the root filesystem mounted.
  • A server wanting POSIX's rights-fixed-at-open copies them into its per-open state, which is where that freeze belongs; a frozen one cannot be un-frozen.
  • taskman answers TM_REQ_GET_CRED on both kernels, and ConnectClientInfo is an ordinary library call over it.
  • A file belongs to whoever made it. fs-tmpfs nodes have carried uid/gid fields all along with nothing writing them.
  • On QSOE/L the credential rides in the connection badge. seL4 hands a receiver a badge and nothing else, and taskman cannot be asked mid-flight who a client is, so the identity is stamped at ConnectAttach — the one moment it can be — and the capability is minted send-only, so the holder cannot re-badge it. An id too wide for the field is refused rather than truncated: the smallest id is 0, and silently granting the superuser is not a rounding error. The trade is that the badge is fixed for the life of the connection, where QSOE/N's is read fresh on every send.
  • The credentials are acted on, not merely delivered. fs-qrv checks the permission triads against the caller on both kernels, so an ordinary user reading /etc/shadow gets EPERM — mode bits that mean something, rather than decoration on a filesystem where everybody was root.
  • su needs no setuid bit, and QSOE has none. TM_REQ_BECOME_USER carries a name and a password to taskman, which owns both the credential table and the password cache: it verifies and changes the caller's identity itself. The privileged step lives in the server that already holds the authority, so no privileged intermediary program has to exist — and the stored hash never leaves taskman.
Filesystems and resource managers

The first writable filespace, and the verbs a server needs to be told about it.

fs-tmpfs — RFS

  • QSOE has had no writable filespace. This is it. RFS is reconstructed in C from a late-1990s x86 assembly filesystem whose block allocation maps and B-tree directories were in turn adapted from David Lindauer's OS-32 kernel. The design is carried over intact; the code is fresh.
  • What made it port cleanly is that RFS names storage by block number and never by pointer, which allows the one structural change: instead of a contiguous RAM disk reserved up front, the store is a directory of 2 MiB chunks mmap'd as blocks are first allocated. An idle mount costs a handful of pages; the size is a cap against runaway growth, not a reservation.
  • The geometry is the original's proportions at modern sizes — 4 KiB blocks, a 256-byte node header and 128-byte entries land on 30 entries per node, exactly the original pair — so every split, concatenate and roll invariant carries over unchanged, while names grow from 20 to 108 characters and the largest file from 16 MiB to 259 MiB.
  • Three parts of the original were unfinished and are written here rather than translated: path resolution, reading a directory, and the B-tree's root collapse on removal. Two further departures are deliberate: a directory's head block number never changes, and "." and ".." are synthesized rather than stored.

The resource-manager framework

  • _IO_SETATTR — chmod, chown and truncate are the same operation on a different field, so they are one verb. It reaches the framework's adjust() method, which had been defined, implemented by fs-tmpfs, and unreachable for want of a message. Numbered inside the _IO_ range so one frame serves twice: on a connection the badge names the object; with a path taskman resolves and forwards.
  • Removal reaches a Provider. tm_unlink used to answer ENOSYS for any path an external resource manager owned, so no filesystem could remove anything however writable. It forwards the caller's frame to the owning server now, the same shape spawn-from-filesystem already uses, and returns the server's own errno. mkdir(), rmdir() and unlink() reach a server on both kernels.
  • _IO_CONTROL dispatches to a Provider's control() method, and devcontrol() is the client end — what QSOE has instead of ioctl(). Three unsigned values rather than one signed integer with all three packed into it: the subsystem addressed, the direction the bytes travel, and that subsystem's own command ordinal. The direction is checked against the byte counts before the server is entered, so a caller claiming to read while offering no capacity is refused at the boundary; and because the subsystem is a field rather than a prefix baked into the number, two providers of different kinds may both call their first command 1. That surface is much of what made the shared network ring possible.
  • One catalog names the parts of the system, and both users of a name share it. <qsoe/ctlcodes.h> numbers the subsystems — kernel, taskman, character devices, terminals, video, input, PCI, block, USB, filesystems, network, QSP, log, power, RTC, random — in a deliberately sparse 16-bit space, so a new driver family lands beside its relatives rather than at the end. Those numbers are the slog major codes: <sys/slogcodes.h> defines its _SLOGC_* names in terms of them. A log record and a control command about the same subsystem cannot disagree about what that subsystem is called.
  • Pipes are an ordinary resource manager that clients open twice, with capture objects so a shell can read a command's output for $(...). taskman's TM_REQ_PIPE_CREATE is deleted on both kernels.
  • An end lives until its last holder closes: taskman gives an inherited fd the parent's connection id on purpose, so parent and child share one binding and the server sees one scoid.
  • libressrv locks its deferred-call pool.
  • /dev/urandom, served by taskman over a SplitMix64 generator in libtaskman, seeded from CLOCK_MONOTONIC. Nothing in QSOE could get an unpredictable byte before. /dev/random is deliberately absent — it would have to be a lie or a stub that blocks forever, and the honest thing is for the name not to resolve until there is something real behind it.
  • /sys grows. The hostname, the mount table, and the fields uname reads — so the release string comes from git describe and no literal needs bumping anywhere.
  • ls can tell a failed readdir from the end of a directory.
Drivers and hardware

A screen, a keyboard, three network controllers, and a second board to run them on.

The SpacemiT K3 Pico-ITX

  • A board concept. Kconfig gains a Target boards menu (PLAT_QEMU_VIRT, PLAT_SIFIVE, PLAT_K3), several selectable at once. The board turns out to be a link-time property alone: once the kernel derives its own physical load address at run time, no compiled object carries one, so every board shares one set of objects and differs only in the link step.
  • The IMSIC interrupt-file stride is derived, not assumed. A file is one page, but consecutive harts' files are not one page apart: each hart owns 2^guest-index-bits pages, and the K3 declares 6, making its real stride 256 KiB. Every hart above hart 0 would have had its MSIs written into the wrong page — nothing faulting, the interrupts simply never arriving.
  • The S-mode APLIC is identified by its msi-parent, the IMSIC it sends to, instead of by the console UART's interrupt-parent. The old coupling made the interrupt controller depend on recognizing a serial port: an unknown console left no APLIC, the PLIC backend was selected, and plic_init panicked — on a SoC that has no PLIC, with nothing in the message pointing at the UART that caused it.
  • The identity map no longer starts at a compile-time constant. With DRAM at 4 GiB the stale value paved over TASKMAN_VA_BASE with megapage leaves and panicked at the first user blob.
  • Physical-memory tables sized for a SoC with companion firmware. Eight exclusions came from QEMU virt and the FU740, which use five between them; the K3 needs 29 before the first page table exists. Now 64, and an overflow names the region that did not fit.
  • head.S bounds-checks the hartid the firmware enters on, in both the primary and secondary paths, and announces over SBI DBCN before halting. A failed sbi_hart_start is remembered, so a refused start no longer surfaces 500 ms later as a timeout on a hart that was never started.
  • The initrd need not lie inside the RAM the kernel manages; mem_info describes the bank the kernel manages rather than whichever memory@ node was parsed last, and says out loud when higher banks go unused. CONFIG_MAX_CPU_COUNT defaults to 16.
  • The K3's console: spacemit,k1-uart / intel,xscale-uart / snps,dw-apb-uart are recognized beside ns16550a, and the FDT walker names the part's flavor rather than answering yes or no. reg-shift and reg-io-width are parsed and published — a byte access at the right address on this part reads a byte lane, not the register — and the xscale flavor sets IER.UUE, without which the block is simply off while every register still reads back plausibly.
  • The K3's RTC (mrvl,mmp-rtc: one MMIO register holding Unix seconds, with no bus protocol in front of it) is published through the sysmap beside goldfish and the DA9063.

Console and input

  • devc-hficon — a console, not a display. Repaint goes through the controller's GPFIFO. A read defers instead of returning EOF, and the input seam is push, not pull: the keyboard driver, whenever it comes up, opens the console's published path and injects characters — not scancodes, because translating what the hardware speaks belongs in the driver that understands that hardware.
  • Who owns the blink. The K3's character generator runs on a hart of its own and toggles the blink phase itself; writing that field from the driver made it the second writer on a word it does not own. The handoff block's owned_hart_mask says which controllers are like that.
  • The terminal knows its size. devc-hficon answers TIOCGWINSZ from the character grid the firmware handed over — it is the only party that knows, since the grid is mode-dependent and 80x24 is a guess that is sometimes simply wrong. TIOCSWINSZ is deliberately absent: a caller announcing a different size would be describing something that is not on the glass.
  • devu-xhci (renamed from devu-dwc3, for what it implements rather than the one core it was written on) class-matches an xHCI on the PCI bus and falls back to the device tree. A controller found on PCI has BAR0 mapped, memory decode and bus mastering enabled, and its PCI handle kept. Board-specific glue is gated on the board. Hot-plug rescanning every three seconds by default, with -p to change the period or turn it off.
  • sysact — the keyboard reports a chord and sysact decides from sys_actions.conf what it means, so a new binding is a line in a table and never a change to a driver. devu-xhci sends any Alt-held keystroke as a pulse rather than injecting text, which also fixes Alt being ignored outright: Ctrl-Alt-Del used to inject whatever Ctrl-Delete produced.
  • devc-ser8250 takes its register shape and part quirks from the sysmap, inherits the firmware's baud rate instead of re-bauding, and does not reset the transmit FIFO when adopting the console. It had set the divisor to 1 — ignored by QEMU, about 928 kbaud at the K3's 14.857 MHz UART clock — so the board's first successful boot ran the entire kernel, taskman and this driver's own spawn correctly, then turned to line noise the instant the driver touched its own port.
  • devc-sersifive gained job control, so Ctrl-C reaches the foreground group.

PCI and storage

  • pci-server drives several root complexes, gets BAR addresses right, and closes the bridge windows it never allocates from — which is what the K3's MSI-X turned out to depend on.
  • The firmware's layout is kept where firmware made one. Reassigning BARs underneath a display controller the firmware had handed over broke the handoff; the policy is now per-board and asks the narrower question it always meant — is the handed-over display controller behind PCI.
  • devb-nvme aims its MSI-X table entry at the hart the kernel reports for its interrupt attach, rather than at a plausible zero.
  • Drivers ask the sysmap where to put an interrupt-service thread instead of naming hart 1.
  • The IRQ listings stopped printing numbers that contradict each other; the interrupt identity is named honestly and its hart reported.
Networking — QSP

The system had no networking of any kind at 0.1 — no sockets, no stack, no driver, not a line of it.

  • The wire. libvirtio (the MMIO register block, the status handshake, 64-bit feature negotiation and the split virtqueue, in both transport versions), devn-virtio for QEMU, devn-gem for the Unmatched's Cadence GEM, and devn-dwmac for the DesignWare EQoS on the K3 and the VisionFive 2.
  • The shared descriptor ring. A network driver used to be a resource manager like any other — read() a frame out, write() one back — which put every frame through a message round trip with an 896-byte payload cap, so a 1514-byte Ethernet frame arrived in pieces that had to be reassembled by the very layer trying to measure the wire. At line rate that is some eighty thousand round trips a second. libnetdev replaces it: one physically contiguous uncached region, published; free-running producer and consumer counters; doorbells as pulses; a frame written once and read in place.
  • QSP circuits and transactions. open("/net/node2/dev/ser1") opens node 2's serial port, and reading and writing the descriptor reach it. Each station that comes up is registered at /net/<callsign>; longest-prefix resolution sends the directory to the framework and everything beneath a station to the relay, decided once at open() and never again per message.
  • The relay does not decode. The framework exists to turn a request into a typed method call, which is the one thing a transparent relay must never do. Exactly two message types are looked at, and everything else is forwarded as bytes without being understood — so an application's own private protocol crosses unchanged.
  • The division of labor between threads is forced rather than chosen. MsgReadv and MsgWritev act on the message the calling thread received, so the wire thread — which owns the interface and must never block — cannot touch a client's message at all. Work arriving for us is queued to a separate pool, because both ends of a two-station net run both roles and a station must not wait on itself.
  • Output split by audience. Which station this is, who else is out there, and when either changes stay on the console: on a net that is the operational picture and not diagnostics. Circuits, relayed messages, fragment boundaries and failed transactions go to slog. net_dbg and net_trace sit below the default floor, so they cost a load and a compare until asked for — which matters on this path specifically, since the relay is in the middle of every remote message.
  • A development station on the roster. qspstation speaks QSP from Linux user space, so a board can reach the build tree directly as /net/<station> instead of waiting for a program to be staged into an image. workday and qfetch fetch the day's working set over that link, into tmpfs mounts placed where they are wanted.
  • Diagnostics: qspinfo, netloop, netmirror, netpoke, and a host-side frame sniffer for the emulated segment.
The C library

One libc.so, around 85% shared at the source level between the two kernels.

  • The POSIX surface a serious program expects to find — the sweep that made the file manager, the editor and the network tools compile without local workarounds.
  • R_RISCV_COPY relocations are applied — and bound. A non-PIE executable that imports a data object from a shared library reaches it through a private copy in its own .bss, filled by the loader. Applying it is only half of what ELF asks: the copy must then become the definition for every other reference to that name, including the defining library's own, or the two drift apart the moment either side writes. Both halves now happen, on both kernels.
  • crt0 calls exit() rather than _exit(), so atexit handlers and stdio flushing happen where a program expects them.
  • The run-time loader is gone, because nothing was running it. taskman loads the program, libc.so and every library named by DT_NEEDED, resolves them against one another and jumps to the entry point; probing showed the separate loader's relocation walker had never fired, in any process, on either kernel. PT_INTERP is gone from every binary — an interpreter path names who would load a program on a system that delegates, and this one does not — and lib/ld-qsoe.so.1 no longer ships.
  • The vectored Msg* family carries QNX 8's signatures type for type (rcvid_t, size_t, ssize_t, const iov_t * in both directions), and the plain forms call their own _r variants rather than re-issuing the syscall.
  • pipe(), pipe2() and capture objects; posix_spawn file actions; mkdir(), rmdir(), unlink(), chmod(), chown(), truncate(); uname() answering from /sys; gethostname() / sethostname(); the region and physical-allocation calls; devcontrol(); setsid(), tcsetctty() and the foreground group; TIOCGWINSZ.
  • ioctl() is retired. Its defects are structural rather than incidental: one signed integer carrying the subsystem, the direction and the command, packed by macros that different systems pack differently — so a request code is not portable, the direction is a claim nobody verifies, and a code above INT_MAX arrives negative. devcontrol() carries the same three things as three unsigned values.
  • slog gained a verbosity threshold tested before the record is built, so a suppressed line costs a load and a compare rather than a format. Timestamps are monotonic, the ring can be drained by several readers, and sloginfo -c empties it. A major code for USB was added.
  • pthread's static mutex and condvar initializers were wrong and are fixed. ENETDOWN and ENOTEMPTY exist. gethostid is deleted. execve's announcing stub now lives once in the shared libc, since there is nothing OS-dependent about not having exec.
  • U.S. spelling throughout the comments, and the current copyright address on the files that predate it.
Shell and utilities

The part a person actually touches.

qsh

  • Pipelines without fork(). a | b works: every element is a posix_spawn and all but the last must be left running while the next starts — a pipe holds one page, so a producer run to completion first would wedge.
  • $(...) runs into a capture object rather than a pipe, so a command substitution collects its output without a second process to drain it.
  • Ctrl-C stops a loop. The console driver signals the foreground process group and the shell gives that group to the child, so the child ended and the shell heard nothing — while true; do cmd; done quietly began its next iteration. A foreground child killed by an interrupt now interrupts the shell.
  • TAB completes a command or a file name, with the candidate listing sized from the terminal's real width.
  • COLUMNS and LINES are materialized at startup and the geometry comes from the terminal rather than a pinned 80x24 — but they are deliberately not exported, because a child must ask its own terminal and a size passed through the environment is a snapshot nothing can correct.
  • Ctrl-C and ^D behave at an interactive prompt.

The file manager and the editor

  • An orthodox two-panel file manager is built into qsh as libcommander.a; Ctrl-O enters the panels and COMMANDER=1 brings the shell up in them. Directory navigation, descend and ascend, tagging, an F-key bar, a live command line that runs in the active panel's directory, a status line, and type colors. The working directory follows the active panel on exit.
  • qedit — a full-screen editor over a dynamic line array: load and save with CR/LF handling, insert, split and join, scrolling, and column-preserving vertical motion. Launched by F4 and usable standalone. One terminal key decoder is shared between the two.

Utilities

  • New: uname, mount, df, mkdir, rm, rtc, time, qfetch, workday, sysact, and the network tools.
  • mount reports rather than mounts — there is no mount(2) here, a filesystem attaches by registering a name — and settles which names are filesystems by asking each one rather than from a list that would go stale. df asks the same question; both filesystems have had the numbers all along, printed once at mount and then unreachable.
  • ps shows a TTY column and says why a thread is blocked and on what — REPLY(0x10001) for a thread waiting on a server that owes it an answer, SLEEP(250ms) for one that is merely early.
  • sloginfo drains the whole ring rather than the first batch of it.
  • getty claims a session and a controlling terminal on every line.
  • login refuses on geteuid() before it prompts, so a caller who may not become somebody else never reaches a password read at all. See Bugs worth naming for why that ordering is the whole fix.
Boot, build and tooling
  • A self-booting QEMU disk image. nvme.img is a GPT UEFI disk: QEMU's firmware runs mr-bml from its ESP, and mr-bml's menu starts either variant straight off the disk — no -kernel juggling. run-nvme.sh wraps the right machine, firmware and disks for each variant and takes NVME_IMG / VIRTIO_IMG overrides.
  • The image is assembled by default, and everything the tree builds is staged onto it — including the drivers sysinit starts, which had been expected on a disk nothing put them on.
  • mr-bml 0.5.2 → 0.9: the loader this release is tested with, and the one the K3 requires — a board that keeps all of its memory above 4 GiB. Its own changelog carries the rest.
  • A changed header rebuilds what it touches. No quser component tracked header dependencies, so a shared struct could grow and leave every object that was not itself edited compiled against the old layout — two halves of the system disagreeing about where a field is. The cause was -MMD where -MD was needed: QSOE headers arrive through -isystem, and -MMD omits exactly those. A program is also relinked when a library it links against changes.
  • The boot archive is asked for every time. With file prerequisites alone, make compared the archive against libc and the loader, found it newer, and skipped the recipe — so a rebuilt userland never reached the image. Not hypothetical: it shipped a five-hour-old userland to a board, including a libc.so predating symbols the on-disk programs were already linked against, with the kernel and taskman in that image current.
  • A two-machine emulator mode. emu.sh brings up two QEMU instances on one shared Ethernet segment, so a protocol that only means anything between two machines can be developed without two boards. It takes EMU_EXTRA, and refuses fewer than two harts — the character generator needs one of its own.
  • Host tools: mkgpt.py (the GPT image builder), mkubootenv.py, nvme-mrbml.cfg, qspsniff.py, qspstation.c, and a CC wrapper that assembles QSOE through rvasm.
  • component.list records the tested component set for every tag; the umbrella README and ROADMAP were rewritten around the new 1.0 gate, and documentation moved to a centralized repository.
Bugs worth naming

Six defects that shipped in 0.1 and are closed in 0.2. What they had in common: none of them failed cleanly. Each answered plausibly and wrongly, which is the expensive kind.

  • Any process could halt the machine by attaching an interrupt and ending. An entry in the kernel's interrupt table is a pointer to a thread, and the dispatcher schedules it. Nothing removed that entry when the thread went away: a driver thread that crashed, or simply returned, left the kernel pointing at a thread that no longer existed, and the next fire panicked. That inverts the entire reason drivers live in userspace. Teardown now happens immediately before the thread is published as a zombie, on the ending thread's own hart — which is not incidental, since an IMSIC enable bit lives in a CSR only that hart can reach.
  • login was a password oracle. From an ordinary user's shell, login root with the correct password ended the session and a wrong one did not — so an unprivileged user could test root's password and read the answer off the difference. The eviction was not the credential switch failing, which is what it looked like: it was ksh's login=exec login alias, inherited wholesale, which turns every ordinary refusal into a logout. Closed by refusing on geteuid() before the prompt, so no password is read and the two outcomes cannot differ.
  • The 32769th message on a connection slot vanished, and its sender waited for ever. The per-slot generation counter was sixteen bits wide inside a signed 32-bit rcvid, so it reached bit 31 and an ordinary message arrived looking like an error. The server skipped it; the kernel had already completed the rendezvous, so that sender stayed reply-blocked for good while the server went on serving everyone else, healthily and visibly. Fifteen bits still gives ample protection against a reused slot.
  • A thread destroyed by another could be scheduled and run. The victim was zombified and had its address space dropped under the comment "it never runs again" — but nothing took a merely runnable victim off the run queue. Scheduled, it ran on the kernel boot page table and returned to U-mode at an address not mapped there. Reproduced on the Unmatched with a spawn loop and rapid Ctrl-C.
  • Every program that returned from main skipped its own exit. crt0 called _exit, and C says returning from main is calling exit: atexit handlers never ran, stdio never flushed, and descriptors were never handed back to the servers holding them. The last one is what bites — a producer in a | b ended without telling the pipe, so the consumer waited for an end-of-file that could not arrive.
  • Every process spawned on QSOE/N started with an empty environment. The spawn handler built the child's initial stack from a literal empty vector instead of the one its caller passed, so login could set USER and HOME, spawn a shell, and the shell would have neither. QSOE/L had been delivering it all along, which is exactly the kind of divergence a shared userspace exists to make visible.

Open every section · the same ground is covered in the per-component CHANGELOG.md files in the source repositories.

Known gaps

Stated rather than implied away. These are the things 0.2 does not do, and most of them have a release number against them.

  • There is no loading on demand. taskman binds every library a program names at the moment it spawns it; dlopen() is declared and unimplemented, so a program that calls it fails at link time rather than surprising anyone at run time. Two foundations wait behind it, and 0.3 states them: library text is copied per process rather than shared, and every load is serialized through taskman's single dispatch.
  • A credential on QSOE/L is fixed when the connection is made. The badge is stamped at ConnectAttach and cannot be restamped, so a process that changes identity is still reported as it was on connections it already holds. QSOE/N reads the identity on every send.
  • poll() is a stop-gap that reports readiness unconditionally, and announces itself as such on first call. The follow-up read() blocks inside the resource manager until a byte actually arrives, which gives the line editor functional interactive behavior without a real readiness layer. Pulse-based per-fd readiness is the real answer.
  • A thread spinning in U-mode without ever blocking cannot be killed. The blocked half of that shipped; the spinning half is the ThreadCancel work package.
  • fs-tmpfs cannot truncate to a smaller nonzero size — a partial shrink returns EINVAL. Truncate to zero and full removal work.
  • A deferred reply can still park a hand-rolled server. QSP escaped this by moving to the shared ring; consoles and pipes still ride the framework path.
  • MSI for the xHCI is polled, every 8 ms, because a controller found on PCI has no wired source to attach. For a keyboard this costs nothing anyone can perceive.
  • There is no deep idle on the K3. An idle hart issues a plain wfi, and the vendor firmware clears every core and cluster power-down vote, so that parks the hart without power-gating it. Asking for a real suspend state means SBI HSM suspend, driving the idle-states the device tree already describes.
  • QSOE/L cannot yet have both a handed-over console and an NVMe root on the Unmatched. The firmware-window overlay that makes the console reachable puts devb-nvme's BAR0 out of reach, because that board's PCI apertures share device untypeds. A console must not cost the root filesystem.
  • The K3 is QSOE/N only — it is too new for seL4.

Getting it

Pre-built images are published at github.com/qsoe-dev/dl; the front page lists what each file is and how to run it under QEMU or install it on a board. To build from source:

git clone https://gitlab.com/qsoe/os
cd os
make prepare   # fetch the 0.2 component set (see component.list)
make           # build both variants: QSOE/N, then QSOE/L
make dist      # optional: the QEMU disk images

The manuals — Design.pdf, UserGuide.pdf, ProgrammingBook.pdf, AppPortingGuide.pdf, Networking.pdf and, new in 0.2, LibcReference.pdf — are published alongside each release at github.com/qsoe-dev/doc. Documentation is a release gate: no version ships before the manuals are brought up to the tree.

Next is 0.3 — writes in qrvfs: O_CREAT/O_TRUNC, create and unlink, realpath and symlink resolution, and QSOE/L moving to the July 2026 seL4 release. A disk that is only read can afford to be approximate; a disk that is compiled on cannot — and 0.6 compiles on it.

Back to QSOE Systems