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.
This commit is contained in:
Daniel Samson 2026-07-08 16:04:18 +01:00
parent 26ac97df31
commit 1b47de5058
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
8 changed files with 56 additions and 10 deletions

View File

@ -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`.

View File

@ -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 = .{};

View File

@ -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 {

View File

@ -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,

View File

@ -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);

View File

@ -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) {

View File

@ -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

View File

@ -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