From 1b47de5058c737b9a403438e0bdffb485319ecf9 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:04:18 +0100 Subject: [PATCH] allocate AP kernel/IST stacks at bring-up, not statically Hoist max_cpus into danos (root.zig), raise it to 128, and stop reserving a static [max_cpus][16 KiB] IST array. Only the BSP's IST stack is static (needed before the allocator); each AP's is heap-allocated when it comes online. Shrinks the kernel from ~1.1 MB to ~139 KiB in ReleaseSmall. --- docs/smp.md | 7 +++++++ src/device/acpi.zig | 3 ++- src/kernel/arch/x86_64/cpu.zig | 8 ++++++++ src/kernel/arch/x86_64/gdt.zig | 4 +++- src/kernel/arch/x86_64/tss.zig | 26 +++++++++++++++++++------- src/kernel/main.zig | 7 +++++++ src/kernel/scheduler.zig | 3 ++- src/root.zig | 8 ++++++++ 8 files changed, 56 insertions(+), 10 deletions(-) diff --git a/docs/smp.md b/docs/smp.md index e11760b..3bd81b1 100644 --- a/docs/smp.md +++ b/docs/smp.md @@ -203,6 +203,13 @@ next lands. [scheduling.md](scheduling.md#affinity-pinning-a-task-to-a-core)). The `affinity` test confirms a pinned task never migrates. This is the mechanism the fault-on-AP test rides on, and the *explicit-affinity* real-time-predictable model. +- **Right-sized footprint** — the per-CPU ceiling (`danos.max_cpus`, one constant + shared by discovery, the scheduler, and the per-core GDT/TSS) is generous (128), but + the *large* per-core resources — the kernel and IST (double-fault) stacks — are + **heap-allocated at bring-up**, only for cores that actually come online. Only the + BSP's IST stack is static, because it must exist before the frame allocator does. + This kept the kernel image small (a static `[128][16 KiB]` IST array would have been + 2 MiB of `.bss`); it's a few tens of KiB instead. - **`single_threaded` off** — the kernel was built `single_threaded = true`, which compiles `std.atomic` down to plain non-atomic ops. Harmless on one core, but it quietly breaks the big kernel lock across cores; it's now `false`. diff --git a/src/device/acpi.zig b/src/device/acpi.zig index 1a8ecdf..5aba781 100644 --- a/src/device/acpi.zig +++ b/src/device/acpi.zig @@ -15,6 +15,7 @@ //! the `Hal.mapMmio` callback the caller supplies (the arch VMM's map primitive). const std = @import("std"); +const danos = @import("danos"); const device = @import("device.zig"); const aml = @import("aml/aml.zig"); const DeviceTree = device.DeviceTree; @@ -115,7 +116,7 @@ pub const CpuInfo = struct { dropped: usize = 0, }; -const max_cpus = 64; +const max_cpus = danos.max_cpus; /// Filled in by `discover` (from the MADT); SMP bring-up reads it to wake the APs. pub var cpu_info: CpuInfo = .{}; diff --git a/src/kernel/arch/x86_64/cpu.zig b/src/kernel/arch/x86_64/cpu.zig index da9181d..a6b2950 100644 --- a/src/kernel/arch/x86_64/cpu.zig +++ b/src/kernel/arch/x86_64/cpu.zig @@ -124,6 +124,14 @@ pub fn setSecondaryEntry(entry: *const fn () callconv(.c) noreturn) void { smp.setSecondaryEntry(entry); } +/// Bytes the kernel should allocate for an AP's IST (double-fault) stack, and where +/// to record its top before waking the core. The stack is heap-allocated per online +/// AP (the BSP's is static — it's needed before the allocator exists). See tss.zig. +pub const ist_stack_size = tss.ist_stack_size; +pub fn setApIstStack(cpu: usize, top: usize) void { + tss.setApIstStack(cpu, top); +} + /// Test hook: force the next `n` AP wake attempts to fail, so the retry path can be /// exercised deterministically (see the smp-retry test). No effect when `n` is 0. pub fn testFailNextWakes(n: u32) void { diff --git a/src/kernel/arch/x86_64/gdt.zig b/src/kernel/arch/x86_64/gdt.zig index bd07907..9d45819 100644 --- a/src/kernel/arch/x86_64/gdt.zig +++ b/src/kernel/arch/x86_64/gdt.zig @@ -9,12 +9,14 @@ //! tss.zig). Two cores can't share one TSS descriptor slot, so each core gets its //! own copy of the table with its own TSS descriptor. Slot 0 is the BSP. +const danos = @import("danos"); + /// Selectors into the table (index * 8). Same on every core's GDT. pub const kernel_code = 0x08; pub const kernel_data = 0x10; pub const tss_selector = 0x18; -const max_cpus = 64; // matches the scheduler / discovery pool +const max_cpus = danos.max_cpus; const entries = 5; // null, code, data, TSS-low, TSS-high /// The shared descriptors (slots 0-2); slots 3-4 hold this core's TSS descriptor, diff --git a/src/kernel/arch/x86_64/tss.zig b/src/kernel/arch/x86_64/tss.zig index ca92983..8237f0d 100644 --- a/src/kernel/arch/x86_64/tss.zig +++ b/src/kernel/arch/x86_64/tss.zig @@ -9,6 +9,7 @@ //! once can't share one fault stack. So the TSS and its IST stack are per-core, //! indexed by CPU number; slot 0 is the BSP. +const danos = @import("danos"); const gdt = @import("gdt.zig"); /// x86_64 TSS. `packed` because several 64-bit fields sit at 4-byte-unaligned @@ -34,24 +35,35 @@ const Tss = packed struct { /// The IST slot (1-based, as the IDT gate encodes it) used for critical faults. pub const double_fault_ist = 1; -const max_cpus = 64; // matches gdt.zig / the scheduler -const ist_stack_size = 16 * 1024; +const max_cpus = danos.max_cpus; +pub const ist_stack_size = 16 * 1024; -/// One TSS per core, and one IST1 stack per core. Static, so they need no allocator -/// and are always valid. (max_cpus × 16 KiB of BSS for the IST stacks.) +/// One TSS per core (small — kept static). The IST stacks are 16 KiB each, so only +/// the **BSP's** is static: it must exist before the frame allocator does, to catch a +/// fault during early boot. Each **AP** gets a heap-allocated IST stack at bring-up +/// (after the heap is up), the top of which the BSP records here before waking it — +/// so we reserve big stacks only for cores that actually come online. var tss_table = [_]Tss{.{}} ** max_cpus; -var ist_stacks: [max_cpus][ist_stack_size]u8 align(16) = undefined; +var bsp_ist_stack: [ist_stack_size]u8 align(16) = undefined; +var ap_ist_top = [_]usize{0} ** max_cpus; // per-AP IST stack top (0 = BSP / not set) /// Loads the task register with the TSS selector. Defined in isr.s. extern fn load_tr(selector: u16) callconv(.c) void; -/// Set up core `cpu`'s TSS: point IST1 at that core's stack, install the TSS +/// Record the top of the IST stack the kernel allocated for AP `cpu`. Called on the +/// BSP before waking that core; read by the core's own `setupThisCpu`. +pub fn setApIstStack(cpu: usize, top: usize) void { + ap_ist_top[cpu] = top; +} + +/// Set up core `cpu`'s TSS: point IST1 at its stack (the BSP's static one for core 0, +/// the allocated one recorded via `setApIstStack` for an AP), install the TSS /// descriptor into that core's GDT, and load it into the task register. Requires the /// core's GDT to already be loaded (gdt.loadOnThisCpu first). pub fn setupThisCpu(cpu: usize) void { const t = &tss_table[cpu]; t.* = .{}; - t.ist1 = @intFromPtr(&ist_stacks[cpu]) + ist_stack_size; // stacks grow down + t.ist1 = if (cpu == 0) @intFromPtr(&bsp_ist_stack) + ist_stack_size else ap_ist_top[cpu]; t.iomap_base = @sizeOf(Tss); // == limit: no I/O permission bitmap gdt.setTssFor(cpu, @intFromPtr(t), @sizeOf(Tss) - 1); load_tr(gdt.tss_selector); diff --git a/src/kernel/main.zig b/src/kernel/main.zig index f62e063..babd1b7 100644 --- a/src/kernel/main.zig +++ b/src/kernel/main.zig @@ -283,6 +283,13 @@ fn bringUpSecondaries() void { continue; }; const stack_top = (@intFromPtr(stack.ptr) + stack.len) & ~@as(usize, 15); + // This core's IST (double-fault) stack — allocated only now that the core is + // real, rather than reserved statically for every possible core. + const ist = heap.allocator().alloc(u8, arch.ist_stack_size) catch { + log.print(" cpu apic_id {d}: no IST stack; skipped\n", .{core.apic_id}); + continue; + }; + arch.setApIstStack(index, (@intFromPtr(ist.ptr) + ist.len) & ~@as(usize, 15)); const pc = scheduler.prepareSecondary(index, core.apic_id); var attempt: u32 = 1; while (attempt <= max_wake_attempts) : (attempt += 1) { diff --git a/src/kernel/scheduler.zig b/src/kernel/scheduler.zig index a70b06c..2aab53d 100644 --- a/src/kernel/scheduler.zig +++ b/src/kernel/scheduler.zig @@ -18,6 +18,7 @@ //! shared queues. const std = @import("std"); +const danos = @import("danos"); const arch = @import("arch"); const heap = @import("heap.zig"); const sync = @import("sync.zig"); @@ -68,7 +69,7 @@ pub const PerCpu = struct { pinned_bitmap: u8 = 0, }; -const max_cpus = 64; // matches the discovery pool (src/device/acpi.zig) +const max_cpus = danos.max_cpus; var cpus = [_]PerCpu{.{}} ** max_cpus; /// This core's per-CPU state, via the arch layer's GS-base pointer. Valid only once diff --git a/src/root.zig b/src/root.zig index 25924e0..a1d7b93 100644 --- a/src/root.zig +++ b/src/root.zig @@ -45,6 +45,14 @@ pub const Framebuffer = extern struct { /// targets so far. pub const page_size = 4096; +/// Ceiling on logical CPUs the kernel tracks — the size of the per-CPU bookkeeping +/// arrays (discovery pool, scheduler state, per-core GDT/TSS). A generous headroom: +/// these structs are small (a few hundred bytes each), and the *large* per-core +/// resources (IST/kernel stacks) are allocated at bring-up, only for cores that +/// actually come online — so this ceiling costs little. A machine with more logical +/// CPUs than this has its surplus reported and left parked (see acpi `cpusDropped`). +pub const max_cpus = 128; + /// danos's own classification of a span of physical memory — deliberately not /// UEFI's vocabulary. Each boot path (UEFI now, device tree later) translates its /// native memory description into these kinds, so the kernel never learns what