feat: TLS runtime - PT_TLS loading and per-thread FS base

MontaukOS binaries could not use thread-local storage: the ELF loader
ignored PT_TLS and no FS base was ever programmed, so any %fs access
(initial-exec/local-exec TLS, e.g. libbfd's _Thread_local error state)
page faulted at address 0. This is the kernel prerequisite for
cc1plus/libstdc++ in the GCC port.

ELF loader: parse PT_TLS and build the main thread's TLS block above
the loaded image (x86-64 variant II ABI: block below the thread
pointer, TP = base + align_up(memsz, align), .tdata copied from the
template, .tbss zeroed, ABI self-pointer stored at [TP]). The
template description is kept on the process for thread spawns.

Scheduler: fsBase per thread, loaded into IA32_FS_BASE at every
dispatch site through a per-CPU cache - TLS-free processes cost one
MSR write per CPU ever, and userspace cannot desync the cache since
CR4.FSGSBASE stays off. SYS_THREAD_SPAWN gives each new thread its
own TLS block copied from the template (allocated from the process
heap; reclaimed at process teardown).

Ships tls-test.elf in the SDK (built by the devkit target): checks
.tdata values, .tbss zeroing, and the %fs:0 self-pointer, exiting
nonzero on failure. Boot-smoke verified; existing TLS-free programs
are unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-16 18:48:25 +02:00
co-authored by Claude Fable 5
parent 75184e0651
commit 5db2bd4c7c
8 changed files with 222 additions and 4 deletions
+81 -1
View File
@@ -32,6 +32,22 @@
extern "C" void SchedContextSwitch(uint64_t* oldRsp, uint64_t newRsp, uint64_t newCR3,
uint8_t* oldFpuArea, uint8_t* newFpuArea);
// Load a user thread's FS base (TLS thread pointer) before dispatching it.
// Cached per CPU: the MSR write is skipped when unchanged, so TLS-free
// processes (fsBase 0) cost one write per CPU ever. Userspace cannot move
// FS base itself (CR4.FSGSBASE is off), so the cache cannot go stale.
static inline void LoadUserFsBase(Smp::CpuData* cpu, uint64_t fsBase) {
if (cpu->currentFsBase == fsBase) {
return;
}
cpu->currentFsBase = fsBase;
asm volatile("wrmsr"
:
: "c"(0xC0000100u), /* IA32_FS_BASE */
"a"((uint32_t)fsBase),
"d"((uint32_t)(fsBase >> 32)));
}
// Assembly: jump to user mode via IRETQ.
// `arg` is delivered as the user-mode RDI (SystemV first argument).
// For freshly spawned processes this is 0; for SpawnThread it is the
@@ -129,6 +145,7 @@ namespace Sched {
cpu->kernelRsp = processTable[next].kernelStackTop;
cpu->tss->rsp0 = processTable[next].kernelStackTop;
LoadUserFsBase(cpu, processTable[next].fsBase);
SchedContextSwitch(&processTable[slot].savedRsp, processTable[next].savedRsp,
processTable[next].pml4Phys,
processTable[slot].fpuState, processTable[next].fpuState);
@@ -161,6 +178,8 @@ namespace Sched {
// Set up per-CPU TSS RSP0 for hardware interrupts from ring 3
cpu->tss->rsp0 = proc.kernelStackTop;
LoadUserFsBase(cpu, proc.fsBase);
// Jump to user mode (never returns).
// For main threads threadArg is 0 (libc _start ignores RDI);
// for sibling threads it carries the user-supplied argument.
@@ -261,7 +280,8 @@ namespace Sched {
uint64_t pml4Phys = Memory::VMM::Paging::CreateUserPML4();
// Load ELF into the process's address space
uint64_t entry = ElfLoad(vfsPath, pml4Phys);
TlsInfo tls{};
uint64_t entry = ElfLoad(vfsPath, pml4Phys, &tls);
if (entry == 0) {
Memory::VMM::Paging::FreeUserHalf(pml4Phys);
Memory::g_pfa->Free((void*)Memory::HHDM(pml4Phys));
@@ -387,6 +407,11 @@ namespace Sched {
proc.kernelStackTop = kernelStackTop;
proc.userStackTop = UserStackTop - 8;
proc.heapNext = UserHeapBase;
proc.fsBase = tls.fsBase;
proc.tlsTemplateVaddr = tls.templateVaddr;
proc.tlsFileSize = tls.fileSize;
proc.tlsMemSize = tls.memSize;
proc.tlsAlign = tls.align;
proc.readdirCursor = 0;
proc.runningOnCpu = -1;
proc.killPending = false;
@@ -499,6 +524,52 @@ namespace Sched {
int processPid = processTable[primarySlot_].pid;
if (sharedPml4 == 0) return -1;
// Give the new thread its own TLS block when the image has one.
// We are executing inside the process's address space, so the
// freshly mapped pages and the template are directly addressable.
// The block is process-heap memory; it is reclaimed with the rest
// of the user half at process teardown (a thread that exits before
// its process leaks its block until then - acceptable for now).
uint64_t threadFsBase = 0;
Process& primary = processTable[primarySlot_];
if (primary.tlsMemSize > 0) {
uint64_t align = primary.tlsAlign < 16 ? 16 : primary.tlsAlign;
uint64_t S = (primary.tlsMemSize + align - 1) & ~(align - 1);
uint64_t total = S + 16;
uint64_t numPages = (total + 0xFFF) / 0x1000;
schedLock.Acquire();
uint64_t base = primary.heapNext;
primary.heapNext += numPages * 0x1000;
schedLock.Release();
bool ok = true;
for (uint64_t p = 0; p < numPages; p++) {
void* page = Memory::g_pfa->AllocateZeroed();
if (page == nullptr) {
ok = false;
break;
}
uint64_t physAddr = Memory::SubHHDM((uint64_t)page);
if (!Memory::VMM::Paging::MapUserIn(sharedPml4, physAddr,
base + p * 0x1000)) {
ok = false;
break;
}
}
if (!ok) {
Kt::KernelLogStream(Kt::ERROR, "Sched")
<< "Thread TLS allocation failed";
return -1;
}
uint64_t tp = base + S;
memcpy((void*)base, (const void*)primary.tlsTemplateVaddr,
primary.tlsFileSize);
*(uint64_t*)tp = tp; /* ABI self-pointer */
threadFsBase = tp;
}
// We do not write to the user stack from kernel mode (that would
// require validating the user VA against the process page tables).
// Userspace is responsible for ensuring the thread entry calls
@@ -566,6 +637,11 @@ namespace Sched {
thr.ioOutHandle = -1; thr.ioInHandle = -1;
thr.ioKeyHandle = -1; thr.ioWaitsetHandle = -1;
thr.args[0] = '\0'; thr.user[0] = '\0'; thr.cwd[0] = '\0';
thr.fsBase = threadFsBase;
thr.tlsTemplateVaddr = 0;
thr.tlsFileSize = 0;
thr.tlsMemSize = 0;
thr.tlsAlign = 0;
// Derive a debug name from the primary's name.
{
@@ -609,6 +685,7 @@ namespace Sched {
if (readyCount > 0) {
KickOneIdleCpu(cpu->cpuIndex);
}
LoadUserFsBase(cpu, processTable[next].fsBase);
SchedContextSwitch(&thr.savedRsp, processTable[next].savedRsp,
processTable[next].pml4Phys,
thr.fpuState, processTable[next].fpuState);
@@ -930,6 +1007,8 @@ namespace Sched {
uint8_t* oldFpu = (oldSlot >= 0) ? processTable[oldSlot].fpuState : nullptr;
uint8_t* newFpu = processTable[next].fpuState;
LoadUserFsBase(cpu, processTable[next].fsBase);
// DO NOT release schedLock here! It is held across the context
// switch so that setting Ready + saving RSP is atomic. The
// resumed process releases it.
@@ -1207,6 +1286,7 @@ namespace Sched {
uint64_t newCR3 = processTable[next].pml4Phys;
cpu->kernelRsp = processTable[next].kernelStackTop;
cpu->tss->rsp0 = processTable[next].kernelStackTop;
LoadUserFsBase(cpu, processTable[next].fsBase);
// Lock held across context switch -- resumed process releases it
SchedContextSwitch(&processTable[slot].savedRsp, processTable[next].savedRsp, newCR3,