Secondary cores
On this page
KoraOS brings up every core of the machine (up to four) and runs programs on all of them. Device interrupts and the Circle USB stack stay on core 0 for good (see locking.md).
Releasing the cores
Core 0 calls smp_start_secondaries() (src/arch/smp.c)
once its MMU, interrupt controller and system tick are up:
- QEMU virt:
PSCI CPU_ONfor eachcpu@Nnode of the device tree, through the conduit its/pscinode names:hvcwithout EL2,smcwithvirtualization=on(the--el2smoke profile). - Raspberry Pi 3 and 4 (and QEMU raspi3b): the firmware’s ARM stub parks
cores 1 to 3 in
WFE, each polling its 64-bit slot of the spin table at0xd8 + 8 * core. Core 0 makes the firmware page writable, storessecondary_entryin the slot, writes the slot back to memory (the parked core reads it with its caches off) and sendsSEV.
Core 0 waits up to a second for each core to report itself online and prints
smp: cpu N online (or that it did not come up), then smp: N cores online.
A core’s way up
A released core enters secondary_entry in src/boot.S at
EL2 (or EL3, or EL1 under PSCI without EL2), drops to EL1 through the same
code as core 0, takes its own 16 KB boot stack, traps FP/SIMD like core 0, and
calls secondary_main(id):
percpu_attach(id): onlyTPIDR_EL1.mmu_enable_this_core(): the page tables core 0 built, with the MMU and caches on.- Then, and only then, everything else: its per-CPU entry, the exception
vectors, an idle task (
idleN), its side of the interrupt controller (the GIC CPU interface, or its routing register in the BCM2836 local controller), and its own generic-timer tick. It marks itself online and waits inWFIwith interrupts enabled.
The order in step 3 is not a detail. Until its MMU is on, a core’s memory accesses bypass the caches while core 0’s are cached:
- It must not take a spinlock: exclusives need Normal cacheable memory, and on
real Cortex-A53/A72 cores the store-exclusive otherwise fails forever. The
locks check this (
caches_on) and panic instead of hanging. - It must not write anything shared: a dirty line in another core’s cache
could later be written back over it. Before releasing a core, core 0 writes
back and invalidates that core’s stack and per-CPU entry, which are the only
things it touches with its caches off; each
struct cpufills whole cache lines.
Scheduling on every core
A task runs on one core for life, chosen when it is created
(sched_place, src/proc/placement.c): the core
asked for (SPAWN_CORE(n), the shell’s cmd args @n), or else the online core
with the fewest live tasks, the lowest-numbered on a tie. There is no
migration and no work stealing; placement is explicit and predictable, in the
spirit of TempleOS.
Each core runs only its own tasks, round robin with its own 10 ms tick, so the task table filtered by core is that core’s run queue. One IRQ-safe scheduler lock covers them all: wake-ups regularly cross cores (a keyboard interrupt on core 0 wakes a shell on core 1, a child’s exit wakes its parent elsewhere), and with at most four cores serialized by the BKL, separate locks would only add lock ordering. A task woken for another core sets that core’s reschedule flag; the core notices on its next interrupt, its own tick at the latest.
Every core has an idle task that runs when none of its tasks can (idleN in
cpus): it waits in WFI and looks for work after each interrupt. Core 0’s
idle task has a stack of its own, because core 0’s boot thread is task 0, which
blocks like any task. Since a task can exit on one core while its parent reaps
it on another, a task is marked on_cpu until the next task on its core has
left its stack; the reaper waits for that before freeing the kernel stack.
ps lists every task with its core; the debug console’s tasks and cpus
show the kernel’s view.
Threads and jobs
A program can run on several cores at once with threads: a thread is a task
of the same process, with its own user and kernel stacks and FP/SIMD state,
sharing the image, heap, page runs, open files and cwd (with one flat address
space there are no page tables to share). thread_create(fn, arg, core)
places it exactly like spawn places a program, on the core asked for or the
least-loaded one, for life; thread_join collects its result. On top, libk’s
TempleOS-style job API queues a function to a core and collects the result
later (job_queue, job_result), one thread per job.
A process lives until its last thread exits. exit() from any thread, or
kill(), marks the others killed; each dies on its own core at its next
kernel entry or return to EL0, so no inter-processor interrupt is needed (see
locking.md). libk’s malloc takes a mutex (mutex_t: spin, then
yield()), so every thread can share one heap.
Two demos show both styles of placement:
jobdemo [iterations]renders the Mandelbrot set (integer fixed point) as one job on core 0, then as one job per online core (every n-th row each), checks that both agree and prints the times and the speedup. The picture stays on screen until Enter.pindemo [units[@core] ...]starts worker processes with uneven amounts of work (a unit is about 100 ms of one core), pinned where you say. Its default on three or more cores,6@1 2@2 2@2 2@2, puts the heavy worker alone on core 1 and the three light ones together on core 2, so all four finish at about the same time.
Without inter-processor interrupts, a thread placed on an idle core starts on
that core’s next tick, and a thread woken from another core continues on its
own next tick, so a parallel job pays up to 10 ms at each end. It only shows
on short jobs (jobdemo 64).
Timers and interrupts
Every core runs its own 100 Hz tick on its own generic timer, a per-core
source (a GIC PPI, banked per core, or the core’s local timer routing on the Pi
3), which drives that core’s scheduling and wakes its sleepers, whose
msleep deadlines are times on the generic timer (a tick that comes late
cannot cut a sleep short). Core 0’s tick is also the system tick: the global
tick count and the Circle timer hook. The debug console’s cpus command shows
each core with its own tick count and what it runs.
Trying it
./run-qemu.sh --target qemu_virt --smp 4
QEMU raspi3b always has four cores. The smoke test checks that every core comes
online and that its own timer ticks (--smp 4 on virt; four CI profiles use it,
with GICv2 and GICv3, entering at EL1 (PSCI over hvc) or through EL2 (smc)).
On an Apple Silicon Mac, --accel hvf runs the cores on real hardware cores,
truly in parallel and with weak memory ordering:
./run-qemu.sh --target qemu_virt --accel hvf --smp 4
GICv3 (virt)
QEMU’s hvf accelerator only emulates a GICv3, so the virt kernel supports both
versions and picks the one the device tree describes
(src/arch/irq_gic3.c). The distributor uses
affinity routing and sends every shared interrupt to core 0; each core finds
its own redistributor frame by its MPIDR, wakes it and configures its timer
interrupt there; the CPU interface is the ICC_* system registers. Booting
through EL2, boot.S first lets EL1 use them (ICC_SRE_EL2).