feat(exec): Run userland in ring 3 with an ELF64 loader and per-process page tables

SUB-OS had a syscall table and user GDT descriptors but nothing that could
actually leave ring 0: lazybox and the shell run as kernel code, and
syscall_handler() was never reachable from a user program. This adds the
missing path end to end on x86_64.

Address spaces (arch/x86_64/mm/vmspace.c)
- The boot page tables identity-map the low 4 GB off PML4[0] and leave the
  rest empty, so userland is given its own PML4 slot (512 GiB) rather than
  holes punched in the kernel's 2 MB huge-page map. A process PML4 shares
  entry 0 with the kernel and privately owns entry 1.
- Frames outside the identity map are refused rather than written through an
  unmapped pointer; lifting that needs a recursive map or a higher-half
  direct map, which is noted in the file.

ELF loader (kernel/elf.c)
- Validates ELF64/little-endian/ET_EXEC/machine, bounds-checks every program
  header against both the image and the user address range, and lays PT_LOAD
  segments into an address space. Frames arrive zeroed, so .bss is covered.

Ring transition (arch/x86_64/cpu/usermode.asm)
- user_enter() drops to ring 3 via iretq after scrubbing every GPR, and
  user_return() unwinds back to it when the process exits, so exec looks like
  a call that blocks until the program finishes.

Syscalls
- INT 0x80 installed at DPL 3 with the Linux x86_64 register convention.
  The process gets its own kernel stack for the trap, because TSS.rsp0 must
  not point at the stack user_enter() is standing on.
- Every user pointer is range-checked before use. The process page tables are
  still live during a syscall, so without that check a program could pass a
  kernel address and have write(2) dump kernel text to the console. The new
  /sbin/init asserts both a wild pointer and a kernel pointer are rejected.
- write(2) to stdout/stderr routes through the new console_write(), which
  reuses printk's routing so output reaches serial and the framebuffer
  console rather than only the VGA text buffer.

Faults
- A ring 3 exception now kills that process and reports CR2 instead of
  panicking the machine.

Userland (userland/init/)
- /sbin/init is a real freestanding static executable, linked at the user
  base and embedded in the kernel image by scripts/mkblob.py. It reports its
  pid, verifies .bss was zeroed and .data is writable, checks the two pointer
  rejections, and exits.

aarch64 and armv8i keep building: the loader and exec path are portable, and
those ports get honest vmspace stubs that fail cleanly rather than pretending
to run a program with kernel privileges.

Verified under QEMU: init loads at 0x80004000a0, reports pid 1, .bss zeroed,
.data writable, both bad pointers rejected, and exits 0 back into the shell.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0161Jh7uCEMLYEMNPsjC1LKf
This commit is contained in:
subhobhai943andClaude Opus 5 committed 2026-08-25 06:37:20 +00:00
1 parent 7929e48b4e
commit ecdc5be435
19 files changed
+1439 -9

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
#include <mm/vmspace.h>
/*
* Userland address spaces are not wired up on this architecture yet.
*
* The ELF loader and exec path are portable, but they need per-process page
* tables and a ring-3 entry path that only the x86_64 port implements today.
* These stubs fail cleanly so a userland exec reports "not supported" instead
* of silently running a program with kernel privileges.
*/
vmspace_t* vmspace_create(void) { return NULL; }
void vmspace_destroy(vmspace_t* vm) { (void)vm; }
int vmspace_map_page(vmspace_t* vm, uint64_t virt, uint64_t phys, int prot) {
(void)vm; (void)virt; (void)phys; (void)prot;
return -1;
}
int vmspace_alloc(vmspace_t* vm, uint64_t virt, size_t len, int prot) {
(void)vm; (void)virt; (void)len; (void)prot;
return -1;
}
int vmspace_write(vmspace_t* vm, uint64_t dst, const void* src, size_t len) {
(void)vm; (void)dst; (void)src; (void)len;
return -1;
}
uint64_t vmspace_resolve(vmspace_t* vm, uint64_t virt) {
(void)vm; (void)virt;
return 0;
}
void vmspace_switch(vmspace_t* vm) { (void)vm; }
bool vmspace_range_ok(vmspace_t* vm, uint64_t virt, size_t len) {
(void)vm; (void)virt; (void)len;
return false;
}
uint64_t vmspace_resident_pages(vmspace_t* vm) { (void)vm; return 0; }
+42
View File
@@ -0,0 +1,42 @@
#include <mm/vmspace.h>
/*
* Userland address spaces are not wired up on this architecture yet.
*
* The ELF loader and exec path are portable, but they need per-process page
* tables and a ring-3 entry path that only the x86_64 port implements today.
* These stubs fail cleanly so a userland exec reports "not supported" instead
* of silently running a program with kernel privileges.
*/
vmspace_t* vmspace_create(void) { return NULL; }
void vmspace_destroy(vmspace_t* vm) { (void)vm; }
int vmspace_map_page(vmspace_t* vm, uint64_t virt, uint64_t phys, int prot) {
(void)vm; (void)virt; (void)phys; (void)prot;
return -1;
}
int vmspace_alloc(vmspace_t* vm, uint64_t virt, size_t len, int prot) {
(void)vm; (void)virt; (void)len; (void)prot;
return -1;
}
int vmspace_write(vmspace_t* vm, uint64_t dst, const void* src, size_t len) {
(void)vm; (void)dst; (void)src; (void)len;
return -1;
}
uint64_t vmspace_resolve(vmspace_t* vm, uint64_t virt) {
(void)vm; (void)virt;
return 0;
}
void vmspace_switch(vmspace_t* vm) { (void)vm; }
bool vmspace_range_ok(vmspace_t* vm, uint64_t virt, size_t len) {
(void)vm; (void)virt; (void)len;
return false;
}
uint64_t vmspace_resident_pages(vmspace_t* vm) { (void)vm; return 0; }
+21
View File
@@ -3,6 +3,7 @@
#include <arch/x86_64/pic.h>
#include <kernel/printk.h>
#include <kernel/panic.h>
#include <kernel/exec.h>
#include <lib/string.h>
extern void* isr_stub_table[48];
@@ -54,6 +55,26 @@ void isr_unregister_handler(uint8_t int_no) {
void isr_handler_common(registers_t* regs) {
if (regs->int_no < 32) {
/* A page fault reports the offending address in CR2; everything else
* is best described by where it was executing. */
uint64_t fault_addr = regs->rip;
if (regs->int_no == 14) {
__asm__ volatile("mov %%cr2, %0" : "=r"(fault_addr));
}
/*
* CS still carries the privilege level the fault was taken from. A
* ring 3 fault is the process's problem, not the kernel's: report it
* and tear that process down instead of bringing the machine down
* with it.
*/
if ((regs->cs & 3) == 3 && uproc_current()) {
printk(KERN_ERR "\n[USER FAULT] Vector %llu: %s (error 0x%llx) at rip=0x%llx\n",
regs->int_no, exception_messages[regs->int_no],
regs->error_code, regs->rip);
uproc_fault(exception_messages[regs->int_no], fault_addr);
}
// CPU Exception Fault
printk(KERN_EMERG "\n[EXCEPTION] Vector %llu: %s (Error code: 0x%llx)\n",
regs->int_no, exception_messages[regs->int_no], regs->error_code);
+9
View File
@@ -3,6 +3,7 @@
extern isr_handler_common
global isr_stub_table
global isr_stub_default
global isr_stub_syscall
%macro ISR_NOERRCODE 1
isr_stub_%1:
@@ -71,6 +72,14 @@ ISR_NOERRCODE 45
ISR_NOERRCODE 46
ISR_NOERRCODE 47
; Software syscall gate. Reached by INT 0x80 from ring 3, so its IDT entry is
; installed with DPL 3; the CPU switches to TSS.rsp0 on the way in, which the
; exec path points at a kernel stack owned by the running process.
isr_stub_syscall:
push qword 0 ; No error code is pushed for a software interrupt
push qword 128 ; Vector number, matching the IDT slot
jmp isr_common_stub
isr_stub_default:
push qword 0
push qword 255
+89
View File
@@ -0,0 +1,89 @@
[BITS 64]
default rel
; Ring 3 entry and exit for SUB-OS.
;
; user_enter() does not behave like a normal call: it drops to ring 3 and only
; comes back when the process leaves through user_return(), which unwinds
; straight to the frame saved here. From C it therefore looks like a call that
; blocks until the process exits and then returns its exit code.
global user_enter
global user_return
section .text
; int64_t user_enter(uint64_t entry /* rdi */, uint64_t user_rsp /* rsi */)
user_enter:
cli
; Preserve the SysV callee-saved set so user_return can rebuild this frame.
push rbx
push rbp
push r12
push r13
push r14
push r15
mov [kernel_resume_rsp], rsp
; Ring 3 data selectors: user data descriptor (0x20) with RPL 3.
mov ax, 0x23
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
; Build the iretq frame: SS, RSP, RFLAGS, CS, RIP.
push 0x23 ; SS = user data, RPL 3
push rsi ; RSP = top of the user stack
push 0x202 ; RFLAGS with IF set, everything else clear
push 0x1B ; CS = user code (0x18), RPL 3
push rdi ; RIP = ELF entry point
; Do not leak kernel register contents across the privilege boundary.
xor rax, rax
xor rbx, rbx
xor rcx, rcx
xor rdx, rdx
xor rsi, rsi
xor rdi, rdi
xor rbp, rbp
xor r8, r8
xor r9, r9
xor r10, r10
xor r11, r11
xor r12, r12
xor r13, r13
xor r14, r14
xor r15, r15
iretq
; void user_return(int64_t status /* rdi */) -- never returns to its caller.
user_return:
cli
mov rsp, [kernel_resume_rsp]
mov rax, rdi ; becomes user_enter's return value
; Restore the kernel's own data selectors before touching kernel memory.
push rax
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
pop rax
pop r15
pop r14
pop r13
pop r12
pop rbp
pop rbx
sti
ret
section .bss
align 8
kernel_resume_rsp:
resq 1
+239
View File
@@ -0,0 +1,239 @@
#include <mm/vmspace.h>
#include <mm/pmm.h>
#include <mm/kmalloc.h>
#include <arch/x86_64/paging.h>
#include <kernel/printk.h>
#include <lib/string.h>
/*
* x86_64 per-process address spaces.
*
* The boot page tables identity-map the low 4 GB, so within that window a
* physical address doubles as a kernel-usable pointer: page tables can be
* edited and user pages seeded without any temporary mapping window.
*
* The PMM allocates linearly from physical zero, so on any machine whose
* usable memory fits under 4 GB every frame it returns is reachable that way.
* On a larger machine it can return a frame above the window, which this file
* has no way to touch -- those are rejected outright rather than silently
* writing through an unmapped pointer. Lifting that needs either a recursive
* page-table mapping or a higher-half direct map.
*/
#define KERNEL_PML4_PHYS 0x1000UL
#define PTE_ADDR_MASK (~0xFFFULL)
#define ENTRIES_PER_TABLE 512
/* Upper bound of the boot identity map; see the file comment. */
#define IDENTITY_MAP_LIMIT (4ULL << 30)
struct vmspace {
uint64_t pml4_phys;
uint64_t resident_pages; /* data pages, excluding page tables */
uint64_t table_pages;
};
static inline uint64_t* table_at(uint64_t phys) {
return (uint64_t*)(uintptr_t)phys;
}
static uint64_t arch_flags_of(int prot) {
uint64_t flags = PAGE_PRESENT;
if (prot & VM_PROT_WRITE) flags |= PAGE_WRITABLE;
if (prot & VM_PROT_USER) flags |= PAGE_USER;
/* NX is only safe to set once EFER.NXE is on; user text needs it clear
* anyway, so non-executable pages are simply left W^X-less for now. */
return flags;
}
/* Take a zeroed frame that the kernel can actually address, or NULL. */
static void* alloc_identity_frame(void) {
void* page = pmm_alloc_page();
if (!page) return NULL;
if ((uint64_t)(uintptr_t)page >= IDENTITY_MAP_LIMIT) {
pmm_free_page(page);
printk(KERN_ERR "vmspace: frame at 0x%llx is outside the identity map\n",
(unsigned long long)(uintptr_t)page);
return NULL;
}
memset(page, 0, VM_PAGE_SIZE);
return page;
}
static uint64_t* alloc_table(vmspace_t* vm) {
void* page = alloc_identity_frame();
if (!page) return NULL;
if (vm) vm->table_pages++;
return (uint64_t*)page;
}
vmspace_t* vmspace_create(void) {
vmspace_t* vm = (vmspace_t*)kzalloc(sizeof(vmspace_t));
if (!vm) return NULL;
uint64_t* pml4 = alloc_table(vm);
if (!pml4) {
kfree(vm);
return NULL;
}
/*
* Share the kernel's PML4[0] verbatim. That entry covers the whole low
* 512 GB, which is where the identity-mapped kernel, its heap and every
* MMIO window live, so ring 0 keeps working immediately after the CR3
* load. Userland gets its own slot and never aliases kernel memory.
*/
uint64_t* kernel_pml4 = table_at(KERNEL_PML4_PHYS);
pml4[0] = kernel_pml4[0];
vm->pml4_phys = (uint64_t)(uintptr_t)pml4;
return vm;
}
/* Walk to the PTE for `virt`, optionally creating the intermediate tables. */
static uint64_t* walk(vmspace_t* vm, uint64_t virt, bool create) {
uint64_t idx[4] = {
(virt >> 39) & 0x1FF,
(virt >> 30) & 0x1FF,
(virt >> 21) & 0x1FF,
(virt >> 12) & 0x1FF,
};
uint64_t* table = table_at(vm->pml4_phys);
for (int level = 0; level < 3; level++) {
uint64_t entry = table[idx[level]];
if (!(entry & PAGE_PRESENT)) {
if (!create) return NULL;
uint64_t* next = alloc_table(vm);
if (!next) return NULL;
/* Every level above the leaf must allow user access, or the
* final PAGE_USER bit is overridden by the walk. */
entry = (uint64_t)(uintptr_t)next | PAGE_PRESENT | PAGE_WRITABLE | PAGE_USER;
table[idx[level]] = entry;
} else if (entry & PAGE_HUGE) {
/* A large mapping already covers this range; refuse rather than
* silently splitting the kernel's identity map. */
return NULL;
}
table = table_at(entry & PTE_ADDR_MASK);
}
return &table[idx[3]];
}
int vmspace_map_page(vmspace_t* vm, uint64_t virt, uint64_t phys, int prot) {
if (!vm) return -1;
if (virt < USER_BASE || virt >= USER_LIMIT) return -1;
uint64_t* pte = walk(vm, virt & ~(VM_PAGE_SIZE - 1), true);
if (!pte) return -1;
if (!(*pte & PAGE_PRESENT)) vm->resident_pages++;
*pte = (phys & PTE_ADDR_MASK) | arch_flags_of(prot);
return 0;
}
int vmspace_alloc(vmspace_t* vm, uint64_t virt, size_t len, int prot) {
if (!vm || len == 0) return -1;
uint64_t start = virt & ~(VM_PAGE_SIZE - 1);
uint64_t end = (virt + len + VM_PAGE_SIZE - 1) & ~(VM_PAGE_SIZE - 1);
if (end <= start) return -1;
for (uint64_t page = start; page < end; page += VM_PAGE_SIZE) {
uint64_t* pte = walk(vm, page, true);
if (!pte) return -1;
if (*pte & PAGE_PRESENT) {
/* Overlapping PT_LOAD segments share a page; widen its rights
* instead of allocating a second frame over the top. */
*pte |= arch_flags_of(prot);
continue;
}
void* frame = alloc_identity_frame();
if (!frame) return -1;
*pte = ((uint64_t)(uintptr_t)frame & PTE_ADDR_MASK) | arch_flags_of(prot);
vm->resident_pages++;
}
return 0;
}
uint64_t vmspace_resolve(vmspace_t* vm, uint64_t virt) {
if (!vm) return 0;
uint64_t* pte = walk(vm, virt & ~(VM_PAGE_SIZE - 1), false);
if (!pte || !(*pte & PAGE_PRESENT)) return 0;
return (*pte & PTE_ADDR_MASK) | (virt & (VM_PAGE_SIZE - 1));
}
bool vmspace_range_ok(vmspace_t* vm, uint64_t virt, size_t len) {
if (!vm || len == 0) return false;
if (virt < USER_BASE || virt >= USER_LIMIT) return false;
if (len > USER_LIMIT - virt) return false;
uint64_t start = virt & ~(VM_PAGE_SIZE - 1);
uint64_t end = (virt + len + VM_PAGE_SIZE - 1) & ~(VM_PAGE_SIZE - 1);
for (uint64_t page = start; page < end; page += VM_PAGE_SIZE) {
uint64_t* pte = walk(vm, page, false);
if (!pte || !(*pte & PAGE_PRESENT) || !(*pte & PAGE_USER)) return false;
}
return true;
}
int vmspace_write(vmspace_t* vm, uint64_t dst, const void* src, size_t len) {
if (!vm || !src) return -1;
const uint8_t* in = (const uint8_t*)src;
while (len) {
uint64_t phys = vmspace_resolve(vm, dst);
if (!phys) return -1;
size_t offset = (size_t)(dst & (VM_PAGE_SIZE - 1));
size_t chunk = VM_PAGE_SIZE - offset;
if (chunk > len) chunk = len;
memcpy((void*)(uintptr_t)phys, in, chunk);
dst += chunk;
in += chunk;
len -= chunk;
}
return 0;
}
void vmspace_switch(vmspace_t* vm) {
uint64_t cr3 = vm ? vm->pml4_phys : KERNEL_PML4_PHYS;
__asm__ volatile("mov %0, %%cr3" : : "r"(cr3) : "memory");
}
uint64_t vmspace_resident_pages(vmspace_t* vm) {
return vm ? vm->resident_pages : 0;
}
/* Free one level of the private sub-tree rooted at `phys`. */
static void free_level(uint64_t phys, int level) {
uint64_t* table = table_at(phys);
for (int i = 0; i < ENTRIES_PER_TABLE; i++) {
uint64_t entry = table[i];
if (!(entry & PAGE_PRESENT) || (entry & PAGE_HUGE)) continue;
if (level > 0) {
free_level(entry & PTE_ADDR_MASK, level - 1);
} else {
pmm_free_page((void*)(uintptr_t)(entry & PTE_ADDR_MASK));
}
}
pmm_free_page((void*)(uintptr_t)phys);
}
void vmspace_destroy(vmspace_t* vm) {
if (!vm) return;
uint64_t* pml4 = table_at(vm->pml4_phys);
/* Slot 0 is the kernel's own table and must outlive this process. */
uint64_t user_entry = pml4[USER_PML4_SLOT];
if (user_entry & PAGE_PRESENT) {
free_level(user_entry & PTE_ADDR_MASK, 2);
}
pmm_free_page((void*)(uintptr_t)vm->pml4_phys);
kfree(vm);
}