Release notes
QSOE 0.2
A screen, a keyboard, a writable filesystem — and two machines that can see each other's files.
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.
/net/k3 is this machine.The component set
| Component | What it is | 0.1 | 0.2 |
|---|---|---|---|
nq |
QSOE/N — the Skimmer microkernel and its taskman | 0.17 | 0.26 |
lq |
QSOE/L — the seL4 taskman and its kernel seam | 0.14 | 0.22 |
libc |
the shared C library and crt0 |
0.6 | 0.15 |
quser |
the shared userspace: shell, drivers, servers, utilities | 0.5 | 0.14 |
mr-bml |
the bootloader (separate project, shipped alongside) | 0.5.2 | 0.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_WIREDtellsInterruptAttachThreadthat 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-nvmeaims its MSI-X table entry at the answer. The same change finally maintainsirq_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_READVandSYS_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-aiproperty, published asTAG_CPUSin the system map, and an auxiliary hart is refused nothing — placement is a policy decision, so drivers askqsoe_sysmap_ist_runmask()instead of naming a hart. - Withheld harts are honored and announced. A
cpu@node whosestatusis notokaybelongs to firmware and is neither started nor counted. ConnectAttachto a non-local node answersENETDOWNon both kernels. It used to beEINVALon one andEHOSTUNREACHon 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_INFOis 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 reachedsyscall_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.ConnectClientInfois an ordinary library call overTM_REQ_GET_CREDnow.- 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_tableentry 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 beforeTDF_ZOMBIEis 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.
MsgReceivereports 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 < 0printedbad rcvid=0on 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
satpbefore the panic.satpagainstv_root_pais 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_READandTM_MSG_XFER_WRITE— that reach a pending message positionally and leave the stash standing. They had been announcing stubs, andqsprelays throughMsgReadv, 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
qshran 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. msglenwas 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 everyopen()through the QSP relay came backENAMETOOLONG.dstmsglenreported 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.MsgReceivereported the sender's byte count asmsglen, which is defined as what landed in the caller's buffer, clamped — so anything trusting the field read past its own buffer.srcmsglencarries 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()andwrite()chunked at 928 bytes — the raw message capacity — rather thanTM_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.
MsgReceivehad been handing taskman's badge back asinfo->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)iss & 0x7f— so a child that exited 7 read as killed by signal 7, andWEXITSTATUSanswered 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 reentrantThreadDetach_r; join never got one, so the sharedpthread_joinwould 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_assertthatseL4_MsgMaxLengthstill equalsQSOE_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 wasMAP_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_PHYStakes 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_UNCACHEDmaps 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_FDTis retired. The device tree is well-known regionQSOE_REGION_FDTnow, 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_PHYSreaches 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/ttyis 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. /procreports the session, the process group and the terminal, andpsshows 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_spawndelivers 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 emptyenvp. So every process on this system started with an empty environment —posix_spawnreturned success, the child ran, and the variables were simply not there.posix_spawnhonors its file actions. With nofork()there is no child to runaddopen/adddup2/addclosein, 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 unsupportedattrpflag.- 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 beforemain(). 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 —waitpidreturned, 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_infocarries 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_CREDon both kernels, andConnectClientInfois an ordinary library call over it. - A file belongs to whoever made it.
fs-tmpfsnodes have carrieduid/gidfields 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-qrvchecks the permission triads against the caller on both kernels, so an ordinary user reading/etc/shadowgets EPERM — mode bits that mean something, rather than decoration on a filesystem where everybody was root. suneeds no setuid bit, and QSOE has none.TM_REQ_BECOME_USERcarries 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'sadjust()method, which had been defined, implemented byfs-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_unlinkused 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()andunlink()reach a server on both kernels. _IO_CONTROLdispatches to a Provider'scontrol()method, anddevcontrol()is the client end — what QSOE has instead ofioctl(). 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'sTM_REQ_PIPE_CREATEis 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 fromCLOCK_MONOTONIC. Nothing in QSOE could get an unpredictable byte before./dev/randomis 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./sysgrows. The hostname, the mount table, and the fieldsunamereads — so the release string comes fromgit describeand no literal needs bumping anywhere.lscan tell a failedreaddirfrom 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-bitspages, 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'sinterrupt-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, andplic_initpanicked — 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_BASEwith 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.Sbounds-checks the hartid the firmware enters on, in both the primary and secondary paths, and announces over SBI DBCN before halting. A failedsbi_hart_startis 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_infodescribes the bank the kernel manages rather than whichevermemory@node was parsed last, and says out loud when higher banks go unused.CONFIG_MAX_CPU_COUNTdefaults to 16. - The K3's console:
spacemit,k1-uart/intel,xscale-uart/snps,dw-apb-uartare recognized besidens16550a, and the FDT walker names the part's flavor rather than answering yes or no.reg-shiftandreg-io-widthare parsed and published — a byte access at the right address on this part reads a byte lane, not the register — and the xscale flavor setsIER.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_masksays which controllers are like that. - The terminal knows its size.
devc-hficonanswersTIOCGWINSZfrom 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.TIOCSWINSZis deliberately absent: a caller announcing a different size would be describing something that is not on the glass. devu-xhci(renamed fromdevu-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-pto change the period or turn it off.sysact— the keyboard reports a chord and sysact decides fromsys_actions.confwhat it means, so a new binding is a line in a table and never a change to a driver.devu-xhcisends 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-ser8250takes 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-sersifivegained job control, so Ctrl-C reaches the foreground group.
PCI and storage
pci-serverdrives 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-nvmeaims 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-virtiofor QEMU,devn-gemfor the Unmatched's Cadence GEM, anddevn-dwmacfor 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.libnetdevreplaces 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 atopen()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.
MsgReadvandMsgWritevact 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_dbgandnet_tracesit 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.
qspstationspeaks 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.workdayandqfetchfetch 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_COPYrelocations 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.crt0callsexit()rather than_exit(), soatexithandlers 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.soand every library named byDT_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_INTERPis gone from every binary — an interpreter path names who would load a program on a system that delegates, and this one does not — andlib/ld-qsoe.so.1no 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_rvariants rather than re-issuing the syscall. pipe(),pipe2()and capture objects;posix_spawnfile 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 aboveINT_MAXarrives 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 -cempties it. A major code for USB was added. pthread's static mutex and condvar initializers were wrong and are fixed.ENETDOWNandENOTEMPTYexist.gethostidis 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 | bworks: every element is aposix_spawnand 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; donequietly 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.
COLUMNSandLINESare 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
^Dbehave at an interactive prompt.
The file manager and the editor
- An orthodox two-panel file manager is built into
qshaslibcommander.a; Ctrl-O enters the panels andCOMMANDER=1brings 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. mountreports rather than mounts — there is nomount(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.dfasks the same question; both filesystems have had the numbers all along, printed once at mount and then unreachable.psshows 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.sloginfodrains the whole ring rather than the first batch of it.gettyclaims a session and a controlling terminal on every line.loginrefuses ongeteuid()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.imgis a GPT UEFI disk: QEMU's firmware runsmr-bmlfrom its ESP, and mr-bml's menu starts either variant straight off the disk — no-kerneljuggling.run-nvme.shwraps the right machine, firmware and disks for each variant and takesNVME_IMG/VIRTIO_IMGoverrides. - The image is assembled by default, and everything the tree builds is
staged onto it — including the drivers
sysinitstarts, 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
-MMDwhere-MDwas needed: QSOE headers arrive through-isystem, and-MMDomits 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.sopredating symbols the on-disk programs were already linked against, with the kernel and taskman in that image current. - A two-machine emulator mode.
emu.shbrings 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 takesEMU_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 throughrvasm. component.listrecords the tested component set for every tag; the umbrellaREADMEandROADMAPwere 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.
loginwas a password oracle. From an ordinary user's shell,login rootwith 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'slogin=exec loginalias, inherited wholesale, which turns every ordinary refusal into a logout. Closed by refusing ongeteuid()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
mainskipped its own exit.crt0called_exit, and C says returning from main is callingexit: 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 ina | bended 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
logincould setUSERandHOME, 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
ConnectAttachand 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-upread()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-tmpfscannot 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 theidle-statesthe 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.