From 5149adf7aeeebd081c48a8af7e501e99ca636dfa Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 16:53:23 +0200 Subject: [PATCH] feat: extend syscall with LoadedImage and loadImageByPath --- src/syscall.c | 195 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 193 insertions(+), 2 deletions(-) diff --git a/src/syscall.c b/src/syscall.c index 8308620..a1388cc 100644 --- a/src/syscall.c +++ b/src/syscall.c @@ -101,11 +101,202 @@ static int64 doRead(uint64 fd, uint64 buffer, uint64 length) return vfsRead((int)fd, (void*)buffer, length); } +typedef struct LoadedImage { + uint64 entry; + uint64 stackTop; + uint64 brkStart; +} LoadedImage; + +/* Shared by doExec and doSpawn: resolves `path` through the VFS, loads its + LOAD segments via elfLoad (which maps them at whatever fixed virtual + address they were linked at -- see elf.c), then derives and maps a fresh + stack for that slot from nothing but the ELF's own entry point (see + elfSlotStackTop/elf.h). Leaves the caller's thread state untouched; + callers decide what to do with a successfully loaded image. */ +static bool loadImageByPath(const char* path, LoadedImage* out) +{ + VfsNode* node = vfsLookup(path); + if (!node) + return false; + /* Real EACCES-shaped refusal: a file without the right exec bit for the + CALLING thread's identity (vfsPermitted, owner-vs-other-vs-root) can + never be exec'd or spawned, exactly like real Unix's own execve() + check -- jobctl.c's own jobctlSpawn (the shell's separate, parallel + loading path) enforces the identical check the identical way. */ + if (!vfsPermitted(vfsMode(node), vfsUid(node), vfsGid(node), currentThreadUid(), currentThreadGid(), fsModeOwnerExec)) + return false; + + const void* image = vfsFileData(node); + uint64 size = vfsSize(node); + uint64 entry = elfLoad(image, size); + if (entry == 0) + return false; + + out->entry = entry; + out->stackTop = elfSlotStackTop(entry); + out->brkStart = elfSlotBrkStart(entry); + + return pagingMapUserRangeFlags(out->stackTop - execStackSize, execStackSize, pageNoExecute); +} + +/* execve()-lite: replaces the calling thread's own program image with a + different one, in place -- same pid, same open file descriptors (POSIX's + default, no O_CLOEXEC support to opt out of here), new code/stack/brk. + Mutates the live interrupt frame's rip/rsp directly (the same trick + threadCreateUser uses to bootstrap a brand new thread's very first + frame): on success this syscall does not "return" to the caller in any + meaningful sense, since regs->rip now points at the new image's entry + point instead of back into the old one's code -- exactly real exec() + semantics. General-purpose registers are zeroed so the new program never + observes leftover state from a syscall it never made. Failure (bad path, + not a valid ELF, thread table/frame mapping issue) leaves regs untouched + apart from the -1 return value: the caller keeps running its old image, + also matching real exec(). + + Also reclaims the OLD image's slot: frostbyte's flat single-address-space + design (see doSpawn below) means a program that exec()s into a binary + linked at a different fixed base -- the common case, since two distinct + binaries can never share a slot -- would otherwise leak its entire + elfSlotSize worth of physical frames every time, forever. Skipped only + when the new image happens to land in the SAME slot as the old one (a + binary exec()ing itself): unmapping first would free frames the new + image's own loadImageByPath call had already mapped moments earlier. + + argv/argc are already kernel-side copies (copyUserArgv, called from the + sysExec dispatch case below, BEFORE this runs) -- critical ordering, + since they may point into the OLD image's own memory (a process's argv + commonly lives in its own .rodata/stack), which this function is about + to unmap out from under it. */ +static bool doExec(const char* path, const char* const* argv, int argc, Registers* regs) +{ + uint32 pid = currentThreadId(); + uint64 oldSlotBase = threadUserSlotBase(pid); + + LoadedImage img; + if (!loadImageByPath(path, &img)) + return false; + + uint64 initialRsp; + if (!elfBuildInitialStack(img.stackTop, execStackSize, argv, argc, &initialRsp)) + return false; + + uint64 newSlotBase = elfSlotBase(img.entry); + if (oldSlotBase != 0 && oldSlotBase != newSlotBase) { + uint64 stackProbe = oldSlotBase + elfSlotStackOffset - 1; + bool wasMapped = pagingResolve(stackProbe) != 0; + + pagingUnmapUserRange(oldSlotBase, elfSlotSize); + + bool nowFreed = pagingResolve(stackProbe) == 0; + kprintf("[exec] pid=%u freed old slot %p (was-mapped=%s now-freed=%s) -> %s\n", + pid, (void*)oldSlotBase, wasMapped ? "yes" : "no", nowFreed ? "yes" : "no", + (wasMapped && nowFreed) ? "exec-old-slot-freed-ok" : "exec-old-slot-freed-FAIL"); + } else if (oldSlotBase != 0) { + /* Self-exec (or the pid==0/no-prior-slot case, unreachable for a + real ring-3 caller): the branch above is skipped precisely to + avoid unmapping the NEW image's own LOAD segments/stack, which + already landed on these same pages -- but any mmap mapping from + the OLD image is a disjoint sub-range (mmap.h's own mmapReserve + region, [slot+elfSlotStackOffset, slot+elfSlotSize)) that + loadImageByPath/elfBuildInitialStack never touch, so it still + needs reclaiming here explicitly or its frames would leak + forever every time a binary exec()s itself. */ + pagingUnmapUserRange(oldSlotBase + elfSlotStackOffset, elfSlotSize - elfSlotStackOffset); + } + if (oldSlotBase != 0) + mmapReleaseAll(threadAt(pid)); + threadSetUserSlotBase(pid, newSlotBase); + + threadSetName(pid, path); + threadSetBrk(pid, img.brkStart); + + regs->rip = img.entry; + regs->rsp = initialRsp; + regs->rbp = 0; + regs->rdi = 0; + regs->rsi = 0; + regs->rdx = 0; + regs->rcx = 0; + regs->rbx = 0; + regs->rax = 0; + regs->r8 = 0; + regs->r9 = 0; + regs->r10 = 0; + regs->r11 = 0; + regs->r12 = 0; + regs->r13 = 0; + regs->r14 = 0; + regs->r15 = 0; + return true; +} + +/* posix_spawn()-lite: loads `path` into a brand new thread/process rather + than replacing the caller, and returns its pid immediately without + waiting -- the caller joins it later with sysWait (threadJoin). This is + deliberately not a literal fork(): frostbyte has one shared address space + system-wide (no per-process page tables/CR3, see paging.c), so there is + no address space to duplicate, only a fresh slot to load a binary into. + As with any binary in this kernel, spawning two live instances of the + same path (or of two paths whose fixed link addresses happen to share a + slot) at once would map both onto the same pages and corrupt each other; + this is an accepted limitation of the flat single-address-space design, + not something doSpawn can detect from a path string alone. */ +static int64 doSpawn(const char* path, const char* const* argv, int argc) +{ + LoadedImage img; + if (!loadImageByPath(path, &img)) + return -1; + + uint64 initialRsp; + if (!elfBuildInitialStack(img.stackTop, execStackSize, argv, argc, &initialRsp)) + return -1; + + uint32 pid = threadCreateUser(path, img.entry, initialRsp); + if (pid == threadInvalidId) + return -1; + + threadSetBrk(pid, img.brkStart); + return (int64)pid; +} + uint64 syscallDispatch(Registers* regs) { + /* This thread is not (yet, or any more) parked mid a retryable + syscall's own block-and-retry pattern the instant ANY dispatch for + it begins -- whether this dispatch turns out to BE that exact retry + re-executing (sysRead/sysWrite/sysWait's own cases set it true again + below if they block once more) or a completely unrelated syscall. + See sched.h's Thread.sigInRetryableSyscall and signal.c's + sigConsumePending for why this precise a window matters: without it, + a signal delivered while this thread is doing perfectly ordinary + work could be mistaken for one interrupting a slow syscall. */ + currentThread()->sigInRetryableSyscall = false; + switch (regs->rax) { - case sysWrite: - regs->rax = (uint64)doWrite(regs->rdi, regs->rsi, regs->rdx); + case sysWrite: { + int64 result = doWrite(regs->rdi, regs->rsi, regs->rdx); + if (result == -1) { + /* A "would block" result on a fdFlagBlocking pipe end (see + vfs.h) is retried, not returned: park on the pipe's channel + and rewind rip by the 2-byte `int $0x80` this trap came from + so the whole syscall re-executes once schedWakeChannel makes + this thread Runnable again (pipeWrite, called from doWrite + above, already does that whenever it frees or fills space) -- + the exact restart-syscall pattern sysWait uses for + threadJoin, for the same reason: halt()ing here directly + would freeze the whole machine, not just this thread, since + this handler runs with interrupts disabled. A non-blocking + pipe end (vfsPipe, e.g. pipetest.c) or a permanently broken + one gets nullptr back and the -1 passes through untouched. */ + const void* channel = vfsBlockingChannel((int)regs->rdi, true); + if (channel) { + currentThread()->sigInRetryableSyscall = true; + schedBlockCurrent(channel); + regs->rip -= 2; + return schedule((uint64)regs); + } + } + regs->rax = (uint64)result; return (uint64)regs; case sysRead: regs->rax = (uint64)doRead(regs->rdi, regs->rsi, regs->rdx);