//! User-space processes: loading a user ELF and running it in ring 3. danos is a //! microkernel, so this only ever loads *user* binaries — there is no kernel-space //! loader; in-kernel code is linked into the kernel image, not loaded here. //! //! Two entry points: //! - `spawnProcess` loads a user ELF (`/system/services/init`, and later servers/drivers) //! into a fresh address space and schedules it as a real preemptive ring-3 //! process on its own page tables. This is the production path. //! - `run` executes a raw code blob (the user-pf isolation test program) on the //! *current* kernel context via the borrowed-thread path — a minimal probe of //! the ring-transition mechanisms, kept for that test. //! Both map frames user-accessible with W^X (code RO+X, data RW+NX); the program //! talks to the kernel only through the system_call instruction (or the int 0x80 //! gate). The shared handler is installed once by `init`. //! //! Borrowed-path caveat (`run` only): it publishes TSS.rsp0 on the *current* //! core and uses a single global unwind slot (`user_saved_rsp` in isr.s), so the //! caller must disable preemption and only one core may be inside it at a time. //! Real processes (`spawnProcess`) have none of these limits — the scheduler //! maintains rsp0/CR3 per switch. const std = @import("std"); const elf = std.elf; const boot_handoff = @import("boot-handoff"); const abi = @import("abi"); const device_abi = @import("device-abi"); const parameters = @import("parameters"); const architecture = @import("architecture"); const pmm = @import("pmm.zig"); const scheduler = @import("scheduler.zig"); const console = @import("console.zig"); const sync = @import("sync.zig"); const ipc = @import("ipc-synchronous.zig"); const devices_broker = @import("devices-broker.zig"); const irq = @import("irq.zig"); const initial_ramdisk = @import("initial-ramdisk"); const log = @import("log.zig"); const wall_clock = @import("wall-clock.zig"); const page_size = abi.page_size; const SystemCall = abi.SystemCall; /// User virtual addresses. PML4 index 224 — a user-exclusive region, far from /// the identity map (low indices) and the vmm test address (index 128), so /// setting the U/S bit on its intermediate tables widens no kernel mapping. /// An ELF image may occupy [code_virtual, stack_virtual); the stack sits above. pub const code_virtual: u64 = 0x0000_7000_0000_0000; /// The stack region, above the image. The page at `stack_virtual` is **never /// mapped** — it is the guard page: a process that overflows its stack walks into /// it and faults (killing only that process) rather than silently corrupting the /// top of its own image. The stack proper is `parameters.user_stack_pages` pages /// at [stack_base_virtual, stack_top_virtual), RW + NX, with the System V entry /// block (argc/argv) at the very top. pub const stack_virtual: u64 = 0x0000_7000_0020_0000; // guard page (unmapped) pub const stack_base_virtual: u64 = stack_virtual + page_size; pub const stack_top_virtual: u64 = stack_base_virtual + parameters.user_stack_pages * page_size; /// The mmap grant arena: where `mmap` hands out fresh user pages, above the image /// and stack but still inside PML4[224] (so no kernel mapping is widened). Each /// process bump-allocates from `heap_arena_base` upward via a per-address-space cursor /// (`scheduler.addressSpaceMmapNextPtr`, shared by its threads); a 1 GiB window is far more /// than any user heap needs today. pub const heap_arena_base: u64 = 0x0000_7000_1000_0000; pub const heap_arena_end: u64 = heap_arena_base + (1 << 30); /// End of the user (low) canonical half. Any legitimate user pointer is below it; /// used to bound the addresses a system_call will dereference on the caller's behalf. pub const user_half_end: u64 = 0x0000_8000_0000_0000; /// The MMIO-grant arena: where `mmio_map` places device windows, in PML4[226] — /// a user-exclusive region distinct from code/stack/heap (PML4[224]), so mapping /// device pages user-accessible widens no kernel mapping. Per-address-space cursor /// (`scheduler.addressSpaceDeviceMapNextPtr`). pub const device_arena_base: u64 = 0x0000_7100_0000_0000; pub const device_arena_end: u64 = device_arena_base + (4 << 30); /// The DMA arena: where `dma_alloc` places coherent DMA buffers, in PML4[228] — a /// user-exclusive region distinct from the MMIO arena. Unlike MMIO grants these back /// real RAM (contiguous frames), so they are reclaimed on teardown. Per-process cursor /// in `Task.dma_map_next`. pub const dma_arena_base: u64 = 0x0000_7200_0000_0000; pub const dma_arena_end: u64 = dma_arena_base + (256 << 20); // 256 MiB per process /// The shared-memory arena: where `shm_create`/`shm_map` place shared cacheable regions, in /// PML4[230] — a user-exclusive region distinct from the DMA arena. The frames are owned by /// a refcounted shm object and freed when its last capability drops, not on teardown, so the /// mapping carries `device_grant`. Per-process cursor in `Task.shm_map_next` (docs/display-v2.md). pub const shm_arena_base: u64 = 0x0000_7300_0000_0000; pub const shm_arena_end: u64 = shm_arena_base + (256 << 20); // 256 MiB per process /// Largest single `shm_create`, in pages (32 MiB) — enough for a 4K framebuffer surface; /// also an overflow guard on the page count. shm frames are contiguous (like DMA), so this /// bounds the contiguous allocation asked of the frame allocator. const maximum_shm_pages = 8192; /// Largest single `mmap` grant, in pages (32 MiB). Big enough for a display service's /// back buffer at up to 4K (3840x2160x4 ≈ 8100 pages); the user heap otherwise grows in /// small chunks. `systemMmap` maps page by page with rollback, so this is only a sanity /// bound (and an overflow guard on the page count), not the size of any scratch array. const maximum_mmap_pages = 8192; /// Ceiling on a process's argv entries, including argv[0]. Arguments are spawn /// parameters ("you are the driver for device 12"), not bulk data — IPC carries /// that — so the bound is small and everything fits the single stack page. pub const maximum_arguments = 8; /// Ceiling on the `system_spawn` extra-arguments blob (argv[1..], NUL-separated). pub const maximum_argument_bytes = 256; /// Auxiliary-vector entry types (System V AMD64 process entry). Only what the /// kernel emits today; a C runtime scans the vector until the null terminator. const auxiliary_vector_null: u64 = 0; // AT_NULL — end of the vector const auxiliary_vector_page_size: u64 = 6; // AT_PAGESZ // The hand-assembled user program blob (isr.s, .rodata) — the isolation probe. const pf_start = @extern([*]const u8, .{ .name = "user_pf_start" }); const pf_end = @extern([*]const u8, .{ .name = "user_pf_end" }); /// The isolation-proof program: reads a kernel-only page, must #PF. pub fn pfBlob() []const u8 { return pf_start[0 .. @intFromPtr(pf_end) - @intFromPtr(pf_start)]; } /// What debug_write syscalls produced (accumulated), and the exit system_call's code. pub var write_buffer: [256]u8 = undefined; pub var write_len: usize = 0; pub var write_from_user: bool = false; pub var write_count: u64 = 0; // total write syscalls served (for the heartbeat tests) pub var exit_code: u64 = 0; /// The initial-ramdisk image, recorded at boot so `system_spawn` can find bundled /// binaries by name. Null until `setInitialRamdisk` runs; `system_spawn` then fails /// cleanly rather than reaching into unset memory. var ramdisk_image: ?[]const u8 = null; /// Record the initial-ramdisk image (the kernel already holds it from the boot /// handoff) so a user-space supervisor can `system_spawn` binaries out of it. pub fn setInitialRamdisk(image: []const u8) void { ramdisk_image = image; } /// The system_call surface, dispatched on the saved system_call number (`abi.SystemCall`). /// This is the microkernel-minimal set — memory + scheduling only; file/device /// I/O will arrive as IPC to user-space servers (docs/syscall.md). The result is /// written back into the trap frame, since the entry paths restore user registers /// from it. One handler serves both the system_call/sysret and int-0x80 entry paths. /// /// Install it once at boot (before any user code runs) via `init`. Also registers /// the scheduler's kill hooks: the scheduler sits below this layer, so finishing a /// deferred process_kill (IRQ bindings, IPC handles, the exit notification) is /// called back up into here from the tick (see scheduler.reapKillPendingLocked). pub fn init() void { architecture.setSystemCallHandler(system_call); scheduler.terminate_current_hook = terminateCurrentLocked; scheduler.reap_task_hook = reapTaskLocked; scheduler.timer_tick_hook = timerSweepLocked; } /// Return -1 (as an unsigned bit pattern) in the system_call result register. fn fail(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, @bitCast(@as(i64, -1))); } fn system_call(state: *architecture.CpuState) void { const t = scheduler.current(); const user = t.address_space != 0; if (user) { // A condemned process (process_kill caught it running) dies at its next // kernel entry — before it can spawn, claim, or message anything else. if (t.kill_pending) terminateCurrent(); // Mark the span of this call so the timer tick never tears the task down // in the middle of a kernel operation (scheduler.reapKillPendingLocked). t.in_system_call = true; } defer if (user) { t.in_system_call = false; }; switch (@as(SystemCall, @enumFromInt(architecture.systemCallNumber(state)))) { .exit => { exit_code = architecture.systemCallArg(state, 0); // A scheduled process tears down fully (terminateCurrent); a borrowed // test thread unwinds back to the kernel that entered it. if (scheduler.currentIsUserProcess()) { scheduler.current().exit_reason = .exited; terminateCurrent(); } else architecture.userExit(); }, .yield => { scheduler.yield(); architecture.setSystemCallResult(state, 0); }, .sleep => { scheduler.sleep(architecture.systemCallArg(state, 0)); architecture.setSystemCallResult(state, 0); }, .debug_write => systemDebugWrite(state), .mmap => systemMmap(state), .munmap => systemMunmap(state), .create_ipc_endpoint => systemCreateIpcEndpoint(state), .ipc_register => systemIpcRegister(state), .ipc_lookup => systemIpcLookup(state), .ipc_call => systemIpcCall(state), .ipc_reply_wait => systemIpcReplyWait(state), .ipc_send => systemIpcSend(state), .device_enumerate => systemDeviceEnumerate(state), .device_claim => systemDeviceClaim(state), .mmio_map => systemMmioMap(state), .irq_bind => systemIrqBind(state), .irq_ack => systemIrqAck(state), .device_register => systemDeviceRegister(state), .system_spawn => systemSpawn(state), .dma_alloc => systemDmaAlloc(state), .dma_free => systemDmaFree(state), .msi_bind => systemMsiBind(state), .io_read => systemIoRead(state), .io_write => systemIoWrite(state), .clock => systemClock(state), .process_enumerate => systemProcessEnumerate(state), .process_kill => systemProcessKill(state), .process_exit_reason => systemProcessExitReason(state), .process_subscribe => systemProcessSubscribe(state), .signal_bind => systemSignalBind(state), .process_signal => systemProcessSignal(state), .timer_bind => systemTimerBind(state), .klog_read => systemKlogRead(state), .wall_clock => systemWallClock(state), .shm_create => systemShmCreate(state), .shm_map => systemShmMap(state), .shm_physical => systemShmPhysical(state), .thread_spawn => systemThreadSpawn(state), .current_core => systemCurrentCore(state), .thread_self => systemThreadSelf(state), .thread_join => systemThreadJoin(state), .set_thread_pointer => systemSetThreadPointer(state), .futex_wait => systemFutexWait(state), .futex_wake => systemFutexWake(state), .thread_exit => { // A thread ends like a process exit(0), but only this task: its // resources are released and its address-space reference dropped (the // space survives while sibling threads hold it). docs/threading.md. if (scheduler.currentIsUserProcess()) { scheduler.current().exit_reason = .exited; terminateCurrent(); } else architecture.userExit(); }, _ => fail(state), } } /// Return `-errno` in the system_call result register. fn failErr(state: *architecture.CpuState, errno: i64) void { architecture.setSystemCallResult(state, @bitCast(-errno)); } /// create_ipc_endpoint() -> handle: allocate an endpoint and install it in the /// caller's handle table. fn systemCreateIpcEndpoint(state: *architecture.CpuState) void { // Under the big kernel lock: this allocates from the kernel heap and mutates the // caller's handle table. A multi-threaded process (e.g. the display's compositor + // mouse-listener threads) can drive this concurrently from two cores, so the endpoint // allocation and every other lock holder must serialize (heap.zig: "a lock comes with // threads/SMP"). const flags = sync.enter(); defer sync.leave(flags); const endpoint = ipc.createIpcEndpoint() orelse return failErr(state, ipc.ENOMEM); const h = ipc.installHandle(scheduler.current(), endpoint); if (h < 0) { ipc.dropRef(endpoint); return failErr(state, ipc.ENOSPC); } architecture.setSystemCallResult(state, @intCast(h)); } /// ipc_register(service_id, handle): publish the caller's endpoint under a /// well-known id so other processes can find it. fn systemIpcRegister(state: *architecture.CpuState) void { // Under the big kernel lock: mutates the global service registry and endpoint // refcounts, which threads of the same (or another) process can race. const flags = sync.enter(); defer sync.leave(flags); const id: u32 = @truncate(architecture.systemCallArg(state, 0)); const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF); architecture.setSystemCallResult(state, @bitCast(ipc.register(id, endpoint))); } /// ipc_lookup(service_id) -> handle: find a published endpoint and install a /// handle to it in the caller. fn systemIpcLookup(state: *architecture.CpuState) void { // Under the big kernel lock: reads the global registry, takes an endpoint reference, // and installs a handle — all racy against concurrent threads (this is the path the // display's mouse-listener thread takes to reach the compositor endpoint). const flags = sync.enter(); defer sync.leave(flags); const id: u32 = @truncate(architecture.systemCallArg(state, 0)); const endpoint = ipc.lookup(id) orelse return failErr(state, ipc.ENOENT); const h = ipc.installHandle(scheduler.current(), endpoint); if (h < 0) { ipc.dropRef(endpoint); return failErr(state, ipc.ENOSPC); } architecture.setSystemCallResult(state, @intCast(h)); } /// ipc_call(handle, message_ptr, message_len, reply_ptr, reply_cap) -> reply_len. /// Blocks until the server replies; the trap frame lives on this task's kernel /// stack, so it survives the block and receives the result on resume. fn systemIpcCall(state: *architecture.CpuState) void { const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); var received_cap: u64 = abi.no_cap; const r = ipc.call(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &received_cap); architecture.setSystemCallResult(state, @bitCast(r)); architecture.setSystemCallResult3(state, received_cap); } /// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len, /// with the sender's badge in the secondary result register (rdx). fn systemIpcReplyWait(state: *architecture.CpuState) void { const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); var badge: u64 = 0; var received_cap: u64 = abi.no_cap; const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap); architecture.setSystemCallResult(state, @bitCast(r)); architecture.setSystemCallResult2(state, badge); architecture.setSystemCallResult3(state, received_cap); } /// ipc_send(handle, message_ptr, message_len) -> 0/-errno: post a payload to an /// endpoint's async queue and wake a receiver, without blocking the caller. The async /// counterpart of ipc_call — for broadcasts (the input service) where a rendezvous would /// let one dead subscriber hang the sender. Delivered through ipc_reply_wait as a /// buffered message (badge carries notify_message_bit and the caller's task id). fn systemIpcSend(state: *architecture.CpuState) void { const me = scheduler.current(); const endpoint = ipc.resolveHandle(me, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); const r = ipc.send(endpoint, me.address_space, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), me.id); architecture.setSystemCallResult(state, @bitCast(r)); } /// device_enumerate(buffer, maximum) -> total: snapshot the device table into the caller's /// buffer (up to `maximum` entries), returning the total device count. fn systemDeviceEnumerate(state: *architecture.CpuState) void { const buffer_ptr = architecture.systemCallArg(state, 0); const maximum = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0 or buffer_ptr >= user_half_end) return fail(state); const sz = @sizeOf(device_abi.DeviceDescriptor); const cap = @min(maximum, (user_half_end - buffer_ptr) / sz); // clamp to the user half const out: [*]device_abi.DeviceDescriptor = @ptrFromInt(buffer_ptr); architecture.setSystemCallResult(state, devices_broker.enumerate(out[0..@intCast(cap)])); } /// device_claim(id) -> 0/-1: take exclusive ownership of a device for this process. fn systemDeviceClaim(state: *architecture.CpuState) void { const device_id = architecture.systemCallArg(state, 0); const claim_flags = sync.enter(); defer sync.leave(claim_flags); if (devices_broker.claim(device_id, scheduler.current().id)) { // A display service just took the framebuffer — quiesce the bootstrap console // so the kernel and the service don't scribble over each other's pixels. The // claim releases (and the console resumes) automatically if the service dies; // see releaseTaskResourcesLocked. if (devices_broker.displayDevice()) |display_id| { if (device_id == display_id) console.setSuppressed(true); } architecture.setSystemCallResult(state, 0); } else fail(state); } /// mmio_map(device_id, resource_index) -> virtual_address: map a claimed device's MMIO window into /// this address space (strong-uncacheable) and return the register base address. /// The claim is the capability — a process can only map hardware it owns. fn systemMmioMap(state: *architecture.CpuState) void { const device_id = architecture.systemCallArg(state, 0); const resource_index = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0) return fail(state); // Read the broker table under the lock: ring-3 device_register (M19) now // mutates it concurrently on other cores, so a lock-free read here could // see a torn resource (and a torn length used to panic the arithmetic // below on integer overflow). const r = blk: { const flags = sync.enter(); defer sync.leave(flags); const owner = devices_broker.ownerOf(device_id) orelse return fail(state); if (owner != t.id) return fail(state); // not claimed by this process break :blk devices_broker.resourceOf(device_id, resource_index) orelse return fail(state); }; if (r.kind != @intFromEnum(device_abi.ResourceKind.memory)) return fail(state); // A zero-length or wrapping window is not mappable — fail cleanly rather // than underflow `r.len - 1`. if (r.len == 0) return fail(state); if (@addWithOverflow(r.start, r.len)[1] != 0) return fail(state); const first = r.start & ~@as(u64, page_size - 1); const last = (r.start + r.len - 1) & ~@as(u64, page_size - 1); const pages = (last - first) / page_size + 1; // A framebuffer resource asks (via its flag) to be mapped write-combining rather // than the strong-uncacheable default that register MMIO needs. const write_combining = (r.flags & device_abi.resource_flag_write_combining) != 0; // Per-address-space cursor + shared page tables → serialize under the big lock, // same as mmap (docs/threading-plan.md M7). const flags = sync.enter(); defer sync.leave(flags); const cursor = scheduler.addressSpaceDeviceMapNextPtr(t.address_space) orelse return fail(state); if (cursor.* == 0) cursor.* = device_arena_base; // seed the arena lazily const base_v = cursor.*; if (base_v + pages * page_size > device_arena_end) return fail(state); architecture.mapUserDeviceInto(t.address_space, base_v, r.start, r.len, write_combining); cursor.* = base_v + pages * page_size; architecture.setSystemCallResult(state, base_v + (r.start & (page_size - 1))); // register base } /// Resolve a port-I/O access against the caller's claims. The device must be claimed by /// `t`, `resource_index` must name one of its `io_port` resources, and the access /// `[offset, offset+width)` must fall wholly inside it. Returns the absolute 16-bit /// port, or null if the capability check fails. The claim plus the discovered `io_port` /// resource are the capability — exactly like `mmio_map` for memory, so a driver can /// only touch the ports its device actually owns, never a raw `in`/`out` to anywhere. pub fn resolveIoPort(t: *scheduler.Task, device_id: u64, resource_index: u64, offset: u64, width: u64) ?u16 { if (width != 1 and width != 2 and width != 4) return null; const owner = devices_broker.ownerOf(device_id) orelse return null; if (owner != t.id) return null; // not claimed by this process const r = devices_broker.resourceOf(device_id, resource_index) orelse return null; if (r.kind != @intFromEnum(device_abi.ResourceKind.io_port)) return null; if (offset + width > r.len) return null; // access escapes the claimed port range const port = r.start + offset; if (port + width > 0x1_0000) return null; // I/O ports are 16-bit return @intCast(port); } /// io_read(device_id, resource_index, offset, width) -> value: read `width` bytes (1/2/4) /// from a port in a claimed device's `io_port` resource. Ring 3 has no direct `in`/`out` /// (no TSS I/O bitmap, IOPL never raised), so a legacy driver (PS/2, 16550 UART) reaches /// its ports through this claim-gated call — low-rate hardware, so a syscall per access /// is fine. See docs/drivers.md. fn systemIoRead(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const width = architecture.systemCallArg(state, 3); const port = resolveIoPort(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), width) orelse return fail(state); architecture.setSystemCallResult(state, architecture.pioRead(@intCast(width), port)); } /// io_write(device_id, resource_index, offset, width, value) -> 0: write `value` (low /// `width` bytes) to a port in a claimed device's `io_port` resource. Same capability /// gate as `io_read`. fn systemIoWrite(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const width = architecture.systemCallArg(state, 3); const port = resolveIoPort(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), width) orelse return fail(state); architecture.pioWrite(@intCast(width), port, @intCast(architecture.systemCallArg(state, 4))); architecture.setSystemCallResult(state, 0); } /// dma_alloc(len, flags) -> virtual_address (rax), physical_address (rdx): grant `len` bytes (rounded up to /// whole pages) of DMA-capable memory — physically contiguous, zeroed, pinned, and /// strong-uncacheable (coherent) — mapping it into the caller's DMA arena and handing /// back both the virtual address to touch and the physical address to program into the /// device. This is what `sysMmap` can't do: mmap frames are scattered, cacheable, and /// their physical address is never disclosed. `dma_below_4g` caps the physical address /// for legacy engines; `dma_write_combining` is accepted but falls back to coherent /// until PAT is programmed. See docs/driver-model.md (M14). fn systemDmaAlloc(state: *architecture.CpuState) void { const len = architecture.systemCallArg(state, 0); const flags = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0 or len == 0) return fail(state); const pages: usize = @intCast((len + page_size - 1) / page_size); const max_phys: u64 = if (flags & abi.dma_below_4g != 0) (@as(u64, 4) << 30) else ~@as(u64, 0); const phys = pmm.allocContiguous(pages, max_phys) orelse return fail(state); if (t.dma_map_next == 0) t.dma_map_next = dma_arena_base; const base_v = t.dma_map_next; if (base_v + pages * page_size > dma_arena_end) { for (0..pages) |i| pmm.free(phys + i * page_size); // arena exhausted; give the frames back return fail(state); } // Zero through the physmap (the frames aren't mapped in the caller yet), then map. const kernel_view: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(phys)); @memset(kernel_view[0 .. pages * page_size], 0); architecture.mapUserDmaInto(t.address_space, base_v, phys, pages * page_size); t.dma_map_next = base_v + pages * page_size; architecture.setSystemCallResult(state, base_v); // virtual address for the CPU architecture.setSystemCallResult2(state, phys); // physical address for the device } /// dma_free(virtual_address, len) -> 0: release a prior `dma_alloc`. Bounded to the DMA arena so /// it can never unmap-and-free the caller's stack, heap, or an MMIO grant; only pages /// actually mapped are freed (an unmapped hole is skipped). Teardown also reclaims any /// DMA pages left mapped at exit (they carry no `device_grant`, so `freeSubtree` frees /// them as ordinary RAM), so a driver that just dies leaks nothing. fn systemDmaFree(state: *architecture.CpuState) void { const base_v = architecture.systemCallArg(state, 0); const len = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0) return fail(state); const pages: usize = @intCast((len + page_size - 1) / page_size); if (base_v < dma_arena_base or base_v + pages * page_size > dma_arena_end) return fail(state); for (0..pages) |i| { const va = base_v + i * page_size; if (architecture.translate(t.address_space, va)) |phys| { architecture.unmapUserPageInto(t.address_space, va); pmm.free(phys); } } architecture.setSystemCallResult(state, 0); } /// shm_create(len) -> virtual_address (rax), handle (rdx): grant `len` bytes (rounded up to whole /// pages) of **shareable, zeroed, cacheable** RAM — contiguous frames mapped into the /// caller's shm arena — and hand back the virtual address plus a capability handle. Unlike /// `dma_alloc` the memory is write-back cacheable (for CPU compositing, not device DMA) and /// its frames are owned by a refcounted object: the handle is passed to another process as /// an `ipc_call` send_cap, that process `shm_map`s it, and the frames free only when the /// last capability drops (docs/display-v2.md — the compositor↔native-driver and /// app↔compositor surface path). fn systemShmCreate(state: *architecture.CpuState) void { const len = architecture.systemCallArg(state, 0); const t = scheduler.current(); if (t.address_space == 0 or len == 0) return fail(state); const pages: usize = @intCast((len + page_size - 1) / page_size); if (pages == 0 or pages > maximum_shm_pages) return fail(state); // Reserve arena virtual space up front, so a mapping failure needs no rollback. if (t.shm_map_next == 0) t.shm_map_next = shm_arena_base; const base_v = t.shm_map_next; if (base_v + pages * page_size > shm_arena_end) return fail(state); // arena exhausted const phys = pmm.allocContiguous(pages, ~@as(u64, 0)) orelse return fail(state); // Zero through the physmap (the frames aren't mapped in the caller yet). const kernel_view: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(phys)); @memset(kernel_view[0 .. pages * page_size], 0); const shm = ipc.createShm(phys, pages) orelse { for (0..pages) |i| pmm.free(phys + i * page_size); return fail(state); }; const handle = ipc.installShmHandle(t, shm); if (handle < 0) { ipc.dropShmRef(shm); // last ref: frees the object and its frames return fail(state); } architecture.mapUserSharedInto(t.address_space, base_v, phys, pages * page_size); t.shm_map_next = base_v + pages * page_size; architecture.setSystemCallResult(state, base_v); // virtual_address for the CPU architecture.setSystemCallResult2(state, @intCast(handle)); // capability handle to pass on } /// shm_map(cap) -> virtual_address: map the shared region named by a capability handle the caller /// received (via an `ipc_call` send_cap) into its shm arena — the same physical frames the /// creator sees — returning the virtual address. The handle already holds a reference (taken /// when the capability was shared), so this only adds a mapping; it never bumps the refcount. fn systemShmMap(state: *architecture.CpuState) void { const cap = architecture.systemCallArg(state, 0); const t = scheduler.current(); if (t.address_space == 0) return fail(state); const shm = ipc.resolveShm(t, cap) orelse return fail(state); // not an shm handle we hold if (t.shm_map_next == 0) t.shm_map_next = shm_arena_base; const base_v = t.shm_map_next; const size = shm.pages * page_size; if (base_v + size > shm_arena_end) return fail(state); architecture.mapUserSharedInto(t.address_space, base_v, shm.phys, size); t.shm_map_next = base_v + size; architecture.setSystemCallResult(state, base_v); } /// shm_physical(cap) -> physical_address: the guest-physical base of a shared region the caller holds a /// capability for. The frames are contiguous (allocated by `allocContiguous`), so a single /// physical base + length describes the whole region — which is exactly what a driver needs /// to hand a shm surface to a device (virtio-gpu `attach_backing`). Only a holder of the /// capability can ask; there is no ambient way to turn a virtual address into a physical one. fn systemShmPhysical(state: *architecture.CpuState) void { const cap = architecture.systemCallArg(state, 0); const t = scheduler.current(); if (t.address_space == 0) return fail(state); const shm = ipc.resolveShm(t, cap) orelse return fail(state); // not an shm handle we hold architecture.setSystemCallResult(state, shm.phys); } /// device_register(parent_id, descriptor_ptr) -> id: publish a child device below a device /// this process has claimed. The bus-driver primitive: a process that owns a bus /// enumerates it and hands each device it finds to the table, where a class driver /// can claim it. /// /// The kernel copies the descriptor into a kernel local *once* (via the same /// physmap-walking path as IPC, so an unmapped user page fails the call rather than /// faulting the kernel), then validates and uses that copy — no second read of user /// memory, so nothing it checked can change under it. It refuses any child resource /// that escapes the parent's windows: a descriptor is a licence to map physical /// memory, so a bus may only subdivide what it already holds. `id`/`parent` in the /// supplied descriptor are ignored. fn systemDeviceRegister(state: *architecture.CpuState) void { const parent_id = architecture.systemCallArg(state, 0); const descriptor_ptr = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0) return fail(state); var descriptor: device_abi.DeviceDescriptor = undefined; if (!ipc.copyFromUser(t.address_space, descriptor_ptr, std.mem.asBytes(&descriptor))) return fail(state); // Under the big kernel lock: the broker's table is also mutated by the // death sweep (releaseAllOwnedBy) and read by enumerate on other cores — // ring-3 registration (M19) made those genuinely concurrent. const flags = sync.enter(); defer sync.leave(flags); const id = devices_broker.register(parent_id, t.id, &descriptor) catch return fail(state); architecture.setSystemCallResult(state, id); } /// system_spawn(name_ptr, name_len, arguments_ptr, arguments_len, exit_endpoint) /// -> the child's process id on success, -1 on failure. Load the binary bundled in /// the initial-ramdisk under `name` as a fresh ring-3 process. `name` becomes the /// child's argv[0] (and its task name, so a fault report can say which binary /// died); `arguments` is an optional NUL-separated blob that becomes argv[1..] — /// how a supervisor parameterises what it starts ("you are the driver for device /// 12"). 0/0 means no extra arguments. This is the mechanism a user-space /// supervisor (the device manager) uses to start a driver it matched: discovery /// and policy stay in user space, the kernel only spawns. /// /// The caller is recorded as the child's **supervisor** — the sole holder of the /// right to `process_kill` it (docs/process-management.md). `exit_endpoint` (a /// handle, or `abi.no_cap` for none) names an endpoint of the caller's to notify /// when the child ends, any way it ends — the IRQ-as-IPC pattern reused as the /// microkernel's SIGCHLD. /// /// Spawning itself is still ungated — any process may spawn any bundled binary; a /// spawn capability belongs here once the model grows one (docs/driver-model.md). /// Both buffers are bounds-checked into the user half exactly like `debug_write`, /// and an unknown name or a load failure returns -1. fn systemSpawn(state: *architecture.CpuState) void { const ptr = architecture.systemCallArg(state, 0); const len = architecture.systemCallArg(state, 1); const arguments_ptr = architecture.systemCallArg(state, 2); const arguments_len = architecture.systemCallArg(state, 3); const exit_handle = architecture.systemCallArg(state, 4); const t = scheduler.current(); if (len == 0 or len > scheduler.maximum_task_name or ptr >= user_half_end or ptr + len > user_half_end) return fail(state); if (arguments_len > maximum_argument_bytes) return fail(state); if (arguments_len != 0 and (arguments_ptr >= user_half_end or arguments_ptr + arguments_len > user_half_end)) return fail(state); const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap) null else ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); const image = ramdisk_image orelse return fail(state); const rd = initial_ramdisk.Reader.init(image) orelse return fail(state); const name = @as([*]const u8, @ptrFromInt(ptr))[0..len]; var argv: [maximum_arguments][]const u8 = undefined; argv[0] = name; var argc: usize = 1; if (arguments_len != 0) { const blob = @as([*]const u8, @ptrFromInt(arguments_ptr))[0..arguments_len]; var pieces = std.mem.tokenizeScalar(u8, blob, 0); while (pieces.next()) |piece| { if (argc == maximum_arguments) return fail(state); argv[argc] = piece; argc += 1; } } var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!std.mem.eql(u8, item.name, name)) continue; const child = spawnProcessSupervised(item.blob, 4, argv[0..argc], t.id, exit_endpoint) catch return fail(state); architecture.setSystemCallResult(state, child); return; } fail(state); // no bundled binary by that name } /// thread_spawn(entry, stack_top, arg) -> tid: start a task that shares the **caller's** /// address space (docs/threading.md). The runtime supplies `entry` (its thread /// trampoline), a stack it mmap'd, and the closure pointer, which the kernel delivers in /// the new thread's rdi. The entry and stack must lie in the user half; the new thread is /// supervised by the caller and inherits its priority. Only a user process may spawn. fn systemThreadSpawn(state: *architecture.CpuState) void { const entry = architecture.systemCallArg(state, 0); const stack_top = architecture.systemCallArg(state, 1); const arg = architecture.systemCallArg(state, 2); const exit_handle = architecture.systemCallArg(state, 3); const t = scheduler.current(); if (t.address_space == 0) return fail(state); // kernel tasks own no address space to share if (entry == 0 or entry >= user_half_end) return fail(state); if (stack_top == 0 or stack_top > user_half_end) return fail(state); // The endpoint the thread notifies on exit (how join waits), or none. const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap) null else ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); const tid = spawnThreadSupervised(t.address_space, entry, stack_top, arg, t.priority, t.id, exit_endpoint) orelse return fail(state); architecture.setSystemCallResult(state, tid); } /// Spawn a thread sharing `address_space`, taking the exit-endpoint reference under the **same** /// lock as the spawn (as `spawnProcessSupervised` does), so the thread cannot die before /// its reference exists. Returns the new thread id, or null on resource exhaustion. fn spawnThreadSupervised(address_space: u64, entry: u64, stack_top: u64, arg: u64, priority: scheduler.Priority, supervisor: u32, exit_endpoint: ?*ipc.Endpoint) ?u32 { const flags = sync.enter(); defer sync.leave(flags); const tid = scheduler.spawnUserLocked(address_space, entry, stack_top, arg, priority, "thread", supervisor, if (exit_endpoint) |e| @ptrCast(e) else null) orelse return null; if (exit_endpoint) |endpoint| endpoint.refcount += 1; // the thread holds it birth-to-death return tid; } /// current_core() -> index: the dense 0-based index of the core the caller runs on. fn systemCurrentCore(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, scheduler.currentCpuIndex()); } /// thread_self() -> tid: the calling thread's kernel task id. fn systemThreadSelf(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, scheduler.currentId()); } /// set_thread_pointer(addr) -> 0: set the caller's user-space TLS thread pointer. The /// arch layer maps it to IA32_FS_BASE on x86_64, `TPIDR_EL0` on aarch64; the kernel /// never reads it, and the scheduler restores it per task across context switches /// (docs/threading-plan.md M10). `addr` must be a user-half address. fn systemSetThreadPointer(state: *architecture.CpuState) void { const addr = architecture.systemCallArg(state, 0); const t = scheduler.current(); if (t.address_space == 0) return fail(state); // kernel tasks have no user TLS if (addr >= user_half_end) return fail(state); const flags = sync.enter(); scheduler.setThreadPointerLocked(addr); sync.leave(flags); architecture.setSystemCallResult(state, 0); } /// thread_join(tid) -> 0: block until the thread with id `tid` has exited (docs/threading- /// plan.md M9). Needs no per-thread IPC endpoint. The compare-and-block is one critical /// section, so an exit cannot slip between "is it alive?" and the block. fn systemThreadJoin(state: *architecture.CpuState) void { const tid: u32 = @truncate(architecture.systemCallArg(state, 0)); const t = scheduler.current(); if (t.address_space == 0) return fail(state); // kernel tasks don't join const flags = sync.enter(); scheduler.joinThreadLocked(tid); sync.leave(flags); architecture.setSystemCallResult(state, 0); } /// futex_wait(addr, expected, timeout_ns) -> status (docs/threading.md): if the 4-byte /// user word at `addr` still equals `expected`, block until a futex_wake on `addr` or /// (if timeout_ns > 0) the deadline. The compare and the block are one critical section, /// so a concurrent futex_wake cannot slip between them. Returns futex_woken / mismatch / /// timed_out. fn systemFutexWait(state: *architecture.CpuState) void { const addr = architecture.systemCallArg(state, 0); const expected: u32 = @truncate(architecture.systemCallArg(state, 1)); const timeout_ns = architecture.systemCallArg(state, 2); const t = scheduler.current(); if (t.address_space == 0) return fail(state); if (addr == 0 or (addr & 3) != 0 or addr + 4 > user_half_end) return fail(state); const flags = sync.enter(); var word_bytes: [4]u8 = undefined; if (!ipc.copyFromUser(t.address_space, addr, &word_bytes)) { sync.leave(flags); return fail(state); } if (std.mem.readInt(u32, &word_bytes, .little) != expected) { sync.leave(flags); architecture.setSystemCallResult(state, abi.futex_mismatch); return; } const timeout_ms = if (timeout_ns == 0) 0 else (timeout_ns + 999_999) / 1_000_000; const result = scheduler.futexWaitLocked(addr, timeout_ms); sync.leave(flags); architecture.setSystemCallResult(state, switch (result) { .woken => abi.futex_woken, .timed_out => abi.futex_timed_out, }); } /// futex_wake(addr, count) -> woken: wake up to `count` tasks blocked in futex_wait on /// `addr` in the caller's address space. fn systemFutexWake(state: *architecture.CpuState) void { const addr = architecture.systemCallArg(state, 0); const count: u32 = @truncate(architecture.systemCallArg(state, 1)); const t = scheduler.current(); if (t.address_space == 0) return fail(state); if (addr == 0 or (addr & 3) != 0 or addr + 4 > user_half_end) return fail(state); const flags = sync.enter(); const woken = scheduler.futexWakeLocked(t.address_space, addr, count); sync.leave(flags); architecture.setSystemCallResult(state, woken); } /// process_enumerate(buffer, maximum) -> total: snapshot the task table into the /// caller's buffer (up to `maximum` `abi.ProcessDescriptor` entries), returning /// the total live-task count — the exact shape of `device_enumerate`, so a `ps` /// is a user program over a snapshot, not a kernel service. Read-only and /// ungated: what is running is not a secret between cooperating bring-up /// processes. fn systemProcessEnumerate(state: *architecture.CpuState) void { const buffer_ptr = architecture.systemCallArg(state, 0); const maximum = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0 or buffer_ptr >= user_half_end) return fail(state); const sz = @sizeOf(abi.ProcessDescriptor); const cap = @min(maximum, (user_half_end - buffer_ptr) / sz); // clamp to the user half const out: [*]abi.ProcessDescriptor = @ptrFromInt(buffer_ptr); architecture.setSystemCallResult(state, scheduler.enumerate(out[0..@intCast(cap)])); } /// process_kill(id) -> 0 / -ESRCH / -EPERM: end the process `id`. Only its /// supervisor — the process that spawned it — may do so; the supervision link is /// the kill capability, so no user/permission model is needed and a stray id /// cannot be a weapon (ids are never reused, so a stale one just misses). fn systemProcessKill(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const id = architecture.systemCallArg(state, 0); if (id > std.math.maxInt(u32)) return failErr(state, ipc.ESRCH); const r = killProcess(t.id, @intCast(id)); architecture.setSystemCallResult(state, @bitCast(r)); } /// Processes killed by a CPU fault rather than a clean exit. Evidence for the /// fault-recovery test, and a health signal a supervisor can consult later. pub var fault_kill_count: u64 = 0; /// Release everything a dying task holds and tell its supervisor — the shared /// half of every path out of a process: clean exit, fault kill, and process_kill /// (both the immediate reap and the deferred tick-time terminate). The order /// matters: /// - IRQ bindings are dropped before the handle table closes: dropping the last /// endpoint reference destroys the Endpoint, and a still-bound GSI would have an /// ISR call notifyFromIsr on freed memory the next time the device fired. /// `releaseOwner` also leaves the line masked, so a dead driver's device goes /// quiet rather than storming. (It drops MSI vectors by the same owner sweep.) /// - Device claims are released with the IRQ bindings, so a restarted driver can /// claim the same hardware again — the cleanup half of process-lifecycle.md's /// iron rule 1. Claims hold no pointers, so ordering is free; they go here so /// the exit notification (below, last) observes a fully-released child. /// - A client this task still owes a reply to (it died between receive and reply) /// is failed with -EPEER rather than left blocked forever — a dead server must /// not hang its callers. /// - The task is unlinked from wherever IPC parked it (an endpoint's sender FIFO, /// a receive wait queue, or a server's owed-reply slot) *before* the handles /// close, so nothing ever dequeues a dangling pointer. These are no-ops for a /// running task ending itself; they matter when process_kill reaps a blocked one. /// - The exit notification is posted last, once the process can no longer act, so /// a supervisor that receives it observes a fully-released child. The endpoint /// reference taken at spawn is dropped with it. /// Precondition: the big kernel lock is held. fn releaseTaskResourcesLocked(t: *scheduler.Task) void { recordExitLocked(t); irq.releaseOwner(t.id); devices_broker.releaseAllOwnedBy(t.id); // If that dropped the framebuffer claim (this task was the display service), let the // bootstrap console draw again — the screen is nobody's now, so panics/status land. if (!devices_broker.displayClaimed()) console.setSuppressed(false); // The dying task's signal endpoint and one-shot timers go with it. if (t.signal_endpoint) |raw| { ipc.dropRef(@ptrCast(@alignCast(raw))); t.signal_endpoint = null; } t.pending_signals = 0; for (&one_shot_timers) |*slot| { if (slot.*) |timer| { if (timer.owner == t.id) { ipc.dropRef(timer.endpoint); slot.* = null; } } } // A dead subscriber's own subscriptions go first: it must not hear about // itself, and the slots' endpoint references drop with it. for (&exit_subscribers) |*slot| { if (slot.*) |subscriber| { if (subscriber.owner == t.id) { ipc.dropRef(subscriber.endpoint); slot.* = null; } } } if (t.ipc_client) |client| { t.ipc_client = null; client.ipc_status = -ipc.EPEER; scheduler.readyLocked(client); // its blocked `call` now returns the error } ipc.abandonSenderLocked(t); ipc.killOwnedEndpointsLocked(t.id); // its registered services are gone: callers get -EPEER, not a hang scheduler.removeFromWaitQueueLocked(t); scheduler.forgetIpcClientLocked(t); ipc.closeHandles(t); // Publish the exit to every subscriber (docs/process-lifecycle.md): the same // badge encoding as the supervisor's notification, and equally late, so a // subscriber also observes a fully-released child. for (&exit_subscribers) |*slot| { if (slot.*) |subscriber| ipc.notifyLocked(subscriber.endpoint, abi.notify_exit_bit | t.id); } if (t.exit_endpoint) |raw| { const endpoint: *ipc.Endpoint = @ptrCast(@alignCast(raw)); t.exit_endpoint = null; ipc.notifyLocked(endpoint, abi.notify_exit_bit | t.id); ipc.dropRef(endpoint); } } /// Tear down the current user process and reschedule; never returns. Shared by the /// exit system call and the fault path (`killCurrentProcess`). See /// `releaseTaskResourcesLocked` for what is released, and in what order. pub fn terminateCurrent() noreturn { _ = sync.enter(); // handed off through the exit switch, released by the resumed task terminateCurrentLocked(); } /// The body of `terminateCurrent` for a caller that already holds the big kernel /// lock — the scheduler's tick calls this (via `terminate_current_hook`) to finish /// a deferred process_kill on its own core's current task. Never returns; the /// tick's abandoned interrupt frame is fine (the LAPIC was acknowledged before the /// tick hook ran), exactly as on the fault path. fn terminateCurrentLocked() noreturn { releaseTaskResourcesLocked(scheduler.current()); scheduler.exitUserLocked(); } /// Reap a condemned task that is NOT running on any core (ready or blocked — and /// it cannot start running: state changes need the lock we hold). The other half /// of a deferred process_kill, called by the scheduler's tick (via /// `reap_task_hook`) and directly by `killProcess` for targets caught off-CPU. /// Precondition: the big kernel lock is held. fn reapTaskLocked(t: *scheduler.Task) void { releaseTaskResourcesLocked(t); scheduler.removeFromReadyQueueLocked(t); // no-op unless it was ready in a queue scheduler.destroyTaskLocked(t); } /// Kill process `target_id` on behalf of `caller_id` — the kernel half of the /// process_kill system call. Returns 0, -ESRCH (no such live process — kernel /// tasks are not killable processes and stale ids miss, since ids are never /// reused), or -EPERM (the caller is not the target's supervisor). /// /// A target that is ready or blocked is reaped on the spot. One that is running /// on another core cannot be torn down mid-instruction, so it is condemned /// (`kill_pending`) and dies at its next system_call entry, block, or timer tick /// — like a Unix signal, delivery is prompt but asynchronous. Either way the /// call returns 0: the kill is accepted and irrevocable. pub fn killProcess(caller_id: u32, target_id: u32) i64 { const flags = sync.enter(); defer sync.leave(flags); const target = scheduler.taskByIdLocked(target_id) orelse return -ipc.ESRCH; if (target.address_space == 0) return -ipc.ESRCH; // kernel tasks are not processes if (target.supervisor != caller_id) return -ipc.EPERM; target.exit_reason = .killed; if (target.state == .running) { target.kill_pending = true; } else { reapTaskLocked(target); } return 0; } /// Kill the current user process in response to a CPU fault it raised in ring 3. /// The fault is confined to the process — the kernel trapped it on the task's own /// kernel stack and is intact — so everything the process held is reclaimed and the /// core reschedules. The system keeps running; only the faulting process dies /// (docs/resilience.md: fault -> kill -> continue). `reason` is the fault class /// (from the vector), recorded for the supervisor's `process_exit_reason`. pub fn killCurrentProcess(reason: abi.ExitReason) noreturn { scheduler.current().exit_reason = reason; fault_kill_count += 1; terminateCurrent(); } /// The bounded record of recent deaths, for `process_exit_reason`: ids are never /// reused, so a ring keyed by id is enough — a record evicted by wraparound reads /// as -ESRCH, the same as an id that never lived, which a supervisor treats as /// "too late to ask". Written under the big kernel lock by the reap. const exit_record_capacity = 64; const ExitRecord = struct { id: u32 = 0, supervisor: u32 = 0, reason: abi.ExitReason = .exited, valid: bool = false }; var exit_records: [exit_record_capacity]ExitRecord = .{ExitRecord{}} ** exit_record_capacity; var exit_record_next: usize = 0; /// Record a dying task's (id, supervisor, reason) — called by the reap before the /// exit notification is posted, so a supervisor that hears the notification can /// always still query the reason. Precondition: the big kernel lock is held. fn recordExitLocked(t: *scheduler.Task) void { exit_records[exit_record_next] = .{ .id = t.id, .supervisor = t.supervisor, .reason = t.exit_reason, .valid = true }; exit_record_next = (exit_record_next + 1) % exit_record_capacity; } /// How dead process `id` ended, for `caller` — the kernel half of the /// process_exit_reason system call. Returns the ExitReason value, -ESRCH (never /// lived, still alive, or evicted from the ring), or -EPERM (the caller was not /// its supervisor — the same authority gate as process_kill). pub fn exitReasonOf(caller_id: u32, target_id: u32) i64 { const flags = sync.enter(); defer sync.leave(flags); for (&exit_records) |*record| { if (record.valid and record.id == target_id) { if (record.supervisor != caller_id) return -ipc.EPERM; return @intFromEnum(record.reason); } } return -ipc.ESRCH; } /// The published exit events' subscribers (docs/process-lifecycle.md "Who learns /// of a death"): stateful services — the VFS's file handles, input's /// subscriptions — that must release what a dead client held and cannot learn it /// any other way (a client that simply never calls again looks like silence). /// Bounded like every kernel table; each entry holds its own endpoint reference. const exit_subscriber_capacity = 8; const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 }; var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity; /// process_subscribe(endpoint): subscribe the caller's endpoint to published exit /// events. Ungated, like process_enumerate — what is running (and dying) is not a /// secret between cooperating processes. -ENOSPC when the table is full. fn systemProcessSubscribe(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); const flags = sync.enter(); defer sync.leave(flags); for (&exit_subscribers) |*slot| { if (slot.* == null) { endpoint.refcount += 1; // the slot's own reference, dropped on unsubscribe-by-death slot.* = .{ .endpoint = endpoint, .owner = t.id }; return architecture.setSystemCallResult(state, 0); } } failErr(state, ipc.ENOSPC); } /// signal_bind(endpoint): nominate where this process's signals arrive — the /// IRQ-as-IPC pattern a fourth time (docs/process-lifecycle.md). Replacing a /// binding drops the old reference; signals that pended while unbound are /// delivered immediately on bind, coalesced into one notification. fn systemSignalBind(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); const flags = sync.enter(); defer sync.leave(flags); if (t.signal_endpoint) |raw| ipc.dropRef(@ptrCast(@alignCast(raw))); endpoint.refcount += 1; t.signal_endpoint = @ptrCast(endpoint); if (t.pending_signals != 0) { ipc.notifyLocked(endpoint, abi.notify_signal_bit | t.pending_signals); t.pending_signals = 0; } architecture.setSystemCallResult(state, 0); } /// process_signal(id, signal): post a signal — a one-way, coalescing statement, /// never a question (docs/process-lifecycle.md). The authority gate is the /// supervision link, like kill; a process may also signal itself. Unbound /// targets accumulate the signal in their pending mask. fn systemProcessSignal(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const id = architecture.systemCallArg(state, 0); const signal = architecture.systemCallArg(state, 1); if (id > std.math.maxInt(u32)) return failErr(state, ipc.ESRCH); if (signal > 31) return failErr(state, ipc.EBADF); // not a Signal bit position const flags = sync.enter(); defer sync.leave(flags); const target = scheduler.taskByIdLocked(@intCast(id)) orelse return failErr(state, ipc.ESRCH); if (target.address_space == 0) return failErr(state, ipc.ESRCH); if (target.supervisor != t.id and target.id != t.id) return failErr(state, ipc.EPERM); target.pending_signals |= @as(u32, 1) << @intCast(signal); if (target.signal_endpoint) |raw| { const endpoint: *ipc.Endpoint = @ptrCast(@alignCast(raw)); ipc.notifyLocked(endpoint, abi.notify_signal_bit | target.pending_signals); target.pending_signals = 0; } architecture.setSystemCallResult(state, 0); } /// The one-shot timers of timer_bind: the missing timed wait. A service arms a /// deadline and keeps serving; the expiry arrives in the same replyWait as /// everything else (notify_timer_bit). What stop-sequence escalation, hello /// deadlines, and restart backoff are built from — and later, `alarm`. const timer_capacity = 16; const OneShotTimer = struct { deadline: u64, endpoint: *ipc.Endpoint, owner: u32 }; var one_shot_timers: [timer_capacity]?OneShotTimer = .{null} ** timer_capacity; /// Sweep expired timers — hung on scheduler.timer_tick_hook, so it runs on every /// tick with the big kernel lock held, like the sleeper wake it rides beside. fn timerSweepLocked() void { const now = architecture.millis(); for (&one_shot_timers) |*slot| { if (slot.*) |timer| { if (now >= timer.deadline) { ipc.notifyLocked(timer.endpoint, abi.notify_timer_bit); ipc.dropRef(timer.endpoint); slot.* = null; } } } } /// timer_bind(endpoint, ms): arm a one-shot timer. -ENOSPC when the table is full. fn systemTimerBind(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); const ms = architecture.systemCallArg(state, 1); const flags = sync.enter(); defer sync.leave(flags); for (&one_shot_timers) |*slot| { if (slot.* == null) { endpoint.refcount += 1; slot.* = .{ .deadline = architecture.millis() + ms, .endpoint = endpoint, .owner = t.id }; return architecture.setSystemCallResult(state, 0); } } failErr(state, ipc.ENOSPC); } fn systemProcessExitReason(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const id = architecture.systemCallArg(state, 0); if (id > std.math.maxInt(u32)) return failErr(state, ipc.ESRCH); const r = exitReasonOf(t.id, @intCast(id)); architecture.setSystemCallResult(state, @bitCast(r)); } /// Resolve `(device_id, resource_index)` to a GSI this process is entitled to bind, or null. /// The two checks are the whole security story: the device must be *claimed* by the /// caller, and the resource must be one of that device's `irq` resources as recorded /// by discovery. Neither a raw GSI nor an unclaimed device can get through — which /// is why irq_bind takes a resource index and not an interrupt number. fn ownedGsi(t: *scheduler.Task, device_id: u64, resource_index: u64) ?u32 { const owner = devices_broker.ownerOf(device_id) orelse return null; if (owner != t.id) return null; const r = devices_broker.resourceOf(device_id, resource_index) orelse return null; if (r.kind != @intFromEnum(device_abi.ResourceKind.irq)) return null; if (r.start >= irq.maximum_gsi) return null; return @intCast(r.start); } /// irq_bind(device_id, resource_index, endpoint) -> 0/-1: deliver that device's IRQ to the /// endpoint as an asynchronous IPC notification. The driver then blocks in /// IPC_ReplyWait and is woken by the ISR; see system/kernel/irq.zig for the cycle. fn systemIrqBind(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 2)) orelse return fail(state); const flags = sync.enter(); defer sync.leave(flags); irq.bind(gsi, endpoint, t.id) catch return fail(state); architecture.setSystemCallResult(state, 0); } /// msi_bind(device_id, endpoint_handle) -> address (rax), data (rdx): set up /// Message-Signalled Interrupts for a claimed device. The kernel allocates a vector, /// binds it to `endpoint`, and hands back the (address, data) the driver programs into /// its own MSI capability (found via its ECAM config space, resource 0). Unlike /// `irq_bind` there is no GSI, no sharing, and no ack — MSI is edge-triggered. The /// claim is the capability. See docs/driver-model.md (M15). fn systemMsiBind(state: *architecture.CpuState) void { const device_id = architecture.systemCallArg(state, 0); const t = scheduler.current(); if (t.address_space == 0) return fail(state); const owner = devices_broker.ownerOf(device_id) orelse return fail(state); if (owner != t.id) return fail(state); // not claimed by this process const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF); const flags = sync.enter(); defer sync.leave(flags); const vector = irq.msiBind(endpoint, t.id) catch return fail(state); // x86 MSI: the address routes to the BSP's LAPIC (physical destination, fixed // delivery — dest field 0); the data carries the vector. architecture.setSystemCallResult(state, abi.msi_address_base); architecture.setSystemCallResult2(state, vector); } /// irq_ack(device_id, resource_index) -> 0/-1: re-arm a bound IRQ. The ISR left the line /// masked (it could not quiet the device — that's this driver's job), so nothing /// more arrives until the driver says it has serviced the hardware. fn systemIrqAck(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse return fail(state); const flags = sync.enter(); defer sync.leave(flags); if (irq.ack(gsi)) architecture.setSystemCallResult(state, 0) else fail(state); } /// Whether the debug_write stream sits at the start of a line — the last emitted /// byte was a newline (true at boot: nothing emitted yet). Guarded by the kernel /// lock in `systemDebugWrite`, like the stream it describes. var write_at_line_start: bool = true; /// debug_write(ptr, len): copy bytes from user memory into the kernel log. /// A bring-up diagnostic — real output goes through the VFS/console later. /// /// The pointer must lie in the user (low) half, so kernel addresses and /// non-canonical values fall outside it and the read below can't be steered at /// kernel data. Length is checked first so the upper-bound add can't overflow. /// Known gap (fine for trusted user code): a pointer into an *unmapped* hole in /// the user half passes the check and the read #PFs -> on_fault halts — a /// self-DoS, not an isolation break. Fault-recovering copy-in is a later item. /// /// The emit runs under the kernel lock, so a message is atomic on the wire — two /// processes writing from different cores can interleave *messages*, never bytes. /// The "DANOS-INIT: " marker is emitted only at the start of a line (not per /// call), so a process may assemble a line from several writes without the marker /// (or, with the lock, another byte) landing in the middle. Cleanly-terminated /// lines from concurrent writers stay whole either way. fn systemDebugWrite(state: *architecture.CpuState) void { const ptr = architecture.systemCallArg(state, 0); const len = architecture.systemCallArg(state, 1); if (len <= write_buffer.len and ptr < user_half_end and ptr + len <= user_half_end) { const source: [*]const u8 = @ptrFromInt(ptr); const flags = sync.enter(); defer sync.leave(flags); @memcpy(write_buffer[0..len], source[0..len]); // keep the latest message write_len = len; write_from_user = architecture.fromUser(state); write_count += 1; log.write(source[0..len]); if (len != 0) write_at_line_start = source[len - 1] == '\n'; architecture.setSystemCallResult(state, len); } else { fail(state); } } /// klog_read(offset, ptr, len) -> bytes copied: copy the kernel's in-memory /// diagnostic log (the RAM sink in log.zig) out to the user buffer at `ptr`, /// starting at `offset`. Returns the count copied — 0 once `offset` reaches the /// end — so a program reads the whole log by looping from 0 until it gets 0. /// /// The mirror of `debug_write`: the same overflow-safe user-half bounds check, /// but the copy runs kernel -> user. Written under the kernel lock so the source /// snapshot can't grow underneath the copy. A read-only diagnostic — it exposes /// only the log the kernel already broadcasts to serial, nothing else. fn systemKlogRead(state: *architecture.CpuState) void { const offset = architecture.systemCallArg(state, 0); const ptr = architecture.systemCallArg(state, 1); const len = architecture.systemCallArg(state, 2); // Confine the whole destination span to the user (low) half. `len <= // user_half_end - ptr` bounds the length without an overflowing add. if (ptr < user_half_end and len <= user_half_end - ptr) { const flags = sync.enter(); defer sync.leave(flags); const snapshot = log.ramSnapshot(); var n: usize = 0; if (offset < snapshot.len) { n = @min(len, snapshot.len - offset); const dest: [*]u8 = @ptrFromInt(ptr); @memcpy(dest[0..n], snapshot[offset..][0..n]); } architecture.setSystemCallResult(state, n); } else { fail(state); } } /// mmap(len, prot) -> base: grant `len` bytes (rounded up to whole pages) of /// fresh, zeroed, writable+NX memory in the caller's mmap arena, and return the /// base virtual address. `prot` is accepted but not yet honoured (grants are /// always RW+NX; W^X for user code stays with the ELF loader). Failure returns /// -1. The user-space allocator (lib `runtime`) carves these pages into malloc blocks. fn systemMmap(state: *architecture.CpuState) void { const len = architecture.systemCallArg(state, 0); const t = scheduler.current(); if (t.address_space == 0) return fail(state); // not a user process — nothing to map into const pages = (len + page_size - 1) / page_size; if (pages == 0 or pages > maximum_mmap_pages) return fail(state); // Reserve a disjoint range under a *brief* lock (the cursor is shared by every thread // in this address space). The mapping below then takes the lock **per page**, not for // the whole grant: the big lock is held with interrupts disabled, so pinning it across // a multi-MiB memset+map would freeze every other core on its next tick — which timed // the `affinity` scenario out (docs/threading-plan.md M7). const base = reserve: { const flags = sync.enter(); defer sync.leave(flags); const cursor = scheduler.addressSpaceMmapNextPtr(t.address_space) orelse return fail(state); if (cursor.* == 0) cursor.* = heap_arena_base; // seed the arena lazily const b = cursor.*; if (b + pages * page_size > heap_arena_end) return fail(state); // arena exhausted cursor.* = b + pages * page_size; // reserve now, so concurrent grants can't overlap break :reserve b; }; // Map the reserved range page by page, each page under a short-held lock (the range is // already reserved, so pages can't overlap another thread's; the lock only serializes // the shared page-table walk). On mid-way frame exhaustion, roll back the mapped pages // so no partial grant leaks — the reserved-but-unmapped tail of the arena is left // fallow (a rare, bounded address-space leak, not a memory leak). var mapped: usize = 0; while (mapped < pages) : (mapped += 1) { const flags = sync.enter(); const frame = pmm.alloc() orelse { var i: usize = 0; while (i < mapped) : (i += 1) { const va = base + i * page_size; if (architecture.translate(t.address_space, va)) |physical| { architecture.unmapUserPageInto(t.address_space, va); pmm.free(physical); } } sync.leave(flags); return fail(state); }; const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame)); @memset(destination[0..page_size], 0); // hand out zeroed memory architecture.mapUserPageInto(t.address_space, base + mapped * page_size, frame, true, false); // RW + NX sync.leave(flags); } architecture.setSystemCallResult(state, base); // the cursor was already advanced at reserve } /// munmap(base, len): release a range previously handed out by `mmap`. Unmaps /// each page and frees its frame. The arena is a bump allocator, so the virtual /// range is not recycled (the user-space allocator reuses freed *blocks* itself); /// this just returns the physical frames to the kernel. Returns 0, or -1 if the /// range is not page-aligned or lies outside the arena. fn systemMunmap(state: *architecture.CpuState) void { const base = architecture.systemCallArg(state, 0); const len = architecture.systemCallArg(state, 1); const t = scheduler.current(); if (t.address_space == 0 or base % page_size != 0) return fail(state); const pages = (len + page_size - 1) / page_size; if (base < heap_arena_base or base + pages * page_size > heap_arena_end) return fail(state); for (0..pages) |i| { const va = base + i * page_size; if (architecture.translate(t.address_space, va)) |physical| { architecture.unmapUserPageInto(t.address_space, va); pmm.free(physical); } } architecture.setSystemCallResult(state, 0); } /// Reset the recorded system_call evidence before a user-mode run. fn resetRecords() void { write_len = 0; write_from_user = false; write_count = 0; exit_code = 0; } pub const RunError = error{ ProgramTooBig, OutOfMemory }; /// Map `blob` at code_virtual with a fresh user stack, drop to ring 3, and return /// once the program exits via system_call 0. See the migration caveat in the module /// doc. A program that faults instead never returns (on_fault halts the core). pub fn run(blob: []const u8) RunError!void { if (blob.len > page_size) return error.ProgramTooBig; const code_frame = pmm.alloc() orelse return error.OutOfMemory; const stack_frame = pmm.alloc() orelse { pmm.free(code_frame); return error.OutOfMemory; }; // Fill the code frame through the physmap (supervisor RW): the user-facing // mapping is read-only, and this also sidesteps CR0.WP/SMAP. The tail is // padded with int3 so a stray jump traps instead of sliding. const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(code_frame)); @memcpy(code[0..blob.len], blob); @memset(code[blob.len..page_size], 0xCC); architecture.mapUserPage(code_virtual, code_frame, false, true); // RO + X architecture.mapUserPage(stack_base_virtual, stack_frame, true, false); // RW + NX (one page; the probe barely stacks) resetRecords(); architecture.enterUser(scheduler.currentCpuIndex(), code_virtual, stack_base_virtual + page_size); // Back via the exit system_call; the interrupt gate left IF clear. architecture.enableInterrupts(); architecture.unmapPage(code_virtual); architecture.unmapPage(stack_base_virtual); pmm.free(code_frame); pmm.free(stack_frame); } // --- user ELF loading (/system/services/init) ------------------------------------------ pub const InitError = error{ BadElf, // malformed/inapplicable image (magic, class, machine, type, bounds) BadSegment, // PT_LOAD unaligned, out of the user region, W&X, or overlapping BadEntry, // e_entry not inside an executable segment BadArguments, // no argv[0], too many entries, or too many bytes for the entry stack ProgramTooBig, // more pages than the loader's budget OutOfMemory, }; const maximum_segments = 16; const maximum_pages = 256; // 1 MiB loader budget; the user region caps at 2 MiB anyway const Segment = struct { virtual_address: u64, memsz: u64, filesz: u64, off: u64, writable: bool, executable: bool, fn pages(self: Segment) u64 { return (self.memsz + page_size - 1) / page_size; } }; /// Parse and validate every PT_LOAD before touching memory. Bounds are checked /// against the image and the user region; segments must be page-aligned, /// non-overlapping, and W^X (R-only is fine — linkers may emit a headers-only /// segment, mapped RO+NX). fn parseSegments(image: []const u8, segs: *[maximum_segments]Segment) InitError!struct { count: usize, entry: u64 } { if (image.len < @sizeOf(elf.Elf64_Ehdr)) return error.BadElf; const ehdr = std.mem.bytesToValue(elf.Elf64_Ehdr, image[0..@sizeOf(elf.Elf64_Ehdr)]); if (!std.mem.eql(u8, image[0..4], "\x7fELF")) return error.BadElf; if (image[elf.EI_CLASS] != elf.ELFCLASS64) return error.BadElf; if (ehdr.e_machine != .X86_64) return error.BadElf; if (ehdr.e_type != .EXEC) return error.BadElf; // a PIE would need relocation if (ehdr.e_phentsize < @sizeOf(elf.Elf64_Phdr)) return error.BadElf; if (ehdr.e_phnum > maximum_segments) return error.BadElf; const ph_bytes = @as(u64, ehdr.e_phnum) * ehdr.e_phentsize; if (ehdr.e_phoff > image.len or ph_bytes > image.len - ehdr.e_phoff) return error.BadElf; var count: usize = 0; var total_pages: u64 = 0; for (0..ehdr.e_phnum) |i| { const off = ehdr.e_phoff + i * ehdr.e_phentsize; const phdr = std.mem.bytesToValue(elf.Elf64_Phdr, image[off..][0..@sizeOf(elf.Elf64_Phdr)]); if (phdr.p_type != elf.PT_LOAD) continue; if (phdr.p_memsz == 0) continue; if (phdr.p_vaddr % page_size != 0) return error.BadSegment; if (phdr.p_filesz > phdr.p_memsz) return error.BadSegment; if (phdr.p_offset > image.len or phdr.p_filesz > image.len - phdr.p_offset) return error.BadSegment; // Inside the user image region, strictly below the stack page. if (phdr.p_vaddr < code_virtual) return error.BadSegment; if (phdr.p_memsz > stack_virtual - phdr.p_vaddr) return error.BadSegment; const w = phdr.p_flags & elf.PF_W != 0; const x = phdr.p_flags & elf.PF_X != 0; if (w and x) return error.BadSegment; // W^X, even for init const seg = Segment{ .virtual_address = phdr.p_vaddr, .memsz = phdr.p_memsz, .filesz = phdr.p_filesz, .off = phdr.p_offset, .writable = w, .executable = x, }; // No overlap with any earlier segment (page-granular, since mapping is). for (segs[0..count]) |other| { const a_end = seg.virtual_address + seg.pages() * page_size; const b_end = other.virtual_address + other.pages() * page_size; if (seg.virtual_address < b_end and other.virtual_address < a_end) return error.BadSegment; } total_pages += seg.pages(); if (total_pages > maximum_pages) return error.ProgramTooBig; segs[count] = seg; count += 1; } if (count == 0) return error.BadElf; // The entry point must land inside an executable segment. for (segs[0..count]) |seg| { if (seg.executable and ehdr.e_entry >= seg.virtual_address and ehdr.e_entry < seg.virtual_address + seg.memsz) return .{ .count = count, .entry = ehdr.e_entry }; } return error.BadEntry; } /// Load one page of a segment into address space `address_space`: a fresh frame, zeroed /// and filled through the physmap, mapped user-accessible with the segment's W^X. /// On a later failure the whole address space is torn down, which frees every /// frame mapped into it — so no per-page rollback list is needed here. fn loadPageInto(address_space: u64, image: []const u8, seg: Segment, page_index: u64) InitError!void { const frame = pmm.alloc() orelse return error.OutOfMemory; const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame)); @memset(destination[0..page_size], 0); const page_off = page_index * page_size; if (page_off < seg.filesz) { const n = @min(page_size, seg.filesz - page_off); @memcpy(destination[0..n], image[seg.off + page_off ..][0..n]); } architecture.mapUserPageInto(address_space, seg.virtual_address + page_off, frame, seg.writable, seg.executable); } /// Build the System V AMD64 process-entry block at the top of a process's stack /// page and return the initial user stack pointer. At the first user instruction, /// rsp is 16-byte aligned and points at (addresses growing upward): /// /// argc, argv[0..argc-1], NULL, NULL (empty envp), auxiliary vector, strings /// /// — the layout every C runtime's startup code walks, so danos's own runtime and a /// future libc port read arguments identically (docs/sysv.md). `page` is the kernel /// (physmap) view of the stack's **top** frame and `page_user_base` that frame's /// user address (stack_top_virtual - page_size); the pointers written into it are /// user addresses inside that page. The caller has validated the sizes /// (`entryStackBytes`), so this cannot overrun. fn buildEntryStack(page: [*]u8, page_user_base: u64, argv: []const []const u8) u64 { // The strings live at the very top of the page, packed from the end downward. var string_offset: usize = page_size; var pointers: [maximum_arguments]u64 = undefined; var i: usize = argv.len; while (i > 0) { i -= 1; string_offset -= argv[i].len + 1; @memcpy(page[string_offset..][0..argv[i].len], argv[i]); page[string_offset + argv[i].len] = 0; // NUL-terminated, as C expects pointers[i] = page_user_base + string_offset; } // The vector sits below the strings: argc, the argv pointers, the argv // terminator, an empty envp (terminator only), then the auxiliary vector. const word_count = 1 + argv.len + 1 + 1 + 4; const vector_offset = (string_offset - word_count * 8) & ~@as(usize, 15); // entry rsp % 16 == 0 const words: [*]u64 = @ptrCast(@alignCast(page + vector_offset)); var w: usize = 0; words[w] = argv.len; // argc w += 1; for (pointers[0..argv.len]) |pointer| { words[w] = pointer; w += 1; } words[w] = 0; // argv terminator words[w + 1] = 0; // envp: no environment yet, just the terminator words[w + 2] = auxiliary_vector_page_size; words[w + 3] = page_size; words[w + 4] = auxiliary_vector_null; // end of the auxiliary vector words[w + 5] = 0; return page_user_base + vector_offset; } /// Bytes the entry block for `argv` occupies at the top of the stack page: /// strings (each NUL-terminated), vector words, and the alignment slack. fn entryStackBytes(argv: []const []const u8) usize { var string_bytes: usize = 0; for (argv) |argument| string_bytes += argument.len + 1; return string_bytes + (1 + argv.len + 1 + 1 + 4) * 8 + 16; } /// Load a user ELF image into a fresh address space and spawn it as a scheduled /// ring-3 process at `priority`, entered with `argv` on its stack per the System V /// convention (`buildEntryStack`). `argv[0]` is required — it names the process: /// the path or initial-ramdisk name it was spawned as. It is also recorded on the /// task, so a fault report can say *which* binary died, not just its id. /// The kernel-internal spawn (init at boot, tests): supervisor 0, no exit /// notification. `spawnProcessSupervised` is the full form. pub fn spawnProcess(image: []const u8, priority: u3, argv: []const []const u8) InitError!void { _ = try spawnProcessSupervised(image, priority, argv, 0, null); } /// `spawnProcess`, recording `supervisor` (the id of the process that asked — the /// kill authority) and, if given, `exit_endpoint` to notify when the child ends /// (a reference is taken here and dropped when the notification posts). /// Returns the child's process id. /// Returns immediately — the process runs preemptively on its own page tables /// alongside everything else, and its exit is handled by the system_call layer. /// The whole build (address space + ELF load + task) runs under the kernel lock so /// it appears atomically and can't race pmm/heap on another core. pub fn spawnProcessSupervised(image: []const u8, priority: u3, argv: []const []const u8, supervisor: u32, exit_endpoint: ?*ipc.Endpoint) InitError!u32 { if (argv.len == 0 or argv.len > maximum_arguments) return error.BadArguments; // The entry block must leave most of the page as actual stack. if (entryStackBytes(argv) > page_size / 2) return error.BadArguments; var segs: [maximum_segments]Segment = undefined; const parsed = try parseSegments(image, &segs); const flags = sync.enter(); defer sync.leave(flags); const address_space = architecture.createAddressSpace() orelse return error.OutOfMemory; errdefer architecture.destroyAddressSpace(address_space); for (segs[0..parsed.count]) |seg| { for (0..seg.pages()) |i| try loadPageInto(address_space, image, seg, i); } // The stack: `user_stack_pages` zeroed pages below stack_top_virtual, RW + NX. // The page below them (`stack_virtual`) stays unmapped as the overflow guard. // The entry block goes at the top of the highest page. var user_sp: u64 = 0; for (0..parameters.user_stack_pages) |i| { const stack_frame = pmm.alloc() orelse return error.OutOfMemory; const stack_page: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(stack_frame)); @memset(stack_page[0..page_size], 0); // no stale frame contents leak into user space const page_virtual = stack_base_virtual + i * page_size; if (i == parameters.user_stack_pages - 1) user_sp = buildEntryStack(stack_page, page_virtual, argv); architecture.mapUserPageInto(address_space, page_virtual, stack_frame, true, false); // RW + NX } const child = scheduler.spawnUserLocked(address_space, parsed.entry, user_sp, 0, priority, argv[0], supervisor, if (exit_endpoint) |endpoint| @ptrCast(endpoint) else null) orelse return error.OutOfMemory; // The child holds a reference to its exit endpoint from birth to death. Taken // only now, after nothing can fail; the lock is still held, so the child // cannot run (let alone die) before the reference exists. if (exit_endpoint) |endpoint| endpoint.refcount += 1; return child; } /// clock() -> nanoseconds since boot: a monotonic time source. The kernel already owns /// the scheduling timer and computes this for preemption, so surfacing it is pure /// mechanism — no policy (wall-clock time, calendars, timezones are a user-space /// service layered on top). It lets a driver bound a poll loop by real time instead of /// a spin count, and time short delays. fn systemClock(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, architecture.nanos()); } /// wall_clock() -> Unix epoch seconds (UTC). The RTC value, read at boot and offset /// by the monotonic clock (wall-clock.zig) — mechanism, not policy: calendars and /// timezones layer on top in user space. Needed for filesystem timestamps (mtime). fn systemWallClock(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, wall_clock.nowSeconds()); }