Claude Code keeps per-project memory at <home>/.claude/projects/<key>/,
where <key> is the project's absolute path with every separator and the
drive colon turned into a dash. That makes it both rename-fragile and
machine-specific, and this project has hit each of those once:
- D:\DEV\Schunix -> D:\DEV\SchOS changed the key, and 43 memories were
invisible until moved across by hand.
- Development happens from Windows and from Linux, and each looks in a
different place for the same checkout.
Committing .claude/memory/ fixes both -- git carries it across renames
and between hosts -- and `import` puts it wherever the local machine
expects. .claude/settings.local.json stays ignored: those are per-machine
permissions.
The colon maps to a dash rather than being dropped. Deleting it gives
D-DEV-SchOS, which matches nothing and silently finds zero memories; that
was the first attempt. On Git Bash the key comes from `cygpath -w`,
because Claude Code is a Windows program and sees D:\DEV\SchOS, not
/d/DEV/SchOS.
4.9 KiB
| name | description | metadata | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| schunix-smp-and-memory-bugs | SMP works as of 2026-08-08 — the five bugs it took, the __thread lesson, and the 224 MB RAM floor that is also gone. |
|
Both of Nexoniarz's 2026-08-08 reports are fixed. docs/roadmap.md §3b has
the full write-up.
SMP works
One, two and four processors boot; every CPU prints cpu%d: starting and
/proc/cpuinfo lists them all. smp_start_aps in kernel/core/main.c is 1.
Verified on QEMU at -smp 1/2/4, with all four bootcheck modes passing.
Five bugs, each hidden behind the one before it. All were invisible under QEMU, which publishes only one processor in the legacy MP table; VMware published the real count, which is why the triple fault only ever appeared there.
- Enumeration —
mpinit()read the 1994 Intel MP table.kernel/arch/x86_64/acpi.cnow reads the ACPI MADT (RSDP → RSDT/XSDT → MADT type 0). Do this first: nothing else is reproducible until QEMU reports more than one CPU. - Triple fault —
entry64highinentry.Shad no AP branch at all; the check was a comment saying it had never been written. Every AP fell into the BSP's path, took its stack, and rankmain()twice. Now testsEBX == 1, whichentryother.Shad always been setting. panic("acquire")—seginit()calledkalloc()for its per-CPU page. kalloc locks,acquire()callsholding(),holding()readscpu— whichseginit()was about to set up.startothers()now preallocates each AP's page on the BSP.panic("sched locks")—kernel_fs_basewas one global written by every CPU and read bytrapasm.Son every entry from user mode, so all CPUs shared one per-CPU block.RESTORE_KERNEL_FSnow reads%gs:0;seginit()sets GS base per CPU. No swapgs, becausesys_arch_prctl()implements only ARCH_SET_FS/GET_FS and musl uses FS for TLS — if ARCH_SET_GS is ever added this must become a swapgs pair.- Null
procinsleep(), then#GPinsyscall_entry— see below.
The lesson: __thread is wrong for per-CPU kernel data
cpu and proc were __thread. That means per thread, and the compiler is
entitled to cache the thread pointer across calls on that basis:
movq %fs:0x0, %rax # read the thread pointer
movq %rax, -0x18(%rbp) # spill it
callq sched # <-- the CPU can change here
movq -0x18(%rbp), %rax # reload the OLD pointer
movq (%rax,%rcx), %rax # proc, from the previous CPU's block
movq $0x0, 0x40(%rax) # NULL, because that CPU set proc = 0
But %fs here is per CPU, and a kernel thread changes CPU at every sched().
Now mycpu()/myproc() — inline volatile asm reading %gs at each use, so
nothing can be hoisted across a call. Named that way (as xv6-riscv does) and
not cpu/proc, because a macro by those names would also rewrite every
struct cpu and struct proc; macros have no namespace, tags do.
Per-CPU block layout — shared with assembly, do not reorder:
+0 self-pointer x86-64 TLS ABI (%fs:0)
+8 syscall scratch trapasm.S parks %rsp here
+16 struct cpu * PERCPU_CPU
+24 struct proc * PERCPU_PROC
Offset 8 is not free. syscall_entry uses %fs:(8) as scratch, and since
FS base and GS base are the same address, %fs:8 is %gs:8. Putting cpu
there made every syscall overwrite it with %rsp — a #GP inside
syscall_entry. That was bug 5.
The 224 MB RAM floor is gone
kernel/arch/x86_64/e820.c reads the firmware map and sets phystop.
Floor is 32 MB, was 224 MB; 512 MB in gives MemTotal: 524160 kB. Take the
end of the usable region containing the kernel (EXTMEM), not the global
maximum — freerange() walks from the kernel's end to phystop, so jumping a
reserved hole puts firmware tables on the free list. Ceiling PHYSTOP_MAX is
3 GB: kvmalloc() maps 0–4 GB as four 1 GB pages with the top one uncached for
PCI BARs.
Techniques worth reusing
qemu -d int,cpu_reset -D logprintedv=0ewithSP=ffff80000000ffb0— a stack pointer in low memory, which said instantly the AP was not on its own stack and pointed at the entry path rather than anything in C.- The symbol table is too sparse for nearest-preceding-symbol. Use
build/kernel/kernel.asmandawk '/^[0-9a-f]+ <.*>:/{fn=$0} index($0,"<addr>:")==1{print fn; print; exit}'— gives the enclosing function and the exact instruction. - Do not blanket-
sedan identifier rename. Doing that forproc/cpurewrote#include "proc.h", string literals and declarations, and this is not a git repo so there was no undo. The recovery that worked: revert the whole substitution, reapply only the safeproc->→myproc()->form, then let the compiler name each remaining site.