Part 4: CPU, interrupts, timers and multi-core
This part describes everything between the bare CPU and the scheduler: the inline CPU helpers, descriptor tables, the interrupt and system-call entry paths, the legacy PIC/PIT, the high-resolution timers, and the symmetric-multiprocessing (SMP) layer with its big kernel lock. All paths are relative to the repository root. Each .c file is its own compilation unit with an interface header under include/nvx/ (Part 1, section 3); functions another unit calls are declared there, everything else stays static. The four assembly files (boot64.S, isr64.S, userspace.S, ap_trampoline.S) are separate objects as before.
Include order matters for this part. arch/x86_64/cpu/cpu.h and arch/x86_64/cpu/percpu.c come first (kernel64.c lines 25-26), so every later file can use PCPU, cpu_id() and the BKL. ring3.c and smp.c come early, interrupts.c and core/panic.c come in the middle, hrtimer.c comes late, and smp_sched.c and cpu_init.c come last, just before core/init/main.c.
4.1 arch/x86_64/cpu/cpu.h, the CPU interface
cpu.h is the only header portable code uses for privileged instructions. tools/check.py rejects inline assembly outside arch/, and tools/tests/linux/cpu_host.h provides host stand-ins for the Linux-hosted test build. Every helper is static inline.
| Helper | Instruction(s) | Notes / main users |
|---|---|---|
cpu_irq_on / cpu_irq_off |
sti / cli |
memory clobber |
cpu_pause |
pause |
spin loops (BKL, IPI busy-wait) |
cpu_halt |
hlt |
main loop idle (between bkl_drop/bkl_take), panic |
cpu_idle |
sti; hlt |
atomic "enable and wait": an IRQ cannot slip in between |
cpu_stop |
cli; hlt |
parks a core for good (ap_main, IPI_HALT) |
cpu_wmb |
sfence |
store barrier |
cpu_irq_save |
pushfq; popq; cli |
returns RFLAGS |
cpu_irq_restore(f) |
sti if f & 0x200 |
restores IF only |
cpu_get_pt / cpu_set_pt |
mov cr3 |
writing CR3 flushes the TLB (no CR4.PGE is set anywhere, so there are no global pages) |
cpu_flush_page(v) |
invlpg (v) |
mem/pmm_vmm.c vm_map/vm_unmap/COW, sys/linux/linux.c munmap |
cpu_cache_flush |
wbinvd |
apps/system/files_classic.c |
cpu_tsc |
rdtsc |
|
cpu_rand32 / cpu_seed32 |
rdrand / rdseed + setc |
return CF; used by net/http/http.c, nvxcrypto2.c |
cpu_cpuid(leaf,sub,...) |
cpuid |
|
cpu_sp |
mov rsp |
|
cpu_fpu_save / cpu_fpu_restore |
fxsave / fxrstor |
per-process fx area in jc_enter / jc_leave |
cpu_bios_ebda_seg |
movw 0x40E |
BDA EBDA segment, core/power.c RSDP scan |
cpu_triple_fault |
lidt of a null IDT, then int3 |
last-resort reboot (core/power.c) |
cpu_rdmsr / cpu_wrmsr |
rdmsr / wrmsr |
|
cpu_set_fsbase(v) |
wrmsr 0xC0000100 (FS_BASE) |
arch_prctl(ARCH_SET_FS) in sys/linux/linux.c case 158 |
cpu_percpu() |
mov %gs:16, ... |
reads pcpu.self (see 4.9) |
cpu_set_percpu(p) |
wrmsr 0xC0000101 = p, wrmsr 0xC0000102 = 0 |
GS_BASE = per-CPU block, KERNEL_GS_BASE = 0 |
cpu_cr2 |
mov cr2 |
page-fault address |
cpu_copy(d,s,n) |
rep movsb |
fs/vfs/ramfs.c kmemcpy |
cpu_set_percpu puts the per-CPU pointer in the active GS base and zero in the shadow one. The first swapgs on the way to ring 3 swaps them, so user code always runs with GS base 0 and the kernel pointer waits in KERNEL_GS_BASE until the next entry.
4.2 arch/x86_64/cpu/cpu_init.c
arch_fpu_init
The BSP calls this early in kmain (core/init/main.c, after percpu_init_bsp and mm_load), and every AP calls it again in ap_entry_c.
cr0 &= ~(1<<2); /* EM=0: x87/SSE not emulated */
cr0 |= (1<<1)|(1<<16); /* MP=1, WP=1 (supervisor honours R/O) */
cr4 |= (1<<9)|(1<<10); /* OSFXSR, OSXMMEXCPT: SSE + fxsave on */
g_ssse3 = (cpuid(1).ecx >> 9) & 1;
fninit; ldmxcsr 0x1F80; /* all SSE exceptions masked */
Bit 16 is CR0.WP (Write Protect), not NE. With WP set, kernel writes to read-only user pages also fault, which the copy-on-write logic in mem/pmm_vmm.c relies on. g_ssse3 selects SIMD paths (simd.h). XSAVE and AVX are not enabled. Only fxsave state is managed.
apic_virtual_wire
This runs only on UEFI boots: kmain checks the boot-info magic *(u32*)0x502C == 0x49464555 ("UEFI"). It reads IA32_APIC_BASE (MSR 0x1B). If the APIC is globally disabled (bit 11 clear), it does nothing. Otherwise it programs the local APIC as a legacy "virtual wire", so 8259 interrupts still reach the BSP:
| Register | xAPIC MMIO offset | x2APIC MSR | Value |
|---|---|---|---|
| TPR | 0x80 |
0x808 |
0 |
| Spurious vector (SVR) | 0xF0 |
0x80F |
0x1FF (enable + vector 0xFF) |
| LVT LINT0 | 0x350 |
0x835 |
0x700 (ExtINT) |
| LVT LINT1 | 0x360 |
0x836 |
0x400 (NMI) |
| LVT Timer | 0x320 |
0x832 |
OR 0x10000 (masked) |
If bit 10 (x2APIC) is set, the MSR path is used. Otherwise the MMIO base must lie below 4 GB.
arch_idt_init
for(i=0;i<48;i++) set_gate(i, isr_stubs[i]); /* exceptions + PIC IRQs */
for(i=48;i<256;i++) set_gate(i, isr_ignore); /* everything else */
lidt {sizeof(idt)-1, idt}
Other gates are filled in later (see 4.4). There is one IDT, static struct idt_entry idt[256] in interrupts.c, and all cores share it. APs load it with idt_load() (smp_sched.c).
4.3 GDT, selectors, TSS and IST stacks (arch/x86_64/user/ring3.c)
At boot the kernel runs on the bootloader's GDT. us_init() installs the final GDT the first time anything needs ring 3. It is called from ring3_run, elf.c, sched.c, jobs.c (jc_enter), sys/native/syscall.c, the shell, and every AP's ap_entry_c. us_init returns early if g_us_ready is set. Because g_us_ready, g_gdt, g_gdtp and g_tss are all per-CPU macros (4.9), each core builds and loads its own GDT and TSS exactly once.
gdt_set(i, base, limit, access, flags) packs a legacy 8-byte descriptor into g_gdt[i].
| Index | Selector | Access | Flags | Meaning |
|---|---|---|---|---|
| 0 | 0x00 |
, | , | null |
| 1 | 0x08 |
0x9A |
0xA (G, L) |
kernel code, 64-bit |
| 2 | 0x10 |
0x92 |
0xC (G, D/B) |
kernel data |
| 3 | 0x18 to 0x1B with RPL 3 |
0xFA |
0xA |
user code, DPL 3, 64-bit |
| 4 | 0x20 to 0x23 with RPL 3 |
0xF2 |
0xC |
user data, DPL 3 |
| 5-6 | 0x28 |
0x89 |
, | 64-bit available TSS (16-byte descriptor; slot 6 holds base bits 63:32) |
gdt_flush (userspace.S) runs lgdt, reloads DS/ES/SS with 0x10, and far-returns (lretq) through CS 0x08. tss_flush(0x28) runs ltr.
The TSS is struct tss64 in percpu.h (packed: rsp0..2, ist[7], iomap). us_init zeroes it and sets these fields:
| Field | Value | Purpose |
|---|---|---|
rsp0 |
top of g_kstack (per-CPU 16 KB) |
ring 3 to 0 stack; jc_enter later points it at g_jkstack |
iomap |
sizeof(g_tss) |
no I/O permission bitmap, so all ring-3 port I/O faults |
ist[0] (IST1) |
top of g_ist_nmi (4 KB) |
NMI, vector 2 |
ist[1] (IST2) |
top of g_ist_df (8 KB) |
double fault, vector 8 |
After that, us_init calls idt_set128(), lsys_init(), idt_use_ist(2,1) and idt_use_ist(8,2). The IDT is shared, so the IST indices are global, but each core's TSS points them at its own stacks.
4.4 IDT gates and the vector map (arch/x86_64/interrupts/interrupts.c)
struct idt_entry{ u16 lo; u16 sel; u8 ist; u8 attr; u16 mid; u32 hi; u32 z; };
static void set_gate(int n,void*h){ ... sel=0x08; ist=0; attr=0x8E; ... } /* P, DPL0, interrupt gate */
static void idt_set128(void){ ... attr=0xEE; } /* P, DPL3, interrupt gate */
static void idt_use_ist(int n,int ist){ idt[n].ist=(u8)ist; }
Every gate is an interrupt gate (type 0xE), so the CPU clears IF on entry. The only DPL-3 gate is int 0x80. A software int n from ring 3 to any other vector raises #GP.
| Vector(s) | Stub | Installed by | Handling |
|---|---|---|---|
| 0-31 | isr0-isr31 |
arch_idt_init |
user-mode PF to vm_user_pf; other user faults to jc_fault; kernel faults to panic (RSOD) |
| 2 (NMI) | isr2, IST1 |
us_init |
isr_dispatch returns at once (ignored) |
| 8 (#DF) | isr8, IST2 |
us_init |
panic |
| 32-47 | isr32-isr47 |
arch_idt_init |
PIC IRQ 0-15 |
0x40 |
isr_lapic_timer |
kmain: set_gate(HR_LAPIC_VEC,...) |
BSP: hr_lapic_isr; AP: ap_timer |
0x80 |
isr128 (DPL 3) |
us_init to idt_set128 |
legacy NoovexOS syscall ABI |
0xF0 |
isr_ipi_resched |
smp_enable |
IPI_RESCHED |
0xF1 |
isr_ipi_tlb |
smp_enable |
IPI_TLB |
0xF2 |
isr_ipi_halt |
smp_enable |
IPI_HALT |
| all others | isr_ignore (pushes 255) |
arch_idt_init |
no EOI; also catches the APIC spurious vector 0xFF |
Stubs (boot/isr64.S)
STUB_NOERR n pushes a dummy error code 0 and then n. STUB_ERR n pushes only n, because the CPU already pushed an error code. The error-code vectors are 8, 10, 11, 12, 13, 14, 17 and 21. Vectors 29 (#VC) and 30 (#SX) are AMD-specific and push an error code on hardware, but this table uses STUB_NOERR for them, so their frame would be off by one slot. Neither vector is expected in practice. isr_stubs[48] is the .rodata table that arch_idt_init reads.
4.5 Interrupt frame and isr_common
/* interrupts.c - "order must match the asm" */
typedef struct {
u64 r15,r14,r13,r12,r11,r10,r9,r8,rbp,rdi,rsi,rdx,rcx,rbx,rax; /* pushed by isr_common */
u64 vector,errcode; /* pushed by the stub */
u64 rip,cs,rflags,rsp,ss; /* pushed by the CPU */
} regs_t;
| Offset | Field | Offset | Field |
|---|---|---|---|
| 0x00 | r15 | 0x60 | rcx |
| 0x08 | r14 | 0x68 | rbx |
| 0x10 | r13 | 0x70 | rax |
| 0x18 | r12 | 0x78 | vector |
| 0x20 | r11 | 0x80 | errcode |
| 0x28 | r10 | 0x88 | rip |
| 0x30 | r9 | 0x90 | cs |
| 0x38 | r8 | 0x98 | rflags |
| 0x40 | rbp | 0xA0 | rsp |
| 0x48 | rdi | 0xA8 | ss |
| 0x50 | rsi | ||
| 0x58 | rdx |
CPU (ring3->0: loads TSS.rsp0, or IST1/IST2 for NMI/#DF)
push ss, rsp, rflags, cs, rip [, errcode]
stub: [push 0] ; push vector ; jmp isr_common
isr_common: testb $3, 24(%rsp) ; cs RPL at vec(0) err(8) rip(16) cs(24)
jz 1f ; swapgs ; only when coming from ring 3
1: push rax..r15 ; regs_t complete, rdi = rsp
mov rsp,rbp ; and $-16,rsp ; call isr_dispatch ; mov rbp,rsp
pop r15..rax
cli
testb $3, 24(%rsp) ; jz 1f ; swapgs ; only when returning to ring 3
1: add $16,%rsp ; iretq
swapgs rule. GS is swapped only when the saved CS has RPL 3, on both entry and exit. The exit check reads the CS in the frame after isr_dispatch, and the scheduler may have rewritten that frame. So a frame changed to target another ring-3 context still gets the correct swap. Kernel-to-kernel interrupts never swap, so GS base always holds the pcpu pointer while kernel code runs.
isr_common does not run cld. The C code is built for SysV and expects DF clear, and DF is clear in every context this kernel creates.
4.6 isr_dispatch and isr_body
void isr_dispatch(regs_t*r){
if(r->vector==2)return; /* NMI: ignore */
u64 v=r->vector; int user=(r->cs&3)==3;
if(v>=0xF0&&v<=0xF2){ smp_ipi(r,user); return; } /* IPIs: lock-free */
if(v==HR_LAPIC_VEC&&cpu_id()!=0){ ap_timer(r,user); return; }
if(v==HR_LAPIC_VEC&&!user){ hr_lapic_isr(); return; } /* BSP one-shot wake */
if(v==32)g_ticks++; /* PIT tick, before lock */
bkl_lock(); isr_body(r); bkl_unlock();
}
g_ticks is incremented before the lock is taken, so the 100 Hz clock keeps counting while another core holds the BKL. IPIs and AP timer ticks never block on the lock up front. They take it only when they are about to preempt a user process.
isr_body (called with the BKL held):
- Vector 14 and
vm_user_pf(r)(core/proc/jobs.c). If a job is running, CR2 is in the user window andvm_fault_userresolves the fault (lazy allocation or COW), the handler returns and the instruction is retried. A result of-2(out of memory) kills the job with signal 9. - Other exceptions (< 32). If
g_js_runningis set and the fault came from ring 3,jc_fault(r)runs. It saves the frame into the job'sctxand maps the vector to a signal: 0/16/19 to SIGFPE (8), 6 to SIGILL (4), 1/3 to SIGTRAP (5), anything else to SIGSEGV (11). If the signal is unhandled or masked, the job is finished; otherwise the signal is marked pending. Thenjc_leave()runs. Any kernel-mode exception callspanic(r,0). HR_LAPIC_VEC(BSP, user mode) tohr_lapic_isr().- IRQ0 (PIT). If a job is in ring 3,
jc_timer(r)runs. It preempts after two ticks (g_ticks - g_js_slice >= 2): it savesctx, sendspic_eoi(0)and callsjc_leave(). The older demo scheduler (g_preempt_on, used bypreempt/ipc/compositor) round-robins overg_proc[]. It copies the frame intog_proc[cur].ctx, setsg_tss.rsp0, loads the next CR3 and overwrites*r, soisr_commonirets into the next process. If no process is left, it callsk_longjmp(g_preempt_kern,1). - IRQ1 / IRQ12 to
ps2_byte()(keyboard and mouse share one byte reader indrivers/input/ps2/ps2.c). - EOI.
pic_eoi(irq)runs unless the vector is 255 (isr_ignore). EOI is sent to the slave for IRQ at least 8, then always to the master.
RSOD (core/panic.c)
panic(regs_t*, const char*) does the following in order:
- Calls
smp_halt_others(), which sendsIPI_HALTto every other core. - Disables interrupts.
- Guards against recursion with
static in_panic. A fault during the panic just halts. - Fills
SCENEwith a red gradient and prints:(, the exception name fromEXC[32], the vector, error code, RIP, RSP, RFLAGS and all 16 GPRs. When called withr==0(or a non-exception), it printsmsginstead. - Calls
blit_all()and halts forever.
The shell panic command uses the message path.
4.7 PIC and PIT
static void pic_remap(void){
ICW1 0x11 -> 0x20/0xA0; ICW2 0x20 / 0x28; ICW3 0x04 / 0x02; ICW4 0x01 / 0x01;
outb(0x21,0xF8); /* master: IRQ0 timer, IRQ1 kbd, IRQ2 cascade unmasked */
outb(0xA1,0xEF); /* slave: IRQ12 PS/2 mouse only */
}
static void pit_init(u32 hz){ div=1193182/hz; outb(0x43,0x36); lo; hi; } /* ch0, mode 3 */
kmain runs pic_remap(); pit_init(100);, so g_ticks advances at 100 Hz (10 ms per tick). g_ticks is a volatile u64 defined in net/http/http.c and declared in include/nvx/net/http/http.h. Everything else (USB, NVMe, Wi-Fi) is polled, so only IRQ 0, 1, 2 and 12 are unmasked.
After cpu_irq_on(), kmain checks that ticks are actually arriving. If g_ticks does not move within 3,000,000 pauses, it clears g_ticks_live and the main loop calls pit_poll_tick() instead (core/init/main.c). That function latches channel 0 and counts reloads.
Boot order (core/init/main.c):
percpu_init_bsp -> arch_fpu_init -> ... -> arch_idt_init -> [UEFI] apic_virtual_wire
-> pic_remap; pit_init(100) -> set_gate(0x40,isr_lapic_timer); hr_init()
-> PS/2, xHCI, touchpad, ACPI AML ... -> cpu_irq_on -> tick check -> smp_boot_auto_c -> main loop
4.8 High-resolution timers (arch/x86_64/time/hrtimer.c)
All state lives in static struct {...} g_hr. src is the clock source (0 = PIT ticks, 1 = HPET, 2 = TSC). ref records what the calibration was measured against (0 nothing, 1 HPET, 2 PIT channel 2, 3 CPUID 0x15, 4 CPUID 0x16), and timers_cmd in shell/commands.c shows these names. why[48] holds the reason the HPET was rejected.
hr_init sequence
CPUID.
0x80000007EDX bit 8 setstsc_inv(invariant TSC). Leaf 1 EDX bit 4 setstsc_ok.hr_hpet_init. It findsHPETwithacpi_find_table. It requires length at least 56 and an address space of 0 (memory), with the address at table offset 44 non-zero and 8-byte aligned, and maps it withacpi_hi_map(fails above 512 GB). It reads capabilities: the period in femtoseconds iscap>>32and must be between 1 and 10^8,hpet_64is bit 13, andntimis bits 12:8 plus 1. It sets ENABLE_CNF (and clears LEG_RT) in config+0x10and checks that main counter+0xF0moves within 20,000 reads. Thenhpet_mult = (fs<<32)/10^6. A 32-bit counter is extended to 64 bits inhr_hpet_countby detecting wraps (hpet_hi += 2^32) with interrupts disabled.hr_lapic_init. It reads MSR0x1B. A globally disabled APIC is left alone. With x2APIC (bit 10), registers are reached as MSR0x800 + off/16. Otherwise MMIO atg_lapic_baseis used, which must be below 4 GB. If the SVR enable bit is clear, it does the same virtual-wire setup as 4.2. It sets the timer LVT to0x10000|0x40(masked), divide0x3E0 = 3(/16), and initial count 0.hr_calibrate. It takes the best of 3 runs with interrupts off. The local APIC counter starts at0xFFFFFFFF, and TSC and APIC current count (0x390) are sampled across a reference window:- with an HPET: wait until 20,000,000 ns of HPET time have passed;
- without one: PIT channel 2 one-shot through port
0x61/0x42, countHR_PIT_COUNT = 59659, which isHR_PIT_WIN_NS = 49,999,930 ns(59659 / 1,193,182 Hz). Wait for OUT2 (bit 5 of port0x61).
The run with the smallest TSC-per-window wins. Then
tsc_hz = dt*10^9/winandlapic_hz = dl*10^9/win, usinghr_muldiv, which avoids overflow.Fallback
hr_cpuid_tsc. CPUID0x15givestsc_hz = ecx*ebx/eax, andlapic_hz = ecx/16if it is still unknown. CPUID0x16givestsc_hz = eax[15:0] MHz.Accept. If
tsc_hz >= 10 MHz, thentsc_mult = (10^9<<32)/tsc_hzandtsc0 = rdtsc. Iflapic_hz < 100 kHz, the APIC timer is disabled.Pick
src. Invariant TSC first, then HPET, then a non-invariant TSC, then PIT ticks. The choice is logged ("timers: clock = ...").
API
| Function | Behaviour |
|---|---|
hr_ns() |
src==2: ((rdtsc-tsc0)*tsc_mult)>>32 (128-bit multiply); src==1: hr_hpet_ns(); else g_ticks*10,000,000 |
hr_us(), hr_precise() |
hr_ns()/1000; src!=0 |
hr_delay_us(us) |
busy-waits on hr_ns() with pause, bounded by an iteration limit; without a precise clock, us x io_wait() |
hr_delay_ms(ms) |
repeated hr_delay_us(1000) |
hr_wake_at(ns) |
arms an APIC one-shot for min(ns-now, 1 s) (0x320 = 0x40, unmasked); returns 1 if armed |
hr_wake_cancel() |
count 0, mask the LVT |
hr_lapic_periodic(hz) |
periodic mode (bit 17); defined but currently unused (APs program their own) |
hr_lapic_isr() |
lapic_irqs++, EOI (0xB0) |
The main loop uses hr_wake_at on the desktop when the cursor moved. It arms a wake for the next 60 Hz frame (frame_ns = 16,666,667), then runs bkl_drop(); cpu_halt(); bkl_take(), so the BSP sleeps until either the PIT tick or the precise wake. The timers and timertest shell commands report and cross-check the sources.
hr_ns uses the TSC base of the BSP (tsc0). Code on APs that calls it (ap_udelay, hr_delay_us in smp_disable) assumes the TSCs are synchronised. That holds on invariant-TSC hardware.
4.9 Per-CPU state (arch/x86_64/cpu/percpu.c)
#define NCPU 16
struct pcpu {
u64 kstack_top; /* +0 syscall stack (lsys_entry: %gs:0) */
u64 user_rsp; /* +8 user rsp scratch (lsys_entry: %gs:8) */
struct pcpu* self; /* +16 read by cpu_percpu(): %gs:16 */
u64 fsbase; /* +24 user FS base (jc_resume: %gs:24) */
int id; u32 apic_id; int jcur; volatile int js_running;
u64 js_kern[8]; u64 js_slice;
int dcwd, uid, gid, umask; u64 ubrk, mmap_next; int us_ready;
volatile int online, stop, parked, tlb_pending, in_user;
volatile u64 ticks, user_ticks, idle_ticks, switches, syscalls, ipis;
struct tss64 tss; u64 gdt[7]; struct{u16 limit; u64 base;} gdtp;
u8 kstack[16384], jkstack[16384], ist_nmi[4096], ist_df[8192];
};
static struct pcpu g_cpu[NCPU] __attribute__((aligned(64)));
#define PCPU ((struct pcpu*)cpu_percpu())
static inline int cpu_id(void){ return PCPU->id; }
_Static_asserts fix the first four offsets (0, 8, 16, 24), because the assembly addresses them directly. PCPU is one mov %gs:16 instruction. GS base points at the struct, and self points back at it.
The old global names are kept as macros over PCPU->..., so pre-SMP code became per-CPU without being rewritten:
| Macro | Field | Macro | Field |
|---|---|---|---|
g_jcur |
jcur (running job, -1 none) |
g_us_ready |
us_ready |
g_js_running |
js_running |
lsys_kstack |
kstack_top |
g_js_kern |
js_kern (setjmp buffer) |
lsys_fsbase |
fsbase |
g_js_slice |
js_slice |
g_tss |
tss |
g_dcwd, g_uid, g_gid, g_umask |
current credentials and cwd | g_gdt, g_gdtp |
gdt, gdtp |
g_ubrk, g_mmap_next |
brk and mmap cursor | g_kstack, g_jkstack |
stacks |
g_ist_nmi, g_ist_df |
IST stacks |
percpu_setup(i, apic) sets self, id, apic_id, jcur=-1, umask=022, ubrk=0x40002000000, mmap_next=0x40004000000 and kstack_top. percpu_init_bsp() sets up slot 0 with APIC ID 0 (corrected later in smp_start_aps), loads GS and marks it online. It is the first statement after BSS clearing in kmain, so PCPU is valid everywhere afterwards.
4.10 The big kernel lock (arch/x86_64/cpu/percpu.c)
struct bkl { volatile u32 next, serving; volatile int owner; volatile int depth; };
static struct bkl g_bkl={1,0,0,1}; /* CPU 0 owns it from boot, depth 1 */
- Ticket lock.
bkl_locktakes a ticket with__atomic_fetch_add(&next,1)and spins (pause) untilserving == ticket(acquire). Tickets make it FIFO-fair. - Recursion. If
owner == cpu_id(), it only incrementsdepth. Nested entry is common, for example a syscall that ends up in code that locks again. - Interrupts while spinning.
cpu_irq_savedisables interrupts during the spin andcpu_irq_restorerestores the previous IF afterwards. - Deferred TLB flush. After acquiring, if
PCPU->tlb_pendingis set, it clears it and reloads CR3. - Unlock.
bkl_unlockignores callers that are not the owner, decrementsdepth, and at zero setsowner=-1and doesserving+1(release). - Helpers.
bkl_held()tells whether this CPU owns it.bkl_drop()releases fully and returns the old depth, andbkl_take(d)re-acquires and restores that depth. Together they hand the lock over around blocking waits.bkl_yield()(only when SMP is on, the lock is held at depth 1, and someone is queued, i.e.next != serving+1) unlocks, pauses 64 times, and relocks.
Where it is taken or dropped.
| Site | Action |
|---|---|
isr_dispatch |
around isr_body (all PIC IRQs, exceptions, BSP-user LAPIC) |
lsys_c, isr128_c |
around linux_syscall / syscall_dispatch (also PCPU->syscalls++) |
ap_loop |
around jc_pick + jc_enter |
smp_ipi (RESCHED), ap_timer |
bkl_lock() just before jc_preempt (released by the bkl_take in jc_enter) |
jc_enter (jobs.c) |
bd=bkl_drop() right before jc_resume; bkl_take(bd) after the longjmp back |
kmain main loop |
bkl_drop(); cpu_halt(); bkl_take() when idle; bkl_yield() once per iteration |
smp_enable / smp_disable |
dropped around smp_start_aps and the 5 ms park waits |
The model: user code runs without the lock, kernel code runs with it. A job enters ring 3 with the BKL released. The next kernel entry on that core (interrupt, syscall, int 0x80, fault) acquires it. When a job leaves (jc_leave to k_longjmp(g_js_kern)), the code is still inside the handler that took the lock. bkl_take(bd) then finds owner == me, increments, and overwrites depth with the saved bd, so the nesting count from before jc_enter is restored exactly.
4.11 System calls: int 0x80 and syscall
MSRs (lsys_init, run per CPU from us_init)
| MSR | Address | Value | Meaning |
|---|---|---|---|
| EFER | 0xC0000080 |
` | = 1` |
| STAR | 0xC0000081 |
`(0x08<<32) | (0x10<<48)` |
| LSTAR | 0xC0000082 |
lsys_entry |
64-bit entry point |
| FMASK | 0xC0000084 |
0x47700 |
clears TF, IF, DF, IOPL, NT, AC on entry |
| FS_BASE | 0xC0000100 |
per job | jc_resume, cpu_set_fsbase |
| GS_BASE / KERNEL_GS_BASE | 0xC0000101/2 |
pcpu / 0 |
cpu_set_percpu |
lsys_init also sets lsys_kstack = top of g_kstack. STAR[63:48] = 0x10 would make sysretq load CS = 0x23 and SS = 0x1B, which is the wrong way round for this GDT. That does not matter because the kernel never runs sysret: both syscall paths return with iretq.
lsys_entry (Linux ABI, userspace.S)
syscall: rcx=user rip, r11=user rflags, CS/SS from STAR, IF=0 (FMASK)
lsys_entry:
swapgs ; GS -> pcpu
mov %rsp,%gs:8 ; pcpu.user_rsp
mov %gs:0,%rsp ; pcpu.kstack_top (= jkstack top while a job runs)
push $0x23 ; push %gs:8 ; push %r11 ; push $0x1B ; push %rcx ; fake iret frame
push rax..r15 ; struct sysregs
mov %rsp,%rdi ; cld ; call lsys_c ; bkl_lock; linux_syscall; bkl_unlock
pop r15..rax ; cli ; swapgs ; iretq
The fake frame is the same shape isr128 produces: struct sysregs (15 GPRs, same order as the top of regs_t) followed directly by the 5-word iret frame. Code that blocks, forks or delivers signals can therefore treat both entry types the same way. 20 pushes on a 16-aligned stack keep the call aligned.
isr128 (legacy NoovexOS ABI, number in rax)
isr128 checks testb $3, 8(%rsp) (CS sits at +8 because there is no vector or error code) and swaps GS when coming from ring 3. It pushes the 15 GPRs and calls isr128_c to syscall_dispatch. On the way out it pops, runs cli, swaps GS back if returning to ring 3, and does iretq. Because the gate is an interrupt gate, IF is already 0.
Entering and resuming ring 3
| Routine | Use |
|---|---|
enter_ring3(rip,rsp) |
cli, DS/ES=0x23, push SS 0x23, RSP, RFLAGS 0x202, CS 0x1B, RIP; swapgs; iretq (ring3_run demo) |
enter_ring3_args(rip,rsp,argc,argv) |
same, with rdi=argc, rsi=argv |
user_trampoline / user_trampoline_fork |
first run of a sched_swap-based process (fork variant zeroes rax) |
jc_resume(regs_t* ctx) |
job-control resume (below) |
k_setjmp / k_longjmp |
save/restore rbx, rbp, r12-r15, rsp, return address; longjmp(...,0) returns 1 |
sched_swap(save,load) |
cooperative kernel-stack switch |
jc_resume(ctx): cli ; DS=ES=FS=0x23
FS_BASE <- %gs:24 (pcpu.fsbase) ; still kernel GS here
rsp <- ctx ; pop r15..rax ; add $16 (vector,errcode)
swapgs ; iretq ; iret frame = ctx->rip..ss
jc_enter (core/proc/jobs.c) prepares a resume this way:
- Points
g_tss.rsp0andlsys_kstackat the top ofg_jkstack, setslsys_fsbase,g_js_slice,p->runcpu = cpu_id()+1andswitches++. - Disables interrupts and calls
k_setjmp(g_js_kern). - On the first return:
fxrstor, load the job's CR3,bkl_drop,jc_resume.
Any later kernel entry that decides to leave the job calls jc_leave(). It saves FPU state (fxsave), switches back to the kernel CR3 and longjmps to g_js_kern.
4.12 Multi-core discovery (arch/x86_64/smp/smp.c)
acpi_count_cores()is the older, read-only scanner. It searches0xE0000-0xFFFFFfor"RSD PTR ", follows the XSDT (revision at least 2) or the RSDT (both must be below0xF0000000), findsAPIC, and counts MADT type-0 entries with the enabled flag. It cannot see a UEFI RSDP outside the BIOS area. Thecores/cpushell command uses it.madt_apic_ids(out,max)is used for real startup. It reads the MADT throughacpi_find_table("APIC",0), which also works on UEFI, and walks the entries from offset 44 with bounds checks (el>=2,p+el<=len). It collects the APIC IDs of enabled type-0 (Local APIC,id=m[p+3]) and type-9 (Local x2APIC, 32-bit ID at+4, flags at+8) entries. x2APIC IDs at least 255 and duplicates are skipped.
smp_start_aps()
nc = madt_apic_ids(ids, NCPU). Ifnc <= 1, there is nothing to do.- Read IA32_APIC_BASE.
g_lapic_x2= bit 10. The base is sanity-checked, with0xFEE00000as the default. The APIC is globally enabled if needed, and SVR bit 8 is set. - Copy
ap_tramp_start..ap_tramp_endto physical0x8000. The code is in section.aptramp, which the linker scripts do not name, so it is placed as an orphan section and copied at runtime. - Write the parameter block (all physical, identity-mapped):
| Address | Size | Content | Written by |
|---|---|---|---|
0x8FD8 |
int | logical CPU index next |
per AP, before INIT |
0x8FE0 |
u64 | stack top g_ap_stacks[next]+16384 |
per AP |
0x8FF0 |
u64 | BSP CR3 (shared kernel page tables) | once |
0x8FF8 |
u64 | ap_main address |
once |
- Record the BSP APIC ID in
g_cpu[0].apic_id. - For each other ID:
percpu_setup(next, id),g_ncpu = next+1, then the INIT-SIPI-SIPI sequence:
ICR = 0x4500 (INIT, assert) ; wait 10 ms
ICR = 0x4608 (Start-up, vector 0x08) ; wait 200 us -> CS:IP = 0x0800:0000 = 0x8000
ICR = 0x4608 ; second SIPI
poll g_cores_online for up to 2000 x 100 us (200 ms)
In x2APIC mode this is one 64-bit wrmsr 0x830 with the destination in bits 63:32. In xAPIC mode it writes 0x310 (destination << 24) and then 0x300. The lapic_wr wrapper also emulates the xAPIC ICR pair on x2APIC: a write to 0x310 is buffered in g_lapic_icr_dest, and a write to 0x300 becomes one 0x830 MSR write. lapic_rd(0x20) shifts the x2APIC ID left by 24 so callers can treat it like xAPIC. If the core does not answer, g_ncpu is rolled back and that slot is reused. Delays come from ap_udelay, which uses hr_delay_us when a precise clock exists and a calibrated-free pause loop otherwise.
smp.c also contains an older lock-protected compute queue (g_jobs[NJOBS=16], smp_lock test-and-set spinlock, smp_submit, smp_work_step, smp_compute counting primes). The smpwork/parallel and smpsched commands use it to show isolated parallel work and per-core counters (g_core_load, g_core_cycles).
4.13 AP trampoline (arch/x86_64/boot/ap_trampoline.S)
0x8000 .code16 cli; cld; DS=ES=SS=0
lgdtl %cs:gdtptr ; local 5-entry GDT
CR0.PE=1 ; ljmpl $0x08, pm32
.code32 DS=ES=SS=0x10
CR4 |= 0x620 ; PAE | OSFXSR | OSXMMEXCPT
CR3 = [0x8FF0] ; BSP page tables (low 32 bits)
EFER |= 0x100 ; LME
CR0 &= ~0xC (EM,TS) ; CR0 |= 0x80010002 (PG | WP | MP)
ljmpl $0x18, lm64
.code64 DS=ES=SS=0x20 ; rsp=[0x8FE0] ; rbp=0
call *[0x8FF8] ; ap_main
1: cli; hlt; jmp 1b
The trampoline GDT has null, 0x08 32-bit code, 0x10 32-bit data, 0x18 64-bit code and 0x20 64-bit data. These selectors differ from the kernel GDT, and us_init replaces them shortly afterwards. CR3 is loaded with a 32-bit movl, so the BSP's PML4 must be below 4 GB. EFER.NXE is not set on any core, which matches the kernel's page tables (no NX bits are used).
ap_main() reads its index from 0x8FD8, calls ap_entry_c(id), and parks with cpu_stop() if that ever returns.
4.14 AP runtime (arch/x86_64/smp/smp_sched.c)
ap_entry_c(id)
cpu_set_percpu(&g_cpu[id]); /* GS -> own pcpu */
arch_fpu_init(); idt_load(); /* shared IDT */
APIC_BASE |= enable (+x2APIC bit if the BSP uses x2APIC)
lapic: TPR=0, SVR=0x1FF, LINT0=LINT1=masked (0x10000) /* only the BSP takes ExtINT/NMI */
us_init(); /* own GDT, TSS(rsp0, IST1/2), syscall MSRs */
LVT timer = 0x40 | periodic, divide 16, count = g_hr.lapic_hz/100 /* 100 Hz tick */
c->online=1; g_cores_online++ (atomic); sti; ap_loop();
The APs reuse the BSP's lapic_hz measurement. All cores are assumed to have the same APIC timer clock.
ap_loop
for(;;){
if(c->stop){ c->parked=1; while(c->stop){ cpu_idle(); cpu_irq_off(); } c->parked=0; }
int ran=0;
if(g_smp_sched){ bkl_lock(); int pick=g_smp_sched?jc_pick():-1;
if(pick>=0){ jc_enter(pick); ran=1; } bkl_unlock(); }
if(!ran && !smp_work_step()){ cpu_idle(); cpu_irq_off(); }
}
An idle AP sleeps in sti; hlt and wakes on its own 100 Hz timer or an IPI. jc_runnable refuses a job whose runcpu is non-zero, so each job runs on at most one core at a time.
Timer and IPIs
| Vector | Name | Sender | Handler (smp_ipi, no BKL up front) |
|---|---|---|---|
0xF0 |
IPI_RESCHED |
smp_kick(cpu) (signal to a job running elsewhere, jobs.c), ipi_others in smp_enable/smp_disable, and smp_kick_idle() at the end of jc_create and jc_fork, which wakes every idle online core so a new process starts within microseconds instead of at the next 10 ms timer tick |
EOI; if the core is in user mode running a job: bkl_lock(); jc_preempt(r) (sends the job back to the scheduler so the signal or stop is seen); otherwise just wakes the hlt |
0xF1 |
IPI_TLB |
smp_tlb_shootdown(), called by acpi_hi_map after it installs a new page directory for MMIO above 4 GiB |
tlb_pending=0, reload CR3 |
0xF2 |
IPI_HALT |
smp_halt_others() from panic |
parked=1, then cli; hlt forever |
0x40 |
APIC timer | own LVT | ap_timer: ticks++, user_ticks or idle_ticks (idle means no job and no BKL), EOI; if user and ticks - js_slice >= 2 to bkl_lock(); jc_preempt(r) |
ipi_send skips offline cores. In x2APIC mode it is a single wrmsr 0x830. In xAPIC mode it waits for the ICR delivery-status bit 12 to clear (up to 200,000 pauses) and then writes 0x310/0x300. lapic_eoi() writes 0xB0.
On APs the time slice is counted in the core's own ticks (jc_now() returns PCPU->ticks off-BSP and g_ticks on CPU 0). Both run at 100 Hz, so a slice is about 20 ms on every core.
smp_enable / smp_disable
smp_enablereturns early (with the CPU count) if SMP is already on. It returns-1without a calibrated APIC timer (g_hr.lapic_ok && lapic_hz). It installs gates0xF0-0xF2, starts the APs if they are not already up, and returns-2if none came online. Otherwise it clearsstopon every AP, setsg_smp_on = g_smp_sched = 1and sends RESCHED to the others. APs started once are never restarted. Latersmp oncalls only unpark them.smp_disableclearsg_smp_sched, setsstop=1on every online AP and sends RESCHED. It then pollsparkedfor up to 400 x 5 ms (2 s), dropping the BKL during each wait so a core finishing a syscall can leave. It returns-1if some core did not park, and otherwise clearsg_smp_on.
smp_boot_auto and the /etc/smp hang guard
kmain calls smp_boot_auto_c() just before the main loop, after the integrity check (section 2.12) has had its chance to stop the boot. (The old comment in core/init/main.c saying the cores start only on demand was removed in v8.95.)
/etc/smp == "off" -> stay single-core
/etc/smp == "starting" -> previous start never finished: write "off", log + notify, stay single-core
madt_apic_ids <= 1 && acpi_count_cores <= 1 -> nothing to start
write "starting" (smp_pref_set syncs the filesystem with nvx3_sync_all)
smp_enable()
write "on"
If the machine hangs during INIT/SIPI, the next boot reads "starting" and falls back to one core. smp_pref_set does nothing without a disk (disk_ok).
4.15 TLB coherence
Coherence relies on these properties in the current design:
- One kernel address space. APs load the BSP's CR3 (
0x8FF0). Kernel mappings are set up before SMP starts and are not changed per core. - Per-thread scheduling.
jc_runnablerequiresruncpu == 0, so one thread never runs on two cores.jc_enterloadsp->cr3andjc_leaveloads the kernel CR3. With no CR4.PGE, every CR3 write flushes the whole TLB, so no stale user translation survives a switch to another core. - Threads share page tables. Since v8.97 the threads of one process (section 5.15) have the same
cr3and may run on several cores at the same time.jc_enterrecords the running address space inPCPU->ucr3while the thread is in user mode. - Local invalidation.
vm_map,vm_unmap, the COW fault path andmunmapchange the current address space and invalidate locally withinvlpg. - Shootdown mechanism.
smp_tlb_shootdown()setstlb_pendingon every other online core and sendsIPI_TLB; the receiver reloads CR3 insmp_ipi.acpi_hi_mapcalls it after adding a kernel-wide mapping for high MMIO. - Synchronous shootdown for shared address spaces.
smp_tlb_sync(cr3)sendsIPI_TLBonly to cores whoseucr3is that address space and then waits (a bounded spin) until each has clearedtlb_pendingor stopped running it. It is called where a page that another thread may hold in its TLB is taken away or changes: the COW copy invm_fault_user(before the old frame is released),vm_clone_userwhen it write-protects the parent's pages, andlx_unmap_rangewhen the process has other threads (before the frames are freed).linux_syscallalso clears a pending flag on entry, so a core that took the system call with interrupts off cannot use a stale entry.
4.16 The smp shell commands (shell/commands.c)
| Command | Effect |
|---|---|
smp |
Prints cores in the system (MADT, falling back to the legacy scan), cores online and the scheduling mode, then one row per online CPU: cpu, apic, state (boot / parked / running / idle), switches, syscalls, user-ticks, idle-ticks, ipis (from struct pcpu). |
smp on / smpstart |
Writes "starting", runs smp_enable(), writes "on". It reports "no calibrated local APIC timer" for -1, "no other cores came online" for at most 1, or "N cores schedule programs (big kernel lock, preemptive user mode)". |
smp off |
Runs smp_disable(). On success it writes "off" and reports "programs run on core 0 only"; on timeout, "some cores did not stop in time". |
cores / cpu |
Legacy MADT count and g_cores_online. |
smpwork / parallel, smpsched |
Prime-counting job queue demo and per-core counters (4.12). |
Other places that report the core count: /proc/cpuinfo (fs/vfs/vfs.c, one entry per online g_cpu[]), /sys/devices/system/cpu/online (fs/vfs/unixfs.c), and Linux sched_getaffinity (syscall 204 in sys/linux/linux.c). All of them report g_cores_online only while g_smp_on is set, and 1 otherwise. The user program usr/musl/smptest.c (named in the smp help line) forks workers, records which CPUs they ran on, and checks results as a stress test.
