feat: extend syscall with LoadedImage and loadImageByPath

This commit is contained in:
dualfroz committed 2026-09-08 16:53:23 +02:00
1 parent 0a1f4868dd
commit 5149adf7ae
1 file changed
+193 -2
+193 -2
View File
@@ -101,11 +101,202 @@ static int64 doRead(uint64 fd, uint64 buffer, uint64 length)
return vfsRead((int)fd, (void*)buffer, 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) 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) { switch (regs->rax) {
case sysWrite: case sysWrite: {
regs->rax = (uint64)doWrite(regs->rdi, regs->rsi, regs->rdx); 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; return (uint64)regs;
case sysRead: case sysRead:
regs->rax = (uint64)doRead(regs->rdi, regs->rsi, regs->rdx); regs->rax = (uint64)doRead(regs->rdi, regs->rsi, regs->rdx);